ox 0.14.37 → 0.14.38

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.
@@ -76,6 +76,255 @@ 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
+ payload,
113
+ version: configVersion,
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
+ payload: TxEnvelopeTempo.getSignPayload(envelope),
285
+ signatures: approvals,
286
+ version: value.configVersion,
287
+ })
288
+ const signature = value.init
289
+ ? SignatureEnvelope.from({
290
+ init: true,
291
+ initialConfig: value.config,
292
+ signatures,
293
+ })
294
+ : SignatureEnvelope.from({
295
+ account: value.account,
296
+ signatures,
297
+ })
298
+ return TxEnvelopeTempo.serialize(
299
+ envelope,
300
+ value.transaction.startsWith(TxEnvelopeTempo.feePayerMagic)
301
+ ? {
302
+ format: 'feePayer',
303
+ sender: envelope.from,
304
+ signature,
305
+ }
306
+ : { signature },
307
+ )
308
+ }
309
+
310
+ export declare namespace serializeTransaction {
311
+ /** Options for `serializeTransaction`. */
312
+ export type Options = {
313
+ /** Selected retained approvals to attach to the transaction. */
314
+ approvals: readonly SignatureEnvelope.Serialized[]
315
+ }
316
+
317
+ /** Error type for `serializeTransaction`. */
318
+ export type ErrorType =
319
+ | from.ErrorType
320
+ | InvalidOperationError
321
+ | SignatureEnvelope.sortMultisigApprovals.ErrorType
322
+ | TxEnvelopeTempo.deserialize.ErrorType
323
+ | TxEnvelopeTempo.getSignPayload.ErrorType
324
+ | TxEnvelopeTempo.serialize.ErrorType
325
+ | Errors.GlobalErrorType
326
+ }
327
+
79
328
  /**
80
329
  * Validates and normalizes a multisig operation.
81
330
  *
@@ -208,6 +457,220 @@ export declare namespace toRpc {
208
457
  export type ErrorType = from.ErrorType | Hex.fromNumber.ErrorType
209
458
  }
210
459
 
460
+ /**
461
+ * Validates and selects approvals recursively.
462
+ *
463
+ * @internal
464
+ */
465
+ async function selectApprovals_internal(
466
+ options: selectApprovals.Options,
467
+ path: readonly string[],
468
+ ): Promise<selectApprovals.ReturnValue> {
469
+ const owners = new Map(
470
+ options.config.owners.map((owner) => [
471
+ owner.owner.toLowerCase(),
472
+ { address: owner.owner, weight: Number(owner.weight) },
473
+ ]),
474
+ )
475
+ const groups = new Map<string, ApprovalGroup>()
476
+ for (const serialized of options.approvals) {
477
+ const signature = SignatureEnvelope.from(serialized)
478
+ if (signature.type === 'keychain')
479
+ throw new InvalidApprovalError({
480
+ reason: 'keychain signatures cannot approve a multisig operation',
481
+ })
482
+ const address =
483
+ signature.type === 'multisig'
484
+ ? signature.account
485
+ : SignatureEnvelope.extractAddress({
486
+ payload: options.hash,
487
+ signature,
488
+ })
489
+ const owner = owners.get(address.toLowerCase())
490
+ if (!owner)
491
+ throw new InvalidApprovalError({
492
+ reason: `signature is from non-owner ${address}`,
493
+ })
494
+ const key = address.toLowerCase()
495
+ const group = groups.get(key)
496
+ if (group) group.signatures.push(signature)
497
+ else
498
+ groups.set(key, {
499
+ address: owner.address,
500
+ signatures: [signature],
501
+ weight: owner.weight,
502
+ })
503
+ }
504
+
505
+ const valid: SelectedApproval[] = []
506
+ const retained: RetainedApproval[] = []
507
+ for (const group of groups.values()) {
508
+ const nested = group.signatures.filter(
509
+ (signature) => signature.type === 'multisig',
510
+ )
511
+ if (nested.length > 0) {
512
+ if (nested.length !== group.signatures.length)
513
+ throw new InvalidApprovalError({
514
+ reason: `owner ${group.address} has conflicting signature types`,
515
+ })
516
+ if (nested.some((signature) => signature.init))
517
+ throw new InvalidApprovalError({
518
+ reason: `nested multisig owner ${group.address} cannot carry init`,
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 selected = await selectApprovals_internal(
533
+ {
534
+ account: group.address,
535
+ approvals: nested.flatMap((signature) =>
536
+ signature.signatures.map((approval) =>
537
+ SignatureEnvelope.serialize(approval),
538
+ ),
539
+ ),
540
+ config: MultisigConfig.from(resolved.config),
541
+ hash: MultisigConfig.getSignPayload({
542
+ account: group.address,
543
+ payload: options.hash,
544
+ version: resolved.version,
545
+ }),
546
+ resolveConfig: options.resolveConfig,
547
+ },
548
+ [...path, group.address.toLowerCase()],
549
+ )
550
+ retained.push({
551
+ address: group.address,
552
+ signature: SignatureEnvelope.serialize(
553
+ SignatureEnvelope.from({
554
+ account: group.address,
555
+ signatures: selected.approvals.map((approval) =>
556
+ SignatureEnvelope.from(approval),
557
+ ),
558
+ }),
559
+ ),
560
+ })
561
+ if (selected.weight >= selected.threshold)
562
+ valid.push({
563
+ address: group.address,
564
+ signature: SignatureEnvelope.serialize(
565
+ SignatureEnvelope.from({
566
+ account: group.address,
567
+ signatures: selected.selectedApprovals.map((approval) =>
568
+ SignatureEnvelope.from(approval),
569
+ ),
570
+ }),
571
+ ),
572
+ weight: group.weight,
573
+ })
574
+ continue
575
+ }
576
+
577
+ const signatures = group.signatures.map((signature) => {
578
+ if (
579
+ !SignatureEnvelope.verify(signature, {
580
+ address: group.address,
581
+ payload: options.hash,
582
+ })
583
+ )
584
+ throw new InvalidApprovalError({
585
+ reason: `signature from owner ${group.address} is invalid`,
586
+ })
587
+ return SignatureEnvelope.serialize(signature)
588
+ })
589
+ const signature = signatures.sort(compareHex)[0]!
590
+ valid.push({
591
+ address: group.address,
592
+ signature,
593
+ weight: group.weight,
594
+ })
595
+ retained.push({ address: group.address, signature })
596
+ }
597
+
598
+ const ranked = valid.sort(
599
+ (a, b) => b.weight - a.weight || compareApprovalAddress(a, b),
600
+ )
601
+ const selected: typeof ranked = []
602
+ let weight = 0
603
+ for (const approval of ranked.slice(0, MultisigConfig.maxSignatures)) {
604
+ if (weight >= Number(options.config.threshold)) break
605
+ selected.push(approval)
606
+ weight += approval.weight
607
+ }
608
+ selected.sort(compareApprovalAddress)
609
+
610
+ return {
611
+ approvals: retained
612
+ .sort(compareApprovalAddress)
613
+ .map((approval) => approval.signature),
614
+ selectedApprovals: selected.map((approval) => approval.signature),
615
+ signatureCount: selected.length,
616
+ threshold: Number(options.config.threshold),
617
+ weight,
618
+ }
619
+ }
620
+
621
+ /** Approval selected for quorum evaluation. @internal */
622
+ type SelectedApproval = {
623
+ /** Configured owner address. */
624
+ address: Address.Address
625
+ /** Serialized owner signature. */
626
+ signature: SignatureEnvelope.Serialized
627
+ /** Configured owner weight. */
628
+ weight: number
629
+ }
630
+
631
+ /** Approvals submitted for one configured owner. @internal */
632
+ type ApprovalGroup = {
633
+ /** Configured owner address. */
634
+ address: Address.Address
635
+ /** Submitted signatures that resolve to the owner. */
636
+ signatures: SignatureEnvelope.SignatureEnvelope[]
637
+ /** Configured owner weight. */
638
+ weight: number
639
+ }
640
+
641
+ /** Approval retained in operation storage. @internal */
642
+ type RetainedApproval = {
643
+ /** Configured owner address. */
644
+ address: Address.Address
645
+ /** Serialized primitive or normalized nested approval. */
646
+ signature: SignatureEnvelope.Serialized
647
+ }
648
+
649
+ /**
650
+ * Orders approval records by owner address.
651
+ *
652
+ * @internal
653
+ */
654
+ function compareApprovalAddress(
655
+ a: SelectedApproval | RetainedApproval,
656
+ b: SelectedApproval | RetainedApproval,
657
+ ) {
658
+ const addressA = Hex.toBigInt(a.address)
659
+ const addressB = Hex.toBigInt(b.address)
660
+ return addressA < addressB ? -1 : addressA > addressB ? 1 : 0
661
+ }
662
+
663
+ /**
664
+ * Orders hexadecimal data bytewise.
665
+ *
666
+ * @internal
667
+ */
668
+ function compareHex(a: Hex.Hex, b: Hex.Hex) {
669
+ const hexA = a.toLowerCase()
670
+ const hexB = b.toLowerCase()
671
+ return hexA < hexB ? -1 : hexA > hexB ? 1 : 0
672
+ }
673
+
211
674
  /**
212
675
  * Validates fields shared by every operation.
213
676
  *
@@ -526,6 +989,30 @@ function assertApproval(
526
989
  return approval
527
990
  }
528
991
 
992
+ /**
993
+ * Checks that selected transaction approvals are retained by the operation.
994
+ *
995
+ * @internal
996
+ */
997
+ function assertRetainedApprovals(
998
+ operation: TransactionOperation,
999
+ selected: readonly SignatureEnvelope.SignatureEnvelope[],
1000
+ ): void {
1001
+ const retained = operation.approvals.map((approval) =>
1002
+ SignatureEnvelope.deserialize(approval),
1003
+ )
1004
+ for (const approval of selected) {
1005
+ const index = retained.findIndex((candidate) =>
1006
+ includesApproval(candidate, approval),
1007
+ )
1008
+ if (index === -1)
1009
+ throw new InvalidOperationError({
1010
+ reason: 'transaction signature is not a retained approval',
1011
+ })
1012
+ retained.splice(index, 1)
1013
+ }
1014
+ }
1015
+
529
1016
  /**
530
1017
  * Checks that a successful key authorization uses retained approvals in canonical order.
531
1018
  *
@@ -681,6 +1168,44 @@ function sameConfig(
681
1168
  )
682
1169
  }
683
1170
 
1171
+ /** Thrown when a multisig owner approval is invalid. */
1172
+ export class InvalidApprovalError extends Errors.BaseError<Error | undefined> {
1173
+ override readonly name = 'MultisigOperation.InvalidApprovalError'
1174
+
1175
+ /**
1176
+ * Creates an invalid multisig approval error.
1177
+ *
1178
+ * @example
1179
+ * ```ts twoslash
1180
+ * import { MultisigOperation } from 'ox/tempo'
1181
+ *
1182
+ * throw new MultisigOperation.InvalidApprovalError({
1183
+ * reason: 'signature is from a non-owner',
1184
+ * })
1185
+ * ```
1186
+ *
1187
+ * @param options - Error options.
1188
+ */
1189
+ constructor(options: InvalidApprovalError.Options = {}) {
1190
+ super(
1191
+ options.reason
1192
+ ? `Invalid multisig approval: ${options.reason}.`
1193
+ : 'Invalid multisig approval.',
1194
+ { cause: options.cause as Error | undefined },
1195
+ )
1196
+ }
1197
+ }
1198
+
1199
+ export declare namespace InvalidApprovalError {
1200
+ /** Error construction options. */
1201
+ export type Options = {
1202
+ /** Underlying error. */
1203
+ cause?: unknown | undefined
1204
+ /** Validation failure. */
1205
+ reason?: string | undefined
1206
+ }
1207
+ }
1208
+
684
1209
  /** Thrown when a multisig operation is malformed or internally inconsistent. */
685
1210
  export class InvalidOperationError extends Errors.BaseError<Error | undefined> {
686
1211
  override readonly name = 'MultisigOperation.InvalidOperationError'
package/tempo/index.ts CHANGED
@@ -153,8 +153,8 @@ export * as MultisigConfig from './MultisigConfig.js'
153
153
  /**
154
154
  * Offchain multisig transaction and key authorization operation utilities.
155
155
  *
156
- * Validates operation state, serialized payloads, and deterministic operation
157
- * hashes, and converts between domain and JSON-RPC representations.
156
+ * Derives operation hashes, selects owner approvals, serializes transactions,
157
+ * validates operation state, and converts JSON-RPC representations.
158
158
  *
159
159
  * @category Reference
160
160
  */
package/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** @internal */
2
- export const version = '0.14.37'
2
+ export const version = '0.14.38'