ox 0.14.28 → 0.14.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,31 +30,51 @@ describe('from', () => {
30
30
  })
31
31
  })
32
32
 
33
- describe('configId', () => {
33
+ describe('getAddress', () => {
34
34
  test('matches independent ground truth', () => {
35
- expect(MultisigConfig.toId(singleOwnerConfig)).toMatchInlineSnapshot(
36
- `"0xd1f20e1a5bfdd89488f57f68db5bd1aae9a51b510f4a042b2604b57a0b7b471d"`,
35
+ expect(MultisigConfig.getAddress(singleOwnerConfig)).toMatchInlineSnapshot(
36
+ `"0x8820d1497eeaf4f68e00b2cfc00a2f3b1dbb00da"`,
37
37
  )
38
38
  })
39
39
 
40
+ test('matches independent ground truth (salt + weights)', () => {
41
+ expect(
42
+ MultisigConfig.getAddress({
43
+ salt: `0x${'42'.repeat(32)}`,
44
+ threshold: 2,
45
+ owners: [
46
+ { owner: owner1, weight: 1 },
47
+ { owner: owner2, weight: 2 },
48
+ ],
49
+ }),
50
+ ).toMatchInlineSnapshot(`"0x0773e28146400643e42cb28f6659b74e7c0b451d"`)
51
+ })
52
+
40
53
  test('is stable across calls', () => {
41
- expect(MultisigConfig.toId(singleOwnerConfig)).toBe(
42
- MultisigConfig.toId(singleOwnerConfig),
54
+ expect(MultisigConfig.getAddress(singleOwnerConfig)).toBe(
55
+ MultisigConfig.getAddress(singleOwnerConfig),
43
56
  )
44
57
  })
45
58
 
46
59
  test('differs for a different salt', () => {
47
- expect(MultisigConfig.toId(singleOwnerConfig)).not.toBe(
48
- MultisigConfig.toId({
60
+ expect(MultisigConfig.getAddress(singleOwnerConfig)).not.toBe(
61
+ MultisigConfig.getAddress({
49
62
  ...singleOwnerConfig,
50
63
  salt: `0x${'42'.repeat(32)}`,
51
64
  }),
52
65
  )
53
66
  })
54
67
 
68
+ test('address is chain-independent', () => {
69
+ // Derivation does not include chain ID; identical config → identical address.
70
+ const a = MultisigConfig.getAddress(singleOwnerConfig)
71
+ const b = MultisigConfig.getAddress(MultisigConfig.from(singleOwnerConfig))
72
+ expect(a).toBe(b)
73
+ })
74
+
55
75
  test('throws on invalid config', () => {
56
76
  expect(() =>
57
- MultisigConfig.toId({
77
+ MultisigConfig.getAddress({
58
78
  threshold: 5,
59
79
  owners: singleOwnerConfig.owners,
60
80
  }),
@@ -62,41 +82,27 @@ describe('configId', () => {
62
82
  })
63
83
  })
64
84
 
65
- describe('getAddress', () => {
85
+ describe('getSignPayload', () => {
66
86
  test('matches independent ground truth', () => {
67
87
  expect(
68
- MultisigConfig.getAddress({ config: singleOwnerConfig }),
69
- ).toMatchInlineSnapshot(`"0x6ca655065b1de473d903eebd50e5cb4996e10468"`)
70
- })
71
-
72
- test('derives from config or configId identically', () => {
73
- const configId = MultisigConfig.toId(singleOwnerConfig)
74
- expect(MultisigConfig.getAddress({ configId })).toBe(
75
- MultisigConfig.getAddress({ config: singleOwnerConfig }),
88
+ MultisigConfig.getSignPayload({
89
+ payload: `0x${'de'.repeat(32)}`,
90
+ genesisConfig: singleOwnerConfig,
91
+ }),
92
+ ).toMatchInlineSnapshot(
93
+ `"0x7df8cb9fef4fc2aeb271b617d1a4c6178b720ae1d7564f48a363069b7d77a079"`,
76
94
  )
77
95
  })
78
96
 
79
- test('config ID and address are chain-independent', () => {
80
- // Derivation does not include chain ID; identical config → identical id/address.
81
- const a = MultisigConfig.toId(singleOwnerConfig)
82
- const b = MultisigConfig.toId(MultisigConfig.from(singleOwnerConfig))
83
- expect(a).toBe(b)
84
- })
85
- })
86
-
87
- describe('getSignPayload', () => {
88
- test('matches independent ground truth', () => {
89
- const configId = MultisigConfig.toId(singleOwnerConfig)
90
- const account = MultisigConfig.getAddress({ configId })
97
+ test('behavior: `genesisConfig` and `{ account }` produce identical digests', () => {
98
+ const account = MultisigConfig.getAddress(singleOwnerConfig)
99
+ const payload = `0x${'42'.repeat(32)}` as const
91
100
  expect(
92
101
  MultisigConfig.getSignPayload({
93
- payload: `0x${'42'.repeat(32)}`,
94
- account,
95
- configId,
102
+ payload,
103
+ genesisConfig: singleOwnerConfig,
96
104
  }),
97
- ).toMatchInlineSnapshot(
98
- `"0xe3d66f6118b89a67c71c8137c46abf0c829056a46ee6a038a1b42c84529fc17e"`,
99
- )
105
+ ).toBe(MultisigConfig.getSignPayload({ payload, account }))
100
106
  })
101
107
  })
102
108
 
@@ -143,8 +149,16 @@ describe('assert / validate', () => {
143
149
  expect(MultisigConfig.validate({ threshold: 1, owners: [] })).toBe(false)
144
150
  })
145
151
 
152
+ test('accepts 255 owners', () => {
153
+ const owners = Array.from({ length: 255 }, (_, i) => ({
154
+ owner: `0x${(i + 1).toString(16).padStart(40, '0')}` as `0x${string}`,
155
+ weight: 1,
156
+ }))
157
+ expect(MultisigConfig.validate({ threshold: 255, owners })).toBe(true)
158
+ })
159
+
146
160
  test('too many owners', () => {
147
- const owners = Array.from({ length: 11 }, (_, i) => ({
161
+ const owners = Array.from({ length: 256 }, (_, i) => ({
148
162
  owner: `0x${(i + 1).toString(16).padStart(40, '0')}` as `0x${string}`,
149
163
  weight: 1,
150
164
  }))
@@ -169,6 +183,18 @@ describe('assert / validate', () => {
169
183
  ).toBe(false)
170
184
  })
171
185
 
186
+ test('total weight exceeds u8 max', () => {
187
+ expect(
188
+ MultisigConfig.validate({
189
+ threshold: 255,
190
+ owners: [
191
+ { owner: owner1, weight: 128 },
192
+ { owner: owner2, weight: 128 },
193
+ ],
194
+ }),
195
+ ).toBe(false)
196
+ })
197
+
172
198
  test('zero owner weight', () => {
173
199
  expect(
174
200
  MultisigConfig.validate({
@@ -3,10 +3,16 @@ import type * as Bytes from '../core/Bytes.js'
3
3
  import * as Errors from '../core/Errors.js'
4
4
  import * as Hash from '../core/Hash.js'
5
5
  import * as Hex from '../core/Hex.js'
6
- import type { Compute } from '../core/internal/types.js'
6
+ import type { Compute, OneOf } from '../core/internal/types.js'
7
7
 
8
8
  /** Maximum number of owners allowed in a native multisig config. */
9
- export const maxOwners = 10
9
+ export const maxOwners = 255
10
+
11
+ /**
12
+ * Maximum number of native multisig signatures in one nested authorization
13
+ * path, including the top-level transaction signature.
14
+ */
15
+ export const maxNestingDepth = 3
10
16
 
11
17
  /** Maximum encoded byte length for one primitive owner approval. */
12
18
  export const maxOwnerSignatureBytes = 2049
@@ -20,20 +26,17 @@ export const zeroSalt = `0x${'00'.repeat(32)}` as const
20
26
  /** Domain prefix for the native multisig account address derivation. */
21
27
  const accountDomain = 'tempo:multisig:account'
22
28
 
23
- /** Domain prefix for the native multisig config ID derivation. */
24
- const configDomain = 'tempo:multisig:config'
25
-
26
29
  /** Domain prefix for native multisig owner approvals. */
27
30
  const signatureDomain = 'tempo:multisig:signature'
28
31
 
29
32
  /**
30
- * Native multisig configuration. Determines the permanent config ID and the
31
- * stable multisig account address.
33
+ * Native multisig configuration. Determines the stable multisig account
34
+ * address.
32
35
  */
33
36
  export type Config<numberType = number> = Compute<{
34
37
  /**
35
- * Caller-chosen 32-byte salt mixed into the permanent config ID. Defaults to
36
- * the zero salt (`MultisigConfig.zeroSalt`) when omitted.
38
+ * Caller-chosen 32-byte salt mixed into the derived account address.
39
+ * Defaults to the zero salt (`MultisigConfig.zeroSalt`) when omitted.
37
40
  */
38
41
  salt?: Hex.Hex | undefined
39
42
  /** Minimum total owner weight required to authorize a transaction. */
@@ -44,7 +47,7 @@ export type Config<numberType = number> = Compute<{
44
47
 
45
48
  /** Native multisig owner entry. */
46
49
  export type Owner<numberType = number> = {
47
- /** Owner address (recovered from the owner's primitive signature). */
50
+ /** Owner address (recovered from the owner's approval). */
48
51
  owner: Address.Address
49
52
  /** Nonzero owner weight. */
50
53
  weight: numberType
@@ -60,9 +63,9 @@ export type Tuple = readonly [
60
63
  /**
61
64
  * Asserts that a native multisig {@link ox#MultisigConfig.Config} is valid.
62
65
  *
63
- * Mirrors the Tempo `validate_multisig_config` rules: owners non-empty and
66
+ * Mirrors the Tempo `InitMultisig::validate` rules: owners non-empty and
64
67
  * `<= maxOwners`, strictly ascending unique nonzero owner addresses, nonzero
65
- * owner weights, `threshold >= 1`, total weight `<= u32::MAX`, and
68
+ * owner weights, `threshold >= 1`, total weight `<= 255` (u8 max), and
66
69
  * `threshold <= total weight`.
67
70
  *
68
71
  * @example
@@ -109,9 +112,9 @@ export function assert<numberType = number>(config: Config<numberType>): void {
109
112
  totalWeight += Number(owner.weight)
110
113
  }
111
114
 
112
- if (totalWeight > 0xffffffff)
115
+ if (totalWeight > 0xff)
113
116
  throw new InvalidConfigError({
114
- reason: 'total owner weight exceeds u32 max',
117
+ reason: 'total owner weight exceeds u8 max',
115
118
  })
116
119
  if (Number(threshold) > totalWeight)
117
120
  throw new InvalidConfigError({
@@ -127,7 +130,7 @@ export declare namespace assert {
127
130
  * Normalizes a native multisig {@link ox#MultisigConfig.Config}.
128
131
  *
129
132
  * Sorts owners into strictly ascending `owner` address order (the canonical
130
- * form required for config ID derivation) and asserts the config is valid.
133
+ * form required for account derivation) and asserts the config is valid.
131
134
  *
132
135
  * @example
133
136
  * ```ts twoslash
@@ -197,39 +200,57 @@ export function fromTuple(tuple: Tuple): Config {
197
200
  /**
198
201
  * Derives the stable native multisig account address.
199
202
  *
200
- * `keccak256("tempo:multisig:account" || config_id)[12:32]`.
203
+ * Preimage (fixed-width big-endian, **not** RLP):
204
+ * `keccak256("tempo:multisig:account" || salt || u8(threshold) || u8(owners.length) || (owner || u8(weight)) for each owner)[12:32]`.
205
+ *
206
+ * The address is derived once from the initial (bootstrap) config and never
207
+ * changes — config updates do not affect it.
201
208
  *
202
209
  * @example
203
210
  * ```ts twoslash
204
211
  * import { MultisigConfig } from 'ox/tempo'
205
212
  *
206
- * const config = MultisigConfig.from({
213
+ * const genesisConfig = MultisigConfig.from({
207
214
  * threshold: 1,
208
215
  * owners: [
209
216
  * { owner: '0x1111111111111111111111111111111111111111', weight: 1 },
210
217
  * ],
211
218
  * })
212
219
  *
213
- * const address = MultisigConfig.getAddress({ config })
220
+ * const address = MultisigConfig.getAddress(genesisConfig)
214
221
  * ```
215
222
  *
216
- * @param value - The config or config ID to derive the address from.
223
+ * @param config - The initial (bootstrap) multisig config.
217
224
  * @returns The multisig account address.
218
225
  */
219
- export function getAddress(value: getAddress.Value): Address.Address {
220
- const id = 'configId' in value ? value.configId : toId(value.config)
221
- const hash = Hash.keccak256(Hex.concat(Hex.fromString(accountDomain), id))
222
- return Address.from(Hex.slice(hash, 12, 32))
226
+ export function getAddress(config: Config): Address.Address {
227
+ assert(config)
228
+ const hash = Hash.keccak256(
229
+ Hex.concat(
230
+ Hex.fromString(accountDomain),
231
+ Hex.padLeft(config.salt ?? zeroSalt, 32),
232
+ Hex.fromNumber(config.threshold, { size: 1 }),
233
+ Hex.fromNumber(config.owners.length, { size: 1 }),
234
+ ...config.owners.flatMap((owner) => [
235
+ owner.owner,
236
+ Hex.fromNumber(owner.weight, { size: 1 }),
237
+ ]),
238
+ ),
239
+ )
240
+ const account = Address.from(Hex.slice(hash, 12, 32))
241
+ if (Hex.toBigInt(account) === 0n)
242
+ throw new InvalidConfigError({ reason: 'derived account cannot be zero' })
243
+ return account
223
244
  }
224
245
 
225
246
  export declare namespace getAddress {
226
- type Value = { config: Config } | { configId: Hex.Hex }
227
-
228
247
  type ErrorType =
229
- | toId.ErrorType
248
+ | assert.ErrorType
230
249
  | Address.from.ErrorType
231
250
  | Hash.keccak256.ErrorType
232
251
  | Hex.concat.ErrorType
252
+ | Hex.fromNumber.ErrorType
253
+ | Hex.fromString.ErrorType
233
254
  | Hex.slice.ErrorType
234
255
  | Errors.GlobalErrorType
235
256
  }
@@ -237,22 +258,27 @@ export declare namespace getAddress {
237
258
  /**
238
259
  * Computes the digest a native multisig owner approves (signs).
239
260
  *
240
- * `keccak256("tempo:multisig:signature" || inner_digest || account || config_id)`,
261
+ * `keccak256("tempo:multisig:signature" || inner_digest || account)`,
241
262
  * where `inner_digest` is the transaction sign payload
242
263
  * ({@link ox#TxEnvelopeTempo.(getSignPayload:function)}).
243
264
  *
265
+ * The digest is keyed on the permanent `account` derived from the genesis
266
+ * (bootstrap) config — config updates never change it, so the genesis config
267
+ * is the correct input even for post-update transactions.
268
+ *
269
+ * For a nested multisig owner approval, the parent digest becomes the nested
270
+ * approval's `payload`, with the nested multisig `account`.
271
+ *
244
272
  * @example
245
273
  * ```ts twoslash
246
274
  * import { MultisigConfig, TxEnvelopeTempo } from 'ox/tempo'
247
275
  *
248
- * const config = MultisigConfig.from({
276
+ * const genesisConfig = MultisigConfig.from({
249
277
  * threshold: 1,
250
278
  * owners: [
251
279
  * { owner: '0x1111111111111111111111111111111111111111', weight: 1 },
252
280
  * ],
253
281
  * })
254
- * const configId = MultisigConfig.toId(config)
255
- * const account = MultisigConfig.getAddress({ configId })
256
282
  *
257
283
  * const envelope = TxEnvelopeTempo.from({
258
284
  * chainId: 1,
@@ -261,93 +287,74 @@ export declare namespace getAddress {
261
287
  *
262
288
  * const digest = MultisigConfig.getSignPayload({
263
289
  * payload: TxEnvelopeTempo.getSignPayload(envelope),
264
- * account,
265
- * configId,
290
+ * genesisConfig,
266
291
  * })
267
292
  * ```
268
293
  *
269
- * @param value - The digest derivation parameters.
270
- * @returns The owner approval digest.
271
- */
272
- export function getSignPayload(value: getSignPayload.Value): Hex.Hex {
273
- const { payload, account, configId } = value
274
- return Hash.keccak256(
275
- Hex.concat(
276
- Hex.fromString(signatureDomain),
277
- Hex.from(payload),
278
- account,
279
- configId,
280
- ),
281
- )
282
- }
283
-
284
- export declare namespace getSignPayload {
285
- type Value = {
286
- /** The inner transaction sign payload (`tx.signature_hash()`). */
287
- payload: Hex.Hex | Bytes.Bytes
288
- /** The native multisig account address. */
289
- account: Address.Address
290
- /** The permanent config ID. */
291
- configId: Hex.Hex
292
- }
293
-
294
- type ErrorType =
295
- | Hash.keccak256.ErrorType
296
- | Hex.concat.ErrorType
297
- | Hex.from.ErrorType
298
- | Errors.GlobalErrorType
299
- }
300
-
301
- /**
302
- * Derives the permanent config ID for a native multisig
303
- * {@link ox#MultisigConfig.Config}.
294
+ * @example
295
+ * ### From `account`
304
296
  *
305
- * Preimage (fixed-width big-endian, **not** RLP):
306
- * `keccak256("tempo:multisig:config" || salt || be_u32(threshold) || be_u32(owners.length) || (owner || be_u32(weight)) for each owner)`.
297
+ * If you already have the permanent `account` (for example, recovered from a
298
+ * stored envelope), pass it directly:
307
299
  *
308
- * @example
309
300
  * ```ts twoslash
310
- * import { MultisigConfig } from 'ox/tempo'
301
+ * import { MultisigConfig, TxEnvelopeTempo } from 'ox/tempo'
311
302
  *
312
- * const config = MultisigConfig.from({
303
+ * const genesisConfig = MultisigConfig.from({
313
304
  * threshold: 1,
314
305
  * owners: [
315
306
  * { owner: '0x1111111111111111111111111111111111111111', weight: 1 },
316
307
  * ],
317
308
  * })
309
+ * const account = MultisigConfig.getAddress(genesisConfig)
318
310
  *
319
- * const configId = MultisigConfig.toId(config)
311
+ * const envelope = TxEnvelopeTempo.from({ chainId: 1, calls: [] })
312
+ *
313
+ * const digest = MultisigConfig.getSignPayload({
314
+ * payload: TxEnvelopeTempo.getSignPayload(envelope),
315
+ * account
316
+ * })
320
317
  * ```
321
318
  *
322
- * @param config - The multisig config.
323
- * @returns The 32-byte config ID.
319
+ * @param value - The digest derivation parameters.
320
+ * @returns The owner approval digest.
324
321
  */
325
- export function toId(config: Config): Hex.Hex {
326
- assert(config)
327
- const id = Hash.keccak256(
328
- Hex.concat(
329
- Hex.fromString(configDomain),
330
- Hex.padLeft(config.salt ?? zeroSalt, 32),
331
- Hex.fromNumber(config.threshold, { size: 4 }),
332
- Hex.fromNumber(config.owners.length, { size: 4 }),
333
- ...config.owners.flatMap((owner) => [
334
- owner.owner,
335
- Hex.fromNumber(owner.weight, { size: 4 }),
336
- ]),
337
- ),
322
+ export function getSignPayload(value: getSignPayload.Value): Hex.Hex {
323
+ const { payload } = value
324
+ const account =
325
+ 'account' in value && value.account
326
+ ? value.account
327
+ : getAddress((value as { genesisConfig: Config }).genesisConfig)
328
+ return Hash.keccak256(
329
+ Hex.concat(Hex.fromString(signatureDomain), Hex.from(payload), account),
338
330
  )
339
- if (Hex.toBigInt(id) === 0n)
340
- throw new InvalidConfigError({ reason: 'config ID cannot be zero' })
341
- return id
342
331
  }
343
332
 
344
- export declare namespace toId {
333
+ export declare namespace getSignPayload {
334
+ type Value = {
335
+ /** The inner transaction sign payload (`tx.signature_hash()`). */
336
+ payload: Hex.Hex | Bytes.Bytes
337
+ } & OneOf<
338
+ | {
339
+ /** The native multisig account address. */
340
+ account: Address.Address
341
+ }
342
+ | {
343
+ /**
344
+ * The initial multisig config (the bootstrap config that derived the
345
+ * permanent `account`). Used to derive the account automatically.
346
+ * Config updates never change `account`, so the genesis config is
347
+ * also the correct input for post-update transactions.
348
+ */
349
+ genesisConfig: Config
350
+ }
351
+ >
352
+
345
353
  type ErrorType =
346
- | assert.ErrorType
354
+ | getAddress.ErrorType
347
355
  | Hash.keccak256.ErrorType
348
356
  | Hex.concat.ErrorType
349
- | Hex.fromNumber.ErrorType
350
- | Hex.fromString.ErrorType
357
+ | Hex.from.ErrorType
351
358
  | Errors.GlobalErrorType
352
359
  }
353
360