Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/tempo-multisig-accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
13 changes: 7 additions & 6 deletions site/pages/tempo/accounts/account.fromMultisig.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -32,21 +32,22 @@ 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 },
{ owner: owner_2.address, weight: 1 },
],
})

// 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
Expand Down
52 changes: 26 additions & 26 deletions site/pages/tempo/guides/multisig-transactions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(
Expand All @@ -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]
```
Expand All @@ -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(
Expand All @@ -107,20 +109,20 @@ 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 },
{ owner: owner_2.address, weight: 1 },
{ 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]
Expand All @@ -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]
})
```
Expand All @@ -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(
Expand All @@ -158,27 +159,26 @@ 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]
const signature = await client.signTransaction({ ...request, account: owner_1 }) // [!code focus]

const hash = await client.sendTransaction({
...request,
account,
signatures: [signature], // [!code focus]
})
```
Expand Down
31 changes: 15 additions & 16 deletions src/chains/definitions/ladyChain.ts
Original file line number Diff line number Diff line change
@@ -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,
})
},
testnet: false,
})
42 changes: 42 additions & 0 deletions src/tempo/Account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
})
})
23 changes: 18 additions & 5 deletions src/tempo/Account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
29 changes: 19 additions & 10 deletions src/tempo/chainConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 & {
Expand Down Expand Up @@ -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 (
Expand Down
Loading