Skip to content

Commit

Permalink
feat: add script to merge and split coins (#255)
Browse files Browse the repository at this point in the history
Co-authored-by: Blockchain Guy <[email protected]>
Co-authored-by: Milap Sheth <[email protected]>
  • Loading branch information
3 people authored Aug 21, 2024
1 parent 740cdf0 commit c32f90d
Show file tree
Hide file tree
Showing 3 changed files with 282 additions and 1 deletion.
26 changes: 26 additions & 0 deletions sui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,3 +300,29 @@ node sui/transfer-object.js --objectId <object id to be transferred> --recipient

node sui/transfer-object.js --contractName <Can be checked from config> --objectName <picked from config> --recipient <recipient address>
```

## Coins Management

List of coins in the wallet:

```bash
node sui/tokens.js list
```

Merge the coins:

```bash
node sui/tokens.js merge --coin-type <coin type to merge>
```

If coin type is not provided, it will merge all the coins.

Split the coins:

```bash
node sui/tokens.js split --amount <amount> --coin-type <coin type to split> --transfer <recipient address>
```

Note:
- If coin type is not provided, it will split all the coins.
- If transfer address is not provided, it will split the coins in the same wallet. Otherwise, it will transfer the splitted coins to the provided address.
226 changes: 226 additions & 0 deletions sui/tokens.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
const { Transaction } = require('@mysten/sui/transactions');
const { Command } = require('commander');
const { loadConfig, saveConfig, printInfo, printError } = require('../common/');
const {
addBaseOptions,
parseSuiUnitAmount,
addOptionsToCommands,
broadcast,
getWallet,
suiCoinId,
isGasToken,
paginateAll,
} = require('./utils');
const {
utils: { formatUnits },
} = require('ethers');

class CoinManager {
static async getAllCoins(client, account) {
const coinTypeToCoins = {};

try {
// Fetch all coins using pagination
const coins = await paginateAll(client, 'getAllCoins', { owner: account });

// Iterate over each coin and organize them by coin type
for (const coin of coins) {
const coinsByType = coinTypeToCoins[coin.coinType] || {
data: [],
totalBalance: 0n,
};

coinsByType.data.push(coin);
coinsByType.totalBalance += BigInt(coin.balance);

coinTypeToCoins[coin.coinType] = coinsByType;
}
} catch (e) {
printError('Failed to fetch coins', e.message);
}

return coinTypeToCoins;
}

static async printCoins(client, coinTypeToCoins) {
for (const coinType in coinTypeToCoins) {
const coins = coinTypeToCoins[coinType];

const metadata = await client.getCoinMetadata({
coinType,
});

if (!metadata) {
printError('No metadata found for', coinType);
process.exit(0);
}

printInfo('Coin Type', coinType);
printInfo('Total Balance', `${formatUnits(coins.totalBalance.toString(), metadata.decimals).toString()}`);
printInfo('Total Objects', coins.data.length);

for (const coin of coins.data) {
printInfo(`- ${formatUnits(coin.balance, metadata.decimals)}`);
}
}
}

static async splitCoins(tx, client, coinTypeToCoins, walletAddress, args, options) {
const coinType = options.coinType || suiCoinId;
const splitAmount = args.splitAmount;

const metadata = await client.getCoinMetadata({
coinType,
});

if (!metadata) {
printError('No metadata found for', coinType);
process.exit(0);
}

const objectToSplit = isGasToken(coinType)
? tx.gas
: coinTypeToCoins[coinType].data.find((coinObject) => BigInt(coinObject.balance) >= splitAmount)?.coinObjectId;

if (!objectToSplit) {
printError('No coin object found with enough balance to split');
process.exit(0);
}

const [coin] = tx.splitCoins(objectToSplit, [splitAmount]);

printInfo('Split Coins', coinType);
printInfo('Split Amount', `${formatUnits(splitAmount, metadata.decimals).toString()}`);

if (options.transfer) {
tx.transferObjects([coin], options.transfer);
printInfo('Transfer Coins to', options.transfer);
} else {
tx.transferObjects([coin], walletAddress);
}

// The transaction will fail if the gas budget is not set for splitting coins transaction
tx.setGasBudget(1e7);
}

static async mergeCoins(tx, coinTypeToCoins, options) {
const coinTypes = options.coinType ? [options.coinType] : Object.keys(coinTypeToCoins);

let merged = false;

for (const coinType of coinTypes) {
const coins = coinTypeToCoins[coinType];

if (!coins) {
throw new Error(`No coins found for coin type ${coinType}`);
}

const coinObjectIds = coins.data.map((coin) => coin.coinObjectId);

// If the first coin is a gas token, remove it from the list. Otherwise, the merge will fail.
if (isGasToken(coins.data[0].coinType)) {
coinObjectIds.shift();
}

if (coinObjectIds.length < 2) {
// Need at least 2 coins to merge
continue;
}

const firstCoin = coinObjectIds.shift();
const remainingCoins = coinObjectIds.map((id) => tx.object(id));

tx.mergeCoins(firstCoin, remainingCoins);
merged = true;

printInfo('Merge Coins', coins.data[0].coinType);
}

return merged;
}
}

async function processSplitCommand(keypair, client, args, options) {
printInfo('Action', 'Split Coins');

const coinTypeToCoins = await CoinManager.getAllCoins(client, keypair.toSuiAddress());

const tx = new Transaction();

await CoinManager.splitCoins(tx, client, coinTypeToCoins, keypair.toSuiAddress(), args, options);

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

printInfo('Splitted Coins', receipt.digest);
}

async function processMergeCommand(keypair, client, args, options) {
printInfo('Action', 'Merge Coins');
const coinTypeToCoins = await CoinManager.getAllCoins(client, keypair.toSuiAddress());

const tx = new Transaction();
const hasMerged = await CoinManager.mergeCoins(tx, coinTypeToCoins, options);

if (!hasMerged) {
printInfo('No coins to merge');
return;
}

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

printInfo('Merged Coins', receipt.digest);
}

async function processListCommand(keypair, client, args, options) {
printInfo('Action', 'List Coins');
printInfo('Wallet Address', keypair.toSuiAddress());

const coinTypeToCoins = await CoinManager.getAllCoins(client, keypair.toSuiAddress());
await CoinManager.printCoins(client, coinTypeToCoins);
}

async function mainProcessor(options, processor, args = {}) {
const config = loadConfig(options.env);
const [keypair, client] = getWallet(config.sui, options);
await processor(keypair, client, args, options);
saveConfig(config, options.env);
}

if (require.main === module) {
// Main program
const program = new Command('tokens').description('Merge, split, and list coins.');

// Sub-programs
const mergeProgram = new Command('merge').description('Merge all coins into a single object');
const splitProgram = new Command('split').description(
'Split coins into a new object. If no coin type is specified, SUI coins will be used by default.',
);
const listProgram = new Command('list').description('List all coins and balances');

// Define options, arguments, and actions for each sub-program
mergeProgram.option('--coin-type <coinType>', 'Coin type to merge').action((options) => {
mainProcessor(options, processMergeCommand);
});

splitProgram
.argument('<amount>', 'Amount should be in the full coin unit (e.g. 1.5 for 1_500_000_000 coins)', parseSuiUnitAmount)
.option('--transfer <recipientAddress>', 'Used with split command to transfer the split coins to the recipient address')
.option('--coin-type <coinType>', 'Coin type to split')
.action((splitAmount, options) => {
mainProcessor(options, processSplitCommand, { splitAmount });
});

listProgram.action((options) => {
mainProcessor(options, processListCommand);
});

// Add sub-programs to the main program
program.addCommand(mergeProgram);
program.addCommand(splitProgram);
program.addCommand(listProgram);

// Add base options to all sub-programs
addOptionsToCommands(program, addBaseOptions);

program.parse();
}
31 changes: 30 additions & 1 deletion sui/utils/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const { singletonStruct, itsStruct, squidStruct } = require('./types-utils');

const suiPackageAddress = '0x2';
const suiClockAddress = '0x6';
const suiCoinId = '0x2::sui::SUI';

const getAmplifierSigners = async (config, chain) => {
const client = await CosmWasmClient.connect(config.axelar.rpc);
Expand Down Expand Up @@ -148,6 +149,31 @@ const getSigners = async (keypair, config, chain, options) => {
return getAmplifierSigners(config, chain);
};

const isGasToken = (coinType) => {
return coinType === suiCoinId;
};

const paginateAll = async (client, paginatedFn, params, pageLimit = 100) => {
let cursor;
let response = await client[paginatedFn]({
...params,
cursor,
limit: pageLimit,
});
const items = response.data;

while (response.hasNextPage) {
response = await client[paginatedFn]({
...params,
cursor: response.nextCursor,
limit: pageLimit,
});
items.push(...response.data);
}

return items;
};

const findOwnedObjectId = async (client, ownerAddress, objectType) => {
const ownedObjects = await client.getOwnedObjects({
owner: ownerAddress,
Expand Down Expand Up @@ -188,10 +214,13 @@ const getBagContentId = async (client, objectType, bagId, bagName) => {
};

module.exports = {
suiCoinId,
getAmplifierSigners,
isGasToken,
paginateAll,
suiPackageAddress,
suiClockAddress,
findOwnedObjectId,
getAmplifierSigners,
getBcsBytesByObjectId,
deployPackage,
findPublishedObject,
Expand Down

0 comments on commit c32f90d

Please sign in to comment.