ox
Version:
Ethereum Standard Library
1,346 lines • 46.8 kB
JavaScript
import * as Address from '../core/Address.js';
import * as Errors from '../core/Errors.js';
import * as Hex from '../core/Hex.js';
import * as Json from '../core/Json.js';
import * as ox_P256 from '../core/P256.js';
import * as Rlp from '../core/Rlp.js';
import * as ox_Secp256k1 from '../core/Secp256k1.js';
import * as Signature from '../core/Signature.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
/** List of supported signature types. */
export const types = ['secp256k1', 'p256', 'webAuthn'];
/**
* 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) {
const type = getType(envelope);
if (type === 'secp256k1') {
const secp256k1 = envelope;
Signature.assert(secp256k1.signature);
return;
}
if (type === 'p256') {
const p256 = envelope;
const missing = [];
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;
const missing = [];
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;
assert(keychain.inner);
return;
}
if (type === 'multisig') {
const multisig = envelope;
assertMultisig(multisig, 1);
return;
}
}
function assertMultisig(envelope, depth) {
const missing = [];
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;
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) {
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));
}
/**
* 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) {
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 });
}
}
/**
* 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) {
return deserialize_(value, 0);
}
function deserialize_(value, multisigDepth) {
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' };
}
// 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',
};
}
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;
let clientDataJSON;
// 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',
};
}
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',
};
}
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) > 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]) > 1))
throw new InvalidSerializedError({
reason: 'invalid multisig init config',
serialized,
});
}
const init = Array.isArray(address)
? MultisigConfig.fromTuple(address)
: undefined;
const account = init
? MultisigConfig.getAddress(init)
: address;
const envelope = {
type: 'multisig',
account,
signatures: signatures.map((signature) => deserialize_(signature, depth)),
...(init ? { init } : {}),
};
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(value, options) {
if (typeof value === 'string')
return deserialize(value);
if (typeof value === 'object' &&
value !== null &&
'r' in value &&
's' in value &&
'yParity' in value)
return { signature: value, type: 'secp256k1' };
const type = getType(value);
if (type === 'multisig') {
const multisig = value;
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,
};
}
return {
...value,
...(type === 'p256' ? { prehash: value.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.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,
};
}
/**
* 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) {
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;
let clientDataJSON;
// 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;
return {
type: 'keychain',
userAddress: keychain.userAddress,
inner: fromRpc(keychain.signature),
...(keychain.keyId ? { keyId: keychain.keyId } : {}),
...(keychain.version ? { version: keychain.version } : {}),
};
}
if (envelope.type === 'multisig' ||
('signatures' in envelope && ('account' in envelope || 'init' in envelope))) {
const multisig = envelope;
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)
: undefined;
const account = init ? MultisigConfig.getAddress(init) : multisig.account;
const result = {
type: 'multisig',
account: account,
signatures: multisig.signatures.map((signature) => fromRpc(signature)),
...(init ? { init } : {}),
};
assert(result);
return result;
}
throw new CoercionError({ envelope });
}
/**
* 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) {
if (typeof envelope !== 'object' || envelope === null)
throw new CoercionError({ envelope });
if ('type' in envelope && envelope.type)
return envelope.type;
// 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';
// Detect secp256k1 signature (flat structure)
if ('r' in envelope && 's' in envelope && 'yParity' in envelope)
return 'secp256k1';
// Detect P256 signature
if ('signature' in envelope &&
'prehash' in envelope &&
'publicKey' in envelope &&
typeof envelope.prehash === 'boolean')
return 'p256';
// Detect WebAuthn signature
if ('signature' in envelope &&
'metadata' in envelope &&
'publicKey' in envelope)
return 'webAuthn';
// Detect Keychain signature
if ('userAddress' in envelope && 'inner' in envelope)
return 'keychain';
// Detect Multisig signature
if (('account' in envelope ||
'genesisConfig' in envelope ||
'init' in envelope) &&
'signatures' in envelope)
return 'multisig';
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, options = {}) {
const type = getType(envelope);
// Backward compatibility: no type identifier for secp256k1
if (type === 'secp256k1') {
const secp256k1 = envelope;
return Hex.concat(Signature.toHex(secp256k1.signature), options.magic ? magicBytes : '0x');
}
if (type === 'p256') {
const p256 = envelope;
// 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, Hex.fromNumber(p256.prehash ? 1 : 0, { size: 1 }), options.magic ? magicBytes : '0x');
}
if (type === 'webAuthn') {
const webauthn = envelope;
// 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, options.magic ? magicBytes : '0x');
}
if (type === 'keychain') {
const keychain = envelope;
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;
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 });
}
/**
* 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) {
const { payload, signatures } = value;
const digest = MultisigConfig.getSignPayload('genesisConfig' in value && value.genesisConfig
? { payload, genesisConfig: value.genesisConfig }
: { payload, account: value.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);
}
/**
* 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(envelope) {
const type = getType(envelope);
if (type === 'secp256k1') {
const secp256k1 = envelope;
return {
...Signature.toRpc(secp256k1.signature),
type: 'secp256k1',
};
}
if (type === 'p256') {
const p256 = envelope;
return {
preHash: p256.prehash,
pubKeyX: p256.publicKey.x,
pubKeyY: p256.publicKey.y,
r: p256.signature.r,
s: p256.signature.s,
type: 'p256',
};
}
if (type === 'webAuthn') {
const webauthn = envelope;
const webauthnData = Hex.concat(webauthn.metadata.authenticatorData, Hex.fromString(webauthn.metadata.clientDataJSON));
return {
pubKeyX: webauthn.publicKey.x,
pubKeyY: webauthn.publicKey.y,
r: webauthn.signature.r,
s: webauthn.signature.s,
type: 'webAuthn',
webauthnData,
};
}
if (type === 'keychain') {
const keychain = envelope;
return {
type: 'keychain',
userAddress: keychain.userAddress,
signature: toRpc(keychain.inner),
...(keychain.keyId ? { keyId: keychain.keyId } : {}),
...(keychain.version ? { version: keychain.version } : {}),
};
}
if (type === 'multisig') {
const multisig = envelope;
assert(multisig);
const signatures = multisig.signatures.map((signature) => toRpc(signature));
if (multisig.init) {
const init = {
...multisig.init,
salt: multisig.init.salt ?? MultisigConfig.zeroSalt,
threshold: Number(multisig.init.threshold),
owners: multisig.init.owners.map((owner) => ({
...owner,
weight: Number(owner.weight),
})),
};
return {
init,
signatures,
};
}
return {
account: multisig.account,
signatures,
};
}
throw new CoercionError({ envelope });
}
/**
* Validates a signature envelope. Returns `true` if the envelope is valid, `false` otherwise.
*
* @example
* ```ts twoslash
* import { SignatureEnvelope } from 'ox/tempo'
*
* const valid = SignatureEnvelope.validate({
* signature: {
* r: '0x0000000000000000000000000000000000000000000000000000000000000000',
* s: '0x0000000000000000000000000000000000000000000000000000000000000000',
* yParity: 0
* },
* type: 'secp256k1'
* })
* // @log: true
* ```
*
* @param envelope - The signature envelope to validate.
* @returns `true` if valid, `false` otherwise.
*/
export function validate(envelope) {
try {
assert(envelope);
return true;
}
catch {
return false;
}
}
/**
* Verifies a signature envelope against a digest/payload.
*
* Supports `secp256k1`, `p256`, and `webAuthn` signature types.
*
* :::warning
* `keychain` signatures are not supported and will throw an error.
* :::
*
* @example
* ### Secp256k1
*
* ```ts twoslash
* import { SignatureEnvelope } from 'ox/tempo'
* import { Secp256k1 } from 'ox'
*
* const privateKey = Secp256k1.randomPrivateKey()
* const publicKey = Secp256k1.getPublicKey({ privateKey })
* const payload = '0xdeadbeef'
*
* const signature = Secp256k1.sign({ payload, privateKey })
* const envelope = SignatureEnvelope.from(signature)
*
* const valid = SignatureEnvelope.verify(envelope, {
* payload,
* publicKey
* })
* // @log: true
* ```
*
* @example
* ### P256
*
* For P256 signatures, the `address` or `publicKey` must match the embedded
* public key in the signature envelope.
*
* ```ts twoslash
* import { SignatureEnvelope } from 'ox/tempo'
* import { P256 } from 'ox'
*
* const privateKey = P256.randomPrivateKey()
* const publicKey = P256.getPublicKey({ privateKey })
* const payload = '0xdeadbeef'
*
* const signature = P256.sign({ payload, privateKey })
* const envelope = SignatureEnvelope.from({
* prehash: false,
* publicKey,
* signature
* })
*
* const valid = SignatureEnvelope.verify(envelope, {
* payload,
* publicKey
* })
* // @log: true
* ```
*
* @example
* ### WebCryptoP256
*
* ```ts twoslash
* import { SignatureEnvelope } from 'ox/tempo'
* import { WebCryptoP256 } from 'ox'
*
* const { privateKey, publicKey } =
* await WebCryptoP256.createKeyPair()
* const payload = '0xdeadbeef'
*
* const signature = await WebCryptoP256.sign({
* payload,
* privateKey
* })
* const envelope = SignatureEnvelope.from({
* prehash: true,
* publicKey,
* signature
* })
*
* const valid = SignatureEnvelope.verify(envelope, {
* payload,
* publicKey
* })
* // @log: true
* ```
*
* @example
* ### WebAuthnP256
*
* ```ts twoslash
* import { SignatureEnvelope } from 'ox/tempo'
* import { WebAuthnP256 } from 'ox'
*
* const credential = await WebAuthnP256.createCredential({
* name: 'Example'
* })
* const payload = '0xdeadbeef'
*
* const { metadata, signature } = await WebAuthnP256.sign({
* challenge: payload,
* credentialId: credential.id
* })
* const envelope = SignatureEnvelope.from({
* metadata,
* signature,
* publicKey: credential.publicKey
* })
*
* const valid = SignatureEnvelope.verify(envelope, {
* payload,
* publicKey: credential.publicKey
* })
* // @log: true
* ```
*
* @param parameters - Verification parameters.
* @returns `true` if the signature is valid, `false` otherwise.
*/
export function verify(signature, parameters) {
const { payload } = parameters;
const address = (() => {
if (parameters.address)
return parameters.address;
if (parameters.publicKey)
return Address.fromPublicKey(parameters.publicKey);
return undefined;
})();
if (!address)
return false;
const envelope = from(signature);
if (envelope.type === 'secp256k1') {
if (!address)
return false;
return ox_Secp256k1.verify({
address,
payload,
signature: envelope.signature,
});
}
if (envelope.type === 'p256') {
const envelopeAddress = Address.fromPublicKey(envelope.publicKey);
if (!Address.isEqual(envelopeAddress, address))
return false;
return ox_P256.verify({
hash: envelope.prehash,
publicKey: envelope.publicKey,
payload,
signature: envelope.signature,
});
}
if (envelope.type === 'webAuthn') {
const envelopeAddress = Address.fromPublicKey(envelope.publicKey);
if (!Address.isEqual(envelopeAddress, address))
return false;
return ox_WebAuthnP256.verify({
challenge: Hex.from(payload),
metadata: envelope.metadata,
publicKey: envelope.publicKey,
signature: envelope.signature,
});
}
throw new VerificationError(`Unable to verify signature envelope of type "${envelope.type}".`);
}
/**
* Error thrown when a signature envelope cannot be coerced to a valid type.
*/
export class CoercionError extends Errors.BaseError {
name = 'SignatureEnvelope.CoercionError';
constructor({ envelope }) {
super(`Unable to coerce value (\`${Json.stringify(envelope)}\`) to a valid signature envelope.`);
}
}
/**
* Error thrown when a signature envelope is missing required properties.
*/
export class MissingPropertiesError extends Errors.BaseError {
name = 'SignatureEnvelope.MissingPropertiesError';
constructor({ envelope, missing, type, }) {
super(`Signature envelope of type "${type}" is missing required properties: ${missing.map((m) => `\`${m}\``).join(', ')}.\n\nProvided: ${Json.stringify(envelope)}`);
}
}
/**
* Error thrown when a serialized signature envelope cannot be deserialized.
*/
export class InvalidSerializedError extends Errors.BaseError {
name = 'SignatureEnvelope.InvalidSerializedError';
constructor({ reason, serialized }) {
super(`Unable to deserialize signature envelope: ${reason}`, {
metaMessages: [`Serialized: ${serialized}`],
});
}
}
/**
* Error thrown when a native multisig owner approval is invalid.
*/
export class InvalidMultisigApprovalError extends Errors.BaseError {
name = 'SignatureEnvelope.InvalidMultisigApprovalError';
constructor({ reason }) {
super(`Invalid native multisig owner approval: ${reason}.`);
}
}
/**
* Error thrown when a signature envelope fails to verify.
*/
export class VerificationError extends Errors.BaseError {
name = 'SignatureEnvelope.VerificationError';
}
//# sourceMappingURL=SignatureEnvelope.js.map