Skip to content

Commit 4812fd3

Browse files
committed
fix(tempo): derive multisig accounts with create2
1 parent 2f68aa6 commit 4812fd3

8 files changed

Lines changed: 108 additions & 75 deletions

File tree

.changeset/slow-cats-create.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"ox": patch
3+
---
4+
5+
Updated Tempo multisig account derivation to use the canonical CREATE2 recovery address.

src/tempo/KeyAuthorization.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,17 +1112,17 @@ describe('getSignPayload', () => {
11121112
describe('deserialize', () => {
11131113
test('example: matches the frozen multisig witness vector', () => {
11141114
const serialized =
1115-
'0xf8f1f85382107980941eff47bc3a10a45d4b230b5d10e37751fe6aa718808080a0535353535353535353535353535353535353535353535353535353535353535380949dba7f426b711d4893c11611eacf7cc334e7146bb89a05f897949dba7f426b711d4893c11611eacf7cc334e7146bf83ba000000000000000000000000000000000000000000000000000000000000000008001d7d6947e5f4552091a69125d5dfcb7b8c2659029395bdf01f843b8412cfed9350faf80e4115e3c4c967356b80a8e6ee42e4a49ab953c8c8a3537d8d8359bf035a9bea18f32b0da7129cdce3ce701abc09ad8919b93da21a0d57e21451b' as const
1115+
'0xf8f1f85382107980941eff47bc3a10a45d4b230b5d10e37751fe6aa718808080a053535353535353535353535353535353535353535353535353535353535353538094c4a590afa7337e5cd5eb3aa60cacf91c5400044bb89a05f89794c4a590afa7337e5cd5eb3aa60cacf91c5400044bf83ba000000000000000000000000000000000000000000000000000000000000000008001d7d6947e5f4552091a69125d5dfcb7b8c2659029395bdf01f843b8412cfed9350faf80e4115e3c4c967356b80a8e6ee42e4a49ab953c8c8a3537d8d8359bf035a9bea18f32b0da7129cdce3ce701abc09ad8919b93da21a0d57e21451b' as const
11161116
const authorization = KeyAuthorization.deserialize(serialized)
11171117

11181118
expect(authorization).toMatchInlineSnapshot(`
11191119
{
1120-
"account": "0x9dba7f426b711d4893c11611eacf7cc334e7146b",
1120+
"account": "0xc4a590afa7337e5cd5eb3aa60cacf91c5400044b",
11211121
"address": "0x1eff47bc3a10a45d4b230b5d10e37751fe6aa718",
11221122
"chainId": 4217n,
11231123
"isAdmin": false,
11241124
"signature": {
1125-
"account": "0x9dba7f426b711d4893c11611eacf7cc334e7146b",
1125+
"account": "0xc4a590afa7337e5cd5eb3aa60cacf91c5400044b",
11261126
"config": {
11271127
"owners": [
11281128
{

src/tempo/MultisigConfig.test.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,11 @@ describe('fromRpc/toRpc', () => {
9898
})
9999

100100
describe('getAddress', () => {
101-
test('example: matches the frozen version-0 vector', () => {
101+
test('example: matches the frozen CREATE2 vector', () => {
102102
expect(account).toMatchInlineSnapshot(
103-
`"0x8820d1497eeaf4f68e00b2cfc00a2f3b1dbb00da"`,
103+
`"0xf4b916c5aea0fb199bd942389be00db0690c961f"`,
104104
)
105+
expect(account).not.toBe('0x8820d1497eeaf4f68e00b2cfc00a2f3b1dbb00da')
105106
})
106107

107108
test('behavior: includes salt, threshold, and owners', () => {
@@ -114,7 +115,22 @@ describe('getAddress', () => {
114115
salt: `0x${'42'.repeat(32)}`,
115116
threshold: 2,
116117
}),
117-
).toMatchInlineSnapshot(`"0x0773e28146400643e42cb28f6659b74e7c0b451d"`)
118+
).toMatchInlineSnapshot(`"0x94040edd3d7b542e0a96e01141bc250d709b4469"`)
119+
})
120+
121+
test('behavior: is stable and binds the salt', () => {
122+
expect({
123+
repeated: MultisigConfig.getAddress(config),
124+
salted: MultisigConfig.getAddress({
125+
...config,
126+
salt: `0x${'42'.repeat(32)}`,
127+
}),
128+
}).toMatchInlineSnapshot(`
129+
{
130+
"repeated": "0xf4b916c5aea0fb199bd942389be00db0690c961f",
131+
"salted": "0x95e771f514fd6ac5b8bbd62a9b37db86eeed7e38",
132+
}
133+
`)
118134
})
119135

120136
test('error: rejects a current configuration', () => {
@@ -151,7 +167,7 @@ describe('getSignPayload', () => {
151167
expect(
152168
MultisigConfig.getSignPayload({ account, config, payload }),
153169
).toMatchInlineSnapshot(
154-
`"0xbf944a7a752b2cfab0418d5fb4591c5a7ff62976488edce11794d7f35fb34f41"`,
170+
`"0xdba6b49849aaef399fbc1de73fe26d520f21846c80c3e5d2486ad56e8df3cee3"`,
155171
)
156172
})
157173

@@ -213,7 +229,7 @@ describe('assert/validate', () => {
213229
valid: MultisigConfig.validate(boundary),
214230
}).toMatchInlineSnapshot(`
215231
{
216-
"account": "0x6c67c57e0eed05341137dbf88e4e7a90dc46ef50",
232+
"account": "0xa832c9a61d254a157c05edcd856b2cbe4dea8c77",
217233
"commitment": "0x0dc47a7ab45ffa21a01bfd115427e26617b5a57d7ccbea57db2fd4537ba96f56",
218234
"rlpHash": "0xbaf0d030add91caaa10815d2e99c942f1e39b0d199216973781adb4fc1af6955",
219235
"rlpLength": 1145,

src/tempo/MultisigConfig.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as Address from '../core/Address.js'
22
import type * as Bytes from '../core/Bytes.js'
3+
import * as ContractAddress from '../core/ContractAddress.js'
34
import * as Errors from '../core/Errors.js'
45
import * as Hash from '../core/Hash.js'
56
import * as Hex from '../core/Hex.js'
@@ -38,6 +39,13 @@ const accountDomain = 'tempo:multisig:account'
3839
/** Domain prefix for native multisig configuration commitments. */
3940
const configDomain = 'tempo:multisig:config'
4041

42+
/** Canonical CREATE2 factory for multisig recovery wallets. */
43+
const recoveryFactory = '0x8a196A227C48Ae8A3E36EebD4E106675CC0f6E64'
44+
45+
/** Keccak-256 of the canonical recovery wallet creation code. */
46+
const recoveryWalletInitCodeHash =
47+
'0x4b5ff53c5328a10a6ec5224adf16de5e204a47057c98af037ee30b7de660a8a6'
48+
4149
/** Domain prefix for native multisig owner approvals. */
4250
const signatureDomain = 'tempo:multisig:signature'
4351

@@ -321,8 +329,9 @@ export function fromTuple(tuple: Tuple): Config {
321329
/**
322330
* Derives the stable native multisig account address.
323331
*
324-
* Preimage (fixed-width big-endian, **not** RLP):
325-
* `keccak256("tempo:multisig:account" || salt || u8(threshold) || u8(owners.length) || (owner || u8(weight)) for each owner)[12:32]`.
332+
* The initial config is hashed into a CREATE2 salt using fixed-width
333+
* big-endian fields, not RLP. The account uses the canonical recovery factory
334+
* and wallet init-code hash.
326335
*
327336
* The address is derived once from the initial version-0 config. Config
328337
* updates do not change it.
@@ -353,7 +362,7 @@ export function getAddress(config: Input): Address.Address {
353362
throw new InvalidConfigError({
354363
reason: 'account address requires version zero',
355364
})
356-
const hash = Hash.keccak256(
365+
const accountSalt = Hash.keccak256(
357366
Hex.concat(
358367
Hex.fromString(accountDomain),
359368
Hex.padLeft(config.salt ?? zeroSalt, 32),
@@ -365,7 +374,11 @@ export function getAddress(config: Input): Address.Address {
365374
]),
366375
),
367376
)
368-
const account = Address.from(Hex.slice(hash, 12, 32))
377+
const account = ContractAddress.fromCreate2({
378+
bytecodeHash: recoveryWalletInitCodeHash,
379+
from: recoveryFactory,
380+
salt: accountSalt,
381+
})
369382
if (Hex.toBigInt(account) === 0n)
370383
throw new InvalidConfigError({ reason: 'derived account cannot be zero' })
371384
if (config.owners.some((owner) => Address.isEqual(owner.owner, account)))
@@ -378,12 +391,11 @@ export function getAddress(config: Input): Address.Address {
378391
export declare namespace getAddress {
379392
type ErrorType =
380393
| assert.ErrorType
381-
| Address.from.ErrorType
394+
| ContractAddress.fromCreate2.ErrorType
382395
| Hash.keccak256.ErrorType
383396
| Hex.concat.ErrorType
384397
| Hex.fromNumber.ErrorType
385398
| Hex.fromString.ErrorType
386-
| Hex.slice.ErrorType
387399
| Errors.GlobalErrorType
388400
}
389401

0 commit comments

Comments
 (0)