Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add gas service commands #296

Merged
merged 23 commits into from
Jul 18, 2024
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions sui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,21 +51,21 @@ node sui/faucet.js

Deploy the gateway package:

- By querying the signer set from the Amplifier contract (this only works if Amplifier contracts have been setup):
- By querying the signer set from the Amplifier contract (this only works if Amplifier contracts have been setup):

```bash
node sui/deploy-gateway.js
```

Use `--help` flag to see other setup params that can be overridden.

- For testing convenience, you can use the secp256k1 wallet as the signer set for the gateway.
- For testing convenience, you can use the secp256k1 wallet as the signer set for the gateway.

```bash
node sui/deploy-gateway.js --signers wallet --nonce test
```

- You can also provide a JSON object with a full signer set:
- You can also provide a JSON object with a full signer set:

```bash
node sui/deploy-gateway.js -e testnet --signers '{"signers": [{"pubkey": "0x020194ead85b350d90472117e6122cf1764d93bf17d6de4b51b03d19afc4d6302b", "weight": 1}], "threshold": 1, "nonce": "0x0000000000000000000000000000000000000000000000000000000000000000"}'
Expand All @@ -89,6 +89,14 @@ Call Contract:
node sui/gateway.js call-contract ethereum 0xba76c6980428A0b10CFC5d8ccb61949677A61233 0x1234
```

Pay for gas:

The syntax is `node sui/gas-service.js payGas --amount <amount> <destinationChain> <destinationAddress> <channelId> <payload>`

```bash
node sui/gas-service.js payGas --amount 0.1 ethereum 0x6f24A47Fc8AE5441Eb47EFfC3665e70e69Ac3F05 0xba76c6980428A0b10CFC5d8ccb61949677A61233 0x1234
```

Approve messages:

If the gateway was deployed using the wallet, you can submit a message approval with it
Expand Down Expand Up @@ -189,7 +197,7 @@ example for adding multisig info to chains config:
"publicKey": "AIqrCb324p6Qd4srkqCzn9NJHS7W17tA7r3t7Ur6aYN",
"weight": 1,
"schemeType": "ed25519"
},
},
.
.
.
Expand Down
16 changes: 16 additions & 0 deletions sui/amount-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const { ethers } = require('ethers');

// Convert formatted amount to atomic units (e.g. 1000000000). Default decimals is 9 for SUI
function getUnitAmount(amount, decimals = 9) {
return ethers.utils.parseUnits(amount, decimals).toBigInt();
}

// Convert atomic amount to formatted units (e.g. 1.0) with decimals. Default decimals is 9 for SUI
function getFormattedAmount(amount, decimals = 9) {
return ethers.utils.formatUnits(amount, decimals);
}

module.exports = {
getUnitAmount,
getFormattedAmount,
};
22 changes: 22 additions & 0 deletions sui/cli-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require('dotenv').config();

const { Option } = require('commander');
const { getUnitAmount } = require('./amount-utils');

const addBaseOptions = (program, options = {}) => {
program.addOption(
Expand Down Expand Up @@ -51,7 +52,28 @@ const addExtendedOptions = (program, options = {}) => {
return program;
};

// `optionMethod` is a method such as `addBaseOptions`
// `options` is an option object for optionMethod
const addOptionsToCommands = (program, optionMethod, options) => {
if (program.commands.length > 0) {
npty marked this conversation as resolved.
Show resolved Hide resolved
program.commands.forEach((command) => {
optionMethod(command, options);
});
}

optionMethod(program, options);
};

// Custom option processing for amount. https://github.com/tj/commander.js?tab=readme-ov-file#custom-option-processing
// The user is expected to pass a full amount (e.g. 1.0), and this option parser will convert it to smallest units (e.g. 1000000000).
// Note that this function will use decimals of 9 for SUI. So, other tokens with different decimals will not work.
const parseSuiUnitAmount = (value, previous) => {
return getUnitAmount(value);
npty marked this conversation as resolved.
Show resolved Hide resolved
};

module.exports = {
addBaseOptions,
addExtendedOptions,
addOptionsToCommands,
parseSuiUnitAmount,
};
219 changes: 219 additions & 0 deletions sui/gas-service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
const { saveConfig, printInfo, printError } = require('../evm/utils');
const { Command } = require('commander');
const { TransactionBlock } = require('@mysten/sui.js/transactions');
const { bcs } = require('@mysten/sui.js/bcs');
const { gasServiceStruct } = require('./types-utils');
const { loadSuiConfig, getBcsBytesByObjectId } = require('./utils');
const { ethers } = require('hardhat');
const { getFormattedAmount } = require('./amount-utils');
const {
utils: { arrayify },
} = ethers;

const { addOptionsToCommands, addBaseOptions, parseSuiUnitAmount } = require('./cli-utils');
const { getWallet, printWalletInfo, broadcast } = require('./sign-utils');

async function payGas(keypair, client, gasServiceConfig, args, options) {
const walletAddress = keypair.toSuiAddress();

const gasServicePackageId = gasServiceConfig.address;

const refundAddress = options.refundAddress || walletAddress;
const params = options.params || '0x';

const [destinationChain, destinationAddress, channelId, payload] = args;
const unitAmount = options.amount;

const tx = new TransactionBlock();
const [coin] = tx.splitCoins(tx.gas, [unitAmount]);

tx.moveCall({
target: `${gasServicePackageId}::gas_service::pay_gas`,
arguments: [
tx.object(gasServiceConfig.objects.GasService),
coin, // Coin<SUI>
tx.pure.address(channelId), // Channel address
tx.pure(bcs.string().serialize(destinationChain).toBytes()), // Destination chain
tx.pure(bcs.string().serialize(destinationAddress).toBytes()), // Destination address
tx.pure(bcs.vector(bcs.u8()).serialize(arrayify(payload)).toBytes()), // Payload
tx.pure.address(refundAddress), // Refund address
tx.pure(bcs.vector(bcs.u8()).serialize(arrayify(params)).toBytes()), // Params
],
});

const receipt = await broadcast(client, keypair, tx);

printInfo('Gas paid', receipt.digest);
}

async function addGas(keypair, client, gasServiceConfig, args, options) {
const walletAddress = keypair.toSuiAddress();

const gasServicePackageId = gasServiceConfig.address;

const refundAddress = options.refundAddress || walletAddress;
const params = options.params || '0x';

const [messageId] = args;
const unitAmount = options.amount;

const tx = new TransactionBlock();
const [coin] = tx.splitCoins(tx.gas, [unitAmount]);

tx.moveCall({
target: `${gasServicePackageId}::gas_service::add_gas`,
arguments: [
tx.object(gasServiceConfig.objects.GasService),
coin, // Coin<SUI>
tx.pure(bcs.string().serialize(messageId).toBytes()), // Message ID for the contract call
tx.pure.address(refundAddress), // Refund address
tx.pure(bcs.vector(bcs.u8()).serialize(arrayify(params)).toBytes()), // Params
],
});

const receipt = await broadcast(client, keypair, tx);

printInfo('Gas added', receipt.digest);
}

async function collectGas(keypair, client, gasServiceConfig, args, options) {
const walletAddress = keypair.toSuiAddress();

const gasServicePackageId = gasServiceConfig.address;
const gasServiceObjectId = gasServiceConfig.objects.GasService;

const unitAmount = options.amount;
const receiver = options.receiver || walletAddress;

const bytes = await getBcsBytesByObjectId(client, gasServiceObjectId);
const { balance: gasServiceBalance } = gasServiceStruct.parse(bytes);

// Check if the gas service balance is sufficient
if (gasServiceBalance < unitAmount) {
printError('Insufficient gas service balance', `${getFormattedAmount(gasServiceBalance)} < ${getFormattedAmount(unitAmount)}`);
return;
}

const tx = new TransactionBlock();

tx.moveCall({
target: `${gasServicePackageId}::gas_service::collect_gas`,
arguments: [
tx.object(gasServiceConfig.objects.GasService),
tx.object(gasServiceConfig.objects.GasCollectorCap),
tx.pure.address(receiver), // Receiver address
tx.pure.u64(unitAmount), // Amount
],
});

const receipt = await broadcast(client, keypair, tx);

printInfo('Gas collected', receipt.digest);
}

async function refund(keypair, client, gasServiceConfig, args, options) {
const walletAddress = keypair.toSuiAddress();

const gasServicePackageId = gasServiceConfig.address;
const gasServiceObjectId = gasServiceConfig.objects.GasService;

const [messageId] = args;
const unitAmount = options.amount;
const receiver = options.receiver || walletAddress;

const bytes = await getBcsBytesByObjectId(client, gasServiceObjectId);
const { balance: gasServiceBalance } = gasServiceStruct.parse(bytes);

// Check if the gas service balance is sufficient
if (gasServiceBalance < unitAmount) {
printError('Insufficient gas service balance', `${getFormattedAmount(gasServiceBalance)} < ${getFormattedAmount(unitAmount)}`);
return;
}

const tx = new TransactionBlock();
tx.moveCall({
target: `${gasServicePackageId}::gas_service::refund`,
arguments: [
tx.object(gasServiceConfig.objects.GasService),
tx.object(gasServiceConfig.objects.GasCollectorCap),
tx.pure(bcs.string().serialize(messageId).toBytes()), // Message ID for the contract call
tx.pure.address(receiver), // Refund address
tx.pure.u64(unitAmount), // Amount
],
});

const receipt = await broadcast(client, keypair, tx);

printInfo('Gas refunded', receipt.digest);
}

async function processCommand(command, chain, args, options) {
const [keypair, client] = getWallet(chain, options);
npty marked this conversation as resolved.
Show resolved Hide resolved

await printWalletInfo(keypair, client, chain, options);

if (!chain.contracts.GasService) {
throw new Error('GasService contract not found');
}

await command(keypair, client, chain.contracts.GasService, args, options);
}

async function mainProcessor(options, args, processor, command) {
const config = loadSuiConfig(options.env);
await processor(command, config.sui, args, options);
saveConfig(config, options.env);
}

if (require.main === module) {
const program = new Command();

program.name('gas-service').description('Interact with the gas service contract.');

const payGasCmd = new Command()
.command('payGas <destinationChain> <destinationAddress> <channelId> <payload>')
.description('Pay gas for the new contract call.')
.option('--refundAddress <refundAddress>', 'Refund address. Default is the sender address.')
.requiredOption('--amount <amount>', 'Amount to pay gas', parseSuiUnitAmount)
.option('--params <params>', 'Params. Default is empty.')
npty marked this conversation as resolved.
Show resolved Hide resolved
.action((destinationChain, destinationAddress, channelId, payload, options) => {
mainProcessor(options, [destinationChain, destinationAddress, channelId, payload], processCommand, payGas);
});

const addGasCmd = new Command()
.command('addGas <message_id>')
.description('Add gas for the existing contract call.')
.option('--refundAddress <refundAddress>', 'Refund address.')
.requiredOption('--amount <amount>', 'Amount to add gas', parseSuiUnitAmount)
.option('--params <params>', 'Params. Default is empty.')
.action((messageId, options) => {
mainProcessor(options, [messageId], processCommand, addGas);
});

const collectGasCmd = new Command()
.command('collectGas')
.description('Collect gas from the gas service contract.')
.option('--receiver <receiver>', 'Receiver address. Default is the sender address.')
npty marked this conversation as resolved.
Show resolved Hide resolved
.requiredOption('--amount <amount>', 'Amount to collect gas', parseSuiUnitAmount)
.action((options) => {
mainProcessor(options, [], processCommand, collectGas);
});

const refundCmd = new Command()
.command('refund <messageId>')
.description('Refund gas from the gas service contract.')
.option('--receiver <receiver>', 'Receiver address. Default is the sender address.')
.requiredOption('--amount <amount>', 'Amount to refund gas', parseSuiUnitAmount)
.action((messageId, options) => {
mainProcessor(options, [messageId], processCommand, refund);
});

program.addCommand(payGasCmd);
program.addCommand(addGasCmd);
program.addCommand(collectGasCmd);
program.addCommand(refundCmd);

addOptionsToCommands(program, addBaseOptions);

program.parse();
}
12 changes: 12 additions & 0 deletions sui/types-utils.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

const { bcs } = require('@mysten/sui.js/bcs');
const { fromHEX, toHEX } = require('@mysten/bcs');
const { ethers } = require('hardhat');
const {
utils: { arrayify, hexlify },
Expand All @@ -21,6 +22,11 @@ const bytes32Struct = bcs.fixedArray(32, bcs.u8()).transform({
output: (id) => hexlify(id),
});

const UID = bcs.fixedArray(32, bcs.u8()).transform({
npty marked this conversation as resolved.
Show resolved Hide resolved
input: (id) => fromHEX(id),
output: (id) => toHEX(Uint8Array.from(id)),
});

const signersStruct = bcs.struct('WeightedSigners', {
signers: bcs.vector(signerStruct),
threshold: bcs.u128(),
Expand All @@ -46,6 +52,11 @@ const proofStruct = bcs.struct('Proof', {
signatures: bcs.vector(bcs.vector(bcs.u8())),
});

const gasServiceStruct = bcs.struct('GasService', {
npty marked this conversation as resolved.
Show resolved Hide resolved
id: UID,
balance: bcs.u64(),
});

module.exports = {
addressStruct,
signerStruct,
Expand All @@ -54,4 +65,5 @@ module.exports = {
messageToSignStruct,
messageStruct,
proofStruct,
gasServiceStruct,
};
14 changes: 14 additions & 0 deletions sui/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const {
BigNumber,
utils: { arrayify, hexlify },
} = ethers;
const { fromB64 } = require('@mysten/bcs');
const { CosmWasmClient } = require('@cosmjs/cosmwasm-stargate');

const getAmplifierSigners = async (config, chain) => {
Expand All @@ -31,6 +32,18 @@ const getAmplifierSigners = async (config, chain) => {
};
};

// Given sui client and object id, return the base64-decoded object bcs bytes
const getBcsBytesByObjectId = async (client, objectId) => {
const response = await client.getObject({
id: objectId,
options: {
showBcs: true,
},
});

return fromB64(response.data.bcs.bcsBytes);
};

const loadSuiConfig = (env) => {
const config = loadConfig(env);
const suiEnv = env === 'local' ? 'localnet' : env;
Expand All @@ -55,6 +68,7 @@ const findPublishedObject = (published, packageName, contractName) => {

module.exports = {
getAmplifierSigners,
getBcsBytesByObjectId,
loadSuiConfig,
findPublishedObject,
};
Loading