@massalabs/multisig-contract 0.0.2-dev.20260414091108 → 0.1.0

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.
@@ -0,0 +1,457 @@
1
+ import {
2
+ constructor,
3
+ submit,
4
+ approve,
5
+ execute,
6
+ revoke,
7
+ setTimestamp,
8
+ receiveCoins,
9
+ changeRequirement,
10
+ changeExecutionDelay,
11
+ changeUpgradeDelay,
12
+ getApprovals,
13
+ } from '../contracts/Multisig';
14
+ import {
15
+ Storage,
16
+ mockAdminContext,
17
+ Address,
18
+ generateEvent,
19
+ } from '@massalabs/massa-as-sdk';
20
+ import {
21
+ mockBalance,
22
+ mockScCall,
23
+ } from '@massalabs/massa-as-sdk/assembly/vm-mock';
24
+ import {
25
+ Args,
26
+ bytesToU64,
27
+ bytesToI32,
28
+ bytesToNativeTypeArray,
29
+ bytesToSerializableObjectArray,
30
+ } from '@massalabs/as-types';
31
+ import {
32
+ changeCallStack,
33
+ resetStorage,
34
+ } from '@massalabs/massa-as-sdk/assembly/vm-mock/storage';
35
+ import { Transaction } from '../structs/Transaction';
36
+ import { getApprovalCount, hasApproved } from '../contracts/multisig-internals';
37
+ import { getTransactions } from '../contracts/Multisig';
38
+ import { DELAY, REQUIRED } from '../storage/Multisig';
39
+
40
+ // address of the contract set in vm-mock. must match contractAddr of vm.js
41
+ const contractAddr = 'AS12BqZEQ6sByhRLyEuf0YbQmcF2PsDdkNNG1akBJu9XcjZA1eT';
42
+
43
+ // default deployer used by the vm-mock (caller != callee so isDeployingContract is true)
44
+ const deployerAddress = 'AU12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKq';
45
+
46
+ // multisig owners
47
+ const ownerA = 'AU1qDAxGJ387ETi9JRQzZWSPKYq4YPXrFvdiE4VoXUaiAt38JFEC';
48
+ const ownerB = 'AU125TiSrnD2YatYfEyRAWnBdD7TEuVbvGFkFgDuaYc2bdKyqKtb';
49
+ const ownerC = 'AU12LmTm4zRYkUQZusw7eevvV5ySzSwndJpENQ7EZHcmDbWafx96T';
50
+
51
+ // a well-formed user address that is not an owner
52
+ const nonOwner = 'AU1aMywGBgBywiL6WcbKR4ugxoBtdP9P3waBVi5e713uvj7F1DJL';
53
+
54
+ // EOA destination
55
+ const destination = 'AU155TiSrnD2YatYfEyRAWnBdD7TEuVbvGFkFgDuaYc2bdKyqKtb';
56
+
57
+ // smart-contract destination used to exercise execute()'s `call` branch
58
+ const scDestination = 'AS1uku77MYEHy3i12WeERtD2JQzeKePz5zmJjWCq6A8wWyZakccQ';
59
+
60
+ function switchUser(user: string): void {
61
+ changeCallStack(user + ' , ' + contractAddr);
62
+ }
63
+
64
+ function switchToMultisig(): void {
65
+ changeCallStack(contractAddr + ' , ' + contractAddr);
66
+ }
67
+
68
+ // Bootstraps a fresh 2-of-3 multisig with executionDelay = 0 and upgradeDelay = 0.
69
+ function setupMultisig(
70
+ execDelay: u64 = u64(0),
71
+ upgDelay: u64 = u64(0),
72
+ reqd: i32 = i32(2),
73
+ ): void {
74
+ resetStorage();
75
+ changeCallStack(deployerAddress + ' , ' + contractAddr);
76
+ constructor(
77
+ new Args()
78
+ .add<Array<string>>([ownerA, ownerB, ownerC])
79
+ .add(reqd)
80
+ .add(upgDelay)
81
+ .add(execDelay)
82
+ .serialize(),
83
+ );
84
+ }
85
+
86
+ // Submits + approves a transaction by `execDelay` owners so the validation
87
+ // threshold is reached. Returns the txId.
88
+ function submitAndValidate(tx: Transaction): u64 {
89
+ switchUser(ownerA);
90
+ const idBytes = submit(new Args().add(tx).serialize());
91
+ const id = bytesToU64(idBytes);
92
+ approve(new Args().add(id).serialize());
93
+ switchUser(ownerB);
94
+ approve(new Args().add(id).serialize());
95
+ return id;
96
+ }
97
+
98
+ function newTransfer(to: string, value: u64): Transaction {
99
+ return new Transaction(new Address(to), '', value, [], 0, false);
100
+ }
101
+
102
+ beforeAll(() => {
103
+ resetStorage();
104
+ mockAdminContext(true);
105
+ });
106
+
107
+ // ==========================================================================
108
+ // approve() — error paths
109
+ // ==========================================================================
110
+
111
+ describe('approve: error paths', () => {
112
+ test('reverts on a non-existent transaction id', () => {
113
+ setupMultisig();
114
+ switchUser(ownerA);
115
+ expect(() => {
116
+ approve(new Args().add(u64(42)).serialize());
117
+ }).toThrow();
118
+ });
119
+
120
+ test('reverts when called by a non-owner', () => {
121
+ setupMultisig();
122
+ switchUser(ownerA);
123
+ submit(new Args().add(newTransfer(destination, u64(1000))).serialize());
124
+
125
+ switchUser(nonOwner);
126
+ expect(() => {
127
+ approve(new Args().add(u64(0)).serialize());
128
+ }).toThrow();
129
+ });
130
+
131
+ test('reverts when the transaction has already been executed', () => {
132
+ setupMultisig();
133
+ mockBalance(contractAddr, u64(1000));
134
+ const id = submitAndValidate(newTransfer(destination, u64(1000)));
135
+
136
+ // execute so the tx becomes `executed = true`
137
+ switchUser(ownerA);
138
+ execute(new Args().add(id).serialize());
139
+
140
+ // ownerC now tries to approve - should revert on `_notExecuted`
141
+ switchUser(ownerC);
142
+ expect(() => {
143
+ approve(new Args().add(u64(0)).serialize());
144
+ }).toThrow();
145
+ });
146
+ });
147
+
148
+ // ==========================================================================
149
+ // execute() — error paths and branches
150
+ // ==========================================================================
151
+
152
+ describe('execute: error paths and branches', () => {
153
+ test('reverts on a non-existent transaction id', () => {
154
+ setupMultisig();
155
+ switchUser(ownerA);
156
+ expect(() => {
157
+ execute(new Args().add(u64(99)).serialize());
158
+ }).toThrow();
159
+ });
160
+
161
+ test('reverts when the transaction has already been executed', () => {
162
+ setupMultisig();
163
+ mockBalance(contractAddr, u64(1000));
164
+ const id = submitAndValidate(newTransfer(destination, u64(1000)));
165
+
166
+ switchUser(ownerA);
167
+ execute(new Args().add(id).serialize());
168
+
169
+ // second execute call must fail (already executed)
170
+ expect(() => {
171
+ execute(new Args().add(u64(0)).serialize());
172
+ }).toThrow();
173
+ });
174
+
175
+ test("reverts with 'delay not passed' when the execution delay hasn't elapsed", () => {
176
+ // use a huge execution delay so now() + huge delay > Context.timestamp()
177
+ setupMultisig(u64(1_000_000_000_000));
178
+ mockBalance(contractAddr, u64(1000));
179
+ submitAndValidate(newTransfer(destination, u64(1000)));
180
+
181
+ switchUser(ownerA);
182
+ expect(() => {
183
+ execute(new Args().add(u64(0)).serialize());
184
+ }).toThrow();
185
+ });
186
+
187
+ test('executes the call-branch when the destination is a smart contract', () => {
188
+ setupMultisig();
189
+ mockBalance(contractAddr, u64(500));
190
+
191
+ // Call a method on a smart-contract destination with some payload
192
+ const tx = new Transaction(
193
+ new Address(scDestination),
194
+ 'doSomething',
195
+ u64(500),
196
+ new Args().add(u64(123)).serialize(),
197
+ 0,
198
+ false,
199
+ );
200
+
201
+ // execute() reaches the `!isAddressEoa(...) -> call(...)` branch for SC
202
+ // destinations; the vm-mock needs a mocked return value for that call.
203
+ // If the transferCoins branch were hit by mistake, vm-mock would throw
204
+ // because `scDestination` has no bytecode registered.
205
+ mockScCall([]);
206
+ const id = submitAndValidate(tx);
207
+
208
+ switchUser(ownerA);
209
+ execute(new Args().add(id).serialize());
210
+
211
+ // the transaction is marked executed — the call branch didn't revert
212
+ const operationList = bytesToSerializableObjectArray<Transaction>(
213
+ getTransactions([]),
214
+ ).unwrap();
215
+ expect(operationList[i32(id)].executed).toBe(true);
216
+ });
217
+ });
218
+
219
+ // ==========================================================================
220
+ // revoke() — error paths and behavior
221
+ // ==========================================================================
222
+
223
+ describe('revoke: error paths and behavior', () => {
224
+ test('reverts on a non-existent transaction id', () => {
225
+ setupMultisig();
226
+ switchUser(ownerA);
227
+ expect(() => {
228
+ revoke(new Args().add(u64(99)).serialize());
229
+ }).toThrow();
230
+ });
231
+
232
+ test('reverts when the transaction has already been executed', () => {
233
+ setupMultisig();
234
+ mockBalance(contractAddr, u64(1000));
235
+ const id = submitAndValidate(newTransfer(destination, u64(1000)));
236
+
237
+ switchUser(ownerA);
238
+ execute(new Args().add(id).serialize());
239
+
240
+ expect(() => {
241
+ revoke(new Args().add(u64(0)).serialize());
242
+ }).toThrow();
243
+ });
244
+
245
+ test('reverts when the caller has not approved the tx', () => {
246
+ setupMultisig();
247
+ switchUser(ownerA);
248
+ submit(new Args().add(newTransfer(destination, u64(1000))).serialize());
249
+ // ownerA never approved — revoking must fail
250
+ expect(() => {
251
+ revoke(new Args().add(u64(0)).serialize());
252
+ }).toThrow();
253
+ });
254
+
255
+ test('resets the approval count after a revoke below the threshold', () => {
256
+ setupMultisig();
257
+ switchUser(ownerA);
258
+ submit(new Args().add(newTransfer(destination, u64(1000))).serialize());
259
+ approve(new Args().add(u64(0)).serialize());
260
+
261
+ expect(getApprovalCount(u64(0))).toBe(1);
262
+ expect(hasApproved(u64(0), new Address(ownerA))).toBe(true);
263
+
264
+ revoke(new Args().add(u64(0)).serialize());
265
+
266
+ expect(getApprovalCount(u64(0))).toBe(0);
267
+ expect(hasApproved(u64(0), new Address(ownerA))).toBe(false);
268
+ });
269
+ });
270
+
271
+ // ==========================================================================
272
+ // changeRequirement()
273
+ // ==========================================================================
274
+
275
+ describe('changeRequirement', () => {
276
+ test('reverts when called by a non-multisig caller', () => {
277
+ setupMultisig();
278
+ switchUser(ownerA);
279
+ expect(() => {
280
+ changeRequirement(new Args().add(i32(1)).serialize());
281
+ }).toThrow();
282
+ });
283
+
284
+ test('reverts when the new value is 0', () => {
285
+ setupMultisig();
286
+ switchToMultisig();
287
+ expect(() => {
288
+ changeRequirement(new Args().add(i32(0)).serialize());
289
+ }).toThrow();
290
+ });
291
+
292
+ test('reverts when the new value exceeds the owner count', () => {
293
+ setupMultisig();
294
+ switchToMultisig();
295
+ expect(() => {
296
+ // 3 owners → required = 4 is invalid
297
+ changeRequirement(new Args().add(i32(4)).serialize());
298
+ }).toThrow();
299
+ });
300
+
301
+ test('updates REQUIRED storage on a valid call', () => {
302
+ setupMultisig();
303
+ switchToMultisig();
304
+ changeRequirement(new Args().add(i32(3)).serialize());
305
+ expect(bytesToI32(Storage.get(REQUIRED))).toBe(3);
306
+ });
307
+ });
308
+
309
+ // ==========================================================================
310
+ // changeExecutionDelay()
311
+ // ==========================================================================
312
+
313
+ describe('changeExecutionDelay', () => {
314
+ test('reverts when called by a non-multisig caller', () => {
315
+ setupMultisig();
316
+ switchUser(ownerA);
317
+ expect(() => {
318
+ changeExecutionDelay(new Args().add(u64(5000)).serialize());
319
+ }).toThrow();
320
+ });
321
+
322
+ test('updates DELAY storage on a valid call', () => {
323
+ setupMultisig();
324
+ switchToMultisig();
325
+ changeExecutionDelay(new Args().add(u64(123456)).serialize());
326
+ expect(bytesToU64(Storage.get(DELAY))).toBe(u64(123456));
327
+ });
328
+
329
+ test('after raising the delay, execute() becomes subject to the delay gate', () => {
330
+ setupMultisig();
331
+ mockBalance(contractAddr, u64(1000));
332
+ const id = submitAndValidate(newTransfer(destination, u64(1000)));
333
+
334
+ // raise the execution delay after the tx has already been validated
335
+ switchToMultisig();
336
+ changeExecutionDelay(new Args().add(u64(1_000_000_000_000)).serialize());
337
+
338
+ // now the delay gate should trip
339
+ switchUser(ownerA);
340
+ expect(() => {
341
+ execute(new Args().add(u64(0)).serialize());
342
+ }).toThrow();
343
+ // silence "unused id" warning
344
+ generateEvent(id.toString());
345
+ });
346
+ });
347
+
348
+ // ==========================================================================
349
+ // changeUpgradeDelay()
350
+ // ==========================================================================
351
+
352
+ describe('changeUpgradeDelay', () => {
353
+ test('reverts when called by a non-multisig caller', () => {
354
+ setupMultisig();
355
+ switchUser(ownerA);
356
+ expect(() => {
357
+ changeUpgradeDelay(new Args().add(u64(10000)).serialize());
358
+ }).toThrow();
359
+ });
360
+
361
+ test('does not throw when called by the multisig itself', () => {
362
+ setupMultisig();
363
+ switchToMultisig();
364
+ changeUpgradeDelay(new Args().add(u64(10000)).serialize());
365
+ });
366
+ });
367
+
368
+ // ==========================================================================
369
+ // setTimestamp()
370
+ // ==========================================================================
371
+
372
+ describe('setTimestamp', () => {
373
+ test('reverts when called by a non-owner', () => {
374
+ setupMultisig();
375
+ switchUser(ownerA);
376
+ submit(new Args().add(newTransfer(destination, u64(1000))).serialize());
377
+
378
+ switchUser(nonOwner);
379
+ expect(() => {
380
+ setTimestamp(new Args().add(u64(0)).serialize());
381
+ }).toThrow();
382
+ });
383
+
384
+ test('reverts on a non-existent transaction id', () => {
385
+ setupMultisig();
386
+ switchUser(ownerA);
387
+ expect(() => {
388
+ setTimestamp(new Args().add(u64(99)).serialize());
389
+ }).toThrow();
390
+ });
391
+
392
+ test('is a no-op when the approval threshold has not been reached', () => {
393
+ setupMultisig();
394
+ switchUser(ownerA);
395
+ submit(new Args().add(newTransfer(destination, u64(1000))).serialize());
396
+ approve(new Args().add(u64(0)).serialize());
397
+ // only 1/2 approvals — setTimestamp must leave the timestamp untouched
398
+ setTimestamp(new Args().add(u64(0)).serialize());
399
+ });
400
+
401
+ test('reverts when the timestamp is already set (threshold reached via approve)', () => {
402
+ setupMultisig();
403
+ submitAndValidate(newTransfer(destination, u64(1000)));
404
+ // approve() already set tx.timestamp, so setTimestamp must revert
405
+ switchUser(ownerA);
406
+ expect(() => {
407
+ setTimestamp(new Args().add(u64(0)).serialize());
408
+ }).toThrow();
409
+ });
410
+ });
411
+
412
+ // ==========================================================================
413
+ // receiveCoins()
414
+ // ==========================================================================
415
+
416
+ describe('receiveCoins', () => {
417
+ test('does not throw', () => {
418
+ setupMultisig();
419
+ switchUser(ownerA);
420
+ receiveCoins([]);
421
+ });
422
+ });
423
+
424
+ // ==========================================================================
425
+ // getApprovals()
426
+ // ==========================================================================
427
+
428
+ describe('getApprovals', () => {
429
+ test('returns the list of owners that approved a transaction', () => {
430
+ setupMultisig();
431
+
432
+ // submit, then have ownerA and ownerC approve (skipping ownerB)
433
+ switchUser(ownerA);
434
+ submit(new Args().add(newTransfer(destination, u64(1000))).serialize());
435
+ approve(new Args().add(u64(0)).serialize());
436
+ switchUser(ownerC);
437
+ approve(new Args().add(u64(0)).serialize());
438
+
439
+ const raw = getApprovals(new Args().add(u64(0)).serialize());
440
+ const approvers = bytesToNativeTypeArray<string>(raw);
441
+
442
+ expect(approvers.length).toBe(2);
443
+ expect(approvers.includes(ownerA)).toBe(true);
444
+ expect(approvers.includes(ownerC)).toBe(true);
445
+ expect(approvers.includes(ownerB)).toBe(false);
446
+ });
447
+
448
+ test('returns an empty list for a transaction with no approvals', () => {
449
+ setupMultisig();
450
+ switchUser(ownerA);
451
+ submit(new Args().add(newTransfer(destination, u64(1000))).serialize());
452
+
453
+ const raw = getApprovals(new Args().add(u64(0)).serialize());
454
+ const approvers = bytesToNativeTypeArray<string>(raw);
455
+ expect(approvers.length).toBe(0);
456
+ });
457
+ });
@@ -0,0 +1,177 @@
1
+ import {
2
+ constructor,
3
+ submit,
4
+ approve,
5
+ execute,
6
+ getTransactions,
7
+ } from '../contracts/Multisig';
8
+ import {
9
+ Storage,
10
+ mockAdminContext,
11
+ Address,
12
+ Context,
13
+ balanceOf,
14
+ } from '@massalabs/massa-as-sdk';
15
+ import { mockBalance } from '@massalabs/massa-as-sdk/assembly/vm-mock';
16
+ import {
17
+ Args,
18
+ bytesToU64,
19
+ bytesToSerializableObjectArray,
20
+ } from '@massalabs/as-types';
21
+ import {
22
+ changeCallStack,
23
+ resetStorage,
24
+ } from '@massalabs/massa-as-sdk/assembly/vm-mock/storage';
25
+ import { Transaction } from '../structs/Transaction';
26
+ import { SafeMath } from '../libraries/SafeMath';
27
+ import { DELAY } from '../storage/Multisig';
28
+
29
+ // address of the contract set in vm-mock. must match with contractAddr of @massalabs/massa-as-sdk/vm-mock/vm.js
30
+ const contractAddr = 'AS12BqZEQ6sByhRLyEuf0YbQmcF2PsDdkNNG1akBJu9XcjZA1eT';
31
+
32
+ // default deployer (caller != callee so Context.isDeployingContract() is true)
33
+ const deployerAddress = 'AU12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKq';
34
+
35
+ // multisig owners
36
+ const ownerA = 'AU1qDAxGJ387ETi9JRQzZWSPKYq4YPXrFvdiE4VoXUaiAt38JFEC';
37
+ const ownerB = 'AU125TiSrnD2YatYfEyRAWnBdD7TEuVbvGFkFgDuaYc2bdKyqKtb';
38
+
39
+ // destination for the dummy transaction
40
+ const destination = 'AU155TiSrnD2YatYfEyRAWnBdD7TEuVbvGFkFgDuaYc2bdKyqKtb';
41
+
42
+ function switchUser(user: string): void {
43
+ changeCallStack(user + ' , ' + contractAddr);
44
+ }
45
+
46
+ function retrieveOperation(opIndex: i32): Transaction {
47
+ const operationList = bytesToSerializableObjectArray<Transaction>(
48
+ getTransactions([]),
49
+ ).unwrap();
50
+ return operationList[opIndex];
51
+ }
52
+
53
+ beforeAll(() => {
54
+ resetStorage();
55
+ mockAdminContext(true);
56
+ });
57
+
58
+ describe('Zero-ms execution delay', () => {
59
+ test('constructor accepts executionDelay = 0 and stores it as zero', () => {
60
+ resetStorage();
61
+ changeCallStack(deployerAddress + ' , ' + contractAddr);
62
+
63
+ expect(() => {
64
+ constructor(
65
+ new Args()
66
+ .add<Array<string>>([ownerA, ownerB])
67
+ .add(i32(2))
68
+ // upgradeDelay
69
+ .add(u64(0))
70
+ // executionDelay = 0 ms (no wait between approval and execution)
71
+ .add(u64(0))
72
+ .serialize(),
73
+ );
74
+ }).not.toThrow();
75
+
76
+ // DELAY must be stored as u64(0)
77
+ expect(bytesToU64(Storage.get(DELAY))).toBe(u64(0));
78
+ });
79
+
80
+ test('once approved, a transaction is immediately eligible for execution (delay check passes)', () => {
81
+ resetStorage();
82
+ changeCallStack(deployerAddress + ' , ' + contractAddr);
83
+ constructor(
84
+ new Args()
85
+ .add<Array<string>>([ownerA, ownerB])
86
+ .add(i32(2))
87
+ .add(u64(0))
88
+ .add(u64(0))
89
+ .serialize(),
90
+ );
91
+
92
+ const tx = new Transaction(
93
+ new Address(destination),
94
+ '',
95
+ u64(0),
96
+ [],
97
+ 0,
98
+ false,
99
+ );
100
+
101
+ // submit + reach the required threshold (2 approvals)
102
+ switchUser(ownerA);
103
+ const opIdBytes = submit(new Args().add(tx).serialize());
104
+ const opId = bytesToU64(opIdBytes);
105
+ approve(new Args().add(opId).serialize());
106
+
107
+ switchUser(ownerB);
108
+ approve(new Args().add(opId).serialize());
109
+
110
+ // With a 0 ms execution delay, the gate enforced by execute()
111
+ // SafeMath.add(tx.timestamp, DELAY) <= Context.timestamp()
112
+ // must already be satisfied as soon as the threshold is reached.
113
+ const storedTx = retrieveOperation(i32(opId));
114
+ const delay = bytesToU64(Storage.get(DELAY));
115
+ expect(delay).toBe(u64(0));
116
+ expect(storedTx.timestamp).toBeGreaterThan(u64(0));
117
+ expect(SafeMath.add(storedTx.timestamp, delay)).toBeLessThanOrEqual(
118
+ Context.timestamp(),
119
+ );
120
+ });
121
+
122
+ test('execute() succeeds immediately after approval with executionDelay = 0', () => {
123
+ resetStorage();
124
+ changeCallStack(deployerAddress + ' , ' + contractAddr);
125
+ constructor(
126
+ new Args()
127
+ .add<Array<string>>([ownerA, ownerB])
128
+ .add(i32(2))
129
+ .add(u64(0))
130
+ .add(u64(0))
131
+ .serialize(),
132
+ );
133
+
134
+ // Fund the multisig so transferCoins in execute() has balance to move.
135
+ const transferAmount: u64 = 15000;
136
+ mockBalance(contractAddr, transferAmount);
137
+
138
+ const tx = new Transaction(
139
+ new Address(destination),
140
+ '',
141
+ transferAmount,
142
+ [],
143
+ 0,
144
+ false,
145
+ );
146
+
147
+ switchUser(ownerA);
148
+ const opIdBytes = submit(new Args().add(tx).serialize());
149
+ const opId = bytesToU64(opIdBytes);
150
+ approve(new Args().add(opId).serialize());
151
+
152
+ switchUser(ownerB);
153
+ approve(new Args().add(opId).serialize());
154
+
155
+ const destinationBalanceBefore = balanceOf(destination);
156
+ const contractBalanceBefore = balanceOf(contractAddr);
157
+ expect(contractBalanceBefore).toBe(transferAmount);
158
+
159
+ // Immediately execute - with executionDelay = 0 no waiting is required.
160
+ // Calling execute() directly (not via a closure) because AssemblyScript
161
+ // closures cannot capture local variables like opId.
162
+ switchUser(ownerA);
163
+ execute(new Args().add(opId).serialize());
164
+
165
+ // tx is flagged as executed
166
+ const storedTx = retrieveOperation(i32(opId));
167
+ expect(storedTx.executed).toBe(true);
168
+
169
+ // coins actually moved from the multisig to the destination
170
+ expect(balanceOf(destination)).toBe(
171
+ destinationBalanceBefore + transferAmount,
172
+ );
173
+ expect(balanceOf(contractAddr)).toBe(
174
+ contractBalanceBefore - transferAmount,
175
+ );
176
+ });
177
+ });