ox
Version:
Ethereum Standard Library
1,628 lines (1,514 loc) • 59.1 kB
text/typescript
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