Skip to content

Commit cf7ce25

Browse files
committed
implement multisig-compatible batch coin withdrawal with automated SUI buffer management and CI support
1 parent eb5e7c8 commit cf7ce25

File tree

4 files changed

+256
-0
lines changed

4 files changed

+256
-0
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Build Send Coins Batch Transaction
2+
3+
on:
4+
workflow_dispatch:
5+
# Manual trigger remains, but we remove the 'inputs' section
6+
7+
jobs:
8+
build-send-coins-batch-tx:
9+
runs-on: ubuntu-latest
10+
env:
11+
# Multisig specific config (from GitHub Actions variables)
12+
MULTISIG_ADDRESS: ${{ vars.MULTISIG_ADDRESS }}
13+
MULTISIG_SIGNERS_BASE64_PUBKEYS: ${{ vars.MULTISIG_SIGNERS_BASE64_PUBKEYS }}
14+
MULTISIG_THRESHOLD: ${{ vars.MULTISIG_THRESHOLD }}
15+
MULTISIG_WEIGHTS: ${{ vars.MULTISIG_WEIGHTS }}
16+
17+
# Destination address to send coins to
18+
DESTINATION_ADDRESS: ${{ vars.DESTINATION_ADDRESS }}
19+
20+
steps:
21+
- name: Checkout Code
22+
uses: actions/checkout@v4
23+
24+
- name: Setup Node.js
25+
uses: actions/setup-node@v4
26+
with:
27+
node-version: 20
28+
29+
- name: Install Dependencies
30+
run: npm ci
31+
32+
- name: Build Send Coins Batch Transaction
33+
run: npx tsx examples/send-coins/send-coins-batch-to-destination.ts
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { Transaction } from "@mysten/sui/transactions";
2+
import { SUI_COIN_TYPE } from "../constants";
3+
import { provider } from "../provider";
4+
import { MULTISIG_CONFIG } from "../multisig/multisig";
5+
import { formatBalance } from "../utils";
6+
import { CoinGroup } from "./types";
7+
import { buildAndLogMultisigTransaction } from "../multisig/buildAndLogMultisigTransaction";
8+
9+
// Maximum objects per transaction (conservative limit)
10+
const MAX_OBJECTS_PER_PTB = 1500;
11+
const BUFFER_IN_SUI = 3;
12+
const BUFFER_IN_MIST = BUFFER_IN_SUI * 1_000_000_000;
13+
14+
const DESTINATION_ADDRESS = process.env.DESTINATION_ADDRESS;
15+
if (!DESTINATION_ADDRESS) {
16+
throw new Error("DESTINATION_ADDRESS environment variable is required.");
17+
}
18+
19+
// npx tsx examples/send-coins/send-coins-batch-to-destination.ts > logs/send-coins-batch.log 2>&1
20+
(async () => {
21+
console.log(`\n========== SEND ALL COINS TO DESTINATION ==========`);
22+
console.log(`From address: ${MULTISIG_CONFIG.address}`);
23+
console.log(`To address: ${DESTINATION_ADDRESS || "(NOT SET)"}\n`);
24+
25+
// Safety checks
26+
if (!DESTINATION_ADDRESS) {
27+
console.error(`❌ ERROR: DESTINATION_ADDRESS is not set!`);
28+
console.error(`Please edit the script and set DESTINATION_ADDRESS before running.\n`);
29+
process.exit(1);
30+
}
31+
32+
if (DESTINATION_ADDRESS === MULTISIG_CONFIG.address) {
33+
console.error(`❌ ERROR: Destination address is the same as sender address!`);
34+
console.error(`This would be pointless. Please set a different address.\n`);
35+
process.exit(1);
36+
}
37+
38+
// Get initial SUI balance
39+
const initialSuiBalance = await provider.getBalance({
40+
owner: MULTISIG_CONFIG.address,
41+
coinType: SUI_COIN_TYPE,
42+
});
43+
44+
console.log(`Initial SUI balance: ${formatBalance(initialSuiBalance.totalBalance)} SUI`);
45+
console.log(`Initial SUI coin objects: ${initialSuiBalance.coinObjectCount}\n`);
46+
47+
// Fetch all coins for the MULTISIG_CONFIG.address
48+
console.log(`Fetching all coins from sender...`);
49+
50+
const coinGroups: Map<string, { objectIds: string[]; totalBalance: bigint }> = new Map();
51+
let cursor: string | null | undefined = null;
52+
let hasNextPage = true;
53+
54+
while (hasNextPage) {
55+
const response = await provider.getAllCoins({
56+
owner: MULTISIG_CONFIG.address,
57+
cursor,
58+
});
59+
60+
for (const coin of response.data) {
61+
const existing = coinGroups.get(coin.coinType) || { objectIds: [], totalBalance: 0n };
62+
existing.objectIds.push(coin.coinObjectId);
63+
existing.totalBalance += BigInt(coin.balance);
64+
coinGroups.set(coin.coinType, existing);
65+
}
66+
67+
cursor = response.nextCursor;
68+
hasNextPage = response.hasNextPage;
69+
}
70+
71+
console.log(`Found ${coinGroups.size} unique coin types.\n`);
72+
73+
// Separate SUI coins from other coins (SUI will be sent LAST)
74+
let suiCoins: string[] = [];
75+
const nonSuiCoins: CoinGroup[] = [];
76+
77+
for (const [coinType, data] of coinGroups.entries()) {
78+
// Check if this is SUI (handles both 0x2::sui::SUI and full address formats)
79+
const isSuiCoin = coinType === SUI_COIN_TYPE || coinType.endsWith("::sui::SUI") || coinType === "0x2::sui::SUI";
80+
81+
if (!isSuiCoin) {
82+
nonSuiCoins.push({ coinType, objectIds: data.objectIds, totalBalance: data.totalBalance });
83+
} else {
84+
// Collect all SUI coins
85+
suiCoins = data.objectIds;
86+
}
87+
}
88+
89+
// Calculate SUI transfer amount based on buffer
90+
const totalSuiBalance = BigInt(initialSuiBalance.totalBalance);
91+
const suiBuffer = BigInt(BUFFER_IN_MIST);
92+
const amountSuiToSend = totalSuiBalance > suiBuffer ? totalSuiBalance - suiBuffer : 0n;
93+
94+
// Display all coins to be sent
95+
console.log(`========== COINS TO SEND ==========`);
96+
console.log(`Non-SUI coins: ${nonSuiCoins.length} types`);
97+
for (const { coinType, objectIds, totalBalance } of nonSuiCoins) {
98+
const coinSymbol = coinType.split("::").pop() || "UNKNOWN";
99+
console.log(` ${coinSymbol}: ${objectIds.length} objects, Total: ${formatBalance(totalBalance)}`);
100+
}
101+
102+
console.log(`\nSUI balance:`);
103+
console.log(` Total: ${formatBalance(totalSuiBalance)} SUI`);
104+
console.log(` Buffer: ${formatBalance(suiBuffer)} SUI (to remain in wallet)`);
105+
if (amountSuiToSend > 0n) {
106+
console.log(` To send: ${formatBalance(amountSuiToSend)} SUI`);
107+
} else {
108+
console.log(` To send: 0 SUI (balance <= buffer)`);
109+
}
110+
console.log(``);
111+
112+
if (nonSuiCoins.length === 0 && amountSuiToSend === 0n) {
113+
console.log(`No coins found to send. Exiting.`);
114+
return;
115+
}
116+
117+
// Confirm before proceeding
118+
console.log(`⚠️ WARNING: This will prepare transactions to send coins to ${DESTINATION_ADDRESS}`);
119+
120+
console.log(`\n========== BUILDING TRANSACTION PTBs ==========\n`);
121+
122+
// ========== STEP 1: Build PTBs (Non-SUI + SUI in the last batch) ==========
123+
const ptbs: { tx: Transaction; description: string }[] = [];
124+
125+
let currentTx = new Transaction();
126+
let currentObjectCount = 0;
127+
let coinsInCurrentBatch = 0;
128+
let ptbNumber = 0;
129+
130+
// Process non-SUI coins first
131+
if (nonSuiCoins.length > 0) {
132+
for (const { coinType, objectIds } of nonSuiCoins) {
133+
// Check if adding this coin type would exceed limit
134+
if (currentObjectCount + objectIds.length > MAX_OBJECTS_PER_PTB && coinsInCurrentBatch > 0) {
135+
// Save current PTB and start a new one
136+
ptbNumber++;
137+
ptbs.push({
138+
tx: currentTx,
139+
description: `PTB #${ptbNumber} (${coinsInCurrentBatch} types, ${currentObjectCount} objects)`,
140+
});
141+
142+
currentTx = new Transaction();
143+
currentObjectCount = 0;
144+
coinsInCurrentBatch = 0;
145+
}
146+
147+
// Add all objects of this coin type to current PTB
148+
currentTx.transferObjects(
149+
objectIds.map((id) => currentTx.object(id)),
150+
currentTx.pure.address(DESTINATION_ADDRESS),
151+
);
152+
153+
currentObjectCount += objectIds.length;
154+
coinsInCurrentBatch++;
155+
}
156+
}
157+
158+
// Add SUI transfer to the current/last PTB if needed
159+
if (amountSuiToSend > 0n) {
160+
const [coinToSend] = currentTx.splitCoins(currentTx.gas, [currentTx.pure.u64(amountSuiToSend)]);
161+
currentTx.transferObjects([coinToSend], currentTx.pure.address(DESTINATION_ADDRESS));
162+
}
163+
164+
// Push the final PTB
165+
if (coinsInCurrentBatch > 0 || amountSuiToSend > 0n) {
166+
ptbNumber++;
167+
const description =
168+
amountSuiToSend > 0n
169+
? `Final PTB #${ptbNumber} (${coinsInCurrentBatch} types + SUI transfer)`
170+
: `Final PTB #${ptbNumber} (${coinsInCurrentBatch} types)`;
171+
172+
ptbs.push({
173+
tx: currentTx,
174+
description,
175+
});
176+
}
177+
178+
console.log(`Built ${ptbs.length} PTB(s) total.\n`);
179+
180+
// ========== STEP 2: Generate Transaction Bytes ==========
181+
if (ptbs.length > 0) {
182+
console.log(`========== GENERATING TRANSACTION BYTES ==========\n`);
183+
184+
let successfulBatches = 0;
185+
let failedBatches = 0;
186+
187+
for (let i = 0; i < ptbs.length; i++) {
188+
const { tx, description } = ptbs[i];
189+
console.log(`Processing ${description}...`);
190+
191+
try {
192+
await buildAndLogMultisigTransaction(tx);
193+
successfulBatches++;
194+
} catch (error) {
195+
console.error(` ✗ Failed:`);
196+
console.error(` Error: ${error instanceof Error ? error.message : String(error)}\n`);
197+
failedBatches++;
198+
}
199+
}
200+
201+
console.log(`\nSummary: ${successfulBatches} transactions prepared, ${failedBatches} failed\n`);
202+
} else {
203+
console.log(`No transactions needed.\n`);
204+
}
205+
206+
// Final summary
207+
console.log(`\n========== PREPARATION COMPLETE ==========`);
208+
console.log(`All transactions have been built and validated.`);
209+
console.log(`Please follow the multisig steps logged above to sign and execute them.`);
210+
console.log(`\n========================================\n`);
211+
})();

examples/send-coins/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export interface CoinGroup {
2+
coinType: string;
3+
objectIds: string[];
4+
totalBalance: bigint;
5+
}

examples/utils.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,10 @@ export function normalizeMnemonic(mnemonic: string): string {
4646
export function percentageInBillionths(percentage: number) {
4747
return (percentage / 100) * 1_000_000_000;
4848
}
49+
50+
// Helper function to format balance with decimals
51+
export function formatBalance(balance: string | number | bigint, decimals: number = 9): string {
52+
const balanceBigInt = BigInt(balance);
53+
const amount = Number(balanceBigInt) / 10 ** decimals;
54+
return amount.toFixed(decimals).replace(/\.?0+$/, "");
55+
}

0 commit comments

Comments
 (0)