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..e77f9a964c
--- /dev/null
+++ b/site/pages/tempo/accounts/account.fromMultisig.mdx
@@ -0,0 +1,102 @@
+# `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.
+It is not yet available on Tempo mainnet or testnet.
+:::
+
+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..f903226f08
--- /dev/null
+++ b/site/pages/tempo/guides/multisig-transactions.mdx
@@ -0,0 +1,213 @@
+---
+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.
+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.
+
+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. // [!code focus]
+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. // [!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 // [!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. // [!code focus]
+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]
+```
+
+:::
+
+### 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. // [!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]
+
+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). // [!code focus]
+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')