@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,210 @@
1
+ import {
2
+ constructor,
3
+ addOwner,
4
+ removeOwner,
5
+ replaceOwner,
6
+ } from '../contracts/Multisig';
7
+ import { mockAdminContext } from '@massalabs/massa-as-sdk';
8
+ import { Args } from '@massalabs/as-types';
9
+ import {
10
+ changeCallStack,
11
+ resetStorage,
12
+ } from '@massalabs/massa-as-sdk/assembly/vm-mock/storage';
13
+ import { owners, required } from '../contracts/multisig-internals';
14
+
15
+ // address of the contract set in vm-mock. must match with contractAddr of @massalabs/massa-as-sdk/vm-mock/vm.js
16
+ const contractAddr = 'AS12BqZEQ6sByhRLyEuf0YbQmcF2PsDdkNNG1akBJu9XcjZA1eT';
17
+
18
+ // multisig owners
19
+ const ownerA = 'AU1qDAxGJ387ETi9JRQzZWSPKYq4YPXrFvdiE4VoXUaiAt38JFEC';
20
+ const ownerB = 'AU125TiSrnD2YatYfEyRAWnBdD7TEuVbvGFkFgDuaYc2bdKyqKtb';
21
+ const ownerC = 'A12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKq';
22
+
23
+ // additional / replacement addresses
24
+ const newOwner = 'AU1NewOwnerAddressForTestingPurposesOnlyXXXXXXXXXXX';
25
+ const otherOwner = 'AU1OtherNewOwnerForTestingPurposesOnlyXXXXXXXXXXXXX';
26
+
27
+ // a plain user that is not an owner and not the contract itself
28
+ const randomCaller = 'AU1RandomUserNotAnOwnerXXXXXXXXXXXXXXXXXXXXXXXXXXX';
29
+
30
+ // Default deployer address used by the vm-mock (caller != callee so
31
+ // Context.isDeployingContract() is true). Must match the mock vm default caller.
32
+ const deployerAddress = 'AU12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKq';
33
+
34
+ // Simulate the multisig calling itself (required by _isMultisig):
35
+ // Context.caller() == Context.callee() == contractAddr.
36
+ function switchToMultisig(): void {
37
+ changeCallStack(contractAddr + ' , ' + contractAddr);
38
+ }
39
+
40
+ // Simulate an external caller: Context.caller() == user, Context.callee() == contractAddr
41
+ function switchUser(user: string): void {
42
+ changeCallStack(user + ' , ' + contractAddr);
43
+ }
44
+
45
+ // Helper that bootstraps a 2-of-3 multisig with ownerA/B/C.
46
+ function setupMultisig(required_: i32 = i32(2)): void {
47
+ resetStorage();
48
+ // Context.isDeployingContract() requires caller != callee, so make sure
49
+ // the call stack is set to a deployer context before calling constructor.
50
+ changeCallStack(deployerAddress + ' , ' + contractAddr);
51
+ constructor(
52
+ new Args()
53
+ .add<Array<string>>([ownerA, ownerB, ownerC])
54
+ .add(required_)
55
+ .add(u64(0))
56
+ .add(u64(0))
57
+ .serialize(),
58
+ );
59
+ }
60
+
61
+ beforeAll(() => {
62
+ resetStorage();
63
+ mockAdminContext(true);
64
+ });
65
+
66
+ describe('addOwner', () => {
67
+ test('reverts when called by a non-multisig caller', () => {
68
+ setupMultisig();
69
+ switchUser(ownerA);
70
+ expect(() => {
71
+ addOwner(new Args().add(newOwner).serialize());
72
+ }).toThrow();
73
+ });
74
+
75
+ test('reverts when the address is already an owner', () => {
76
+ setupMultisig();
77
+ switchToMultisig();
78
+ expect(() => {
79
+ addOwner(new Args().add(ownerA).serialize());
80
+ }).toThrow();
81
+ });
82
+
83
+ test('adds a new owner when called by the multisig itself', () => {
84
+ setupMultisig();
85
+ switchToMultisig();
86
+
87
+ expect(() => {
88
+ addOwner(new Args().add(newOwner).serialize());
89
+ }).not.toThrow();
90
+
91
+ const storedOwners = owners();
92
+ expect(storedOwners.length).toBe(4);
93
+ expect(storedOwners.includes(newOwner)).toBe(true);
94
+ // Adding an owner must not change the required threshold
95
+ expect(required()).toBe(2);
96
+ });
97
+ });
98
+
99
+ describe('removeOwner', () => {
100
+ test('reverts when called by a non-multisig caller', () => {
101
+ setupMultisig();
102
+ switchUser(ownerA);
103
+ expect(() => {
104
+ removeOwner(new Args().add(ownerB).serialize());
105
+ }).toThrow();
106
+ });
107
+
108
+ test('removes an existing owner when called by the multisig itself', () => {
109
+ // 2-of-3 → drop one, leaving 2-of-2
110
+ setupMultisig();
111
+ switchToMultisig();
112
+
113
+ expect(() => {
114
+ removeOwner(new Args().add(ownerC).serialize());
115
+ }).not.toThrow();
116
+
117
+ const storedOwners = owners();
118
+ expect(storedOwners.length).toBe(2);
119
+ expect(storedOwners.includes(ownerC)).toBe(false);
120
+ expect(storedOwners.includes(ownerA)).toBe(true);
121
+ expect(storedOwners.includes(ownerB)).toBe(true);
122
+ expect(required()).toBe(2);
123
+ });
124
+
125
+ test('reverts when removing would drop owners below the required threshold', () => {
126
+ // 3-of-3 → cannot remove any owner without breaking the threshold
127
+ setupMultisig(i32(3));
128
+ switchToMultisig();
129
+
130
+ expect(() => {
131
+ removeOwner(new Args().add(ownerC).serialize());
132
+ }).toThrow();
133
+
134
+ // state unchanged
135
+ expect(owners().length).toBe(3);
136
+ });
137
+
138
+ test('reverts when trying to remove an address that is not an owner', () => {
139
+ setupMultisig();
140
+ switchToMultisig();
141
+ expect(() => {
142
+ removeOwner(new Args().add(randomCaller).serialize());
143
+ }).toThrow();
144
+ });
145
+
146
+ test('reverts when removing would leave the multisig with zero owners', () => {
147
+ // 1-of-1 → cannot drop the last owner
148
+ resetStorage();
149
+ changeCallStack(deployerAddress + ' , ' + contractAddr);
150
+ constructor(
151
+ new Args()
152
+ .add<Array<string>>([ownerA])
153
+ .add(i32(1))
154
+ .add(u64(0))
155
+ .add(u64(0))
156
+ .serialize(),
157
+ );
158
+ switchToMultisig();
159
+
160
+ expect(() => {
161
+ removeOwner(new Args().add(ownerA).serialize());
162
+ }).toThrow();
163
+
164
+ expect(owners().length).toBe(1);
165
+ });
166
+ });
167
+
168
+ describe('replaceOwner', () => {
169
+ test('reverts when called by a non-multisig caller', () => {
170
+ setupMultisig();
171
+ switchUser(ownerA);
172
+ expect(() => {
173
+ replaceOwner(new Args().add(ownerC).add(newOwner).serialize());
174
+ }).toThrow();
175
+ });
176
+
177
+ test('reverts when the old owner does not exist', () => {
178
+ setupMultisig();
179
+ switchToMultisig();
180
+ expect(() => {
181
+ replaceOwner(new Args().add(randomCaller).add(newOwner).serialize());
182
+ }).toThrow();
183
+ });
184
+
185
+ test('reverts when the new owner is already an owner', () => {
186
+ setupMultisig();
187
+ switchToMultisig();
188
+ expect(() => {
189
+ // try to replace ownerA with ownerB (already present)
190
+ replaceOwner(new Args().add(ownerA).add(ownerB).serialize());
191
+ }).toThrow();
192
+ });
193
+
194
+ test('replaces an owner without changing the owner count or threshold', () => {
195
+ setupMultisig();
196
+ switchToMultisig();
197
+
198
+ expect(() => {
199
+ replaceOwner(new Args().add(ownerC).add(otherOwner).serialize());
200
+ }).not.toThrow();
201
+
202
+ const storedOwners = owners();
203
+ expect(storedOwners.length).toBe(3);
204
+ expect(storedOwners.includes(ownerC)).toBe(false);
205
+ expect(storedOwners.includes(otherOwner)).toBe(true);
206
+ expect(storedOwners.includes(ownerA)).toBe(true);
207
+ expect(storedOwners.includes(ownerB)).toBe(true);
208
+ expect(required()).toBe(2);
209
+ });
210
+ });
@@ -0,0 +1,155 @@
1
+ import {
2
+ submit,
3
+ approve,
4
+ constructor,
5
+ getTransactions,
6
+ } from '../contracts/Multisig';
7
+ import { mockAdminContext, Address } from '@massalabs/massa-as-sdk';
8
+ import {
9
+ Args,
10
+ u64ToBytes,
11
+ bytesToSerializableObjectArray,
12
+ } from '@massalabs/as-types';
13
+ import {
14
+ changeCallStack,
15
+ resetStorage,
16
+ } from '@massalabs/massa-as-sdk/assembly/vm-mock/storage';
17
+ import { Transaction } from '../structs/Transaction';
18
+ import {
19
+ getApprovalCount,
20
+ hasApproved,
21
+ required,
22
+ owners,
23
+ } from '../contracts/multisig-internals';
24
+
25
+ // address of the contract set in vm-mock. must match with contractAddr of @massalabs/massa-as-sdk/vm-mock/vm.js
26
+ const contractAddr = 'AS12BqZEQ6sByhRLyEuf0YbQmcF2PsDdkNNG1akBJu9XcjZA1eT';
27
+
28
+ // default deployer address (caller != callee so Context.isDeployingContract() is true)
29
+ const deployerAddress = 'AU12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKq';
30
+
31
+ // the single owner of the multisig
32
+ const soloOwner = 'AU1qDAxGJ387ETi9JRQzZWSPKYq4YPXrFvdiE4VoXUaiAt38JFEC';
33
+
34
+ // a non-owner for negative testing
35
+ const nonOwner = 'AU125TiSrnD2YatYfEyRAWnBdD7TEuVbvGFkFgDuaYc2bdKyqKtb';
36
+
37
+ // destination for the dummy transaction
38
+ const destination = 'AU155TiSrnD2YatYfEyRAWnBdD7TEuVbvGFkFgDuaYc2bdKyqKtb';
39
+
40
+ function switchUser(user: string): void {
41
+ changeCallStack(user + ' , ' + contractAddr);
42
+ }
43
+
44
+ function retrieveOperation(opIndex: i32): Transaction {
45
+ let operationList = bytesToSerializableObjectArray<Transaction>(
46
+ getTransactions([]),
47
+ ).unwrap();
48
+ return operationList[opIndex];
49
+ }
50
+
51
+ beforeAll(() => {
52
+ resetStorage();
53
+ mockAdminContext(true);
54
+ });
55
+
56
+ describe('Single-owner multisig tests', () => {
57
+ test('constructor rejects 0 owners', () => {
58
+ expect(() => {
59
+ const serializedArgs = new Args()
60
+ .add<Array<string>>([])
61
+ .add(i32(1))
62
+ .add(u64(0))
63
+ .add(u64(0))
64
+ .serialize();
65
+ constructor(serializedArgs);
66
+ }).toThrow();
67
+ });
68
+
69
+ test('constructor accepts a single owner with required=1', () => {
70
+ resetStorage();
71
+ // Context.isDeployingContract() requires caller != callee.
72
+ changeCallStack(deployerAddress + ' , ' + contractAddr);
73
+
74
+ expect(() => {
75
+ constructor(
76
+ new Args()
77
+ .add<Array<string>>([soloOwner])
78
+ .add(i32(1))
79
+ .add(u64(0))
80
+ .add(u64(0))
81
+ .serialize(),
82
+ );
83
+ }).not.toThrow();
84
+
85
+ // check the owner list has exactly one entry matching soloOwner
86
+ const storedOwners = owners();
87
+ expect(storedOwners.length).toBe(1);
88
+ expect(storedOwners[0]).toBe(soloOwner);
89
+
90
+ // required number of confirmations is 1
91
+ expect(required()).toBe(1);
92
+ });
93
+
94
+ test('non-owner cannot submit a transaction', () => {
95
+ switchUser(nonOwner);
96
+ expect(() => {
97
+ submit(
98
+ new Args()
99
+ .add(
100
+ new Transaction(
101
+ new Address(destination),
102
+ '',
103
+ u64(15000),
104
+ [],
105
+ 0,
106
+ false,
107
+ ),
108
+ )
109
+ .serialize(),
110
+ );
111
+ }).toThrow();
112
+ });
113
+
114
+ test('solo owner can submit and approve (single approval validates)', () => {
115
+ switchUser(soloOwner);
116
+
117
+ const tx = new Transaction(
118
+ new Address(destination),
119
+ '',
120
+ u64(15000),
121
+ [],
122
+ 0,
123
+ false,
124
+ );
125
+
126
+ // submit returns txId 0 (first tx)
127
+ const opId: u64 = 0;
128
+ expect(submit(new Args().add(tx).serialize())).toStrictEqual(
129
+ u64ToBytes(opId),
130
+ );
131
+
132
+ // before approval, timestamp is 0 and approval count is 0
133
+ expect(retrieveOperation(i32(opId)).timestamp).toBe(0);
134
+ expect(getApprovalCount(opId)).toBe(0);
135
+
136
+ // solo owner approves
137
+ approve(new Args().add(opId).serialize());
138
+
139
+ // the owner's approval is recorded
140
+ expect(hasApproved(opId, new Address(soloOwner))).toBe(true);
141
+
142
+ // approval count equals the required threshold (1) so the tx is validated:
143
+ // a non-zero timestamp must have been set for it.
144
+ expect(getApprovalCount(opId)).toBe(required());
145
+ expect(retrieveOperation(i32(opId)).timestamp).toBeGreaterThan(0);
146
+ });
147
+
148
+ test('solo owner cannot approve the same tx twice', () => {
149
+ switchUser(soloOwner);
150
+ const opId: u64 = 0;
151
+ expect(() => {
152
+ approve(new Args().add(opId).serialize());
153
+ }).toThrow();
154
+ });
155
+ });
@@ -0,0 +1,124 @@
1
+ import { constructor, proposeUpgrade, upgrade } from '../contracts/Multisig';
2
+ import { mockAdminContext } from '@massalabs/massa-as-sdk';
3
+ import {
4
+ mockBalance,
5
+ mockTimestamp,
6
+ } from '@massalabs/massa-as-sdk/assembly/vm-mock';
7
+ import { Args } from '@massalabs/as-types';
8
+ import {
9
+ changeCallStack,
10
+ resetStorage,
11
+ } from '@massalabs/massa-as-sdk/assembly/vm-mock/storage';
12
+
13
+ // address of the contract set in vm-mock. must match contractAddr of vm.js
14
+ const contractAddr = 'AS12BqZEQ6sByhRLyEuf0YbQmcF2PsDdkNNG1akBJu9XcjZA1eT';
15
+
16
+ // default deployer used so Context.isDeployingContract() is true
17
+ const deployerAddress = 'AU12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKq';
18
+
19
+ // multisig owner
20
+ const ownerA = 'AU1qDAxGJ387ETi9JRQzZWSPKYq4YPXrFvdiE4VoXUaiAt38JFEC';
21
+
22
+ // An arbitrary non-empty payload used as "new bytecode" for proposeUpgrade.
23
+ const newBytecode: StaticArray<u8> = [1, 2, 3, 4];
24
+
25
+ const UPGRADE_DELAY: u64 = 1_000; // 1 second, in ms
26
+
27
+ function switchUser(user: string): void {
28
+ changeCallStack(user + ' , ' + contractAddr);
29
+ }
30
+
31
+ function switchToMultisig(): void {
32
+ changeCallStack(contractAddr + ' , ' + contractAddr);
33
+ }
34
+
35
+ function setupMultisig(upgDelay: u64 = UPGRADE_DELAY): void {
36
+ resetStorage();
37
+ mockTimestamp(u64(1_000_000)); // deterministic starting time
38
+ // Seed the contract address in the ledger so setBytecode can write to it.
39
+ mockBalance(contractAddr, u64(0));
40
+ changeCallStack(deployerAddress + ' , ' + contractAddr);
41
+ constructor(
42
+ new Args()
43
+ .add<Array<string>>([ownerA])
44
+ .add(i32(1))
45
+ .add(upgDelay)
46
+ .add(u64(0))
47
+ .serialize(),
48
+ );
49
+ }
50
+
51
+ beforeAll(() => {
52
+ mockAdminContext(true);
53
+ });
54
+
55
+ describe('Upgradeable: access control', () => {
56
+ test('proposeUpgrade reverts when not called by the multisig itself', () => {
57
+ setupMultisig();
58
+ switchUser(ownerA);
59
+ expect(() => {
60
+ proposeUpgrade(newBytecode);
61
+ }).toThrow();
62
+ });
63
+
64
+ test('upgrade reverts when not called by the multisig itself', () => {
65
+ setupMultisig();
66
+ switchUser(ownerA);
67
+ expect(() => {
68
+ upgrade([]);
69
+ }).toThrow();
70
+ });
71
+ });
72
+
73
+ // ==========================================================================
74
+ // islocked() / upgrade() - timelock semantics
75
+ //
76
+ // A timelock must *block* upgrades until the configured delay has elapsed
77
+ // after a proposal, and then *allow* them. The current implementation of
78
+ // `Upgradeable.islocked()` (see assembly/libraries/Upgradeable.ts) has its
79
+ // comparison inverted, so `upgrade()` is allowed while the delay has not
80
+ // yet passed and blocked after. These tests document the correct behaviour
81
+ // and therefore fail against the buggy implementation.
82
+ // ==========================================================================
83
+
84
+ describe('Upgradeable: timelock semantics', () => {
85
+ test('upgrade reverts when called before the upgrade delay has elapsed', () => {
86
+ setupMultisig(UPGRADE_DELAY);
87
+
88
+ switchToMultisig();
89
+ proposeUpgrade(newBytecode);
90
+
91
+ // Only part of the delay has passed - still locked.
92
+ mockTimestamp(u64(1_000_000) + UPGRADE_DELAY / 2);
93
+
94
+ expect(() => {
95
+ upgrade([]);
96
+ }).toThrow('upgrade should be blocked while the timelock is active');
97
+ });
98
+
99
+ test('upgrade succeeds once the upgrade delay has elapsed', () => {
100
+ setupMultisig(UPGRADE_DELAY);
101
+
102
+ switchToMultisig();
103
+ proposeUpgrade(newBytecode);
104
+
105
+ // Advance time strictly past the delay.
106
+ mockTimestamp(u64(1_000_000) + UPGRADE_DELAY + 1);
107
+
108
+ // Should not throw.
109
+ upgrade([]);
110
+ });
111
+
112
+ test('upgrade reverts if no upgrade has been proposed (after delay)', () => {
113
+ setupMultisig(UPGRADE_DELAY);
114
+
115
+ // No proposeUpgrade() call. Still advance time so that the timelock
116
+ // check cannot mask the "no proposal" error.
117
+ mockTimestamp(u64(1_000_000) + UPGRADE_DELAY + 1);
118
+
119
+ switchToMultisig();
120
+ expect(() => {
121
+ upgrade([]);
122
+ }).toThrow('upgrade should revert when no proposal exists');
123
+ });
124
+ });
@@ -54,7 +54,7 @@ export function constructor(bs: StaticArray<u8>): void {
54
54
  const executionDelay = args.nextU64().expect('executionDelay not found');
55
55
 
56
56
  Upgradeable.__Upgradeable_init(upgradeDelay);
57
- assert(owners.length > 1, 'At least 2 owners required');
57
+ assert(owners.length >= 1, 'At least 1 owner required');
58
58
  assert(required > 0 && required <= owners.length, 'invalid required');
59
59
 
60
60
  for (let i = 0; i < owners.length; i++) {
@@ -236,7 +236,7 @@ export function removeOwner(bs: StaticArray<u8>): void {
236
236
 
237
237
  _isMultisig();
238
238
  assert(
239
- owners().length - 1 >= required() && owners().length > 2,
239
+ owners().length - 1 >= required() && owners().length > 1,
240
240
  'cannot remove owner',
241
241
  );
242
242
 
@@ -362,7 +362,7 @@ export function getApprovals(bs: StaticArray<u8>): StaticArray<u8> {
362
362
  const approvals: string[] = [];
363
363
  const _owners = owners();
364
364
 
365
- for (let i = 0; i < owners.length; i++) {
365
+ for (let i = 0; i < _owners.length; i++) {
366
366
  const owner = _owners[i];
367
367
  if (hasApproved(txId, new Address(owner))) {
368
368
  approvals.push(owner);
@@ -39,11 +39,15 @@ export class Upgradeable {
39
39
  }
40
40
 
41
41
  /**
42
- * @dev Returns true if the contract is locked
42
+ * @dev Returns true if the contract is still locked, i.e. the upgrade
43
+ * delay has not yet elapsed since the last proposal.
44
+ *
45
+ * The contract becomes unlocked (ready to upgrade) once
46
+ * `Context.timestamp() >= timelock + PERIOD`.
43
47
  */
44
48
  static islocked(): bool {
45
49
  return (
46
- SafeMath.add(this.timelock(), bytesToU64(Storage.get(PERIOD))) <
50
+ SafeMath.add(this.timelock(), bytesToU64(Storage.get(PERIOD))) >
47
51
  Context.timestamp()
48
52
  );
49
53
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massalabs/multisig-contract",
3
- "version": "0.0.2-dev.20260414091108",
3
+ "version": "0.1.0",
4
4
  "description": "",
5
5
  "scripts": {
6
6
  "test": "asp --summary",
@@ -11,6 +11,10 @@
11
11
  "get:multisig-parameters": "tsx src/get-multisig-parameters.ts",
12
12
  "list:proposals": "tsx src/list-proposals.ts",
13
13
  "propose:add-member": "tsx src/propose-add-member.ts",
14
+ "propose:replace-member": "tsx src/propose-replace-member.ts",
15
+ "propose:revoke-member": "tsx src/propose-revoke-member.ts",
16
+ "propose:threshold": "tsx src/propose-threshold.ts",
17
+ "propose:execution-delay": "tsx src/propose-execution-delay.ts",
14
18
  "prettier": "prettier assembly//**/*.ts --check",
15
19
  "prettier:fix": "prettier assembly//**/*.ts --write",
16
20
  "lint": "eslint .",
@@ -30,7 +34,7 @@
30
34
  "@massalabs/as-transformer": "^0.3.2",
31
35
  "@massalabs/as-types": "^2.1.0",
32
36
  "@massalabs/eslint-config": "^0.0.10",
33
- "@massalabs/massa-as-sdk": "^3.0.2",
37
+ "@massalabs/massa-as-sdk": "^3.0.3-dev",
34
38
  "@massalabs/massa-sc-compiler": "^0.1.0",
35
39
  "@massalabs/massa-web3": "^5.3.0",
36
40
  "@massalabs/prettier-config-as": "^0.0.2",