UNPKG

accounts

Version:

Tempo Accounts SDK

744 lines (698 loc) 25.6 kB
import { Address, Hex, P256 } from 'ox' import { MultisigConfig, SignatureEnvelope, TxEnvelopeTempo } from 'ox/tempo' import { createClient, custom } from 'viem' import { Account, Transaction } from 'viem/tempo' import { tempoDevnet } from 'viem/tempo/chains' import { describe, expect, test } from 'vp/test' import * as Multisig from './multisig.js' const privateKey_1 = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' const privateKey_2 = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' const privateKey_3 = '0x7c852118294f7e395961158d39c448445a22a434adcd1d4e9048bcd3d85e9e4d' const feeToken = '0x20c0000000000000000000000000000000000000' as const describe('memoryStore', () => { test('behavior: submission claims are exclusive until TTL expiry', async () => { const store = Multisig.memoryStore() const operation = createOperation() await store.savePending(operation) const first = await store.claimSubmission(operation, { ttl: 1_000 }) const second = await store.claimSubmission(operation, { ttl: 1_000 }) expect({ first: first.status, second: second.status }).toMatchInlineSnapshot(` { "first": "claimed", "second": "submitting", } `) }) test('behavior: expired submission claims can be reclaimed', async () => { const store = Multisig.memoryStore() const operation = createOperation() await store.savePending(operation) await store.claimSubmission(operation, { ttl: -1 }) const reclaimed = await store.claimSubmission(operation, { ttl: 1_000 }) expect(reclaimed.status).toMatchInlineSnapshot(`"claimed"`) }) test('behavior: pending saves merge signatures and submitted operations are not listed as pending', async () => { const store = Multisig.memoryStore() const operation = createOperation() await store.savePending({ ...operation, signatures: [`0x${'11'.repeat(65)}`] }) const merged = await store.savePending({ ...operation, signatures: [`0x${'22'.repeat(65)}`] }) await store.claimSubmission(merged, { ttl: 1_000 }) await store.setSubmitted(operation.id, `0x${'33'.repeat(32)}`) expect({ listed: await store.listPendingByAddress(operation.account), signatures: merged.signatures.length, status: (await store.get(operation.id))?.status, }).toMatchInlineSnapshot(` { "listed": [], "signatures": 2, "status": "submitted", } `) }) }) describe('handleRawTransaction', () => { test('behavior: rejects first approvals when no config can be resolved', async () => { const owner = Account.fromSecp256k1(privateKey_1) const account = Account.fromMultisig({ threshold: 1, owners: [{ owner: owner.address, weight: 1 }], }) const transaction = { calls: [{ to: '0xcafebabecafebabecafebabecafebabecafebabe', value: 1n }], chainId: tempoDevnet.id, gas: 21_000n, maxFeePerGas: 1n, maxPriorityFeePerGas: 0n, multisig: account.config, nonce: 0n, } const signature = await owner.signTransaction(transaction as never) const serialized = withoutInit( await account.signTransaction({ ...transaction, signatures: [signature] } as never), ) await expect( Multisig.handleRawTransaction({ getClient: (() => undefined) as never, method: 'eth_sendRawTransaction', request: { params: [serialized] }, store: Multisig.memoryStore(), }), ).rejects.toThrowErrorMatchingInlineSnapshot( `[RpcResponse.InvalidParamsError: Multisig config is required to collect approvals. Provide it in the bootstrap transaction or configure \`multisig.resolveConfig\`.]`, ) }) test('behavior: finalize submitted preserves async broadcast method', async () => { const owner = Account.fromSecp256k1(privateKey_1) const account = Account.fromMultisig({ threshold: 1, owners: [{ owner: owner.address, weight: 1 }], }) const transaction = { calls: [{ to: '0xcafebabecafebabecafebabecafebabecafebabe', value: 1n }], chainId: tempoDevnet.id, gas: 21_000n, maxFeePerGas: 1n, maxPriorityFeePerGas: 0n, multisig: account.config, nonce: 0n, } const signature = await owner.signTransaction(transaction as never) const serialized = await account.signTransaction({ ...transaction, signatures: [signature], } as never) let broadcastMethod: string | undefined const hash = await Multisig.handleRawTransaction({ finalize: 'submitted', getClient: (() => ({ request: async ({ method, params }: { method: string; params: unknown }) => { broadcastMethod = method return hashRawTransaction(params) }, })) as never, method: 'eth_sendRawTransaction', request: { params: [serialized] }, store: Multisig.memoryStore(), }) expect({ broadcastMethod, hash }).toMatchInlineSnapshot(` { "broadcastMethod": "eth_sendRawTransaction", "hash": "${hash}", } `) }) }) describe('config resolution', () => { test('behavior: reuses pending bootstrap config when a later approval omits init', async () => { const store = Multisig.memoryStore() const owner_1 = Account.fromSecp256k1(privateKey_1) const owner_2 = Account.fromSecp256k1(privateKey_2) const account = Account.fromMultisig({ threshold: 2, owners: [ { owner: owner_1.address, weight: 1 }, { owner: owner_2.address, weight: 1 }, ], }) const transaction = { calls: [{ to: '0xcafebabecafebabecafebabecafebabecafebabe', value: 1n }], chainId: tempoDevnet.id, gas: 21_000n, maxFeePerGas: 1n, maxPriorityFeePerGas: 0n, multisig: account.config, nonce: 0n, } const signature_1 = await owner_1.signTransaction(transaction as never) const signature_2 = await owner_2.signTransaction(transaction as never) const first = await account.signTransaction({ ...transaction, signatures: [signature_1], } as never) const second = withoutInit( await account.signTransaction({ ...transaction, signatures: [signature_2] } as never), ) await Multisig.handleRawTransaction({ getClient: (() => undefined) as never, method: 'eth_sendRawTransaction', request: { params: [first] }, store, }) const hash = await Multisig.handleRawTransaction({ getClient: (() => ({ request: async ({ params }: { params: unknown }) => hashRawTransaction(params), })) as never, method: 'eth_sendRawTransaction', request: { params: [second] }, store, }) expect(hash).toMatchInlineSnapshot(`"${hash}"`) }) test('behavior: preserves bootstrap init when it arrives after a resolver-backed pending approval', async () => { const store = Multisig.memoryStore() const owner_1 = Account.fromSecp256k1(privateKey_1) const owner_2 = Account.fromSecp256k1(privateKey_2) const account = Account.fromMultisig({ threshold: 2, owners: [ { owner: owner_1.address, weight: 1 }, { owner: owner_2.address, weight: 1 }, ], }) const transaction = { calls: [{ to: '0xcafebabecafebabecafebabecafebabecafebabe', value: 1n }], chainId: tempoDevnet.id, gas: 21_000n, maxFeePerGas: 1n, maxPriorityFeePerGas: 0n, multisig: account.config, nonce: 0n, } const signature_1 = await owner_1.signTransaction(transaction as never) const signature_2 = await owner_2.signTransaction(transaction as never) const first = withoutInit( await account.signTransaction({ ...transaction, signatures: [signature_1] } as never), ) const second = await account.signTransaction({ ...transaction, signatures: [signature_2], } as never) let submitted: `0x${string}` | undefined await Multisig.handleRawTransaction({ getClient: (() => undefined) as never, method: 'eth_sendRawTransaction', request: { params: [first] }, resolveConfig: () => account.config, store, }) const hash = await Multisig.handleRawTransaction({ getClient: (() => ({ request: async ({ params }: { params: unknown }) => { submitted = Array.isArray(params) ? (params[0] as `0x${string}`) : undefined return hashRawTransaction(params) }, })) as never, method: 'eth_sendRawTransaction', request: { params: [second] }, resolveConfig: () => account.config, store, }) const transaction_submitted = Transaction.deserialize(submitted! as never) as { signature: SignatureEnvelope.Multisig } expect({ hash, init: transaction_submitted.signature.init ? MultisigConfig.getAddress(transaction_submitted.signature.init) : undefined, }).toMatchInlineSnapshot(` { "hash": "${hash}", "init": "${MultisigConfig.getAddress(account.config)}", } `) }) test('behavior: attaches resolver-provided bootstrap config when approvals omit init', async () => { const store = Multisig.memoryStore() const owner_1 = Account.fromSecp256k1(privateKey_1) const owner_2 = Account.fromSecp256k1(privateKey_2) const account = Account.fromMultisig({ threshold: 2, owners: [ { owner: owner_1.address, weight: 1 }, { owner: owner_2.address, weight: 1 }, ], }) const transaction = { calls: [{ to: '0xcafebabecafebabecafebabecafebabecafebabe', value: 1n }], chainId: tempoDevnet.id, gas: 21_000n, maxFeePerGas: 1n, maxPriorityFeePerGas: 0n, multisig: account.config, nonce: 0n, } const signature_1 = await owner_1.signTransaction(transaction as never) const signature_2 = await owner_2.signTransaction(transaction as never) const first = withoutInit( await account.signTransaction({ ...transaction, signatures: [signature_1] } as never), ) const second = withoutInit( await account.signTransaction({ ...transaction, signatures: [signature_2] } as never), ) let submitted: `0x${string}` | undefined await Multisig.handleRawTransaction({ getClient: (() => undefined) as never, method: 'eth_sendRawTransaction', request: { params: [first] }, resolveConfig: () => account.config, store, }) const hash = await Multisig.handleRawTransaction({ getClient: (() => ({ request: async ({ params }: { params: unknown }) => { submitted = Array.isArray(params) ? (params[0] as `0x${string}`) : undefined return hashRawTransaction(params) }, })) as never, method: 'eth_sendRawTransaction', request: { params: [second] }, resolveConfig: () => account.config, store, }) const transaction_submitted = Transaction.deserialize(submitted! as never) as { signature: SignatureEnvelope.Multisig } expect({ hash, init: transaction_submitted.signature.init ? MultisigConfig.getAddress(transaction_submitted.signature.init) : undefined, }).toMatchInlineSnapshot(` { "hash": "${hash}", "init": "${MultisigConfig.getAddress(account.config)}", } `) }) test('behavior: preserves bootstrap init when claimed operation omits stored init config', async () => { const base = Multisig.memoryStore() const store: Multisig.Store = { claimSubmission: async (operation, options) => { const result = await base.claimSubmission(operation, options) if (result.status !== 'claimed') return result return { operation: { ...result.operation, initConfig: undefined }, status: result.status, } }, get: base.get, listPendingByAddress: base.listPendingByAddress, savePending: base.savePending, setSubmitted: base.setSubmitted, } const owner = Account.fromSecp256k1(privateKey_1) const account = Account.fromMultisig({ threshold: 1, owners: [{ owner: owner.address, weight: 1 }], }) const transaction = { calls: [{ to: '0xcafebabecafebabecafebabecafebabecafebabe', value: 1n }], chainId: tempoDevnet.id, gas: 21_000n, maxFeePerGas: 1n, maxPriorityFeePerGas: 0n, multisig: account.config, nonce: 0n, } const signature = await owner.signTransaction(transaction as never) const serialized = await account.signTransaction({ ...transaction, signatures: [signature], } as never) let submitted: `0x${string}` | undefined const hash = await Multisig.handleRawTransaction({ getClient: (() => ({ request: async ({ params }: { params: unknown }) => { submitted = Array.isArray(params) ? (params[0] as `0x${string}`) : undefined return hashRawTransaction(params) }, })) as never, method: 'eth_sendRawTransaction', request: { params: [serialized] }, store, }) const transaction_submitted = Transaction.deserialize(submitted! as never) as { signature: SignatureEnvelope.Multisig } expect({ hash, init: transaction_submitted.signature.init ? MultisigConfig.getAddress(transaction_submitted.signature.init) : undefined, }).toMatchInlineSnapshot(` { "hash": "${hash}", "init": "${MultisigConfig.getAddress(account.config)}", } `) }) test('behavior: rejects resolved configs that do not match the multisig account', async () => { const owner_1 = Account.fromSecp256k1(privateKey_1) const owner_2 = Account.fromSecp256k1(privateKey_2) const account = Account.fromMultisig({ threshold: 1, owners: [{ owner: owner_1.address, weight: 1 }], }) const wrong = Account.fromMultisig({ threshold: 1, owners: [{ owner: owner_2.address, weight: 1 }], }) const transaction = { calls: [{ to: '0xcafebabecafebabecafebabecafebabecafebabe', value: 1n }], chainId: tempoDevnet.id, gas: 21_000n, maxFeePerGas: 1n, maxPriorityFeePerGas: 0n, multisig: account.config, nonce: 0n, } const signature = await owner_1.signTransaction(transaction as never) const serialized = await account.signTransaction({ ...transaction, signatures: [signature], } as never) await expect( Multisig.handleRawTransaction({ getClient: (() => undefined) as never, method: 'eth_sendRawTransaction', request: { params: [serialized] }, resolveConfig: () => wrong.config, store: Multisig.memoryStore(), }), ).rejects.toThrowErrorMatchingInlineSnapshot( `[RpcResponse.InvalidParamsError: Signature from non-owner 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266.]`, ) }) test('behavior: accepts current configs for updated multisig signer sets', async () => { const owner_1 = Account.fromSecp256k1(privateKey_1) const owner_2 = Account.fromSecp256k1(privateKey_2) const account = Account.fromMultisig({ threshold: 1, owners: [{ owner: owner_1.address, weight: 1 }], }) const current = MultisigConfig.from({ owners: [ { owner: owner_1.address, weight: 1 }, { owner: owner_2.address, weight: 1 }, ], threshold: 1, }) const transaction = { calls: [{ to: '0xcafebabecafebabecafebabecafebabecafebabe', value: 1n }], chainId: tempoDevnet.id, gas: 21_000n, maxFeePerGas: 1n, maxPriorityFeePerGas: 0n, multisig: account.config, nonce: 1n, } const signature = await owner_2.signTransaction(transaction as never) const serialized = withoutInit( await account.signTransaction({ ...transaction, signatures: [signature], } as never), ) let submitted: `0x${string}` | undefined const hash = await Multisig.handleRawTransaction({ getClient: (() => ({ request: async ({ params }: { params: unknown }) => { submitted = Array.isArray(params) ? (params[0] as `0x${string}`) : undefined return hashRawTransaction(params) }, })) as never, method: 'eth_sendRawTransaction', request: { params: [serialized] }, resolveConfig: () => current, store: Multisig.memoryStore(), }) const transaction_submitted = Transaction.deserialize(submitted! as never) as { signature: SignatureEnvelope.Multisig } expect({ account: transaction_submitted.signature.account, hash, init: transaction_submitted.signature.init, }).toMatchInlineSnapshot(` { "account": "${MultisigConfig.getAddress(account.config)}", "hash": "${hash}", "init": undefined, } `) }) }) describe('approval validation', () => { test('behavior: rejects P256 approvals whose embedded owner key did not sign the operation', async () => { const privateKey = P256.randomPrivateKey() const publicKey = P256.getPublicKey({ privateKey }) const owner = Address.fromPublicKey(publicKey) const account = Account.fromMultisig({ threshold: 1, owners: [{ owner, weight: 1 }], }) const transaction = { calls: [{ to: '0xcafebabecafebabecafebabecafebabecafebabe', value: 1n }], chainId: tempoDevnet.id, gas: 21_000n, maxFeePerGas: 1n, maxPriorityFeePerGas: 0n, multisig: account.config, nonce: 0n, } const signature = SignatureEnvelope.serialize( SignatureEnvelope.from({ publicKey, signature: P256.sign({ payload: `0x${'44'.repeat(32)}`, privateKey }), type: 'p256', }), ) const serialized = await account.signTransaction({ ...transaction, signatures: [signature], } as never) await expect( Multisig.handleRawTransaction({ getClient: (() => ({ request: async ({ params }: { params: unknown }) => hashRawTransaction(params), })) as never, method: 'eth_sendRawTransaction', request: { params: [serialized] }, store: Multisig.memoryStore(), }), ).rejects.toThrowErrorMatchingInlineSnapshot( `[RpcResponse.InvalidParamsError: Invalid signature from owner ${owner}.]`, ) }) }) describe('handleRawTransaction with sponsor', () => { test('behavior: broadcasts finalized multisig transactions with feePayerSignature and default feeToken', async () => { const store = Multisig.memoryStore() const owner_1 = Account.fromSecp256k1(privateKey_1) const owner_2 = Account.fromSecp256k1(privateKey_2) const feePayer = Account.fromSecp256k1(privateKey_3) const account = Account.fromMultisig({ threshold: 2, owners: [ { owner: owner_1.address, weight: 1 }, { owner: owner_2.address, weight: 1 }, ], }) const transaction = { calls: [{ to: '0xcafebabecafebabecafebabecafebabecafebabe', value: 1n }], chainId: tempoDevnet.id, feePayerSignature: null, gas: 21_000n, maxFeePerGas: 1n, maxPriorityFeePerGas: 0n, multisig: account.config, nonce: 0n, } const signature_1 = await owner_1.signTransaction(transaction as never) const signature_2 = await owner_2.signTransaction(transaction as never) const first = await account.signTransaction({ ...transaction, signatures: [signature_1], } as never) const second = await account.signTransaction({ ...transaction, signatures: [signature_2], } as never) const broadcasted: string[] = [] await Multisig.handleRawTransaction({ getClient: (() => undefined) as never, method: 'eth_sendRawTransaction', request: { params: [first] }, sponsor: { account: feePayer as never, resolveFeeToken: () => feeToken, }, store, }) const hash = await Multisig.handleRawTransaction({ getClient: () => createClient({ chain: tempoDevnet, transport: custom({ request: async ({ method, params }) => { if (method === 'eth_chainId') return Hex.fromNumber(tempoDevnet.id) broadcasted.push((params as readonly string[])[0]!) return hashRawTransaction(params) }, }), }), method: 'eth_sendRawTransaction', request: { params: [second] }, sponsor: { account: feePayer as never, resolveFeeToken: () => feeToken, }, store, }) const transaction_broadcasted = Transaction.deserialize(broadcasted[0] as never) as Record< string, unknown > const signature = transaction_broadcasted.signature as { init?: unknown; type?: string } expect({ feePayerSignature: Boolean(transaction_broadcasted.feePayerSignature), feeToken: (transaction_broadcasted.feeToken as string).toLowerCase(), hash, init: Boolean(signature.init), signature: signature.type, }).toMatchInlineSnapshot(` { "feePayerSignature": true, "feeToken": "0x20c0000000000000000000000000000000000000", "hash": "${hash}", "init": true, "signature": "multisig", } `) }) test('behavior: broadcasts already sponsored multisig transactions without replacing fee payer', async () => { const owner = Account.fromSecp256k1(privateKey_1) const feePayer_original = Account.fromSecp256k1(privateKey_2) const feePayer_relay = Account.fromSecp256k1(privateKey_3) const account = Account.fromMultisig({ threshold: 1, owners: [{ owner: owner.address, weight: 1 }], }) const transaction = { calls: [{ to: '0xcafebabecafebabecafebabecafebabecafebabe', value: 1n }], chainId: tempoDevnet.id, feePayerSignature: null, gas: 21_000n, maxFeePerGas: 1n, maxPriorityFeePerGas: 0n, multisig: account.config, nonce: 0n, } const signature = await owner.signTransaction(transaction as never) const serialized = await account.signTransaction({ ...transaction, signatures: [signature], } as never) const sponsored: `0x${string}`[] = [] await Multisig.handleRawTransaction({ getClient: () => createClient({ chain: tempoDevnet, transport: custom({ request: async ({ method, params }) => { if (method === 'eth_chainId') return Hex.fromNumber(tempoDevnet.id) sponsored.push((params as readonly `0x${string}`[])[0]!) return hashRawTransaction(params) }, }), }), method: 'eth_sendRawTransaction', request: { params: [serialized] }, sponsor: { account: feePayer_original as never, resolveFeeToken: () => feeToken, }, store: Multisig.memoryStore(), }) const presponsored = sponsored[0] if (!presponsored) throw new Error('Expected pre-sponsored transaction.') let broadcasted: `0x${string}` | undefined const hash = await Multisig.handleRawTransaction({ getClient: (() => ({ request: async ({ params }: { params: unknown }) => { broadcasted = Array.isArray(params) ? (params[0] as `0x${string}`) : undefined return hashRawTransaction(params) }, })) as never, method: 'eth_sendRawTransaction', request: { params: [presponsored] }, sponsor: { account: feePayer_relay as never, feeToken, validate: () => false, }, store: Multisig.memoryStore(), }) const transaction_broadcasted = Transaction.deserialize(broadcasted! as never) as Record< string, unknown > expect({ feePayerSignature: Boolean(transaction_broadcasted.feePayerSignature), feeToken: (transaction_broadcasted.feeToken as string).toLowerCase(), hash, signature: (transaction_broadcasted.signature as { type?: string }).type, }).toMatchInlineSnapshot(` { "feePayerSignature": true, "feeToken": "0x20c0000000000000000000000000000000000000", "hash": "${hash}", "signature": "multisig", } `) }) }) function createOperation(): Multisig.Operation { const now = Date.now() return { account: '0x0000000000000000000000000000000000000001', chainId: tempoDevnet.id, createdAt: now, id: `0x${'22'.repeat(32)}`, payload: `0x${'33'.repeat(32)}`, signatures: [], status: 'pending', transaction: `0x76${'44'.repeat(32)}`, updatedAt: now, } } function hashRawTransaction(params: unknown) { const serialized = Array.isArray(params) ? params[0] : undefined if (typeof serialized !== 'string') throw new Error('Expected raw transaction.') return TxEnvelopeTempo.hash( TxEnvelopeTempo.deserialize(serialized as TxEnvelopeTempo.Serialized) as TxEnvelopeTempo.Signed, ) } function withoutInit(serialized: `0x${string}`) { const transaction = Transaction.deserialize(serialized as never) as Record<string, unknown> const signature = transaction.signature as SignatureEnvelope.Multisig const { signature: _, ...unsigned } = transaction return TxEnvelopeTempo.serialize(TxEnvelopeTempo.from(unsigned as never), { signature: SignatureEnvelope.from({ account: signature.account, signatures: signature.signatures, }), }) }