UNPKG

ox

Version:

Ethereum Standard Library

1,628 lines (1,514 loc) 59.1 kB
import * as Address from '../core/Address.js' import type * as Bytes from '../core/Bytes.js' import * as Errors from '../core/Errors.js' import * as Hex from '../core/Hex.js' import type { Assign, Compute, IsNarrowable, OneOf, PartialBy, UnionPartialBy, } from '../core/internal/types.js' import * as Json from '../core/Json.js' import * as ox_P256 from '../core/P256.js' import type * as PublicKey from '../core/PublicKey.js' import * as Rlp from '../core/Rlp.js' import * as ox_Secp256k1 from '../core/Secp256k1.js' import * as Signature from '../core/Signature.js' import type * as WebAuthnP256 from '../core/WebAuthnP256.js' import * as ox_WebAuthnP256 from '../core/WebAuthnP256.js' import * as MultisigConfig from './MultisigConfig.js' /** Signature type identifiers for encoding/decoding */ const serializedP256Type = '0x01' const serializedWebAuthnType = '0x02' const serializedKeychainType = '0x03' const serializedKeychainV2Type = '0x04' const serializedMultisigType = '0x05' /** Serialized magic identifier for Tempo signature envelopes. */ export const magicBytes = '0x7777777777777777777777777777777777777777777777777777777777777777' // 32 "T"s /** * Statically determines the signature type of an envelope at compile time. * * @example * ```ts twoslash * import type { SignatureEnvelope } from 'ox/tempo' * * type Type = SignatureEnvelope.GetType<{ * r: `0x${string}` * s: `0x${string}` * yParity: number * }> * // @log: 'secp256k1' * ``` */ export type GetType< envelope extends PartialBy<SignatureEnvelope, 'type'> | unknown, > = unknown extends envelope ? envelope extends unknown ? Type : never : envelope extends { type: infer T extends Type } ? T : envelope extends { signature: { r: `0x${string}`; s: `0x${string}` } prehash: boolean publicKey: PublicKey.PublicKey } ? 'p256' : envelope extends { signature: { r: `0x${string}`; s: `0x${string}` } metadata: any publicKey: PublicKey.PublicKey } ? 'webAuthn' : envelope extends { r: `0x${string}` s: `0x${string}` yParity: number } ? 'secp256k1' : envelope extends { signature: { r: `0x${string}` s: `0x${string}` yParity: number } } ? 'secp256k1' : envelope extends { userAddress: Address.Address } ? 'keychain' : envelope extends | { account: Address.Address signatures: any } | { init: MultisigConfig.Config signatures: any } ? 'multisig' : never /** * Represents a signature envelope that can contain different signature types. * * Tempo transactions support multiple signature types, each with different wire formats: * * - **secp256k1** (no type prefix, 65 bytes): Standard Ethereum ECDSA signature. The sender * address is recovered via `ecrecover`. Base transaction cost: 21,000 gas. * * - **p256** (type `0x01`, 130 bytes): P256/secp256r1 curve signature for passkey accounts. * Includes embedded public key (64 bytes) and prehash flag. Enables native WebCrypto * key support. Additional gas cost: +5,000 gas over secp256k1. * * - **webAuthn** (type `0x02`, 129-2049 bytes): WebAuthn signature with authenticator data * and clientDataJSON. Enables browser passkey authentication. The signature is also * charged as calldata (16 gas/non-zero byte, 4 gas/zero byte). * * - **keychain** (type `0x03` V1, `0x04` V2): Access key signature that wraps an inner signature * (secp256k1, p256, or webAuthn). Format: type byte + user_address (20 bytes) + inner signature. * V2 binds the signature to the user account via `keccak256(sigHash || userAddress)`. * The protocol validates the access key authorization via the AccountKeychain precompile. * * [Signature Types Specification](https://docs.tempo.xyz/protocol/transactions/spec-tempo-transaction#signature-types) */ export type SignatureEnvelope<numberType = number> = OneOf< | Secp256k1<numberType> | P256<numberType> | WebAuthn<numberType> | Keychain<numberType> | Multisig<numberType> > /** * RPC-formatted signature envelope. */ export type SignatureEnvelopeRpc = OneOf< Secp256k1Rpc | P256Rpc | WebAuthnRpc | KeychainRpc | MultisigRpc > /** Primitive signature envelope accepted by protocol sidecars. */ export type Primitive<numberType = number> = OneOf< Secp256k1<numberType> | P256<numberType> | WebAuthn<numberType> > /** RPC-formatted primitive signature envelope. */ export type PrimitiveRpc = OneOf<Secp256k1Rpc | P256Rpc | WebAuthnRpc> /** * Keychain signature version. * * - `'v1'`: Legacy format. Inner signature signs the raw `sig_hash` directly. Deprecated at T1C. * - `'v2'`: Inner signature signs `keccak256(sig_hash || user_address)`, binding the signature * to the specific user account. */ export type KeychainVersion = 'v1' | 'v2' export type Keychain<numberType = number> = { /** Root account address that this transaction is being executed for */ userAddress: Address.Address /** The actual signature from the access key (can be Secp256k1, P256, or WebAuthn) */ inner: SignatureEnvelope<numberType> /** The access key address (recovered address of the access key signer). */ keyId?: Address.Address | undefined type: 'keychain' /** Keychain signature version. @default 'v1' */ version?: KeychainVersion | undefined } export type KeychainRpc = { type: 'keychain' userAddress: Address.Address keyId?: Address.Address | undefined signature: SignatureEnvelopeRpc version?: KeychainVersion | undefined } /** * Native multisig signature (type `0x05`). * * Wraps a set of owner approvals (secp256k1, p256, webAuthn, or nested * multisig) over the multisig owner approval digest. The transaction sender is * the derived `account`, authorized once the recovered owner weights meet the * configured threshold. * * [TIP-1061](https://tips.sh/1061) */ export type Multisig<numberType = number> = { type: 'multisig' /** Native multisig account address. */ account: Address.Address /** * Owner approvals over the multisig owner approval digest. Each approval is * either a primitive signature or a nested multisig signature (keychain * approvals are invalid). */ signatures: readonly SignatureEnvelope<numberType>[] /** * Initial native multisig config for bootstrapping this account. Present only on * the first (bootstrap) transaction from the derived account; absent on every * subsequent transaction. */ init?: MultisigConfig.Config<numberType> | undefined } /** RPC-formatted native multisig signature. */ export type MultisigRpc = OneOf< | { /** Existing native multisig account. */ account: Address.Address /** Structured owner approvals. */ signatures: readonly SignatureEnvelopeRpc[] /** Multisig RPC signatures are untagged. */ type?: undefined } | { /** Initial config for bootstrapping a native multisig account. */ init: MultisigConfig.Config /** Structured owner approvals. */ signatures: readonly SignatureEnvelopeRpc[] /** Multisig RPC signatures are untagged. */ type?: undefined } > export type P256<numberType = number> = { prehash: boolean publicKey: PublicKey.PublicKey signature: Signature.Signature<false, numberType> type: 'p256' } export type P256Rpc = { preHash: boolean pubKeyX: Hex.Hex pubKeyY: Hex.Hex r: Hex.Hex s: Hex.Hex type: 'p256' } export type Secp256k1<numberType = number> = { signature: Signature.Signature<true, numberType> type: 'secp256k1' } export type Secp256k1Rpc = Compute< Signature.Rpc<true> & { v?: Hex.Hex | undefined type: 'secp256k1' } > export type Secp256k1Flat<numberType = number> = Signature.Signature< true, numberType > & { type?: 'secp256k1' | undefined } export type WebAuthn<numberType = number> = { metadata: Pick< WebAuthnP256.SignMetadata, 'authenticatorData' | 'clientDataJSON' > signature: Signature.Signature<false, numberType> publicKey: PublicKey.PublicKey type: 'webAuthn' } export type WebAuthnRpc = { pubKeyX: Hex.Hex pubKeyY: Hex.Hex r: Hex.Hex s: Hex.Hex type: 'webAuthn' webauthnData: Hex.Hex } /** Hex-encoded serialized signature envelope. */ export type Serialized = Hex.Hex /** List of supported signature types. */ export const types = ['secp256k1', 'p256', 'webAuthn'] as const /** Union type of supported signature types. */ export type Type = (typeof types)[number] /** * Asserts that a {@link ox#SignatureEnvelope.SignatureEnvelope} is valid. * * @example * ```ts twoslash * import { SignatureEnvelope } from 'ox/tempo' * * SignatureEnvelope.assert({ * type: 'secp256k1', * signature: { * r: '0x0000000000000000000000000000000000000000000000000000000000000000', * s: '0x0000000000000000000000000000000000000000000000000000000000000000', * yParity: 0 * } * }) * ``` * * @param envelope - The signature envelope to assert. * @throws `CoercionError` if the envelope type cannot be determined. */ export function assert(envelope: PartialBy<SignatureEnvelope, 'type'>): void { const type = getType(envelope) if (type === 'secp256k1') { const secp256k1 = envelope as Secp256k1 Signature.assert(secp256k1.signature) return } if (type === 'p256') { const p256 = envelope as P256 const missing: string[] = [] if (typeof p256.signature?.r !== 'string') missing.push('signature.r') if (typeof p256.signature?.s !== 'string') missing.push('signature.s') if (typeof p256.prehash !== 'boolean') missing.push('prehash') if (!p256.publicKey) missing.push('publicKey') else { if (typeof p256.publicKey.x !== 'string') missing.push('publicKey.x') if (typeof p256.publicKey.y !== 'string') missing.push('publicKey.y') } if (missing.length > 0) throw new MissingPropertiesError({ envelope, missing, type: 'p256' }) return } if (type === 'webAuthn') { const webauthn = envelope as WebAuthn const missing: string[] = [] if (typeof webauthn.signature?.r !== 'string') missing.push('signature.r') if (typeof webauthn.signature?.s !== 'string') missing.push('signature.s') if (!webauthn.metadata) missing.push('metadata') else { if (!webauthn.metadata.authenticatorData) missing.push('metadata.authenticatorData') if (!webauthn.metadata.clientDataJSON) missing.push('metadata.clientDataJSON') } if (!webauthn.publicKey) missing.push('publicKey') else { if (typeof webauthn.publicKey.x !== 'string') missing.push('publicKey.x') if (typeof webauthn.publicKey.y !== 'string') missing.push('publicKey.y') } if (missing.length > 0) throw new MissingPropertiesError({ envelope, missing, type: 'webAuthn' }) return } if (type === 'keychain') { const keychain = envelope as Keychain assert(keychain.inner) return } if (type === 'multisig') { const multisig = envelope as Multisig assertMultisig(multisig, 1) return } } export declare namespace assert { type ErrorType = | CoercionError | InvalidMultisigApprovalError | MissingPropertiesError | MultisigConfig.assert.ErrorType | MultisigConfig.getAddress.ErrorType | Signature.assert.ErrorType | Errors.GlobalErrorType } function assertMultisig(envelope: Multisig, depth: number): void { const missing: string[] = [] if (!envelope.account) missing.push('account') if (!Array.isArray(envelope.signatures)) missing.push('signatures') if (missing.length > 0) throw new MissingPropertiesError({ envelope, missing, type: 'multisig', }) if (depth > MultisigConfig.maxNestingDepth) throw new InvalidMultisigApprovalError({ reason: `multisig nesting depth exceeds ${MultisigConfig.maxNestingDepth}`, }) if (!Address.validate(envelope.account)) throw new InvalidMultisigApprovalError({ reason: 'multisig account is invalid', }) if (Hex.toBigInt(envelope.account) === 0n) throw new InvalidMultisigApprovalError({ reason: 'multisig account cannot be zero', }) if (envelope.signatures.length === 0) throw new InvalidMultisigApprovalError({ reason: 'multisig signatures cannot be empty', }) if (envelope.signatures.length > MultisigConfig.maxSignatures) throw new InvalidMultisigApprovalError({ reason: `multisig signatures exceed ${MultisigConfig.maxSignatures}`, }) if (envelope.init) { MultisigConfig.assert(envelope.init) if ( !Address.isEqual( MultisigConfig.getAddress(envelope.init), envelope.account, ) ) throw new InvalidMultisigApprovalError({ reason: 'multisig init does not derive account', }) } for (const inner of envelope.signatures) { const type = getType(inner) if (type === 'keychain') throw new InvalidMultisigApprovalError({ reason: 'keychain owner approvals are not allowed', }) if (type === 'multisig') { const multisig = inner as Multisig if (multisig.init) throw new InvalidMultisigApprovalError({ reason: 'nested multisig owner approvals cannot carry `init`', }) assertMultisig(multisig, depth + 1) } else assert(inner) if (Hex.size(serialize(inner)) > MultisigConfig.maxOwnerSignatureBytes) throw new InvalidMultisigApprovalError({ reason: `multisig owner signature exceeds ${MultisigConfig.maxOwnerSignatureBytes} bytes`, }) } } /** * Extracts the address of the signer from a {@link ox#SignatureEnvelope.SignatureEnvelope}. * * - **secp256k1**: Recovers the address from the payload via `ecrecover`. * - **p256** / **webAuthn**: Derives the address from the embedded public key. * - **keychain**: Extracts from the inner signature (or returns `userAddress` if `user` is `true`). * * @example * ```ts twoslash * import { Secp256k1 } from 'ox' * import { SignatureEnvelope } from 'ox/tempo' * * const payload = '0xdeadbeef' * const signature = Secp256k1.sign({ * payload, * privateKey: '0x...' * }) * const envelope = SignatureEnvelope.from(signature) * * const address = SignatureEnvelope.extractAddress({ * // [!code focus] * payload, // [!code focus] * signature: envelope // [!code focus] * }) // [!code focus] * ``` * * @param options - The extraction options. * @returns The signer address. */ export function extractAddress( options: extractAddress.Options, ): extractAddress.ReturnType { const { signature, root } = options if (signature.type === 'keychain') { if (root) return signature.userAddress return extractAddress({ ...options, signature: signature.inner }) } // Native multisig signatures have no single signer; the recovered sender is the // derived multisig account address. if (signature.type === 'multisig') return signature.account return Address.fromPublicKey(extractPublicKey(options)) } export declare namespace extractAddress { type Options = { /** The sign payload that was signed (only required for secp256k1 signatures). */ payload: Hex.Hex | Bytes.Bytes /** The signature envelope. */ signature: SignatureEnvelope /** Whether to return the root `userAddress` for keychain signatures instead of extracting from the inner signature. */ root?: boolean | undefined } type ReturnType = Address.Address type ErrorType = | Address.fromPublicKey.ErrorType | extractPublicKey.ErrorType | Errors.GlobalErrorType } /** * Extracts the public key of the signer from a {@link ox#SignatureEnvelope.SignatureEnvelope}. * * - **secp256k1**: Recovers the public key from the payload via `ecrecover`. * - **p256** / **webAuthn**: Returns the embedded public key. * - **keychain**: Extracts from the inner signature. * * @example * ```ts twoslash * import { Secp256k1 } from 'ox' * import { SignatureEnvelope } from 'ox/tempo' * * const payload = '0xdeadbeef' * const signature = Secp256k1.sign({ * payload, * privateKey: '0x...' * }) * const envelope = SignatureEnvelope.from(signature) * * const publicKey = SignatureEnvelope.extractPublicKey({ * // [!code focus] * payload, // [!code focus] * signature: envelope // [!code focus] * }) // [!code focus] * ``` * * @param options - The extraction options. * @returns The signer's public key. */ export function extractPublicKey( options: extractPublicKey.Options, ): extractPublicKey.ReturnType { const { payload, signature } = options switch (signature.type) { case 'secp256k1': return ox_Secp256k1.recoverPublicKey({ payload, signature: signature.signature, }) case 'p256': case 'webAuthn': return signature.publicKey case 'keychain': return extractPublicKey({ payload, signature: signature.inner }) case 'multisig': // A multisig signature aggregates multiple owner approvals and has no // single public key; recover the multisig account via `extractAddress`. throw new CoercionError({ envelope: signature }) } } export declare namespace extractPublicKey { type Options = { /** The sign payload that was signed (only required for secp256k1 signatures). */ payload: Hex.Hex | Bytes.Bytes /** The signature envelope. */ signature: SignatureEnvelope } type ReturnType = PublicKey.PublicKey type ErrorType = | CoercionError | ox_Secp256k1.recoverPublicKey.ErrorType | Errors.GlobalErrorType } /** * Deserializes a hex-encoded signature envelope into a typed signature object. * * Wire format detection: * - 65 bytes (no prefix): secp256k1 signature * - Type `0x01` + 129 bytes: P256 signature (r, s, pubKeyX, pubKeyY, prehash) * - Type `0x02` + variable: WebAuthn signature (webauthnData, r, s, pubKeyX, pubKeyY) * - Type `0x03` + 20 bytes + inner: Keychain V1 signature (userAddress + inner signature) * - Type `0x04` + 20 bytes + inner: Keychain V2 signature (userAddress + inner signature) * * [Signature Types](https://docs.tempo.xyz/protocol/transactions/spec-tempo-transaction#signature-types) * * @example * ```ts twoslash * import { SignatureEnvelope } from 'ox/tempo' * * const envelope = SignatureEnvelope.deserialize('0x...') * ``` * * @param serialized - The hex-encoded signature envelope to deserialize. * @returns The deserialized signature envelope. * @throws `CoercionError` if the serialized value cannot be coerced to a valid signature envelope. */ export function deserialize(value: Serialized): SignatureEnvelope { return deserialize_(value, 0) } function deserialize_( value: Serialized, multisigDepth: number, ): SignatureEnvelope { const serialized = value.endsWith(magicBytes.slice(2)) ? Hex.slice(value, 0, -Hex.size(magicBytes)) : value const size = Hex.size(serialized) // Backward compatibility: 65 bytes means secp256k1 without type identifier if (size === 65) { const signature = Signature.fromHex(serialized) Signature.assert(signature) return { signature, type: 'secp256k1' } satisfies Secp256k1 } // For all other lengths, first byte is the type identifier const typeId = Hex.slice(serialized, 0, 1) const data = Hex.slice(serialized, 1) const dataSize = Hex.size(data) if (typeId === serializedP256Type) { // P256: 32 (r) + 32 (s) + 32 (pubKeyX) + 32 (pubKeyY) + 1 (prehash) = 129 bytes if (dataSize !== 129) throw new InvalidSerializedError({ reason: `Invalid P256 signature envelope size: expected 129 bytes, got ${dataSize} bytes`, serialized, }) return { publicKey: { prefix: 4, x: Hex.slice(data, 64, 96), y: Hex.slice(data, 96, 128), }, prehash: Hex.toNumber(Hex.slice(data, 128, 129)) !== 0, signature: { r: Hex.slice(data, 0, 32), s: Hex.slice(data, 32, 64), }, type: 'p256', } satisfies P256 } if (typeId === serializedWebAuthnType) { // WebAuthn: variable (webauthnData) + 32 (r) + 32 (s) + 32 (pubKeyX) + 32 (pubKeyY) // Minimum: 128 bytes (at least some authenticator data + signature components) if (dataSize < 128) throw new InvalidSerializedError({ reason: `Invalid WebAuthn signature envelope size: expected at least 128 bytes, got ${dataSize} bytes`, serialized, }) const webauthnDataSize = dataSize - 128 const webauthnData = Hex.slice(data, 0, webauthnDataSize) // Parse webauthnData into authenticatorData and clientDataJSON // According to the Rust code, it's authenticatorData || clientDataJSON // We need to find the split point (minimum authenticatorData is 37 bytes) let authenticatorData: Hex.Hex | undefined let clientDataJSON: string | undefined // Try to find the JSON start (clientDataJSON should start with '{') for (let split = 37; split < webauthnDataSize; split++) { const potentialJson = Hex.toString(Hex.slice(webauthnData, split)) if (potentialJson.startsWith('{') && potentialJson.endsWith('}')) { try { JSON.parse(potentialJson) authenticatorData = Hex.slice(webauthnData, 0, split) clientDataJSON = potentialJson break } catch {} } } if (!authenticatorData || !clientDataJSON) throw new InvalidSerializedError({ reason: 'Unable to parse WebAuthn metadata: could not extract valid authenticatorData and clientDataJSON', serialized, }) return { publicKey: { prefix: 4, x: Hex.slice(data, webauthnDataSize + 64, webauthnDataSize + 96), y: Hex.slice(data, webauthnDataSize + 96, webauthnDataSize + 128), }, metadata: { authenticatorData, clientDataJSON, }, signature: { r: Hex.slice(data, webauthnDataSize, webauthnDataSize + 32), s: Hex.slice(data, webauthnDataSize + 32, webauthnDataSize + 64), }, type: 'webAuthn', } satisfies WebAuthn } if ( typeId === serializedKeychainType || typeId === serializedKeychainV2Type ) { const userAddress = Hex.slice(data, 0, 20) const inner = deserialize_(Hex.slice(data, 20), multisigDepth) return { userAddress, inner, type: 'keychain', version: typeId === serializedKeychainV2Type ? 'v2' : 'v1', } satisfies Keychain } if (typeId === serializedMultisigType) { const depth = multisigDepth + 1 if (depth > MultisigConfig.maxNestingDepth) throw new InvalidSerializedError({ reason: `multisig nesting depth exceeds ${MultisigConfig.maxNestingDepth}`, serialized, }) // The first field distinguishes the static wire shapes: a bootstrap init // config is an RLP list, while an initialized account is a 20-byte string. const decoded = Rlp.toHex(data) if (!Array.isArray(decoded) || decoded.length !== 2) throw new InvalidSerializedError({ reason: 'invalid multisig wire shape: expected exactly two fields', serialized, }) const [address, signatures] = decoded if (!Array.isArray(signatures) || signatures.some(Array.isArray)) throw new InvalidSerializedError({ reason: 'invalid multisig signatures list', serialized, }) if (signatures.length === 0) throw new InvalidSerializedError({ reason: 'multisig signatures cannot be empty', serialized, }) if (signatures.length > MultisigConfig.maxSignatures) throw new InvalidSerializedError({ reason: `multisig signatures exceed ${MultisigConfig.maxSignatures}`, serialized, }) for (const signature of signatures) if ( Hex.size(signature as Hex.Hex) > MultisigConfig.maxOwnerSignatureBytes ) throw new InvalidSerializedError({ reason: `multisig owner signature exceeds ${MultisigConfig.maxOwnerSignatureBytes} bytes`, serialized, }) if (!Array.isArray(address) && !Address.validate(address)) throw new InvalidSerializedError({ reason: 'invalid multisig account', serialized, }) if (Array.isArray(address)) { const [salt, threshold, owners] = address if ( address.length !== 3 || Array.isArray(salt) || Hex.size(salt) !== 32 || Array.isArray(threshold) || Hex.size(threshold) > 1 || !Array.isArray(owners) || owners.some( (owner) => !Array.isArray(owner) || owner.length !== 2 || owner.some(Array.isArray) || Hex.size(owner[1] as Hex.Hex) > 1, ) ) throw new InvalidSerializedError({ reason: 'invalid multisig init config', serialized, }) } const init = Array.isArray(address) ? MultisigConfig.fromTuple(address as unknown as MultisigConfig.Tuple) : undefined const account = init ? MultisigConfig.getAddress(init) : (address as Address.Address) const envelope = { type: 'multisig', account, signatures: signatures.map((signature) => deserialize_(signature as Hex.Hex, depth), ), ...(init ? { init } : {}), } satisfies Multisig assertMultisig(envelope, depth) return envelope } throw new InvalidSerializedError({ reason: `Unknown signature type identifier: ${typeId}. Expected ${serializedP256Type} (P256), ${serializedWebAuthnType} (WebAuthn), ${serializedKeychainType} (Keychain V1), ${serializedKeychainV2Type} (Keychain V2), or ${serializedMultisigType} (Multisig)`, serialized, }) } /** * Coerces a value to a signature envelope. * * Accepts either a serialized hex string or an existing signature envelope object. * Use this to wrap raw signatures from {@link ox#Secp256k1.(sign:function)}, {@link ox#P256.(sign:function)}, * {@link ox#WebCryptoP256.(sign:function)}, or {@link ox#WebAuthnP256.(sign:function)} into the envelope format * required by Tempo transactions. * * [Signature Types](https://docs.tempo.xyz/protocol/transactions/spec-tempo-transaction#signature-types) * * @example * ### Secp256k1 * * Standard Ethereum ECDSA signature using the secp256k1 curve. * * ```ts twoslash * import { Secp256k1 } from 'ox' * import { SignatureEnvelope } from 'ox/tempo' * * const privateKey = Secp256k1.randomPrivateKey() * const signature = Secp256k1.sign({ * payload: '0xdeadbeef', * privateKey * }) * * const envelope = SignatureEnvelope.from(signature) * ``` * * @example * ### P256 * * ECDSA signature using the P-256 (secp256r1) curve. Requires embedding the * public key. * * ```ts twoslash * import { P256 } from 'ox' * import { SignatureEnvelope } from 'ox/tempo' * * const { privateKey, publicKey } = P256.createKeyPair() * const signature = P256.sign({ * payload: '0xdeadbeef', * privateKey * }) * * const envelope = SignatureEnvelope.from({ * signature, * publicKey * }) * ``` * * @example * ### P256 (WebCrypto) * * When using WebCrypto keys, `prehash` must be `true` since WebCrypto always * SHA256 hashes the digest before signing. * * ```ts twoslash * // @noErrors * import { WebCryptoP256 } from 'ox' * import { SignatureEnvelope } from 'ox/tempo' * * const { privateKey, publicKey } = * await WebCryptoP256.createKeyPair() * const signature = await WebCryptoP256.sign({ * payload: '0xdeadbeef', * privateKey * }) * * const envelope = SignatureEnvelope.from({ * signature, * publicKey, * prehash: true * }) * ``` * * @example * ### WebAuthn * * Passkey-based signature using WebAuthn. Includes authenticator metadata * (authenticatorData and clientDataJSON) along with the P-256 signature and * public key. * * ```ts twoslash * // @noErrors * import { WebAuthnP256 } from 'ox' * import { SignatureEnvelope } from 'ox/tempo' * * const credential = await WebAuthnP256.createCredential({ * name: 'Example' * }) * * const { metadata, signature } = await WebAuthnP256.sign({ * challenge: '0xdeadbeef', * credentialId: credential.id * }) * * const envelope = SignatureEnvelope.from({ * signature, * publicKey: credential.publicKey, * metadata * }) * ``` * * @example * ### Keychain * * Wraps another signature type with a user address, used for delegated signing * via access keys on behalf of a root account. * * ```ts twoslash * import { Secp256k1 } from 'ox' * import { SignatureEnvelope } from 'ox/tempo' * * const privateKey = Secp256k1.randomPrivateKey() * const signature = Secp256k1.sign({ * payload: '0xdeadbeef', * privateKey * }) * * const envelope = SignatureEnvelope.from({ * userAddress: '0x1234567890123456789012345678901234567890', * inner: SignatureEnvelope.from(signature) * }) * ``` * * @example * ### Multisig (from genesis config) * * Pass `genesisConfig` to derive `account` automatically. Set `init: true` to * opt into bootstrap (uses `genesisConfig` as the bootstrap `init`); omit * `init` for subsequent (non-bootstrap) transactions. * * ```ts twoslash * import { Secp256k1 } from 'ox' * import { MultisigConfig, SignatureEnvelope } from 'ox/tempo' * * const genesisConfig = MultisigConfig.from({ * threshold: 1, * owners: [ * { * owner: '0x1111111111111111111111111111111111111111', * weight: 1 * } * ] * }) * * const privateKey = Secp256k1.randomPrivateKey() * const signature = SignatureEnvelope.from( * Secp256k1.sign({ payload: '0xdeadbeef', privateKey }) * ) * * // Bootstrap transaction * const bootstrap = SignatureEnvelope.from({ * genesisConfig, * signatures: [signature], * init: true * }) * * // Subsequent (non-bootstrap) transactions * const subsequent = SignatureEnvelope.from({ * genesisConfig, * signatures: [signature] * }) * ``` * * @param value - The value to coerce (either a hex string or signature envelope). * @returns The signature envelope. */ export function from<const value extends from.Value>( value: value | from.Value, options?: from.Options, ): from.ReturnValue<value> { if (typeof value === 'string') return deserialize(value) as never if ( typeof value === 'object' && value !== null && 'r' in value && 's' in value && 'yParity' in value ) return { signature: value, type: 'secp256k1' } as never const type = getType(value) if (type === 'multisig') { const multisig = value as Multisig & { genesisConfig?: MultisigConfig.Config | undefined init?: MultisigConfig.Config | boolean | undefined } const { genesisConfig, init, ...rest } = multisig // Derive `account` from `genesisConfig` when not provided explicitly. const account = (() => { if (rest.account) return rest.account if (genesisConfig) return MultisigConfig.getAddress(genesisConfig) return rest.account })() // `init: true` opts into bootstrap using the supplied `genesisConfig`. // Otherwise, `init` is treated as the explicit bootstrap config (or // omitted). const initSource = init === true ? genesisConfig : init || undefined return { ...rest, account, signatures: rest.signatures.map((signature) => from(signature)), // Normalize the bootstrap config (sorts owners, defaults the salt) so the // in-memory envelope matches what `deserialize` reconstructs. ...(initSource ? { init: MultisigConfig.from(initSource) } : {}), type, } as never } return { ...value, ...(type === 'p256' ? { prehash: (value as P256).prehash } : {}), ...(type === 'keychain' ? { ...(!( typeof value === 'object' && value !== null && 'version' in value && value.version ) ? { version: 'v2' } : {}), ...(!(typeof value === 'object' && 'keyId' in value && value.keyId) ? (() => { const inner = (value as Keychain).inner if (inner.type === 'p256' || inner.type === 'webAuthn') return { keyId: Address.fromPublicKey(inner.publicKey) } if (inner.type === 'secp256k1' && options?.payload) return { keyId: Address.fromPublicKey( ox_Secp256k1.recoverPublicKey({ payload: options.payload, signature: inner.signature, }), ), } return {} })() : {}), } : {}), type, } as never } export declare namespace from { type Options = { /** Payload that was signed. Used to recover `keyId` for keychain envelopes with secp256k1 inner signatures. */ payload?: Hex.Hex | Bytes.Bytes | undefined } /** * Multisig envelope input variant where `account` is derived from the * supplied `genesisConfig`. Pass `init: true` to opt into bootstrap (uses * `genesisConfig` as the bootstrap `init`); omit `init` for subsequent * (non-bootstrap) transactions. */ type MultisigFromGenesisConfig = { type?: 'multisig' | undefined genesisConfig: MultisigConfig.Config signatures: readonly SignatureEnvelope[] init?: MultisigConfig.Config | boolean | undefined } type Value = | UnionPartialBy<SignatureEnvelope, 'prehash' | 'type'> | Secp256k1Flat | Serialized | MultisigFromGenesisConfig type ReturnValue<value extends Value> = Compute< OneOf< value extends Serialized ? SignatureEnvelope : value extends Secp256k1Flat ? Secp256k1 : value extends MultisigFromGenesisConfig ? Multisig : IsNarrowable<value, SignatureEnvelope> extends true ? SignatureEnvelope : Assign< value, { readonly type: GetType<value> } & (GetType<value> extends 'keychain' ? { keyId?: Address.Address | undefined } : {}) > > > } /** * Converts an RPC-formatted signature envelope to a typed signature envelope. * * @example * ```ts twoslash * import { SignatureEnvelope } from 'ox/tempo' * * const envelope = SignatureEnvelope.fromRpc({ * r: '0x0', * s: '0x0', * yParity: '0x0', * type: 'secp256k1' * }) * ``` * * @param envelope - The RPC signature envelope to convert. * @returns The signature envelope with bigint values. */ export function fromRpc(envelope: SignatureEnvelopeRpc): SignatureEnvelope { if (envelope.type === 'secp256k1') return { signature: Signature.fromRpc(envelope), type: 'secp256k1', } if (envelope.type === 'p256') { return { prehash: envelope.preHash, publicKey: { prefix: 4, x: Hex.padLeft(envelope.pubKeyX, 32), y: Hex.padLeft(envelope.pubKeyY, 32), }, signature: { r: Hex.padLeft(envelope.r, 32), s: Hex.padLeft(envelope.s, 32), }, type: 'p256', } } if (envelope.type === 'webAuthn') { const webauthnData = envelope.webauthnData const webauthnDataSize = Hex.size(webauthnData) // Parse webauthnData into authenticatorData and clientDataJSON let authenticatorData: Hex.Hex | undefined let clientDataJSON: string | undefined // Try to find the JSON start (clientDataJSON should start with '{') for (let split = 37; split < webauthnDataSize; split++) { const potentialJson = Hex.toString(Hex.slice(webauthnData, split)) if (potentialJson.startsWith('{') && potentialJson.endsWith('}')) { try { JSON.parse(potentialJson) authenticatorData = Hex.slice(webauthnData, 0, split) clientDataJSON = potentialJson break } catch {} } } if (!authenticatorData || !clientDataJSON) throw new InvalidSerializedError({ reason: 'Unable to parse WebAuthn metadata: could not extract valid authenticatorData and clientDataJSON', serialized: webauthnData, }) return { metadata: { authenticatorData, clientDataJSON, }, publicKey: { prefix: 4, x: Hex.padLeft(envelope.pubKeyX, 32), y: Hex.padLeft(envelope.pubKeyY, 32), }, signature: { r: Hex.padLeft(envelope.r, 32), s: Hex.padLeft(envelope.s, 32), }, type: 'webAuthn', } } if ( envelope.type === 'keychain' || ('userAddress' in envelope && 'signature' in envelope) ) { const keychain = envelope as KeychainRpc return { type: 'keychain', userAddress: keychain.userAddress, inner: fromRpc(keychain.signature), ...(keychain.keyId ? { keyId: keychain.keyId } : {}), ...(keychain.version ? { version: keychain.version } : {}), } } if ( (envelope as { type?: string | undefined }).type === 'multisig' || ('signatures' in envelope && ('account' in envelope || 'init' in envelope)) ) { const multisig = envelope as MultisigRpc const hasAccount = typeof multisig.account !== 'undefined' const hasInit = typeof multisig.init !== 'undefined' if (hasAccount === hasInit) throw new InvalidMultisigApprovalError({ reason: 'RPC multisig must contain exactly one of `account` or `init`', }) const init = hasInit ? MultisigConfig.from(multisig.init as MultisigConfig.Config) : undefined const account = init ? MultisigConfig.getAddress(init) : multisig.account const result = { type: 'multisig', account: account as Address.Address, signatures: multisig.signatures.map((signature) => fromRpc(signature)), ...(init ? { init } : {}), } satisfies Multisig assert(result) return result } throw new CoercionError({ envelope }) } export declare namespace fromRpc { type ErrorType = | assert.ErrorType | CoercionError | InvalidSerializedError | MultisigConfig.getAddress.ErrorType | Signature.fromRpc.ErrorType | Errors.GlobalErrorType } /** * Determines the signature type of an envelope. * * @example * ```ts twoslash * import { SignatureEnvelope } from 'ox/tempo' * * const type = SignatureEnvelope.getType({ * signature: { * r: '0x0000000000000000000000000000000000000000000000000000000000000000', * s: '0x0000000000000000000000000000000000000000000000000000000000000000', * yParity: 0 * } * }) * // @log: 'secp256k1' * ``` * * @param envelope - The signature envelope to inspect. * @returns The signature type ('secp256k1', 'p256', or 'webAuthn'). * @throws `CoercionError` if the envelope type cannot be determined. */ export function getType< envelope extends | PartialBy<SignatureEnvelope, 'type'> | Secp256k1Flat | unknown, >(envelope: envelope): GetType<envelope> { if (typeof envelope !== 'object' || envelope === null) throw new CoercionError({ envelope }) if ('type' in envelope && envelope.type) return envelope.type as never // Detect secp256k1 signature (backwards compatibility: also support flat structure) if ( 'signature' in envelope && !('publicKey' in envelope) && typeof envelope.signature === 'object' && envelope.signature !== null && 'r' in envelope.signature && 's' in envelope.signature && 'yParity' in envelope.signature ) return 'secp256k1' as never // Detect secp256k1 signature (flat structure) if ('r' in envelope && 's' in envelope && 'yParity' in envelope) return 'secp256k1' as never // Detect P256 signature if ( 'signature' in envelope && 'prehash' in envelope && 'publicKey' in envelope && typeof envelope.prehash === 'boolean' ) return 'p256' as never // Detect WebAuthn signature if ( 'signature' in envelope && 'metadata' in envelope && 'publicKey' in envelope ) return 'webAuthn' as never // Detect Keychain signature if ('userAddress' in envelope && 'inner' in envelope) return 'keychain' as never // Detect Multisig signature if ( ('account' in envelope || 'genesisConfig' in envelope || 'init' in envelope) && 'signatures' in envelope ) return 'multisig' as never throw new CoercionError({ envelope, }) } /** * Serializes a signature envelope to a hex-encoded string. * * Wire format: * - secp256k1: 65 bytes (no type prefix, for backward compatibility) * - P256: `0x01` + r (32) + s (32) + pubKeyX (32) + pubKeyY (32) + prehash (1) = 130 bytes * - WebAuthn: `0x02` + webauthnData (variable) + r (32) + s (32) + pubKeyX (32) + pubKeyY (32) * - Keychain V1: `0x03` + userAddress (20) + inner signature (recursive) * - Keychain V2: `0x04` + userAddress (20) + inner signature (recursive) * - Multisig: `0x05` + RLP `[account | init, signatures]` * * [Signature Types](https://docs.tempo.xyz/protocol/transactions/spec-tempo-transaction#signature-types) * * @example * ```ts twoslash * import { SignatureEnvelope } from 'ox/tempo' * * const serialized = SignatureEnvelope.serialize({ * signature: { * r: '0x0000000000000000000000000000000000000000000000000000000000000000', * s: '0x0000000000000000000000000000000000000000000000000000000000000000', * yParity: 0 * }, * type: 'secp256k1' * }) * ``` * * @param envelope - The signature envelope to serialize. * @returns The hex-encoded serialized signature. * @throws `CoercionError` if the envelope cannot be serialized. */ export function serialize( envelope: UnionPartialBy<SignatureEnvelope, 'prehash'>, options: serialize.Options = {}, ): Serialized { const type = getType(envelope) // Backward compatibility: no type identifier for secp256k1 if (type === 'secp256k1') { const secp256k1 = envelope as Secp256k1 return Hex.concat( Signature.toHex(secp256k1.signature), options.magic ? magicBytes : '0x', ) } if (type === 'p256') { const p256 = envelope as P256 // Format: 1 byte (type) + 32 (r) + 32 (s) + 32 (pubKeyX) + 32 (pubKeyY) + 1 (prehash) return Hex.concat( serializedP256Type, p256.signature.r, p256.signature.s, p256.publicKey.x, p256.publicKey.y as Hex.Hex, Hex.fromNumber(p256.prehash ? 1 : 0, { size: 1 }), options.magic ? magicBytes : '0x', ) } if (type === 'webAuthn') { const webauthn = envelope as WebAuthn // Format: 1 byte (type) + variable (authenticatorData || clientDataJSON) + 32 (r) + 32 (s) + 32 (pubKeyX) + 32 (pubKeyY) const webauthnData = Hex.concat( webauthn.metadata.authenticatorData, Hex.fromString(webauthn.metadata.clientDataJSON), ) return Hex.concat( serializedWebAuthnType, webauthnData, webauthn.signature.r, webauthn.signature.s, webauthn.publicKey.x, webauthn.publicKey.y as Hex.Hex, options.magic ? magicBytes : '0x', ) } if (type === 'keychain') { const keychain = envelope as Keychain const keychainTypeId = keychain.version === 'v1' ? serializedKeychainType : serializedKeychainV2Type return Hex.concat( keychainTypeId, keychain.userAddress, serialize(keychain.inner), options.magic ? magicBytes : '0x', ) } if (type === 'multisig') { const multisig = envelope as Multisig assert(multisig) // The first field is either the initialized account or the bootstrap init // config. Each owner approval is an encoded signature. return Hex.concat( serializedMultisigType, Rlp.fromHex([ multisig.init ? MultisigConfig.toTuple(multisig.init) : multisig.account, multisig.signatures.map((signature) => serialize(signature)), ]), options.magic ? magicBytes : '0x', ) } throw new CoercionError({ envelope }) } export declare namespace serialize { type Options = { /** * Whether to serialize the signature envelope with the Tempo magic identifier. * This is useful for being able to distinguish between Tempo and non-Tempo (e.g. ERC-1271) signatures. */ magic?: boolean | undefined } type ErrorType = | assert.ErrorType | CoercionError | Hex.concat.ErrorType | Hex.fromNumber.ErrorType | Hex.fromString.ErrorType | Rlp.fromHex.ErrorType | Signature.toHex.ErrorType | Errors.GlobalErrorType } /** * Orders native multisig owner approvals into the strictly-ascending * recovered-owner order the Tempo node requires for the multisig `signatures` * array (the node enforces "recovered owners must be strictly ascending"). * * Each approval is signed over the multisig owner approval digest * ({@link ox#MultisigConfig.(getSignPayload:function)}), so the signer of * every approval is recovered against that digest and the list is sorted by the * recovered owner address. Works for any owner key type (secp256k1, p256, * webAuthn). * * Config updates never change `account`, so the genesis config is the correct * input even for post-update transactions. * * @example * ```ts twoslash * import { Secp256k1 } from 'ox' * import { * MultisigConfig, * SignatureEnvelope, * TxEnvelopeTempo * } from 'ox/tempo' * * const genesisConfig = MultisigConfig.from({ * threshold: 2, * owners: [ * { * owner: '0x1111111111111111111111111111111111111111', * weight: 1 * }, * { * owner: '0x2222222222222222222222222222222222222222', * weight: 1 * } * ] * }) * * const tx = TxEnvelopeTempo.from({ chainId: 1, calls: [] }) * const payload = TxEnvelopeTempo.getSignPayload(tx) * * const privateKeys = [ * Secp256k1.randomPrivateKey(), * Secp256k1.randomPrivateKey() * ] * const digest = MultisigConfig.getSignPayload({ * payload, * genesisConfig * }) * const signatures = privateKeys.map((privateKey) => * SignatureEnvelope.from( * Secp256k1.sign({ payload: digest, privateKey }) * ) * ) * * const ordered = SignatureEnvelope.sortMultisigApprovals({ * // [!code focus] * genesisConfig, // [!code focus] * payload, // [!code focus] * signatures // [!code focus] * }) // [!code focus] * ``` * * @param value - The approval ordering parameters. * @returns The owner approvals ordered ascending by recovered owner address. */ export function sortMultisigApprovals( value: sortMultisigApprovals.Value, ): readonly SignatureEnvelope[] { const { payload, signatures } = value const digest = MultisigConfig.getSignPayload( 'genesisConfig' in value && value.genesisConfig ? { payload, genesisConfig: value.genesisConfig } : { payload, account: (value as { account: Address.Address }).account }, ) // Recover each signer once (decorate–sort–undecorate) rather than inside the // comparator. return signatures .map((signature) => ({ key: Hex.toBigInt(extractAddress({ payload: digest, signature })), signature, })) .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)) .map((entry) => entry.signature) } export declare namespace sortMultisigApprovals { type Value = { /** The inner transaction sign payload (`tx.signature_hash()`). */ payload: Hex.Hex | Bytes.Bytes /** The owner approvals to order. */ signatures: readonly SignatureEnvelope[] } & OneOf< | { /** The native multisig account address. */ account: Address.Address } | { /** * The initial multisig config (the bootstrap config that derived the * permanent `account`). Used to derive the account automatically. */ genesisConfig: MultisigConfig.Config } > type ErrorType = | MultisigConfig.getSignPayload.ErrorType | extractAddress.ErrorType | Errors.GlobalErrorType } /** * Converts a signature envelope to RPC format. * * @example * ```ts twoslash * import { SignatureEnvelope } from 'ox/tempo' * * const rpc = SignatureEnvelope.toRpc({ * signature: { * r: '0x0000000000000000000000000000000000000000000000000000000000000000', * s: '0x0000000000000000000000000000000000000000000000000000000000000000', * yParity: 0 * }, * type: 'secp256k1' * }) * ``` * * @param envelope - The signature envelope to convert. * @returns The RPC signature envelope with hex values. */ export function toRpc<const envelope extends toRpc.Input>( envelope: envelope, ): toRpc.ReturnType<envelope> { const type = getType(envelope) if (type === 'secp256k1') { const secp256k1 = envelope as Secp256k1 return { ...Signature.toRpc(secp256k1.signature), type: 'secp256k1', } as never } if (type === 'p256') { const p256 = envelope as P256 return { preHash: p256.prehash, pubKeyX: p256.publicKey.x, pubKeyY: p256.publicKey.y as Hex.Hex, r: p256.signature.r, s: p256.signature.s, type: 'p256', } as never } if (type === 'webAuthn') { const webauthn = envelope as WebAuthn const webauthnData = Hex.concat( webauthn.metadata.authenticatorData, Hex.fromString(webauthn.metadata.clientDataJSON), ) return { pubKeyX: webauthn.publicKey.x, pubKeyY: webauthn.publicKey.y as Hex.Hex, r: webauthn.signa