ox 0.14.26 → 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.
Files changed (40) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/_cjs/tempo/MultisigConfig.js +127 -0
  3. package/_cjs/tempo/MultisigConfig.js.map +1 -0
  4. package/_cjs/tempo/ReceivePolicyReceipt.js +80 -0
  5. package/_cjs/tempo/ReceivePolicyReceipt.js.map +1 -0
  6. package/_cjs/tempo/SignatureEnvelope.js +107 -6
  7. package/_cjs/tempo/SignatureEnvelope.js.map +1 -1
  8. package/_cjs/tempo/index.js +3 -1
  9. package/_cjs/tempo/index.js.map +1 -1
  10. package/_cjs/version.js +1 -1
  11. package/_esm/tempo/MultisigConfig.js +312 -0
  12. package/_esm/tempo/MultisigConfig.js.map +1 -0
  13. package/_esm/tempo/ReceivePolicyReceipt.js +176 -0
  14. package/_esm/tempo/ReceivePolicyReceipt.js.map +1 -0
  15. package/_esm/tempo/SignatureEnvelope.js +170 -6
  16. package/_esm/tempo/SignatureEnvelope.js.map +1 -1
  17. package/_esm/tempo/index.js +48 -0
  18. package/_esm/tempo/index.js.map +1 -1
  19. package/_esm/version.js +1 -1
  20. package/_types/tempo/MultisigConfig.d.ts +270 -0
  21. package/_types/tempo/MultisigConfig.d.ts.map +1 -0
  22. package/_types/tempo/ReceivePolicyReceipt.d.ts +168 -0
  23. package/_types/tempo/ReceivePolicyReceipt.d.ts.map +1 -0
  24. package/_types/tempo/SignatureEnvelope.d.ts +106 -6
  25. package/_types/tempo/SignatureEnvelope.d.ts.map +1 -1
  26. package/_types/tempo/index.d.ts +48 -0
  27. package/_types/tempo/index.d.ts.map +1 -1
  28. package/_types/version.d.ts +1 -1
  29. package/package.json +11 -1
  30. package/tempo/MultisigConfig/package.json +6 -0
  31. package/tempo/MultisigConfig.test.ts +227 -0
  32. package/tempo/MultisigConfig.ts +423 -0
  33. package/tempo/ReceivePolicyReceipt/package.json +6 -0
  34. package/tempo/ReceivePolicyReceipt.test.ts +198 -0
  35. package/tempo/ReceivePolicyReceipt.ts +263 -0
  36. package/tempo/SignatureEnvelope.test.ts +213 -2
  37. package/tempo/SignatureEnvelope.ts +257 -9
  38. package/tempo/e2e.test.ts +217 -0
  39. package/tempo/index.ts +48 -0
  40. package/version.ts +1 -1
@@ -13,16 +13,19 @@ import type {
13
13
  import * as Json from '../core/Json.js'
14
14
  import * as ox_P256 from '../core/P256.js'
15
15
  import type * as PublicKey from '../core/PublicKey.js'
16
+ import * as Rlp from '../core/Rlp.js'
16
17
  import * as ox_Secp256k1 from '../core/Secp256k1.js'
17
18
  import * as Signature from '../core/Signature.js'
18
19
  import type * as WebAuthnP256 from '../core/WebAuthnP256.js'
19
20
  import * as ox_WebAuthnP256 from '../core/WebAuthnP256.js'
21
+ import * as MultisigConfig from './MultisigConfig.js'
20
22
 
21
23
  /** Signature type identifiers for encoding/decoding */
22
24
  const serializedP256Type = '0x01'
23
25
  const serializedWebAuthnType = '0x02'
24
26
  const serializedKeychainType = '0x03'
25
27
  const serializedKeychainV2Type = '0x04'
28
+ const serializedMultisigType = '0x05'
26
29
 
27
30
  /** Serialized magic identifier for Tempo signature envelopes. */
28
31
  export const magicBytes =
@@ -69,7 +72,13 @@ export type GetType<
69
72
  userAddress: Address.Address
70
73
  }
71
74
  ? 'keychain'
72
- : never
75
+ : envelope extends {
76
+ account: Address.Address
77
+ configId: `0x${string}`
78
+ signatures: any
79
+ }
80
+ ? 'multisig'
81
+ : never
73
82
 
74
83
  /**
75
84
  * Represents a signature envelope that can contain different signature types.
@@ -99,13 +108,14 @@ export type SignatureEnvelope<bigintType = bigint, numberType = number> = OneOf<
99
108
  | P256<bigintType, numberType>
100
109
  | WebAuthn<bigintType, numberType>
101
110
  | Keychain<bigintType, numberType>
111
+ | Multisig<bigintType, numberType>
102
112
  >
103
113
 
104
114
  /**
105
115
  * RPC-formatted signature envelope.
106
116
  */
107
117
  export type SignatureEnvelopeRpc = OneOf<
108
- Secp256k1Rpc | P256Rpc | WebAuthnRpc | KeychainRpc
118
+ Secp256k1Rpc | P256Rpc | WebAuthnRpc | KeychainRpc | MultisigRpc
109
119
  >
110
120
 
111
121
  /**
@@ -137,6 +147,43 @@ export type KeychainRpc = {
137
147
  version?: KeychainVersion | undefined
138
148
  }
139
149
 
150
+ /**
151
+ * Native multisig signature (type `0x05`).
152
+ *
153
+ * Wraps a set of primitive owner approvals (secp256k1, p256, or webAuthn) over the
154
+ * multisig owner approval digest. The transaction sender is the derived `account`,
155
+ * authorized once the recovered owner weights meet the configured threshold.
156
+ *
157
+ * [TIP-1061](https://tips.sh/1061)
158
+ */
159
+ export type Multisig<bigintType = bigint, numberType = number> = {
160
+ type: 'multisig'
161
+ /** Native multisig account address. */
162
+ account: Address.Address
163
+ /** Permanent config ID derived from the initial multisig config. */
164
+ configId: Hex.Hex
165
+ /** Primitive owner approvals over the multisig owner approval digest. */
166
+ signatures: readonly SignatureEnvelope<bigintType, numberType>[]
167
+ /**
168
+ * Initial native multisig config for bootstrapping this account. Present only on
169
+ * the first (bootstrap) transaction from the derived account; absent on every
170
+ * subsequent transaction.
171
+ */
172
+ init?: MultisigConfig.Config<numberType> | undefined
173
+ }
174
+
175
+ export type MultisigRpc = {
176
+ type: 'multisig'
177
+ account: Address.Address
178
+ configId: Hex.Hex
179
+ /**
180
+ * Encoded primitive owner approvals (raw serialized signatures), matching the
181
+ * node's `Vec<Bytes>` representation.
182
+ */
183
+ signatures: readonly Serialized[]
184
+ init?: MultisigConfig.Config | undefined
185
+ }
186
+
140
187
  export type P256<bigintType = bigint, numberType = number> = {
141
188
  prehash: boolean
142
189
  publicKey: PublicKey.PublicKey
@@ -276,12 +323,26 @@ export function assert(envelope: PartialBy<SignatureEnvelope, 'type'>): void {
276
323
  assert(keychain.inner)
277
324
  return
278
325
  }
326
+
327
+ if (type === 'multisig') {
328
+ const multisig = envelope as Multisig
329
+ const missing: string[] = []
330
+ if (!multisig.account) missing.push('account')
331
+ if (!multisig.configId) missing.push('configId')
332
+ if (!Array.isArray(multisig.signatures)) missing.push('signatures')
333
+ if (missing.length > 0)
334
+ throw new MissingPropertiesError({ envelope, missing, type: 'multisig' })
335
+ for (const inner of multisig.signatures) assert(inner)
336
+ if (multisig.init) MultisigConfig.assert(multisig.init)
337
+ return
338
+ }
279
339
  }
280
340
 
281
341
  export declare namespace assert {
282
342
  type ErrorType =
283
343
  | CoercionError
284
344
  | MissingPropertiesError
345
+ | MultisigConfig.assert.ErrorType
285
346
  | Signature.assert.ErrorType
286
347
  | Errors.GlobalErrorType
287
348
  }
@@ -319,6 +380,9 @@ export function extractAddress(
319
380
  if (root) return signature.userAddress
320
381
  return extractAddress({ ...options, signature: signature.inner })
321
382
  }
383
+ // Native multisig signatures have no single signer; the recovered sender is the
384
+ // derived multisig account address.
385
+ if (signature.type === 'multisig') return signature.account
322
386
  return Address.fromPublicKey(extractPublicKey(options))
323
387
  }
324
388
 
@@ -381,6 +445,10 @@ export function extractPublicKey(
381
445
  return signature.publicKey
382
446
  case 'keychain':
383
447
  return extractPublicKey({ payload, signature: signature.inner })
448
+ case 'multisig':
449
+ // A multisig signature aggregates multiple owner approvals and has no
450
+ // single public key; recover the multisig account via `extractAddress`.
451
+ throw new CoercionError({ envelope: signature })
384
452
  }
385
453
  }
386
454
 
@@ -395,6 +463,7 @@ export declare namespace extractPublicKey {
395
463
  type ReturnType = PublicKey.PublicKey
396
464
 
397
465
  type ErrorType =
466
+ | CoercionError
398
467
  | ox_Secp256k1.recoverPublicKey.ErrorType
399
468
  | Errors.GlobalErrorType
400
469
  }
@@ -543,8 +612,31 @@ export function deserialize(value: Serialized): SignatureEnvelope {
543
612
  } satisfies Keychain
544
613
  }
545
614
 
615
+ if (typeId === serializedMultisigType) {
616
+ // Wire format: `0x05 || rlp([account, configId, signatures, init])`. `init`
617
+ // is optional: absent when the element is missing or the `0x80` placeholder
618
+ // (decoded as the empty string `0x`), otherwise the bootstrap config list.
619
+ const [account, configId, signatures, init] = Rlp.toHex(data) as [
620
+ Hex.Hex,
621
+ Hex.Hex,
622
+ readonly Hex.Hex[],
623
+ (Hex.Hex | MultisigConfig.Tuple)?,
624
+ ]
625
+ return {
626
+ type: 'multisig',
627
+ account,
628
+ configId,
629
+ signatures: signatures.map((signature) => deserialize(signature)),
630
+ ...(init && init !== '0x'
631
+ ? {
632
+ init: MultisigConfig.fromTuple(init as MultisigConfig.Tuple),
633
+ }
634
+ : {}),
635
+ } satisfies Multisig
636
+ }
637
+
546
638
  throw new InvalidSerializedError({
547
- reason: `Unknown signature type identifier: ${typeId}. Expected ${serializedP256Type} (P256), ${serializedWebAuthnType} (WebAuthn), ${serializedKeychainType} (Keychain V1), or ${serializedKeychainV2Type} (Keychain V2)`,
639
+ reason: `Unknown signature type identifier: ${typeId}. Expected ${serializedP256Type} (P256), ${serializedWebAuthnType} (WebAuthn), ${serializedKeychainType} (Keychain V1), ${serializedKeychainV2Type} (Keychain V2), or ${serializedMultisigType} (Multisig)`,
548
640
  serialized,
549
641
  })
550
642
  }
@@ -680,6 +772,19 @@ export function from<const value extends from.Value>(
680
772
  return { signature: value, type: 'secp256k1' } as never
681
773
 
682
774
  const type = getType(value)
775
+
776
+ if (type === 'multisig') {
777
+ const multisig = value as Multisig
778
+ return {
779
+ ...multisig,
780
+ signatures: multisig.signatures.map((signature) => from(signature)),
781
+ // Normalize the bootstrap config (sorts owners, defaults the salt) so the
782
+ // in-memory envelope matches what `deserialize` reconstructs.
783
+ ...(multisig.init ? { init: MultisigConfig.from(multisig.init) } : {}),
784
+ type,
785
+ } as never
786
+ }
787
+
683
788
  return {
684
789
  ...value,
685
790
  ...(type === 'p256' ? { prehash: value.prehash } : {}),
@@ -837,14 +942,35 @@ export function fromRpc(envelope: SignatureEnvelopeRpc): SignatureEnvelope {
837
942
  if (
838
943
  envelope.type === 'keychain' ||
839
944
  ('userAddress' in envelope && 'signature' in envelope)
840
- )
945
+ ) {
946
+ const keychain = envelope as KeychainRpc
841
947
  return {
842
948
  type: 'keychain',
843
- userAddress: envelope.userAddress,
844
- inner: fromRpc(envelope.signature),
845
- ...(envelope.keyId ? { keyId: envelope.keyId } : {}),
846
- ...(envelope.version ? { version: envelope.version } : {}),
949
+ userAddress: keychain.userAddress,
950
+ inner: fromRpc(keychain.signature),
951
+ ...(keychain.keyId ? { keyId: keychain.keyId } : {}),
952
+ ...(keychain.version ? { version: keychain.version } : {}),
847
953
  }
954
+ }
955
+
956
+ if (
957
+ envelope.type === 'multisig' ||
958
+ ('account' in envelope &&
959
+ 'configId' in envelope &&
960
+ 'signatures' in envelope)
961
+ ) {
962
+ const multisig = envelope as MultisigRpc
963
+ return {
964
+ type: 'multisig',
965
+ account: multisig.account,
966
+ configId: multisig.configId,
967
+ // Owner approvals are raw serialized signatures (node `Vec<Bytes>`).
968
+ signatures: multisig.signatures.map((signature) =>
969
+ deserialize(signature),
970
+ ),
971
+ ...(multisig.init ? { init: MultisigConfig.from(multisig.init) } : {}),
972
+ }
973
+ }
848
974
 
849
975
  throw new CoercionError({ envelope })
850
976
  }
@@ -922,6 +1048,14 @@ export function getType<
922
1048
  if ('userAddress' in envelope && 'inner' in envelope)
923
1049
  return 'keychain' as never
924
1050
 
1051
+ // Detect Multisig signature
1052
+ if (
1053
+ 'account' in envelope &&
1054
+ 'configId' in envelope &&
1055
+ 'signatures' in envelope
1056
+ )
1057
+ return 'multisig' as never
1058
+
925
1059
  throw new CoercionError({
926
1060
  envelope,
927
1061
  })
@@ -1015,6 +1149,24 @@ export function serialize(
1015
1149
  )
1016
1150
  }
1017
1151
 
1152
+ if (type === 'multisig') {
1153
+ const multisig = envelope as Multisig
1154
+ // Format: `0x05 || rlp([account, configId, signatures, init])`, where each
1155
+ // owner approval is an encoded primitive signature. `init` is the bootstrap
1156
+ // config (an RLP list) when present, otherwise the canonical empty-string
1157
+ // placeholder (`0x` → RLP `0x80`).
1158
+ return Hex.concat(
1159
+ serializedMultisigType,
1160
+ Rlp.fromHex([
1161
+ multisig.account,
1162
+ multisig.configId,
1163
+ multisig.signatures.map((signature) => serialize(signature)),
1164
+ multisig.init ? MultisigConfig.toTuple(multisig.init) : '0x',
1165
+ ]),
1166
+ options.magic ? magicBytes : '0x',
1167
+ )
1168
+ }
1169
+
1018
1170
  throw new CoercionError({ envelope })
1019
1171
  }
1020
1172
 
@@ -1028,6 +1180,90 @@ export declare namespace serialize {
1028
1180
  }
1029
1181
  }
1030
1182
 
1183
+ /**
1184
+ * Orders native multisig owner approvals into the strictly-ascending
1185
+ * recovered-owner order the Tempo node requires for the multisig `signatures`
1186
+ * array (the node enforces "recovered owners must be strictly ascending").
1187
+ *
1188
+ * Each approval is signed over the multisig owner approval digest
1189
+ * ({@link ox#MultisigConfig.(getSignPayload:function)}), so the signer of
1190
+ * every approval is recovered against that digest and the list is sorted by the
1191
+ * recovered owner address. Works for any owner key type (secp256k1, p256,
1192
+ * webAuthn, keychain).
1193
+ *
1194
+ * @example
1195
+ * ```ts twoslash
1196
+ * import { Secp256k1 } from 'ox'
1197
+ * import { MultisigConfig, SignatureEnvelope, TxEnvelopeTempo } from 'ox/tempo'
1198
+ *
1199
+ * const config = MultisigConfig.from({
1200
+ * threshold: 2,
1201
+ * owners: [
1202
+ * { owner: '0x1111111111111111111111111111111111111111', weight: 1 },
1203
+ * { owner: '0x2222222222222222222222222222222222222222', weight: 1 },
1204
+ * ],
1205
+ * })
1206
+ * const configId = MultisigConfig.toId(config)
1207
+ * const account = MultisigConfig.getAddress({ configId })
1208
+ *
1209
+ * const tx = TxEnvelopeTempo.from({ chainId: 1, calls: [] })
1210
+ * const payload = TxEnvelopeTempo.getSignPayload(tx)
1211
+ * const digest = MultisigConfig.getSignPayload({ payload, account, configId })
1212
+ *
1213
+ * const privateKeys = [Secp256k1.randomPrivateKey(), Secp256k1.randomPrivateKey()]
1214
+ * const signatures = privateKeys.map((privateKey) =>
1215
+ * SignatureEnvelope.from(Secp256k1.sign({ payload: digest, privateKey })),
1216
+ * )
1217
+ *
1218
+ * const ordered = SignatureEnvelope.sortMultisigApprovals({ // [!code focus]
1219
+ * account, // [!code focus]
1220
+ * configId, // [!code focus]
1221
+ * payload, // [!code focus]
1222
+ * signatures, // [!code focus]
1223
+ * }) // [!code focus]
1224
+ * ```
1225
+ *
1226
+ * @param value - The approval ordering parameters.
1227
+ * @returns The owner approvals ordered ascending by recovered owner address.
1228
+ */
1229
+ export function sortMultisigApprovals(
1230
+ value: sortMultisigApprovals.Value,
1231
+ ): readonly SignatureEnvelope[] {
1232
+ const { account, configId, payload, signatures } = value
1233
+ const digest = MultisigConfig.getSignPayload({
1234
+ account,
1235
+ configId,
1236
+ payload,
1237
+ })
1238
+ // Recover each signer once (decorate–sort–undecorate) rather than inside the
1239
+ // comparator.
1240
+ return signatures
1241
+ .map((signature) => ({
1242
+ key: Hex.toBigInt(extractAddress({ payload: digest, signature })),
1243
+ signature,
1244
+ }))
1245
+ .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
1246
+ .map((entry) => entry.signature)
1247
+ }
1248
+
1249
+ export declare namespace sortMultisigApprovals {
1250
+ type Value = {
1251
+ /** The native multisig account address. */
1252
+ account: Address.Address
1253
+ /** The permanent config ID. */
1254
+ configId: Hex.Hex
1255
+ /** The inner transaction sign payload (`tx.signature_hash()`). */
1256
+ payload: Hex.Hex | Bytes.Bytes
1257
+ /** The primitive owner approvals to order. */
1258
+ signatures: readonly SignatureEnvelope[]
1259
+ }
1260
+
1261
+ type ErrorType =
1262
+ | MultisigConfig.getSignPayload.ErrorType
1263
+ | extractAddress.ErrorType
1264
+ | Errors.GlobalErrorType
1265
+ }
1266
+
1031
1267
  /**
1032
1268
  * Converts a signature envelope to RPC format.
1033
1269
  *
@@ -1095,6 +1331,18 @@ export function toRpc(envelope: SignatureEnvelope): SignatureEnvelopeRpc {
1095
1331
  }
1096
1332
  }
1097
1333
 
1334
+ if (type === 'multisig') {
1335
+ const multisig = envelope as Multisig
1336
+ return {
1337
+ type: 'multisig',
1338
+ account: multisig.account,
1339
+ configId: multisig.configId,
1340
+ // Owner approvals are raw serialized signatures (node `Vec<Bytes>`).
1341
+ signatures: multisig.signatures.map((signature) => serialize(signature)),
1342
+ ...(multisig.init ? { init: multisig.init } : {}),
1343
+ }
1344
+ }
1345
+
1098
1346
  throw new CoercionError({ envelope })
1099
1347
  }
1100
1348
 
@@ -1332,7 +1580,7 @@ export class MissingPropertiesError extends Errors.BaseError {
1332
1580
  }: {
1333
1581
  envelope: unknown
1334
1582
  missing: string[]
1335
- type: Type
1583
+ type: Type | 'keychain' | 'multisig'
1336
1584
  }) {
1337
1585
  super(
1338
1586
  `Signature envelope of type "${type}" is missing required properties: ${missing.map((m) => `\`${m}\``).join(', ')}.\n\nProvided: ${Json.stringify(envelope)}`,
package/tempo/e2e.test.ts CHANGED
@@ -14,6 +14,7 @@ import { chain, client, fundAddress, nodeEnv } from '../../test/tempo/config.js'
14
14
  import {
15
15
  AuthorizationTempo,
16
16
  KeyAuthorization,
17
+ MultisigConfig,
17
18
  Period,
18
19
  SignatureEnvelope,
19
20
  } from './index.js'
@@ -2826,6 +2827,11 @@ describe('behavior: keyAuthorization', () => {
2826
2827
  },
2827
2828
  )
2828
2829
 
2830
+ // TIP-1049 admin keys are not yet in any released tempo tag (only on
2831
+ // `main` via PR #4265). CI runs the localnet job against the `edge`
2832
+ // image which tracks main; devnet/testnet still ship the older
2833
+ // release and would reject the 9-field wire format with
2834
+ // `failed to decode signed transaction`.
2829
2835
  // TODO: remove skipIf when devnet/testnet have T6 (TIP-1049).
2830
2836
  test.skipIf(nodeEnv !== 'localnet')(
2831
2837
  'behavior: TIP-1049 admin access key round-trips through registration',
@@ -2993,3 +2999,214 @@ describe('behavior: keyAuthorization', () => {
2993
2999
  },
2994
3000
  )
2995
3001
  })
3002
+
3003
+ // TODO: unskip once TIP-1061 native multisig is deployed to the standard
3004
+ // localnet/testnet nodes. Until then these only pass against the dedicated
3005
+ // PR-5178 devnet (run with VITE_TEMPO_RPC_URL pointed at it).
3006
+ describe.skip('behavior: multisig (TIP-1061)', () => {
3007
+ // Helper: builds a fresh set of secp256k1 owners + the derived config.
3008
+ function setup(parameters: { count: number; threshold: number }) {
3009
+ const { count, threshold } = parameters
3010
+ const ownerKeys = Array.from({ length: count }, () => {
3011
+ const privateKey = Secp256k1.randomPrivateKey()
3012
+ const address = Address.fromPublicKey(
3013
+ Secp256k1.getPublicKey({ privateKey }),
3014
+ )
3015
+ return { address, privateKey } as const
3016
+ })
3017
+
3018
+ const config = MultisigConfig.from({
3019
+ // A fresh random salt yields a distinct account each run, exercising the
3020
+ // salt-inclusive config-ID derivation against the node.
3021
+ salt: Hex.random(32),
3022
+ threshold,
3023
+ owners: ownerKeys.map((key) => ({
3024
+ owner: key.address,
3025
+ weight: 1,
3026
+ })),
3027
+ })
3028
+ const configId = MultisigConfig.toId(config)
3029
+ const account = MultisigConfig.getAddress({ configId })
3030
+
3031
+ return { account, config, configId, ownerKeys } as const
3032
+ }
3033
+
3034
+ // Signs the multisig owner digest with the provided owner keys, returning
3035
+ // primitive approval envelopes ordered strictly ascending by recovered owner
3036
+ // address (required by the node: "recovered owners must be strictly
3037
+ // ascending").
3038
+ function approve(parameters: {
3039
+ account: Address.Address
3040
+ configId: Hex.Hex
3041
+ payload: Hex.Hex
3042
+ signers: readonly { privateKey: Hex.Hex }[]
3043
+ }) {
3044
+ const { account, configId, payload, signers } = parameters
3045
+ const digest = MultisigConfig.getSignPayload({
3046
+ account,
3047
+ configId,
3048
+ payload,
3049
+ })
3050
+ const signatures = signers.map((signer) =>
3051
+ SignatureEnvelope.from(
3052
+ Secp256k1.sign({ payload: digest, privateKey: signer.privateKey }),
3053
+ ),
3054
+ )
3055
+ return SignatureEnvelope.sortMultisigApprovals({
3056
+ account,
3057
+ configId,
3058
+ payload,
3059
+ signatures,
3060
+ })
3061
+ }
3062
+
3063
+ test('behavior: bootstrap + spend (2-of-3 secp256k1)', async () => {
3064
+ const { account, config, configId, ownerKeys } = setup({
3065
+ count: 3,
3066
+ threshold: 2,
3067
+ })
3068
+
3069
+ // The derived multisig account pays its own fees.
3070
+ await fundAddress(client, { address: account })
3071
+
3072
+ // Bootstrap (first transaction): the bootstrap config travels in the
3073
+ // multisig signature `init`, nonce 0.
3074
+ const bootstrap = TxEnvelopeTempo.from({
3075
+ calls: [{ to: '0x0000000000000000000000000000000000000000' }],
3076
+ chainId,
3077
+ feeToken: '0x20c0000000000000000000000000000000000001',
3078
+ nonce: 0n,
3079
+ gas: 2_000_000n,
3080
+ maxFeePerGas: Value.fromGwei('20'),
3081
+ maxPriorityFeePerGas: Value.fromGwei('10'),
3082
+ })
3083
+
3084
+ const bootstrap_signed = TxEnvelopeTempo.serialize(bootstrap, {
3085
+ signature: SignatureEnvelope.from({
3086
+ type: 'multisig',
3087
+ account,
3088
+ configId,
3089
+ // The bootstrap config is carried by the signature `init`.
3090
+ init: config,
3091
+ // Approve with 2 of the 3 owners to satisfy the threshold.
3092
+ signatures: approve({
3093
+ account,
3094
+ configId,
3095
+ payload: TxEnvelopeTempo.getSignPayload(bootstrap),
3096
+ signers: [ownerKeys[0]!, ownerKeys[1]!],
3097
+ }),
3098
+ }),
3099
+ })
3100
+
3101
+ const bootstrap_receipt = (await client
3102
+ .request({
3103
+ method: 'eth_sendRawTransactionSync',
3104
+ params: [bootstrap_signed],
3105
+ })
3106
+ .then((tx) => TransactionReceipt.fromRpc(tx as any)))!
3107
+ expect(bootstrap_receipt).toBeDefined()
3108
+ expect(bootstrap_receipt.status).toBe('success')
3109
+ expect(bootstrap_receipt.from).toBe(account)
3110
+
3111
+ {
3112
+ const response = await client
3113
+ .request({
3114
+ method: 'eth_getTransactionByHash',
3115
+ params: [bootstrap_receipt.transactionHash],
3116
+ })
3117
+ .then((tx) => Transaction.fromRpc(tx as any))
3118
+ if (!response) throw new Error()
3119
+ expect(response.from).toBe(account)
3120
+ expect(response.signature?.type).toBe('multisig')
3121
+ // The bootstrap config is carried by the multisig signature `init`.
3122
+ expect(
3123
+ (response.signature as SignatureEnvelope.Multisig | undefined)?.init,
3124
+ ).toEqual(config)
3125
+ }
3126
+
3127
+ // Spend (subsequent transaction): no signature `init`, nonce 1, uses the
3128
+ // stored config loaded by the node.
3129
+ const nonce = await getTransactionCount(client, {
3130
+ address: account,
3131
+ blockTag: 'pending',
3132
+ })
3133
+
3134
+ const spend = TxEnvelopeTempo.from({
3135
+ calls: [{ to: '0x0000000000000000000000000000000000000000' }],
3136
+ chainId,
3137
+ feeToken: '0x20c0000000000000000000000000000000000001',
3138
+ nonce: BigInt(nonce),
3139
+ gas: 2_000_000n,
3140
+ maxFeePerGas: Value.fromGwei('20'),
3141
+ maxPriorityFeePerGas: Value.fromGwei('10'),
3142
+ })
3143
+
3144
+ const spend_signed = TxEnvelopeTempo.serialize(spend, {
3145
+ signature: SignatureEnvelope.from({
3146
+ type: 'multisig',
3147
+ account,
3148
+ configId,
3149
+ // A different 2-of-3 subset still authorizes the transaction.
3150
+ signatures: approve({
3151
+ account,
3152
+ configId,
3153
+ payload: TxEnvelopeTempo.getSignPayload(spend),
3154
+ signers: [ownerKeys[1]!, ownerKeys[2]!],
3155
+ }),
3156
+ }),
3157
+ })
3158
+
3159
+ const spend_receipt = (await client
3160
+ .request({
3161
+ method: 'eth_sendRawTransactionSync',
3162
+ params: [spend_signed],
3163
+ })
3164
+ .then((tx) => TransactionReceipt.fromRpc(tx as any)))!
3165
+ expect(spend_receipt).toBeDefined()
3166
+ expect(spend_receipt.status).toBe('success')
3167
+ expect(spend_receipt.from).toBe(account)
3168
+ })
3169
+
3170
+ test('behavior: rejects below-threshold approvals', async () => {
3171
+ const { account, config, configId, ownerKeys } = setup({
3172
+ count: 3,
3173
+ threshold: 2,
3174
+ })
3175
+
3176
+ await fundAddress(client, { address: account })
3177
+
3178
+ const bootstrap = TxEnvelopeTempo.from({
3179
+ calls: [{ to: '0x0000000000000000000000000000000000000000' }],
3180
+ chainId,
3181
+ feeToken: '0x20c0000000000000000000000000000000000001',
3182
+ nonce: 0n,
3183
+ gas: 2_000_000n,
3184
+ maxFeePerGas: Value.fromGwei('20'),
3185
+ maxPriorityFeePerGas: Value.fromGwei('10'),
3186
+ })
3187
+
3188
+ const serialized_signed = TxEnvelopeTempo.serialize(bootstrap, {
3189
+ signature: SignatureEnvelope.from({
3190
+ type: 'multisig',
3191
+ account,
3192
+ configId,
3193
+ // The bootstrap config is carried by the signature `init`.
3194
+ init: config,
3195
+ // Only one approval — below the threshold of 2.
3196
+ signatures: approve({
3197
+ account,
3198
+ configId,
3199
+ payload: TxEnvelopeTempo.getSignPayload(bootstrap),
3200
+ signers: [ownerKeys[0]!],
3201
+ }),
3202
+ }),
3203
+ })
3204
+
3205
+ await expect(
3206
+ client.request({
3207
+ method: 'eth_sendRawTransactionSync',
3208
+ params: [serialized_signed],
3209
+ }),
3210
+ ).rejects.toThrow()
3211
+ })
3212
+ })
package/tempo/index.ts CHANGED
@@ -102,6 +102,32 @@ export * as Channel from './Channel.js'
102
102
  * @category Reference
103
103
  */
104
104
  export * as KeyAuthorization from './KeyAuthorization.js'
105
+ /**
106
+ * Native multisig account utilities (TIP-1061).
107
+ *
108
+ * Derives stable multisig account addresses and permanent config IDs from a weighted
109
+ * owner configuration, and computes the owner approval digest that owners sign.
110
+ *
111
+ * [TIP-1061](https://tips.sh/1061)
112
+ *
113
+ * @example
114
+ * ```ts twoslash
115
+ * import { MultisigConfig } from 'ox/tempo'
116
+ *
117
+ * const config = MultisigConfig.from({
118
+ * threshold: 2,
119
+ * owners: [
120
+ * { owner: '0x1111111111111111111111111111111111111111', weight: 1 },
121
+ * { owner: '0x2222222222222222222222222222222222222222', weight: 1 },
122
+ * ],
123
+ * })
124
+ *
125
+ * const account = MultisigConfig.getAddress({ config })
126
+ * ```
127
+ *
128
+ * @category Reference
129
+ */
130
+ export * as MultisigConfig from './MultisigConfig.js'
105
131
  /**
106
132
  * Utilities for constructing period durations (in seconds) for recurring spending limits.
107
133
  *
@@ -153,6 +179,28 @@ export * as Period from './Period.js'
153
179
  * @category Reference
154
180
  */
155
181
  export * as PoolId from './PoolId.js'
182
+ /**
183
+ * TIP-1028 receive-policy claim receipt utilities.
184
+ *
185
+ * When an inbound transfer or mint violates the recipient's receive policy, the
186
+ * funds are redirected to the `ReceivePolicyGuard` and a `ClaimReceiptV1`
187
+ * witness is emitted. This module decodes those witnesses (required to later
188
+ * `claim` or `burn` the blocked funds) from raw bytes or transaction receipts.
189
+ *
190
+ * [TIP-1028](https://docs.tempo.xyz/protocol/tips/tip-1028)
191
+ *
192
+ * @example
193
+ * ```ts twoslash
194
+ * // @noErrors
195
+ * import { ReceivePolicyReceipt } from 'ox/tempo'
196
+ *
197
+ * const receipts = ReceivePolicyReceipt.fromTransactionReceipt(receipt)
198
+ * const decoded = ReceivePolicyReceipt.decode('0x...')
199
+ * ```
200
+ *
201
+ * @category Reference
202
+ */
203
+ export * as ReceivePolicyReceipt from './ReceivePolicyReceipt.js'
156
204
  /**
157
205
  * Union of all JSON-RPC Methods for the `tempo_` namespace.
158
206
  *
package/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** @internal */
2
- export const version = '0.14.26'
2
+ export const version = '0.14.28'