From aa012d3d4f0e7681064787fdab828a45baa0f60a Mon Sep 17 00:00:00 2001 From: Shrom Date: Sat, 8 Aug 2026 02:07:56 +0530 Subject: [PATCH 1/2] feat(scripts): recover stray SUI from the stSUI CollectionFeeCap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add scripts/receiveStray.ts to pull the 1513.878 SUI mistakenly sent to CollectionFeeCap 0x019466989a. Guards on receive_stray existing, the cap owner signing, and the coin still being unclaimed, so it no-ops until the liquid_staking upgrade lands. Port scripts/utils.ts to SuiGrpcClient: public fullnodes no longer serve JSON-RPC, and 2.22.0 dropped SuiClient/fromB64. Fix scripts/test.ts against current src signatures — explicit lstInfo and lstCoinType params, setValidators/updateFee renames, required typeName on event queries. --- scripts/receiveStray.ts | 148 ++++++++++++++++++++++++++++++++++++++++ scripts/test.ts | 57 +++++++++++----- scripts/utils.ts | 100 ++++++++++++++++----------- 3 files changed, 248 insertions(+), 57 deletions(-) create mode 100644 scripts/receiveStray.ts diff --git a/scripts/receiveStray.ts b/scripts/receiveStray.ts new file mode 100644 index 0000000..f00680d --- /dev/null +++ b/scripts/receiveStray.ts @@ -0,0 +1,148 @@ +/** + * Recovers the 1513.878129304 SUI mistakenly transferred to the stSUI CollectionFeeCap. + * + * The coin is owned by AddressOwner(), so it can only be pulled out by + * `liquid_staking::receive_stray` — added in AlphaFiTech/alpha#bugfix/lst-receive-stray. Until + * that upgrade is live the script reports and stops; it checks the function exists before + * building anything. + * + * npx tsx scripts/receiveStray.ts # dry run + * npx tsx scripts/receiveStray.ts --execute # sign and submit + * RECIPIENT=0x… npx tsx scripts/receiveStray.ts # send somewhere other than the signer + * + * The cap is address-owned, so PK_B64 must be the cap owner's key regardless of RECIPIENT. + */ +import { Transaction } from "@mysten/sui/transactions"; +import { getConf } from "../src/index.ts"; +import { + dryRunTransactionBlock, + executeTransactionBlock, + getExecStuff, + getGrpcClient, +} from "./utils.ts"; + +const MODULE = "liquid_staking"; +const FUNCTION = "receive_stray"; + +const CAP = "0x019466989adf3cf8320f8e7ab45a44c6f8ce9688125852b60c82475d2d2f9849"; +const STRAY_COIN = + "0xb2bedbc05a022505fb1c8564e39c9f8f19e176f20563197f8abe2c9cc9fc99aa"; +const STRAY_TYPE = "0x2::coin::Coin<0x2::sui::SUI>"; + +/** + * Builds a PTB that receives `stray` out of `collectionFeeCap` and transfers it to `address`. + * + * `receive_stray` returns the object rather than transferring it, so the destination is decided + * here instead of being baked into the package. + */ +export function buildReceiveStrayTx( + packageId: string, + collectionFeeCap: string, + lstCoinType: string, + stray: { objectId: string; version: string; digest: string; type: string }, + address: string, +): Transaction { + const txb = new Transaction(); + + const [received] = txb.moveCall({ + target: `${packageId}::${MODULE}::${FUNCTION}`, + typeArguments: [lstCoinType, stray.type], + arguments: [ + txb.object(collectionFeeCap), + txb.receivingRef({ + objectId: stray.objectId, + version: stray.version, + digest: stray.digest, + }), + ], + }); + + txb.transferObjects([received], address); + return txb; +} + +async function main() { + const execute = process.argv.includes("--execute"); + const packageId = process.env.PACKAGE_ID ?? getConf().STSUI_LATEST_PACKAGE_ID; + const { address: signer } = getExecStuff(); + const recipient = process.env.RECIPIENT ?? signer; + const client = getGrpcClient(); + + // Fetched, never hardcoded: receivingRef needs the object's current version and digest. + const { objects } = await client.core.getObjects({ + objectIds: [CAP, STRAY_COIN], + include: { json: true }, + }); + const [cap, coin] = objects; + if (cap instanceof Error) throw new Error(`cap: ${cap.message}`); + if (coin instanceof Error) throw new Error(`stray coin: ${coin.message}`); + + const owner = + cap.owner.$kind === "AddressOwner" ? cap.owner.AddressOwner : undefined; + const coinOwner = + coin.owner.$kind === "AddressOwner" ? coin.owner.AddressOwner : undefined; + if (coinOwner !== CAP) { + console.log(`already recovered — ${STRAY_COIN} is now owned by ${coinOwner}`); + return; + } + + const lstCoinType = cap.type.slice( + cap.type.indexOf("<") + 1, + cap.type.lastIndexOf(">"), + ); + + console.log(`package : ${packageId}`); + console.log(`cap : ${CAP} (owner ${owner})`); + console.log(`stray : ${STRAY_COIN} v${coin.version}`); + console.log(`amount : ${(coin.json as { balance: string }).balance} MIST`); + console.log(`type arg : ${lstCoinType}`); + console.log(`signer : ${signer}`); + console.log(`recipient: ${recipient}`); + + try { + await client.core.getMoveFunction({ + packageId, + moduleName: MODULE, + name: FUNCTION, + }); + } catch { + console.log( + `\nNOT RECOVERABLE YET — ${packageId} has no ${MODULE}::${FUNCTION}. ` + + `Land the package upgrade, then bump STSUI_LATEST_PACKAGE_ID in src/common/ids.ts.`, + ); + return; + } + + if (owner !== signer) { + console.log( + `\nWRONG SIGNER — the cap is owned by ${owner}, PK_B64 resolves to ${signer}.`, + ); + return; + } + + const txb = buildReceiveStrayTx( + packageId, + CAP, + lstCoinType, + { + objectId: STRAY_COIN, + version: coin.version, + digest: coin.digest, + type: STRAY_TYPE, + }, + recipient, + ); + + if (execute) { + console.log("\nexecuting…"); + await executeTransactionBlock(txb); + } else { + console.log("\ndry run (pass --execute to submit):"); + await dryRunTransactionBlock(txb, signer); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/test.ts b/scripts/test.ts index 9f54246..d723cfd 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -1,16 +1,13 @@ -import { exec } from "child_process"; import { stSuiExchangeRate } from "../src/common/utils.ts"; import { getConf, create_lst, - set_validators, + setValidators as setValidatorsTx, mint as mintStsui, redeem, collect_fee, refresh, - updateFees, - getFees, - FeeConfig, + updateFee, Events, fetchStSuiAPR, fetchStSuiAPY, @@ -32,7 +29,7 @@ async function createLst() { 1, 600, 10000, - { address }, + address, ); // dryRunTransactionBlock(txb); executeTransactionBlock(txb); @@ -41,7 +38,10 @@ async function createLst() { //createLst(); async function setValidators() { - const txb = await set_validators( + const txb = await setValidatorsTx( + getConf().LST_INFO, + getConf().ADMIN_CAP, + getConf().STSUI_COIN_TYPE, ["0xcb7efe4253a0fe58df608d8a2d3c0eea94b4b40a8738c8daae4eb77830c16cd7"], [100], ); @@ -53,7 +53,12 @@ async function setValidators() { async function mint() { const { address } = getExecStuff(); - const txb = await mintStsui("1000000000", { address }); + const txb = await mintStsui( + getConf().LST_INFO, + getConf().STSUI_COIN_TYPE, + "1000000000", + address, + ); if (txb) { // dryRunTransactionBlock(txb); executeTransactionBlock(txb); @@ -63,7 +68,12 @@ async function mint() { async function redeemstsui() { const { address } = getExecStuff(); - const txb = await redeem("100899000", { address }); + const txb = await redeem( + getConf().LST_INFO, + getConf().STSUI_COIN_TYPE, + "100899000", + address, + ); if (txb) { // dryRunTransactionBlock(txb); executeTransactionBlock(txb); @@ -73,7 +83,12 @@ async function redeemstsui() { async function collectFee() { const { address } = getExecStuff(); - const txb = await collect_fee({ address }); + const txb = await collect_fee( + getConf().LST_INFO, + getConf().STSUI_COIN_TYPE, + getConf().COLLECTION_FEE_CAP_ID, + address, + ); if (txb) { executeTransactionBlock(txb); } @@ -81,13 +96,13 @@ async function collectFee() { // collectFee(); async function xrate() { - console.log(await stSuiExchangeRate()); - // console.log((await getFees()) as FeeConfig); + console.log(await stSuiExchangeRate(getConf().LST_INFO, true)); + // console.log(await getFees(getConf().LST_INFO, true)); } // xrate(); async function refreshh() { - const txb = await refresh(); + const txb = await refresh(getConf().LST_INFO, getConf().STSUI_COIN_TYPE); if (txb) { executeTransactionBlock(txb); // dryRunTransactionBlock(txb); @@ -96,7 +111,15 @@ async function refreshh() { // refreshh(); async function update_fee() { - const txb = await updateFees(0, 2, 1000, 10000); + const txb = await updateFee( + getConf().LST_INFO, + getConf().ADMIN_CAP, + getConf().STSUI_COIN_TYPE, + 0, + 2, + 1000, + 10000, + ); if (txb) { dryRunTransactionBlock(txb); // executeTransactionBlock(txb); @@ -108,6 +131,7 @@ async function epochEvents() { let ok = await Events.getEpochChangeEvents({ startTime: 1733776365964, endTime: 1733827977386, + typeName: getConf().STSUI_COIN_TYPE, }); console.log(ok); } @@ -116,7 +140,7 @@ async function epochEvents() { async function apr() { console.log(await fetchStSuiAPR(1)); } -apr(); +// apr(); async function apy() { console.log(await fetchStSuiAPY(1)); } @@ -134,13 +158,14 @@ async function totalStakers() { async function fetchTotalStakerss() { console.log(await fetchTotalStakers()); } -// fetchTotalStakerss() +fetchTotalStakerss(); async function getRedeemEvents() { console.log( await Events.getRedeemEvents({ startTime: 1735660741000, endTime: 1735660801000, + typeName: getConf().STSUI_COIN_TYPE, }), ); } diff --git a/scripts/utils.ts b/scripts/utils.ts index 6f87cc7..8e3cf60 100644 --- a/scripts/utils.ts +++ b/scripts/utils.ts @@ -1,68 +1,86 @@ -import { fromB64 } from "@mysten/bcs"; +import { fromBase64 } from "@mysten/bcs"; import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"; -import { SuiClient, getFullnodeUrl } from "@mysten/sui/client"; +import { SuiGrpcClient } from "@mysten/sui/grpc"; import * as dotenv from "dotenv"; import { Transaction } from "@mysten/sui/transactions"; dotenv.config(); +const GRPC_URLS: Record = { + mainnet: "https://fullnode.mainnet.sui.io:443", + testnet: "https://fullnode.testnet.sui.io:443", + devnet: "https://fullnode.devnet.sui.io:443", + localnet: "http://127.0.0.1:9000", +}; + +/** + * gRPC client. Public fullnodes no longer serve JSON-RPC, so reads, simulation + * and execution all go through gRPC. Override the endpoint with SUI_GRPC_URL. + */ +export function getGrpcClient( + network: string = process.env.NETWORK ?? "mainnet", +) { + const baseUrl = process.env.SUI_GRPC_URL ?? GRPC_URLS[network]; + if (!baseUrl) { + throw new Error(`no gRPC url for network '${network}'`); + } + + return new SuiGrpcClient({ + network: network as "mainnet" | "testnet" | "devnet" | "localnet", + baseUrl, + }); +} + export function getExecStuff() { if (!process.env.PK_B64) { throw new Error("env var PK_B64 not configured"); } const b64PrivateKey = process.env.PK_B64 as string; - const keypair = Ed25519Keypair.fromSecretKey(fromB64(b64PrivateKey).slice(1)); + const keypair = Ed25519Keypair.fromSecretKey( + fromBase64(b64PrivateKey).slice(1), + ); const address = `${keypair.getPublicKey().toSuiAddress()}`; if (!process.env.NETWORK) { throw new Error("env var NETWORK not configured"); } - const suiClient = new SuiClient({ - url: getFullnodeUrl( - process.env.NETWORK as "mainnet" | "testnet" | "devnet" | "localnet", - ), - }); - - return { address, keypair, suiClient }; + return { address, keypair, client: getGrpcClient(process.env.NETWORK) }; } export async function executeTransactionBlock(txb: Transaction) { - const { keypair, suiClient } = getExecStuff(); + const { keypair, client, address } = getExecStuff(); + txb.setSenderIfNotSet(address); - await suiClient - .signAndExecuteTransaction({ - signer: keypair, - transaction: txb, - requestType: "WaitForLocalExecution", - options: { - showEffects: true, - showBalanceChanges: true, - showObjectChanges: true, - }, - }) - .then((res) => { - console.log(JSON.stringify(res, null, 2)); - }) - .catch((error) => { - console.error(error); + try { + const bytes = await txb.build({ client }); + const { signature } = await keypair.signTransaction(bytes); + const res = await client.core.executeTransaction({ + transaction: bytes, + signatures: [signature], + include: { effects: true, balanceChanges: true }, }); + console.log(JSON.stringify(res, null, 2)); + } catch (error) { + console.error(error); + } } -export async function dryRunTransactionBlock(txb: Transaction) { - const { keypair, suiClient, address } = getExecStuff(); - txb.setSender(address); - let serializedTxb = await txb.build({ client: suiClient }); - suiClient - .dryRunTransactionBlock({ - transactionBlock: serializedTxb, - }) - .then((res) => { - // console.log(JSON.stringify(res, null, 2)); - console.log(res.effects.status, res.balanceChanges); - }) - .catch((error) => { - console.error(error); + +export async function dryRunTransactionBlock(txb: Transaction, sender?: string) { + const { client, address } = getExecStuff(); + txb.setSender(sender ?? address); + + try { + const res = await client.core.simulateTransaction({ + transaction: txb, + include: { effects: true, balanceChanges: true }, }); + const tx = + res.$kind === "Transaction" ? res.Transaction : res.FailedTransaction; + console.log(tx?.status, tx?.balanceChanges); + } catch (error) { + console.error(error); + } } From ee21ddaacfabeda33d110cfd4b1319af80fadc05 Mon Sep 17 00:00:00 2001 From: Shrom Date: Mon, 10 Aug 2026 10:28:28 +0400 Subject: [PATCH 2/2] fix(scripts): validate RECIPIENT and fail loudly on tx failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject addresses that BCS would silently zero-pad into a valid dead address, and print the normalized destination instead of the raw env value. Return status from execute/dry-run so a MoveAbort — which does not throw, it returns $kind: FailedTransaction — exits non-zero instead of 0. --- scripts/receiveStray.ts | 33 ++++++++++++++++++++++++++++++--- scripts/utils.ts | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/scripts/receiveStray.ts b/scripts/receiveStray.ts index f00680d..65c26de 100644 --- a/scripts/receiveStray.ts +++ b/scripts/receiveStray.ts @@ -13,6 +13,7 @@ * The cap is address-owned, so PK_B64 must be the cap owner's key regardless of RECIPIENT. */ import { Transaction } from "@mysten/sui/transactions"; +import { isValidSuiAddress, normalizeSuiAddress } from "@mysten/sui/utils"; import { getConf } from "../src/index.ts"; import { dryRunTransactionBlock, @@ -61,11 +62,32 @@ export function buildReceiveStrayTx( return txb; } +/** + * Sui's BCS address serializer runs normalizeSuiAddress(), which LEFT-PADS rather than + * rejecting — a truncated paste like "0x019466989a" becomes the valid, unowned address + * 0x00…019466989a, and transferring there is irreversible. isValidSuiAddress requires the full + * normalized form, so it rejects exactly those. Callers print the normalized value, never the + * raw input. + */ +function requireAddress(label: string, value: string): string { + if (!isValidSuiAddress(value)) { + console.error( + `${label} is not a valid Sui address: ${JSON.stringify(value)}\n` + + `Expected the full 66-character 0x-prefixed form. Short values are silently ` + + `zero-padded into an address nobody owns.`, + ); + process.exit(1); + } + return normalizeSuiAddress(value); +} + async function main() { const execute = process.argv.includes("--execute"); const packageId = process.env.PACKAGE_ID ?? getConf().STSUI_LATEST_PACKAGE_ID; const { address: signer } = getExecStuff(); - const recipient = process.env.RECIPIENT ?? signer; + const recipient = process.env.RECIPIENT + ? requireAddress("RECIPIENT", process.env.RECIPIENT) + : signer; const client = getGrpcClient(); // Fetched, never hardcoded: receivingRef needs the object's current version and digest. @@ -133,12 +155,17 @@ async function main() { recipient, ); + let ok: boolean; if (execute) { console.log("\nexecuting…"); - await executeTransactionBlock(txb); + ok = await executeTransactionBlock(txb); } else { console.log("\ndry run (pass --execute to submit):"); - await dryRunTransactionBlock(txb, signer); + ok = await dryRunTransactionBlock(txb, signer); + } + + if (!ok) { + process.exit(1); } } diff --git a/scripts/utils.ts b/scripts/utils.ts index 8e3cf60..c52a266 100644 --- a/scripts/utils.ts +++ b/scripts/utils.ts @@ -50,7 +50,15 @@ export function getExecStuff() { return { address, keypair, client: getGrpcClient(process.env.NETWORK) }; } -export async function executeTransactionBlock(txb: Transaction) { +/** + * Signs and submits. Returns false if the transaction threw or executed with a failure. + * + * A MoveAbort does not throw — it comes back as `$kind: "FailedTransaction"` — so checking only + * for exceptions would report a failed run as a success. + */ +export async function executeTransactionBlock( + txb: Transaction, +): Promise { const { keypair, client, address } = getExecStuff(); txb.setSenderIfNotSet(address); @@ -63,12 +71,25 @@ export async function executeTransactionBlock(txb: Transaction) { include: { effects: true, balanceChanges: true }, }); console.log(JSON.stringify(res, null, 2)); + + if (res.$kind === "FailedTransaction") { + console.error( + `transaction failed: ${JSON.stringify(res.FailedTransaction.status)}`, + ); + return false; + } + return true; } catch (error) { console.error(error); + return false; } } -export async function dryRunTransactionBlock(txb: Transaction, sender?: string) { +/** Simulates. Returns false if the simulation threw or the transaction would fail. */ +export async function dryRunTransactionBlock( + txb: Transaction, + sender?: string, +): Promise { const { client, address } = getExecStuff(); txb.setSender(sender ?? address); @@ -80,7 +101,16 @@ export async function dryRunTransactionBlock(txb: Transaction, sender?: string) const tx = res.$kind === "Transaction" ? res.Transaction : res.FailedTransaction; console.log(tx?.status, tx?.balanceChanges); + + if (res.$kind === "FailedTransaction") { + console.error( + `simulation failed: ${JSON.stringify(res.FailedTransaction.status)}`, + ); + return false; + } + return true; } catch (error) { console.error(error); + return false; } }