@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.
- package/README.md +40 -0
- package/assembly/__tests__/basic-tests.spec.ts +91 -132
- package/assembly/__tests__/coverage-tests.spec.ts +457 -0
- package/assembly/__tests__/execution-delay-tests.spec.ts +177 -0
- package/assembly/__tests__/member-management-tests.spec.ts +210 -0
- package/assembly/__tests__/single-owner-tests.spec.ts +155 -0
- package/assembly/__tests__/upgradeable-tests.spec.ts +124 -0
- package/assembly/contracts/Multisig.ts +3 -3
- package/assembly/libraries/Upgradeable.ts +6 -2
- package/package.json +6 -2
|
@@ -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
|
|
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 >
|
|
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 <
|
|
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
|
|
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.
|
|
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",
|