diff --git a/.changeset/tempo-multisig-accounts.md b/.changeset/tempo-multisig-accounts.md index a5405aeda8..f56f81cbdf 100644 --- a/.changeset/tempo-multisig-accounts.md +++ b/.changeset/tempo-multisig-accounts.md @@ -2,4 +2,4 @@ "viem": minor --- -**Tempo:** Added experimental native multisig account support. Use `Account.fromMultisig` (and the re-exported `MultisigConfig` from `ox/tempo`) to derive a multisig sender, prepare a transaction with `multisig: config`, collect owner approvals via `signTransaction`, and broadcast with the collected `signatures`. +**Tempo:** Added experimental native multisig account support. Use `Account.fromMultisig` (and the re-exported `MultisigConfig` from `ox/tempo`) to derive a multisig sender, prepare a transaction by passing the multisig `account` to `prepareTransactionRequest` (the config is inferred from it), collect owner approvals via `signTransaction`, and broadcast with the collected `signatures`. diff --git a/site/pages/tempo/accounts/account.fromMultisig.mdx b/site/pages/tempo/accounts/account.fromMultisig.mdx index e77f9a964c..75cc108780 100644 --- a/site/pages/tempo/accounts/account.fromMultisig.mdx +++ b/site/pages/tempo/accounts/account.fromMultisig.mdx @@ -23,7 +23,7 @@ broadcasting), see the [Multisig Transactions](/tempo/guides/multisig-transactio ## Usage ```ts twoslash -import { Account, MultisigConfig } from 'viem/tempo' +import { Account } from 'viem/tempo' const owner_1 = Account.fromSecp256k1( '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' @@ -32,8 +32,8 @@ const owner_2 = Account.fromSecp256k1( '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' ) -// Define the multisig configuration (2-of-2). -const config = MultisigConfig.from({ +// Instantiate the multisig account (2-of-2) from its owners and threshold. +const account = Account.fromMultisig({ threshold: 2, owners: [ { owner: owner_1.address, weight: 1 }, @@ -41,12 +41,13 @@ const config = MultisigConfig.from({ ], }) -// Instantiate the multisig account. -const account = Account.fromMultisig(config) - console.log('Address:', account.address) ``` +`Account.fromMultisig` accepts a raw config and normalizes it internally (via +`MultisigConfig.from`), so you don't need to call `MultisigConfig.from` yourself. You can +still pass a pre-built `MultisigConfig.Config` if you have one. + ## Return Type The return type is backwards compatible with Viem's `Account` type, with the multisig diff --git a/site/pages/tempo/guides/multisig-transactions.mdx b/site/pages/tempo/guides/multisig-transactions.mdx index f903226f08..b70bf6f5b0 100644 --- a/site/pages/tempo/guides/multisig-transactions.mdx +++ b/site/pages/tempo/guides/multisig-transactions.mdx @@ -14,17 +14,19 @@ It is not yet available on Tempo mainnet or testnet. ## Overview Tempo supports **native multisig** accounts ([TIP-1061](https://docs.tempo.xyz/protocol/transactions)). -A multisig account is defined by a [`MultisigConfig`](/tempo/accounts/account.fromMultisig) – -a set of owners with voting weights and a threshold. A transaction from the multisig is -authorized when owner approvals collectively meet the threshold. +A multisig account is defined by a set of owners with voting weights and a threshold. A +transaction from the multisig is authorized when owner approvals collectively meet the +threshold. With Viem, the flow is: -1. Define the config with `MultisigConfig.from` and derive the account with - [`Account.fromMultisig`](/tempo/accounts/account.fromMultisig). -2. Prepare the transaction request, passing `multisig: config`. +1. Derive the account with [`Account.fromMultisig`](/tempo/accounts/account.fromMultisig), + passing the owners and threshold. +2. Prepare the transaction request, passing the multisig `account` (the config is inferred + from it). 3. Each owner signs the prepared request to produce an **owner approval** signature. -4. Broadcast the transaction with the multisig account and the collected `signatures`. +4. Broadcast the transaction with the collected `signatures` (the prepared request already + carries the multisig account as sender). The first transaction from a multisig account **auto-bootstraps** (registers) it on-chain – you don't need to pass an explicit `init` flag. Subsequent transactions are sent normally. @@ -40,7 +42,7 @@ These recipes assume you have [set up a Tempo client](/tempo). :::code-group ```ts twoslash [example.ts] -import { Account, MultisigConfig } from 'viem/tempo' +import { Account } from 'viem/tempo' import { client } from './viem.config' const owner_1 = Account.fromSecp256k1( @@ -50,32 +52,32 @@ const owner_2 = Account.fromSecp256k1( '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' ) -// 1. Define the multisig configuration (2-of-2) and derive the account. // [!code focus] -const config = MultisigConfig.from({ // [!code focus] +// 1. Derive the multisig account (2-of-2) from its owners and threshold. // [!code focus] +const account = Account.fromMultisig({ // [!code focus] threshold: 2, // [!code focus] owners: [ // [!code focus] { owner: owner_1.address, weight: 1 }, // [!code focus] { owner: owner_2.address, weight: 1 }, // [!code focus] ], // [!code focus] }) // [!code focus] -const account = Account.fromMultisig(config) // [!code focus] -// 2. Prepare the request, passing the multisig config. // [!code focus] +// 2. Prepare the request, passing the multisig account. // [!code focus] const request = await client.prepareTransactionRequest({ // [!code focus] + account, // [!code focus] + feeToken: '0x20c0000000000000000000000000000000000001', // [!code focus] to: '0xcafebabecafebabecafebabecafebabecafebabe', // [!code focus] value: 1n, // [!code focus] - multisig: config, // [!code focus] }) // [!code focus] // 3. Each owner approves the prepared request. Spread `...request` first so the // [!code focus] -// explicit `account` wins (the request carries no signing account). // [!code focus] +// explicit `account` wins (the owner signs over the multisig request). // [!code focus] const signature_1 = await client.signTransaction({ ...request, account: owner_1 }) // [!code focus] const signature_2 = await client.signTransaction({ ...request, account: owner_2 }) // [!code focus] -// 4. Broadcast with the multisig account and the collected signatures. // [!code focus] +// 4. Broadcast with the collected signatures. The prepared `request` already // [!code focus] +// carries the multisig account as sender, so it doesn't need re-passing. // [!code focus] const hash = await client.sendTransaction({ // [!code focus] ...request, // [!code focus] - account, // [!code focus] signatures: [signature_1, signature_2], // [!code focus] }) // [!code focus] ``` @@ -94,7 +96,7 @@ A threshold below the owner count means only a subset of owners needs to approve :::code-group ```ts twoslash [example.ts] -import { Account, MultisigConfig } from 'viem/tempo' +import { Account } from 'viem/tempo' import { client } from './viem.config' const owner_1 = Account.fromSecp256k1( @@ -107,7 +109,7 @@ const owner_3 = Account.fromSecp256k1( '0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a' ) -const config = MultisigConfig.from({ +const account = Account.fromMultisig({ threshold: 2, // [!code focus] owners: [ { owner: owner_1.address, weight: 1 }, @@ -115,12 +117,12 @@ const config = MultisigConfig.from({ { owner: owner_3.address, weight: 1 }, ], }) -const account = Account.fromMultisig(config) const request = await client.prepareTransactionRequest({ + account, + feeToken: '0x20c0000000000000000000000000000000000001', to: '0xcafebabecafebabecafebabecafebabecafebabe', value: 1n, - multisig: config, }) // Only 2 of the 3 owners need to approve to meet the threshold. // [!code focus] @@ -129,7 +131,6 @@ const signature_3 = await client.signTransaction({ ...request, account: owner_3 const hash = await client.sendTransaction({ ...request, - account, signatures: [signature_1, signature_3], // [!code focus] }) ``` @@ -148,7 +149,7 @@ authorize alone. :::code-group ```ts twoslash [example.ts] -import { Account, MultisigConfig } from 'viem/tempo' +import { Account } from 'viem/tempo' import { client } from './viem.config' const owner_1 = Account.fromSecp256k1( @@ -158,19 +159,19 @@ const owner_2 = Account.fromSecp256k1( '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' ) -const config = MultisigConfig.from({ +const account = Account.fromMultisig({ threshold: 2, owners: [ { owner: owner_1.address, weight: 2 }, // [!code focus] { owner: owner_2.address, weight: 1 }, // [!code focus] ], }) -const account = Account.fromMultisig(config) const request = await client.prepareTransactionRequest({ + account, + feeToken: '0x20c0000000000000000000000000000000000001', to: '0xcafebabecafebabecafebabecafebabecafebabe', value: 1n, - multisig: config, }) // The heavy owner alone satisfies the threshold (weight 2 >= 2). // [!code focus] @@ -178,7 +179,6 @@ const signature = await client.signTransaction({ ...request, account: owner_1 }) const hash = await client.sendTransaction({ ...request, - account, signatures: [signature], // [!code focus] }) ``` diff --git a/src/chains/definitions/ladyChain.ts b/src/chains/definitions/ladyChain.ts index 3825c2b41a..edccccd1f0 100644 --- a/src/chains/definitions/ladyChain.ts +++ b/src/chains/definitions/ladyChain.ts @@ -1,20 +1,19 @@ import { defineChain } from '../../utils/chain/defineChain.js' - export const ladyChain = /*#__PURE__*/ defineChain({ - id: 589, - name: 'LadyChain', - nativeCurrency: { name: 'Lady', symbol: 'LADY', decimals: 18 }, - rpcUrls: { - default: { - http: ['https://ladyrpc.us/rpc'], - }, +export const ladyChain = /*#__PURE__*/ defineChain({ + id: 589, + name: 'LadyChain', + nativeCurrency: { name: 'Lady', symbol: 'LADY', decimals: 18 }, + rpcUrls: { + default: { + http: ['https://ladyrpc.us/rpc'], }, - blockExplorers: { - default: { - name: 'LadyScan', - url: 'https://ladyscan.us', - }, + }, + blockExplorers: { + default: { + name: 'LadyScan', + url: 'https://ladyscan.us', }, - testnet: false, - }) - \ No newline at end of file + }, + testnet: false, +}) diff --git a/src/tempo/Account.test.ts b/src/tempo/Account.test.ts index 4467920728..daa38930c7 100644 --- a/src/tempo/Account.test.ts +++ b/src/tempo/Account.test.ts @@ -1462,4 +1462,46 @@ describe.runIf(import.meta.env.VITE_TEMPO_MULTISIG)('multisig', () => { expect(receipt.status).toBe('success') expect(receipt.from).toBe(account.address.toLowerCase()) }) + + test('infer multisig from `account` (no `multisig` field)', async () => { + const owner_1 = accounts[10] + const owner_2 = accounts[11] + // Build the account straight from a raw config — `fromMultisig` normalizes + // it via `MultisigConfig.from` internally. + const account = Account.fromMultisig({ + threshold: 2, + owners: [ + { owner: owner_1.address, weight: 1 }, + { owner: owner_2.address, weight: 1 }, + ], + }) + + await Actions.token.transferSync(client, { + account: accounts[0], + amount: parseUnits('10000', 6), + to: account.address, + token: feeToken, + }) + + // Pass the multisig `account` to `prepareTransactionRequest` — the multisig + // config is inferred from it, so no `multisig` field is needed. + const request = await prepareTransactionRequest(client, { + account, + calls: [Actions.token.transfer.call({ amount: 1n, to, token: feeToken })], + feeToken, + }) + // The prepared request carries the multisig account as sender, so `...request` + // is enough — no need to re-pass `account` to `sendTransaction`. + const signatures = await Promise.all( + [owner_1, owner_2].map((owner) => + signTransaction(client, { ...request, account: owner }), + ), + ) + const receipt = await sendTransactionSync(client, { + ...request, + signatures, + }) + expect(receipt.status).toBe('success') + expect(receipt.from).toBe(account.address.toLowerCase()) + }) }) diff --git a/src/tempo/Account.ts b/src/tempo/Account.ts index 74852b2b3f..ab4be993c2 100644 --- a/src/tempo/Account.ts +++ b/src/tempo/Account.ts @@ -286,21 +286,34 @@ export declare namespace fromSecp256k1 { * Owner approvals are produced separately by signing with `multisig` request * metadata (see `signTransaction`), and provided here via `signatures`. * + * Accepts a raw config and normalizes it internally (via `MultisigConfig.from`), + * so callers don't need to call `MultisigConfig.from` themselves. + * * @example * ```ts - * import { Account, MultisigConfig } from 'viem/tempo' + * import { Account } from 'viem/tempo' + * + * const account = Account.fromMultisig({ + * threshold: 2, + * owners: [ + * { owner: owner_1.address, weight: 1 }, + * { owner: owner_2.address, weight: 1 }, + * ], + * }) * - * const config = MultisigConfig.from({ ... }) - * const account = Account.fromMultisig(config) + * // Pass the account to `prepareTransactionRequest` — the multisig config is + * // inferred from it, so no `multisig` field is needed. + * const request = await client.prepareTransactionRequest({ account, ...rest }) * + * // The prepared request carries the multisig account as sender, so it doesn't + * // need to be re-passed to `sendTransaction`. * const transaction = await client.sendTransaction({ - * account, * ...request, * signatures: [signature_1, signature_2], * }) * ``` * - * @param config Multisig config (from `MultisigConfig.from`). + * @param config Multisig config (raw or from `MultisigConfig.from`). * @returns Multisig account. */ export function fromMultisig(config: MultisigConfig.Config): MultisigAccount { diff --git a/src/tempo/chainConfig.ts b/src/tempo/chainConfig.ts index 6eb2c6821b..06570dd315 100644 --- a/src/tempo/chainConfig.ts +++ b/src/tempo/chainConfig.ts @@ -13,7 +13,7 @@ import { defineTransactionRequest } from '../utils/formatters/transactionRequest import { getAction } from '../utils/getAction.js' import { keccak256 } from '../utils/hash/keccak256.js' import type { SerializeTransactionFn } from '../utils/transaction/serializeTransaction.js' -import type { Account } from './Account.js' +import type { Account, MultisigAccount } from './Account.js' import { getMetadata } from './actions/accessKey.js' import * as Formatters from './Formatters.js' import type { Hardfork } from './Hardfork.js' @@ -43,7 +43,7 @@ export const chainConfig = { prepareTransactionRequest: [ async (r, { client, phase }) => { const request = r as Transaction.TransactionRequest & { - account?: Account | undefined + account?: Account | MultisigAccount | undefined chainId?: number | undefined chain?: | (Chain & { @@ -73,14 +73,23 @@ export const chainConfig = { // approvals later via `signTransaction`). Derive the sender from the // config; core fills nonce/gas/fees for it via `request.from`, and the // serializer auto-detects bootstrap (`init`) from `nonce == 0`. - if (request.multisig) { - request.from = MultisigConfig.getAddress(request.multisig) - // The sender is the config-derived multisig address (`request.from`), - // not a signing account. Drop any `account` (e.g. the client's default) - // so core's `prepareTransactionRequest` fills nonce/gas/fees for the - // multisig sender rather than the account, and so the prepared request - // doesn't surface a sender account to callers. - delete request.account + // + // The config is taken from an explicit `multisig` field, or inferred from + // a multisig account (so callers can just pass `account` to + // `prepareTransactionRequest` without also passing `multisig`). + const multisig = + request.multisig ?? + (request.account?.source === 'multisig' + ? (request.account as MultisigAccount).config + : undefined) + if (multisig) { + request.multisig = multisig + request.from = MultisigConfig.getAddress(multisig) + // A non-multisig `account` (e.g. the client's default) isn't the sender, + // so drop it: core then fills nonce/gas/fees for the multisig sender via + // `request.from`. A multisig account *is* the sender — keep it so the + // prepared request can be sent without re-passing `account`. + if (request.account?.source !== 'multisig') delete request.account } if (