From e825bf89625078774a17bb1b8110c9a2551854db Mon Sep 17 00:00:00 2001 From: Anantha Date: Fri, 13 Mar 2026 00:08:37 +0530 Subject: [PATCH] feat(sdk-coin-sui): add XMN staking support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add XMN (xMoney) token staking support on SUI blockchain: - Register tsui:xmn testnet entry in statics with SUI_TOKEN_FEATURES_STAKING - Upgrade sui:xmn mainnet entry from SUI_TOKEN_FEATURES to SUI_TOKEN_FEATURES_STAKING - Add XmnStake/XmnRequestUnstake/XmnUnbond/XmnClaimRewards transaction types to iface.ts - Add xmnConfig.ts with mainnet/testnet StakingFactory contract addresses - Add XmnStakingBuilder, XmnUnstakeBuilder, XmnClaimRewardsBuilder - Wire all builders in TransactionBuilderFactory.from() and getters - Add XMN method name detection in utils.ts getSuiTransactionType() - Fix utils.ts getTransactionType() to handle new XMN SuiTransactionTypes - Remove unused InvalidTransactionError imports; run prettier on XMN files XMN uses an app-level StakingFactory contract (no validators). All staking functions have void returns and transfer objects internally — no transferObjects calls needed. Type args for all calls: . Ticket: SC-6116 --- modules/sdk-coin-sui/src/lib/iface.ts | 70 +++++- modules/sdk-coin-sui/src/lib/index.ts | 4 + .../src/lib/resources/xmnConfig.ts | 62 ++++++ .../src/lib/transactionBuilderFactory.ts | 34 +++ modules/sdk-coin-sui/src/lib/utils.ts | 15 ++ .../src/lib/xmnClaimRewardsBuilder.ts | 156 ++++++++++++++ .../sdk-coin-sui/src/lib/xmnStakingBuilder.ts | 182 ++++++++++++++++ .../sdk-coin-sui/src/lib/xmnUnstakeBuilder.ts | 202 ++++++++++++++++++ modules/statics/src/allCoinsAndTokens.ts | 14 +- modules/statics/src/base.ts | 1 + 10 files changed, 738 insertions(+), 2 deletions(-) create mode 100644 modules/sdk-coin-sui/src/lib/resources/xmnConfig.ts create mode 100644 modules/sdk-coin-sui/src/lib/xmnClaimRewardsBuilder.ts create mode 100644 modules/sdk-coin-sui/src/lib/xmnStakingBuilder.ts create mode 100644 modules/sdk-coin-sui/src/lib/xmnUnstakeBuilder.ts diff --git a/modules/sdk-coin-sui/src/lib/iface.ts b/modules/sdk-coin-sui/src/lib/iface.ts index a5325f34f5..ed249260b1 100644 --- a/modules/sdk-coin-sui/src/lib/iface.ts +++ b/modules/sdk-coin-sui/src/lib/iface.ts @@ -22,6 +22,10 @@ export enum SuiTransactionType { WalrusStakeWithPool = 'WalrusStakeWithPool', WalrusRequestWithdrawStake = 'WalrusRequestWithdrawStake', WalrusWithdrawStake = 'WalrusWithdrawStake', + XmnStake = 'XmnStake', + XmnRequestUnstake = 'XmnRequestUnstake', + XmnUnbond = 'XmnUnbond', + XmnClaimRewards = 'XmnClaimRewards', } export interface TransactionExplanation extends BaseTransactionExplanation { @@ -35,7 +39,10 @@ export type SuiProgrammableTransaction = | CustomProgrammableTransaction | TokenTransferProgrammableTransaction | WalrusStakingProgrammableTransaction - | WalrusWithdrawStakeProgrammableTransaction; + | WalrusWithdrawStakeProgrammableTransaction + | XmnStakingProgrammableTransaction + | XmnUnstakeProgrammableTransaction + | XmnClaimRewardsProgrammableTransaction; export interface TxData { id?: string; @@ -97,6 +104,27 @@ export type WalrusWithdrawStakeProgrammableTransaction = transactions: TransactionType[]; }; +export type XmnStakingProgrammableTransaction = + | ProgrammableTransaction + | { + inputs: CallArg[] | TransactionBlockInput[]; + transactions: TransactionType[]; + }; + +export type XmnUnstakeProgrammableTransaction = + | ProgrammableTransaction + | { + inputs: CallArg[] | TransactionBlockInput[]; + transactions: TransactionType[]; + }; + +export type XmnClaimRewardsProgrammableTransaction = + | ProgrammableTransaction + | { + inputs: CallArg[] | TransactionBlockInput[]; + transactions: TransactionType[]; + }; + export interface SuiTransaction { id?: string; type: SuiTransactionType; @@ -126,6 +154,30 @@ export interface RequestWalrusWithdrawStake { stakedWal: SuiObjectRef; } +export interface RequestXmnStake { + /** Amount in base units (1 XMN = 1_000_000) */ + amount: number; + /** XMN coin objects to spend */ + inputObjects: SuiObjectRef[]; +} + +export interface RequestXmnRequestUnstake { + /** OpenPosition object owned by the staker */ + openPosition: SuiObjectRef; + /** Amount to unstake in base units; if omitted, the full position amount is used */ + amount?: number; +} + +export interface RequestXmnUnbond { + /** UnbondingTicket object owned by the staker (created by request_unstake) */ + unbondingTicket: SuiObjectRef; +} + +export interface RequestXmnClaimRewards { + /** OpenPosition object owned by the staker */ + openPosition: SuiObjectRef; +} + /** * Method names for the transaction method. Names change based on the type of transaction e.g 'request_add_delegation_mul_coin' for the staking transaction */ @@ -172,6 +224,22 @@ export enum MethodNames { * @see https://github.com/MystenLabs/walrus-docs/blob/9307e66df0ea3f6555cdef78d46aefa62737e216/contracts/walrus/sources/staking/staked_wal.move#L143 */ WalrusSplitStakedWal = '::staked_wal::split', + /** + * XMN staking_factory::stake — stakes XMN tokens, mints OpenPosition (void return). + */ + XmnStake = '::staking_factory::stake', + /** + * XMN staking_factory::request_unstake — initiates unbonding, mints UnbondingTicket (void return). + */ + XmnRequestUnstake = '::staking_factory::request_unstake', + /** + * XMN staking_factory::unbond — redeems UnbondingTicket after cooldown, returns principal (void return). + */ + XmnUnbond = '::staking_factory::unbond', + /** + * XMN staking_factory::claim_and_transfer — claims accrued rewards to liquid balance (void return). + */ + XmnClaimRewards = '::staking_factory::claim_and_transfer', } export interface SuiObjectInfo extends SuiObjectRef { diff --git a/modules/sdk-coin-sui/src/lib/index.ts b/modules/sdk-coin-sui/src/lib/index.ts index cbd8035fec..5fb56fce33 100644 --- a/modules/sdk-coin-sui/src/lib/index.ts +++ b/modules/sdk-coin-sui/src/lib/index.ts @@ -18,5 +18,9 @@ export { WalrusStakingTransaction } from './walrusStakingTransaction'; export { WalrusStakingBuilder } from './walrusStakingBuilder'; export { WalrusWithdrawStakeTransaction } from './walrusWithdrawStakeTransaction'; export { WalrusWithdrawStakeBuilder } from './walrusWithdrawStakeBuilder'; +export { XmnStakingBuilder } from './xmnStakingBuilder'; +export { XmnUnstakeBuilder } from './xmnUnstakeBuilder'; +export { XmnClaimRewardsBuilder } from './xmnClaimRewardsBuilder'; +export { XMN_MAINNET_CONFIG, XMN_TESTNET_CONFIG } from './resources/xmnConfig'; export { TransactionBuilderFactory } from './transactionBuilderFactory'; export { Interface, Utils }; diff --git a/modules/sdk-coin-sui/src/lib/resources/xmnConfig.ts b/modules/sdk-coin-sui/src/lib/resources/xmnConfig.ts new file mode 100644 index 0000000000..866c18d643 --- /dev/null +++ b/modules/sdk-coin-sui/src/lib/resources/xmnConfig.ts @@ -0,0 +1,62 @@ +/** + * XMN staking contract configuration for mainnet and testnet. + * + * XMN uses an app-level staking contract (StakingFactory) on the SUI blockchain. + * There are no validators — all staked XMN goes into a single liquidity pool. + * + * Key insight: stake(), request_unstake(), unbond(), and claim_and_transfer() all + * have void returns and transfer objects internally to ctx.sender(). Scripts must + * NOT call tx.transferObjects() on their results. + * + * Type arguments for all StakingFactory calls: + * The Db (boosted deposit) type is BRIDGE_TOKEN, not XMN. + */ + +export interface XmnSharedObject { + objectId: string; + initialSharedVersion: number; + mutable: boolean; +} + +export interface XmnConfig { + /** Active (upgraded) package ID — used for Move call targets */ + XMN_PKG_ID: string; + /** Original package ID — used for type tag matching in on-chain object types */ + XMN_ORIGINAL_PKG_ID: string; + /** StakingFactory shared object reference */ + XMN_STAKING_FACTORY: XmnSharedObject; + /** XMN coin type tag (R and Dn type args) */ + XMN_COIN_TYPE: string; + /** BRIDGE_TOKEN coin type tag (Db type arg — NOT XMN) */ + BRIDGE_TOKEN_COIN_TYPE: string; + /** Module name in the staking contract */ + STAKING_MODULE: string; +} + +export const XMN_TESTNET_CONFIG: XmnConfig = { + XMN_PKG_ID: '0x82e3f0021547fdbc88d25b09a99e175742e7fb45b3a457e4373817f768494454', + XMN_ORIGINAL_PKG_ID: '0x49934c5c0866e0b62db2f43296994f28d09505f48005032d4285a5da53f35e2a', + XMN_STAKING_FACTORY: { + objectId: '0x016a243d61c0814da7e07bbc5f6963f73941839433da9a11e2bc8a251dbd83a0', + initialSharedVersion: 685940293, + mutable: true, + }, + XMN_COIN_TYPE: '0x49934c5c0866e0b62db2f43296994f28d09505f48005032d4285a5da53f35e2a::xmn::XMN', + BRIDGE_TOKEN_COIN_TYPE: + '0x4d2ba7e9a819c306c94c744efdd89f52009b0ed892c97b4e49adf4c78923d801::bridge_token::BRIDGE_TOKEN', + STAKING_MODULE: 'staking_factory', +}; + +export const XMN_MAINNET_CONFIG: XmnConfig = { + ...XMN_TESTNET_CONFIG, + XMN_PKG_ID: '0x37e54838496fc4d620032cfa9e1d2542f21874429b107f097c1dab2c8bad2de8', + XMN_ORIGINAL_PKG_ID: '0x3cc209ca80fde4e68f9abbae4776abc57de7bb3da33bdb8cbf6a66740ff81bd8', + XMN_STAKING_FACTORY: { + objectId: '0x8f8a82182a12f08f579046f167303a1083fcdd3b2eb7f58c3eefe7835639d5f8', + initialSharedVersion: 648114799, + mutable: true, + }, + XMN_COIN_TYPE: '0x3cc209ca80fde4e68f9abbae4776abc57de7bb3da33bdb8cbf6a66740ff81bd8::xmn::XMN', + BRIDGE_TOKEN_COIN_TYPE: + '0x8db9d9dc5cd5723ee55725869620073e28f88ddf3c360a512ebd73cb46f1903d::bridge_token::BRIDGE_TOKEN', +}; diff --git a/modules/sdk-coin-sui/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-sui/src/lib/transactionBuilderFactory.ts index de44263406..781f18f49d 100644 --- a/modules/sdk-coin-sui/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-sui/src/lib/transactionBuilderFactory.ts @@ -14,6 +14,9 @@ import { TokenTransferProgrammableTransaction, WalrusStakingProgrammableTransaction, WalrusWithdrawStakeProgrammableTransaction, + XmnStakingProgrammableTransaction, + XmnUnstakeProgrammableTransaction, + XmnClaimRewardsProgrammableTransaction, } from './iface'; import { StakingTransaction } from './stakingTransaction'; import { TransferTransaction } from './transferTransaction'; @@ -29,6 +32,9 @@ import { WalrusStakingBuilder } from './walrusStakingBuilder'; import { WalrusStakingTransaction } from './walrusStakingTransaction'; import { WalrusWithdrawStakeBuilder } from './walrusWithdrawStakeBuilder'; import { WalrusWithdrawStakeTransaction } from './walrusWithdrawStakeTransaction'; +import { XmnStakingBuilder } from './xmnStakingBuilder'; +import { XmnUnstakeBuilder } from './xmnUnstakeBuilder'; +import { XmnClaimRewardsBuilder } from './xmnClaimRewardsBuilder'; export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { constructor(_coinConfig: Readonly) { @@ -70,6 +76,19 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { const walrusRequestWithdrawStakeTransaction = new WalrusWithdrawStakeTransaction(this._coinConfig); walrusRequestWithdrawStakeTransaction.fromRawTransaction(raw); return this.getWalrusRequestWithdrawStakeBuilder(walrusRequestWithdrawStakeTransaction); + case SuiTransactionType.XmnStake: + const xmnStakeTx = new StakingTransaction(this._coinConfig); + xmnStakeTx.fromRawTransaction(raw); + return this.getXmnStakingBuilder(xmnStakeTx); + case SuiTransactionType.XmnRequestUnstake: + case SuiTransactionType.XmnUnbond: + const xmnUnstakeTx = new UnstakingTransaction(this._coinConfig); + xmnUnstakeTx.fromRawTransaction(raw); + return this.getXmnUnstakeBuilder(xmnUnstakeTx); + case SuiTransactionType.XmnClaimRewards: + const xmnClaimTx = new StakingTransaction(this._coinConfig); + xmnClaimTx.fromRawTransaction(raw); + return this.getXmnClaimRewardsBuilder(xmnClaimTx); default: throw new InvalidTransactionError('Invalid transaction'); } @@ -115,6 +134,21 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.initializeBuilder(tx, new WalrusWithdrawStakeBuilder(this._coinConfig)); } + /** @inheritdoc */ + getXmnStakingBuilder(tx?: Transaction): XmnStakingBuilder { + return this.initializeBuilder(tx, new XmnStakingBuilder(this._coinConfig)); + } + + /** @inheritdoc */ + getXmnUnstakeBuilder(tx?: Transaction): XmnUnstakeBuilder { + return this.initializeBuilder(tx, new XmnUnstakeBuilder(this._coinConfig)); + } + + /** @inheritdoc */ + getXmnClaimRewardsBuilder(tx?: Transaction): XmnClaimRewardsBuilder { + return this.initializeBuilder(tx, new XmnClaimRewardsBuilder(this._coinConfig)); + } + /** @inheritdoc */ getWalletInitializationBuilder(): void { throw new Error('Method not implemented.'); diff --git a/modules/sdk-coin-sui/src/lib/utils.ts b/modules/sdk-coin-sui/src/lib/utils.ts index cc912aba7e..f8284da34f 100644 --- a/modules/sdk-coin-sui/src/lib/utils.ts +++ b/modules/sdk-coin-sui/src/lib/utils.ts @@ -208,6 +208,13 @@ export class Utils implements BaseUtils { return TransactionType.StakingWithdraw; case SuiTransactionType.CustomTx: return TransactionType.CustomTx; + case SuiTransactionType.XmnStake: + return TransactionType.StakingAdd; + case SuiTransactionType.XmnRequestUnstake: + case SuiTransactionType.XmnUnbond: + return TransactionType.StakingDeactivate; + case SuiTransactionType.XmnClaimRewards: + return TransactionType.StakingClaim; } } @@ -250,6 +257,14 @@ export class Utils implements BaseUtils { return SuiTransactionType.WalrusRequestWithdrawStake; } else if (command.target.endsWith(MethodNames.WalrusWithdrawStake)) { return SuiTransactionType.WalrusWithdrawStake; + } else if (command.target.endsWith(MethodNames.XmnStake)) { + return SuiTransactionType.XmnStake; + } else if (command.target.endsWith(MethodNames.XmnRequestUnstake)) { + return SuiTransactionType.XmnRequestUnstake; + } else if (command.target.endsWith(MethodNames.XmnUnbond)) { + return SuiTransactionType.XmnUnbond; + } else if (command.target.endsWith(MethodNames.XmnClaimRewards)) { + return SuiTransactionType.XmnClaimRewards; } else { throw new InvalidTransactionError(`unsupported target method ${command.target}`); } diff --git a/modules/sdk-coin-sui/src/lib/xmnClaimRewardsBuilder.ts b/modules/sdk-coin-sui/src/lib/xmnClaimRewardsBuilder.ts new file mode 100644 index 0000000000..faee95c616 --- /dev/null +++ b/modules/sdk-coin-sui/src/lib/xmnClaimRewardsBuilder.ts @@ -0,0 +1,156 @@ +import { BaseCoin as CoinConfig, NetworkType } from '@bitgo/statics'; +import { BaseKey, BuildTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { + RequestXmnClaimRewards, + SuiTransaction, + SuiTransactionType, + XmnClaimRewardsProgrammableTransaction, +} from './iface'; +import { TransactionBuilder } from './transactionBuilder'; +import { Transaction } from './transaction'; +import { StakingTransaction } from './stakingTransaction'; +import { + Inputs, + MoveCallTransaction, + TransactionBlock as ProgrammingTransactionBlockBuilder, +} from './mystenlab/builder'; +import { MAX_GAS_OBJECTS } from './constants'; +import { XMN_MAINNET_CONFIG, XMN_TESTNET_CONFIG, XmnConfig } from './resources/xmnConfig'; +import assert from 'assert'; + +/** + * Builder for XMN claim rewards transactions. + * + * Calls staking_factory::claim_and_transfer(factory, openPosition). + * Void return — the reward XMN coin is internally transferred to ctx.sender(). + * + * The OpenPosition remains active after claiming; rewards are reset to zero. + * auto_claimed_on_unstake = false — rewards must be claimed separately from unstaking. + * + * IMPORTANT: void return — do NOT call tx.transferObjects() on the result. + */ +export class XmnClaimRewardsBuilder extends TransactionBuilder { + protected _claimRequest: RequestXmnClaimRewards; + + private xmnConfig: XmnConfig; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._transaction = new StakingTransaction(_coinConfig); + this.xmnConfig = _coinConfig.network.type === NetworkType.MAINNET ? XMN_MAINNET_CONFIG : XMN_TESTNET_CONFIG; + } + + protected get transactionType(): TransactionType { + return TransactionType.StakingClaim; + } + + validateTransaction(transaction: Transaction): void { + if (!transaction.suiTransaction) { + return; + } + this.validateTransactionFields(); + } + + sign(key: BaseKey) { + this.transaction.setSuiTransaction(this.buildSuiTransaction()); + super.sign(key); + } + + /** + * Set the claim rewards request parameters. + * + * @param request - OpenPosition object ref to claim rewards from + */ + claim(request: RequestXmnClaimRewards): this { + assert(request.openPosition, 'openPosition is required for claim'); + this._claimRequest = request; + return this; + } + + protected fromImplementation(rawTransaction: string): Transaction { + const tx = new StakingTransaction(this._coinConfig); + this.validateRawTransaction(rawTransaction); + tx.fromRawTransaction(rawTransaction); + this.initBuilder(tx); + return this.transaction; + } + + protected async buildImplementation(): Promise> { + this.transaction.setSuiTransaction(this.buildSuiTransaction()); + this.transaction.transactionType(this.transactionType); + + if (this._signer) { + this.transaction.sign(this._signer); + } + + this._signatures.forEach((signature) => { + this.transaction.addSignature(signature.publicKey, signature.signature); + }); + + this.transaction.loadInputsAndOutputs(); + return this.transaction; + } + + initBuilder(tx: Transaction): void { + this._transaction = tx; + + if (tx.signature && tx.signature.length > 0) { + this._signatures = [tx.suiSignature]; + } + + const txData = tx.toJson(); + this.type(SuiTransactionType.XmnClaimRewards); + this.sender(txData.sender); + this.gasData(txData.gasData); + } + + private validateTransactionFields(): void { + assert(this._type, new BuildTransactionError('type is required before building')); + assert(this._sender, new BuildTransactionError('sender is required before building')); + assert(this._claimRequest, new BuildTransactionError('claim request is required before building')); + assert(this._claimRequest.openPosition, new BuildTransactionError('openPosition is required before building')); + assert(this._gasData, new BuildTransactionError('gasData is required before building')); + this.validateGasData(this._gasData); + } + + protected buildSuiTransaction(): SuiTransaction { + this.validateTransactionFields(); + + const programmableTxBuilder = new ProgrammingTransactionBlockBuilder(); + + // Call staking_factory::claim_and_transfer(factory, openPosition) + // Void return — reward XMN coin is internally transferred to ctx.sender() + programmableTxBuilder.moveCall({ + target: `${this.xmnConfig.XMN_PKG_ID}::${this.xmnConfig.STAKING_MODULE}::claim_and_transfer`, + typeArguments: [ + this.xmnConfig.XMN_COIN_TYPE, + this.xmnConfig.XMN_COIN_TYPE, + this.xmnConfig.BRIDGE_TOKEN_COIN_TYPE, + ], + arguments: [ + programmableTxBuilder.object( + Inputs.SharedObjectRef({ + objectId: this.xmnConfig.XMN_STAKING_FACTORY.objectId, + initialSharedVersion: this.xmnConfig.XMN_STAKING_FACTORY.initialSharedVersion, + mutable: this.xmnConfig.XMN_STAKING_FACTORY.mutable, + }) + ), + programmableTxBuilder.object(Inputs.ObjectRef(this._claimRequest.openPosition)), + ], + } as unknown as MoveCallTransaction); + + const txData = programmableTxBuilder.blockData; + return { + type: this._type, + sender: this._sender, + tx: { + inputs: [...txData.inputs], + transactions: [...txData.transactions], + }, + gasData: { + ...this._gasData, + payment: this._gasData.payment.slice(0, MAX_GAS_OBJECTS - 1), + }, + }; + } +} diff --git a/modules/sdk-coin-sui/src/lib/xmnStakingBuilder.ts b/modules/sdk-coin-sui/src/lib/xmnStakingBuilder.ts new file mode 100644 index 0000000000..a161e2d53f --- /dev/null +++ b/modules/sdk-coin-sui/src/lib/xmnStakingBuilder.ts @@ -0,0 +1,182 @@ +import { BaseCoin as CoinConfig, NetworkType } from '@bitgo/statics'; +import { BaseKey, BuildTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { RequestXmnStake, SuiTransaction, SuiTransactionType, XmnStakingProgrammableTransaction } from './iface'; +import { TransactionBuilder } from './transactionBuilder'; +import { Transaction } from './transaction'; +import { StakingTransaction } from './stakingTransaction'; +import { + Inputs, + MoveCallTransaction, + TransactionArgument, + TransactionBlock as ProgrammingTransactionBlockBuilder, +} from './mystenlab/builder'; +import { MAX_GAS_OBJECTS } from './constants'; +import { XMN_MAINNET_CONFIG, XMN_TESTNET_CONFIG, XmnConfig } from './resources/xmnConfig'; +import { SuiObjectRef } from './mystenlab/types'; +import assert from 'assert'; +import utils from './utils'; + +/** + * Builder for XMN staking transactions. + * + * Calls staking_factory::stake(factory, coin). + * The function has a void return — it internally transfers the minted OpenPosition + * to ctx.sender(). Do NOT call tx.transferObjects() on the result. + */ +export class XmnStakingBuilder extends TransactionBuilder { + protected _stakeRequest: RequestXmnStake; + + private xmnConfig: XmnConfig; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._transaction = new StakingTransaction(_coinConfig); + this.xmnConfig = _coinConfig.network.type === NetworkType.MAINNET ? XMN_MAINNET_CONFIG : XMN_TESTNET_CONFIG; + } + + protected buildStakeTransaction(): SuiTransaction { + return { + type: SuiTransactionType.XmnStake, + sender: this._sender, + tx: { + inputs: [], + transactions: [], + }, + gasData: this._gasData, + }; + } + + protected get transactionType(): TransactionType { + return TransactionType.StakingActivate; + } + + validateTransaction(transaction: Transaction): void { + if (!transaction.suiTransaction) { + return; + } + this.validateTransactionFields(); + } + + sign(key: BaseKey) { + this.transaction.setSuiTransaction(this.buildSuiTransaction()); + super.sign(key); + } + + /** + * Set the stake request parameters. + * + * @param request - amount in base units (1 XMN = 1_000_000) and XMN coin objects to spend + */ + stake(request: RequestXmnStake): this { + assert(utils.isValidAmount(request.amount), 'Invalid stake amount'); + assert(request.inputObjects && request.inputObjects.length > 0, 'inputObjects required'); + this._stakeRequest = request; + return this; + } + + protected fromImplementation(rawTransaction: string): Transaction { + const tx = new StakingTransaction(this._coinConfig); + this.validateRawTransaction(rawTransaction); + tx.fromRawTransaction(rawTransaction); + this.initBuilder(tx); + return this.transaction; + } + + protected async buildImplementation(): Promise> { + this.transaction.setSuiTransaction(this.buildSuiTransaction()); + this.transaction.transactionType(this.transactionType); + + if (this._signer) { + this.transaction.sign(this._signer); + } + + this._signatures.forEach((signature) => { + this.transaction.addSignature(signature.publicKey, signature.signature); + }); + + this.transaction.loadInputsAndOutputs(); + return this.transaction; + } + + initBuilder(tx: Transaction): void { + this._transaction = tx; + + if (tx.signature && tx.signature.length > 0) { + this._signatures = [tx.suiSignature]; + } + + const txData = tx.toJson(); + this.type(SuiTransactionType.XmnStake); + this.sender(txData.sender); + this.gasData(txData.gasData); + } + + private validateTransactionFields(): void { + assert(this._type, new BuildTransactionError('type is required before building')); + assert(this._sender, new BuildTransactionError('sender is required before building')); + assert(this._stakeRequest, new BuildTransactionError('stake request is required before building')); + assert(this._stakeRequest.amount, new BuildTransactionError('stake amount is required before building')); + assert( + this._stakeRequest.inputObjects && this._stakeRequest.inputObjects.length > 0, + new BuildTransactionError('input objects required before building') + ); + assert(this._gasData, new BuildTransactionError('gasData is required before building')); + this.validateGasData(this._gasData); + } + + protected buildSuiTransaction(): SuiTransaction { + this.validateTransactionFields(); + + const programmableTxBuilder = new ProgrammingTransactionBlockBuilder(); + + // Merge all input XMN coin objects into one, then split the required amount + const inputObjects: TransactionArgument[] = this._stakeRequest.inputObjects.map((obj: SuiObjectRef) => + programmableTxBuilder.object(Inputs.ObjectRef(obj)) + ); + const mergedObject = inputObjects.shift() as TransactionArgument; + + if (inputObjects.length > 0) { + programmableTxBuilder.mergeCoins(mergedObject, inputObjects); + } + + const splitObject = programmableTxBuilder.splitCoins(mergedObject, [ + programmableTxBuilder.pure(this._stakeRequest.amount), + ]); + + // Call staking_factory::stake(factory, coin) + // IMPORTANT: void return — OpenPosition is internally transferred to ctx.sender() + // Do NOT call transferObjects on the result. + programmableTxBuilder.moveCall({ + target: `${this.xmnConfig.XMN_PKG_ID}::${this.xmnConfig.STAKING_MODULE}::stake`, + typeArguments: [ + this.xmnConfig.XMN_COIN_TYPE, + this.xmnConfig.XMN_COIN_TYPE, + this.xmnConfig.BRIDGE_TOKEN_COIN_TYPE, + ], + arguments: [ + programmableTxBuilder.object( + Inputs.SharedObjectRef({ + objectId: this.xmnConfig.XMN_STAKING_FACTORY.objectId, + initialSharedVersion: this.xmnConfig.XMN_STAKING_FACTORY.initialSharedVersion, + mutable: this.xmnConfig.XMN_STAKING_FACTORY.mutable, + }) + ), + splitObject, + ], + } as unknown as MoveCallTransaction); + + const txData = programmableTxBuilder.blockData; + return { + type: this._type, + sender: this._sender, + tx: { + inputs: [...txData.inputs], + transactions: [...txData.transactions], + }, + gasData: { + ...this._gasData, + payment: this._gasData.payment.slice(0, MAX_GAS_OBJECTS - 1), + }, + }; + } +} diff --git a/modules/sdk-coin-sui/src/lib/xmnUnstakeBuilder.ts b/modules/sdk-coin-sui/src/lib/xmnUnstakeBuilder.ts new file mode 100644 index 0000000000..d7d41d03af --- /dev/null +++ b/modules/sdk-coin-sui/src/lib/xmnUnstakeBuilder.ts @@ -0,0 +1,202 @@ +import { BaseCoin as CoinConfig, NetworkType } from '@bitgo/statics'; +import { BaseKey, BuildTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { + RequestXmnRequestUnstake, + RequestXmnUnbond, + SuiTransaction, + SuiTransactionType, + XmnUnstakeProgrammableTransaction, +} from './iface'; +import { TransactionBuilder } from './transactionBuilder'; +import { Transaction } from './transaction'; +import { UnstakingTransaction } from './unstakingTransaction'; +import { + Inputs, + MoveCallTransaction, + TransactionBlock as ProgrammingTransactionBlockBuilder, +} from './mystenlab/builder'; +import { MAX_GAS_OBJECTS } from './constants'; +import { XMN_MAINNET_CONFIG, XMN_TESTNET_CONFIG, XmnConfig } from './resources/xmnConfig'; +import assert from 'assert'; + +/** + * Builder for XMN unstaking transactions (2-step process). + * + * Step 1: XmnRequestUnstake + * Calls staking_factory::request_unstake(factory, openPosition, amount). + * Void return — UnbondingTicket is internally transferred to ctx.sender(). + * After this TX, wait for the unbonding period (10 days mainnet / ~30 min testnet). + * + * Step 2: XmnUnbond + * Calls staking_factory::unbond(factory, unbondingTicket). + * Void return — principal XMN coin is internally transferred to ctx.sender(). + * The UnbondingTicket object is deleted on-chain. + * + * IMPORTANT: Both functions have void returns. Do NOT call tx.transferObjects(). + */ +export class XmnUnstakeBuilder extends TransactionBuilder { + protected _requestUnstake?: RequestXmnRequestUnstake; + protected _unbond?: RequestXmnUnbond; + + private xmnConfig: XmnConfig; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._transaction = new UnstakingTransaction(_coinConfig); + this.xmnConfig = _coinConfig.network.type === NetworkType.MAINNET ? XMN_MAINNET_CONFIG : XMN_TESTNET_CONFIG; + } + + protected get transactionType(): TransactionType { + return this._type === SuiTransactionType.XmnUnbond + ? TransactionType.StakingWithdraw + : TransactionType.StakingDeactivate; + } + + validateTransaction(transaction: Transaction): void { + if (!transaction.suiTransaction) { + return; + } + this.validateTransactionFields(); + } + + sign(key: BaseKey) { + this.transaction.setSuiTransaction(this.buildSuiTransaction()); + super.sign(key); + } + + /** + * Set request_unstake parameters (Step 1 of 2). + * + * @param request - OpenPosition object ref and optional amount (partial unstake) + */ + requestUnstake(request: RequestXmnRequestUnstake): this { + assert(request.openPosition, 'openPosition is required for requestUnstake'); + this._requestUnstake = request; + this.type(SuiTransactionType.XmnRequestUnstake); + return this; + } + + /** + * Set unbond parameters (Step 2 of 2). + * + * @param request - UnbondingTicket object ref (received after request_unstake) + */ + unbond(request: RequestXmnUnbond): this { + assert(request.unbondingTicket, 'unbondingTicket is required for unbond'); + this._unbond = request; + this.type(SuiTransactionType.XmnUnbond); + return this; + } + + protected fromImplementation(rawTransaction: string): Transaction { + const tx = new UnstakingTransaction(this._coinConfig); + this.validateRawTransaction(rawTransaction); + tx.fromRawTransaction(rawTransaction); + this.initBuilder(tx); + return this.transaction; + } + + protected async buildImplementation(): Promise> { + this.transaction.setSuiTransaction(this.buildSuiTransaction()); + this.transaction.transactionType(this.transactionType); + + if (this._signer) { + this.transaction.sign(this._signer); + } + + this._signatures.forEach((signature) => { + this.transaction.addSignature(signature.publicKey, signature.signature); + }); + + this.transaction.loadInputsAndOutputs(); + return this.transaction; + } + + initBuilder(tx: Transaction): void { + this._transaction = tx; + + if (tx.signature && tx.signature.length > 0) { + this._signatures = [tx.suiSignature]; + } + + const txData = tx.toJson(); + this.sender(txData.sender); + this.gasData(txData.gasData); + } + + private validateTransactionFields(): void { + assert(this._type, new BuildTransactionError('type is required before building')); + assert(this._sender, new BuildTransactionError('sender is required before building')); + + if (this._type === SuiTransactionType.XmnRequestUnstake) { + assert(this._requestUnstake, new BuildTransactionError('requestUnstake params are required')); + assert(this._requestUnstake.openPosition, new BuildTransactionError('openPosition is required')); + } else if (this._type === SuiTransactionType.XmnUnbond) { + assert(this._unbond, new BuildTransactionError('unbond params are required')); + assert(this._unbond.unbondingTicket, new BuildTransactionError('unbondingTicket is required')); + } else { + throw new BuildTransactionError(`Unsupported type for XmnUnstakeBuilder: ${this._type}`); + } + + assert(this._gasData, new BuildTransactionError('gasData is required before building')); + this.validateGasData(this._gasData); + } + + protected buildSuiTransaction(): SuiTransaction { + this.validateTransactionFields(); + + const programmableTxBuilder = new ProgrammingTransactionBlockBuilder(); + + const factoryRef = programmableTxBuilder.object( + Inputs.SharedObjectRef({ + objectId: this.xmnConfig.XMN_STAKING_FACTORY.objectId, + initialSharedVersion: this.xmnConfig.XMN_STAKING_FACTORY.initialSharedVersion, + mutable: this.xmnConfig.XMN_STAKING_FACTORY.mutable, + }) + ); + + const typeArgs = [ + this.xmnConfig.XMN_COIN_TYPE, + this.xmnConfig.XMN_COIN_TYPE, + this.xmnConfig.BRIDGE_TOKEN_COIN_TYPE, + ]; + + if (this._type === SuiTransactionType.XmnRequestUnstake) { + // Call staking_factory::request_unstake(factory, openPosition, amount) + // Void return — UnbondingTicket is internally transferred to ctx.sender() + const args = [factoryRef, programmableTxBuilder.object(Inputs.ObjectRef(this._requestUnstake!.openPosition))]; + + if (this._requestUnstake!.amount !== undefined) { + args.push(programmableTxBuilder.pure(this._requestUnstake!.amount) as any); + } + + programmableTxBuilder.moveCall({ + target: `${this.xmnConfig.XMN_PKG_ID}::${this.xmnConfig.STAKING_MODULE}::request_unstake`, + typeArguments: typeArgs, + arguments: args, + } as unknown as MoveCallTransaction); + } else { + // XmnUnbond: Call staking_factory::unbond(factory, unbondingTicket) + // Void return — principal XMN coin is internally transferred to ctx.sender() + programmableTxBuilder.moveCall({ + target: `${this.xmnConfig.XMN_PKG_ID}::${this.xmnConfig.STAKING_MODULE}::unbond`, + typeArguments: typeArgs, + arguments: [factoryRef, programmableTxBuilder.object(Inputs.ObjectRef(this._unbond!.unbondingTicket))], + } as unknown as MoveCallTransaction); + } + + const txData = programmableTxBuilder.blockData; + return { + type: this._type, + sender: this._sender, + tx: { + inputs: [...txData.inputs], + transactions: [...txData.transactions], + }, + gasData: { + ...this._gasData, + payment: this._gasData.payment.slice(0, MAX_GAS_OBJECTS - 1), + }, + }; + } +} diff --git a/modules/statics/src/allCoinsAndTokens.ts b/modules/statics/src/allCoinsAndTokens.ts index 94be9ef785..89ae570e96 100644 --- a/modules/statics/src/allCoinsAndTokens.ts +++ b/modules/statics/src/allCoinsAndTokens.ts @@ -6267,7 +6267,7 @@ export const allCoinsAndTokens = [ 'XMN', '0x97c7571f4406cdd7a95f3027075ab80d3e9c937c2a567690d31e14ab1872ccee::xmn::XMN', UnderlyingAsset['sui:xmn'], - SUI_TOKEN_FEATURES + SUI_TOKEN_FEATURES_STAKING ), suiToken( '9b6a8372-5d8a-41d1-8074-d53e59b2e513', @@ -6353,6 +6353,18 @@ export const allCoinsAndTokens = [ UnderlyingAsset['tsui:wal'], SUI_TOKEN_FEATURES_STAKING ), + tsuiToken( + '8083c263-817c-41b0-aac4-3b9052e83331', + 'tsui:xmn', + 'xMoney', + 6, + '0x49934c5c0866e0b62db2f43296994f28d09505f48005032d4285a5da53f35e2a', + 'xmn', + 'XMN', + '0x49934c5c0866e0b62db2f43296994f28d09505f48005032d4285a5da53f35e2a::xmn::XMN', + UnderlyingAsset['tsui:xmn'], + SUI_TOKEN_FEATURES_STAKING + ), ttaoToken( 'b8b5fded-65f8-49eb-8f83-ad97d08d07f2', 'ttao:apex', diff --git a/modules/statics/src/base.ts b/modules/statics/src/base.ts index b52d2723dd..196ba896eb 100644 --- a/modules/statics/src/base.ts +++ b/modules/statics/src/base.ts @@ -3450,6 +3450,7 @@ export enum UnderlyingAsset { // Sui testnet tokens 'tsui:deep' = 'tsui:deep', 'tsui:wal' = 'tsui:wal', + 'tsui:xmn' = 'tsui:xmn', // Apt tokens 'apt:usd1' = 'apt:usd1',