@openzeppelin/miden-multisig-client 0.13.1 → 0.13.2

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 (275) hide show
  1. package/README.md +98 -119
  2. package/dist/account/builder.d.ts +18 -0
  3. package/dist/account/builder.d.ts.map +1 -1
  4. package/dist/account/builder.js +59 -29
  5. package/dist/account/builder.js.map +1 -1
  6. package/dist/account/builder.test.js +108 -32
  7. package/dist/account/builder.test.js.map +1 -1
  8. package/dist/account/index.d.ts +1 -1
  9. package/dist/account/index.d.ts.map +1 -1
  10. package/dist/account/index.js +1 -1
  11. package/dist/account/index.js.map +1 -1
  12. package/dist/account/masm/account-components/auth.d.ts +5 -0
  13. package/dist/account/masm/account-components/auth.d.ts.map +1 -0
  14. package/dist/account/masm/account-components/auth.js +67 -0
  15. package/dist/account/masm/account-components/auth.js.map +1 -0
  16. package/dist/account/masm/auth.d.ts +5 -0
  17. package/dist/account/masm/auth.d.ts.map +1 -0
  18. package/dist/account/masm/auth.js +1395 -0
  19. package/dist/account/masm/auth.js.map +1 -0
  20. package/dist/account/masm/index.d.ts +3 -0
  21. package/dist/account/masm/index.d.ts.map +1 -0
  22. package/dist/account/masm/index.js +5 -0
  23. package/dist/account/masm/index.js.map +1 -0
  24. package/dist/account/masm.d.ts +1 -4
  25. package/dist/account/masm.d.ts.map +1 -1
  26. package/dist/account/masm.js +3 -1034
  27. package/dist/account/masm.js.map +1 -1
  28. package/dist/account/storage.d.ts +2 -2
  29. package/dist/account/storage.d.ts.map +1 -1
  30. package/dist/account/storage.js +18 -16
  31. package/dist/account/storage.js.map +1 -1
  32. package/dist/client.d.ts +67 -9
  33. package/dist/client.d.ts.map +1 -1
  34. package/dist/client.js +73 -22
  35. package/dist/client.js.map +1 -1
  36. package/dist/client.test.js +31 -32
  37. package/dist/client.test.js.map +1 -1
  38. package/dist/index.d.ts +54 -12
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +53 -9
  41. package/dist/index.js.map +1 -1
  42. package/dist/inspector.d.ts +32 -6
  43. package/dist/inspector.d.ts.map +1 -1
  44. package/dist/inspector.js +58 -23
  45. package/dist/inspector.js.map +1 -1
  46. package/dist/inspector.test.js +18 -18
  47. package/dist/inspector.test.js.map +1 -1
  48. package/dist/multisig/proposal/execution.d.ts +4 -4
  49. package/dist/multisig/proposal/execution.d.ts.map +1 -1
  50. package/dist/multisig/proposal/execution.js +42 -31
  51. package/dist/multisig/proposal/execution.js.map +1 -1
  52. package/dist/multisig/proposal/parser.d.ts +3 -3
  53. package/dist/multisig/proposal/parser.d.ts.map +1 -1
  54. package/dist/multisig/proposal/parser.js +23 -23
  55. package/dist/multisig/proposal/parser.js.map +1 -1
  56. package/dist/multisig/signing.d.ts +3 -3
  57. package/dist/multisig/signing.d.ts.map +1 -1
  58. package/dist/multisig/signing.js +3 -3
  59. package/dist/multisig/signing.js.map +1 -1
  60. package/dist/multisig.d.ts +188 -48
  61. package/dist/multisig.d.ts.map +1 -1
  62. package/dist/multisig.js +650 -308
  63. package/dist/multisig.js.map +1 -1
  64. package/dist/multisig.test.js +1680 -241
  65. package/dist/multisig.test.js.map +1 -1
  66. package/dist/procedures.d.ts +46 -24
  67. package/dist/procedures.d.ts.map +1 -1
  68. package/dist/procedures.js +46 -21
  69. package/dist/procedures.js.map +1 -1
  70. package/dist/proposal/factory.d.ts +18 -0
  71. package/dist/proposal/factory.d.ts.map +1 -0
  72. package/dist/proposal/factory.js +99 -0
  73. package/dist/proposal/factory.js.map +1 -0
  74. package/dist/proposal/metadata.d.ts +5 -12
  75. package/dist/proposal/metadata.d.ts.map +1 -1
  76. package/dist/proposal/metadata.js +118 -174
  77. package/dist/proposal/metadata.js.map +1 -1
  78. package/dist/proposal/signatures.d.ts +10 -0
  79. package/dist/proposal/signatures.d.ts.map +1 -0
  80. package/dist/proposal/signatures.js +54 -0
  81. package/dist/proposal/signatures.js.map +1 -0
  82. package/dist/signer.d.ts +2 -0
  83. package/dist/signer.d.ts.map +1 -1
  84. package/dist/signer.js +2 -0
  85. package/dist/signer.js.map +1 -1
  86. package/dist/signers/ecdsa.d.ts +3 -0
  87. package/dist/signers/ecdsa.d.ts.map +1 -1
  88. package/dist/signers/ecdsa.js +8 -4
  89. package/dist/signers/ecdsa.js.map +1 -1
  90. package/dist/signers/ecdsa.test.d.ts +2 -0
  91. package/dist/signers/ecdsa.test.d.ts.map +1 -0
  92. package/dist/signers/ecdsa.test.js +65 -0
  93. package/dist/signers/ecdsa.test.js.map +1 -0
  94. package/dist/signers/falcon.d.ts +3 -0
  95. package/dist/signers/falcon.d.ts.map +1 -1
  96. package/dist/signers/falcon.js +8 -4
  97. package/dist/signers/falcon.js.map +1 -1
  98. package/dist/signers/falcon.test.js +12 -0
  99. package/dist/signers/falcon.test.js.map +1 -1
  100. package/dist/signers/miden-wallet.d.ts +3 -1
  101. package/dist/signers/miden-wallet.d.ts.map +1 -1
  102. package/dist/signers/miden-wallet.js +11 -2
  103. package/dist/signers/miden-wallet.js.map +1 -1
  104. package/dist/signers/miden-wallet.test.js +26 -0
  105. package/dist/signers/miden-wallet.test.js.map +1 -1
  106. package/dist/signers/para.d.ts +3 -3
  107. package/dist/signers/para.d.ts.map +1 -1
  108. package/dist/signers/para.js +15 -6
  109. package/dist/signers/para.js.map +1 -1
  110. package/dist/signers/para.test.js +39 -0
  111. package/dist/signers/para.test.js.map +1 -1
  112. package/dist/transaction/consumeNotes.d.ts.map +1 -1
  113. package/dist/transaction/consumeNotes.js +1 -0
  114. package/dist/transaction/consumeNotes.js.map +1 -1
  115. package/dist/transaction/index.d.ts +2 -1
  116. package/dist/transaction/index.d.ts.map +1 -1
  117. package/dist/transaction/index.js +2 -1
  118. package/dist/transaction/index.js.map +1 -1
  119. package/dist/transaction/options.d.ts +1 -1
  120. package/dist/transaction/options.d.ts.map +1 -1
  121. package/dist/transaction/p2id.d.ts +1 -0
  122. package/dist/transaction/p2id.d.ts.map +1 -1
  123. package/dist/transaction/p2id.js +9 -3
  124. package/dist/transaction/p2id.js.map +1 -1
  125. package/dist/transaction/updateGuardian.d.ts +7 -0
  126. package/dist/transaction/updateGuardian.d.ts.map +1 -0
  127. package/dist/transaction/{updatePsm.js → updateGuardian.js} +15 -29
  128. package/dist/transaction/updateGuardian.js.map +1 -0
  129. package/dist/transaction/updateProcedureThreshold.d.ts +9 -0
  130. package/dist/transaction/updateProcedureThreshold.d.ts.map +1 -0
  131. package/dist/transaction/updateProcedureThreshold.js +55 -0
  132. package/dist/transaction/updateProcedureThreshold.js.map +1 -0
  133. package/dist/transaction/updateSigners.d.ts.map +1 -1
  134. package/dist/transaction/updateSigners.js +5 -23
  135. package/dist/transaction/updateSigners.js.map +1 -1
  136. package/dist/transaction.d.ts +2 -1
  137. package/dist/transaction.d.ts.map +1 -1
  138. package/dist/transaction.js +2 -1
  139. package/dist/transaction.js.map +1 -1
  140. package/dist/types/proposal.d.ts +32 -15
  141. package/dist/types/proposal.d.ts.map +1 -1
  142. package/dist/types.d.ts +24 -7
  143. package/dist/types.d.ts.map +1 -1
  144. package/dist/utils/digest.d.ts +5 -0
  145. package/dist/utils/digest.d.ts.map +1 -1
  146. package/dist/utils/digest.js +34 -1
  147. package/dist/utils/digest.js.map +1 -1
  148. package/dist/utils/digest.test.d.ts +2 -0
  149. package/dist/utils/digest.test.d.ts.map +1 -0
  150. package/dist/utils/digest.test.js +48 -0
  151. package/dist/utils/digest.test.js.map +1 -0
  152. package/dist/utils/signature.d.ts +3 -1
  153. package/dist/utils/signature.d.ts.map +1 -1
  154. package/dist/utils/signature.js +31 -6
  155. package/dist/utils/signature.js.map +1 -1
  156. package/dist/utils/word.test.js +1 -52
  157. package/dist/utils/word.test.js.map +1 -1
  158. package/masm/account_components/auth/multisig.masm +13 -0
  159. package/masm/account_components/auth/multisig_ecdsa.masm +13 -0
  160. package/masm/account_components/auth/multisig_guardian.masm +17 -0
  161. package/masm/account_components/auth/multisig_guardian_ecdsa.masm +17 -0
  162. package/masm/auth/guardian.masm +177 -0
  163. package/masm/auth/guardian_ecdsa.masm +178 -0
  164. package/masm/{multisig.masm → auth/multisig.masm} +149 -55
  165. package/masm/{multisig_ecdsa.masm → auth/multisig_ecdsa.masm} +148 -56
  166. package/package.json +11 -9
  167. package/src/account/builder.test.ts +165 -0
  168. package/src/account/builder.ts +71 -34
  169. package/src/account/index.ts +1 -1
  170. package/src/account/masm/account-components/auth.ts +70 -0
  171. package/src/account/masm/auth.ts +1398 -0
  172. package/src/account/masm/index.ts +5 -0
  173. package/src/account/masm.ts +3 -1040
  174. package/src/account/storage.ts +18 -16
  175. package/src/client.test.ts +31 -35
  176. package/src/client.ts +98 -26
  177. package/src/index.ts +84 -22
  178. package/src/inspector.test.ts +17 -17
  179. package/src/inspector.ts +63 -29
  180. package/src/multisig/proposal/execution.ts +66 -49
  181. package/src/multisig/proposal/parser.ts +26 -26
  182. package/src/multisig/signing.ts +4 -4
  183. package/src/multisig.test.ts +2097 -248
  184. package/src/multisig.ts +882 -388
  185. package/src/procedures.ts +50 -26
  186. package/src/proposal/factory.ts +171 -0
  187. package/src/proposal/metadata.ts +165 -0
  188. package/src/proposal/signatures.ts +68 -0
  189. package/src/signer.ts +2 -0
  190. package/src/signers/ecdsa.test.ts +80 -0
  191. package/src/signers/ecdsa.ts +15 -4
  192. package/src/signers/falcon.test.ts +19 -0
  193. package/src/signers/falcon.ts +16 -5
  194. package/src/signers/miden-wallet.test.ts +47 -0
  195. package/src/signers/miden-wallet.ts +18 -1
  196. package/src/signers/para.test.ts +57 -0
  197. package/src/signers/para.ts +26 -7
  198. package/src/transaction/consumeNotes.ts +1 -0
  199. package/src/transaction/index.ts +2 -2
  200. package/src/transaction/options.ts +1 -2
  201. package/src/transaction/p2id.test.ts +190 -0
  202. package/src/transaction/p2id.ts +11 -5
  203. package/src/transaction/rpoRandomCoin.test.ts +64 -0
  204. package/src/transaction/rpoRandomCoin.ts +24 -0
  205. package/src/transaction/{updatePsm.ts → updateGuardian.ts} +19 -32
  206. package/src/transaction/updateProcedureThreshold.ts +97 -0
  207. package/src/transaction/updateSigners.ts +12 -27
  208. package/src/transaction.ts +5 -3
  209. package/src/types/proposal.ts +43 -11
  210. package/src/types.ts +29 -11
  211. package/src/utils/digest.test.ts +55 -0
  212. package/src/utils/digest.ts +51 -1
  213. package/src/utils/signature.ts +47 -9
  214. package/src/utils/word.test.ts +1 -55
  215. package/dist/account.d.ts +0 -104
  216. package/dist/account.d.ts.map +0 -1
  217. package/dist/account.js +0 -136
  218. package/dist/account.js.map +0 -1
  219. package/dist/builder.d.ts +0 -54
  220. package/dist/builder.d.ts.map +0 -1
  221. package/dist/builder.js +0 -78
  222. package/dist/builder.js.map +0 -1
  223. package/dist/http.d.ts +0 -64
  224. package/dist/http.d.ts.map +0 -1
  225. package/dist/http.js +0 -162
  226. package/dist/http.js.map +0 -1
  227. package/dist/keystore.d.ts +0 -65
  228. package/dist/keystore.d.ts.map +0 -1
  229. package/dist/keystore.js +0 -121
  230. package/dist/keystore.js.map +0 -1
  231. package/dist/miden.d.ts +0 -9
  232. package/dist/miden.d.ts.map +0 -1
  233. package/dist/miden.js +0 -26
  234. package/dist/miden.js.map +0 -1
  235. package/dist/multisig/metadata.d.ts +0 -18
  236. package/dist/multisig/metadata.d.ts.map +0 -1
  237. package/dist/multisig/metadata.js +0 -100
  238. package/dist/multisig/metadata.js.map +0 -1
  239. package/dist/multisig/metadata.test.d.ts +0 -2
  240. package/dist/multisig/metadata.test.d.ts.map +0 -1
  241. package/dist/multisig/metadata.test.js +0 -323
  242. package/dist/multisig/metadata.test.js.map +0 -1
  243. package/dist/proposal/exported.d.ts +0 -12
  244. package/dist/proposal/exported.d.ts.map +0 -1
  245. package/dist/proposal/exported.js +0 -88
  246. package/dist/proposal/exported.js.map +0 -1
  247. package/dist/signer.test.d.ts +0 -2
  248. package/dist/signer.test.d.ts.map +0 -1
  249. package/dist/signer.test.js +0 -123
  250. package/dist/signer.test.js.map +0 -1
  251. package/dist/transaction/p2id.interop.test.d.ts +0 -2
  252. package/dist/transaction/p2id.interop.test.d.ts.map +0 -1
  253. package/dist/transaction/p2id.interop.test.js +0 -36
  254. package/dist/transaction/p2id.interop.test.js.map +0 -1
  255. package/dist/transaction/updatePsm.d.ts +0 -7
  256. package/dist/transaction/updatePsm.d.ts.map +0 -1
  257. package/dist/transaction/updatePsm.js.map +0 -1
  258. package/dist/transport/http.d.ts +0 -64
  259. package/dist/transport/http.d.ts.map +0 -1
  260. package/dist/transport/http.js +0 -162
  261. package/dist/transport/http.js.map +0 -1
  262. package/dist/transport/index.d.ts +0 -5
  263. package/dist/transport/index.d.ts.map +0 -1
  264. package/dist/transport/index.js +0 -5
  265. package/dist/transport/index.js.map +0 -1
  266. package/dist/utils/state-validation.d.ts +0 -5
  267. package/dist/utils/state-validation.d.ts.map +0 -1
  268. package/dist/utils/state-validation.js +0 -30
  269. package/dist/utils/state-validation.js.map +0 -1
  270. package/dist/utils/validation.d.ts +0 -6
  271. package/dist/utils/validation.d.ts.map +0 -1
  272. package/dist/utils/validation.js +0 -52
  273. package/dist/utils/validation.js.map +0 -1
  274. package/masm/psm.masm +0 -178
  275. package/masm/psm_ecdsa.masm +0 -179
@@ -1,9 +1,24 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { Multisig } from './multisig.js';
3
- import { PsmHttpClient, type Signer } from '@openzeppelin/psm-client';
3
+ import { GuardianHttpClient, type Signer } from '@openzeppelin/guardian-client';
4
+ import {
5
+ buildUpdateProcedureThresholdTransactionRequest,
6
+ buildUpdateGuardianTransactionRequest,
7
+ buildUpdateSignersTransactionRequest,
8
+ executeForSummary,
9
+ } from './transaction.js';
10
+
11
+ const { mockRpcGetAccountDetails, mockAccountDeserialize, mockDetectConfig } = vi.hoisted(() => ({
12
+ mockRpcGetAccountDetails: vi.fn(),
13
+ mockAccountDeserialize: vi.fn(),
14
+ mockDetectConfig: vi.fn(),
15
+ }));
4
16
 
5
17
  // Mock the Miden SDK
6
18
  vi.mock('@miden-sdk/miden-sdk', () => ({
19
+ Account: {
20
+ deserialize: mockAccountDeserialize,
21
+ },
7
22
  AccountId: {
8
23
  fromHex: vi.fn((hex: string) => ({ toString: () => hex })),
9
24
  },
@@ -38,6 +53,10 @@ vi.mock('@miden-sdk/miden-sdk', () => ({
38
53
  toHex: () => '0x' + 'e'.repeat(64),
39
54
  }),
40
55
  },
56
+ Endpoint: vi.fn().mockImplementation((url: string) => ({ url })),
57
+ RpcClient: vi.fn().mockImplementation(() => ({
58
+ getAccountDetails: mockRpcGetAccountDetails,
59
+ })),
41
60
  }));
42
61
 
43
62
  // Mock transaction module
@@ -48,7 +67,12 @@ vi.mock('./transaction.js', () => ({
48
67
  salt: { toHex: () => '0x' + 'd'.repeat(64) },
49
68
  configHash: { toHex: () => '0x' + 'e'.repeat(64) },
50
69
  }),
51
- buildUpdatePsmTransactionRequest: vi.fn().mockResolvedValue({
70
+ buildUpdateProcedureThresholdTransactionRequest: vi.fn().mockResolvedValue({
71
+ request: {},
72
+ salt: { toHex: () => '0x' + 'd'.repeat(64) },
73
+ configHash: { toHex: () => '0x' + 'e'.repeat(64) },
74
+ }),
75
+ buildUpdateGuardianTransactionRequest: vi.fn().mockResolvedValue({
52
76
  request: {},
53
77
  salt: { toHex: () => '0x' + 'd'.repeat(64) },
54
78
  }),
@@ -62,50 +86,92 @@ vi.mock('./transaction.js', () => ({
62
86
  }),
63
87
  }));
64
88
 
65
- vi.mock('./utils/signature.js', () => ({
66
- buildSignatureAdviceEntry: vi.fn().mockReturnValue({
67
- key: { toHex: () => '0x' + 'f'.repeat(64) },
68
- values: [1, 2, 3],
69
- }),
70
- signatureHexToBytes: vi.fn((hex: string) => new Uint8Array([0, 1, 2, 3])),
71
- }));
89
+ vi.mock('./utils/signature.js', async () => {
90
+ const actual = await vi.importActual<typeof import('./utils/signature.js')>('./utils/signature.js');
91
+ return {
92
+ ...actual,
93
+ buildSignatureAdviceEntry: vi.fn().mockImplementation((signerCommitment: { toHex?: () => string }) => ({
94
+ key: { toHex: () => signerCommitment.toHex ? signerCommitment.toHex() : '0x' + 'f'.repeat(64) },
95
+ values: [1, 2, 3],
96
+ })),
97
+ signatureHexToBytes: vi.fn((hex: string) => new Uint8Array([0, 1, 2, 3])),
98
+ };
99
+ });
72
100
 
73
101
  vi.mock('./utils/encoding.js', async () => {
74
102
  const actual = await vi.importActual<typeof import('./utils/encoding.js')>('./utils/encoding.js');
75
103
  return {
76
- ensureHexPrefix: actual.ensureHexPrefix,
77
- normalizeHexWord: vi.fn((hex: string) => '0x' + hex.replace(/^0x/i, '').padStart(64, '0')),
78
- bytesToHex: actual.bytesToHex,
79
- hexToBytes: actual.hexToBytes,
80
- uint8ArrayToBase64: actual.uint8ArrayToBase64,
81
- base64ToUint8Array: actual.base64ToUint8Array,
104
+ ...actual,
105
+ normalizeHexWord: vi.fn((hex: string) => '0x' + hex.replace(/^0x/i, '').toLowerCase().padStart(64, '0')),
82
106
  };
83
107
  });
84
108
 
85
- // Mock fetch for PSM client
109
+ vi.mock('./inspector.js', () => ({
110
+ AccountInspector: {
111
+ fromAccount: mockDetectConfig,
112
+ },
113
+ }));
114
+
115
+ // Mock fetch for GUARDIAN client
86
116
  const mockFetch = vi.fn();
87
117
  vi.stubGlobal('fetch', mockFetch);
88
118
 
119
+ function mockedAccount(commitmentHex: string, nonce = 0): any {
120
+ return {
121
+ commitment: () => ({
122
+ toHex: () => commitmentHex,
123
+ }),
124
+ nonce: () => ({
125
+ asInt: () => BigInt(nonce),
126
+ }),
127
+ };
128
+ }
129
+
89
130
  describe('Multisig', () => {
90
- let psm: PsmHttpClient;
131
+ let guardian: GuardianHttpClient;
91
132
  let mockSigner: Signer;
92
133
  let mockAccount: any;
93
134
  let mockWebClient: any;
94
135
 
95
136
  beforeEach(() => {
96
137
  mockFetch.mockReset();
138
+ vi.mocked(executeForSummary).mockResolvedValue({
139
+ toCommitment: () => ({
140
+ toHex: () => '0x' + 'c'.repeat(64),
141
+ }),
142
+ serialize: () => new Uint8Array([1, 2, 3]),
143
+ } as any);
144
+ mockRpcGetAccountDetails.mockReset();
145
+ mockAccountDeserialize.mockReset();
146
+ mockRpcGetAccountDetails.mockResolvedValue({
147
+ commitment: () => ({
148
+ toHex: () => '0x' + 'b'.repeat(64),
149
+ }),
150
+ });
151
+ mockAccountDeserialize.mockReturnValue(mockedAccount('0x' + 'b'.repeat(64), 1));
152
+ mockDetectConfig.mockReset();
153
+ mockDetectConfig.mockReturnValue({
154
+ threshold: 1,
155
+ numSigners: 1,
156
+ signerCommitments: ['0x' + 'a'.repeat(64)],
157
+ guardianEnabled: true,
158
+ guardianCommitment: '0x' + 'c'.repeat(64),
159
+ vaultBalances: [],
160
+ procedureThresholds: new Map(),
161
+ });
97
162
 
98
- psm = new PsmHttpClient('http://localhost:3000');
163
+ guardian = new GuardianHttpClient('http://localhost:3000');
99
164
 
100
165
  mockSigner = {
101
- scheme: 'falcon',
102
166
  commitment: '0x' + '1'.repeat(64),
103
167
  publicKey: '0x' + '2'.repeat(64),
168
+ scheme: 'falcon',
104
169
  signAccountIdWithTimestamp: vi.fn().mockResolvedValue('0x' + 'a'.repeat(128)),
105
- signCommitment: vi.fn().mockResolvedValue('0x' + 'b'.repeat(128)),
170
+ signRequest: vi.fn().mockReturnValue('0x' + 'a'.repeat(128)),
171
+ signCommitment: vi.fn().mockReturnValue('0x' + 'b'.repeat(128)),
106
172
  };
107
173
 
108
- psm.setSigner(mockSigner);
174
+ guardian.setSigner(mockSigner);
109
175
 
110
176
  mockAccount = {
111
177
  id: () => ({
@@ -123,6 +189,8 @@ describe('Multisig', () => {
123
189
  applyTransaction: vi.fn(),
124
190
  getConsumableNotes: vi.fn().mockResolvedValue([]),
125
191
  syncState: vi.fn(),
192
+ getAccount: vi.fn().mockResolvedValue(null),
193
+ newAccount: vi.fn(),
126
194
  };
127
195
  });
128
196
 
@@ -131,28 +199,28 @@ describe('Multisig', () => {
131
199
  const config = {
132
200
  threshold: 2,
133
201
  signerCommitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)],
134
- psmCommitment: '0x' + 'c'.repeat(64),
202
+ guardianCommitment: '0x' + 'c'.repeat(64),
135
203
  };
136
204
 
137
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
205
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
138
206
 
139
207
  expect(multisig.threshold).toBe(2);
140
208
  expect(multisig.signerCommitments).toEqual(config.signerCommitments);
141
- expect(multisig.psmCommitment).toBe(config.psmCommitment);
209
+ expect(multisig.guardianCommitment).toBe(config.guardianCommitment);
142
210
  expect(multisig.account).toBe(mockAccount);
143
211
  });
144
212
 
145
- it('should create Multisig without account (loaded)', () => {
213
+ it('should create Multisig with explicit accountId override', () => {
146
214
  const config = {
147
215
  threshold: 2,
148
216
  signerCommitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)],
149
- psmCommitment: '0x' + 'c'.repeat(64),
217
+ guardianCommitment: '0x' + 'c'.repeat(64),
150
218
  };
151
219
 
152
220
  const accountId = '0x' + 'd'.repeat(30);
153
- const multisig = new Multisig(null, config, psm, mockSigner, mockWebClient, accountId);
221
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient, accountId);
154
222
 
155
- expect(multisig.account).toBeNull();
223
+ expect(multisig.account).toBe(mockAccount);
156
224
  expect(multisig.accountId).toBe(accountId);
157
225
  });
158
226
  });
@@ -162,22 +230,22 @@ describe('Multisig', () => {
162
230
  const config = {
163
231
  threshold: 1,
164
232
  signerCommitments: ['0x' + 'a'.repeat(64)],
165
- psmCommitment: '0x' + 'c'.repeat(64),
233
+ guardianCommitment: '0x' + 'c'.repeat(64),
166
234
  };
167
235
 
168
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
236
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
169
237
  expect(multisig.accountId).toBe('0x' + 'a'.repeat(30));
170
238
  });
171
239
 
172
- it('should return provided account ID for loaded multisig', () => {
240
+ it('should return provided account ID when constructor override is set', () => {
173
241
  const config = {
174
242
  threshold: 1,
175
243
  signerCommitments: ['0x' + 'a'.repeat(64)],
176
- psmCommitment: '0x' + 'c'.repeat(64),
244
+ guardianCommitment: '0x' + 'c'.repeat(64),
177
245
  };
178
246
 
179
247
  const accountId = '0x' + 'e'.repeat(30);
180
- const multisig = new Multisig(null, config, psm, mockSigner, mockWebClient, accountId);
248
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient, accountId);
181
249
  expect(multisig.accountId).toBe(accountId);
182
250
  });
183
251
  });
@@ -187,23 +255,23 @@ describe('Multisig', () => {
187
255
  const config = {
188
256
  threshold: 1,
189
257
  signerCommitments: ['0x' + 'a'.repeat(64)],
190
- psmCommitment: '0x' + 'c'.repeat(64),
258
+ guardianCommitment: '0x' + 'c'.repeat(64),
191
259
  };
192
260
 
193
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
261
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
194
262
  expect(multisig.signerCommitment).toBe(mockSigner.commitment);
195
263
  });
196
264
  });
197
265
 
198
266
  describe('fetchState', () => {
199
- it('should fetch account state from PSM', async () => {
267
+ it('should fetch account state from GUARDIAN', async () => {
200
268
  const config = {
201
269
  threshold: 1,
202
270
  signerCommitments: ['0x' + 'a'.repeat(64)],
203
- psmCommitment: '0x' + 'c'.repeat(64),
271
+ guardianCommitment: '0x' + 'c'.repeat(64),
204
272
  };
205
273
 
206
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
274
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
207
275
 
208
276
  mockFetch.mockResolvedValueOnce({
209
277
  ok: true,
@@ -224,15 +292,378 @@ describe('Multisig', () => {
224
292
  });
225
293
  });
226
294
 
227
- describe('registerOnPsm', () => {
228
- it('should register account on PSM', async () => {
295
+ describe('syncState', () => {
296
+ it('should overwrite local state when account is missing locally', async () => {
297
+ const config = {
298
+ threshold: 1,
299
+ signerCommitments: ['0x' + 'a'.repeat(64)],
300
+ guardianCommitment: '0x' + 'c'.repeat(64),
301
+ };
302
+
303
+ const multisig = new Multisig(
304
+ mockAccount,
305
+ config,
306
+ guardian,
307
+ mockSigner,
308
+ mockWebClient,
309
+ undefined,
310
+ 'https://rpc.devnet.miden.io'
311
+ );
312
+
313
+ mockWebClient.getAccount.mockResolvedValueOnce(null);
314
+ mockFetch.mockResolvedValueOnce({
315
+ ok: true,
316
+ json: async () => ({
317
+ account_id: multisig.accountId,
318
+ commitment: '0x' + 'b'.repeat(64),
319
+ state_json: { data: 'AQID' },
320
+ created_at: '2024-01-01T00:00:00Z',
321
+ updated_at: '2024-01-02T00:00:00Z',
322
+ }),
323
+ });
324
+
325
+ await multisig.syncState();
326
+
327
+ expect(mockWebClient.newAccount).toHaveBeenCalledTimes(1);
328
+ expect(mockRpcGetAccountDetails).toHaveBeenCalledTimes(1);
329
+ });
330
+
331
+ it('should overwrite local state when incoming commitment matches on-chain commitment', async () => {
332
+ const config = {
333
+ threshold: 1,
334
+ signerCommitments: ['0x' + 'a'.repeat(64)],
335
+ guardianCommitment: '0x' + 'c'.repeat(64),
336
+ };
337
+
338
+ const multisig = new Multisig(
339
+ mockAccount,
340
+ config,
341
+ guardian,
342
+ mockSigner,
343
+ mockWebClient,
344
+ undefined,
345
+ 'https://rpc.devnet.miden.io'
346
+ );
347
+
348
+ mockWebClient.getAccount.mockResolvedValueOnce(mockedAccount('0x' + 'a'.repeat(64), 0));
349
+ mockRpcGetAccountDetails.mockResolvedValueOnce({
350
+ commitment: () => ({
351
+ toHex: () => '0x' + 'b'.repeat(64),
352
+ }),
353
+ });
354
+ mockFetch.mockResolvedValueOnce({
355
+ ok: true,
356
+ json: async () => ({
357
+ account_id: multisig.accountId,
358
+ commitment: '0x' + 'b'.repeat(64),
359
+ state_json: { data: 'AQID' },
360
+ created_at: '2024-01-01T00:00:00Z',
361
+ updated_at: '2024-01-02T00:00:00Z',
362
+ }),
363
+ });
364
+
365
+ await multisig.syncState();
366
+
367
+ expect(mockWebClient.newAccount).toHaveBeenCalledTimes(1);
368
+ });
369
+
370
+ it('refreshes multisig config from synced account state', async () => {
371
+ const config = {
372
+ threshold: 1,
373
+ signerCommitments: ['0x' + 'a'.repeat(64)],
374
+ guardianCommitment: '0x' + 'c'.repeat(64),
375
+ };
376
+
377
+ const multisig = new Multisig(
378
+ mockAccount,
379
+ config,
380
+ guardian,
381
+ mockSigner,
382
+ mockWebClient,
383
+ undefined,
384
+ 'https://rpc.devnet.miden.io'
385
+ );
386
+
387
+ mockWebClient.getAccount.mockResolvedValueOnce({
388
+ commitment: () => ({
389
+ toHex: () => '0x' + 'b'.repeat(64),
390
+ }),
391
+ });
392
+ mockFetch.mockResolvedValueOnce({
393
+ ok: true,
394
+ json: async () => ({
395
+ account_id: multisig.accountId,
396
+ commitment: '0x' + 'b'.repeat(64),
397
+ state_json: { data: 'AQID' },
398
+ created_at: '2024-01-01T00:00:00Z',
399
+ updated_at: '2024-01-02T00:00:00Z',
400
+ }),
401
+ });
402
+ mockDetectConfig.mockReturnValueOnce({
403
+ threshold: 2,
404
+ numSigners: 2,
405
+ signerCommitments: ['0x' + '1'.repeat(64), '0x' + '2'.repeat(64)],
406
+ guardianEnabled: true,
407
+ guardianCommitment: '0x' + 'd'.repeat(64),
408
+ vaultBalances: [],
409
+ procedureThresholds: new Map(),
410
+ });
411
+
412
+ await multisig.syncState();
413
+
414
+ expect(multisig.threshold).toBe(2);
415
+ expect(multisig.signerCommitments).toEqual([
416
+ '0x' + '1'.repeat(64),
417
+ '0x' + '2'.repeat(64),
418
+ ]);
419
+ expect(multisig.guardianCommitment).toBe('0x' + 'd'.repeat(64));
420
+ expect(mockWebClient.newAccount).not.toHaveBeenCalled();
421
+ });
422
+
423
+ it('should overwrite local state when account is not found on-chain', async () => {
424
+ const config = {
425
+ threshold: 1,
426
+ signerCommitments: ['0x' + 'a'.repeat(64)],
427
+ guardianCommitment: '0x' + 'c'.repeat(64),
428
+ };
429
+
430
+ const multisig = new Multisig(
431
+ mockAccount,
432
+ config,
433
+ guardian,
434
+ mockSigner,
435
+ mockWebClient,
436
+ undefined,
437
+ 'https://rpc.devnet.miden.io'
438
+ );
439
+
440
+ mockWebClient.getAccount.mockResolvedValueOnce(mockedAccount('0x' + 'a'.repeat(64), 0));
441
+ mockRpcGetAccountDetails.mockRejectedValueOnce(
442
+ new Error('No account header record found for given ID')
443
+ );
444
+ mockFetch.mockResolvedValueOnce({
445
+ ok: true,
446
+ json: async () => ({
447
+ account_id: multisig.accountId,
448
+ commitment: '0x' + 'b'.repeat(64),
449
+ state_json: { data: 'AQID' },
450
+ created_at: '2024-01-01T00:00:00Z',
451
+ updated_at: '2024-01-02T00:00:00Z',
452
+ }),
453
+ });
454
+
455
+ await multisig.syncState();
456
+
457
+ expect(mockWebClient.newAccount).toHaveBeenCalledTimes(1);
458
+ });
459
+
460
+ it('should throw when incoming commitment does not match on-chain commitment', async () => {
461
+ const config = {
462
+ threshold: 1,
463
+ signerCommitments: ['0x' + 'a'.repeat(64)],
464
+ guardianCommitment: '0x' + 'c'.repeat(64),
465
+ };
466
+
467
+ const multisig = new Multisig(
468
+ mockAccount,
469
+ config,
470
+ guardian,
471
+ mockSigner,
472
+ mockWebClient,
473
+ undefined,
474
+ 'https://rpc.devnet.miden.io'
475
+ );
476
+
477
+ mockWebClient.getAccount.mockResolvedValueOnce(mockedAccount('0x' + 'a'.repeat(64), 0));
478
+ mockAccountDeserialize.mockReturnValueOnce(mockedAccount('0x' + 'b'.repeat(64), 1));
479
+ mockRpcGetAccountDetails.mockResolvedValueOnce({
480
+ commitment: () => ({
481
+ toHex: () => '0x' + 'c'.repeat(64),
482
+ }),
483
+ });
484
+ mockFetch.mockResolvedValueOnce({
485
+ ok: true,
486
+ json: async () => ({
487
+ account_id: multisig.accountId,
488
+ commitment: '0x' + 'b'.repeat(64),
489
+ state_json: { data: 'AQID' },
490
+ created_at: '2024-01-01T00:00:00Z',
491
+ updated_at: '2024-01-02T00:00:00Z',
492
+ }),
493
+ });
494
+
495
+ await expect(multisig.syncState()).rejects.toThrow('Refusing to overwrite local state');
496
+ expect(mockWebClient.newAccount).not.toHaveBeenCalled();
497
+ });
498
+
499
+ it('should throw when incoming state nonce is lower than local nonce', async () => {
500
+ const config = {
501
+ threshold: 1,
502
+ signerCommitments: ['0x' + 'a'.repeat(64)],
503
+ guardianCommitment: '0x' + 'c'.repeat(64),
504
+ };
505
+
506
+ const multisig = new Multisig(
507
+ mockAccount,
508
+ config,
509
+ guardian,
510
+ mockSigner,
511
+ mockWebClient,
512
+ undefined,
513
+ 'https://rpc.devnet.miden.io'
514
+ );
515
+
516
+ mockWebClient.getAccount.mockResolvedValueOnce(mockedAccount('0x' + 'a'.repeat(64), 3));
517
+ mockAccountDeserialize.mockReturnValueOnce(mockedAccount('0x' + 'b'.repeat(64), 2));
518
+ mockFetch.mockResolvedValueOnce({
519
+ ok: true,
520
+ json: async () => ({
521
+ account_id: multisig.accountId,
522
+ commitment: '0x' + 'b'.repeat(64),
523
+ state_json: { data: 'AQID' },
524
+ created_at: '2024-01-01T00:00:00Z',
525
+ updated_at: '2024-01-02T00:00:00Z',
526
+ }),
527
+ });
528
+
529
+ await expect(multisig.syncState()).rejects.toThrow(
530
+ 'incoming nonce 2 is not greater than local nonce 3'
531
+ );
532
+ expect(mockWebClient.newAccount).not.toHaveBeenCalled();
533
+ });
534
+
535
+ it('should throw when incoming state nonce equals local nonce but commitment differs', async () => {
536
+ const config = {
537
+ threshold: 1,
538
+ signerCommitments: ['0x' + 'a'.repeat(64)],
539
+ guardianCommitment: '0x' + 'c'.repeat(64),
540
+ };
541
+
542
+ const multisig = new Multisig(
543
+ mockAccount,
544
+ config,
545
+ guardian,
546
+ mockSigner,
547
+ mockWebClient,
548
+ undefined,
549
+ 'https://rpc.devnet.miden.io'
550
+ );
551
+
552
+ mockWebClient.getAccount.mockResolvedValueOnce(mockedAccount('0x' + 'a'.repeat(64), 2));
553
+ mockAccountDeserialize.mockReturnValueOnce(mockedAccount('0x' + 'b'.repeat(64), 2));
554
+ mockFetch.mockResolvedValueOnce({
555
+ ok: true,
556
+ json: async () => ({
557
+ account_id: multisig.accountId,
558
+ commitment: '0x' + 'b'.repeat(64),
559
+ state_json: { data: 'AQID' },
560
+ created_at: '2024-01-01T00:00:00Z',
561
+ updated_at: '2024-01-02T00:00:00Z',
562
+ }),
563
+ });
564
+
565
+ await expect(multisig.syncState()).rejects.toThrow(
566
+ 'incoming nonce 2 is not greater than local nonce 2'
567
+ );
568
+ expect(mockWebClient.newAccount).not.toHaveBeenCalled();
569
+ });
570
+ });
571
+
572
+ describe('verifyStateCommitment', () => {
573
+ it('should pass when local and on-chain commitments match', async () => {
574
+ const config = {
575
+ threshold: 1,
576
+ signerCommitments: ['0x' + 'a'.repeat(64)],
577
+ guardianCommitment: '0x' + 'c'.repeat(64),
578
+ };
579
+ mockWebClient.getAccount.mockResolvedValueOnce({
580
+ commitment: () => ({
581
+ toHex: () => '0x' + 'b'.repeat(64),
582
+ }),
583
+ });
584
+
585
+ const multisigWithRpc = new Multisig(
586
+ mockAccount,
587
+ config,
588
+ guardian,
589
+ mockSigner,
590
+ mockWebClient,
591
+ undefined,
592
+ 'https://rpc.devnet.miden.io'
593
+ );
594
+
595
+ await expect(
596
+ multisigWithRpc.verifyStateCommitment()
597
+ ).resolves.toMatchObject({
598
+ accountId: multisigWithRpc.accountId,
599
+ });
600
+ });
601
+
602
+ it('should throw when local account state is missing', async () => {
603
+ const config = {
604
+ threshold: 1,
605
+ signerCommitments: ['0x' + 'a'.repeat(64)],
606
+ guardianCommitment: '0x' + 'c'.repeat(64),
607
+ };
608
+ mockWebClient.getAccount.mockResolvedValueOnce(null);
609
+
610
+ const multisigWithRpc = new Multisig(
611
+ mockAccount,
612
+ config,
613
+ guardian,
614
+ mockSigner,
615
+ mockWebClient,
616
+ undefined,
617
+ 'https://rpc.devnet.miden.io'
618
+ );
619
+
620
+ await expect(
621
+ multisigWithRpc.verifyStateCommitment()
622
+ ).rejects.toThrow('Local account state not found');
623
+ });
624
+
625
+ it('should throw when local and on-chain commitments differ', async () => {
626
+ const config = {
627
+ threshold: 1,
628
+ signerCommitments: ['0x' + 'a'.repeat(64)],
629
+ guardianCommitment: '0x' + 'c'.repeat(64),
630
+ };
631
+ mockWebClient.getAccount.mockResolvedValueOnce({
632
+ commitment: () => ({
633
+ toHex: () => '0x' + 'f'.repeat(64),
634
+ }),
635
+ });
636
+ mockRpcGetAccountDetails.mockResolvedValueOnce({
637
+ commitment: () => ({
638
+ toHex: () => '0x' + 'b'.repeat(64),
639
+ }),
640
+ });
641
+
642
+ const multisigWithRpc = new Multisig(
643
+ mockAccount,
644
+ config,
645
+ guardian,
646
+ mockSigner,
647
+ mockWebClient,
648
+ undefined,
649
+ 'https://rpc.devnet.miden.io'
650
+ );
651
+
652
+ await expect(
653
+ multisigWithRpc.verifyStateCommitment()
654
+ ).rejects.toThrow('Local account commitment does not match on-chain commitment');
655
+ });
656
+ });
657
+
658
+ describe('registerOnGuardian', () => {
659
+ it('should register account on GUARDIAN', async () => {
229
660
  const config = {
230
661
  threshold: 1,
231
662
  signerCommitments: ['0x' + 'a'.repeat(64)],
232
- psmCommitment: '0x' + 'c'.repeat(64),
663
+ guardianCommitment: '0x' + 'c'.repeat(64),
233
664
  };
234
665
 
235
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
666
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
236
667
 
237
668
  mockFetch.mockResolvedValueOnce({
238
669
  ok: true,
@@ -243,29 +674,60 @@ describe('Multisig', () => {
243
674
  }),
244
675
  });
245
676
 
246
- await expect(multisig.registerOnPsm()).resolves.toBeUndefined();
677
+ await expect(multisig.registerOnGuardian()).resolves.toBeUndefined();
247
678
  });
248
679
 
249
- it('should throw when no account and no initial state', async () => {
680
+ it('should register ECDSA accounts with MidenEcdsa auth', async () => {
250
681
  const config = {
251
682
  threshold: 1,
252
683
  signerCommitments: ['0x' + 'a'.repeat(64)],
253
- psmCommitment: '0x' + 'c'.repeat(64),
684
+ guardianCommitment: '0x' + 'c'.repeat(64),
685
+ };
686
+
687
+ const ecdsaSigner: Signer = {
688
+ ...mockSigner,
689
+ publicKey: '0x' + '2'.repeat(66),
690
+ scheme: 'ecdsa',
254
691
  };
255
692
 
256
- const multisig = new Multisig(null, config, psm, mockSigner, mockWebClient, '0x' + 'e'.repeat(30));
693
+ guardian.setSigner(ecdsaSigner);
694
+ const multisig = new Multisig(mockAccount, config, guardian, ecdsaSigner, mockWebClient);
695
+
696
+ mockFetch.mockResolvedValueOnce({
697
+ ok: true,
698
+ json: async () => ({
699
+ success: true,
700
+ message: 'Account configured',
701
+ ack_pubkey: '0x' + 'd'.repeat(66),
702
+ }),
703
+ });
704
+
705
+ await expect(multisig.registerOnGuardian()).resolves.toBeUndefined();
257
706
 
258
- await expect(multisig.registerOnPsm()).rejects.toThrow('Cannot register on PSM');
707
+ const [, requestInit] = mockFetch.mock.calls[0] as [string, RequestInit];
708
+ const body = JSON.parse(String(requestInit.body));
709
+ expect(body.auth).toEqual({
710
+ MidenEcdsa: {
711
+ cosigner_commitments: config.signerCommitments,
712
+ },
713
+ });
259
714
  });
260
715
 
261
- it('should accept initial state base64', async () => {
716
+ it('should accept explicit initial state base64', async () => {
262
717
  const config = {
263
718
  threshold: 1,
264
719
  signerCommitments: ['0x' + 'a'.repeat(64)],
265
- psmCommitment: '0x' + 'c'.repeat(64),
720
+ guardianCommitment: '0x' + 'c'.repeat(64),
266
721
  };
267
722
 
268
- const multisig = new Multisig(null, config, psm, mockSigner, mockWebClient, '0x' + 'e'.repeat(30));
723
+ const multisig = new Multisig(
724
+ mockAccount,
725
+ config,
726
+ guardian,
727
+ mockSigner,
728
+ mockWebClient,
729
+ '0x' + 'e'.repeat(30),
730
+ );
269
731
 
270
732
  mockFetch.mockResolvedValueOnce({
271
733
  ok: true,
@@ -275,17 +737,17 @@ describe('Multisig', () => {
275
737
  }),
276
738
  });
277
739
 
278
- await expect(multisig.registerOnPsm('base64initialstate')).resolves.toBeUndefined();
740
+ await expect(multisig.registerOnGuardian('base64initialstate')).resolves.toBeUndefined();
279
741
  });
280
742
 
281
- it('should throw on PSM registration failure', async () => {
743
+ it('should throw on GUARDIAN registration failure', async () => {
282
744
  const config = {
283
745
  threshold: 1,
284
746
  signerCommitments: ['0x' + 'a'.repeat(64)],
285
- psmCommitment: '0x' + 'c'.repeat(64),
747
+ guardianCommitment: '0x' + 'c'.repeat(64),
286
748
  };
287
749
 
288
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
750
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
289
751
 
290
752
  mockFetch.mockResolvedValueOnce({
291
753
  ok: true,
@@ -295,19 +757,19 @@ describe('Multisig', () => {
295
757
  }),
296
758
  });
297
759
 
298
- await expect(multisig.registerOnPsm()).rejects.toThrow('Failed to register on PSM');
760
+ await expect(multisig.registerOnGuardian()).rejects.toThrow('Failed to register on GUARDIAN');
299
761
  });
300
762
  });
301
763
 
302
- describe('syncTransactionProposals', () => {
303
- it('should sync proposals from PSM', async () => {
764
+ describe('syncProposals', () => {
765
+ it('should sync proposals from GUARDIAN', async () => {
304
766
  const config = {
305
767
  threshold: 2,
306
768
  signerCommitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)],
307
- psmCommitment: '0x' + 'c'.repeat(64),
769
+ guardianCommitment: '0x' + 'c'.repeat(64),
308
770
  };
309
771
 
310
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
772
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
311
773
 
312
774
  const mockProposals = [
313
775
  {
@@ -330,7 +792,7 @@ describe('Multisig', () => {
330
792
  proposer_id: '0x' + 'c'.repeat(64),
331
793
  cosigner_sigs: [
332
794
  {
333
- signer_id: '0x' + 'd'.repeat(64),
795
+ signer_id: '0x' + 'a'.repeat(64),
334
796
  signature: { scheme: 'falcon', signature: '0x' + 'e'.repeat(128) },
335
797
  timestamp: '2024-01-01T00:00:00Z',
336
798
  },
@@ -344,22 +806,21 @@ describe('Multisig', () => {
344
806
  json: async () => ({ proposals: mockProposals }),
345
807
  });
346
808
 
347
- const proposals = await multisig.syncTransactionProposals();
809
+ const proposals = await multisig.syncProposals();
348
810
 
349
811
  expect(proposals.length).toBe(1);
350
812
  expect(proposals[0].nonce).toBe(1);
351
- expect(proposals[0].status.type).toBe('pending');
352
- expect(proposals[0].commitment).toBe(proposals[0].id);
813
+ expect(proposals[0].status).toBe('pending');
353
814
  });
354
815
 
355
816
  it('should return ready status when enough signatures', async () => {
356
817
  const config = {
357
818
  threshold: 1, // Only 1 signature needed
358
819
  signerCommitments: ['0x' + 'a'.repeat(64)],
359
- psmCommitment: '0x' + 'c'.repeat(64),
820
+ guardianCommitment: '0x' + 'c'.repeat(64),
360
821
  };
361
822
 
362
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
823
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
363
824
 
364
825
  const mockProposals = [
365
826
  {
@@ -382,7 +843,7 @@ describe('Multisig', () => {
382
843
  proposer_id: '0x' + 'c'.repeat(64),
383
844
  cosigner_sigs: [
384
845
  {
385
- signer_id: '0x' + 'd'.repeat(64),
846
+ signer_id: '0x' + 'a'.repeat(64),
386
847
  signature: { scheme: 'falcon', signature: '0x' + 'e'.repeat(128) },
387
848
  timestamp: '2024-01-01T00:00:00Z',
388
849
  },
@@ -396,44 +857,626 @@ describe('Multisig', () => {
396
857
  json: async () => ({ proposals: mockProposals }),
397
858
  });
398
859
 
399
- const proposals = await multisig.syncTransactionProposals();
860
+ const proposals = await multisig.syncProposals();
400
861
 
401
- expect(proposals[0].status.type).toBe('ready');
862
+ expect(proposals[0].status).toBe('ready');
402
863
  });
403
- });
404
864
 
405
- describe('listTransactionProposals', () => {
406
- it('should return empty list initially', () => {
865
+ it('should reject proposals whose metadata does not match tx_summary', async () => {
407
866
  const config = {
408
867
  threshold: 1,
409
868
  signerCommitments: ['0x' + 'a'.repeat(64)],
410
- psmCommitment: '0x' + 'c'.repeat(64),
869
+ guardianCommitment: '0x' + 'c'.repeat(64),
411
870
  };
412
871
 
413
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
414
- expect(multisig.listTransactionProposals()).toEqual([]);
872
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
873
+
874
+ mockFetch.mockResolvedValueOnce({
875
+ ok: true,
876
+ json: async () => ({
877
+ proposals: [
878
+ {
879
+ account_id: '0x' + 'a'.repeat(30),
880
+ nonce: 1,
881
+ prev_commitment: '0x' + 'b'.repeat(64),
882
+ delta_payload: {
883
+ tx_summary: { data: 'AQID' },
884
+ signatures: [],
885
+ metadata: {
886
+ proposal_type: 'add_signer',
887
+ target_threshold: 1,
888
+ signer_commitments: ['0x' + 'a'.repeat(64)],
889
+ description: '',
890
+ },
891
+ },
892
+ status: {
893
+ status: 'pending',
894
+ timestamp: '2024-01-01T00:00:00Z',
895
+ proposer_id: '0x' + 'c'.repeat(64),
896
+ cosigner_sigs: [],
897
+ },
898
+ },
899
+ ],
900
+ }),
901
+ });
902
+
903
+ vi.mocked(executeForSummary).mockResolvedValueOnce({
904
+ toCommitment: () => ({
905
+ toHex: () => '0x' + 'f'.repeat(64),
906
+ }),
907
+ } as any);
908
+
909
+ await expect(multisig.syncProposals()).rejects.toThrow(
910
+ 'Invalid proposal: metadata does not match tx_summary'
911
+ );
415
912
  });
416
- });
417
913
 
418
- describe('createProposal', () => {
419
- it('should create a new proposal', async () => {
914
+ it('should reject non-32-byte signer IDs from GUARDIAN proposals', async () => {
420
915
  const config = {
421
916
  threshold: 1,
422
917
  signerCommitments: ['0x' + 'a'.repeat(64)],
423
- psmCommitment: '0x' + 'c'.repeat(64),
918
+ guardianCommitment: '0x' + 'c'.repeat(64),
424
919
  };
425
920
 
426
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
921
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
427
922
 
428
- const mockDelta = {
429
- account_id: '0x' + 'a'.repeat(30),
430
- nonce: 1,
431
- prev_commitment: '0x' + 'b'.repeat(64),
432
- delta_payload: {
433
- tx_summary: { data: 'base64summary' },
434
- signatures: [],
435
- },
436
- status: {
923
+ const mockProposals = [
924
+ {
925
+ account_id: '0x' + 'a'.repeat(30),
926
+ nonce: 1,
927
+ prev_commitment: '0x' + 'b'.repeat(64),
928
+ delta_payload: {
929
+ tx_summary: { data: 'AQID' },
930
+ signatures: [],
931
+ metadata: {
932
+ proposal_type: 'add_signer',
933
+ target_threshold: 1,
934
+ signer_commitments: ['0x' + 'a'.repeat(64)],
935
+ description: '',
936
+ },
937
+ },
938
+ status: {
939
+ status: 'pending',
940
+ timestamp: '2024-01-01T00:00:00Z',
941
+ proposer_id: '0x' + 'c'.repeat(64),
942
+ cosigner_sigs: [
943
+ {
944
+ signer_id: '0x1',
945
+ signature: { scheme: 'falcon', signature: '0x' + 'e'.repeat(128) },
946
+ timestamp: '2024-01-01T00:00:00Z',
947
+ },
948
+ ],
949
+ },
950
+ },
951
+ ];
952
+
953
+ mockFetch.mockResolvedValueOnce({
954
+ ok: true,
955
+ json: async () => ({ proposals: mockProposals }),
956
+ });
957
+
958
+ await expect(multisig.syncProposals()).rejects.toThrow('expected signerId as 32-byte hex');
959
+ });
960
+
961
+ it('should reject duplicate normalized signer IDs from GUARDIAN proposals', async () => {
962
+ const config = {
963
+ threshold: 2,
964
+ signerCommitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)],
965
+ guardianCommitment: '0x' + 'c'.repeat(64),
966
+ };
967
+
968
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
969
+
970
+ const mockProposals = [
971
+ {
972
+ account_id: '0x' + 'a'.repeat(30),
973
+ nonce: 1,
974
+ prev_commitment: '0x' + 'b'.repeat(64),
975
+ delta_payload: {
976
+ tx_summary: { data: 'AQID' },
977
+ signatures: [],
978
+ metadata: {
979
+ proposal_type: 'add_signer',
980
+ target_threshold: 2,
981
+ signer_commitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)],
982
+ description: '',
983
+ },
984
+ },
985
+ status: {
986
+ status: 'pending',
987
+ timestamp: '2024-01-01T00:00:00Z',
988
+ proposer_id: '0x' + 'c'.repeat(64),
989
+ cosigner_sigs: [
990
+ {
991
+ signer_id: '0x' + 'A'.repeat(64),
992
+ signature: { scheme: 'falcon', signature: '0x' + 'e'.repeat(128) },
993
+ timestamp: '2024-01-01T00:00:00Z',
994
+ },
995
+ {
996
+ signer_id: '0x' + 'a'.repeat(64),
997
+ signature: { scheme: 'falcon', signature: '0x' + 'f'.repeat(128) },
998
+ timestamp: '2024-01-01T00:00:01Z',
999
+ },
1000
+ ],
1001
+ },
1002
+ },
1003
+ ];
1004
+
1005
+ mockFetch.mockResolvedValueOnce({
1006
+ ok: true,
1007
+ json: async () => ({ proposals: mockProposals }),
1008
+ });
1009
+
1010
+ await expect(multisig.syncProposals()).rejects.toThrow('duplicate signatures for signer');
1011
+ });
1012
+ });
1013
+
1014
+ describe('listProposals', () => {
1015
+ it('should return empty list initially', () => {
1016
+ const config = {
1017
+ threshold: 1,
1018
+ signerCommitments: ['0x' + 'a'.repeat(64)],
1019
+ guardianCommitment: '0x' + 'c'.repeat(64),
1020
+ };
1021
+
1022
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
1023
+ expect(multisig.listProposals()).toEqual([]);
1024
+ });
1025
+ });
1026
+
1027
+ describe('createProposal', () => {
1028
+ it('should create a new proposal', async () => {
1029
+ const config = {
1030
+ threshold: 1,
1031
+ signerCommitments: ['0x' + 'a'.repeat(64)],
1032
+ guardianCommitment: '0x' + 'c'.repeat(64),
1033
+ };
1034
+
1035
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
1036
+
1037
+ const mockDelta = {
1038
+ account_id: '0x' + 'a'.repeat(30),
1039
+ nonce: 1,
1040
+ prev_commitment: '0x' + 'b'.repeat(64),
1041
+ delta_payload: {
1042
+ tx_summary: { data: 'AQID' },
1043
+ signatures: [],
1044
+ },
1045
+ status: {
1046
+ status: 'pending',
1047
+ timestamp: '2024-01-01T00:00:00Z',
1048
+ proposer_id: '0x' + 'c'.repeat(64),
1049
+ cosigner_sigs: [],
1050
+ },
1051
+ };
1052
+
1053
+ mockFetch.mockResolvedValueOnce({
1054
+ ok: true,
1055
+ json: async () => ({
1056
+ delta: mockDelta,
1057
+ commitment: '0x' + 'c'.repeat(64),
1058
+ }),
1059
+ });
1060
+
1061
+ const proposal = await multisig.createProposal(1, 'AQID', {
1062
+ proposalType: 'add_signer',
1063
+ targetThreshold: 1,
1064
+ targetSignerCommitments: ['0x' + 'a'.repeat(64)],
1065
+ description: '',
1066
+ });
1067
+
1068
+ expect(proposal.nonce).toBe(1);
1069
+ expect(proposal.id).toBe('0x' + 'c'.repeat(64));
1070
+ });
1071
+
1072
+ it('should reject a mismatched returned commitment', async () => {
1073
+ const config = {
1074
+ threshold: 1,
1075
+ signerCommitments: ['0x' + 'a'.repeat(64)],
1076
+ guardianCommitment: '0x' + 'c'.repeat(64),
1077
+ };
1078
+
1079
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
1080
+
1081
+ const mockDelta = {
1082
+ account_id: '0x' + 'a'.repeat(30),
1083
+ nonce: 1,
1084
+ prev_commitment: '0x' + 'b'.repeat(64),
1085
+ delta_payload: {
1086
+ tx_summary: { data: 'AQID' },
1087
+ signatures: [],
1088
+ },
1089
+ status: {
1090
+ status: 'pending',
1091
+ timestamp: '2024-01-01T00:00:00Z',
1092
+ proposer_id: '0x' + 'c'.repeat(64),
1093
+ cosigner_sigs: [],
1094
+ },
1095
+ };
1096
+
1097
+ mockFetch.mockResolvedValueOnce({
1098
+ ok: true,
1099
+ json: async () => ({
1100
+ delta: mockDelta,
1101
+ commitment: '0x' + 'd'.repeat(64),
1102
+ }),
1103
+ });
1104
+
1105
+ await expect(
1106
+ multisig.createProposal(1, 'AQID', {
1107
+ proposalType: 'add_signer',
1108
+ targetThreshold: 1,
1109
+ targetSignerCommitments: ['0x' + 'a'.repeat(64)],
1110
+ description: '',
1111
+ }),
1112
+ ).rejects.toThrow(
1113
+ 'Invalid proposal: commitment 0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd does not match tx_summary 0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
1114
+ );
1115
+ });
1116
+
1117
+ it('should reject a response whose tx_summary does not match the provided metadata', async () => {
1118
+ const config = {
1119
+ threshold: 1,
1120
+ signerCommitments: ['0x' + 'a'.repeat(64)],
1121
+ guardianCommitment: '0x' + 'c'.repeat(64),
1122
+ };
1123
+
1124
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
1125
+
1126
+ const mockDelta = {
1127
+ account_id: '0x' + 'a'.repeat(30),
1128
+ nonce: 1,
1129
+ prev_commitment: '0x' + 'b'.repeat(64),
1130
+ delta_payload: {
1131
+ tx_summary: { data: 'AQID' },
1132
+ signatures: [],
1133
+ },
1134
+ status: {
1135
+ status: 'pending',
1136
+ timestamp: '2024-01-01T00:00:00Z',
1137
+ proposer_id: '0x' + 'c'.repeat(64),
1138
+ cosigner_sigs: [],
1139
+ },
1140
+ };
1141
+
1142
+ mockFetch.mockResolvedValueOnce({
1143
+ ok: true,
1144
+ json: async () => ({
1145
+ delta: mockDelta,
1146
+ commitment: '0x' + 'c'.repeat(64),
1147
+ }),
1148
+ });
1149
+
1150
+ vi.mocked(executeForSummary).mockResolvedValueOnce({
1151
+ toCommitment: () => ({
1152
+ toHex: () => '0x' + 'f'.repeat(64),
1153
+ }),
1154
+ } as any);
1155
+
1156
+ await expect(
1157
+ multisig.createProposal(1, 'AQID', {
1158
+ proposalType: 'add_signer',
1159
+ targetThreshold: 1,
1160
+ targetSignerCommitments: ['0x' + 'a'.repeat(64)],
1161
+ description: '',
1162
+ })
1163
+ ).rejects.toThrow('Invalid proposal: metadata does not match tx_summary');
1164
+ });
1165
+ });
1166
+
1167
+ describe('createP2idProposal', () => {
1168
+ it('should include the faucet asset in the proposal description', async () => {
1169
+ const { executeForSummary } = await import('./transaction.js');
1170
+ vi.mocked(executeForSummary).mockResolvedValue({
1171
+ toCommitment: () => ({
1172
+ toHex: () => '0x' + 'c'.repeat(64),
1173
+ }),
1174
+ serialize: () => new Uint8Array([1, 2, 3]),
1175
+ } as any);
1176
+
1177
+ const config = {
1178
+ threshold: 1,
1179
+ signerCommitments: ['0x' + 'a'.repeat(64)],
1180
+ guardianCommitment: '0x' + 'c'.repeat(64),
1181
+ };
1182
+
1183
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
1184
+
1185
+ const mockDelta = {
1186
+ account_id: '0x' + 'a'.repeat(30),
1187
+ nonce: 1,
1188
+ prev_commitment: '0x' + 'b'.repeat(64),
1189
+ delta_payload: {
1190
+ tx_summary: { data: 'AQID' },
1191
+ signatures: [],
1192
+ metadata: {
1193
+ proposal_type: 'p2id',
1194
+ recipient_id: '0xrecipient',
1195
+ faucet_id: '0xfaucet',
1196
+ amount: '100',
1197
+ description: '',
1198
+ },
1199
+ },
1200
+ status: {
1201
+ status: 'pending',
1202
+ timestamp: '2024-01-01T00:00:00Z',
1203
+ proposer_id: '0x' + 'c'.repeat(64),
1204
+ cosigner_sigs: [],
1205
+ },
1206
+ };
1207
+
1208
+ mockFetch.mockResolvedValueOnce({
1209
+ ok: true,
1210
+ json: async () => ({
1211
+ delta: mockDelta,
1212
+ commitment: '0x' + 'c'.repeat(64),
1213
+ }),
1214
+ });
1215
+
1216
+ const proposal = await multisig.createP2idProposal('0xrecipient', '0xfaucet', 100n, 1);
1217
+
1218
+ expect(proposal.metadata.description).toBe('Send 100 of asset 0xfaucet... to 0xrecipien...');
1219
+ });
1220
+ });
1221
+
1222
+ describe('createChangeThresholdProposal', () => {
1223
+ it('passes the signer scheme to update-signers requests', async () => {
1224
+ vi.mocked(executeForSummary).mockResolvedValue({
1225
+ toCommitment: () => ({
1226
+ toHex: () => '0x' + 'c'.repeat(64),
1227
+ }),
1228
+ serialize: () => new Uint8Array([1, 2, 3]),
1229
+ } as any);
1230
+
1231
+ const ecdsaSigner: Signer = {
1232
+ ...mockSigner,
1233
+ publicKey: '0x' + '2'.repeat(66),
1234
+ scheme: 'ecdsa',
1235
+ };
1236
+ guardian.setSigner(ecdsaSigner);
1237
+
1238
+ const config = {
1239
+ threshold: 1,
1240
+ signerCommitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)],
1241
+ guardianCommitment: '0x' + 'c'.repeat(64),
1242
+ };
1243
+
1244
+ const mockDelta = {
1245
+ account_id: '0x' + 'a'.repeat(30),
1246
+ nonce: 1,
1247
+ prev_commitment: '0x' + 'b'.repeat(64),
1248
+ delta_payload: {
1249
+ tx_summary: { data: 'AQID' },
1250
+ signatures: [],
1251
+ metadata: {
1252
+ proposal_type: 'change_threshold',
1253
+ target_threshold: 2,
1254
+ description: '',
1255
+ },
1256
+ },
1257
+ status: {
1258
+ status: 'pending',
1259
+ timestamp: '2024-01-01T00:00:00Z',
1260
+ proposer_id: '0x' + 'c'.repeat(64),
1261
+ cosigner_sigs: [],
1262
+ },
1263
+ };
1264
+
1265
+ mockFetch.mockResolvedValueOnce({
1266
+ ok: true,
1267
+ json: async () => ({
1268
+ delta: mockDelta,
1269
+ commitment: '0x' + 'c'.repeat(64),
1270
+ }),
1271
+ });
1272
+
1273
+ const multisig = new Multisig(mockAccount, config, guardian, ecdsaSigner, mockWebClient);
1274
+ await multisig.createChangeThresholdProposal(2, 1);
1275
+
1276
+ expect(buildUpdateSignersTransactionRequest).toHaveBeenCalledWith(
1277
+ mockWebClient,
1278
+ 2,
1279
+ config.signerCommitments,
1280
+ { signatureScheme: 'ecdsa' },
1281
+ );
1282
+ });
1283
+ });
1284
+
1285
+ describe('createSwitchGuardianProposal', () => {
1286
+ it('should verify new endpoint commitment before creating proposal', async () => {
1287
+ vi.mocked(executeForSummary).mockResolvedValue({
1288
+ serialize: () => new Uint8Array([1, 2, 3]),
1289
+ } as any);
1290
+
1291
+ const config = {
1292
+ threshold: 1,
1293
+ signerCommitments: ['0x' + 'a'.repeat(64)],
1294
+ guardianCommitment: '0x' + 'c'.repeat(64),
1295
+ };
1296
+
1297
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
1298
+ const newGuardianPubkey = '0x' + '1'.repeat(64);
1299
+
1300
+ mockFetch.mockResolvedValueOnce({
1301
+ ok: true,
1302
+ json: async () => ({ commitment: newGuardianPubkey }),
1303
+ });
1304
+
1305
+ const proposal = await multisig.createSwitchGuardianProposal('http://new-guardian.com', newGuardianPubkey);
1306
+
1307
+ expect(proposal.metadata?.proposalType).toBe('switch_guardian');
1308
+ if (proposal.metadata?.proposalType === 'switch_guardian') {
1309
+ expect(proposal.metadata.newGuardianEndpoint).toBe('http://new-guardian.com');
1310
+ }
1311
+ expect(mockFetch).toHaveBeenCalledWith(
1312
+ 'http://new-guardian.com/pubkey?scheme=falcon',
1313
+ expect.objectContaining({ method: 'GET' })
1314
+ );
1315
+ });
1316
+
1317
+ it('should reject switch proposal when endpoint commitment does not match', async () => {
1318
+ vi.mocked(executeForSummary).mockResolvedValue({
1319
+ serialize: () => new Uint8Array([1, 2, 3]),
1320
+ } as any);
1321
+
1322
+ const config = {
1323
+ threshold: 1,
1324
+ signerCommitments: ['0x' + 'a'.repeat(64)],
1325
+ guardianCommitment: '0x' + 'c'.repeat(64),
1326
+ };
1327
+
1328
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
1329
+
1330
+ mockFetch.mockResolvedValueOnce({
1331
+ ok: true,
1332
+ json: async () => ({ commitment: '0x' + '2'.repeat(64) }),
1333
+ });
1334
+
1335
+ await expect(
1336
+ multisig.createSwitchGuardianProposal('http://new-guardian.com', '0x' + '1'.repeat(64))
1337
+ ).rejects.toThrow('Refusing to use GUARDIAN endpoint');
1338
+ });
1339
+
1340
+ it('should use the signer scheme when resolving new GUARDIAN commitments', async () => {
1341
+ vi.mocked(executeForSummary).mockResolvedValue({
1342
+ serialize: () => new Uint8Array([1, 2, 3]),
1343
+ } as any);
1344
+
1345
+ const ecdsaSigner: Signer = {
1346
+ ...mockSigner,
1347
+ publicKey: '0x' + '2'.repeat(66),
1348
+ scheme: 'ecdsa',
1349
+ };
1350
+ guardian.setSigner(ecdsaSigner);
1351
+
1352
+ const config = {
1353
+ threshold: 1,
1354
+ signerCommitments: ['0x' + 'a'.repeat(64)],
1355
+ guardianCommitment: '0x' + 'c'.repeat(64),
1356
+ };
1357
+
1358
+ const multisig = new Multisig(mockAccount, config, guardian, ecdsaSigner, mockWebClient);
1359
+ const newGuardianCommitment = '0x' + '1'.repeat(64);
1360
+
1361
+ mockFetch.mockResolvedValueOnce({
1362
+ ok: true,
1363
+ json: async () => ({ commitment: newGuardianCommitment }),
1364
+ });
1365
+
1366
+ await multisig.createSwitchGuardianProposal('http://new-guardian.com', newGuardianCommitment);
1367
+
1368
+ expect(mockFetch).toHaveBeenCalledWith(
1369
+ 'http://new-guardian.com/pubkey?scheme=ecdsa',
1370
+ expect.objectContaining({ method: 'GET' }),
1371
+ );
1372
+ expect(buildUpdateGuardianTransactionRequest).toHaveBeenCalledWith(
1373
+ mockWebClient,
1374
+ newGuardianCommitment,
1375
+ { signatureScheme: 'ecdsa' },
1376
+ );
1377
+ });
1378
+ });
1379
+
1380
+ describe('createUpdateProcedureThresholdProposal', () => {
1381
+ it('should create procedure-threshold update proposals', async () => {
1382
+ vi.mocked(executeForSummary).mockResolvedValue({
1383
+ toCommitment: () => ({
1384
+ toHex: () => '0x' + 'c'.repeat(64),
1385
+ }),
1386
+ serialize: () => new Uint8Array([1, 2, 3]),
1387
+ } as any);
1388
+
1389
+ const config = {
1390
+ threshold: 2,
1391
+ signerCommitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)],
1392
+ guardianCommitment: '0x' + 'c'.repeat(64),
1393
+ };
1394
+
1395
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
1396
+
1397
+ const mockDelta = {
1398
+ account_id: '0x' + 'a'.repeat(30),
1399
+ nonce: 1,
1400
+ prev_commitment: '0x' + 'b'.repeat(64),
1401
+ delta_payload: {
1402
+ tx_summary: { data: 'AQID' },
1403
+ signatures: [],
1404
+ metadata: {
1405
+ proposal_type: 'update_procedure_threshold',
1406
+ target_threshold: 1,
1407
+ target_procedure: 'send_asset',
1408
+ description: '',
1409
+ },
1410
+ },
1411
+ status: {
1412
+ status: 'pending',
1413
+ timestamp: '2024-01-01T00:00:00Z',
1414
+ proposer_id: '0x' + 'c'.repeat(64),
1415
+ cosigner_sigs: [],
1416
+ },
1417
+ };
1418
+
1419
+ mockFetch.mockResolvedValueOnce({
1420
+ ok: true,
1421
+ json: async () => ({
1422
+ delta: mockDelta,
1423
+ commitment: '0x' + 'c'.repeat(64),
1424
+ }),
1425
+ });
1426
+
1427
+ const proposal = await multisig.createUpdateProcedureThresholdProposal('send_asset', 1, 1);
1428
+
1429
+ expect(buildUpdateProcedureThresholdTransactionRequest).toHaveBeenCalledWith(
1430
+ mockWebClient,
1431
+ 'send_asset',
1432
+ 1,
1433
+ { signatureScheme: 'falcon' },
1434
+ );
1435
+ expect(proposal.metadata.proposalType).toBe('update_procedure_threshold');
1436
+ if (proposal.metadata.proposalType === 'update_procedure_threshold') {
1437
+ expect(proposal.metadata.targetProcedure).toBe('send_asset');
1438
+ expect(proposal.metadata.targetThreshold).toBe(1);
1439
+ }
1440
+ });
1441
+
1442
+ it('passes the signer scheme to ECDSA procedure-threshold updates', async () => {
1443
+ vi.mocked(executeForSummary).mockResolvedValue({
1444
+ toCommitment: () => ({
1445
+ toHex: () => '0x' + 'c'.repeat(64),
1446
+ }),
1447
+ serialize: () => new Uint8Array([1, 2, 3]),
1448
+ } as any);
1449
+
1450
+ const ecdsaSigner: Signer = {
1451
+ ...mockSigner,
1452
+ publicKey: '0x' + '2'.repeat(66),
1453
+ scheme: 'ecdsa',
1454
+ };
1455
+ guardian.setSigner(ecdsaSigner);
1456
+
1457
+ const config = {
1458
+ threshold: 2,
1459
+ signerCommitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)],
1460
+ guardianCommitment: '0x' + 'c'.repeat(64),
1461
+ };
1462
+
1463
+ const multisig = new Multisig(mockAccount, config, guardian, ecdsaSigner, mockWebClient);
1464
+
1465
+ const mockDelta = {
1466
+ account_id: '0x' + 'a'.repeat(30),
1467
+ nonce: 1,
1468
+ prev_commitment: '0x' + 'b'.repeat(64),
1469
+ delta_payload: {
1470
+ tx_summary: { data: 'AQID' },
1471
+ signatures: [],
1472
+ metadata: {
1473
+ proposal_type: 'update_procedure_threshold',
1474
+ target_threshold: 1,
1475
+ target_procedure: 'send_asset',
1476
+ description: '',
1477
+ },
1478
+ },
1479
+ status: {
437
1480
  status: 'pending',
438
1481
  timestamp: '2024-01-01T00:00:00Z',
439
1482
  proposer_id: '0x' + 'c'.repeat(64),
@@ -445,31 +1488,30 @@ describe('Multisig', () => {
445
1488
  ok: true,
446
1489
  json: async () => ({
447
1490
  delta: mockDelta,
448
- commitment: '0x' + 'd'.repeat(64),
1491
+ commitment: '0x' + 'c'.repeat(64),
449
1492
  }),
450
1493
  });
451
1494
 
452
- const proposal = await multisig.createProposal(1, 'AQID', {
453
- proposalType: 'add_signer',
454
- targetThreshold: 1,
455
- targetSignerCommitments: ['0x' + 'a'.repeat(64)],
456
- description: '',
457
- });
1495
+ await multisig.createUpdateProcedureThresholdProposal('send_asset', 1, 1);
458
1496
 
459
- expect(proposal.nonce).toBe(1);
460
- expect(proposal.id).toBe('0x' + 'd'.repeat(64));
1497
+ expect(buildUpdateProcedureThresholdTransactionRequest).toHaveBeenCalledWith(
1498
+ mockWebClient,
1499
+ 'send_asset',
1500
+ 1,
1501
+ { signatureScheme: 'ecdsa' },
1502
+ );
461
1503
  });
462
1504
  });
463
1505
 
464
- describe('signTransactionProposal', () => {
1506
+ describe('signProposal', () => {
465
1507
  it('should sign a proposal', async () => {
466
1508
  const config = {
467
1509
  threshold: 1,
468
- signerCommitments: ['0x' + 'a'.repeat(64)],
469
- psmCommitment: '0x' + 'c'.repeat(64),
1510
+ signerCommitments: [mockSigner.commitment],
1511
+ guardianCommitment: '0x' + 'c'.repeat(64),
470
1512
  };
471
1513
 
472
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
1514
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
473
1515
 
474
1516
  // First create a proposal
475
1517
  const mockDelta = {
@@ -492,7 +1534,7 @@ describe('Multisig', () => {
492
1534
  ok: true,
493
1535
  json: async () => ({
494
1536
  delta: mockDelta,
495
- commitment: '0x' + 'd'.repeat(64),
1537
+ commitment: '0x' + 'c'.repeat(64),
496
1538
  }),
497
1539
  });
498
1540
 
@@ -503,7 +1545,6 @@ describe('Multisig', () => {
503
1545
  description: '',
504
1546
  });
505
1547
 
506
- // Now sign it
507
1548
  const signedDelta = {
508
1549
  ...mockDelta,
509
1550
  status: {
@@ -529,114 +1570,496 @@ describe('Multisig', () => {
529
1570
  },
530
1571
  };
531
1572
 
532
- // Mock for signDeltaProposal
533
1573
  mockFetch.mockResolvedValueOnce({
534
1574
  ok: true,
535
1575
  json: async () => signedDelta,
536
1576
  });
537
1577
 
538
- // Mock for auto-sync (syncTransactionProposals)
1578
+ const proposalId = '0x' + 'c'.repeat(64);
1579
+ const signedProposal = await multisig.signProposal(proposalId);
1580
+
1581
+ expect(mockSigner.signCommitment).toHaveBeenCalledWith(proposalId);
1582
+ expect(signedProposal.signatures.length).toBe(1);
1583
+ });
1584
+
1585
+ it('should reject signing when metadata does not match tx_summary', async () => {
1586
+ const config = {
1587
+ threshold: 1,
1588
+ signerCommitments: ['0x' + 'a'.repeat(64)],
1589
+ guardianCommitment: '0x' + 'c'.repeat(64),
1590
+ };
1591
+
1592
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
1593
+
1594
+ const mockDelta = {
1595
+ account_id: '0x' + 'a'.repeat(30),
1596
+ nonce: 1,
1597
+ prev_commitment: '0x' + 'b'.repeat(64),
1598
+ delta_payload: {
1599
+ tx_summary: { data: 'AQID' },
1600
+ signatures: [],
1601
+ },
1602
+ status: {
1603
+ status: 'pending',
1604
+ timestamp: '2024-01-01T00:00:00Z',
1605
+ proposer_id: '0x' + 'c'.repeat(64),
1606
+ cosigner_sigs: [],
1607
+ },
1608
+ };
1609
+
539
1610
  mockFetch.mockResolvedValueOnce({
540
1611
  ok: true,
541
- json: async () => ({ proposals: [signedDelta] }),
1612
+ json: async () => ({
1613
+ delta: mockDelta,
1614
+ commitment: '0x' + 'c'.repeat(64),
1615
+ }),
1616
+ });
1617
+
1618
+ await multisig.createProposal(1, 'AQID', {
1619
+ proposalType: 'add_signer',
1620
+ targetThreshold: 1,
1621
+ targetSignerCommitments: ['0x' + 'a'.repeat(64)],
1622
+ description: '',
542
1623
  });
543
1624
 
1625
+ vi.mocked(executeForSummary).mockResolvedValueOnce({
1626
+ toCommitment: () => ({
1627
+ toHex: () => '0x' + 'f'.repeat(64),
1628
+ }),
1629
+ } as any);
1630
+
1631
+ await expect(multisig.signProposal('0x' + 'c'.repeat(64))).rejects.toThrow(
1632
+ 'Invalid proposal: metadata does not match tx_summary'
1633
+ );
1634
+ });
1635
+
1636
+ it('should reject proposals for a different account before signing', async () => {
1637
+ const config = {
1638
+ threshold: 1,
1639
+ signerCommitments: [mockSigner.commitment],
1640
+ guardianCommitment: '0x' + 'c'.repeat(64),
1641
+ };
1642
+
1643
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
544
1644
  const proposalId = '0x' + 'd'.repeat(64);
545
- const proposals = await multisig.signTransactionProposal(proposalId);
546
1645
 
547
- expect(mockSigner.signCommitment).toHaveBeenCalledWith(proposalId);
548
- expect(proposals.length).toBeGreaterThanOrEqual(1);
549
- expect(proposals[0].signatures.length).toBe(1);
550
- expect(proposals[0].commitment).toBe(proposals[0].id);
1646
+ mockFetch.mockResolvedValueOnce({
1647
+ ok: true,
1648
+ json: async () => ({
1649
+ proposals: [
1650
+ {
1651
+ account_id: '0x' + 'f'.repeat(30),
1652
+ nonce: 1,
1653
+ prev_commitment: '0x' + 'b'.repeat(64),
1654
+ delta_payload: {
1655
+ tx_summary: { data: 'AQID' },
1656
+ signatures: [],
1657
+ metadata: {
1658
+ proposal_type: 'add_signer',
1659
+ description: '',
1660
+ target_threshold: 1,
1661
+ signer_commitments: [mockSigner.commitment],
1662
+ },
1663
+ },
1664
+ status: {
1665
+ status: 'pending',
1666
+ timestamp: '2024-01-01T00:00:00Z',
1667
+ proposer_id: '0x' + 'c'.repeat(64),
1668
+ cosigner_sigs: [],
1669
+ },
1670
+ },
1671
+ ],
1672
+ }),
1673
+ });
1674
+
1675
+ await expect(multisig.signProposal(proposalId)).rejects.toThrow(
1676
+ 'Proposal is for a different account: 0x' + 'f'.repeat(30),
1677
+ );
1678
+ expect(mockSigner.signCommitment).not.toHaveBeenCalled();
1679
+ });
1680
+ });
1681
+
1682
+ describe('importProposal', () => {
1683
+ it('should reject imported proposals whose metadata does not match tx_summary', async () => {
1684
+ const config = {
1685
+ threshold: 1,
1686
+ signerCommitments: ['0x' + 'a'.repeat(64)],
1687
+ guardianCommitment: '0x' + 'c'.repeat(64),
1688
+ };
1689
+
1690
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
1691
+
1692
+ vi.mocked(executeForSummary).mockResolvedValueOnce({
1693
+ toCommitment: () => ({
1694
+ toHex: () => '0x' + 'f'.repeat(64),
1695
+ }),
1696
+ } as any);
1697
+
1698
+ await expect(
1699
+ multisig.importProposal(
1700
+ JSON.stringify({
1701
+ accountId: '0x' + 'a'.repeat(30),
1702
+ nonce: 1,
1703
+ commitment: '0x' + 'c'.repeat(64),
1704
+ txSummaryBase64: 'AQID',
1705
+ signatures: [],
1706
+ metadata: {
1707
+ proposalType: 'add_signer',
1708
+ targetThreshold: 1,
1709
+ targetSignerCommitments: ['0x' + 'a'.repeat(64)],
1710
+ description: '',
1711
+ },
1712
+ })
1713
+ )
1714
+ ).rejects.toThrow('Invalid proposal: metadata does not match tx_summary');
1715
+ });
1716
+ });
1717
+
1718
+ describe('signProposalOffline', () => {
1719
+ it('should reject signing imported proposals whose metadata does not match tx_summary', async () => {
1720
+ const config = {
1721
+ threshold: 1,
1722
+ signerCommitments: ['0x' + 'a'.repeat(64)],
1723
+ guardianCommitment: '0x' + 'c'.repeat(64),
1724
+ };
1725
+
1726
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
1727
+
1728
+ vi.mocked(executeForSummary).mockResolvedValueOnce({
1729
+ toCommitment: () => ({
1730
+ toHex: () => '0x' + 'c'.repeat(64),
1731
+ }),
1732
+ } as any);
1733
+
1734
+ const proposal = await multisig.importProposal(
1735
+ JSON.stringify({
1736
+ accountId: '0x' + 'a'.repeat(30),
1737
+ nonce: 1,
1738
+ commitment: '0x' + 'c'.repeat(64),
1739
+ txSummaryBase64: 'AQID',
1740
+ signatures: [],
1741
+ metadata: {
1742
+ proposalType: 'add_signer',
1743
+ targetThreshold: 1,
1744
+ targetSignerCommitments: ['0x' + 'a'.repeat(64)],
1745
+ description: '',
1746
+ },
1747
+ })
1748
+ );
1749
+
1750
+ proposal.metadata = {
1751
+ proposalType: 'add_signer',
1752
+ targetThreshold: 2,
1753
+ targetSignerCommitments: ['0x' + 'a'.repeat(64)],
1754
+ description: '',
1755
+ };
1756
+
1757
+ vi.mocked(executeForSummary).mockResolvedValueOnce({
1758
+ toCommitment: () => ({
1759
+ toHex: () => '0x' + 'f'.repeat(64),
1760
+ }),
1761
+ } as any);
1762
+
1763
+ await expect(multisig.signProposalOffline(proposal.id)).rejects.toThrow(
1764
+ 'Invalid proposal: metadata does not match tx_summary'
1765
+ );
1766
+ });
1767
+ });
1768
+
1769
+ describe('exportProposal', () => {
1770
+ it('should export proposal for offline signing', async () => {
1771
+ const config = {
1772
+ threshold: 2,
1773
+ signerCommitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)],
1774
+ guardianCommitment: '0x' + 'c'.repeat(64),
1775
+ };
1776
+
1777
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
1778
+
1779
+ const mockProposals = [
1780
+ {
1781
+ account_id: '0x' + 'a'.repeat(30),
1782
+ nonce: 1,
1783
+ prev_commitment: '0x' + 'b'.repeat(64),
1784
+ delta_payload: {
1785
+ tx_summary: { data: 'AQID' },
1786
+ signatures: [],
1787
+ metadata: {
1788
+ proposal_type: 'add_signer',
1789
+ description: '',
1790
+ target_threshold: 1,
1791
+ signer_commitments: ['0x' + 'a'.repeat(64)],
1792
+ },
1793
+ },
1794
+ status: {
1795
+ status: 'pending',
1796
+ timestamp: '2024-01-01T00:00:00Z',
1797
+ proposer_id: '0x' + 'c'.repeat(64),
1798
+ cosigner_sigs: [
1799
+ {
1800
+ signer_id: '0x' + 'a'.repeat(64),
1801
+ signature: { scheme: 'falcon', signature: '0x' + 'e'.repeat(128) },
1802
+ timestamp: '2024-01-01T00:00:00Z',
1803
+ },
1804
+ ],
1805
+ },
1806
+ },
1807
+ ];
1808
+
1809
+ mockFetch.mockResolvedValueOnce({
1810
+ ok: true,
1811
+ json: async () => mockProposals[0],
1812
+ });
1813
+
1814
+ // The proposal ID is computed from tx_summary, which is mocked to return 'c'.repeat(64)
1815
+ const exported = await multisig.exportProposal('0x' + 'c'.repeat(64));
1816
+
1817
+ expect(exported.accountId).toBe('0x' + 'a'.repeat(30));
1818
+ expect(exported.nonce).toBe(1);
1819
+ expect(exported.txSummaryBase64).toBe('AQID');
1820
+ expect(exported.signatures.length).toBe(1);
1821
+ });
1822
+
1823
+ it('should preserve ECDSA signature metadata in exported proposals', async () => {
1824
+ const config = {
1825
+ threshold: 2,
1826
+ signerCommitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)],
1827
+ guardianCommitment: '0x' + 'c'.repeat(64),
1828
+ };
1829
+
1830
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
1831
+ const publicKey = '0x' + 'd'.repeat(66);
1832
+
1833
+ mockFetch.mockResolvedValueOnce({
1834
+ ok: true,
1835
+ json: async () => ({
1836
+ account_id: '0x' + 'a'.repeat(30),
1837
+ nonce: 1,
1838
+ prev_commitment: '0x' + 'b'.repeat(64),
1839
+ delta_payload: {
1840
+ tx_summary: { data: 'AQID' },
1841
+ signatures: [],
1842
+ metadata: {
1843
+ proposal_type: 'change_threshold',
1844
+ description: '',
1845
+ target_threshold: 2,
1846
+ signer_commitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)],
1847
+ },
1848
+ },
1849
+ status: {
1850
+ status: 'pending',
1851
+ timestamp: '2024-01-01T00:00:00Z',
1852
+ proposer_id: '0x' + 'c'.repeat(64),
1853
+ cosigner_sigs: [
1854
+ {
1855
+ signer_id: '0x' + 'a'.repeat(64),
1856
+ signature: {
1857
+ scheme: 'ecdsa',
1858
+ signature: '0x' + 'e'.repeat(130),
1859
+ public_key: publicKey,
1860
+ },
1861
+ timestamp: '2024-01-01T00:00:00Z',
1862
+ },
1863
+ ],
1864
+ },
1865
+ }),
1866
+ });
1867
+
1868
+ const exported = await multisig.exportProposal('0x' + 'c'.repeat(64));
1869
+
1870
+ expect(exported.signatures).toEqual([
1871
+ {
1872
+ commitment: '0x' + 'a'.repeat(64),
1873
+ signatureHex: '0x' + 'e'.repeat(130),
1874
+ scheme: 'ecdsa',
1875
+ publicKey,
1876
+ timestamp: '2024-01-01T00:00:00Z',
1877
+ },
1878
+ ]);
1879
+ });
1880
+
1881
+ it('should throw if proposal not found', async () => {
1882
+ const config = {
1883
+ threshold: 1,
1884
+ signerCommitments: ['0x' + 'a'.repeat(64)],
1885
+ guardianCommitment: '0x' + 'c'.repeat(64),
1886
+ };
1887
+
1888
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
1889
+
1890
+ mockFetch.mockResolvedValueOnce({
1891
+ ok: false,
1892
+ status: 404,
1893
+ statusText: 'Not Found',
1894
+ text: async () => 'Proposal not found',
1895
+ });
1896
+
1897
+ await expect(
1898
+ multisig.exportProposal('0x' + 'nonexistent'.repeat(5))
1899
+ ).rejects.toThrow('Proposal not found');
1900
+ });
1901
+ });
1902
+
1903
+ describe('importProposal', () => {
1904
+ it('should reject imported signatures with non-32-byte signer IDs', async () => {
1905
+ const config = {
1906
+ threshold: 1,
1907
+ signerCommitments: ['0x' + 'a'.repeat(64)],
1908
+ guardianCommitment: '0x' + 'c'.repeat(64),
1909
+ };
1910
+
1911
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
1912
+
1913
+ const exported = {
1914
+ accountId: multisig.accountId,
1915
+ nonce: 1,
1916
+ commitment: '0x' + 'c'.repeat(64),
1917
+ txSummaryBase64: 'AQID',
1918
+ signatures: [
1919
+ {
1920
+ commitment: '0x1',
1921
+ signatureHex: '0x' + 'b'.repeat(128),
1922
+ },
1923
+ ],
1924
+ metadata: {
1925
+ proposalType: 'add_signer' as const,
1926
+ targetThreshold: 1,
1927
+ targetSignerCommitments: ['0x' + 'a'.repeat(64)],
1928
+ description: '',
1929
+ },
1930
+ };
1931
+
1932
+ await expect(multisig.importProposal(JSON.stringify(exported))).rejects.toThrow(
1933
+ 'expected signerId as 32-byte hex',
1934
+ );
1935
+ });
1936
+
1937
+ it('should preserve ECDSA imported signature metadata', async () => {
1938
+ const config = {
1939
+ threshold: 1,
1940
+ signerCommitments: ['0x' + 'a'.repeat(64)],
1941
+ guardianCommitment: '0x' + 'c'.repeat(64),
1942
+ };
1943
+
1944
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
1945
+ const publicKey = '0x' + 'd'.repeat(66);
1946
+
1947
+ const proposal = await multisig.importProposal(
1948
+ JSON.stringify({
1949
+ accountId: multisig.accountId,
1950
+ nonce: 1,
1951
+ commitment: '0x' + 'c'.repeat(64),
1952
+ txSummaryBase64: 'AQID',
1953
+ signatures: [
1954
+ {
1955
+ commitment: '0x' + 'a'.repeat(64),
1956
+ signatureHex: '0x' + 'b'.repeat(130),
1957
+ scheme: 'ecdsa',
1958
+ publicKey,
1959
+ timestamp: '2024-01-01T00:00:00Z',
1960
+ },
1961
+ ],
1962
+ metadata: {
1963
+ proposalType: 'change_threshold',
1964
+ targetThreshold: 1,
1965
+ targetSignerCommitments: ['0x' + 'a'.repeat(64)],
1966
+ description: '',
1967
+ },
1968
+ })
1969
+ );
1970
+
1971
+ expect(proposal.signatures).toEqual([
1972
+ {
1973
+ signerId: '0x' + 'a'.repeat(64),
1974
+ signature: {
1975
+ scheme: 'ecdsa',
1976
+ signature: '0x' + 'b'.repeat(130),
1977
+ publicKey,
1978
+ },
1979
+ timestamp: '2024-01-01T00:00:00Z',
1980
+ },
1981
+ ]);
551
1982
  });
552
- });
553
1983
 
554
- describe('exportTransactionProposalToJson', () => {
555
- it('should export proposal to JSON from local cache', async () => {
1984
+ it('should reject imported ECDSA signatures without a public key', async () => {
556
1985
  const config = {
557
- threshold: 2,
558
- signerCommitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)],
559
- psmCommitment: '0x' + 'c'.repeat(64),
1986
+ threshold: 1,
1987
+ signerCommitments: ['0x' + 'a'.repeat(64)],
1988
+ guardianCommitment: '0x' + 'c'.repeat(64),
560
1989
  };
561
1990
 
562
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
1991
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
563
1992
 
564
- // First sync proposals to populate local cache
565
- const mockProposals = [
566
- {
567
- account_id: '0x' + 'a'.repeat(30),
568
- nonce: 1,
569
- prev_commitment: '0x' + 'b'.repeat(64),
570
- delta_payload: {
571
- tx_summary: { data: 'AQID' },
572
- signatures: [],
573
- metadata: {
574
- proposal_type: 'add_signer',
575
- description: '',
576
- target_threshold: 1,
577
- signer_commitments: ['0x' + 'a'.repeat(64)],
578
- },
579
- },
580
- status: {
581
- status: 'pending',
582
- timestamp: '2024-01-01T00:00:00Z',
583
- proposer_id: '0x' + 'c'.repeat(64),
584
- cosigner_sigs: [
1993
+ await expect(
1994
+ multisig.importProposal(
1995
+ JSON.stringify({
1996
+ accountId: multisig.accountId,
1997
+ nonce: 1,
1998
+ commitment: '0x' + 'c'.repeat(64),
1999
+ txSummaryBase64: 'AQID',
2000
+ signatures: [
585
2001
  {
586
- signer_id: '0x' + 'd'.repeat(64),
587
- signature: { scheme: 'falcon', signature: '0x' + 'e'.repeat(128) },
588
- timestamp: '2024-01-01T00:00:00Z',
2002
+ commitment: '0x' + 'a'.repeat(64),
2003
+ signatureHex: '0x' + 'b'.repeat(130),
2004
+ scheme: 'ecdsa',
589
2005
  },
590
2006
  ],
591
- },
592
- },
593
- ];
594
-
595
- mockFetch.mockResolvedValueOnce({
596
- ok: true,
597
- json: async () => ({ proposals: mockProposals }),
598
- });
599
-
600
- await multisig.syncTransactionProposals();
601
-
602
- // The proposal ID is computed from tx_summary, which is mocked to return 'c'.repeat(64)
603
- const json = multisig.exportTransactionProposalToJson('0x' + 'c'.repeat(64));
604
- const exported = JSON.parse(json);
605
-
606
- expect(exported.accountId).toBe('0x' + 'a'.repeat(30));
607
- expect(exported.nonce).toBe(1);
608
- expect(exported.txSummaryBase64).toBe('AQID');
609
- expect(exported.signatures.length).toBe(1);
610
- expect(exported.commitment).toBe('0x' + 'c'.repeat(64));
2007
+ metadata: {
2008
+ proposalType: 'change_threshold',
2009
+ targetThreshold: 1,
2010
+ targetSignerCommitments: ['0x' + 'a'.repeat(64)],
2011
+ description: '',
2012
+ },
2013
+ })
2014
+ )
2015
+ ).rejects.toThrow('ECDSA signature for 0x' + 'a'.repeat(64) + ' is missing publicKey');
611
2016
  });
612
2017
 
613
- it('should throw if proposal not found in cache', () => {
2018
+ it('should reject offline signing if an imported proposal account is changed', async () => {
614
2019
  const config = {
615
- threshold: 1,
616
- signerCommitments: ['0x' + 'a'.repeat(64)],
617
- psmCommitment: '0x' + 'c'.repeat(64),
2020
+ threshold: 2,
2021
+ signerCommitments: ['0x' + 'a'.repeat(64), mockSigner.commitment],
2022
+ guardianCommitment: '0x' + 'c'.repeat(64),
618
2023
  };
619
2024
 
620
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
2025
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
621
2026
 
622
- expect(() =>
623
- multisig.exportTransactionProposalToJson('0x' + 'nonexistent'.repeat(5))
624
- ).toThrow('Proposal not found');
2027
+ const exported = {
2028
+ accountId: multisig.accountId,
2029
+ nonce: 1,
2030
+ commitment: '0x' + 'c'.repeat(64),
2031
+ txSummaryBase64: 'AQID',
2032
+ signatures: [],
2033
+ metadata: {
2034
+ proposalType: 'add_signer' as const,
2035
+ targetThreshold: 2,
2036
+ targetSignerCommitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)],
2037
+ description: '',
2038
+ },
2039
+ };
2040
+
2041
+ const proposal = await multisig.importProposal(JSON.stringify(exported));
2042
+ proposal.accountId = '0x' + 'f'.repeat(30);
2043
+
2044
+ await expect(multisig.signProposalOffline(proposal.id)).rejects.toThrow(
2045
+ 'Proposal is for a different account: 0x' + 'f'.repeat(30),
2046
+ );
2047
+ expect(mockSigner.signCommitment).not.toHaveBeenCalled();
625
2048
  });
626
2049
  });
627
2050
 
628
- describe('executeTransactionProposal', () => {
2051
+ describe('executeProposal', () => {
629
2052
  it('should throw if proposal not found locally', async () => {
630
2053
  const config = {
631
2054
  threshold: 1,
632
2055
  signerCommitments: ['0x' + 'a'.repeat(64)],
633
- psmCommitment: '0x' + 'c'.repeat(64),
2056
+ guardianCommitment: '0x' + 'c'.repeat(64),
634
2057
  };
635
2058
 
636
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
2059
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
637
2060
 
638
2061
  await expect(
639
- multisig.executeTransactionProposal('0x' + 'nonexistent'.repeat(5))
2062
+ multisig.executeProposal('0x' + 'nonexistent'.repeat(5))
640
2063
  ).rejects.toThrow('Proposal not found');
641
2064
  });
642
2065
 
@@ -644,10 +2067,10 @@ describe('Multisig', () => {
644
2067
  const config = {
645
2068
  threshold: 2, // Need 2 signatures
646
2069
  signerCommitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)],
647
- psmCommitment: '0x' + 'c'.repeat(64),
2070
+ guardianCommitment: '0x' + 'c'.repeat(64),
648
2071
  };
649
2072
 
650
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
2073
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
651
2074
 
652
2075
  // Sync with pending proposal (only 1 signature)
653
2076
  const mockProposals = [
@@ -671,7 +2094,7 @@ describe('Multisig', () => {
671
2094
  proposer_id: '0x' + 'c'.repeat(64),
672
2095
  cosigner_sigs: [
673
2096
  {
674
- signer_id: '0x' + 'd'.repeat(64),
2097
+ signer_id: '0x' + 'a'.repeat(64),
675
2098
  signature: { scheme: 'falcon', signature: '0x' + 'e'.repeat(128) },
676
2099
  timestamp: '2024-01-01T00:00:00Z',
677
2100
  },
@@ -685,22 +2108,22 @@ describe('Multisig', () => {
685
2108
  json: async () => ({ proposals: mockProposals }),
686
2109
  });
687
2110
 
688
- await multisig.syncTransactionProposals();
2111
+ await multisig.syncProposals();
689
2112
 
690
2113
  // Proposal ID is mocked to return 'c'.repeat(64)
691
2114
  await expect(
692
- multisig.executeTransactionProposal('0x' + 'c'.repeat(64))
2115
+ multisig.executeProposal('0x' + 'c'.repeat(64))
693
2116
  ).rejects.toThrow('not ready for execution');
694
2117
  });
695
2118
 
696
- it('should fail when PSM ack signature is missing (selector ON)', async () => {
2119
+ it('should fail when GUARDIAN ack signature is missing (selector ON)', async () => {
697
2120
  const config = {
698
2121
  threshold: 1,
699
2122
  signerCommitments: ['0x' + 'a'.repeat(64)],
700
- psmCommitment: '0x' + 'c'.repeat(64),
2123
+ guardianCommitment: '0x' + 'c'.repeat(64),
701
2124
  };
702
2125
 
703
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
2126
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
704
2127
 
705
2128
  const readyDelta = {
706
2129
  account_id: '0x' + 'a'.repeat(30),
@@ -722,7 +2145,7 @@ describe('Multisig', () => {
722
2145
  proposer_id: '0x' + 'c'.repeat(64),
723
2146
  cosigner_sigs: [
724
2147
  {
725
- signer_id: '0x' + 'd'.repeat(64),
2148
+ signer_id: '0x' + 'a'.repeat(64),
726
2149
  signature: { scheme: 'falcon', signature: '0x' + 'e'.repeat(128) },
727
2150
  timestamp: '2024-01-01T00:00:00Z',
728
2151
  },
@@ -737,12 +2160,12 @@ describe('Multisig', () => {
737
2160
  ok: true,
738
2161
  json: async () => ({ proposals: [readyDelta] }),
739
2162
  });
740
- await multisig.syncTransactionProposals();
2163
+ await multisig.syncProposals();
741
2164
 
742
- // executeProposal: getDeltaProposals
2165
+ // executeProposal: getDeltaProposal
743
2166
  mockFetch.mockResolvedValueOnce({
744
2167
  ok: true,
745
- json: async () => ({ proposals: [readyDelta] }),
2168
+ json: async () => readyDelta,
746
2169
  });
747
2170
  // executeProposal: pushDelta without ack_sig
748
2171
  mockFetch.mockResolvedValueOnce({
@@ -750,8 +2173,430 @@ describe('Multisig', () => {
750
2173
  json: async () => ({ ...readyDelta, ack_sig: null }),
751
2174
  });
752
2175
 
753
- await expect(multisig.executeTransactionProposal(proposalId)).rejects.toThrow(
754
- 'PSM did not return acknowledgment signature'
2176
+ await expect(multisig.executeProposal(proposalId)).rejects.toThrow(
2177
+ 'GUARDIAN did not return acknowledgment signature'
2178
+ );
2179
+ });
2180
+
2181
+ it('should encode ECDSA proposal and ack signatures with scheme-aware advice', async () => {
2182
+ const { buildSignatureAdviceEntry, signatureHexToBytes } = await import('./utils/signature.js');
2183
+ vi.mocked(signatureHexToBytes).mockClear();
2184
+ vi.mocked(buildSignatureAdviceEntry).mockClear();
2185
+
2186
+ const config = {
2187
+ threshold: 1,
2188
+ signerCommitments: ['0x' + 'a'.repeat(64)],
2189
+ guardianCommitment: '0x' + 'c'.repeat(64),
2190
+ guardianPublicKey: '0x' + '1'.repeat(66),
2191
+ };
2192
+
2193
+ const ecdsaSigner: Signer = {
2194
+ ...mockSigner,
2195
+ scheme: 'ecdsa',
2196
+ publicKey: '0x' + '2'.repeat(66),
2197
+ };
2198
+
2199
+ const multisig = new Multisig(mockAccount, config, guardian, ecdsaSigner, mockWebClient);
2200
+ const proposalId = '0x' + 'c'.repeat(64);
2201
+ const cosignerPubkey = '0x' + '3'.repeat(66);
2202
+ const ackPubkey = '0x' + '4'.repeat(66);
2203
+ const cosignerSignature = '0x' + '5'.repeat(130);
2204
+ const ackSignature = '0x' + '6'.repeat(130);
2205
+
2206
+ (multisig as any).proposals.set(proposalId, {
2207
+ id: proposalId,
2208
+ accountId: multisig.accountId,
2209
+ nonce: 1,
2210
+ status: 'ready',
2211
+ txSummary: 'AQID',
2212
+ signatures: [
2213
+ {
2214
+ signerId: '0x' + 'a'.repeat(64),
2215
+ signature: {
2216
+ scheme: 'ecdsa',
2217
+ signature: cosignerSignature,
2218
+ publicKey: cosignerPubkey,
2219
+ },
2220
+ timestamp: '2024-01-01T00:00:00Z',
2221
+ },
2222
+ ],
2223
+ metadata: {
2224
+ proposalType: 'change_threshold',
2225
+ targetThreshold: 1,
2226
+ targetSignerCommitments: ['0x' + 'a'.repeat(64)],
2227
+ description: '',
2228
+ },
2229
+ });
2230
+
2231
+ mockFetch.mockResolvedValueOnce({
2232
+ ok: true,
2233
+ json: async () => ({
2234
+ account_id: multisig.accountId,
2235
+ nonce: 1,
2236
+ prev_commitment: '0x' + 'b'.repeat(64),
2237
+ delta_payload: {
2238
+ tx_summary: { data: 'AQID' },
2239
+ signatures: [],
2240
+ metadata: {
2241
+ proposal_type: 'change_threshold',
2242
+ target_threshold: 1,
2243
+ signer_commitments: ['0x' + 'a'.repeat(64)],
2244
+ },
2245
+ },
2246
+ status: {
2247
+ status: 'pending',
2248
+ timestamp: '2024-01-01T00:00:00Z',
2249
+ proposer_id: '0x' + 'a'.repeat(64),
2250
+ cosigner_sigs: [],
2251
+ },
2252
+ }),
2253
+ });
2254
+ mockFetch.mockResolvedValueOnce({
2255
+ ok: true,
2256
+ json: async () => ({
2257
+ account_id: multisig.accountId,
2258
+ nonce: 1,
2259
+ ack_sig: ackSignature,
2260
+ ack_pubkey: ackPubkey,
2261
+ ack_scheme: 'ecdsa',
2262
+ }),
2263
+ });
2264
+ mockWebClient.executeTransaction.mockResolvedValueOnce({});
2265
+ mockWebClient.proveTransaction.mockResolvedValueOnce({});
2266
+ mockWebClient.submitProvenTransaction.mockResolvedValueOnce(1n);
2267
+ mockWebClient.applyTransaction.mockResolvedValueOnce(undefined);
2268
+
2269
+ await expect(multisig.executeProposal(proposalId)).resolves.toBeUndefined();
2270
+
2271
+ expect(vi.mocked(signatureHexToBytes)).toHaveBeenNthCalledWith(
2272
+ 1,
2273
+ cosignerSignature,
2274
+ 'ecdsa',
2275
+ );
2276
+ expect(vi.mocked(signatureHexToBytes)).toHaveBeenNthCalledWith(
2277
+ 2,
2278
+ ackSignature,
2279
+ 'ecdsa',
2280
+ );
2281
+ expect(vi.mocked(buildSignatureAdviceEntry)).toHaveBeenNthCalledWith(
2282
+ 1,
2283
+ expect.anything(),
2284
+ expect.anything(),
2285
+ expect.anything(),
2286
+ cosignerPubkey,
2287
+ cosignerSignature,
2288
+ );
2289
+ expect(vi.mocked(buildSignatureAdviceEntry)).toHaveBeenNthCalledWith(
2290
+ 2,
2291
+ expect.anything(),
2292
+ expect.anything(),
2293
+ expect.anything(),
2294
+ ackPubkey,
2295
+ ackSignature,
2296
+ );
2297
+ });
2298
+
2299
+ it('should execute imported ECDSA proposals with scheme-aware advice', async () => {
2300
+ const { buildSignatureAdviceEntry, signatureHexToBytes } = await import('./utils/signature.js');
2301
+ vi.mocked(signatureHexToBytes).mockClear();
2302
+ vi.mocked(buildSignatureAdviceEntry).mockClear();
2303
+
2304
+ const config = {
2305
+ threshold: 1,
2306
+ signerCommitments: ['0x' + 'a'.repeat(64)],
2307
+ guardianCommitment: '0x' + 'c'.repeat(64),
2308
+ guardianPublicKey: '0x' + '1'.repeat(66),
2309
+ };
2310
+
2311
+ const ecdsaSigner: Signer = {
2312
+ ...mockSigner,
2313
+ scheme: 'ecdsa',
2314
+ publicKey: '0x' + '2'.repeat(66),
2315
+ };
2316
+
2317
+ const multisig = new Multisig(mockAccount, config, guardian, ecdsaSigner, mockWebClient);
2318
+ const proposalId = '0x' + 'c'.repeat(64);
2319
+ const cosignerPubkey = '0x' + '3'.repeat(66);
2320
+ const ackPubkey = '0x' + '4'.repeat(66);
2321
+ const cosignerSignature = '0x' + '5'.repeat(130);
2322
+ const ackSignature = '0x' + '6'.repeat(130);
2323
+
2324
+ await multisig.importProposal(
2325
+ JSON.stringify({
2326
+ accountId: multisig.accountId,
2327
+ nonce: 1,
2328
+ commitment: proposalId,
2329
+ txSummaryBase64: 'AQID',
2330
+ signatures: [
2331
+ {
2332
+ commitment: '0x' + 'a'.repeat(64),
2333
+ signatureHex: cosignerSignature,
2334
+ scheme: 'ecdsa',
2335
+ publicKey: cosignerPubkey,
2336
+ timestamp: '2024-01-01T00:00:00Z',
2337
+ },
2338
+ ],
2339
+ metadata: {
2340
+ proposalType: 'change_threshold',
2341
+ targetThreshold: 1,
2342
+ targetSignerCommitments: ['0x' + 'a'.repeat(64)],
2343
+ description: '',
2344
+ },
2345
+ })
2346
+ );
2347
+
2348
+ mockFetch.mockResolvedValueOnce({
2349
+ ok: true,
2350
+ json: async () => ({
2351
+ account_id: multisig.accountId,
2352
+ nonce: 1,
2353
+ prev_commitment: '0x' + 'b'.repeat(64),
2354
+ delta_payload: {
2355
+ tx_summary: { data: 'AQID' },
2356
+ signatures: [],
2357
+ metadata: {
2358
+ proposal_type: 'change_threshold',
2359
+ target_threshold: 1,
2360
+ signer_commitments: ['0x' + 'a'.repeat(64)],
2361
+ },
2362
+ },
2363
+ status: {
2364
+ status: 'pending',
2365
+ timestamp: '2024-01-01T00:00:00Z',
2366
+ proposer_id: '0x' + 'a'.repeat(64),
2367
+ cosigner_sigs: [],
2368
+ },
2369
+ }),
2370
+ });
2371
+ mockFetch.mockResolvedValueOnce({
2372
+ ok: true,
2373
+ json: async () => ({
2374
+ account_id: multisig.accountId,
2375
+ nonce: 1,
2376
+ ack_sig: ackSignature,
2377
+ ack_pubkey: ackPubkey,
2378
+ ack_scheme: 'ecdsa',
2379
+ }),
2380
+ });
2381
+ mockWebClient.executeTransaction.mockResolvedValueOnce({});
2382
+ mockWebClient.proveTransaction.mockResolvedValueOnce({});
2383
+ mockWebClient.submitProvenTransaction.mockResolvedValueOnce(1n);
2384
+ mockWebClient.applyTransaction.mockResolvedValueOnce(undefined);
2385
+
2386
+ await expect(multisig.executeProposal(proposalId)).resolves.toBeUndefined();
2387
+
2388
+ expect(vi.mocked(signatureHexToBytes)).toHaveBeenNthCalledWith(
2389
+ 1,
2390
+ cosignerSignature,
2391
+ 'ecdsa',
2392
+ );
2393
+ expect(vi.mocked(signatureHexToBytes)).toHaveBeenNthCalledWith(
2394
+ 2,
2395
+ ackSignature,
2396
+ 'ecdsa',
2397
+ );
2398
+ expect(vi.mocked(buildSignatureAdviceEntry)).toHaveBeenNthCalledWith(
2399
+ 1,
2400
+ expect.anything(),
2401
+ expect.anything(),
2402
+ expect.anything(),
2403
+ cosignerPubkey,
2404
+ cosignerSignature,
2405
+ );
2406
+ expect(vi.mocked(buildSignatureAdviceEntry)).toHaveBeenNthCalledWith(
2407
+ 2,
2408
+ expect.anything(),
2409
+ expect.anything(),
2410
+ expect.anything(),
2411
+ ackPubkey,
2412
+ ackSignature,
2413
+ );
2414
+ });
2415
+
2416
+ it('should verify switch_guardian endpoint commitment before execution', async () => {
2417
+ const config = {
2418
+ threshold: 1,
2419
+ signerCommitments: ['0x' + 'a'.repeat(64)],
2420
+ guardianCommitment: '0x' + 'c'.repeat(64),
2421
+ };
2422
+
2423
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
2424
+ const proposalId = '0x' + 'c'.repeat(64);
2425
+ const newGuardianPubkey = '0x' + '1'.repeat(64);
2426
+
2427
+ (multisig as any).proposals.set(proposalId, {
2428
+ id: proposalId,
2429
+ accountId: multisig.accountId,
2430
+ nonce: 1,
2431
+ status: 'ready',
2432
+ txSummary: 'AQID',
2433
+ signatures: [
2434
+ {
2435
+ signerId: '0x' + 'a'.repeat(64),
2436
+ signature: { scheme: 'falcon', signature: '0x' + 'b'.repeat(128) },
2437
+ timestamp: '2024-01-01T00:00:00Z',
2438
+ },
2439
+ ],
2440
+ metadata: {
2441
+ proposalType: 'switch_guardian',
2442
+ newGuardianPubkey,
2443
+ newGuardianEndpoint: 'http://new-guardian.com',
2444
+ description: '',
2445
+ },
2446
+ });
2447
+
2448
+ mockFetch.mockResolvedValueOnce({
2449
+ ok: true,
2450
+ json: async () => ({ commitment: newGuardianPubkey }),
2451
+ });
2452
+ mockWebClient.getAccount.mockResolvedValueOnce({
2453
+ serialize: () => new Uint8Array([1, 2, 3]),
2454
+ });
2455
+ mockFetch.mockResolvedValueOnce({
2456
+ ok: true,
2457
+ json: async () => ({ success: true, message: 'ok', ack_pubkey: '0x' + 'f'.repeat(64) }),
2458
+ });
2459
+ mockWebClient.executeTransaction.mockResolvedValueOnce({});
2460
+ mockWebClient.proveTransaction.mockResolvedValueOnce({});
2461
+ mockWebClient.submitProvenTransaction.mockResolvedValueOnce(1n);
2462
+ mockWebClient.applyTransaction.mockResolvedValueOnce(undefined);
2463
+
2464
+ await expect(multisig.executeProposal(proposalId)).resolves.toBeUndefined();
2465
+ expect(mockWebClient.executeTransaction).toHaveBeenCalledTimes(1);
2466
+ });
2467
+
2468
+ it('should reject switch_guardian execution when endpoint commitment mismatches', async () => {
2469
+ const config = {
2470
+ threshold: 1,
2471
+ signerCommitments: ['0x' + 'a'.repeat(64)],
2472
+ guardianCommitment: '0x' + 'c'.repeat(64),
2473
+ };
2474
+
2475
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
2476
+ const proposalId = '0x' + 'c'.repeat(64);
2477
+
2478
+ (multisig as any).proposals.set(proposalId, {
2479
+ id: proposalId,
2480
+ accountId: multisig.accountId,
2481
+ nonce: 1,
2482
+ status: 'ready',
2483
+ txSummary: 'AQID',
2484
+ signatures: [
2485
+ {
2486
+ signerId: '0x' + 'a'.repeat(64),
2487
+ signature: { scheme: 'falcon', signature: '0x' + 'b'.repeat(128) },
2488
+ timestamp: '2024-01-01T00:00:00Z',
2489
+ },
2490
+ ],
2491
+ metadata: {
2492
+ proposalType: 'switch_guardian',
2493
+ newGuardianPubkey: '0x' + '1'.repeat(64),
2494
+ newGuardianEndpoint: 'http://new-guardian.com',
2495
+ description: '',
2496
+ },
2497
+ });
2498
+
2499
+ mockFetch.mockResolvedValueOnce({
2500
+ ok: true,
2501
+ json: async () => ({ commitment: '0x' + '2'.repeat(64) }),
2502
+ });
2503
+
2504
+ await expect(multisig.executeProposal(proposalId)).rejects.toThrow(
2505
+ 'Refusing to use GUARDIAN endpoint'
2506
+ );
2507
+ expect(mockWebClient.executeTransaction).not.toHaveBeenCalled();
2508
+ });
2509
+
2510
+ it('should reject duplicate normalized signer IDs during execution', async () => {
2511
+ const config = {
2512
+ threshold: 2,
2513
+ signerCommitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)],
2514
+ guardianCommitment: '0x' + 'c'.repeat(64),
2515
+ };
2516
+
2517
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
2518
+ const proposalId = '0x' + 'c'.repeat(64);
2519
+
2520
+ (multisig as any).proposals.set(proposalId, {
2521
+ id: proposalId,
2522
+ accountId: multisig.accountId,
2523
+ nonce: 1,
2524
+ status: 'ready',
2525
+ txSummary: 'AQID',
2526
+ signatures: [
2527
+ {
2528
+ signerId: '0x' + 'a'.repeat(64),
2529
+ signature: { scheme: 'falcon', signature: '0x' + 'b'.repeat(128) },
2530
+ timestamp: '2024-01-01T00:00:00Z',
2531
+ },
2532
+ {
2533
+ signerId: '0x' + 'A'.repeat(64),
2534
+ signature: { scheme: 'falcon', signature: '0x' + 'c'.repeat(128) },
2535
+ timestamp: '2024-01-01T00:00:01Z',
2536
+ },
2537
+ ],
2538
+ metadata: {
2539
+ proposalType: 'switch_guardian',
2540
+ newGuardianPubkey: '0x' + '1'.repeat(64),
2541
+ newGuardianEndpoint: 'http://new-guardian.com',
2542
+ description: '',
2543
+ },
2544
+ });
2545
+
2546
+ await expect(multisig.executeProposal(proposalId)).rejects.toThrow(
2547
+ 'duplicate signatures for signer',
2548
+ );
2549
+ });
2550
+
2551
+ it('should reject advice-map key collisions during execution', async () => {
2552
+ const { buildSignatureAdviceEntry } = await import('./utils/signature.js');
2553
+ vi.mocked(buildSignatureAdviceEntry)
2554
+ .mockImplementationOnce(() => ({
2555
+ key: { toHex: () => '0x' + 'f'.repeat(64) },
2556
+ values: [1, 2, 3],
2557
+ }) as any)
2558
+ .mockImplementationOnce(() => ({
2559
+ key: { toHex: () => '0x' + 'f'.repeat(64) },
2560
+ values: [1, 2, 3],
2561
+ }) as any);
2562
+
2563
+ const config = {
2564
+ threshold: 2,
2565
+ signerCommitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)],
2566
+ guardianCommitment: '0x' + 'c'.repeat(64),
2567
+ };
2568
+
2569
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
2570
+ const proposalId = '0x' + 'c'.repeat(64);
2571
+
2572
+ (multisig as any).proposals.set(proposalId, {
2573
+ id: proposalId,
2574
+ accountId: multisig.accountId,
2575
+ nonce: 1,
2576
+ status: 'ready',
2577
+ txSummary: 'AQID',
2578
+ signatures: [
2579
+ {
2580
+ signerId: '0x' + 'a'.repeat(64),
2581
+ signature: { scheme: 'falcon', signature: '0x' + 'b'.repeat(128) },
2582
+ timestamp: '2024-01-01T00:00:00Z',
2583
+ },
2584
+ {
2585
+ signerId: '0x' + 'b'.repeat(64),
2586
+ signature: { scheme: 'falcon', signature: '0x' + 'c'.repeat(128) },
2587
+ timestamp: '2024-01-01T00:00:01Z',
2588
+ },
2589
+ ],
2590
+ metadata: {
2591
+ proposalType: 'switch_guardian',
2592
+ newGuardianPubkey: '0x' + '1'.repeat(64),
2593
+ newGuardianEndpoint: 'http://new-guardian.com',
2594
+ description: '',
2595
+ },
2596
+ });
2597
+
2598
+ await expect(multisig.executeProposal(proposalId)).rejects.toThrow(
2599
+ 'Duplicate advice-map key detected',
755
2600
  );
756
2601
  });
757
2602
  });
@@ -761,10 +2606,10 @@ describe('Multisig', () => {
761
2606
  const config = {
762
2607
  threshold: 1,
763
2608
  signerCommitments: ['0x' + 'a'.repeat(64)],
764
- psmCommitment: '0x' + 'c'.repeat(64),
2609
+ guardianCommitment: '0x' + 'c'.repeat(64),
765
2610
  };
766
2611
 
767
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
2612
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
768
2613
 
769
2614
  // Create a proposal with metadata
770
2615
  const mockDelta = {
@@ -774,7 +2619,11 @@ describe('Multisig', () => {
774
2619
  delta_payload: {
775
2620
  tx_summary: { data: 'AQID' },
776
2621
  signatures: [],
777
- metadata: { proposal_type: 'add_signer' },
2622
+ metadata: {
2623
+ proposal_type: 'add_signer',
2624
+ target_threshold: 2,
2625
+ signer_commitments: ['0x1', '0x2'],
2626
+ },
778
2627
  },
779
2628
  status: {
780
2629
  status: 'pending',
@@ -788,7 +2637,7 @@ describe('Multisig', () => {
788
2637
  ok: true,
789
2638
  json: async () => ({
790
2639
  delta: mockDelta,
791
- commitment: '0x' + 'd'.repeat(64),
2640
+ commitment: '0x' + 'c'.repeat(64),
792
2641
  }),
793
2642
  });
794
2643
 
@@ -809,20 +2658,20 @@ describe('Multisig', () => {
809
2658
  }),
810
2659
  });
811
2660
 
812
- const syncedProposals = await multisig.syncTransactionProposals();
2661
+ const syncedProposals = await multisig.syncProposals();
813
2662
  const syncedProposal = syncedProposals.find(p => p.nonce === 1);
814
2663
 
815
2664
  expect(syncedProposal?.metadata?.proposalType).toBe('add_signer');
816
2665
  });
817
2666
 
818
- it('should use PSM metadata for new proposals from other signers', async () => {
2667
+ it('should use GUARDIAN metadata for new proposals from other signers', async () => {
819
2668
  const config = {
820
2669
  threshold: 2,
821
2670
  signerCommitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)],
822
- psmCommitment: '0x' + 'c'.repeat(64),
2671
+ guardianCommitment: '0x' + 'c'.repeat(64),
823
2672
  };
824
2673
 
825
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
2674
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
826
2675
 
827
2676
  // Sync proposals - no local proposals exist
828
2677
  const mockProposals = [
@@ -854,7 +2703,7 @@ describe('Multisig', () => {
854
2703
  json: async () => ({ proposals: mockProposals }),
855
2704
  });
856
2705
 
857
- const proposals = await multisig.syncTransactionProposals();
2706
+ const proposals = await multisig.syncProposals();
858
2707
 
859
2708
  expect(proposals.length).toBe(1);
860
2709
  expect(proposals[0].metadata?.proposalType).toBe('p2id');
@@ -866,10 +2715,10 @@ describe('Multisig', () => {
866
2715
  const config = {
867
2716
  threshold: 1,
868
2717
  signerCommitments: ['0x' + 'a'.repeat(64)],
869
- psmCommitment: '0x' + 'c'.repeat(64),
2718
+ guardianCommitment: '0x' + 'c'.repeat(64),
870
2719
  };
871
2720
 
872
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
2721
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
873
2722
 
874
2723
  const mockDelta = {
875
2724
  account_id: '0x' + 'a'.repeat(30),
@@ -897,7 +2746,7 @@ describe('Multisig', () => {
897
2746
  ok: true,
898
2747
  json: async () => ({
899
2748
  delta: mockDelta,
900
- commitment: '0x' + 'd'.repeat(64),
2749
+ commitment: '0x' + 'c'.repeat(64),
901
2750
  }),
902
2751
  });
903
2752
 
@@ -914,10 +2763,10 @@ describe('Multisig', () => {
914
2763
  const config = {
915
2764
  threshold: 1,
916
2765
  signerCommitments: ['0x' + 'a'.repeat(64)],
917
- psmCommitment: '0x' + 'c'.repeat(64),
2766
+ guardianCommitment: '0x' + 'c'.repeat(64),
918
2767
  };
919
2768
 
920
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
2769
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
921
2770
 
922
2771
  const mockDelta = {
923
2772
  account_id: '0x' + 'a'.repeat(30),
@@ -945,7 +2794,7 @@ describe('Multisig', () => {
945
2794
  ok: true,
946
2795
  json: async () => ({
947
2796
  delta: mockDelta,
948
- commitment: '0x' + 'd'.repeat(64),
2797
+ commitment: '0x' + 'c'.repeat(64),
949
2798
  }),
950
2799
  });
951
2800
 
@@ -960,14 +2809,14 @@ describe('Multisig', () => {
960
2809
  expect(proposal.metadata?.proposalType).toBe('p2id');
961
2810
  });
962
2811
 
963
- it('should create switch_psm proposal', async () => {
2812
+ it('should create switch_guardian proposal', async () => {
964
2813
  const config = {
965
2814
  threshold: 1,
966
2815
  signerCommitments: ['0x' + 'a'.repeat(64)],
967
- psmCommitment: '0x' + 'c'.repeat(64),
2816
+ guardianCommitment: '0x' + 'c'.repeat(64),
968
2817
  };
969
2818
 
970
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
2819
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
971
2820
 
972
2821
  const mockDelta = {
973
2822
  account_id: '0x' + 'a'.repeat(30),
@@ -995,18 +2844,18 @@ describe('Multisig', () => {
995
2844
  ok: true,
996
2845
  json: async () => ({
997
2846
  delta: mockDelta,
998
- commitment: '0x' + 'd'.repeat(64),
2847
+ commitment: '0x' + 'c'.repeat(64),
999
2848
  }),
1000
2849
  });
1001
2850
 
1002
2851
  const proposal = await multisig.createProposal(1, 'AQID', {
1003
- proposalType: 'switch_psm',
1004
- newPsmPubkey: '0xnewpubkey',
1005
- newPsmEndpoint: 'http://new-psm.com',
2852
+ proposalType: 'switch_guardian',
2853
+ newGuardianPubkey: '0xnewpubkey',
2854
+ newGuardianEndpoint: 'http://new-guardian.com',
1006
2855
  description: '',
1007
2856
  });
1008
2857
 
1009
- expect(proposal.metadata?.proposalType).toBe('switch_psm');
2858
+ expect(proposal.metadata?.proposalType).toBe('switch_guardian');
1010
2859
  });
1011
2860
  });
1012
2861
 
@@ -1015,10 +2864,10 @@ describe('Multisig', () => {
1015
2864
  const config = {
1016
2865
  threshold: 2,
1017
2866
  signerCommitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)],
1018
- psmCommitment: '0x' + 'c'.repeat(64),
2867
+ guardianCommitment: '0x' + 'c'.repeat(64),
1019
2868
  };
1020
2869
 
1021
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
2870
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
1022
2871
 
1023
2872
  // First sync with 1 signature (pending)
1024
2873
  const mockProposalsPending = [
@@ -1056,8 +2905,8 @@ describe('Multisig', () => {
1056
2905
  json: async () => ({ proposals: mockProposalsPending }),
1057
2906
  });
1058
2907
 
1059
- let proposals = await multisig.syncTransactionProposals();
1060
- expect(proposals[0].status.type).toBe('pending');
2908
+ let proposals = await multisig.syncProposals();
2909
+ expect(proposals[0].status).toBe('pending');
1061
2910
 
1062
2911
  // Second sync with 2 signatures (ready)
1063
2912
  const mockProposalsReady = [
@@ -1097,8 +2946,8 @@ describe('Multisig', () => {
1097
2946
  json: async () => ({ proposals: mockProposalsReady }),
1098
2947
  });
1099
2948
 
1100
- proposals = await multisig.syncTransactionProposals();
1101
- expect(proposals[0].status.type).toBe('ready');
2949
+ proposals = await multisig.syncProposals();
2950
+ expect(proposals[0].status).toBe('ready');
1102
2951
  });
1103
2952
  });
1104
2953
 
@@ -1107,10 +2956,10 @@ describe('Multisig', () => {
1107
2956
  const config = {
1108
2957
  threshold: 3,
1109
2958
  signerCommitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64), '0x' + 'c'.repeat(64)],
1110
- psmCommitment: '0x' + 'd'.repeat(64),
2959
+ guardianCommitment: '0x' + 'd'.repeat(64),
1111
2960
  };
1112
2961
 
1113
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
2962
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
1114
2963
  expect(multisig.threshold).toBe(3);
1115
2964
  });
1116
2965
 
@@ -1119,33 +2968,33 @@ describe('Multisig', () => {
1119
2968
  const config = {
1120
2969
  threshold: 2,
1121
2970
  signerCommitments,
1122
- psmCommitment: '0x' + 'd'.repeat(64),
2971
+ guardianCommitment: '0x' + 'd'.repeat(64),
1123
2972
  };
1124
2973
 
1125
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
2974
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
1126
2975
  expect(multisig.signerCommitments).toEqual(signerCommitments);
1127
2976
  });
1128
2977
 
1129
- it('should expose psmCommitment', () => {
1130
- const psmCommitment = '0x' + 'psm'.repeat(20);
2978
+ it('should expose guardianCommitment', () => {
2979
+ const guardianCommitment = '0x' + 'guardian'.repeat(20);
1131
2980
  const config = {
1132
2981
  threshold: 1,
1133
2982
  signerCommitments: ['0x' + 'a'.repeat(64)],
1134
- psmCommitment,
2983
+ guardianCommitment,
1135
2984
  };
1136
2985
 
1137
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
1138
- expect(multisig.psmCommitment).toBe(psmCommitment);
2986
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
2987
+ expect(multisig.guardianCommitment).toBe(guardianCommitment);
1139
2988
  });
1140
2989
 
1141
2990
  it('should expose account when provided', () => {
1142
2991
  const config = {
1143
2992
  threshold: 1,
1144
2993
  signerCommitments: ['0x' + 'a'.repeat(64)],
1145
- psmCommitment: '0x' + 'd'.repeat(64),
2994
+ guardianCommitment: '0x' + 'd'.repeat(64),
1146
2995
  };
1147
2996
 
1148
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
2997
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
1149
2998
  expect(multisig.account).toBe(mockAccount);
1150
2999
  });
1151
3000
  });
@@ -1155,12 +3004,12 @@ describe('Multisig', () => {
1155
3004
  const config = {
1156
3005
  threshold: 2,
1157
3006
  signerCommitments: ['0x' + 'a'.repeat(64), '0x' + 'b'.repeat(64)],
1158
- psmCommitment: '0x' + 'c'.repeat(64),
3007
+ guardianCommitment: '0x' + 'c'.repeat(64),
1159
3008
  };
1160
3009
 
1161
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
3010
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
1162
3011
 
1163
- // Simulates a PSM response with canonical snake_case metadata
3012
+ // Simulates a GUARDIAN response with canonical snake_case metadata
1164
3013
  const rustProposals = [
1165
3014
  {
1166
3015
  account_id: '0x' + 'a'.repeat(30),
@@ -1190,7 +3039,7 @@ describe('Multisig', () => {
1190
3039
  json: async () => ({ proposals: rustProposals }),
1191
3040
  });
1192
3041
 
1193
- const proposals = await multisig.syncTransactionProposals();
3042
+ const proposals = await multisig.syncProposals();
1194
3043
 
1195
3044
  expect(proposals.length).toBe(1);
1196
3045
  // The TS client should normalize snake_case to camelCase
@@ -1205,10 +3054,10 @@ describe('Multisig', () => {
1205
3054
  const config = {
1206
3055
  threshold: 1,
1207
3056
  signerCommitments: ['0x' + 'a'.repeat(64)],
1208
- psmCommitment: '0x' + 'c'.repeat(64),
3057
+ guardianCommitment: '0x' + 'c'.repeat(64),
1209
3058
  };
1210
3059
 
1211
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
3060
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
1212
3061
 
1213
3062
  // P2ID proposal with canonical snake_case fields
1214
3063
  const p2idProposals = [
@@ -1247,7 +3096,7 @@ describe('Multisig', () => {
1247
3096
  json: async () => ({ proposals: p2idProposals }),
1248
3097
  });
1249
3098
 
1250
- const proposals = await multisig.syncTransactionProposals();
3099
+ const proposals = await multisig.syncProposals();
1251
3100
 
1252
3101
  expect(proposals.length).toBe(1);
1253
3102
  expect(proposals[0].metadata?.proposalType).toBe('p2id');
@@ -1258,16 +3107,16 @@ describe('Multisig', () => {
1258
3107
  }
1259
3108
  });
1260
3109
 
1261
- it('should parse switch_psm proposal with snake_case fields', async () => {
3110
+ it('should parse switch_guardian proposal with snake_case fields', async () => {
1262
3111
  const config = {
1263
3112
  threshold: 1,
1264
3113
  signerCommitments: ['0x' + 'a'.repeat(64)],
1265
- psmCommitment: '0x' + 'c'.repeat(64),
3114
+ guardianCommitment: '0x' + 'c'.repeat(64),
1266
3115
  };
1267
3116
 
1268
- const multisig = new Multisig(mockAccount, config, psm, mockSigner, mockWebClient);
3117
+ const multisig = new Multisig(mockAccount, config, guardian, mockSigner, mockWebClient);
1269
3118
 
1270
- const switchPsmProposals = [
3119
+ const switchGuardianProposals = [
1271
3120
  {
1272
3121
  account_id: '0x' + 'a'.repeat(30),
1273
3122
  nonce: 1,
@@ -1276,9 +3125,9 @@ describe('Multisig', () => {
1276
3125
  tx_summary: { data: 'AQID' },
1277
3126
  signatures: [],
1278
3127
  metadata: {
1279
- proposal_type: 'switch_psm',
1280
- new_psm_pubkey: '0xnewpubkey',
1281
- new_psm_endpoint: 'http://new-psm.com',
3128
+ proposal_type: 'switch_guardian',
3129
+ new_guardian_pubkey: '0xnewpubkey',
3130
+ new_guardian_endpoint: 'http://new-guardian.com',
1282
3131
  salt: '0xsalt',
1283
3132
  },
1284
3133
  },
@@ -1293,16 +3142,16 @@ describe('Multisig', () => {
1293
3142
 
1294
3143
  mockFetch.mockResolvedValueOnce({
1295
3144
  ok: true,
1296
- json: async () => ({ proposals: switchPsmProposals }),
3145
+ json: async () => ({ proposals: switchGuardianProposals }),
1297
3146
  });
1298
3147
 
1299
- const proposals = await multisig.syncTransactionProposals();
3148
+ const proposals = await multisig.syncProposals();
1300
3149
 
1301
3150
  expect(proposals.length).toBe(1);
1302
- expect(proposals[0].metadata?.proposalType).toBe('switch_psm');
1303
- if (proposals[0].metadata?.proposalType === 'switch_psm') {
1304
- expect(proposals[0].metadata.newPsmPubkey).toBe('0xnewpubkey');
1305
- expect(proposals[0].metadata.newPsmEndpoint).toBe('http://new-psm.com');
3151
+ expect(proposals[0].metadata?.proposalType).toBe('switch_guardian');
3152
+ if (proposals[0].metadata?.proposalType === 'switch_guardian') {
3153
+ expect(proposals[0].metadata.newGuardianPubkey).toBe('0xnewpubkey');
3154
+ expect(proposals[0].metadata.newGuardianEndpoint).toBe('http://new-guardian.com');
1306
3155
  }
1307
3156
  });
1308
3157
  });