diff --git a/package-lock.json b/package-lock.json index 503d6de1..c391dc6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@axelar-network/axelar-cgp-solidity": "6.3.1", - "@axelar-network/axelar-cgp-sui": "0.3.0", + "@axelar-network/axelar-cgp-sui": "^0.0.0-snapshot.218635e", "@axelar-network/axelar-gmp-sdk-solidity": "5.10.0", "@axelar-network/interchain-token-service": "1.2.4", "@cosmjs/cosmwasm-stargate": "^0.32.1", @@ -42,6 +42,38 @@ "node": ">=18" } }, + "../axelar-cgp-sui": { + "version": "0.3.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@cosmjs/cosmwasm-stargate": "^0.32.2", + "@mysten/sui": "^1.3.0", + "ethers": "^5.0.0", + "secp256k1": "^5.0.0", + "tmp": "^0.2.1", + "typescript": "^5.3.3" + }, + "devDependencies": { + "@changesets/cli": "^2.27.6", + "@ianvs/prettier-plugin-sort-imports": "^4.2.1", + "@types/node": "^20.14.11", + "@types/tmp": "^0.2.6", + "@typescript-eslint/eslint-plugin": "^7.13.1", + "@typescript-eslint/parser": "^7.13.1", + "chai": "^4.3.7", + "dotenv": "^16.3.1", + "eslint": "^8.57.0", + "eslint-config-richardpringle": "^2.0.0", + "mocha": "^10.4.0", + "prettier": "^2.8.7", + "prettier-plugin-sort-imports": "^1.8.5", + "typescript": "^5.5.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@0no-co/graphql.web": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.0.7.tgz", @@ -144,19 +176,16 @@ } }, "node_modules/@axelar-network/axelar-cgp-sui": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@axelar-network/axelar-cgp-sui/-/axelar-cgp-sui-0.3.0.tgz", - "integrity": "sha512-zHhtmT4L1WmOEhL8gjsZKAE+b7EGC+OIIMZTVoXVrK/NRldEoPW6356hGvyGIaWM6UzyxNh2o+JU2jj0C/avaw==", - "hasInstallScript": true, + "version": "0.0.0-snapshot.218635e", + "resolved": "https://registry.npmjs.org/@axelar-network/axelar-cgp-sui/-/axelar-cgp-sui-0.0.0-snapshot.218635e.tgz", + "integrity": "sha512-+k72piOoq6oz/NFMX57keooSP/OZ7FG8IMPbx5Y8BBIKzxusQzMovZ0inz+StJFfd4i+Ho+FlN98m8tPIqLypg==", "dependencies": { "@cosmjs/cosmwasm-stargate": "^0.32.2", "@mysten/sui": "^1.3.0", - "@types/tmp": "^0.2.6", - "child_process": "^1.0.2", "ethers": "^5.0.0", - "fs": "^0.0.1-security", "secp256k1": "^5.0.0", - "tmp": "^0.2.1" + "tmp": "^0.2.1", + "typescript": "^5.3.3" }, "engines": { "node": ">=18" @@ -2594,11 +2623,6 @@ "@types/node": "*" } }, - "node_modules/@types/tmp": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz", - "integrity": "sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==" - }, "node_modules/@types/w3c-web-usb": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.10.tgz", @@ -3551,11 +3575,6 @@ "node": "*" } }, - "node_modules/child_process": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/child_process/-/child_process-1.0.2.tgz", - "integrity": "sha512-Wmza/JzL0SiWz7kl6MhIKT5ceIlnFPJX+lwUGj7Clhy5MMldsSoJR0+uvRzOS5Kv45Mq7t1PoE8TsOA9bzvb6g==" - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -5688,11 +5707,6 @@ "integrity": "sha512-H5KQDspykdHuztLTg+ajGN0Z2qUjcEf3Ybxc6hLt0k7/zPkn29XnKnxlBPyW2XIddWrGaJBzBl4VLYOtk39yZg==", "dev": true }, - "node_modules/fs": { - "version": "0.0.1-security", - "resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz", - "integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==" - }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -10333,7 +10347,6 @@ "version": "5.5.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 37d24349..d03692fc 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "homepage": "https://github.com/axelarnetwork/axelar-contract-deployments#readme", "dependencies": { "@axelar-network/axelar-cgp-solidity": "6.3.1", - "@axelar-network/axelar-cgp-sui": "0.3.0", + "@axelar-network/axelar-cgp-sui": "^0.0.0-snapshot.218635e", "@axelar-network/axelar-gmp-sdk-solidity": "5.10.0", "@axelar-network/interchain-token-service": "1.2.4", "@cosmjs/cosmwasm-stargate": "^0.32.1", diff --git a/sui/README.md b/sui/README.md index 9df6c507..d0b0e283 100644 --- a/sui/README.md +++ b/sui/README.md @@ -147,6 +147,24 @@ The syntax is `node sui/gas-service.js payGas --amount +``` + +```bash +node sui/gas-service.js collectGas --amount 0.1 --receiver +``` + Approve messages: If the gateway was deployed using the wallet, you can submit a message approval with it diff --git a/sui/cli-utils.js b/sui/cli-utils.js index d36d9712..8cf95622 100644 --- a/sui/cli-utils.js +++ b/sui/cli-utils.js @@ -60,8 +60,6 @@ const addOptionsToCommands = (program, optionMethod, options) => { optionMethod(command, options); }); } - - optionMethod(program, options); }; // Custom option processing for amount. https://github.com/tj/commander.js?tab=readme-ov-file#custom-option-processing diff --git a/sui/gas-service.js b/sui/gas-service.js index 4b81be71..0d4b64b6 100644 --- a/sui/gas-service.js +++ b/sui/gas-service.js @@ -176,7 +176,7 @@ if (require.main === module) { .description('Pay gas for the new contract call.') .option('--refundAddress ', 'Refund address. Default is the sender address.') .requiredOption('--amount ', 'Amount to pay gas', parseSuiUnitAmount) - .option('--params ', 'Params. Default is empty.') + .option('--params ', 'Params. Default is empty.', '0x') .action((destinationChain, destinationAddress, channelId, payload, options) => { mainProcessor(options, [destinationChain, destinationAddress, channelId, payload], processCommand, payGas); }); diff --git a/sui/operators.js b/sui/operators.js new file mode 100644 index 00000000..d7dd6188 --- /dev/null +++ b/sui/operators.js @@ -0,0 +1,234 @@ +const { Command, Option } = require('commander'); +const { Transaction } = require('@mysten/sui/transactions'); +const { printInfo, printError, loadConfig } = require('../common/utils'); +const { addBaseOptions, addOptionsToCommands, parseSuiUnitAmount } = require('./cli-utils'); +const { getWallet, printWalletInfo, broadcast } = require('./sign-utils'); +const { findOwnedObjectId } = require('./utils'); + +function operatorMoveCall(contractConfig, gasServiceConfig, operatorCapId, tx, moveCall) { + const operatorId = contractConfig.objects.Operators; + const gasCollectorCapId = gasServiceConfig.objects.GasCollectorCap; + + const [cap, loanedCap] = tx.moveCall({ + target: `${contractConfig.address}::operators::loan_cap`, + arguments: [tx.object(operatorId), tx.object(operatorCapId), tx.object(gasCollectorCapId)], + typeArguments: [`${gasServiceConfig.address}::gas_service::GasCollectorCap`], + }); + + moveCall(cap); + + tx.moveCall({ + target: `${contractConfig.address}::operators::restore_cap`, + arguments: [tx.object(operatorId), tx.object(operatorCapId), tx.object(gasCollectorCapId), cap, loanedCap], + typeArguments: [`${gasServiceConfig.address}::gas_service::GasCollectorCap`], + }); + + return tx; +} + +async function collectGas(keypair, client, gasServiceConfig, contractConfig, args, options) { + const [amount] = args; + const receiver = options.receiver || keypair.toSuiAddress(); + + const operatorCapId = await findOwnedObjectId(client, keypair.toSuiAddress(), `${contractConfig.address}::operators::OperatorCap`); + const tx = new Transaction(); + + operatorMoveCall(contractConfig, gasServiceConfig, operatorCapId, tx, (cap) => { + tx.moveCall({ + target: `${gasServiceConfig.address}::gas_service::collect_gas`, + arguments: [tx.object(gasServiceConfig.objects.GasService), cap, tx.pure.address(receiver), tx.pure.u64(amount)], + }); + }); + + const receipt = await broadcast(client, keypair, tx); + + printInfo('Gas collected', receipt.digest); +} + +async function refund(keypair, client, gasServiceConfig, contractConfig, args, options) { + const [messageId] = args; + const amount = options.amount; + const receiver = options.receiver || keypair.toSuiAddress(); + const operatorCapId = await findOwnedObjectId(client, keypair.toSuiAddress(), `${contractConfig.address}::operators::OperatorCap`); + + const tx = new Transaction(); + + operatorMoveCall(contractConfig, gasServiceConfig, operatorCapId, tx, (cap) => { + tx.moveCall({ + target: `${gasServiceConfig.address}::gas_service::refund`, + arguments: [ + tx.object(gasServiceConfig.objects.GasService), + cap, + tx.pure.string(messageId), + tx.pure.address(receiver), + tx.pure.u64(amount), + ], + }); + }); + + const receipt = await broadcast(client, keypair, tx); + + printInfo('Gas refunded', receipt.digest); +} + +async function storeCap(keypair, client, gasServiceConfig, contractConfig, args, options) { + const [capId] = args; + const gasCollectorCapId = capId || gasServiceConfig.objects.GasCollectorCap; + const ownerCapId = contractConfig.objects.OwnerCap; + const operatorId = contractConfig.objects.Operators; + + const tx = new Transaction(); + + tx.moveCall({ + target: `${contractConfig.address}::operators::store_cap`, + arguments: [tx.object(operatorId), tx.object(ownerCapId), tx.object(gasCollectorCapId)], + typeArguments: [`${gasServiceConfig.address}::gas_service::GasCollectorCap`], + }); + + const receipt = await broadcast(client, keypair, tx); + + printInfo('Capability stored', receipt.digest); +} + +async function addOperator(keypair, client, gasServiceConfig, contractConfig, args, options) { + const [newOperatorAddress] = args; + + const operatorsObjectId = contractConfig.objects.Operators; + const ownerCapObjectId = options.ownerCapId || contractConfig.objects.OwnerCap; + + const tx = new Transaction(); + + tx.moveCall({ + target: `${contractConfig.address}::operators::add_operator`, + arguments: [tx.object(operatorsObjectId), tx.object(ownerCapObjectId), tx.pure.address(newOperatorAddress)], + }); + + const receipt = await broadcast(client, keypair, tx); + + printInfo('Operator Added', receipt.digest); +} + +async function removeCap(keypair, client, gasServiceConfig, contractConfig, args, options) { + const [capId] = args; + + const gasServiceAddress = gasServiceConfig.address; + const operatorsObjectId = contractConfig.objects.Operators; + const ownerCapObjectId = options.ownerCapId || contractConfig.objects.OwnerCap; + const capReceiver = options.receiver || keypair.toSuiAddress(); + + const tx = new Transaction(); + + const cap = tx.moveCall({ + target: `${contractConfig.address}::operators::remove_cap`, + arguments: [tx.object(operatorsObjectId), tx.object(ownerCapObjectId), tx.object(capId)], + typeArguments: [`${gasServiceAddress}::gas_service::GasCollectorCap`], + }); + + tx.transferObjects([cap], capReceiver); + + try { + const receipt = await broadcast(client, keypair, tx); + + printInfo('Capability Removed', receipt.digest); + } catch (e) { + printError('RemoveCap Error', e.message); + } +} + +async function removeOperator(keypair, client, gasServiceConfig, contractConfig, args, options) { + const [operatorAddress] = args; + + const operatorsObjectId = contractConfig.objects.Operators; + const ownerCapObjectId = options.ownerCapId || contractConfig.objects.OwnerCap; + + const tx = new Transaction(); + + tx.moveCall({ + target: `${contractConfig.address}::operators::remove_operator`, + arguments: [tx.object(operatorsObjectId), tx.object(ownerCapObjectId), tx.pure.address(operatorAddress)], + }); + + const receipt = await broadcast(client, keypair, tx); + + printInfo('Operator Removed', receipt.digest); +} + +async function mainProcessor(processor, args, options) { + const config = loadConfig(options.env); + + const contractConfig = config.sui.contracts.Operators; + const gasServiceConfig = config.sui.contracts.GasService; + + if (!contractConfig) { + throw new Error('Operators package not found.'); + } + + if (!gasServiceConfig) { + throw new Error('Gas service package not found.'); + } + + const [keypair, client] = getWallet(config.sui, options); + await printWalletInfo(keypair, client, config.sui, options); + await processor(keypair, client, gasServiceConfig, contractConfig, args, options); +} + +if (require.main === module) { + const program = new Command('operators'); + + program.description('Operators contract operations.'); + + program.addCommand( + new Command('add') + .command('add ') + .description('Add an operator') + .addOption(new Option('--ownerCap ', 'ID of the owner capability')) + .action((newOperatorAddress, options) => mainProcessor(addOperator, [newOperatorAddress], options)), + ); + + program.addCommand( + new Command('remove') + .command('remove ') + .description('Remove an operator') + .addOption(new Option('--ownerCap ', 'ID of the owner capability')) + .action((operatorAddress, options) => mainProcessor(removeOperator, [operatorAddress], options)), + ); + + program.addCommand( + new Command('collectGas') + .command('collectGas') + .description('Collect gas from the gas service') + .addOption(new Option('--receiver ', 'Address of the receiver')) + .requiredOption('--amount ', 'Amount to add gas', parseSuiUnitAmount) + .action((options) => mainProcessor(collectGas, [options.amount], options)), + ); + + program.addCommand( + new Command('storeCap') + .command('storeCap') + .description('Store a capability') + .addOption(new Option('--capId ', 'ID of the capability to store')) + .action((options) => mainProcessor(storeCap, [], options)), + ); + + program.addCommand( + new Command('removeCap') + .command('removeCap ') + .description('Remove a capability') + .addOption(new Option('--ownerCap ', 'ID of the owner capability')) + .addOption(new Option('--receiver ', 'The removed cap receiver address')) + .action((capId, options) => mainProcessor(removeCap, [capId], options)), + ); + + program.addCommand( + new Command('refund') + .command('refund ') + .description('Refund gas from the gas service') + .addOption(new Option('--receiver ', 'Address of the receiver')) + .requiredOption('--amount ', 'Amount to refund', parseSuiUnitAmount) + .action((messageId, options) => mainProcessor(refund, [messageId], options)), + ); + + addOptionsToCommands(program, addBaseOptions); + + program.parse(); +} diff --git a/sui/types-utils.js b/sui/types-utils.js index cd90d4ff..afa87595 100644 --- a/sui/types-utils.js +++ b/sui/types-utils.js @@ -83,12 +83,18 @@ const discoveryStruct = bcs.struct('Discovery', { fields: discoveryTable, }); -const tableStruct = bcs.struct('Table', { +const bagStruct = bcs.struct('Bag', { id: UID, size: bcs.U64, }); -const bagStruct = bcs.struct('Bag', { +const operatorsStruct = bcs.struct('Operators', { + id: UID, + operators: bcs.vector(addressStruct), + caps: bagStruct, +}); + +const tableStruct = bcs.struct('Table', { id: UID, size: bcs.U64, }); @@ -123,6 +129,7 @@ module.exports = { signerStruct, bytes32Struct, signersStruct, + operatorsStruct, messageToSignStruct, messageStruct, approvedMessageStruct, diff --git a/sui/utils.js b/sui/utils.js index 1ef47c8c..8c7ab88c 100644 --- a/sui/utils.js +++ b/sui/utils.js @@ -145,9 +145,49 @@ const getSigners = async (keypair, config, chain, options) => { return getAmplifierSigners(config, chain); }; +const findOwnedObjectId = async (client, ownerAddress, objectType) => { + const ownedObjects = await client.getOwnedObjects({ + owner: ownerAddress, + options: { + showContent: true, + }, + }); + + const targetObject = ownedObjects.data.find(({ data }) => data.content.type === objectType); + + if (!targetObject) { + throw new Error(`No object found for type: ${objectType}`); + } + + return targetObject.data.content.fields.id.id; +}; + +const getBagContentId = async (client, objectType, bagId, bagName) => { + const result = await client.getDynamicFields({ + parentId: bagId, + name: bagName, + }); + + const objectId = result.data.find((cap) => cap.objectType === objectType)?.objectId; + + if (!objectId) { + throw new Error(`${objectType} not found in the capabilities bag`); + } + + const objectDetails = await client.getObject({ + id: objectId, + options: { + showContent: true, + }, + }); + + return objectDetails.data.content.fields.value.fields.id.id; +}; + module.exports = { suiPackageAddress, suiClockAddress, + findOwnedObjectId, getAmplifierSigners, getBcsBytesByObjectId, deployPackage, @@ -158,4 +198,5 @@ module.exports = { getItsChannelId, getSquidChannelId, getSigners, + getBagContentId, };