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.
- package/CHANGELOG.md +12 -0
- package/_cjs/tempo/MultisigConfig.js +127 -0
- package/_cjs/tempo/MultisigConfig.js.map +1 -0
- package/_cjs/tempo/ReceivePolicyReceipt.js +80 -0
- package/_cjs/tempo/ReceivePolicyReceipt.js.map +1 -0
- package/_cjs/tempo/SignatureEnvelope.js +107 -6
- package/_cjs/tempo/SignatureEnvelope.js.map +1 -1
- package/_cjs/tempo/index.js +3 -1
- package/_cjs/tempo/index.js.map +1 -1
- package/_cjs/version.js +1 -1
- package/_esm/tempo/MultisigConfig.js +312 -0
- package/_esm/tempo/MultisigConfig.js.map +1 -0
- package/_esm/tempo/ReceivePolicyReceipt.js +176 -0
- package/_esm/tempo/ReceivePolicyReceipt.js.map +1 -0
- package/_esm/tempo/SignatureEnvelope.js +170 -6
- package/_esm/tempo/SignatureEnvelope.js.map +1 -1
- package/_esm/tempo/index.js +48 -0
- package/_esm/tempo/index.js.map +1 -1
- package/_esm/version.js +1 -1
- package/_types/tempo/MultisigConfig.d.ts +270 -0
- package/_types/tempo/MultisigConfig.d.ts.map +1 -0
- package/_types/tempo/ReceivePolicyReceipt.d.ts +168 -0
- package/_types/tempo/ReceivePolicyReceipt.d.ts.map +1 -0
- package/_types/tempo/SignatureEnvelope.d.ts +106 -6
- package/_types/tempo/SignatureEnvelope.d.ts.map +1 -1
- package/_types/tempo/index.d.ts +48 -0
- package/_types/tempo/index.d.ts.map +1 -1
- package/_types/version.d.ts +1 -1
- package/package.json +11 -1
- package/tempo/MultisigConfig/package.json +6 -0
- package/tempo/MultisigConfig.test.ts +227 -0
- package/tempo/MultisigConfig.ts +423 -0
- package/tempo/ReceivePolicyReceipt/package.json +6 -0
- package/tempo/ReceivePolicyReceipt.test.ts +198 -0
- package/tempo/ReceivePolicyReceipt.ts +263 -0
- package/tempo/SignatureEnvelope.test.ts +213 -2
- package/tempo/SignatureEnvelope.ts +257 -9
- package/tempo/e2e.test.ts +217 -0
- package/tempo/index.ts +48 -0
- 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
|
-
:
|
|
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),
|
|
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:
|
|
844
|
-
inner: fromRpc(
|
|
845
|
-
...(
|
|
846
|
-
...(
|
|
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.
|
|
2
|
+
export const version = '0.14.28'
|