import { Address, Hex, P256 } from 'ox' import { MultisigConfig, SignatureEnvelope, TxEnvelopeTempo } from 'ox/tempo' import { createClient, custom } from 'viem' import { prepareTransactionRequest, sendTransactionSync, signTransaction } from 'viem/actions' import { Account, Transaction } from 'viem/tempo' import { tempoDevnet } from 'viem/tempo/chains' import * as Relay from '../../../test/Relay.js' import * as Runtime from '../../../test/runtime.js' import * as StateStore from '../../internal/Store.js' 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": "${account.address.toLowerCase()}", } `) }) 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": "${account.address.toLowerCase()}", } `) }) 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": "${account.address.toLowerCase()}", } `) }) 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": "${account.address.toLowerCase()}", "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[] = [] const sponsored: unknown[] = [] 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, onSponsored: (event) => { sponsored.push(event) expect(broadcasted).toHaveLength(0) }, 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), sponsored: sponsored.length, signature: signature.type, }).toMatchInlineSnapshot(` { "feePayerSignature": true, "feeToken": "0x20c0000000000000000000000000000000000000", "hash": "${hash}", "init": true, "signature": "multisig", "sponsored": 1, } `) }) 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", } `) }) }) describe.runIf(Runtime.get().mode === 'localnet' && process.env.TEMPO_IMAGE_TAG === 'sha-8968664')( 'e2e', () => { const owner_1 = Relay.accounts[1]! const owner_2 = Relay.accounts[2]! const feePayer = Relay.accounts[0]! const sponsorFeeToken = Relay.addresses.pathUsd const store = StateStore.memory() let client: ReturnType let server: Relay.Server beforeAll(async () => { server = await Relay.createServer( ( await Relay.relay({ feePayer: { account: feePayer, feeToken: sponsorFeeToken, }, multisig: { store }, }) ).listener, ) client = Relay.getClient({ url: server.url }) }) afterAll(() => { server.close() }) test('behavior: sponsors bootstrap multisig', async () => { const account = Account.fromMultisig({ threshold: 2, owners: [ { owner: owner_1.address, weight: 1 }, { owner: owner_2.address, weight: 1 }, ], }) const transaction = await prepareTransactionRequest(client, { account, feePayer: true, to: account.address, value: 0n, }) const signatures = await Promise.all( [owner_1, owner_2].map((owner) => signTransaction(client, { ...transaction, account: owner }), ), ) const receipt = await sendTransactionSync(client, { ...transaction, account, signatures, }) expect(receipt).toMatchObject({ feePayer: feePayer.address.toLowerCase(), feeToken: sponsorFeeToken.toLowerCase(), from: account.address.toLowerCase(), status: 'success', }) }) }, ) 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 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, }), }) }