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, + ), + ); + } +}