ox 0.14.37 → 0.14.39

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/_cjs/tempo/MultisigConfig.js +71 -15
  3. package/_cjs/tempo/MultisigConfig.js.map +1 -1
  4. package/_cjs/tempo/MultisigOperation.js +241 -19
  5. package/_cjs/tempo/MultisigOperation.js.map +1 -1
  6. package/_cjs/tempo/SignatureEnvelope.js +71 -108
  7. package/_cjs/tempo/SignatureEnvelope.js.map +1 -1
  8. package/_cjs/version.js +1 -1
  9. package/_esm/tempo/MultisigConfig.js +140 -71
  10. package/_esm/tempo/MultisigConfig.js.map +1 -1
  11. package/_esm/tempo/MultisigOperation.js +331 -19
  12. package/_esm/tempo/MultisigOperation.js.map +1 -1
  13. package/_esm/tempo/SignatureEnvelope.js +84 -143
  14. package/_esm/tempo/SignatureEnvelope.js.map +1 -1
  15. package/_esm/tempo/index.js +2 -2
  16. package/_esm/version.js +1 -1
  17. package/_types/tempo/KeyAuthorization.d.ts +1 -1
  18. package/_types/tempo/KeyAuthorization.d.ts.map +1 -1
  19. package/_types/tempo/MultisigConfig.d.ts +137 -73
  20. package/_types/tempo/MultisigConfig.d.ts.map +1 -1
  21. package/_types/tempo/MultisigOperation.d.ts +169 -1
  22. package/_types/tempo/MultisigOperation.d.ts.map +1 -1
  23. package/_types/tempo/SignatureEnvelope.d.ts +81 -75
  24. package/_types/tempo/SignatureEnvelope.d.ts.map +1 -1
  25. package/_types/tempo/index.d.ts +2 -2
  26. package/_types/version.d.ts +1 -1
  27. package/package.json +6 -1
  28. package/tempo/KeyAuthorization.test-d.ts +12 -0
  29. package/tempo/KeyAuthorization.test.ts +54 -0
  30. package/tempo/KeyAuthorization.ts +1 -1
  31. package/tempo/MultisigConfig.test-d/package.json +6 -0
  32. package/tempo/MultisigConfig.test-d.ts +52 -0
  33. package/tempo/MultisigConfig.test.ts +291 -263
  34. package/tempo/MultisigConfig.ts +250 -95
  35. package/tempo/MultisigOperation.test-d.ts +25 -0
  36. package/tempo/MultisigOperation.test.ts +754 -22
  37. package/tempo/MultisigOperation.ts +548 -17
  38. package/tempo/SignatureEnvelope.test-d.ts +57 -40
  39. package/tempo/SignatureEnvelope.test.ts +523 -480
  40. package/tempo/SignatureEnvelope.ts +250 -257
  41. package/tempo/index.ts +2 -2
  42. package/tempo/multisig.e2e.test.ts +113 -86
  43. package/version.ts +1 -1
@@ -14,7 +14,7 @@ export type Base<quantity = bigint> = {
14
14
  /** Every retained serialized owner approval. */
15
15
  approvals: readonly Hex.Hex[]
16
16
  /** Root configuration used to verify approvals. */
17
- config: MultisigConfig.Config
17
+ config: MultisigConfig.Config<quantity>
18
18
  /** Root configuration version. */
19
19
  configVersion: quantity
20
20
  /** Unix creation time in milliseconds. */
@@ -76,6 +76,250 @@ export type Rpc = Operation<Hex.Hex>
76
76
  /** Maximum supported multisig configuration version. */
77
77
  const maxConfigVersion = 2n ** 64n - 1n
78
78
 
79
+ /**
80
+ * Derives the deterministic hash for a multisig operation.
81
+ *
82
+ * @example
83
+ * ```ts twoslash
84
+ * // @noErrors
85
+ * import { MultisigOperation } from 'ox/tempo'
86
+ *
87
+ * const hash = MultisigOperation.getHash({
88
+ * account,
89
+ * configVersion: 1n,
90
+ * transaction,
91
+ * type: 'transaction',
92
+ * })
93
+ * ```
94
+ *
95
+ * @param options - Operation payload and multisig identity.
96
+ * @returns The operation hash signed by each owner.
97
+ */
98
+ export function getHash(options: getHash.Options): Hex.Hex {
99
+ const { account, configVersion } = options
100
+ const payload =
101
+ options.type === 'transaction'
102
+ ? TxEnvelopeTempo.getSignPayload(
103
+ TxEnvelopeTempo.deserialize(
104
+ options.transaction as TxEnvelopeTempo.Serialized,
105
+ ),
106
+ )
107
+ : KeyAuthorization_.getSignPayload(
108
+ KeyAuthorization_.deserialize(options.keyAuthorization),
109
+ )
110
+ return MultisigConfig.getSignPayload({
111
+ account,
112
+ config: { version: configVersion },
113
+ payload,
114
+ })
115
+ }
116
+
117
+ export declare namespace getHash {
118
+ /** Parameters for `getHash`. */
119
+ export type Options = {
120
+ /** Root multisig account. */
121
+ account: Address.Address
122
+ /** Root configuration version. */
123
+ configVersion: bigint
124
+ } & (
125
+ | {
126
+ /** Canonical serialized key authorization. */
127
+ keyAuthorization: Hex.Hex
128
+ /** Operation kind. */
129
+ type: 'keyAuthorization'
130
+ }
131
+ | {
132
+ /** Canonical serialized Tempo envelope without its outer sender signature. */
133
+ transaction: Hex.Hex
134
+ /** Operation kind. */
135
+ type: 'transaction'
136
+ }
137
+ )
138
+
139
+ /** Error type for `getHash`. */
140
+ export type ErrorType =
141
+ | KeyAuthorization_.deserialize.ErrorType
142
+ | KeyAuthorization_.getSignPayload.ErrorType
143
+ | MultisigConfig.getSignPayload.ErrorType
144
+ | TxEnvelopeTempo.deserialize.ErrorType
145
+ | TxEnvelopeTempo.getSignPayload.ErrorType
146
+ | Errors.GlobalErrorType
147
+ }
148
+
149
+ /**
150
+ * Validates, deduplicates, and selects owner approvals for an operation.
151
+ *
152
+ * The function retains one canonical approval per owner. It selects the
153
+ * smallest deterministic quorum by owner weight, then orders the selected
154
+ * approvals by owner address for serialization.
155
+ *
156
+ * @example
157
+ * ```ts twoslash
158
+ * // @noErrors
159
+ * import { MultisigOperation } from 'ox/tempo'
160
+ *
161
+ * const selection = await MultisigOperation.selectApprovals({
162
+ * account,
163
+ * approvals,
164
+ * config,
165
+ * hash,
166
+ * resolveConfig,
167
+ * })
168
+ * ```
169
+ *
170
+ * @param options - Approval selection parameters.
171
+ * @returns The retained approvals and deterministic quorum selection.
172
+ */
173
+ export async function selectApprovals(
174
+ options: selectApprovals.Options,
175
+ ): Promise<selectApprovals.ReturnValue> {
176
+ const { account, approvals, hash, resolveConfig } = options
177
+ if (!Address.validate(account) || Hex.toBigInt(account) === 0n)
178
+ throw new InvalidApprovalError({ reason: 'account is invalid' })
179
+ if (!Hash.validate(hash))
180
+ throw new InvalidApprovalError({ reason: 'hash is invalid' })
181
+ return selectApprovals_internal(
182
+ {
183
+ account,
184
+ approvals,
185
+ config: MultisigConfig.from(options.config),
186
+ hash,
187
+ resolveConfig,
188
+ },
189
+ [account.toLowerCase()],
190
+ )
191
+ }
192
+
193
+ export declare namespace selectApprovals {
194
+ /** Parameters for `selectApprovals`. */
195
+ export type Options = {
196
+ /** Root multisig account. */
197
+ account: Address.Address
198
+ /** Serialized primitive or nested owner approvals. */
199
+ approvals: readonly SignatureEnvelope.Serialized[]
200
+ /** Current root multisig configuration. */
201
+ config: MultisigConfig.Config
202
+ /** Deterministic operation hash approved by root owners. */
203
+ hash: Hex.Hex
204
+ /** Resolves the current configuration of a nested multisig owner. */
205
+ resolveConfig?: ResolveConfig | undefined
206
+ }
207
+
208
+ /** Resolves an initialized nested multisig configuration. */
209
+ export type ResolveConfig = (
210
+ options: ResolveConfigOptions,
211
+ ) => ResolvedConfig | Promise<ResolvedConfig>
212
+
213
+ /** Nested multisig configuration lookup parameters. */
214
+ export type ResolveConfigOptions = {
215
+ /** Nested multisig account. */
216
+ account: Address.Address
217
+ }
218
+
219
+ /** Resolved nested multisig configuration. */
220
+ export type ResolvedConfig = {
221
+ /** Current nested multisig configuration. */
222
+ config: MultisigConfig.Config
223
+ /** Current nested multisig configuration version. */
224
+ version: bigint
225
+ }
226
+
227
+ /** Result of validating and selecting approvals. */
228
+ export type ReturnValue = {
229
+ /** Every retained approval, ordered by owner address. */
230
+ approvals: readonly SignatureEnvelope.Serialized[]
231
+ /** Number of approvals selected for quorum evaluation. */
232
+ signatureCount: number
233
+ /** Approvals selected for serialization, ordered by owner address. */
234
+ selectedApprovals: readonly SignatureEnvelope.Serialized[]
235
+ /** Required owner weight. */
236
+ threshold: number
237
+ /** Owner weight reached by the selected approvals. */
238
+ weight: number
239
+ }
240
+
241
+ /** Error type for `selectApprovals`. */
242
+ export type ErrorType =
243
+ | InvalidApprovalError
244
+ | MultisigConfig.assert.ErrorType
245
+ | MultisigConfig.getSignPayload.ErrorType
246
+ | SignatureEnvelope.CoercionError
247
+ | SignatureEnvelope.extractAddress.ErrorType
248
+ | SignatureEnvelope.serialize.ErrorType
249
+ | SignatureEnvelope.VerificationError
250
+ | Errors.GlobalErrorType
251
+ }
252
+
253
+ /**
254
+ * Serializes a multisig transaction operation with selected owner approvals.
255
+ *
256
+ * @example
257
+ * ```ts twoslash
258
+ * // @noErrors
259
+ * import { MultisigOperation } from 'ox/tempo'
260
+ *
261
+ * const transaction = MultisigOperation.serializeTransaction(operation, {
262
+ * approvals: selection.selectedApprovals,
263
+ * })
264
+ * ```
265
+ *
266
+ * @param operation - Multisig transaction operation.
267
+ * @param options - Transaction serialization options.
268
+ * @returns The signed serialized Tempo transaction.
269
+ */
270
+ export function serializeTransaction(
271
+ operation: TransactionOperation,
272
+ options: serializeTransaction.Options,
273
+ ): TxEnvelopeTempo.Serialized {
274
+ const value = from(operation)
275
+ const envelope = TxEnvelopeTempo.deserialize(
276
+ value.transaction as TxEnvelopeTempo.Serialized,
277
+ )
278
+ const approvals = options.approvals.map((approval) =>
279
+ SignatureEnvelope.from(approval),
280
+ )
281
+ assertRetainedApprovals(value, approvals)
282
+ const signatures = SignatureEnvelope.sortMultisigApprovals({
283
+ account: value.account,
284
+ config: value.config,
285
+ payload: TxEnvelopeTempo.getSignPayload(envelope),
286
+ signatures: approvals,
287
+ })
288
+ const signature = SignatureEnvelope.from({
289
+ account: value.account,
290
+ config: value.config,
291
+ signatures,
292
+ })
293
+ return TxEnvelopeTempo.serialize(
294
+ envelope,
295
+ value.transaction.startsWith(TxEnvelopeTempo.feePayerMagic)
296
+ ? {
297
+ format: 'feePayer',
298
+ sender: envelope.from,
299
+ signature,
300
+ }
301
+ : { signature },
302
+ )
303
+ }
304
+
305
+ export declare namespace serializeTransaction {
306
+ /** Options for `serializeTransaction`. */
307
+ export type Options = {
308
+ /** Selected retained approvals to attach to the transaction. */
309
+ approvals: readonly SignatureEnvelope.Serialized[]
310
+ }
311
+
312
+ /** Error type for `serializeTransaction`. */
313
+ export type ErrorType =
314
+ | from.ErrorType
315
+ | InvalidOperationError
316
+ | SignatureEnvelope.sortMultisigApprovals.ErrorType
317
+ | TxEnvelopeTempo.deserialize.ErrorType
318
+ | TxEnvelopeTempo.getSignPayload.ErrorType
319
+ | TxEnvelopeTempo.serialize.ErrorType
320
+ | Errors.GlobalErrorType
321
+ }
322
+
79
323
  /**
80
324
  * Validates and normalizes a multisig operation.
81
325
  *
@@ -94,7 +338,10 @@ export function from<const operation extends Operation>(
94
338
  operation: operation,
95
339
  ): from.ReturnValue<operation> {
96
340
  try {
97
- const config = MultisigConfig.from(operation.config)
341
+ const config = MultisigConfig.from({
342
+ ...operation.config,
343
+ version: operation.configVersion,
344
+ })
98
345
  if (
99
346
  typeof config.threshold !== 'number' ||
100
347
  config.owners.some((owner) => typeof owner.weight !== 'number')
@@ -102,12 +349,13 @@ export function from<const operation extends Operation>(
102
349
  throw new InvalidOperationError({
103
350
  reason: 'config threshold and owner weights must be numbers',
104
351
  })
105
- assertBase(operation, config)
106
- if (operation.type === 'transaction') assertTransaction(operation)
107
- else if (operation.type === 'keyAuthorization')
108
- assertKeyAuthorization(operation, config)
352
+ const value = { ...operation, config } as Operation
353
+ assertBase(value, config)
354
+ if (value.type === 'transaction') assertTransaction(value)
355
+ else if (value.type === 'keyAuthorization')
356
+ assertKeyAuthorization(value, config)
109
357
  else throw new InvalidOperationError({ reason: 'unknown operation type' })
110
- return { ...operation, config } as never
358
+ return value as never
111
359
  } catch (cause) {
112
360
  if (cause instanceof InvalidOperationError) throw cause
113
361
  throw new InvalidOperationError({ cause })
@@ -155,7 +403,11 @@ export function fromRpc<const operation extends Rpc>(
155
403
  throw new InvalidOperationError({
156
404
  reason: 'configVersion must use canonical quantity encoding',
157
405
  })
158
- return from({ ...operation, configVersion } as Operation) as never
406
+ return from({
407
+ ...operation,
408
+ config: MultisigConfig.fromRpc(operation.config),
409
+ configVersion,
410
+ } as Operation) as never
159
411
  } catch (cause) {
160
412
  if (cause instanceof InvalidOperationError) throw cause
161
413
  throw new InvalidOperationError({ cause })
@@ -193,6 +445,7 @@ export function toRpc<const operation extends Operation>(
193
445
  const value = from(operation)
194
446
  return {
195
447
  ...value,
448
+ config: MultisigConfig.toRpc(value.config),
196
449
  configVersion: Hex.fromNumber(value.configVersion),
197
450
  } as never
198
451
  }
@@ -208,6 +461,222 @@ export declare namespace toRpc {
208
461
  export type ErrorType = from.ErrorType | Hex.fromNumber.ErrorType
209
462
  }
210
463
 
464
+ /**
465
+ * Validates and selects approvals recursively.
466
+ *
467
+ * @internal
468
+ */
469
+ async function selectApprovals_internal(
470
+ options: selectApprovals.Options,
471
+ path: readonly string[],
472
+ ): Promise<selectApprovals.ReturnValue> {
473
+ const owners = new Map(
474
+ options.config.owners.map((owner) => [
475
+ owner.owner.toLowerCase(),
476
+ { address: owner.owner, weight: Number(owner.weight) },
477
+ ]),
478
+ )
479
+ const groups = new Map<string, ApprovalGroup>()
480
+ for (const serialized of options.approvals) {
481
+ const signature = SignatureEnvelope.from(serialized)
482
+ if (signature.type === 'keychain')
483
+ throw new InvalidApprovalError({
484
+ reason: 'keychain signatures cannot approve a multisig operation',
485
+ })
486
+ const address =
487
+ signature.type === 'multisig'
488
+ ? signature.account
489
+ : SignatureEnvelope.extractAddress({
490
+ payload: options.hash,
491
+ signature,
492
+ })
493
+ const owner = owners.get(address.toLowerCase())
494
+ if (!owner)
495
+ throw new InvalidApprovalError({
496
+ reason: `signature is from non-owner ${address}`,
497
+ })
498
+ const key = address.toLowerCase()
499
+ const group = groups.get(key)
500
+ if (group) group.signatures.push(signature)
501
+ else
502
+ groups.set(key, {
503
+ address: owner.address,
504
+ signatures: [signature],
505
+ weight: owner.weight,
506
+ })
507
+ }
508
+
509
+ const valid: SelectedApproval[] = []
510
+ const retained: RetainedApproval[] = []
511
+ for (const group of groups.values()) {
512
+ const nested = group.signatures.filter(
513
+ (signature) => signature.type === 'multisig',
514
+ )
515
+ if (nested.length > 0) {
516
+ if (nested.length !== group.signatures.length)
517
+ throw new InvalidApprovalError({
518
+ reason: `owner ${group.address} has conflicting signature types`,
519
+ })
520
+ if (
521
+ path.length >= MultisigConfig.maxNestingDepth ||
522
+ path.includes(group.address.toLowerCase())
523
+ )
524
+ throw new InvalidApprovalError({
525
+ reason: `nested multisig owner ${group.address} is invalid`,
526
+ })
527
+ if (!options.resolveConfig)
528
+ throw new InvalidApprovalError({
529
+ reason: `nested multisig owner ${group.address} requires a config resolver`,
530
+ })
531
+ const resolved = await options.resolveConfig({ account: group.address })
532
+ const config = MultisigConfig.from({
533
+ ...resolved.config,
534
+ version: resolved.version,
535
+ })
536
+ const selected = await selectApprovals_internal(
537
+ {
538
+ account: group.address,
539
+ approvals: nested.flatMap((signature) =>
540
+ signature.signatures.map((approval) =>
541
+ SignatureEnvelope.serialize(approval),
542
+ ),
543
+ ),
544
+ config,
545
+ hash: MultisigConfig.getSignPayload({
546
+ account: group.address,
547
+ config,
548
+ payload: options.hash,
549
+ }),
550
+ resolveConfig: options.resolveConfig,
551
+ },
552
+ [...path, group.address.toLowerCase()],
553
+ )
554
+ retained.push({
555
+ address: group.address,
556
+ signature: SignatureEnvelope.serialize(
557
+ SignatureEnvelope.from({
558
+ account: group.address,
559
+ config,
560
+ signatures: selected.approvals.map((approval) =>
561
+ SignatureEnvelope.from(approval),
562
+ ),
563
+ }),
564
+ ),
565
+ })
566
+ if (selected.weight >= selected.threshold)
567
+ valid.push({
568
+ address: group.address,
569
+ signature: SignatureEnvelope.serialize(
570
+ SignatureEnvelope.from({
571
+ account: group.address,
572
+ config,
573
+ signatures: selected.selectedApprovals.map((approval) =>
574
+ SignatureEnvelope.from(approval),
575
+ ),
576
+ }),
577
+ ),
578
+ weight: group.weight,
579
+ })
580
+ continue
581
+ }
582
+
583
+ const signatures = group.signatures.map((signature) => {
584
+ if (
585
+ !SignatureEnvelope.verify(signature, {
586
+ address: group.address,
587
+ payload: options.hash,
588
+ })
589
+ )
590
+ throw new InvalidApprovalError({
591
+ reason: `signature from owner ${group.address} is invalid`,
592
+ })
593
+ return SignatureEnvelope.serialize(signature)
594
+ })
595
+ const signature = signatures.sort(compareHex)[0]!
596
+ valid.push({
597
+ address: group.address,
598
+ signature,
599
+ weight: group.weight,
600
+ })
601
+ retained.push({ address: group.address, signature })
602
+ }
603
+
604
+ const ranked = valid.sort(
605
+ (a, b) => b.weight - a.weight || compareApprovalAddress(a, b),
606
+ )
607
+ const selected: typeof ranked = []
608
+ let weight = 0
609
+ for (const approval of ranked.slice(0, MultisigConfig.maxSignatures)) {
610
+ if (weight >= Number(options.config.threshold)) break
611
+ selected.push(approval)
612
+ weight += approval.weight
613
+ }
614
+ selected.sort(compareApprovalAddress)
615
+
616
+ return {
617
+ approvals: retained
618
+ .sort(compareApprovalAddress)
619
+ .map((approval) => approval.signature),
620
+ selectedApprovals: selected.map((approval) => approval.signature),
621
+ signatureCount: selected.length,
622
+ threshold: Number(options.config.threshold),
623
+ weight,
624
+ }
625
+ }
626
+
627
+ /** Approval selected for quorum evaluation. @internal */
628
+ type SelectedApproval = {
629
+ /** Configured owner address. */
630
+ address: Address.Address
631
+ /** Serialized owner signature. */
632
+ signature: SignatureEnvelope.Serialized
633
+ /** Configured owner weight. */
634
+ weight: number
635
+ }
636
+
637
+ /** Approvals submitted for one configured owner. @internal */
638
+ type ApprovalGroup = {
639
+ /** Configured owner address. */
640
+ address: Address.Address
641
+ /** Submitted signatures that resolve to the owner. */
642
+ signatures: SignatureEnvelope.SignatureEnvelope[]
643
+ /** Configured owner weight. */
644
+ weight: number
645
+ }
646
+
647
+ /** Approval retained in operation storage. @internal */
648
+ type RetainedApproval = {
649
+ /** Configured owner address. */
650
+ address: Address.Address
651
+ /** Serialized primitive or normalized nested approval. */
652
+ signature: SignatureEnvelope.Serialized
653
+ }
654
+
655
+ /**
656
+ * Orders approval records by owner address.
657
+ *
658
+ * @internal
659
+ */
660
+ function compareApprovalAddress(
661
+ a: SelectedApproval | RetainedApproval,
662
+ b: SelectedApproval | RetainedApproval,
663
+ ) {
664
+ const addressA = Hex.toBigInt(a.address)
665
+ const addressB = Hex.toBigInt(b.address)
666
+ return addressA < addressB ? -1 : addressA > addressB ? 1 : 0
667
+ }
668
+
669
+ /**
670
+ * Orders hexadecimal data bytewise.
671
+ *
672
+ * @internal
673
+ */
674
+ function compareHex(a: Hex.Hex, b: Hex.Hex) {
675
+ const hexA = a.toLowerCase()
676
+ const hexB = b.toLowerCase()
677
+ return hexA < hexB ? -1 : hexA > hexB ? 1 : 0
678
+ }
679
+
211
680
  /**
212
681
  * Validates fields shared by every operation.
213
682
  *
@@ -276,6 +745,7 @@ function assertBase(operation: Operation, config: MultisigConfig.Config): void {
276
745
  const signature = assertApproval(
277
746
  operation.account,
278
747
  approval as SignatureEnvelope.Serialized,
748
+ config,
279
749
  )
280
750
  const address = SignatureEnvelope.extractAddress({
281
751
  payload: operation.hash,
@@ -481,13 +951,9 @@ function assertKeyAuthorization(
481
951
  reason: 'key authorization signatureCount does not match its signature',
482
952
  })
483
953
  assertSelectedApprovals(operation, signature.signatures, authorization)
484
- if (!!signature.init !== operation.init)
954
+ if (!sameConfig(signature.config, config))
485
955
  throw new InvalidOperationError({
486
- reason: 'key authorization bootstrap state does not match',
487
- })
488
- if (signature.init && !sameConfig(signature.init, config))
489
- throw new InvalidOperationError({
490
- reason: 'key authorization bootstrap config does not match',
956
+ reason: 'key authorization config does not match',
491
957
  })
492
958
  }
493
959
  assertOperationHash(
@@ -511,10 +977,12 @@ function assertKeyAuthorization(
511
977
  function assertApproval(
512
978
  account: Address.Address,
513
979
  serialized: SignatureEnvelope.Serialized,
980
+ config: MultisigConfig.Config,
514
981
  ): SignatureEnvelope.SignatureEnvelope {
515
982
  const approval = SignatureEnvelope.deserialize(serialized)
516
983
  SignatureEnvelope.assert({
517
984
  account,
985
+ config,
518
986
  signatures: [approval],
519
987
  type: 'multisig',
520
988
  })
@@ -526,6 +994,30 @@ function assertApproval(
526
994
  return approval
527
995
  }
528
996
 
997
+ /**
998
+ * Checks that selected transaction approvals are retained by the operation.
999
+ *
1000
+ * @internal
1001
+ */
1002
+ function assertRetainedApprovals(
1003
+ operation: TransactionOperation,
1004
+ selected: readonly SignatureEnvelope.SignatureEnvelope[],
1005
+ ): void {
1006
+ const retained = operation.approvals.map((approval) =>
1007
+ SignatureEnvelope.deserialize(approval),
1008
+ )
1009
+ for (const approval of selected) {
1010
+ const index = retained.findIndex((candidate) =>
1011
+ includesApproval(candidate, approval),
1012
+ )
1013
+ if (index === -1)
1014
+ throw new InvalidOperationError({
1015
+ reason: 'transaction signature is not a retained approval',
1016
+ })
1017
+ retained.splice(index, 1)
1018
+ }
1019
+ }
1020
+
529
1021
  /**
530
1022
  * Checks that a successful key authorization uses retained approvals in canonical order.
531
1023
  *
@@ -552,8 +1044,8 @@ function assertSelectedApprovals(
552
1044
 
553
1045
  const digest = MultisigConfig.getSignPayload({
554
1046
  account: operation.account,
1047
+ config: operation.config,
555
1048
  payload: KeyAuthorization_.getSignPayload(authorization),
556
- version: operation.configVersion,
557
1049
  })
558
1050
  const addresses = selected.map((signature) =>
559
1051
  SignatureEnvelope.extractAddress({ payload: digest, signature }),
@@ -588,7 +1080,7 @@ function includesApproval(
588
1080
  )
589
1081
  if (retained.account.toLowerCase() !== selected.account.toLowerCase())
590
1082
  return false
591
- // Nested versions are not serialized, so selected child approvals must preserve the validated retained order.
1083
+ if (!sameConfig(retained.config, selected.config)) return false
592
1084
  let index = 0
593
1085
  for (const approval of selected.signatures) {
594
1086
  while (
@@ -632,8 +1124,8 @@ function isWeightReachable(
632
1124
  function assertOperationHash(operation: Operation, payload: Hex.Hex): void {
633
1125
  const hash = MultisigConfig.getSignPayload({
634
1126
  account: operation.account,
1127
+ config: operation.config,
635
1128
  payload,
636
- version: operation.configVersion,
637
1129
  })
638
1130
  if (hash.toLowerCase() !== operation.hash.toLowerCase())
639
1131
  throw new InvalidOperationError({
@@ -670,6 +1162,7 @@ function sameConfig(
670
1162
  configB.salt ?? MultisigConfig.zeroSalt,
671
1163
  ) &&
672
1164
  configA.threshold === configB.threshold &&
1165
+ configA.version === configB.version &&
673
1166
  configA.owners.length === configB.owners.length &&
674
1167
  configA.owners.every((owner, index) => {
675
1168
  const other = configB.owners[index]!
@@ -681,6 +1174,44 @@ function sameConfig(
681
1174
  )
682
1175
  }
683
1176
 
1177
+ /** Thrown when a multisig owner approval is invalid. */
1178
+ export class InvalidApprovalError extends Errors.BaseError<Error | undefined> {
1179
+ override readonly name = 'MultisigOperation.InvalidApprovalError'
1180
+
1181
+ /**
1182
+ * Creates an invalid multisig approval error.
1183
+ *
1184
+ * @example
1185
+ * ```ts twoslash
1186
+ * import { MultisigOperation } from 'ox/tempo'
1187
+ *
1188
+ * throw new MultisigOperation.InvalidApprovalError({
1189
+ * reason: 'signature is from a non-owner',
1190
+ * })
1191
+ * ```
1192
+ *
1193
+ * @param options - Error options.
1194
+ */
1195
+ constructor(options: InvalidApprovalError.Options = {}) {
1196
+ super(
1197
+ options.reason
1198
+ ? `Invalid multisig approval: ${options.reason}.`
1199
+ : 'Invalid multisig approval.',
1200
+ { cause: options.cause as Error | undefined },
1201
+ )
1202
+ }
1203
+ }
1204
+
1205
+ export declare namespace InvalidApprovalError {
1206
+ /** Error construction options. */
1207
+ export type Options = {
1208
+ /** Underlying error. */
1209
+ cause?: unknown | undefined
1210
+ /** Validation failure. */
1211
+ reason?: string | undefined
1212
+ }
1213
+ }
1214
+
684
1215
  /** Thrown when a multisig operation is malformed or internally inconsistent. */
685
1216
  export class InvalidOperationError extends Errors.BaseError<Error | undefined> {
686
1217
  override readonly name = 'MultisigOperation.InvalidOperationError'