diff --git a/src/core/index.ts b/src/core/index.ts index 053a32f..cb5b59c 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -12,6 +12,7 @@ import { CetusSwap } from '../models/swap.js'; import type { AlphaFiReceipt, PoolBalance, PoolData, UserPortfolioData } from '../models/types.js'; import { AlphaFiSDKConfig, + CancelTransferRequestOptions, CancelWithdrawSlushOptions, CetusSwapOptions, CetusSwapQuoteOptions, @@ -19,12 +20,19 @@ import { ClaimOptions, ClaimWithdrawAlphaOptions, ClaimWithdrawSlushOptions, + CreateTransferRequestOptions, DepositOptions, EstimateLpAmountsOptions, + FulfillTransferRequestOptions, WithdrawOptions, ZapDepositOptions, ZapDepositQuoteOptions, } from './types.js'; +import { + buildCancelTransferRequestTx, + buildCreateTransferRequestTx, + buildFulfillTransferRequestTx, +} from '../services/transferReceipt.js'; import { RouterDataV3 } from '@cetusprotocol/aggregator-sdk'; import { Strategy, StrategyType } from '../strategies/strategy.js'; import { LEGACY_ALPHA_POOL_RECEIPT, PACKAGE_IDS, VERSIONS } from '../utils/constants.js'; @@ -374,12 +382,25 @@ export class AlphaFiSDK { * Get all AlphaFi receipts for a user address. * * @param userAddress - User's wallet address - * @returns Parsed AlphaFi receipt objects + * @returns Parsed AlphaFi receipt objects (transferRequest is always null here) */ async getAlphaFiReceipts(userAddress: string): Promise { return this.strategyContext.getAlphaFiReceipts(userAddress); } + /** + * Get all AlphaFi receipts for a user, enriched with their pending + * TransferRequest dynamic field (if any). + * Use this only when transfer state is needed + * Existing operations (deposit, withdraw, claim, etc.) should use `getAlphaFiReceipts`. + * + * @param userAddress - User's wallet address + * @returns AlphaFi receipts with `transferRequest` populated where applicable + */ + async getAlphaFiReceiptsWithTransferRequests(userAddress: string): Promise { + return this.strategyContext.getAlphaFiReceiptsWithTransferRequests(userAddress); + } + /** * Get AlphaFi positions grouped by pool ID, derived from user receipts. * @@ -390,6 +411,44 @@ export class AlphaFiSDK { return this.strategyContext.getPositionsFromAlphaFiReceipts(userAddress); } + // ── Receipt Transfer ────────────────────────────────────────────────────────── + + /** + * Creates an on-chain TransferRequest and starts the 24-hour cooldown period. + * The sender keeps the receipt during the cooldown and must call + * `fulfillTransferRequest` after the cooldown to complete the transfer. + * + * @param options - CreateTransferRequestOptions + * @returns Transaction ready for signing + */ + createTransferRequest(options: CreateTransferRequestOptions): Transaction { + return buildCreateTransferRequestTx(options); + } + + /** + * Removes the on-chain TransferRequest from the receipt. + * Can be called by the current receipt owner at any time — both during + * the cooldown period and after the cooldown has expired (confirm stage). + * + * @param options - CancelTransferRequestOptions + * @returns Transaction ready for signing + */ + cancelTransferRequest(options: CancelTransferRequestOptions): Transaction { + return buildCancelTransferRequestTx(options); + } + + /** + * Transfers ownership of the AlphaFiReceipt to the receiver address stored + * in the on-chain TransferRequest. Can only be called after the 24-hour + * cooldown has passed. + * + * @param options - FulfillTransferRequestOptions + * @returns Transaction ready for signing + */ + fulfillTransferRequest(options: FulfillTransferRequestOptions): Transaction { + return buildFulfillTransferRequestTx(options); + } + /** * Clear all cached data. Useful for forcing fresh data. */ diff --git a/src/core/types.ts b/src/core/types.ts index 4314f19..5579734 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -177,5 +177,38 @@ export interface CetusSwapOptions { tx?: Transaction; } +/** + * Options for initiating a receipt transfer (creates an on-chain TransferRequest). + * Starts the 24-hour cooldown period. + */ +export interface CreateTransferRequestOptions { + /** Object ID of the AlphaFiReceipt to transfer */ + receiptId: string; + /** Wallet address of the intended recipient */ + receiver: string; + /** Optional existing transaction to append to (for PTB composition) */ + tx?: Transaction; +} + +// Options for cancelling a pending receipt transfer. Can be called at any stage (cooldown or confirm). +export interface CancelTransferRequestOptions { + /** Object ID of the AlphaFiReceipt whose transfer request should be cancelled */ + receiptId: string; + /** Owner's wallet address */ + address: string; + /** Optional existing transaction to append to (for PTB composition) */ + tx?: Transaction; +} + +// Options for fulfilling (confirming) a receipt transfer. Can only be called after the 24-hour cooldown has passed. Consumes the receipt and transfers it to the receiver. +export interface FulfillTransferRequestOptions { + /** Object ID of the AlphaFiReceipt to fulfill the transfer for */ + receiptId: string; + /** Current owner's wallet address */ + address: string; + /** Optional existing transaction to append to (for PTB composition) */ + tx?: Transaction; +} + // Re-export domain types for external consumers -export type { AlphaFiReceipt } from '../models/types.js'; +export type { AlphaFiReceipt, TransferRequest } from '../models/types.js'; diff --git a/src/models/blockchain.ts b/src/models/blockchain.ts index 0ed283b..d471317 100644 --- a/src/models/blockchain.ts +++ b/src/models/blockchain.ts @@ -2,7 +2,7 @@ * Blockchain interface wrapper for Sui network operations using GraphQL and JSON-RPC clients. */ -import { SuiClient } from '@mysten/sui/client'; +import { SuiClient, SuiObjectData } from '@mysten/sui/client'; import { SuiGraphQLClient } from '@mysten/sui/graphql'; import { graphql } from '@mysten/sui/graphql/schemas/latest'; import { Transaction } from '@mysten/sui/transactions'; @@ -392,6 +392,27 @@ export class Blockchain { return graphql(query); } + /** Returns the object data of the first dynamic field whose key type contains `keyTypeFragment`, or null. */ + async getDynamicFieldByKeyType( + parentId: string, + keyTypeFragment: string, + ): Promise { + try { + const fields = await this.txBuildClient.getDynamicFields({ parentId }); + const match = fields.data.find((f) => f.name.type.includes(keyTypeFragment)); + if (!match) return null; + + const obj = await this.txBuildClient.getObject({ + id: match.objectId, + options: { showContent: true }, + }); + return obj?.data ?? null; + } catch (err) { + console.error('[AlphaFiSDK] getDynamicFieldByKeyType error:', err); + return null; + } + } + private isCoinTypeSui(coinType: string) { return ( coinType === '0x2::sui::SUI' || diff --git a/src/models/strategyContext.ts b/src/models/strategyContext.ts index bdf1859..e395c1e 100644 --- a/src/models/strategyContext.ts +++ b/src/models/strategyContext.ts @@ -9,11 +9,20 @@ import { Blockchain } from './blockchain.js'; import { CoinInfoProvider } from './coinInfoProvider.js'; import { PoolLabel, StrategyType } from '../strategies/strategy.js'; import { Decimal } from 'decimal.js'; +import { SuiObjectData } from '@mysten/sui/client/index.js'; import { AlphalendClient, Network } from '@alphafi/alphalend-sdk'; -import { AlphaFiReceipt, AprData, CoinInfo, DistributorObject, SlushPositionCap } from './types.js'; +import { + AlphaFiReceipt, + AprData, + CoinInfo, + DistributorObject, + SlushPositionCap, + TransferRequest, +} from './types.js'; import { normalizeStructTag } from '@mysten/sui/utils'; import { ALPHAFI_RECEIPT_TYPE, + ALPHAFI_TRANSFER_REQUEST_KEY_TYPE, CACHE_TTL, DISTRIBUTOR_OBJECT_ID, SLUSH_POSITION_CAP_TYPE, @@ -855,6 +864,65 @@ export class StrategyContext { }); } + /** + * Like `getAlphaFiReceipts`, but enriches each receipt with its pending `TransferRequest` + * (fetched via dynamic field lookup). Use this only when transfer state is needed; + * existing deposit/withdraw flows should continue using `getAlphaFiReceipts`. + */ + async getAlphaFiReceiptsWithTransferRequests(userAddress: string): Promise { + const receipts = await this.getAlphaFiReceipts(userAddress); + if (receipts.length === 0) return []; + + const transferRequests = await Promise.all( + receipts.map((r) => + r.id + ? this.blockchain.getDynamicFieldByKeyType(r.id, ALPHAFI_TRANSFER_REQUEST_KEY_TYPE) + : Promise.resolve(null), + ), + ); + + return receipts.map((r, i) => ({ + ...r, + transferRequest: this.parseTransferRequestField(transferRequests[i]), + })); + } + + /** + * Parses the raw Field wrapper returned by + * `getDynamicFields` into a typed `TransferRequest`. The actual data lives at + * `content.fields.value.fields`, one level deeper than the field wrapper itself. + */ + private parseTransferRequestField(raw: SuiObjectData | null): TransferRequest | null { + if (!raw || raw.content?.dataType !== 'moveObject') return null; + + // Drill through the Field wrapper to reach TransferRequest fields. + const fieldWrapperFields = raw.content.fields as Record; + const transferRequestFields = (fieldWrapperFields.value as Record | undefined) + ?.fields as Record | undefined; + const transferRequest = transferRequestFields ?? fieldWrapperFields; + + const objectIdField = transferRequest.id; + const id = + typeof objectIdField === 'string' + ? objectIdField + : typeof (objectIdField as Record | undefined)?.id === 'string' + ? ((objectIdField as Record).id as string) + : (raw.objectId ?? ''); + const receiver = typeof transferRequest.receiver === 'string' ? transferRequest.receiver : ''; + const receiptId = + typeof transferRequest.receipt_id === 'string' ? transferRequest.receipt_id : ''; + const autoAcceptTimestampRaw = transferRequest.auto_accept_timestamp; + + if (!receiver || autoAcceptTimestampRaw === undefined) return null; + + return { + id, + receiptId, + autoAcceptTimestamp: Number(autoAcceptTimestampRaw), + receiver, + }; + } + private parseAlphaFiReceipts(responses: any[]): AlphaFiReceipt[] { const results: AlphaFiReceipt[] = []; @@ -905,6 +973,7 @@ export class StrategyContext { positionPoolMap, clientAddress: typeof fields?.client_address === 'string' ? fields.client_address : '', imageUrl, + transferRequest: null, }); } diff --git a/src/models/types.ts b/src/models/types.ts index 2ad5332..08f3d34 100644 --- a/src/models/types.ts +++ b/src/models/types.ts @@ -110,6 +110,21 @@ export type SlushPositionCap = { image_url: string; }; +/** + * On-chain TransferRequest object created when a receipt owner initiates a transfer. + * Stored as a dynamic object field on the AlphaFiReceipt. + */ +export type TransferRequest = { + /** On-chain object ID of the TransferRequest */ + id: string; + /** ID of the AlphaFiReceipt */ + receiptId: string; + /** Timestamp (ms) after which the transfer can be confirmed */ + autoAcceptTimestamp: number; + /** Wallet address of the intended receiver */ + receiver: string; +}; + export type AlphaFiReceipt = { id: string; positionPoolMap: Array<{ @@ -121,6 +136,8 @@ export type AlphaFiReceipt = { }>; clientAddress: string; imageUrl: string; + /** Present when a transfer has been initiated. Null when no transfer is pending. */ + transferRequest: TransferRequest | null; }; /** diff --git a/src/services/transferReceipt.ts b/src/services/transferReceipt.ts new file mode 100644 index 0000000..93ee236 --- /dev/null +++ b/src/services/transferReceipt.ts @@ -0,0 +1,49 @@ +import { Transaction } from '@mysten/sui/transactions'; +import { CLOCK_PACKAGE_ID, PACKAGE_IDS } from '../utils/constants.js'; +import { + CancelTransferRequestOptions, + CreateTransferRequestOptions, + FulfillTransferRequestOptions, +} from '../core/types.js'; + +const RECEIPT_MODULE = `${PACKAGE_IDS.ALPHAFI_RECEIPT}::alphafi_receipt`; + +/** Initiates a receipt transfer — creates an on-chain TransferRequest and starts the cooldown. */ +export function buildCreateTransferRequestTx(options: CreateTransferRequestOptions): Transaction { + const tx = options.tx ?? new Transaction(); + + tx.moveCall({ + target: `${RECEIPT_MODULE}::create_request_transfer`, + arguments: [ + tx.object(options.receiptId), + tx.pure.address(options.receiver), + tx.object(CLOCK_PACKAGE_ID), + ], + }); + + return tx; +} + +/** Cancels a pending transfer request. Can be called at any stage (cooldown or confirm). */ +export function buildCancelTransferRequestTx(options: CancelTransferRequestOptions): Transaction { + const tx = options.tx ?? new Transaction(); + + tx.moveCall({ + target: `${RECEIPT_MODULE}::cancel_request_transfer`, + arguments: [tx.object(options.receiptId)], + }); + + return tx; +} + +/** Confirms the transfer after the cooldown has passed, sending the receipt to the receiver. */ +export function buildFulfillTransferRequestTx(options: FulfillTransferRequestOptions): Transaction { + const tx = options.tx ?? new Transaction(); + + tx.moveCall({ + target: `${RECEIPT_MODULE}::fulfill_transfer_request`, + arguments: [tx.object(options.receiptId), tx.object(CLOCK_PACKAGE_ID)], + }); + + return tx; +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index a0c3455..188edfc 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -50,8 +50,11 @@ export const ALPHAFI_RECEIPT_WHITELISTED_ADDRESSES = export const SLUSH_POSITION_CAP_TYPE = '0x41b1def47b6259cd7306e049d6500eabb1a984e25558b56eefa9b6c000a038c3::alphalend_slush_pool::PositionCap'; -export const ALPHAFI_RECEIPT_TYPE = - '0x18533807391b15db5f1f530f54b32553372e5c204d179928d8da0a1753cbb63c::alphafi_receipt::AlphaFiReceipt'; +export const ALPHAFI_RECEIPT_TYPE = `${PACKAGE_IDS.ALPHAFI_RECEIPT}::alphafi_receipt::AlphaFiReceipt`; + +export const ALPHAFI_TRANSFER_REQUEST_TYPE = `${PACKAGE_IDS.ALPHAFI_RECEIPT}::alphafi_receipt::TransferRequest`; + +export const ALPHAFI_TRANSFER_REQUEST_KEY_TYPE = `${PACKAGE_IDS.ALPHAFI_RECEIPT}::alphafi_receipt::TransferRequestKey`; export const LEGACY_ALPHA_POOL_RECEIPT = '0x9bbd650b8442abb082c20f3bc95a9434a8d47b4bef98b0832dab57c1a8ba7123::alphapool::Receipt';