From aa8538108bf2b668053b7370ef3c4f3545984c42 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:43:03 +0200 Subject: [PATCH 1/4] feat(tempo): add native multisig account support Add `Account.fromMultisig` and re-export `MultisigConfig` from `ox/tempo` to support native multisig (TIP-1061) transactions. Prepare a request with `multisig: config`, collect owner approvals via `signTransaction`, then broadcast with the collected `signatures`. First tx auto-bootstraps the account; `init` is derived from nonce 0. Bumps `ox` to 0.14.29. Amp-Thread-ID: https://ampcode.com/threads/T-019e8cab-fd02-709f-9185-a2766192718b --- .changeset/tempo-multisig-accounts.md | 5 + package.json | 2 +- pnpm-lock.yaml | 38 +-- .../tempo/accounts/account.fromMultisig.mdx | 101 ++++++++ site/pages/tempo/accounts/index.mdx | 3 +- .../tempo/guides/multisig-transactions.mdx | 217 +++++++++++++++++ site/pages/tempo/transactions.mdx | 6 + site/vocs.config.ts | 10 + .../wallet/prepareTransactionRequest.ts | 45 +++- src/package.json | 2 +- src/tempo/Account.test.ts | 223 +++++++++++++++++- src/tempo/Account.ts | 88 ++++++- src/tempo/Formatters.ts | 13 +- src/tempo/Transaction.ts | 42 ++++ src/tempo/chainConfig.test-d.ts | 48 ++++ src/tempo/chainConfig.ts | 22 +- src/tempo/index.test.ts | 1 + src/tempo/index.ts | 1 + test/src/tempo/prool.ts | 4 + 19 files changed, 835 insertions(+), 36 deletions(-) create mode 100644 .changeset/tempo-multisig-accounts.md create mode 100644 site/pages/tempo/accounts/account.fromMultisig.mdx create mode 100644 site/pages/tempo/guides/multisig-transactions.mdx diff --git a/.changeset/tempo-multisig-accounts.md b/.changeset/tempo-multisig-accounts.md new file mode 100644 index 0000000000..a5405aeda8 --- /dev/null +++ b/.changeset/tempo-multisig-accounts.md @@ -0,0 +1,5 @@ +--- +"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`. diff --git a/package.json b/package.json index f1812aad8d..e6e5c85b62 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "ethers": "^6.15.0", "knip": "^5.64.0", "micro-eth-signer": "^0.14.0", - "ox": "0.14.27", + "ox": "0.14.29", "permissionless": "^0.2.57", "prool": "~0.2.3", "publint": "^0.2.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7856f35ff5..8d61a55cb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,11 +145,11 @@ importers: specifier: ^0.14.0 version: 0.14.0 ox: - specifier: 0.14.27 - version: 0.14.27(typescript@5.9.3)(zod@4.3.6) + specifier: 0.14.29 + version: 0.14.29(typescript@5.9.3)(zod@4.3.6) permissionless: specifier: ^0.2.57 - version: 0.2.57(ox@0.14.27(typescript@5.9.3)(zod@4.3.6)) + version: 0.2.57(ox@0.14.29(typescript@5.9.3)(zod@4.3.6)) prool: specifier: ~0.2.3 version: 0.2.3(@pimlico/alto@0.0.18(typescript@5.9.3))(testcontainers@11.10.0) @@ -671,8 +671,8 @@ importers: specifier: 1.0.7 version: 1.0.7(ws@8.20.1) ox: - specifier: 0.14.27 - version: 0.14.27(typescript@5.9.3)(zod@4.3.6) + specifier: 0.14.29 + version: 0.14.29(typescript@5.9.3)(zod@4.3.6) ws: specifier: 8.20.1 version: 8.20.1 @@ -5265,16 +5265,16 @@ packages: typescript: optional: true - ox@0.14.25: - resolution: {integrity: sha512-8DoibKtxE8yw63Y2jjMhlbjaURev6WCx4QR4MWLusl2/qIaeTzMJMBIYIDl1KOF45+8H1Ur6eLTdPlUoO8PlRw==} + ox@0.14.27: + resolution: {integrity: sha512-+xhLHo/f+f4BH121/1Pomm/1vgBBda1wYiFpTvjSo8o5OcEj76Pf1hGPJiepoYMTQoTm2SKdSBvWkFWk5l07PA==} peerDependencies: typescript: ^5.9.3 peerDependenciesMeta: typescript: optional: true - ox@0.14.27: - resolution: {integrity: sha512-+xhLHo/f+f4BH121/1Pomm/1vgBBda1wYiFpTvjSo8o5OcEj76Pf1hGPJiepoYMTQoTm2SKdSBvWkFWk5l07PA==} + ox@0.14.29: + resolution: {integrity: sha512-M5j87Ec4V99MQdRct/g09eWXW60g6zhHTUs1lr4deUtrPDnezBdCJTgKd7pxqTpSZBFveV0ALi9jMMuT1qKyNg==} peerDependencies: typescript: ^5.9.3 peerDependenciesMeta: @@ -6456,8 +6456,8 @@ packages: typescript: optional: true - viem@2.51.3: - resolution: {integrity: sha512-DA4EbrsvatzzLo6MwcWWiv6kI6dIr3I9HH9B6qsJaClN/s0AjIDUz5RIxl+VmGrovIUCcIvG8744yuGH7d37zw==} + viem@2.52.0: + resolution: {integrity: sha512-py2QPYe9e1f4DmPJCsXF7zHmyZ0PkJrBxdQZ5dvNXvzy3UzWkUn7dNfC0TMeNm6Qv1tKw3b6qXXExpx6L0oMbw==} peerDependencies: typescript: ^5.9.3 peerDependenciesMeta: @@ -8459,7 +8459,7 @@ snapshots: pino-pretty: 10.3.1 prom-client: 14.2.0 type-fest: 4.39.0 - viem: 2.51.3(typescript@5.9.3)(zod@3.25.76) + viem: 2.52.0(typescript@5.9.3)(zod@3.25.76) yargs: 17.7.2 zod: 3.25.76 zod-validation-error: 1.5.0(zod@3.25.76) @@ -11871,7 +11871,7 @@ snapshots: transitivePeerDependencies: - zod - ox@0.14.25(typescript@5.9.3)(zod@3.25.76): + ox@0.14.27(typescript@5.9.3)(zod@3.25.76): dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -11886,7 +11886,7 @@ snapshots: transitivePeerDependencies: - zod - ox@0.14.27(typescript@5.9.3)(zod@4.3.6): + ox@0.14.29(typescript@5.9.3)(zod@4.3.6): dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -12026,9 +12026,9 @@ snapshots: pend@1.2.0: {} - permissionless@0.2.57(ox@0.14.27(typescript@5.9.3)(zod@4.3.6)): + permissionless@0.2.57(ox@0.14.29(typescript@5.9.3)(zod@4.3.6)): optionalDependencies: - ox: 0.14.27(typescript@5.9.3)(zod@4.3.6) + ox: 0.14.29(typescript@5.9.3)(zod@4.3.6) picocolors@1.1.1: {} @@ -13339,7 +13339,7 @@ snapshots: - utf-8-validate - zod - viem@2.51.3(typescript@5.9.3)(zod@3.25.76): + viem@2.52.0(typescript@5.9.3)(zod@3.25.76): dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 @@ -13347,7 +13347,7 @@ snapshots: '@scure/bip39': 1.6.0 abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) isows: 1.0.7(ws@8.20.1) - ox: 0.14.25(typescript@5.9.3)(zod@3.25.76) + ox: 0.14.27(typescript@5.9.3)(zod@3.25.76) ws: 8.20.1 optionalDependencies: typescript: 5.9.3 @@ -13593,7 +13593,7 @@ snapshots: webauthx@0.1.2(typescript@5.9.3)(zod@4.3.6): dependencies: - ox: 0.14.27(typescript@5.9.3)(zod@4.3.6) + ox: 0.14.29(typescript@5.9.3)(zod@4.3.6) transitivePeerDependencies: - typescript - zod diff --git a/site/pages/tempo/accounts/account.fromMultisig.mdx b/site/pages/tempo/accounts/account.fromMultisig.mdx new file mode 100644 index 0000000000..8f75cefc0b --- /dev/null +++ b/site/pages/tempo/accounts/account.fromMultisig.mdx @@ -0,0 +1,101 @@ +# `Account.fromMultisig` + +Instantiates an Account from a [native multisig](https://docs.tempo.xyz/protocol/transactions) +(`MultisigConfig`) configuration. + +:::warning +**Experimental.** Native multisig support is experimental and may change in a future release. +::: + +The returned account represents the **multisig sender** – its address is derived from the +config. It does not hold a key and cannot sign primitives directly (`sign`, `signMessage`, +`signTypedData` throw). Instead, it is used purely to **broadcast** a multisig transaction: +it drives the standard [`sendTransaction`](/docs/actions/wallet/sendTransaction) flow, +passing the prepared request (carrying the collected owner `signatures`) through to the +chain serializer, which combines the approvals into the multisig signature envelope. + +:::tip +For the full transaction flow (preparing a request, collecting owner approvals, and +broadcasting), see the [Multisig Transactions](/tempo/guides/multisig-transactions) guide. +::: + +## Usage + +```ts twoslash +import { Account, MultisigConfig } from 'viem/tempo' + +const owner_1 = Account.fromSecp256k1( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' +) +const owner_2 = Account.fromSecp256k1( + '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' +) + +// Define the multisig configuration (2-of-2). +const config = MultisigConfig.from({ + 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) +``` + +## Return Type + +The return type is backwards compatible with Viem's `Account` type, with the multisig +`config` attached. + +```ts +type ReturnType = MultisigAccount + +type MultisigAccount = Account & { + /** Account address, derived from the multisig config. */ + address: Address + /** Multisig config (normalized via `MultisigConfig.from`). */ + config: MultisigConfig.Config + /** Account source. */ + source: 'multisig' + /** Account type. */ + type: 'local' +} +``` + +:::warning +`Account.fromMultisig` is for **broadcasting / sender identity** only. Calling `sign`, +`signMessage`, or `signTypedData` on the returned account throws – owner approvals are +produced separately by signing a prepared request with an owner account (see +[Multisig Transactions](/tempo/guides/multisig-transactions)). +::: + +## Parameters + +### config + +- **Type:** `MultisigConfig.Config` + +The multisig configuration, created with `MultisigConfig.from`. The config is normalized +(owners sorted into canonical ascending order) before the address is derived. + +#### config.threshold + +- **Type:** `number` + +The total owner weight required to authorize a transaction. + +#### config.owners + +- **Type:** `readonly { owner: Address; weight: number }[]` + +The list of owners and their voting weights. + +#### config.salt (optional) + +- **Type:** `Hex` + +Optional salt used to derive a distinct multisig address for the same owner set. diff --git a/site/pages/tempo/accounts/index.mdx b/site/pages/tempo/accounts/index.mdx index 1046c932c3..c3f7dad901 100644 --- a/site/pages/tempo/accounts/index.mdx +++ b/site/pages/tempo/accounts/index.mdx @@ -1,6 +1,6 @@ # Accounts -Tempo.ts provides Viem-compatible [Local Accounts](https://viem.sh/docs/accounts/local) that support multiple signature schemes including **secp256k1**, **P256**, and **WebAuthn (passkeys)**. +Tempo.ts provides Viem-compatible [Local Accounts](https://viem.sh/docs/accounts/local) that support multiple signature schemes including **secp256k1**, **P256**, and **WebAuthn (passkeys)**, as well as **native multisig** accounts. These accounts are fully backwards compatible with Viem APIs, meaning you can use them any Viem Action that accepts an `account`. @@ -38,3 +38,4 @@ const hash = await client.sendTransactionSync({ | [`fromWebAuthnP256`](/tempo/accounts/account.fromWebAuthnP256) | Create an account from a WebAuthn credential (passkeys) | | [`fromWebCryptoP256`](/tempo/accounts/account.fromWebCryptoP256) | Create an account from a WebCrypto P256 key pair | | [`fromP256`](/tempo/accounts/account.fromP256) | Create an account from a P256 private key | +| [`fromMultisig`](/tempo/accounts/account.fromMultisig) | Create an account from a native multisig configuration | diff --git a/site/pages/tempo/guides/multisig-transactions.mdx b/site/pages/tempo/guides/multisig-transactions.mdx new file mode 100644 index 0000000000..d90344e3e9 --- /dev/null +++ b/site/pages/tempo/guides/multisig-transactions.mdx @@ -0,0 +1,217 @@ +--- +description: Send a Tempo Transaction from a native multisig account by collecting owner approvals. +--- + +import { Card, Cards } from 'vocs' + +# Multisig Transactions + +:::warning +**Experimental.** Native multisig support is experimental and may change in a future release. +::: + +## 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. + +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`. +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`. + +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. + +[See the Tempo Transactions specification](https://docs.tempo.xyz/protocol/transactions) + +## Recipes + +These recipes assume you have [set up a Tempo client](/tempo). + +### Send a Multisig Transaction + +:::code-group + +```ts twoslash [example.ts] +import { Account, MultisigConfig } from 'viem/tempo' +import { client } from './viem.config' + +const owner_1 = Account.fromSecp256k1( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' +) +const owner_2 = Account.fromSecp256k1( + '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' +) + +// 1. Define the multisig configuration (2-of-2) and derive the account. +const config = MultisigConfig.from({ // [!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. +const request = await client.prepareTransactionRequest({ // [!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 +// explicit `account` wins (the request carries no signing account). +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. +const hash = await client.sendTransaction({ // [!code focus] + ...request, // [!code focus] + account, // [!code focus] + signatures: [signature_1, signature_2], // [!code focus] +}) // [!code focus] +``` + +```ts twoslash [viem.config.ts] filename="viem.config.ts" +// [!include ~/snippets/tempo/viem.config.ts:setup] +``` + +::: + +:::tip +The first transaction from a multisig account automatically bootstraps (registers) it +on-chain – no explicit `init` flag is required. Subsequent transactions are sent the same way. +::: + +### M-of-N Approvals + +A threshold below the owner count means only a subset of owners needs to approve. Below, a +2-of-3 multisig is satisfied by any two owners. + +:::code-group + +```ts twoslash [example.ts] +import { Account, MultisigConfig } from 'viem/tempo' +import { client } from './viem.config' + +const owner_1 = Account.fromSecp256k1( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' +) +const owner_2 = Account.fromSecp256k1( + '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' +) +const owner_3 = Account.fromSecp256k1( + '0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a' +) + +const config = MultisigConfig.from({ + 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({ + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 1n, + multisig: config, +}) + +// Only 2 of the 3 owners need to approve to meet the threshold. +const signature_1 = await client.signTransaction({ ...request, account: owner_1 }) // [!code focus] +const signature_3 = await client.signTransaction({ ...request, account: owner_3 }) // [!code focus] + +const hash = await client.sendTransaction({ + ...request, + account, + signatures: [signature_1, signature_3], // [!code focus] +}) +``` + +```ts twoslash [viem.config.ts] filename="viem.config.ts" +// [!include ~/snippets/tempo/viem.config.ts:setup] +``` + +::: + +### Weighted Approvals + +Owners can be assigned different weights. A single owner whose weight meets the threshold can +authorize alone. + +:::code-group + +```ts twoslash [example.ts] +import { Account, MultisigConfig } from 'viem/tempo' +import { client } from './viem.config' + +const owner_1 = Account.fromSecp256k1( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' +) +const owner_2 = Account.fromSecp256k1( + '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' +) + +const config = MultisigConfig.from({ + 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({ + to: '0xcafebabecafebabecafebabecafebabecafebabe', + value: 1n, + multisig: config, +}) + +// The heavy owner alone satisfies the threshold (weight 2 >= 2). +const signature = await client.signTransaction({ ...request, account: owner_1 }) // [!code focus] + +const hash = await client.sendTransaction({ + ...request, + account, + signatures: [signature], // [!code focus] +}) +``` + +```ts twoslash [viem.config.ts] filename="viem.config.ts" +// [!include ~/snippets/tempo/viem.config.ts:setup] +``` + +::: + +## See More + + + + + + diff --git a/site/pages/tempo/transactions.mdx b/site/pages/tempo/transactions.mdx index f52de7aa5b..eef450997b 100644 --- a/site/pages/tempo/transactions.mdx +++ b/site/pages/tempo/transactions.mdx @@ -388,6 +388,12 @@ export const client = createWalletClient({ description="Cover gas fees on behalf of your users for a gasless experience." to="/tempo/guides/sponsor-fees" /> + & GetTransactionRequestKzgParameter & { chainId?: number | undefined } +/** + * Infers a chain-specific (non-built-in) transaction type from the request + * shape. Returns the custom `type` (e.g. `'tempo'`) only when the request + * uniquely matches a custom member of the chain's formatted request union (i.e. + * it does not also match any built-in member). Built-in chains have no custom + * members, so this resolves to `never` and leaves their inference unchanged. + */ +type ExtractCustomFormattedTransactionType< + chain extends Chain | undefined, + request, + /// + _candidates = UnionOmit, 'from'>, + _matched extends string = _candidates extends object + ? request extends ExactPartial<_candidates> + ? _candidates extends { type?: infer type | undefined } + ? Extract + : never + : never + : never, + _builtin = NonNullable, +> = IsNever> extends true + ? Exclude<_matched, _builtin> + : never + export type PrepareTransactionRequestReturnType< chain extends Chain | undefined = Chain | undefined, account extends Account | undefined = Account | undefined, @@ -156,11 +183,19 @@ export type PrepareTransactionRequestReturnType< accountOverride >, _derivedChain extends Chain | undefined = DeriveChain, - _transactionType = request['type'] extends string | undefined + _customTransactionType extends string = ExtractCustomFormattedTransactionType< + _derivedChain, + request + >, + _transactionType = request['type'] extends string ? request['type'] - : GetTransactionType extends 'legacy' - ? unknown - : GetTransactionType, + : IsNever<_customTransactionType> extends false + ? _customTransactionType + : request['type'] extends string | undefined + ? request['type'] + : GetTransactionType extends 'legacy' + ? unknown + : GetTransactionType, _transactionRequest = ExtractFormattedTransactionRequest< _derivedChain, { type?: _transactionType extends string ? _transactionType : undefined } diff --git a/src/package.json b/src/package.json index 97e46cc93c..dfff6352f2 100644 --- a/src/package.json +++ b/src/package.json @@ -230,7 +230,7 @@ "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", - "ox": "0.14.27", + "ox": "0.14.29", "ws": "8.20.1" }, "license": "MIT", diff --git a/src/tempo/Account.test.ts b/src/tempo/Account.test.ts index d015c6c564..4467920728 100644 --- a/src/tempo/Account.test.ts +++ b/src/tempo/Account.test.ts @@ -3,12 +3,21 @@ import * as Address from 'ox/Address' import * as P256 from 'ox/P256' import * as PublicKey from 'ox/PublicKey' import * as Secp256k1 from 'ox/Secp256k1' -import { Channel, Period, SignatureEnvelope } from 'ox/tempo' +import { Channel, MultisigConfig, Period, SignatureEnvelope } from 'ox/tempo' import { describe, expect, test } from 'vitest' import * as tempo from '~test/tempo/config.js' -import { verifyHash, verifyMessage, verifyTypedData } from '../actions/index.js' -import { parseGwei } from '../utils/index.js' +import { + getTransaction, + prepareTransactionRequest, + sendTransactionSync, + signTransaction, + verifyHash, + verifyMessage, + verifyTypedData, +} from '../actions/index.js' +import { parseGwei, parseUnits } from '../utils/index.js' import * as Account from './Account.js' +import * as Actions from './actions/index.js' const client = tempo.getClient() @@ -1246,3 +1255,211 @@ describe('signKeyAuthorization (standalone)', () => { `) }) }) + +// Native multisig (TIP-1061) runs only on a multisig-capable node. Opt in with +// `VITE_TEMPO_MULTISIG=true` (e.g. a localnet using a multisig-enabled Tempo +// image via `VITE_TEMPO_TAG`) so the default localnet suite is unaffected. +describe.runIf(import.meta.env.VITE_TEMPO_MULTISIG)('multisig', () => { + const { accounts, feeToken } = tempo + + const to = '0x0000000000000000000000000000000000000001' + + test('flat 2-of-2: init + subsequent', async () => { + const owner_1 = accounts[1] + const owner_2 = accounts[2] + const config = MultisigConfig.from({ + threshold: 2, + owners: [ + { owner: owner_1.address, weight: 1 }, + { owner: owner_2.address, weight: 1 }, + ], + }) + const account = Account.fromMultisig(config) + + // Fund the multisig address with the fee token from the genesis-funded + // account (no faucet RPC, so it works on localnet). + await Actions.token.transferSync(client, { + account: accounts[0], + amount: parseUnits('10000', 6), + to: account.address, + token: feeToken, + }) + + // First tx auto-bootstraps (registers) the multisig account: passing + // `multisig: config` on an uninitialized account (nonce 0) attaches `init`. + { + const request = await prepareTransactionRequest(client, { + calls: [ + Actions.token.transfer.call({ amount: 1n, to, token: feeToken }), + ], + feeToken, + multisig: config, + }) + const signatures = await Promise.all( + [owner_1, owner_2].map((owner) => + signTransaction(client, { ...request, account: owner }), + ), + ) + const receipt = await sendTransactionSync(client, { + ...request, + account, + signatures, + }) + expect(receipt.status).toBe('success') + expect(receipt.from).toBe(account.address.toLowerCase()) + + const tx = await getTransaction(client, { hash: receipt.transactionHash }) + expect(tx.signature?.type).toBe('multisig') + expect(tx.nonce).toBe(0) + } + + // Subsequent tx: the account is now registered (nonce 1), so the same + // `multisig: config` is sent as a normal tx (no `init` attached). + { + const request = await prepareTransactionRequest(client, { + calls: [ + Actions.token.transfer.call({ amount: 1n, to, token: feeToken }), + ], + feeToken, + multisig: config, + }) + const signatures = await Promise.all( + [owner_1, owner_2].map((owner) => + signTransaction(client, { ...request, account: owner }), + ), + ) + const receipt = await sendTransactionSync(client, { + ...request, + account, + signatures, + }) + expect(receipt.status).toBe('success') + expect(receipt.from).toBe(account.address.toLowerCase()) + + const tx = await getTransaction(client, { hash: receipt.transactionHash }) + expect(tx.nonce).toBe(1) + } + }) + + test('2-of-3 (M-of-N): threshold subset of owners approves', async () => { + const owner_1 = accounts[3] + const owner_2 = accounts[4] + const owner_3 = accounts[5] + const config = MultisigConfig.from({ + threshold: 2, + owners: [ + { owner: owner_1.address, weight: 1 }, + { owner: owner_2.address, weight: 1 }, + { owner: owner_3.address, weight: 1 }, + ], + }) + const account = Account.fromMultisig(config) + + await Actions.token.transferSync(client, { + account: accounts[0], + amount: parseUnits('10000', 6), + to: account.address, + token: feeToken, + }) + + const request = await prepareTransactionRequest(client, { + calls: [Actions.token.transfer.call({ amount: 1n, to, token: feeToken })], + feeToken, + multisig: config, + }) + // Only 2 of the 3 owners approve. Spread `...request` first so the explicit + // `account` wins (the multisig request carries no signing account). + const signatures = await Promise.all( + [owner_1, owner_3].map((owner) => + signTransaction(client, { ...request, account: owner }), + ), + ) + const receipt = await sendTransactionSync(client, { + ...request, + account, + signatures, + }) + expect(receipt.status).toBe('success') + expect(receipt.from).toBe(account.address.toLowerCase()) + }) + + test('weighted threshold: single heavy owner meets threshold', async () => { + const owner_1 = accounts[6] + const owner_2 = accounts[7] + const config = MultisigConfig.from({ + threshold: 2, + owners: [ + { owner: owner_1.address, weight: 2 }, + { owner: owner_2.address, weight: 1 }, + ], + }) + const account = Account.fromMultisig(config) + + await Actions.token.transferSync(client, { + account: accounts[0], + amount: parseUnits('10000', 6), + to: account.address, + token: feeToken, + }) + + const request = await prepareTransactionRequest(client, { + calls: [Actions.token.transfer.call({ amount: 1n, to, token: feeToken })], + feeToken, + multisig: config, + }) + // The heavy owner alone satisfies the threshold (weight 2 >= 2). + const signature = await signTransaction(client, { + ...request, + account: owner_1, + }) + const receipt = await sendTransactionSync(client, { + ...request, + account, + signatures: [signature], + }) + expect(receipt.status).toBe('success') + expect(receipt.from).toBe(account.address.toLowerCase()) + }) + + test('account hoisted to client: send without explicit `account`', async () => { + const owner_1 = accounts[8] + const owner_2 = accounts[9] + const config = MultisigConfig.from({ + threshold: 2, + owners: [ + { owner: owner_1.address, weight: 1 }, + { owner: owner_2.address, weight: 1 }, + ], + }) + const account = Account.fromMultisig(config) + + // Hoist the multisig account to the client so it's used as the sender + // without passing `account` to `sendTransactionSync`. + const accountClient = tempo.getClient({ account }) + + await Actions.token.transferSync(client, { + account: accounts[0], + amount: parseUnits('10000', 6), + to: account.address, + token: feeToken, + }) + + const request = await prepareTransactionRequest(client, { + calls: [Actions.token.transfer.call({ amount: 1n, to, token: feeToken })], + feeToken, + multisig: config, + }) + const signatures = await Promise.all( + [owner_1, owner_2].map((owner) => + signTransaction(client, { ...request, account: owner }), + ), + ) + // No `account` is passed — the hoisted multisig account is used as sender. + const receipt = await sendTransactionSync(accountClient, { + ...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 62de9fbe1e..74852b2b3f 100644 --- a/src/tempo/Account.ts +++ b/src/tempo/Account.ts @@ -4,7 +4,12 @@ import * as P256 from 'ox/P256' import * as PublicKey from 'ox/PublicKey' import * as Secp256k1 from 'ox/Secp256k1' import * as Signature from 'ox/Signature' -import { Channel, KeyAuthorization, SignatureEnvelope } from 'ox/tempo' +import { + Channel, + KeyAuthorization, + MultisigConfig, + SignatureEnvelope, +} from 'ox/tempo' import * as WebAuthnP256 from 'ox/WebAuthnP256' import * as WebCryptoP256 from 'ox/WebCryptoP256' import type { @@ -269,6 +274,65 @@ export declare namespace fromSecp256k1 { from.ReturnValue } +/** + * Instantiates a synthetic Account for a native multisig (TIP-1061) config. + * + * The returned account does not hold a key. It is used purely to drive the + * standard `sendTransaction` flow: it derives the multisig address from the + * config and passes the prepared request (carrying the collected owner + * `signatures`) through to the chain serializer, which combines the approvals + * into the multisig signature envelope. + * + * Owner approvals are produced separately by signing with `multisig` request + * metadata (see `signTransaction`), and provided here via `signatures`. + * + * @example + * ```ts + * import { Account, MultisigConfig } from 'viem/tempo' + * + * const config = MultisigConfig.from({ ... }) + * const account = Account.fromMultisig(config) + * + * const transaction = await client.sendTransaction({ + * account, + * ...request, + * signatures: [signature_1, signature_2], + * }) + * ``` + * + * @param config Multisig config (from `MultisigConfig.from`). + * @returns Multisig account. + */ +export function fromMultisig(config: MultisigConfig.Config): MultisigAccount { + const normalized = MultisigConfig.from(config) + const address = Address.checksum(MultisigConfig.getAddress(normalized)) + return { + address, + config: normalized, + publicKey: '0x', + source: 'multisig', + type: 'local', + async sign() { + throw new Error('`sign` is not supported for multisig accounts.') + }, + async signMessage() { + throw new Error('`signMessage` is not supported for multisig accounts.') + }, + async signTransaction(transaction, options) { + const { serializer = Transaction.serialize } = options ?? {} + return (await serializer(transaction as never)) as Hex.Hex + }, + async signTypedData() { + throw new Error('`signTypedData` is not supported for multisig accounts.') + }, + } +} + +export type MultisigAccount = LocalAccount<'multisig'> & { + /** Multisig config (from `MultisigConfig.from`). */ + config: MultisigConfig.Config +} + /** * Instantiates an Account from a WebAuthn credential. * @@ -594,9 +658,25 @@ function fromBase(parameters: fromBase.Parameters): Account_base { return { ...transaction, feePayerSignature: null } return transaction })() - const signature = await sign({ - hash: keccak256(await serializer(presign)), - }) + + const payload = keccak256(await serializer(presign)) + + // Native multisig (TIP-1061): return this owner's approval — a serialized + // primitive signature over the multisig owner approval digest — instead of + // a full serialized transaction. Approvals are combined later in + // `sendTransaction({ signatures })`. + const multisig = ( + transaction as { multisig?: MultisigConfig.Config | undefined } + ).multisig + if (multisig) { + const digest = MultisigConfig.getSignPayload({ + payload, + genesisConfig: multisig, + }) + return await sign({ hash: digest, raw: true }) + } + + const signature = await sign({ hash: payload }) const envelope = SignatureEnvelope.from(signature) return await serializer(transaction, envelope as never) }, diff --git a/src/tempo/Formatters.ts b/src/tempo/Formatters.ts index ec9ad5474c..802931a8f9 100644 --- a/src/tempo/Formatters.ts +++ b/src/tempo/Formatters.ts @@ -79,6 +79,8 @@ export function formatTransactionRequest( keyData?: Hex.Hex | undefined keyId?: Address | undefined keyType?: 'p256' | 'secp256k1' | 'webAuthn' | undefined + multisig?: unknown + signatures?: unknown } const account = request.account ? parseAccount(request.account) @@ -116,8 +118,17 @@ export function formatTransactionRequest( if (request.feePayer === true && !request.feePayerSignature) delete request.feeToken + // `multisig` / `signatures` are client-side only (TIP-1061). They drive + // sender derivation, owner signing, and final envelope assembly, but are + // never sent as raw RPC fields — the wire payload is the serialized tx. + const { + multisig: _multisig, + signatures: _signatures, + ...rpcRequest + } = request + const rpc = ox_TransactionRequest.toRpc({ - ...request, + ...rpcRequest, type: 'tempo', } as never) diff --git a/src/tempo/Transaction.ts b/src/tempo/Transaction.ts index eb28c4ee48..066206b17f 100644 --- a/src/tempo/Transaction.ts +++ b/src/tempo/Transaction.ts @@ -7,6 +7,7 @@ import * as Signature from 'ox/Signature' import { type AuthorizationTempo, type KeyAuthorization, + type MultisigConfig, type TransactionReceipt as ox_TransactionReceipt, SignatureEnvelope, type TempoAddress, @@ -124,7 +125,9 @@ export type TransactionRequestTempo< feePayer?: Account | true | undefined feeToken?: TempoAddress.Address | bigint | undefined keyAuthorization?: KeyAuthorization.Signed | undefined + multisig?: MultisigConfig.Config | undefined nonceKey?: 'expiring' | quantity | undefined + signatures?: readonly SignatureEnvelope.Serialized[] | undefined validBefore?: index | undefined validAfter?: index | undefined } @@ -145,8 +148,10 @@ export type TransactionSerializableTempo< feePayerSignature?: viem_Signature | null | undefined from?: Address | undefined keyAuthorization?: KeyAuthorization.Signed | undefined + multisig?: MultisigConfig.Config | undefined nonceKey?: quantity | undefined signature?: SignatureEnvelope.SignatureEnvelope | undefined + signatures?: readonly SignatureEnvelope.Serialized[] | undefined validBefore?: index | undefined validAfter?: index | undefined type?: 'tempo' | undefined @@ -170,12 +175,16 @@ export function getType( if ( (account?.keyType && account.keyType !== 'secp256k1') || account?.source === 'accessKey' || + account?.source === 'multisig' || typeof transaction.calls !== 'undefined' || typeof transaction.feePayer !== 'undefined' || + typeof transaction.feePayerSignature !== 'undefined' || typeof transaction.feeToken !== 'undefined' || typeof transaction.keyAuthorization !== 'undefined' || + typeof transaction.multisig !== 'undefined' || typeof transaction.nonceKey !== 'undefined' || typeof transaction.signature !== 'undefined' || + typeof transaction.signatures !== 'undefined' || typeof transaction.validBefore !== 'undefined' || typeof transaction.validAfter !== 'undefined' ) @@ -337,6 +346,39 @@ async function serializeTempo( // the fee payer. if (shouldStripFeeTokenForSponsorship) delete transaction_ox.feeToken + // Native multisig (TIP-1061): combine the collected owner approvals into the + // multisig signature envelope and serialize the broadcast transaction. The + // approvals are recovered against the multisig owner digest and ordered into + // the strictly-ascending owner address order the node requires. + // + // Bootstrap (`init`) is auto-detected from the nonce: the protocol requires + // (and consumes) nonce `0` for the first transaction from a derived account, + // so `nonce == 0` uniquely identifies a bootstrap. This matches the serialized + // nonce above (a falsy `nonce` serializes as `0`). The owner approval digest + // doesn't commit to `init`, so attaching it here (rather than at owner-signing) + // is safe. + // NOTE: fee-payer + multisig is handled in a later phase. + if (transaction.multisig && transaction.signatures && !feePayer) { + const payload = TxTempo.getSignPayload(TxTempo.from(transaction_ox)) + const signatures = transaction.signatures.map((approval) => + SignatureEnvelope.from(approval), + ) + const sorted = SignatureEnvelope.sortMultisigApprovals({ + payload, + signatures, + genesisConfig: transaction.multisig, + }) + const signature = SignatureEnvelope.from({ + genesisConfig: transaction.multisig, + signatures: sorted, + ...(nonce ? {} : { init: true }), + }) + return TxTempo.serialize(transaction_ox, { + feePayerSignature: undefined, + signature, + }) + } + if (signature && typeof transaction.feePayer === 'object') { const tx = TxTempo.from(transaction_ox, { signature, diff --git a/src/tempo/chainConfig.test-d.ts b/src/tempo/chainConfig.test-d.ts index 3c058cb8d4..8fadc30cc9 100644 --- a/src/tempo/chainConfig.test-d.ts +++ b/src/tempo/chainConfig.test-d.ts @@ -1,3 +1,4 @@ +import { MultisigConfig } from 'ox/tempo' import { expectTypeOf, test } from 'vitest' import { prepareTransactionRequest } from '../actions/wallet/prepareTransactionRequest.js' import { tempoLocalnet } from '../chains/index.js' @@ -23,3 +24,50 @@ test('prepareTransactionRequest preserves tempo transaction type', async () => { expectTypeOf(request_action.type).toEqualTypeOf<'tempo'>() expectTypeOf(request_client.type).toEqualTypeOf<'tempo'>() }) + +test('prepareTransactionRequest defaults to tempo from tempo-only fields', async () => { + const client = createWalletClient({ + account: '0x', + chain: tempoLocalnet, + transport: http(), + }) + + // No explicit `type`: tempo-exclusive fields (`calls`/`feeToken`/`multisig`) + // narrow the inferred type to `'tempo'`. + const request_calls = await prepareTransactionRequest(client, { calls: [] }) + expectTypeOf(request_calls.type).toEqualTypeOf<'tempo'>() + + const request_feeToken = await prepareTransactionRequest(client, { + feeToken: '0x20c0000000000000000000000000000000000000', + }) + expectTypeOf(request_feeToken.type).toEqualTypeOf<'tempo'>() + + const config = MultisigConfig.from({ + threshold: 1, + owners: [ + { owner: '0x0000000000000000000000000000000000000001', weight: 1 }, + ], + }) + const request_multisig = await prepareTransactionRequest(client, { + multisig: config, + }) + expectTypeOf(request_multisig.type).toEqualTypeOf<'tempo'>() +}) + +test('prepareTransactionRequest stays a union when ambiguous', async () => { + const client = createWalletClient({ + account: '0x', + chain: tempoLocalnet, + transport: http(), + }) + + // No tempo-exclusive fields: the request matches both built-in and tempo + // members, so it must NOT be narrowed to `'tempo'`. + const request = await prepareTransactionRequest(client, { + to: '0x0000000000000000000000000000000000000000', + value: 1n, + }) + expectTypeOf(request.type).toEqualTypeOf< + 'legacy' | 'eip2930' | 'eip1559' | 'eip4844' | 'eip7702' | 'tempo' + >() +}) diff --git a/src/tempo/chainConfig.ts b/src/tempo/chainConfig.ts index 169b9620d3..6eb2c6821b 100644 --- a/src/tempo/chainConfig.ts +++ b/src/tempo/chainConfig.ts @@ -1,5 +1,6 @@ +import type { Address } from 'abitype' import * as Hex from 'ox/Hex' -import { SignatureEnvelope, type TokenId } from 'ox/tempo' +import { MultisigConfig, SignatureEnvelope, type TokenId } from 'ox/tempo' import { getCode } from '../actions/public/getCode.js' import { verifyHash } from '../actions/public/verifyHash.js' import { maxUint256 } from '../constants/number.js' @@ -49,6 +50,9 @@ export const chainConfig = { feeToken?: TokenId.TokenIdOrAddress | undefined }) | undefined + from?: Address | undefined + multisig?: MultisigConfig.Config | undefined + signatures?: readonly unknown[] | undefined } // FIXME: node estimates gas with secp256k1 dummy sig + null feePayerSignature. @@ -60,9 +64,25 @@ export const chainConfig = { else if (request.account?.source === 'accessKey') request.gas = (request.gas ?? 0n) + 10_000n } + return request as unknown as typeof r } + // Native multisig (TIP-1061). The transaction sender is the derived + // multisig account, not a signing account (owner accounts only contribute + // 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 + } + if ( !request.keyAuthorization && request.account?.source === 'accessKey' diff --git a/src/tempo/index.test.ts b/src/tempo/index.test.ts index 5c1a653788..b775fd1145 100644 --- a/src/tempo/index.test.ts +++ b/src/tempo/index.test.ts @@ -9,6 +9,7 @@ test('exports tempo', () => { "PublicKey", "Secp256k1", "Channel", + "MultisigConfig", "Period", "ReceivePolicyReceipt", "TempoAddress", diff --git a/src/tempo/index.ts b/src/tempo/index.ts index 20b85c6890..0d5a129997 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -13,6 +13,7 @@ export type { } from 'ox/tempo' export { Channel, + MultisigConfig, Period, ReceivePolicyReceipt, TempoAddress, diff --git a/test/src/tempo/prool.ts b/test/src/tempo/prool.ts index 744a415434..e6413359e5 100644 --- a/test/src/tempo/prool.ts +++ b/test/src/tempo/prool.ts @@ -14,6 +14,10 @@ import { accounts, nodeEnv } from './config.js' export const port = 9545 export const rpcUrl = (() => { + // Explicit override (e.g. a custom devnet) wins over env presets. Useful for + // pointing the suite at a feature devnet without editing chain definitions. + if (import.meta.env.VITE_TEMPO_RPC_URL) + return import.meta.env.VITE_TEMPO_RPC_URL if (import.meta.env.VITE_TEMPO_ENV === 'mainnet') return 'https://rpc.tempo.xyz' if (import.meta.env.VITE_TEMPO_ENV === 'devnet') From 0285b6756fc289e9f74b97ddc7a9002bae8ec198 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:45:26 +0200 Subject: [PATCH 2/4] docs(tempo): note multisig is not yet on mainnet or testnet Amp-Thread-ID: https://ampcode.com/threads/T-019e8cab-fd02-709f-9185-a2766192718b --- site/pages/tempo/accounts/account.fromMultisig.mdx | 1 + site/pages/tempo/guides/multisig-transactions.mdx | 1 + 2 files changed, 2 insertions(+) diff --git a/site/pages/tempo/accounts/account.fromMultisig.mdx b/site/pages/tempo/accounts/account.fromMultisig.mdx index 8f75cefc0b..e77f9a964c 100644 --- a/site/pages/tempo/accounts/account.fromMultisig.mdx +++ b/site/pages/tempo/accounts/account.fromMultisig.mdx @@ -5,6 +5,7 @@ Instantiates an Account from a [native multisig](https://docs.tempo.xyz/protocol :::warning **Experimental.** Native multisig support is experimental and may change in a future release. +It is not yet available on Tempo mainnet or testnet. ::: The returned account represents the **multisig sender** – its address is derived from the diff --git a/site/pages/tempo/guides/multisig-transactions.mdx b/site/pages/tempo/guides/multisig-transactions.mdx index d90344e3e9..6796dea5ac 100644 --- a/site/pages/tempo/guides/multisig-transactions.mdx +++ b/site/pages/tempo/guides/multisig-transactions.mdx @@ -8,6 +8,7 @@ import { Card, Cards } from 'vocs' :::warning **Experimental.** Native multisig support is experimental and may change in a future release. +It is not yet available on Tempo mainnet or testnet. ::: ## Overview From 171f692beb39fccf39047b0acf3d029a7e3b51dd Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:46:36 +0200 Subject: [PATCH 3/4] docs(tempo): focus step comments in multisig guide --- site/pages/tempo/guides/multisig-transactions.mdx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/site/pages/tempo/guides/multisig-transactions.mdx b/site/pages/tempo/guides/multisig-transactions.mdx index 6796dea5ac..665910efb4 100644 --- a/site/pages/tempo/guides/multisig-transactions.mdx +++ b/site/pages/tempo/guides/multisig-transactions.mdx @@ -50,7 +50,7 @@ const owner_2 = Account.fromSecp256k1( '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' ) -// 1. Define the multisig configuration (2-of-2) and derive the account. +// 1. Define the multisig configuration (2-of-2) and derive the account. // [!code focus] const config = MultisigConfig.from({ // [!code focus] threshold: 2, // [!code focus] owners: [ // [!code focus] @@ -60,19 +60,19 @@ const config = MultisigConfig.from({ // [!code focus] }) // [!code focus] const account = Account.fromMultisig(config) // [!code focus] -// 2. Prepare the request, passing the multisig config. +// 2. Prepare the request, passing the multisig config. // [!code focus] const request = await client.prepareTransactionRequest({ // [!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 -// explicit `account` wins (the request carries no signing account). +// 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] 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. +// 4. Broadcast with the multisig account and the collected signatures. // [!code focus] const hash = await client.sendTransaction({ // [!code focus] ...request, // [!code focus] account, // [!code focus] @@ -128,7 +128,7 @@ const request = await client.prepareTransactionRequest({ multisig: config, }) -// Only 2 of the 3 owners need to approve to meet the threshold. +// Only 2 of the 3 owners need to approve to meet the threshold. // [!code focus] const signature_1 = await client.signTransaction({ ...request, account: owner_1 }) // [!code focus] const signature_3 = await client.signTransaction({ ...request, account: owner_3 }) // [!code focus] @@ -178,7 +178,7 @@ const request = await client.prepareTransactionRequest({ multisig: config, }) -// The heavy owner alone satisfies the threshold (weight 2 >= 2). +// 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({ From 679858aa85b0f659f98345b10906f41e2ee088e4 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:47:35 +0200 Subject: [PATCH 4/4] docs(tempo): remove auto-bootstrap tip from multisig guide --- site/pages/tempo/guides/multisig-transactions.mdx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/site/pages/tempo/guides/multisig-transactions.mdx b/site/pages/tempo/guides/multisig-transactions.mdx index 665910efb4..f903226f08 100644 --- a/site/pages/tempo/guides/multisig-transactions.mdx +++ b/site/pages/tempo/guides/multisig-transactions.mdx @@ -86,11 +86,6 @@ const hash = await client.sendTransaction({ // [!code focus] ::: -:::tip -The first transaction from a multisig account automatically bootstraps (registers) it -on-chain – no explicit `init` flag is required. Subsequent transactions are sent the same way. -::: - ### M-of-N Approvals A threshold below the owner count means only a subset of owners needs to approve. Below, a