ox 0.14.27 → 0.14.28

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.
@@ -0,0 +1,423 @@
1
+ import * as Address from '../core/Address.js'
2
+ import type * as Bytes from '../core/Bytes.js'
3
+ import * as Errors from '../core/Errors.js'
4
+ import * as Hash from '../core/Hash.js'
5
+ import * as Hex from '../core/Hex.js'
6
+ import type { Compute } from '../core/internal/types.js'
7
+
8
+ /** Maximum number of owners allowed in a native multisig config. */
9
+ export const maxOwners = 10
10
+
11
+ /** Maximum encoded byte length for one primitive owner approval. */
12
+ export const maxOwnerSignatureBytes = 2049
13
+
14
+ /** Tempo signature type byte for native multisig signatures. */
15
+ export const signatureTypeByte = '0x05' as const
16
+
17
+ /** Zero 32-byte salt (the default when no salt is provided). */
18
+ export const zeroSalt = `0x${'00'.repeat(32)}` as const
19
+
20
+ /** Domain prefix for the native multisig account address derivation. */
21
+ const accountDomain = 'tempo:multisig:account'
22
+
23
+ /** Domain prefix for the native multisig config ID derivation. */
24
+ const configDomain = 'tempo:multisig:config'
25
+
26
+ /** Domain prefix for native multisig owner approvals. */
27
+ const signatureDomain = 'tempo:multisig:signature'
28
+
29
+ /**
30
+ * Native multisig configuration. Determines the permanent config ID and the
31
+ * stable multisig account address.
32
+ */
33
+ export type Config<numberType = number> = Compute<{
34
+ /**
35
+ * Caller-chosen 32-byte salt mixed into the permanent config ID. Defaults to
36
+ * the zero salt (`MultisigConfig.zeroSalt`) when omitted.
37
+ */
38
+ salt?: Hex.Hex | undefined
39
+ /** Minimum total owner weight required to authorize a transaction. */
40
+ threshold: numberType
41
+ /** Weighted owner list (strictly ascending by `owner` address). */
42
+ owners: readonly Owner<numberType>[]
43
+ }>
44
+
45
+ /** Native multisig owner entry. */
46
+ export type Owner<numberType = number> = {
47
+ /** Owner address (recovered from the owner's primitive signature). */
48
+ owner: Address.Address
49
+ /** Nonzero owner weight. */
50
+ weight: numberType
51
+ }
52
+
53
+ /** RLP tuple representation of a {@link ox#MultisigConfig.Config}. */
54
+ export type Tuple = readonly [
55
+ salt: Hex.Hex,
56
+ threshold: Hex.Hex,
57
+ owners: readonly Hex.Hex[][],
58
+ ]
59
+
60
+ /**
61
+ * Asserts that a native multisig {@link ox#MultisigConfig.Config} is valid.
62
+ *
63
+ * Mirrors the Tempo `validate_multisig_config` rules: owners non-empty and
64
+ * `<= maxOwners`, strictly ascending unique nonzero owner addresses, nonzero
65
+ * owner weights, `threshold >= 1`, total weight `<= u32::MAX`, and
66
+ * `threshold <= total weight`.
67
+ *
68
+ * @example
69
+ * ```ts twoslash
70
+ * import { MultisigConfig } from 'ox/tempo'
71
+ *
72
+ * MultisigConfig.assert({
73
+ * threshold: 1,
74
+ * owners: [
75
+ * { owner: '0x1111111111111111111111111111111111111111', weight: 1 },
76
+ * ],
77
+ * })
78
+ * ```
79
+ *
80
+ * @param config - The multisig config.
81
+ */
82
+ export function assert<numberType = number>(config: Config<numberType>): void {
83
+ const { salt, threshold, owners } = config
84
+
85
+ if (typeof salt !== 'undefined' && Hex.size(salt) !== 32)
86
+ throw new InvalidConfigError({ reason: 'salt must be 32 bytes' })
87
+ if (owners.length === 0)
88
+ throw new InvalidConfigError({ reason: 'owners cannot be empty' })
89
+ if (owners.length > maxOwners)
90
+ throw new InvalidConfigError({ reason: 'too many owners' })
91
+ if (Number(threshold) < 1)
92
+ throw new InvalidConfigError({ reason: 'threshold cannot be zero' })
93
+
94
+ let totalWeight = 0
95
+ let previous: bigint | undefined
96
+ for (const owner of owners) {
97
+ if (!Address.validate(owner.owner) || Hex.toBigInt(owner.owner) === 0n)
98
+ throw new InvalidConfigError({ reason: 'owner cannot be zero' })
99
+ if (Number(owner.weight) < 1)
100
+ throw new InvalidConfigError({ reason: 'owner weight cannot be zero' })
101
+
102
+ const current = Hex.toBigInt(owner.owner)
103
+ if (typeof previous !== 'undefined' && previous >= current)
104
+ throw new InvalidConfigError({
105
+ reason: 'owners must be strictly ascending',
106
+ })
107
+ previous = current
108
+
109
+ totalWeight += Number(owner.weight)
110
+ }
111
+
112
+ if (totalWeight > 0xffffffff)
113
+ throw new InvalidConfigError({
114
+ reason: 'total owner weight exceeds u32 max',
115
+ })
116
+ if (Number(threshold) > totalWeight)
117
+ throw new InvalidConfigError({
118
+ reason: 'threshold exceeds total owner weight',
119
+ })
120
+ }
121
+
122
+ export declare namespace assert {
123
+ type ErrorType = InvalidConfigError | Errors.GlobalErrorType
124
+ }
125
+
126
+ /**
127
+ * Normalizes a native multisig {@link ox#MultisigConfig.Config}.
128
+ *
129
+ * Sorts owners into strictly ascending `owner` address order (the canonical
130
+ * form required for config ID derivation) and asserts the config is valid.
131
+ *
132
+ * @example
133
+ * ```ts twoslash
134
+ * import { MultisigConfig } from 'ox/tempo'
135
+ *
136
+ * const config = MultisigConfig.from({
137
+ * threshold: 2,
138
+ * owners: [
139
+ * { owner: '0x2222222222222222222222222222222222222222', weight: 1 },
140
+ * { owner: '0x1111111111111111111111111111111111111111', weight: 1 },
141
+ * ],
142
+ * })
143
+ * // owners are now sorted ascending by address
144
+ * ```
145
+ *
146
+ * @param config - The multisig config.
147
+ * @returns The normalized multisig config.
148
+ */
149
+ export function from<numberType = number>(
150
+ config: Config<numberType>,
151
+ ): Config<numberType> {
152
+ const owners = [...config.owners].sort((a, b) =>
153
+ Hex.toBigInt(a.owner) < Hex.toBigInt(b.owner) ? -1 : 1,
154
+ )
155
+ const normalized = {
156
+ salt: config.salt ? Hex.padLeft(config.salt, 32) : zeroSalt,
157
+ threshold: config.threshold,
158
+ owners,
159
+ } as Config<numberType>
160
+ assert(normalized)
161
+ return normalized
162
+ }
163
+
164
+ /**
165
+ * Converts an RLP {@link ox#MultisigConfig.Tuple} back to a
166
+ * {@link ox#MultisigConfig.Config}.
167
+ *
168
+ * @example
169
+ * ```ts twoslash
170
+ * import { MultisigConfig } from 'ox/tempo'
171
+ *
172
+ * const config = MultisigConfig.fromTuple([
173
+ * `0x${'00'.repeat(32)}`,
174
+ * '0x01',
175
+ * [['0x1111111111111111111111111111111111111111', '0x01']],
176
+ * ])
177
+ * ```
178
+ *
179
+ * @param tuple - The RLP tuple.
180
+ * @returns The multisig config.
181
+ */
182
+ export function fromTuple(tuple: Tuple): Config {
183
+ const [salt, threshold, owners] = tuple
184
+ return {
185
+ salt: salt && salt !== '0x' ? Hex.padLeft(salt, 32) : zeroSalt,
186
+ threshold: threshold === '0x' ? 0 : Hex.toNumber(threshold),
187
+ owners: owners.map((owner) => {
188
+ const [ownerAddress, weight] = owner as readonly Hex.Hex[]
189
+ return {
190
+ owner: ownerAddress as Address.Address,
191
+ weight: !weight || weight === '0x' ? 0 : Hex.toNumber(weight),
192
+ }
193
+ }),
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Derives the stable native multisig account address.
199
+ *
200
+ * `keccak256("tempo:multisig:account" || config_id)[12:32]`.
201
+ *
202
+ * @example
203
+ * ```ts twoslash
204
+ * import { MultisigConfig } from 'ox/tempo'
205
+ *
206
+ * const config = MultisigConfig.from({
207
+ * threshold: 1,
208
+ * owners: [
209
+ * { owner: '0x1111111111111111111111111111111111111111', weight: 1 },
210
+ * ],
211
+ * })
212
+ *
213
+ * const address = MultisigConfig.getAddress({ config })
214
+ * ```
215
+ *
216
+ * @param value - The config or config ID to derive the address from.
217
+ * @returns The multisig account address.
218
+ */
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))
223
+ }
224
+
225
+ export declare namespace getAddress {
226
+ type Value = { config: Config } | { configId: Hex.Hex }
227
+
228
+ type ErrorType =
229
+ | toId.ErrorType
230
+ | Address.from.ErrorType
231
+ | Hash.keccak256.ErrorType
232
+ | Hex.concat.ErrorType
233
+ | Hex.slice.ErrorType
234
+ | Errors.GlobalErrorType
235
+ }
236
+
237
+ /**
238
+ * Computes the digest a native multisig owner approves (signs).
239
+ *
240
+ * `keccak256("tempo:multisig:signature" || inner_digest || account || config_id)`,
241
+ * where `inner_digest` is the transaction sign payload
242
+ * ({@link ox#TxEnvelopeTempo.(getSignPayload:function)}).
243
+ *
244
+ * @example
245
+ * ```ts twoslash
246
+ * import { MultisigConfig, TxEnvelopeTempo } from 'ox/tempo'
247
+ *
248
+ * const config = MultisigConfig.from({
249
+ * threshold: 1,
250
+ * owners: [
251
+ * { owner: '0x1111111111111111111111111111111111111111', weight: 1 },
252
+ * ],
253
+ * })
254
+ * const configId = MultisigConfig.toId(config)
255
+ * const account = MultisigConfig.getAddress({ configId })
256
+ *
257
+ * const envelope = TxEnvelopeTempo.from({
258
+ * chainId: 1,
259
+ * calls: [],
260
+ * })
261
+ *
262
+ * const digest = MultisigConfig.getSignPayload({
263
+ * payload: TxEnvelopeTempo.getSignPayload(envelope),
264
+ * account,
265
+ * configId,
266
+ * })
267
+ * ```
268
+ *
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}.
304
+ *
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)`.
307
+ *
308
+ * @example
309
+ * ```ts twoslash
310
+ * import { MultisigConfig } from 'ox/tempo'
311
+ *
312
+ * const config = MultisigConfig.from({
313
+ * threshold: 1,
314
+ * owners: [
315
+ * { owner: '0x1111111111111111111111111111111111111111', weight: 1 },
316
+ * ],
317
+ * })
318
+ *
319
+ * const configId = MultisigConfig.toId(config)
320
+ * ```
321
+ *
322
+ * @param config - The multisig config.
323
+ * @returns The 32-byte config ID.
324
+ */
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
+ ),
338
+ )
339
+ if (Hex.toBigInt(id) === 0n)
340
+ throw new InvalidConfigError({ reason: 'config ID cannot be zero' })
341
+ return id
342
+ }
343
+
344
+ export declare namespace toId {
345
+ type ErrorType =
346
+ | assert.ErrorType
347
+ | Hash.keccak256.ErrorType
348
+ | Hex.concat.ErrorType
349
+ | Hex.fromNumber.ErrorType
350
+ | Hex.fromString.ErrorType
351
+ | Errors.GlobalErrorType
352
+ }
353
+
354
+ /**
355
+ * Converts a {@link ox#MultisigConfig.Config} to its RLP tuple form (carried
356
+ * by the multisig signature `init`).
357
+ *
358
+ * Tuple shape: `[salt, threshold, [[owner, weight], ...]]`. The
359
+ * 32-byte `salt` encodes as a full fixed-width string; other integers use
360
+ * canonical RLP encoding (zero values encode as `0x`).
361
+ *
362
+ * @example
363
+ * ```ts twoslash
364
+ * import { MultisigConfig } from 'ox/tempo'
365
+ *
366
+ * const tuple = MultisigConfig.toTuple({
367
+ * threshold: 1,
368
+ * owners: [
369
+ * { owner: '0x1111111111111111111111111111111111111111', weight: 1 },
370
+ * ],
371
+ * })
372
+ * ```
373
+ *
374
+ * @param config - The multisig config.
375
+ * @returns The RLP tuple.
376
+ */
377
+ export function toTuple(config: Config): Tuple {
378
+ assert(config)
379
+ const owners = config.owners.map(
380
+ (owner) => [owner.owner, Hex.fromNumber(owner.weight)] as Hex.Hex[],
381
+ )
382
+ // `salt` is a fixed 32-byte value: it RLP-encodes as a full 32-byte string
383
+ // (including the zero salt), never trimmed like an integer.
384
+ const salt = config.salt ? Hex.padLeft(config.salt, 32) : zeroSalt
385
+ return [salt, Hex.fromNumber(config.threshold), owners] as const
386
+ }
387
+
388
+ /**
389
+ * Validates a native multisig {@link ox#MultisigConfig.Config}. Returns `true`
390
+ * if valid, `false` otherwise.
391
+ *
392
+ * @example
393
+ * ```ts twoslash
394
+ * import { MultisigConfig } from 'ox/tempo'
395
+ *
396
+ * const valid = MultisigConfig.validate({
397
+ * threshold: 1,
398
+ * owners: [
399
+ * { owner: '0x1111111111111111111111111111111111111111', weight: 1 },
400
+ * ],
401
+ * })
402
+ * // @log: true
403
+ * ```
404
+ *
405
+ * @param config - The multisig config.
406
+ * @returns Whether the config is valid.
407
+ */
408
+ export function validate(config: Config): boolean {
409
+ try {
410
+ assert(config)
411
+ return true
412
+ } catch {
413
+ return false
414
+ }
415
+ }
416
+
417
+ /** Thrown when a native multisig config is invalid. */
418
+ export class InvalidConfigError extends Errors.BaseError {
419
+ override readonly name = 'MultisigConfig.InvalidConfigError'
420
+ constructor({ reason }: { reason: string }) {
421
+ super(`Invalid native multisig config: ${reason}.`)
422
+ }
423
+ }
@@ -9,6 +9,7 @@ import {
9
9
  WebCryptoP256,
10
10
  } from 'ox'
11
11
  import { describe, expect, test } from 'vitest'
12
+ import * as MultisigConfig from './MultisigConfig.js'
12
13
  import * as SignatureEnvelope from './SignatureEnvelope.js'
13
14
 
14
15
  const publicKey = PublicKey.from({
@@ -512,7 +513,7 @@ describe('deserialize', () => {
512
513
  SignatureEnvelope.deserialize('0xdeadbeef'),
513
514
  ).toThrowErrorMatchingInlineSnapshot(
514
515
  `
515
- [SignatureEnvelope.InvalidSerializedError: Unable to deserialize signature envelope: Unknown signature type identifier: 0xde. Expected 0x01 (P256), 0x02 (WebAuthn), 0x03 (Keychain V1), or 0x04 (Keychain V2)
516
+ [SignatureEnvelope.InvalidSerializedError: Unable to deserialize signature envelope: Unknown signature type identifier: 0xde. Expected 0x01 (P256), 0x02 (WebAuthn), 0x03 (Keychain V1), 0x04 (Keychain V2), or 0x05 (Multisig)
516
517
 
517
518
  Serialized: 0xdeadbeef]
518
519
  `,
@@ -672,7 +673,7 @@ describe('deserialize', () => {
672
673
  SignatureEnvelope.deserialize(unknownType),
673
674
  ).toThrowErrorMatchingInlineSnapshot(
674
675
  `
675
- [SignatureEnvelope.InvalidSerializedError: Unable to deserialize signature envelope: Unknown signature type identifier: 0xff. Expected 0x01 (P256), 0x02 (WebAuthn), 0x03 (Keychain V1), or 0x04 (Keychain V2)
676
+ [SignatureEnvelope.InvalidSerializedError: Unable to deserialize signature envelope: Unknown signature type identifier: 0xff. Expected 0x01 (P256), 0x02 (WebAuthn), 0x03 (Keychain V1), 0x04 (Keychain V2), or 0x05 (Multisig)
676
677
 
677
678
  Serialized: 0xff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000]
678
679
  `,
@@ -1696,6 +1697,67 @@ describe('serialize', () => {
1696
1697
  })
1697
1698
  })
1698
1699
 
1700
+ describe('sortMultisigApprovals', () => {
1701
+ const account = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as const
1702
+ const configId = `0x${'11'.repeat(32)}` as const
1703
+ const payload = `0x${'42'.repeat(32)}` as const
1704
+ const digest = MultisigConfig.getSignPayload({
1705
+ account,
1706
+ configId,
1707
+ payload,
1708
+ })
1709
+
1710
+ const owners = Array.from({ length: 3 }, () => {
1711
+ const privateKey = Secp256k1.randomPrivateKey()
1712
+ const address = Address.fromPublicKey(
1713
+ Secp256k1.getPublicKey({ privateKey }),
1714
+ )
1715
+ const signature = SignatureEnvelope.from(
1716
+ Secp256k1.sign({ payload: digest, privateKey }),
1717
+ )
1718
+ return { address, signature } as const
1719
+ })
1720
+ const ascending = [...owners].sort((a, b) =>
1721
+ Hex.toBigInt(a.address) < Hex.toBigInt(b.address) ? -1 : 1,
1722
+ )
1723
+
1724
+ test('behavior: orders approvals ascending by recovered owner address', () => {
1725
+ const ordered = SignatureEnvelope.sortMultisigApprovals({
1726
+ account,
1727
+ configId,
1728
+ payload,
1729
+ // Provide approvals in reverse of the canonical order.
1730
+ signatures: [...ascending].reverse().map((owner) => owner.signature),
1731
+ })
1732
+ expect(ordered).toEqual(ascending.map((owner) => owner.signature))
1733
+ })
1734
+
1735
+ test('behavior: already-sorted input is unchanged', () => {
1736
+ const signatures = ascending.map((owner) => owner.signature)
1737
+ expect(
1738
+ SignatureEnvelope.sortMultisigApprovals({
1739
+ account,
1740
+ configId,
1741
+ payload,
1742
+ signatures,
1743
+ }),
1744
+ ).toEqual(signatures)
1745
+ })
1746
+
1747
+ test('behavior: recovered order matches the config owner order', () => {
1748
+ const ordered = SignatureEnvelope.sortMultisigApprovals({
1749
+ account,
1750
+ configId,
1751
+ payload,
1752
+ signatures: owners.map((owner) => owner.signature),
1753
+ })
1754
+ const recovered = ordered.map((signature) =>
1755
+ SignatureEnvelope.extractAddress({ payload: digest, signature }),
1756
+ )
1757
+ expect(recovered).toEqual(ascending.map((owner) => owner.address))
1758
+ })
1759
+ })
1760
+
1699
1761
  describe('validate', () => {
1700
1762
  describe('secp256k1', () => {
1701
1763
  test('behavior: returns true for valid signature', () => {
@@ -2692,3 +2754,152 @@ describe('CoercionError', () => {
2692
2754
  )
2693
2755
  })
2694
2756
  })
2757
+
2758
+ describe('multisig', () => {
2759
+ const account = '0x8ba6d26ff5c4e82ba0c8caf8c8ca794e1489a7ae'
2760
+ const configId =
2761
+ '0x01781fe551182476f2422c759e82d81c92e3263737afbbad57def6e8b69d21f5'
2762
+
2763
+ // P256 signatures do not carry `yParity` in the wire format, so use a clean
2764
+ // inner signature for round-trip equality checks.
2765
+ const innerP256 = SignatureEnvelope.from({
2766
+ signature: { r: p256Signature.r, s: p256Signature.s },
2767
+ publicKey,
2768
+ prehash: true,
2769
+ })
2770
+
2771
+ const envelope = SignatureEnvelope.from({
2772
+ type: 'multisig',
2773
+ account,
2774
+ configId,
2775
+ signatures: [SignatureEnvelope.from(signature_secp256k1), innerP256],
2776
+ })
2777
+
2778
+ test('serialize: type byte 0x05 prefix', () => {
2779
+ const serialized = SignatureEnvelope.serialize(envelope)
2780
+ expect(serialized.startsWith('0x05')).toBe(true)
2781
+ })
2782
+
2783
+ test('serialize/deserialize round-trip', () => {
2784
+ const serialized = SignatureEnvelope.serialize(envelope)
2785
+ expect(SignatureEnvelope.deserialize(serialized)).toEqual(envelope)
2786
+ })
2787
+
2788
+ test('getType', () => {
2789
+ expect(SignatureEnvelope.getType(envelope)).toBe('multisig')
2790
+ })
2791
+
2792
+ test('extractAddress returns the multisig account', () => {
2793
+ expect(
2794
+ SignatureEnvelope.extractAddress({
2795
+ payload: '0xdeadbeef',
2796
+ signature: envelope,
2797
+ }),
2798
+ ).toBe(account)
2799
+ })
2800
+
2801
+ test('toRpc/fromRpc round-trip', () => {
2802
+ const rpc = SignatureEnvelope.toRpc(envelope)
2803
+ expect(rpc.type).toBe('multisig')
2804
+ expect(SignatureEnvelope.fromRpc(rpc)).toEqual(envelope)
2805
+ })
2806
+
2807
+ test('assert: missing properties', () => {
2808
+ expect(() =>
2809
+ SignatureEnvelope.assert({ type: 'multisig', account } as never),
2810
+ ).toThrowError()
2811
+ })
2812
+
2813
+ describe('init (bootstrap)', () => {
2814
+ const init = {
2815
+ salt: `0x${'00'.repeat(32)}` as const,
2816
+ threshold: 1,
2817
+ owners: [
2818
+ {
2819
+ owner: '0x1111111111111111111111111111111111111111' as const,
2820
+ weight: 1,
2821
+ },
2822
+ ],
2823
+ }
2824
+
2825
+ const bootstrapEnvelope = SignatureEnvelope.from({
2826
+ type: 'multisig',
2827
+ account,
2828
+ configId,
2829
+ signatures: [SignatureEnvelope.from(signature_secp256k1), innerP256],
2830
+ init,
2831
+ })
2832
+
2833
+ test('serialize/deserialize round-trip with init', () => {
2834
+ const serialized = SignatureEnvelope.serialize(bootstrapEnvelope)
2835
+ expect(SignatureEnvelope.deserialize(serialized)).toEqual(
2836
+ bootstrapEnvelope,
2837
+ )
2838
+ })
2839
+
2840
+ test('serialize/deserialize round-trip preserves non-zero salt', () => {
2841
+ const salted = SignatureEnvelope.from({
2842
+ type: 'multisig',
2843
+ account,
2844
+ configId,
2845
+ signatures: [SignatureEnvelope.from(signature_secp256k1), innerP256],
2846
+ init: { ...init, salt: `0x${'42'.repeat(32)}` },
2847
+ })
2848
+ const serialized = SignatureEnvelope.serialize(salted)
2849
+ const deserialized = SignatureEnvelope.deserialize(
2850
+ serialized,
2851
+ ) as SignatureEnvelope.Multisig
2852
+ expect(deserialized.init?.salt).toBe(`0x${'42'.repeat(32)}`)
2853
+ expect(deserialized).toEqual(salted)
2854
+ })
2855
+
2856
+ test('absent init has no `init` key after deserialize', () => {
2857
+ const serialized = SignatureEnvelope.serialize(envelope)
2858
+ const deserialized = SignatureEnvelope.deserialize(serialized)
2859
+ expect('init' in deserialized).toBe(false)
2860
+ })
2861
+
2862
+ test('init absent vs present produce different serializations', () => {
2863
+ expect(SignatureEnvelope.serialize(envelope)).not.toBe(
2864
+ SignatureEnvelope.serialize(bootstrapEnvelope),
2865
+ )
2866
+ })
2867
+
2868
+ test('toRpc/fromRpc round-trip with init', () => {
2869
+ const rpc = SignatureEnvelope.toRpc(bootstrapEnvelope)
2870
+ expect(rpc.init).toEqual(init)
2871
+ expect(SignatureEnvelope.fromRpc(rpc)).toEqual(bootstrapEnvelope)
2872
+ })
2873
+
2874
+ test('toRpc encodes owner approvals as serialized hex (node `Vec<Bytes>`)', () => {
2875
+ const multisig = bootstrapEnvelope as SignatureEnvelope.Multisig
2876
+ const rpc = SignatureEnvelope.toRpc(
2877
+ multisig,
2878
+ ) as SignatureEnvelope.MultisigRpc
2879
+ expect(rpc.signatures).toEqual(
2880
+ multisig.signatures.map((s) => SignatureEnvelope.serialize(s)),
2881
+ )
2882
+ })
2883
+
2884
+ test('fromRpc detects multisig by shape (no `type` field)', () => {
2885
+ const rpc = SignatureEnvelope.toRpc(bootstrapEnvelope)
2886
+ // The node omits the `type` discriminant; detection is shape-based.
2887
+ const { type: _type, ...untyped } = rpc
2888
+ expect(SignatureEnvelope.fromRpc(untyped as never)).toEqual(
2889
+ bootstrapEnvelope,
2890
+ )
2891
+ })
2892
+
2893
+ test('assert: invalid init config throws', () => {
2894
+ expect(() =>
2895
+ SignatureEnvelope.assert({
2896
+ type: 'multisig',
2897
+ account,
2898
+ configId,
2899
+ signatures: [],
2900
+ init: { threshold: 1, owners: [] },
2901
+ } as never),
2902
+ ).toThrowError()
2903
+ })
2904
+ })
2905
+ })