diff --git a/README.md b/README.md
index 7ca86a6..9ce2dc1 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,10 @@ The [Massa Domain Name Service standard](smart-contracts/assembly/contracts/dns/
This is MassaLabs implementation of [the ENS](https://docs.ens.domains/).
+## Multisig
+
+The [Multisig standard implementation](smart-contracts/assembly/contracts/multisig) defines a simple multisig contract able to store funds and release them in a weighted multisig schema (multiple declaration of an owner will increase its weight).
+
## Massa Units
The [Massa Units standard](units.md) defines a set of common units of measurement for use on the Massa blockchain.
diff --git a/smart-contracts/README.md b/smart-contracts/README.md
index 865a89c..68492ce 100644
--- a/smart-contracts/README.md
+++ b/smart-contracts/README.md
@@ -2,6 +2,7 @@
- [fungible token](assembly/contracts/FT): implementation of the ERC20 token.
- [non-fungible token](assembly/contracts/NFT)
+- [multisig](assembly/contract/multisig)
## Documentation
diff --git a/smart-contracts/assembly/contracts/NFT/NFT.ts b/smart-contracts/assembly/contracts/NFT/NFT.ts
index 444e723..5a42d4b 100644
--- a/smart-contracts/assembly/contracts/NFT/NFT.ts
+++ b/smart-contracts/assembly/contracts/NFT/NFT.ts
@@ -10,7 +10,6 @@ import {
stringToBytes,
bytesToU256,
u256ToBytes,
- boolToByte,
u32ToBytes,
} from '@massalabs/as-types';
import { u256 } from 'as-bignum/assembly';
diff --git a/smart-contracts/assembly/contracts/NFT/__tests__/NFT.spec.ts b/smart-contracts/assembly/contracts/NFT/__tests__/NFT.spec.ts
index 827869f..1891019 100644
--- a/smart-contracts/assembly/contracts/NFT/__tests__/NFT.spec.ts
+++ b/smart-contracts/assembly/contracts/NFT/__tests__/NFT.spec.ts
@@ -35,7 +35,8 @@ import { u256 } from 'as-bignum/assembly';
const callerAddress = 'A12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKq';
-const userAddress = 'A12BqZEQ6sByhRLyEuf0YbQmcF2PsDdkNNG1akBJu9XcjZA1e8';
+// never used
+// const userAddress = 'A12BqZEQ6sByhRLyEuf0YbQmcF2PsDdkNNG1akBJu9XcjZA1e8';
const NFTName = 'MASSA_NFT';
const NFTSymbol = 'NFT';
diff --git a/smart-contracts/assembly/contracts/index.ts b/smart-contracts/assembly/contracts/index.ts
index b41f39d..5e56918 100644
--- a/smart-contracts/assembly/contracts/index.ts
+++ b/smart-contracts/assembly/contracts/index.ts
@@ -1,7 +1,9 @@
import * as FT from './FT';
import * as NFT from './NFT';
+import * as multisig from './multisig';
import * as ownership from './utils/ownership';
export { FT };
export { NFT };
+export { multisig };
export { ownership };
diff --git a/smart-contracts/assembly/contracts/multisig/__tests__/as-pect.d.ts b/smart-contracts/assembly/contracts/multisig/__tests__/as-pect.d.ts
new file mode 100644
index 0000000..6101ea2
--- /dev/null
+++ b/smart-contracts/assembly/contracts/multisig/__tests__/as-pect.d.ts
@@ -0,0 +1 @@
+///
diff --git a/smart-contracts/assembly/contracts/multisig/__tests__/multisig.spec.ts b/smart-contracts/assembly/contracts/multisig/__tests__/multisig.spec.ts
new file mode 100644
index 0000000..30a1133
--- /dev/null
+++ b/smart-contracts/assembly/contracts/multisig/__tests__/multisig.spec.ts
@@ -0,0 +1,692 @@
+import {
+ ms1_submitTransaction,
+ ms1_submitCall,
+ ms1_confirmOperation,
+ ms1_executeOperation,
+ ms1_revokeConfirmation,
+ ms1_getOperation,
+ ms1_deleteOperation,
+ ms1_getOperationIndexList,
+ constructor,
+ Operation,
+} from '../multisig';
+
+import {
+ Storage,
+ mockAdminContext,
+ Address,
+ createEvent,
+ Coins,
+ generateEvent,
+} from '@massalabs/massa-as-sdk';
+
+import {
+ Args,
+ byteToU8,
+ bytesToU64,
+ u64ToBytes,
+ stringToBytes,
+ bytesToString,
+ serializableObjectsArrayToBytes,
+ bytesToSerializableObjectArray,
+ bytesToFixedSizeArray,
+ Serializable,
+ Result,
+} from '@massalabs/as-types';
+
+import {
+ changeCallStack,
+ resetStorage,
+} from '@massalabs/massa-as-sdk/assembly/vm-mock/storage';
+
+// address of admin caller set in vm-mock. must match with adminAddress of @massalabs/massa-as-sdk/vm-mock/vm.js
+const deployerAddress = 'AU12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKq';
+
+// address of the contract set in vm-mock. must match with contractAddr of @massalabs/massa-as-sdk/vm-mock/vm.js
+const contractAddr = 'AS12BqZEQ6sByhRLyEuf0YbQmcF2PsDdkNNG1akBJu9XcjZA1eT';
+
+// nb of confirmations required
+const nbConfirmations: u8 = 2;
+
+// the multisig owners
+const owners: Array = [
+ 'A12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKq',
+ 'AU1qDAxGJ387ETi9JRQzZWSPKYq4YPXrFvdiE4VoXUaiAt38JFEC',
+ 'AU125TiSrnD2YatYfEyRAWnBdD7TEuVbvGFkFgDuaYc2bdKyqKtb',
+];
+
+// where operation funds are sent when a transaction operation is executed
+const destination = 'AU155TiSrnD2YatYfEyRAWnBdD7TEuVbvGFkFgDuaYc2bdKyqKtb';
+
+// owners declared to the constructor for testing. Note that owners[0] has
+// a weight of 2 since it is mentionned twice in the list:
+const ownerList = [owners[0], owners[0], owners[1], owners[2]];
+const ownerWeight: Array = [2, 1, 1];
+
+export const NB_CONFIRMATIONS_REQUIRED_KEY = stringToBytes(
+ 'NB CONFIRMATIONS REQUIRED',
+);
+export const OWNERS_ADDRESSES_KEY = stringToBytes('OWNERS ADDRESSES');
+export const OPERATION_INDEX_KEY = stringToBytes('OPERATION INDEX');
+
+export const OPERATION_INDEX_PREFIX_KEY = '00';
+export const OWNER_PREFIX_KEY = '01';
+
+// ======================================================== //
+// ==== HELPER FUNCTIONS ==== //
+// ======================================================== //
+
+function makeOwnerKey(owner: string): StaticArray {
+ return stringToBytes(
+ OWNER_PREFIX_KEY + bytesToString(new Args().add(owner).serialize()),
+ );
+}
+
+function makeOperationKey(opIndex: u64): StaticArray {
+ return stringToBytes(
+ OPERATION_INDEX_PREFIX_KEY + bytesToString(u64ToBytes(opIndex)),
+ );
+}
+
+function retrieveOperation(opIndex: u64): Result {
+ const operationKey = makeOperationKey(opIndex);
+
+ if (Storage.has(operationKey)) {
+ let operation = new Operation();
+ operation.deserialize(Storage.get(operationKey));
+ return new Result(operation);
+ }
+
+ return new Result(
+ new Operation(),
+ 'unknown or already executed Operation index',
+ );
+}
+
+function hasOperation(opIndex: u64): bool {
+ return Storage.has(makeOperationKey(opIndex));
+}
+
+// string are not serializable by default, we need this helper class
+class SerializableString implements Serializable {
+ s: string;
+
+ constructor(s: string = '') {
+ this.s = s;
+ }
+
+ public serialize(): StaticArray {
+ return stringToBytes(this.s);
+ }
+
+ public deserialize(data: StaticArray, _offset: i32): Result {
+ this.s = bytesToString(data);
+ return new Result(0);
+ }
+}
+
+function switchUser(user: string): void {
+ changeCallStack(user + ' , ' + contractAddr);
+}
+
+beforeAll(() => {
+ resetStorage();
+ mockAdminContext(true);
+});
+
+describe('Multisig contract tests', () => {
+ test('constructor', () => {
+ // ---------------------------
+ // check invalid constructors
+
+ // 0 confirmations
+ expect(() => {
+ const serializedArgs = new Args()
+ .add(u8(0))
+ .add>(ownerList)
+ .serialize();
+ constructor(serializedArgs);
+ }).toThrow();
+
+ // no owners
+ expect(() => {
+ const serializedArgs = new Args()
+ .add(u8(1))
+ .add>([])
+ .serialize();
+ constructor(serializedArgs);
+ }).toThrow();
+
+ // invalid args
+ expect(() => {
+ constructor([]);
+ }).toThrow();
+
+ resetStorage();
+
+ // -------------------------------------------------------
+ // define a valid constructor for a 2:4 multisig
+ const serializedArgs = new Args()
+ .add(nbConfirmations)
+ .add>(ownerList)
+ .serialize();
+ constructor(serializedArgs);
+
+ // check the nb of confirmations required is properly stored
+ expect(byteToU8(Storage.get(NB_CONFIRMATIONS_REQUIRED_KEY))).toBe(
+ nbConfirmations,
+ );
+
+ // compare the array of addresses as string to the array of Address in storage
+ let serializableStringList: Array = [];
+ for (let i = 0; i < ownerList.length; ++i)
+ serializableStringList.push(new SerializableString(ownerList[i]));
+ let ownersFromStorage = bytesToSerializableObjectArray(
+ Storage.get(OWNERS_ADDRESSES_KEY),
+ ).unwrap();
+ let serializableOwnerStringList: Array = [];
+ for (let i = 0; i < ownersFromStorage.length; ++i)
+ serializableOwnerStringList.push(
+ new SerializableString(ownersFromStorage[i].toString()),
+ );
+ expect(
+ serializableObjectsArrayToBytes(
+ serializableOwnerStringList,
+ ),
+ ).toStrictEqual(
+ serializableObjectsArrayToBytes(
+ serializableStringList,
+ ),
+ );
+
+ // check the weight of each owner
+ expect(byteToU8(Storage.get(makeOwnerKey(owners[0])))).toBe(2);
+ expect(byteToU8(Storage.get(makeOwnerKey(owners[1])))).toBe(1);
+ expect(byteToU8(Storage.get(makeOwnerKey(owners[2])))).toBe(1);
+
+ // check that the operation index is set to 0
+ expect(bytesToU64(Storage.get(OPERATION_INDEX_KEY))).toBe(0);
+
+ // check that there are no operation registered yet
+ let operationList = bytesToFixedSizeArray(
+ ms1_getOperationIndexList([]),
+ );
+ expect(operationList.length).toBe(0);
+ });
+
+ test('submit operation by non owner', () => {
+ // expect the operation submission to fail
+ expect(() => {
+ ms1_submitTransaction(
+ new Args()
+ .add(new Address(destination))
+ .add(u64(15000))
+ .serialize(),
+ );
+ }).toThrow();
+ });
+
+ test('submit transaction operation', () => {
+ // pick owners[1] as the operation creator
+ switchUser(owners[1]);
+
+ // expect the operation index to be 1
+ expect(
+ ms1_submitTransaction(
+ new Args()
+ .add(new Address(destination))
+ .add(u64(15000))
+ .serialize(),
+ ),
+ ).toStrictEqual(u64ToBytes(1));
+
+ // check that the operation is correctly stored
+ let operationResult = retrieveOperation(1);
+ expect(operationResult.isOk()).toBe(true);
+
+ // check the operation content
+ let operation = operationResult.unwrap();
+ expect(operation.address).toBe(new Address(destination));
+ expect(operation.amount).toBe(u64(15000));
+ expect(operation.confirmedOwnerList.length).toBe(0);
+ expect(operation.confirmationWeightedSum).toBe(0);
+ expect(operation.isAlreadyConfirmed(new Address(owners[0]))).toBe(false);
+ expect(operation.isValidated()).toBe(false);
+ });
+
+ // validated operation
+ test('confirm transaction operation [owners[0]]', () => {
+ // pick owners[1] as the operation creator
+ switchUser(owners[1]);
+
+ let confirmingOwnersIndexes: Array;
+ let opIndex: u64;
+ let totalWeight: u8;
+
+ confirmingOwnersIndexes = [0];
+ opIndex = 2;
+
+ expect(
+ ms1_submitTransaction(
+ new Args()
+ .add(new Address(destination))
+ .add(u64(15000))
+ .serialize(),
+ ),
+ ).toStrictEqual(u64ToBytes(opIndex));
+
+ totalWeight = 0;
+ for (let i = 0; i < confirmingOwnersIndexes.length; ++i) {
+ let ownerAddress = owners[confirmingOwnersIndexes[i]];
+ switchUser(ownerAddress);
+ ms1_confirmOperation(new Args().add(opIndex).serialize());
+ totalWeight += ownerWeight[confirmingOwnersIndexes[i]];
+
+ // retrieve the operation in its current state in Storage
+ let operation = retrieveOperation(opIndex).unwrap();
+
+ expect(operation.address).toBe(new Address(destination));
+ expect(operation.amount).toBe(u64(15000));
+ expect(operation.confirmedOwnerList.length).toBe(i + 1);
+ expect(operation.confirmationWeightedSum).toBe(totalWeight);
+ expect(operation.isAlreadyConfirmed(new Address(ownerAddress))).toBe(
+ true,
+ );
+ }
+
+ switchUser(deployerAddress);
+ // retrieve the operation in its final state in Storage
+ let operation = retrieveOperation(opIndex).unwrap();
+ expect(operation.isValidated()).toBe(true);
+ });
+
+ // non validated operation
+ test('confirm transaction operation [owners[1]]', () => {
+ // pick owners[1] as the operation creator
+ switchUser(owners[1]);
+
+ let confirmingOwnersIndexes: Array;
+ let opIndex: u64;
+ let totalWeight: u8;
+
+ confirmingOwnersIndexes = [1];
+ opIndex = 3;
+
+ expect(
+ ms1_submitTransaction(
+ new Args()
+ .add(new Address(destination))
+ .add(u64(15000))
+ .serialize(),
+ ),
+ ).toStrictEqual(u64ToBytes(opIndex));
+
+ totalWeight = 0;
+ for (let i = 0; i < confirmingOwnersIndexes.length; ++i) {
+ let ownerAddress = owners[confirmingOwnersIndexes[i]];
+ switchUser(ownerAddress);
+ ms1_confirmOperation(new Args().add(opIndex).serialize());
+ totalWeight += ownerWeight[confirmingOwnersIndexes[i]];
+
+ // retrieve the operation in its current state in Storage
+ let operation = retrieveOperation(opIndex).unwrap();
+
+ expect(operation.address).toBe(new Address(destination));
+ expect(operation.amount).toBe(u64(15000));
+ expect(operation.confirmedOwnerList.length).toBe(i + 1);
+ expect(operation.confirmationWeightedSum).toBe(totalWeight);
+ expect(operation.isAlreadyConfirmed(new Address(ownerAddress))).toBe(
+ true,
+ );
+ }
+
+ switchUser(deployerAddress);
+ // retrieve the operation in its final state in Storage
+ let operation: Operation = retrieveOperation(opIndex).unwrap();
+ expect(operation.isValidated()).toBe(false);
+ });
+
+ // non validated operation
+ test('confirm transaction operation [owners[2]]', () => {
+ // pick owners[1] as the operation creator
+ switchUser(owners[1]);
+
+ let confirmingOwnersIndexes: Array;
+ let opIndex: u64;
+ let totalWeight: u8;
+
+ confirmingOwnersIndexes = [2];
+ opIndex = 4;
+
+ expect(
+ ms1_submitTransaction(
+ new Args()
+ .add(new Address(destination))
+ .add(u64(15000))
+ .serialize(),
+ ),
+ ).toStrictEqual(u64ToBytes(opIndex));
+
+ totalWeight = 0;
+ for (let i = 0; i < confirmingOwnersIndexes.length; ++i) {
+ let ownerAddress = owners[confirmingOwnersIndexes[i]];
+ switchUser(ownerAddress);
+ ms1_confirmOperation(new Args().add(opIndex).serialize());
+ totalWeight += ownerWeight[confirmingOwnersIndexes[i]];
+
+ // retrieve the operation in its current state in Storage
+ let operation = retrieveOperation(opIndex).unwrap();
+
+ expect(operation.address).toBe(new Address(destination));
+ expect(operation.amount).toBe(u64(15000));
+ expect(operation.confirmedOwnerList.length).toBe(i + 1);
+ expect(operation.confirmationWeightedSum).toBe(totalWeight);
+ expect(operation.isAlreadyConfirmed(new Address(ownerAddress))).toBe(
+ true,
+ );
+ }
+
+ switchUser(deployerAddress);
+ // retrieve the operation in its final state in Storage
+ let operation = retrieveOperation(opIndex).unwrap();
+ expect(operation.isValidated()).toBe(false);
+ });
+
+ // validated operation
+ test('confirm transaction operation [owners[1], owners[2]]', () => {
+ // pick owners[1] as the operation creator
+ switchUser(owners[1]);
+
+ let confirmingOwnersIndexes: Array;
+ let opIndex: u64;
+ let totalWeight: u8;
+
+ confirmingOwnersIndexes = [1, 2];
+ opIndex = 5;
+
+ expect(
+ ms1_submitTransaction(
+ new Args()
+ .add(new Address(destination))
+ .add(u64(15000))
+ .serialize(),
+ ),
+ ).toStrictEqual(u64ToBytes(opIndex));
+
+ totalWeight = 0;
+ for (let i = 0; i < confirmingOwnersIndexes.length; ++i) {
+ let ownerAddress = owners[confirmingOwnersIndexes[i]];
+ switchUser(ownerAddress);
+ ms1_confirmOperation(new Args().add(opIndex).serialize());
+ totalWeight += ownerWeight[confirmingOwnersIndexes[i]];
+
+ // retrieve the operation in its current state in Storage
+ let operation = retrieveOperation(opIndex).unwrap();
+
+ expect(operation.address).toBe(new Address(destination));
+ expect(operation.amount).toBe(u64(15000));
+ expect(operation.confirmedOwnerList.length).toBe(i + 1);
+ expect(operation.confirmationWeightedSum).toBe(totalWeight);
+ expect(operation.isAlreadyConfirmed(new Address(ownerAddress))).toBe(
+ true,
+ );
+ }
+
+ switchUser(deployerAddress);
+ // retrieve the operation in its final state in Storage
+ let operation = retrieveOperation(opIndex).unwrap();
+ expect(operation.isValidated()).toBe(true);
+ });
+
+ // test of the call operation constructor
+ test('submit call operation', () => {
+ // pick owners[1] as the operation creator
+ switchUser(owners[1]);
+
+ expect(
+ ms1_submitCall(
+ new Args()
+ .add(new Address(destination))
+ .add(u64(15000))
+ .add('getValueAt')
+ .add>(new Args().add(42).serialize())
+ .serialize(),
+ ),
+ ).toStrictEqual(u64ToBytes(6));
+
+ // check that the operation is correctly stored
+ let operationResult = retrieveOperation(6);
+ expect(operationResult.isOk()).toBe(true);
+
+ // check the operation content
+ let operation = operationResult.unwrap();
+ expect(operation.address).toBe(new Address(destination));
+ expect(operation.amount).toBe(u64(15000));
+ expect(operation.name).toBe('getValueAt');
+ expect(operation.args).toStrictEqual(new Args().add(42));
+ expect(operation.confirmedOwnerList.length).toBe(0);
+ expect(operation.confirmationWeightedSum).toBe(0);
+ expect(operation.isAlreadyConfirmed(new Address(owners[0]))).toBe(false);
+ expect(operation.isValidated()).toBe(false);
+ });
+
+ // test of the operation deletion
+ test('delete operation by creator', () => {
+ // pick owners[1] as the operation creator
+ switchUser(owners[1]);
+ expect(
+ ms1_submitTransaction(
+ new Args()
+ .add(new Address(destination))
+ .add(u64(15000))
+ .serialize(),
+ ),
+ ).toStrictEqual(u64ToBytes(7));
+
+ expect(() => {
+ ms1_deleteOperation(new Args().add(u64(7)).serialize());
+ }).not.toThrow();
+
+ // check that the operation is indeed deleted
+ expect(hasOperation(7)).toBe(false);
+ switchUser(deployerAddress);
+ });
+
+ test(
+ 'delete operation by non creator (will fail because' +
+ 'the operation is not yet executed)',
+ () => {
+ // pick owners[1] as the operation creator
+ switchUser(owners[1]);
+ expect(
+ ms1_submitTransaction(
+ new Args()
+ .add(new Address(destination))
+ .add(u64(15000))
+ .serialize(),
+ ),
+ ).toStrictEqual(u64ToBytes(8));
+
+ switchUser(owners[2]);
+ expect(() => {
+ ms1_deleteOperation(new Args().add(u64(8)).serialize());
+ }).toThrow();
+
+ // check that the operation is indeed not deleted
+ expect(hasOperation(8)).toBe(true);
+ switchUser(deployerAddress);
+ },
+ );
+
+ test('delete operation by no owner/creator (will fail)', () => {
+ // pick owners[1] as the operation creator
+ switchUser(owners[1]);
+ expect(
+ ms1_submitTransaction(
+ new Args()
+ .add(new Address(destination))
+ .add(u64(15000))
+ .serialize(),
+ ),
+ ).toStrictEqual(u64ToBytes(9));
+
+ switchUser(deployerAddress);
+ expect(() => {
+ ms1_deleteOperation(new Args().add(u64(9)).serialize());
+ }).toThrow();
+
+ // check that the operation is indeed not deleted
+ expect(hasOperation(9)).toBe(true);
+ });
+
+ // operation 5 is validated, let's execute it
+ test('execute transaction operation with success', () => {
+ let destinationBalance = Coins.balanceOf(destination);
+ let contractBalance = Coins.balanceOf(contractAddr);
+ let initDestinationBalance = destinationBalance;
+ let initContractBalance = contractBalance;
+
+ switchUser(owners[1]);
+ generateEvent(
+ createEvent('BALANCES BEFORE', [
+ initDestinationBalance.toString(),
+ initContractBalance.toString(),
+ ]),
+ );
+
+ expect(() => {
+ ms1_executeOperation(new Args().add(u64(5)).serialize());
+ }).not.toThrow();
+
+ // retrieve the operation and check that it is marked as executed
+ let operation = retrieveOperation(u64(5)).unwrap();
+ expect(operation.isExecuted).toBe(true);
+
+ destinationBalance = Coins.balanceOf(destination);
+ contractBalance = Coins.balanceOf(contractAddr);
+ generateEvent(
+ createEvent('BALANCES AFTER', [
+ destinationBalance.toString(),
+ contractBalance.toString(),
+ ]),
+ );
+
+ // check that the transfer has been done
+ expect(destinationBalance).toBe(initDestinationBalance + 15000);
+ expect(contractBalance + 15000).toBe(initContractBalance);
+ });
+
+ // test of the operation deletion after execution
+ test('delete operation by owner (non creator) once executed', () => {
+ // owners[1] is the operation creator for operation 5, let's pick owner2
+ switchUser(owners[2]);
+ expect(() => {
+ ms1_deleteOperation(new Args().add(u64(5)).serialize());
+ }).not.toThrow();
+
+ // check that the operation is indeed deleted
+ expect(hasOperation(5)).toBe(false);
+ switchUser(deployerAddress);
+ });
+
+ // operation 4 is not validated, let's try to execute it
+ test('execute transaction operation with failure', () => {
+ let destinationBalance = Coins.balanceOf(destination);
+ let contractBalance = Coins.balanceOf(contractAddr);
+ let initDestinationBalance = destinationBalance;
+ let initContractBalance = contractBalance;
+
+ switchUser(owners[1]);
+ generateEvent(
+ createEvent('BALANCES BEFORE', [
+ initDestinationBalance.toString(),
+ initContractBalance.toString(),
+ ]),
+ );
+
+ expect(() => {
+ ms1_executeOperation(new Args().add(u64(4)).serialize());
+ }).toThrow();
+
+ // the operation is not supposed to be deleted
+ expect(() => {
+ ms1_getOperation(new Args().add(u64(4)).serialize());
+ }).not.toThrow();
+
+ destinationBalance = Coins.balanceOf(destination);
+ contractBalance = Coins.balanceOf(contractAddr);
+ generateEvent(
+ createEvent('BALANCES AFTER', [
+ destinationBalance.toString(),
+ contractBalance.toString(),
+ ]),
+ );
+
+ // check that the transfer has not been done
+ expect(destinationBalance).toBe(initDestinationBalance);
+ expect(contractBalance).toBe(initContractBalance);
+ });
+
+ // operation 2 is validated by owners[0].
+ // now owners[0] will revoke it and we will try to execute it.
+ test('revoke operation', () => {
+ let destinationBalance = Coins.balanceOf(destination);
+ let contractBalance = Coins.balanceOf(contractAddr);
+ let initDestinationBalance = destinationBalance;
+ let initContractBalance = contractBalance;
+
+ switchUser(owners[0]);
+ expect(() => {
+ ms1_revokeConfirmation(new Args().add(u64(2)).serialize());
+ }).not.toThrow();
+
+ switchUser(deployerAddress);
+ generateEvent(
+ createEvent('BALANCES BEFORE', [
+ initDestinationBalance.toString(),
+ initContractBalance.toString(),
+ ]),
+ );
+
+ expect(() => {
+ ms1_executeOperation(new Args().add(u64(2)).serialize());
+ }).toThrow();
+
+ // the operation should not have been deleted
+ expect(() => {
+ ms1_getOperation(new Args().add(u64(2)).serialize());
+ }).not.toThrow();
+
+ // retrieve the operation in its current state in Storage
+ let operation = retrieveOperation(u64(2)).unwrap();
+
+ expect(operation.address).toBe(new Address(destination));
+ expect(operation.amount).toBe(u64(15000));
+ expect(operation.confirmedOwnerList.length).toBe(0);
+ expect(operation.confirmationWeightedSum).toBe(0);
+ expect(operation.isAlreadyConfirmed(new Address(owners[0]))).toBe(false);
+ expect(operation.isValidated()).toBe(false);
+
+ destinationBalance = Coins.balanceOf(destination);
+ contractBalance = Coins.balanceOf(contractAddr);
+ generateEvent(
+ createEvent('BALANCES AFTER', [
+ destinationBalance.toString(),
+ contractBalance.toString(),
+ ]),
+ );
+
+ // check that the transfer has not been done
+ expect(destinationBalance).toBe(initDestinationBalance);
+ expect(contractBalance).toBe(initContractBalance);
+ });
+
+ // check that the list of operations is now [1,3,4,6,8,9]
+ test('check operation index list', () => {
+ let operationList = bytesToFixedSizeArray(
+ ms1_getOperationIndexList([]),
+ );
+ expect(operationList.length).toBe(7);
+ expect(operationList).toStrictEqual([1, 2, 3, 4, 6, 8, 9]);
+ });
+});
diff --git a/smart-contracts/assembly/contracts/multisig/index.ts b/smart-contracts/assembly/contracts/multisig/index.ts
new file mode 100644
index 0000000..5d5037d
--- /dev/null
+++ b/smart-contracts/assembly/contracts/multisig/index.ts
@@ -0,0 +1,2 @@
+export * from './multisig';
+export * from './multisigWrapper';
diff --git a/smart-contracts/assembly/contracts/multisig/multisig.ts b/smart-contracts/assembly/contracts/multisig/multisig.ts
new file mode 100644
index 0000000..2627736
--- /dev/null
+++ b/smart-contracts/assembly/contracts/multisig/multisig.ts
@@ -0,0 +1,852 @@
+// ======================================================== //
+// MULTISIG CONTRACT //
+// //
+// Accepts funds in a smart contract locked by a multisig //
+// mechanism. Owners can be registered several times, //
+// implementing a simple weighted signature scheme. //
+// //
+// Inspired by the solidity multisig contract here: //
+// https://solidity-by-example.org/app/multi-sig-wallet //
+// //
+// ======================================================== //
+
+import {
+ Address,
+ Context,
+ Contract,
+ Coins,
+ generateEvent,
+ Storage,
+ createEvent,
+ callerHasWriteAccess,
+} from '@massalabs/massa-as-sdk';
+
+import {
+ Args,
+ Result,
+ u8toByte,
+ u64ToBytes,
+ stringToBytes,
+ bytesToString,
+ bytesToU64,
+ byteToU8,
+ boolToByte,
+ bytesToSerializableObjectArray,
+ serializableObjectsArrayToBytes,
+ bytesToFixedSizeArray,
+ fixedSizeArrayToBytes,
+} from '@massalabs/as-types';
+
+const DEPOSIT_EVENT_NAME = 'DEPOSIT';
+const SUBMIT_TRANSACTION_EVENT_NAME = 'SUBMIT_TRANSACTION';
+const SUBMIT_CALL_EVENT_NAME = 'SUBMIT_CALL';
+const CONFIRM_OPERATION_EVENT_NAME = 'CONFIRM_OPERATION';
+const EXECUTE_OPERATION_EVENT_NAME = 'EXECUTE_OPERATION';
+const REVOKE_OPERATION_EVENT_NAME = 'REVOKE_OPERATION';
+const DELETE_OPERATION_EVENT_NAME = 'DELETE_OPERATION';
+const RETRIEVE_OPERATION_EVENT_NAME = 'RETRIEVE_OPERATION';
+const GET_OWNERS_EVENT_NAME = 'GET_OWNERS';
+
+export const OPERATION_INDEX_PREFIX_KEY = '00';
+export const OWNER_PREFIX_KEY = '01';
+
+export const NB_CONFIRMATIONS_REQUIRED_KEY = stringToBytes(
+ 'NB CONFIRMATIONS REQUIRED',
+);
+export const OWNERS_ADDRESSES_KEY = stringToBytes('OWNERS ADDRESSES');
+export const OPERATION_INDEX_KEY = stringToBytes('OPERATION INDEX');
+export const OPERATION_LIST_KEY = stringToBytes('OPERATION LIST');
+
+// ======================================================== //
+// ==== HELPER FUNCTIONS & TYPES ==== //
+// ======================================================== //
+
+function makeOperationKey(opIndex: u64): StaticArray {
+ return stringToBytes(
+ OPERATION_INDEX_PREFIX_KEY + bytesToString(u64ToBytes(opIndex)),
+ );
+}
+
+function makeOwnerKey(address: Address): StaticArray {
+ return stringToBytes(OWNER_PREFIX_KEY + bytesToString(address.serialize()));
+}
+
+/**
+ * Operation represent either a transfer of coins to a given address (a "transaction"),
+ * or a call to a smart contract.
+ *
+ * When the name is empty, the operation is a simple transaction. The amount is
+ * credited to the given address.
+ *
+ * When the name is not empty, the operation is a call to the function of that
+ * name, from the smart contract at the given address. Amount can be used to
+ * transfer coin as part of the call. The list of arguments to the call can be
+ * specified in 'args'
+ *
+ */
+export class Operation {
+ creator: Address; // the creator of the operation.
+ address: Address; // the destination address (the recipient of coins or the smart contract to call)
+ amount: u64; // the amount
+ name: string; // the function call name, if any ("" means the operation is a simple coin transfer)
+ args: Args; // the args of the function call, if any
+ confirmedOwnerList: Array; // the Array listing the owners who have already signed
+ confirmationWeightedSum: u8; // the confirmation total weight sum, for easy check
+ isExecuted: bool; // true when the operation has been executed
+
+ constructor(
+ creator: Address = new Address(),
+ address: Address = new Address(),
+ amount: u64 = 0,
+ name: string = '',
+ args: Args = new Args(),
+ ) {
+ this.creator = creator;
+ this.address = address;
+ this.amount = amount;
+ this.name = name;
+ this.args = args;
+ this.confirmedOwnerList = new Array(0);
+ this.confirmationWeightedSum = 0;
+ this.isExecuted = false;
+ }
+
+ serialize(): StaticArray {
+ // create a serializable Operation record to store in Storage
+ // Here we store some redundant information, like the confirmation
+ // total weight sum, trading some Storage space for Compute space,
+ // knowing that the Operation will be erased from Storage once executed
+ const argOperation = new Args()
+ .add(this.creator)
+ .add(this.address)
+ .add(this.amount)
+ .add(this.name)
+ .add>(this.args.serialize())
+ .addSerializableObjectArray>(this.confirmedOwnerList)
+ .add(this.confirmationWeightedSum)
+ .add(this.isExecuted);
+ return argOperation.serialize();
+ }
+
+ deserialize(data: StaticArray): void {
+ const args = new Args(data);
+
+ this.creator = args
+ .nextSerializable()
+ .expect('Error while deserializing Operation creator');
+ this.address = args
+ .nextSerializable()
+ .expect('Error while deserializing Operation address');
+ this.amount = args
+ .nextU64()
+ .expect('Error while deserializing Operation amount');
+ this.name = args
+ .nextString()
+ .expect('Error while deserializing Operation name');
+ let argData = args
+ .nextBytes()
+ .expect('Error while deserializing Operation args');
+ this.args = new Args(argData);
+ this.confirmedOwnerList = args
+ .nextSerializableObjectArray()
+ .expect('Error while deserializing Operation confirmedOwnerList');
+ this.confirmationWeightedSum = args
+ .nextU8()
+ .expect('Error while deserializing Operation confirmationWeightedSum');
+ this.isExecuted = args
+ .nextBool()
+ .expect('Error while deserializing Operation isExecuted');
+ }
+
+ isAlreadyConfirmed(owner: Address): bool {
+ return this.confirmedOwnerList.includes(owner);
+ }
+
+ confirm(owner: Address, weight: u8): void {
+ this.confirmedOwnerList.push(owner);
+ this.confirmationWeightedSum += weight;
+ }
+
+ revoke(owner: Address, weight: u8): void {
+ let newConfirmedOwnerList = new Array(0);
+ for (let i = 0; i < this.confirmedOwnerList.length; i++) {
+ let address = this.confirmedOwnerList[i];
+ if (address != owner) newConfirmedOwnerList.push(address);
+ }
+ this.confirmedOwnerList = newConfirmedOwnerList;
+ assert(
+ this.confirmationWeightedSum >= weight,
+ 'fatal error: confirmationWeightedSum is less than revoked weight!',
+ );
+ this.confirmationWeightedSum -= weight;
+ }
+
+ isValidated(): bool {
+ let nbConfirmationsRequired = byteToU8(
+ Storage.get(NB_CONFIRMATIONS_REQUIRED_KEY),
+ );
+ return this.confirmationWeightedSum >= nbConfirmationsRequired;
+ }
+
+ execute(): void {
+ this.isExecuted = true;
+ if (this.name.length == 0)
+ // we have a transaction
+ Coins.transferCoinsOf(Context.callee(), this.address, this.amount);
+ // We have a call operation
+ else Contract.call(this.address, this.name, this.args, this.amount);
+ }
+}
+
+function storeOperation(opIndex: u64, operation: Operation): void {
+ // we simply use the Operation index as a key to store it
+ Storage.set(makeOperationKey(opIndex), operation.serialize());
+
+ // update the list of operation index if needed
+ let operationIndexList = bytesToFixedSizeArray(
+ Storage.get(OPERATION_LIST_KEY),
+ );
+ let index = operationIndexList.indexOf(opIndex);
+ if (index == -1) {
+ operationIndexList.push(opIndex);
+ }
+
+ Storage.set(
+ OPERATION_LIST_KEY,
+ fixedSizeArrayToBytes(operationIndexList),
+ );
+}
+
+function retrieveOperation(opIndex: u64): Result {
+ const operationKey = makeOperationKey(opIndex);
+
+ if (Storage.has(operationKey)) {
+ let operation = new Operation();
+ operation.deserialize(Storage.get(operationKey));
+ return new Result(operation);
+ }
+
+ return new Result(new Operation(), 'unknown Operation index');
+}
+
+function hasOperation(opIndex: u64): bool {
+ return Storage.has(makeOperationKey(opIndex));
+}
+
+function deleteOperation(opIndex: u64): void {
+ const operationKey = makeOperationKey(opIndex);
+ Storage.del(operationKey);
+
+ // update the list of operation index
+ let operationIndexList = bytesToFixedSizeArray(
+ Storage.get(OPERATION_LIST_KEY),
+ );
+ let index = operationIndexList.indexOf(opIndex);
+ if (index !== -1) {
+ operationIndexList.splice(index, 1);
+ }
+
+ Storage.set(
+ OPERATION_LIST_KEY,
+ fixedSizeArrayToBytes(operationIndexList),
+ );
+}
+
+/**
+ * Helper function to check if a given address is an owner of the multisig
+ *
+ */
+function isOwner(address: Address): bool {
+ let serializedOwnerAddresses = Storage.get(OWNERS_ADDRESSES_KEY);
+ let owners = bytesToSerializableObjectArray(
+ serializedOwnerAddresses,
+ ).unwrap();
+
+ for (let i = 0; i < owners.length; i++) if (owners[i] == address) return true;
+ return false;
+}
+
+// ======================================================== //
+// ==== CONSTRUCTOR ==== //
+// ======================================================== //
+
+/**
+ * Initialize the multisig wallet
+ * Can be called only once
+ *
+ * @example
+ * ```typescript
+ * constructor(
+ * new Args()
+ * .add(3) // nb of confirmations required
+ * .add>("Owner1Address", "Owner2Address", ..., "OwnerNAddress"])
+ * .serialize(),
+ * );
+ * ```
+ *
+ * @param stringifyArgs - Args object serialized as a string containing:
+ * - the number of confirmations required (u8)
+ * - the Array of addresses for each owner of the multisig (Array).
+ */
+export function constructor(stringifyArgs: StaticArray): void {
+ // This line is important. It ensures that this function can't be called in the future.
+ // If you remove this check, someone could call your constructor function and reset your smart contract.
+ assert(callerHasWriteAccess());
+
+ const args = new Args(stringifyArgs);
+ const MAX_OWNERS: i32 = 255;
+
+ // initialize nb of confirmations required
+ const nbConfirmationsRequired = args
+ .nextU8()
+ .expect('Error while initializing nbConfirmationsRequired');
+
+ assert(
+ nbConfirmationsRequired > 0,
+ 'The number of confirmations required must be at least 1',
+ );
+
+ Storage.set(NB_CONFIRMATIONS_REQUIRED_KEY, u8toByte(nbConfirmationsRequired));
+
+ // initialize array of owners addresses
+ const ownerStringAddresses: Array = args
+ .nextStringArray()
+ .expect('Error while initializing owners addresses array');
+
+ // convert to actual Addresses
+ const ownerAddresses: Array = [];
+ for (let i = 0; i < ownerStringAddresses.length; i++)
+ ownerAddresses.push(new Address(ownerStringAddresses[i]));
+
+ assert(
+ ownerAddresses.length > 0 && ownerAddresses.length <= MAX_OWNERS,
+ 'The multisig must have between one and 255 owners',
+ );
+
+ assert(
+ (nbConfirmationsRequired as i32) <= ownerAddresses.length,
+ 'The number of confirmations cannot exceed the number of owners of the multisig',
+ );
+
+ let ownerWeight = new Map();
+ for (let i = 0; i < ownerAddresses.length; i++) {
+ let address = ownerAddresses[i].toString();
+ assert(address, 'null address is not a valid owner');
+ let currentWeight: u8 = 0;
+ if (ownerWeight.has(address)) currentWeight = ownerWeight.get(address);
+ ownerWeight.set(address, currentWeight + 1);
+ }
+
+ for (let i = 0; i < ownerAddresses.length; i++) {
+ let address = ownerAddresses[i];
+ // we store directly each address weight in the Storage
+ Storage.set(
+ makeOwnerKey(address),
+ u8toByte(ownerWeight.get(address.toString())),
+ );
+ }
+
+ // We store the list of owners to be queries later if needed
+ Storage.set(
+ OWNERS_ADDRESSES_KEY,
+ serializableObjectsArrayToBytes(ownerAddresses),
+ );
+
+ // initialize operation index and operation list
+ Storage.set(OPERATION_INDEX_KEY, u64ToBytes(0));
+ Storage.set(OPERATION_LIST_KEY, fixedSizeArrayToBytes([]));
+}
+
+/**
+ * Returns the version of this smart contract.
+ * This versioning is following the best practices defined in https://semver.org/.
+ *
+ * @param _ - unused see https://github.com/massalabs/massa-sc-std/issues/18
+ * @returns contract version
+ */
+export function ms1_version(_: StaticArray): StaticArray {
+ return stringToBytes('0.0.0');
+}
+
+// ======================================================== //
+// ==== COIN DEPOSIT ==== //
+// ======================================================== //
+
+/**
+ * Accepts funds to credit the multisig wallet
+ * Follow specs here: https://github.com/massalabs/massa-standards/issues/106
+ *
+ * @param _ - unused see https://github.com/massalabs/massa-sc-std/issues/18
+ * @returns token name.
+ */
+export function cr1_receive_coins(_: StaticArray): void {
+ generateEvent(
+ createEvent(DEPOSIT_EVENT_NAME, [
+ Context.caller().toString(),
+ Context.transferredCoins().toString(),
+ Coins.balance().toString(),
+ ]),
+ );
+}
+
+// ======================================================== //
+// ==== OPERATIONS ==== //
+// ======================================================== //
+
+/**
+ * Submit a transaction operation and generate an event with its index number.
+ * For security reasons, only owners can submit operations.
+ *
+ * @example
+ * ```typescript
+ * ms1_submitTransaction(
+ * new Args()
+ * .add(new Address("...")) // destination address
+ * .add(150000) // amount
+ * .serialize()
+ * );
+ * ```
+ *
+ * @param stringifyArgs - Args object serialized as a string containing:
+ * - the destination address for the transfert (Address)
+ * - the amount of the operation (u64).
+ * @returns operation index.
+ */
+export function ms1_submitTransaction(
+ stringifyArgs: StaticArray,
+): StaticArray {
+ assert(
+ isOwner(Context.caller()),
+ 'Invalid caller to submit an operation. Only owners are allowed.',
+ );
+
+ const args = new Args(stringifyArgs);
+
+ // initialize address
+ const address = args
+ .nextSerializable()
+ .expect('Error while initializing transaction operation address');
+
+ // initialize amount
+ const amount = args
+ .nextU64()
+ .expect('Error while initializing transaction operation amount');
+
+ let opIndex = bytesToU64(Storage.get(OPERATION_INDEX_KEY));
+ opIndex++;
+
+ storeOperation(opIndex, new Operation(Context.caller(), address, amount));
+
+ // update the new opIndex value for the next operation
+ Storage.set(OPERATION_INDEX_KEY, u64ToBytes(opIndex));
+
+ generateEvent(
+ createEvent(SUBMIT_TRANSACTION_EVENT_NAME, [
+ Context.caller().toString(),
+ opIndex.toString(),
+ address.toString(),
+ amount.toString(),
+ ]),
+ );
+
+ return u64ToBytes(opIndex);
+}
+
+/**
+ * Submit a call operation and generate an event with its index number.
+ * For security reasons, only owners can submit operations.
+ *
+ * @example
+ * ```typescript
+ * ms1_submitCall(
+ * new Args()
+ * .add(new Address("...")) // smart contract address for the call
+ * .add(150000) // amount to transfer as part of the call
+ * .add("fun_name") // the name of the function to call
+ * .add(new Args()...) // the arguments to the call
+ * .serialize()
+ * );
+ * ```
+ *
+ * @param stringifyArgs - Args object serialized as a string containing:
+ * - the smart contract address for the operation (Address)
+ * - the transfered amount attached to the call (u64).
+ * - the name of the function to call (string).
+ * - the function arguments (Args).
+ * @returns operation index.
+ */
+export function ms1_submitCall(
+ stringifyArgs: StaticArray,
+): StaticArray {
+ assert(
+ isOwner(Context.caller()),
+ 'Invalid caller to submit an operation. Only owners are allowed.',
+ );
+
+ const args = new Args(stringifyArgs);
+
+ // initialize address
+ const address = args
+ .nextSerializable()
+ .expect('Error while initializing call operation address');
+
+ // initialize amount
+ const amount = args
+ .nextU64()
+ .expect('Error while initializing call operation amount');
+
+ // initialize function name
+ const name = args
+ .nextString()
+ .expect('Error while initializing call operation function name');
+
+ // initialize args
+ const callArgsData = args
+ .nextBytes()
+ .expect('Error while initializing call operation function args');
+ const callArgs = new Args(callArgsData);
+
+ let opIndex = bytesToU64(Storage.get(OPERATION_INDEX_KEY));
+ opIndex++;
+
+ storeOperation(
+ opIndex,
+ new Operation(Context.caller(), address, amount, name, callArgs),
+ );
+
+ // update the new opIndex value for the next operation
+ Storage.set(OPERATION_INDEX_KEY, u64ToBytes(opIndex));
+
+ generateEvent(
+ createEvent(SUBMIT_CALL_EVENT_NAME, [
+ Context.caller().toString(),
+ opIndex.toString(),
+ address.toString(),
+ amount.toString(),
+ name,
+ ]),
+ );
+
+ return u64ToBytes(opIndex);
+}
+
+/**
+ * Confirms an operation by an owner, and generate an event
+ *
+ * @example
+ * ```typescript
+ * ms1_confirmOperation(
+ * new Args()
+ * .add(index) // the operation index
+ * .serialize(),
+ * );
+ * ```
+ *
+ * @param stringifyArgs - Args object serialized as a string containing:
+ * - the operation index (u64)
+ */
+export function ms1_confirmOperation(stringifyArgs: StaticArray): void {
+ const args = new Args(stringifyArgs);
+
+ // initialize operation index
+ const opIndex = args
+ .nextU64()
+ .expect('Error while initializing operation index');
+
+ let owner = Context.caller();
+
+ // check owner is legit and retrieve the weight
+ let ownerKey = makeOwnerKey(owner);
+ assert(Storage.has(ownerKey), 'Caller address is not an owner');
+ let weight = byteToU8(Storage.get(ownerKey));
+
+ // check the operation exists and retrieve it from Storage
+ let operation = retrieveOperation(opIndex).unwrap();
+
+ // don't allow changes on executed operations
+ assert(!operation.isExecuted, 'cannot modify an executed operation');
+
+ // did we already confirm it?
+ assert(
+ !operation.isAlreadyConfirmed(owner),
+ 'The caller address has already confirmed this operation',
+ );
+
+ // confirm it and update the Storage
+ operation.confirm(owner, weight);
+ storeOperation(opIndex, operation);
+
+ generateEvent(
+ createEvent(CONFIRM_OPERATION_EVENT_NAME, [
+ owner.toString(),
+ opIndex.toString(),
+ ]),
+ );
+}
+
+/**
+ * Execute an operation and generate an event in case of success
+ *
+ * @example
+ * ```typescript
+ * ms1_executeOperation(
+ * new Args()
+ * .add(index) // the operation index
+ * .serialize(),
+ * );
+ * ```
+ *
+ * @param stringifyArgs - Args object serialized as a string containing:
+ * - the operation index (u64)
+ */
+export function ms1_executeOperation(stringifyArgs: StaticArray): void {
+ assert(
+ isOwner(Context.caller()),
+ 'Invalid caller to execute an operation. Only owners are allowed.',
+ );
+
+ const args = new Args(stringifyArgs);
+
+ // initialize operation index
+ const opIndex = args
+ .nextU64()
+ .expect('Error while initializing operation index');
+
+ // check the operation exists and retrieve it from Storage
+ let operation = retrieveOperation(opIndex).unwrap();
+
+ // if the operation is sufficiently confirmed and not already executed,
+ // then execute it.
+ assert(
+ !operation.isExecuted,
+ 'The operation has already been executed, cannot execute twice',
+ );
+ assert(
+ operation.isValidated(),
+ 'The operation is unsufficiently confirmed, cannot execute',
+ );
+ operation.execute();
+
+ // update the operation in storage to reflect its new isExecuted state
+ storeOperation(opIndex, operation);
+
+ generateEvent(
+ createEvent(EXECUTE_OPERATION_EVENT_NAME, [
+ Context.caller().toString(),
+ opIndex.toString(),
+ operation.address.toString(),
+ operation.amount.toString(),
+ ]),
+ );
+}
+
+/**
+ * Delete an operation and generate an event in case of success
+ * NB: only the operation creator can delete the operation if it
+ * has not yet been executed. This is to avoid delete-bombing
+ * attacks on pending operations by antagonist or compromised owners.
+ *
+ * @example
+ * ```typescript
+ * ms1_deleteOperation(
+ * new Args()
+ * .add(index) // the operation index
+ * .serialize(),
+ * );
+ * ```
+ *
+ * @param stringifyArgs - Args object serialized as a string containing:
+ * - the operation index (u64)
+ */
+export function ms1_deleteOperation(stringifyArgs: StaticArray): void {
+ const args = new Args(stringifyArgs);
+
+ // initialize operation index
+ const opIndex = args
+ .nextU64()
+ .expect('Error while initializing operation index');
+
+ // check the operation exists and retrieve it from Storage
+ let operation = retrieveOperation(opIndex).unwrap();
+
+ // any owner can delete an executed operation, other wise it has to be the
+ // operation creator.
+ assert(
+ (operation.isExecuted && isOwner(Context.caller())) ||
+ Context.caller() == operation.creator,
+ 'invalid caller to delete the operation. Owner can delete executed operations, ' +
+ 'or the creator if the operation is not yet executed.',
+ );
+
+ // clean up Storage and delete operation
+ deleteOperation(opIndex);
+
+ generateEvent(
+ createEvent(DELETE_OPERATION_EVENT_NAME, [
+ Context.caller().toString(),
+ opIndex.toString(),
+ ]),
+ );
+}
+
+/**
+ * Revoke an operation confirmation by an owner, and generate an event
+ *
+ * @example
+ * ```typescript
+ * ms1_revokeConfirmation(
+ * new Args()
+ * .add(index) // the operation index
+ * .serialize(),
+ * );
+ * ```
+ *
+ * @param stringifyArgs - Args object serialized as a string containing:
+ * - the operation index (u64)
+ */
+export function ms1_revokeConfirmation(stringifyArgs: StaticArray): void {
+ const args = new Args(stringifyArgs);
+
+ // initialize operation index
+ const opIndex = args
+ .nextU64()
+ .expect('Error while initializing operation index');
+
+ let owner = Context.caller();
+
+ // check owner is legit and retrieve the weight
+ let ownerKey = makeOwnerKey(owner);
+ assert(Storage.has(ownerKey), 'Caller address is not an owner');
+ let weight = byteToU8(Storage.get(ownerKey));
+
+ // check the operation exists and retrieve it from Storage
+ let operation = retrieveOperation(opIndex).unwrap();
+
+ // did we actually already confirmed it?
+ assert(
+ operation.isAlreadyConfirmed(owner),
+ 'The caller address has not yet confirmed this operation',
+ );
+
+ // don't allow changes on executed operations
+ assert(!operation.isExecuted, 'cannot modify an executed operation');
+
+ // revoke it and update the Storage
+ operation.revoke(owner, weight);
+ storeOperation(opIndex, operation);
+
+ generateEvent(
+ createEvent(REVOKE_OPERATION_EVENT_NAME, [
+ owner.toString(),
+ opIndex.toString(),
+ ]),
+ );
+}
+
+/**
+ * Retrieve the list of the multisig owners addresses as strings and emit an event
+ *
+ * @example
+ * ```typescript
+ * let owners = bytesToSerializableObjectArray(ms1_getOwners()).unwrap();
+ * ```
+ *
+ */
+export function ms1_getOwners(_: StaticArray): StaticArray {
+ let serializedOwnerAddresses = Storage.get(OWNERS_ADDRESSES_KEY);
+ let owners = bytesToSerializableObjectArray(
+ serializedOwnerAddresses,
+ ).unwrap();
+
+ // generate the event with the list of owners
+ let eventPayLoad: Array = [];
+ for (let i = 0; i < owners.length; i++)
+ eventPayLoad.push(owners[i].toString());
+ generateEvent(createEvent(GET_OWNERS_EVENT_NAME, eventPayLoad));
+
+ return serializedOwnerAddresses;
+}
+
+/**
+ * Retrieve a currently stored operation and generate an event
+ *
+ * @example
+ * ```typescript
+ * let operation = new Operation();
+ * operation.deserialize(ms1_getOperation(
+ * new Args()
+ * .add(index) // the operation index
+ * .serialize()
+ * ));
+ * ```
+ *
+ * @param stringifyArgs - Args object serialized as a string containing:
+ * - the operation index (u64)
+ */
+export function ms1_getOperation(
+ stringifyArgs: StaticArray,
+): StaticArray {
+ const args = new Args(stringifyArgs);
+
+ // initialize operation index
+ const opIndex = args
+ .nextU64()
+ .expect('Error while initializing operation index');
+
+ // check the operation exists and retrieve it from Storage
+ let operation = retrieveOperation(opIndex).unwrap();
+
+ // generate the event with the list of confirmed owners
+ let eventPayLoad: Array = [
+ opIndex.toString(),
+ operation.address.toString(),
+ operation.amount.toString(),
+ operation.confirmationWeightedSum.toString(),
+ ];
+ for (let i = 0; i < operation.confirmedOwnerList.length; i++)
+ eventPayLoad.push(operation.confirmedOwnerList[i].toString());
+ generateEvent(createEvent(RETRIEVE_OPERATION_EVENT_NAME, eventPayLoad));
+
+ return operation.serialize();
+}
+
+/**
+ * Check if the operation defined by its index is a currently stored
+ * operation.
+ * @example
+ * ```typescript
+ * ms1_hasOperation(
+ * new Args()
+ * .add(index) // the operation index
+ * .serialize(),
+ * );
+ * ```
+ *
+ * @param stringifyArgs - Args object serialized as a string containing:
+ * - the operation index (u64)
+ */
+export function ms1_hasOperation(
+ stringifyArgs: StaticArray,
+): StaticArray {
+ const args = new Args(stringifyArgs);
+
+ // initialize operation index
+ const opIndex = args
+ .nextU64()
+ .expect('Error while initializing operation index');
+
+ return boolToByte(hasOperation(opIndex));
+}
+
+/**
+ * Returns the list of all currently pending operations indexes (which
+ * can then be queried by ms1_getOperation to display a detail list of
+ * operations currently running)
+ * @example
+ * ```typescript
+ * let operationIndexList = bytesToFixedSizeArray(ms1_getOperationIndexList());
+ * ```
+ */
+export function ms1_getOperationIndexList(_: StaticArray): StaticArray {
+ return Storage.get(OPERATION_LIST_KEY);
+}
diff --git a/smart-contracts/assembly/contracts/multisig/multisigWrapper.ts b/smart-contracts/assembly/contracts/multisig/multisigWrapper.ts
new file mode 100644
index 0000000..53c7266
--- /dev/null
+++ b/smart-contracts/assembly/contracts/multisig/multisigWrapper.ts
@@ -0,0 +1,194 @@
+import { Address, call } from '@massalabs/massa-as-sdk';
+import {
+ Args,
+ NoArg,
+ byteToBool,
+ bytesToSerializableObjectArray,
+ bytesToU64,
+} from '@massalabs/as-types';
+
+/**
+ * The Massa's standard multisig implementation wrapper.
+ *
+ * @remarks
+ * This class can be used to wrap a smart contract implementing
+ * Massa standard multisig.
+ * All the serialization/deserialization will be handled here.
+ *
+ * @example
+ * ```typescript
+ * const multisig = new MultisigWrapper(MultisigAddr);
+ * multisig.confirmOperation(opIndex);
+ * ```
+ */
+export class MultisigWrapper {
+ _origin: Address;
+
+ /**
+ * Wraps a smart contract exposing standard multisig.
+ *
+ * @param at - Address of the contract
+ */
+ constructor(at: Address) {
+ this._origin = at;
+ }
+
+ /**
+ * Deposit tokens in the multisig
+ */
+ deposit(amount: u64): void {
+ call(this._origin, 'ms1_deposit', NoArg, amount);
+ }
+
+ /**
+ * Submit a transaction operation, and retrieve its index to be used by
+ * the multisig owners to confirm it.
+ * For security reasons, only owners can submit operations.
+ *
+ * @param address - recipient address
+ * @param amount - amount to transfer
+ *
+ * @returns the operation index
+ */
+ submitTransaction(address: Address, amount: u64): u64 {
+ return bytesToU64(
+ call(
+ this._origin,
+ 'ms1_submitTransaction',
+ new Args().add(address).add(amount).serialize(),
+ 0,
+ ),
+ );
+ }
+
+ /**
+ * Submit a smart contract call operation, and retrieve its index to be
+ * used by the multisig owners to confirm it.
+ * For security reasons, only owners can submit operations.
+ *
+ * @param address - the smart contract address for the operation
+ * @param amount - the transfered amount attached to the call
+ * @param name - the name of the function to call
+ * @param args - the function arguments
+ *
+ * @returns the operation index
+ */
+ submitCall(address: Address, amount: u64, name: string, args: Args): u64 {
+ return bytesToU64(
+ call(
+ this._origin,
+ 'ms1_submitCall',
+ new Args()
+ .add(address)
+ .add(amount)
+ .add(name)
+ .add>(args.serialize())
+ .serialize(),
+ 0,
+ ),
+ );
+ }
+
+ /**
+ * Confirm an operation identified by its index.
+ *
+ * @param opIndex - the operation index
+ */
+ confirmOperation(opIndex: u64): void {
+ call(
+ this._origin,
+ 'ms1_confirmOperation',
+ new Args().add(opIndex).serialize(),
+ 0,
+ );
+ }
+
+ /**
+ * Execute an operation if it has enough validation from owners
+ *
+ * @param opIndex - the operation index
+ */
+ executeOperation(opIndex: u64): void {
+ call(
+ this._origin,
+ 'ms1_executeOperation',
+ new Args().add(opIndex).serialize(),
+ 0,
+ );
+ }
+
+ /**
+ * Cancel an operation (only the creator can do this)
+ *
+ * @param opIndex - the operation index
+ */
+ cancelOperation(opIndex: u64): void {
+ call(
+ this._origin,
+ 'ms1_cancelOperation',
+ new Args().add(opIndex).serialize(),
+ 0,
+ );
+ }
+
+ /**
+ * Revoke a operation identified by its index.
+ *
+ * @param opIndex - the operation index
+ */
+ revokeOperation(opIndex: u64): void {
+ call(
+ this._origin,
+ 'ms1_revokeOperation',
+ new Args().add(opIndex).serialize(),
+ 0,
+ );
+ }
+
+ /**
+ * Get the list of owners of the multisig.
+ *
+ * @returns the list of owners addresses
+ */
+ getOwners(): Array {
+ return bytesToSerializableObjectArray(
+ call(this._origin, 'ms1_getOwners', NoArgs, 0),
+ ).unwrap();
+ }
+
+ /**
+ * Get an operation identified by its index.
+ * Will throw if the operation index does not exist.
+ *
+ * @returns the operation
+ */
+ getOperation(opIndex: u64): Operation {
+ let operation = new Operation();
+ operation.deserialize(
+ call(
+ this._origin,
+ 'ms1_getOperation',
+ new Args().add(opIndex).serialize(),
+ 0,
+ ),
+ );
+
+ return operation;
+ }
+
+ /**
+ * Check if an operation identified by its index is pending.
+ *
+ * @returns true if the operation is defined and pending execution.
+ */
+ hasOperation(opIndex: u64): bool {
+ return byteToBool(
+ call(
+ this._origin,
+ 'ms1_hasOperation',
+ new Args().add(opIndex).serialize(),
+ 0,
+ ),
+ );
+ }
+}