Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,27 @@ import { CetusSwap } from '../models/swap.js';
import type { AlphaFiReceipt, PoolBalance, PoolData, UserPortfolioData } from '../models/types.js';
import {
AlphaFiSDKConfig,
CancelTransferRequestOptions,
CancelWithdrawSlushOptions,
CetusSwapOptions,
CetusSwapQuoteOptions,
ClaimAirdropOptions,
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';
Expand Down Expand Up @@ -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<AlphaFiReceipt[]> {
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<AlphaFiReceipt[]> {
return this.strategyContext.getAlphaFiReceiptsWithTransferRequests(userAddress);
}

/**
* Get AlphaFi positions grouped by pool ID, derived from user receipts.
*
Expand All @@ -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.
*/
Expand Down
35 changes: 34 additions & 1 deletion src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
23 changes: 22 additions & 1 deletion src/models/blockchain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<SuiObjectData | null> {
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' ||
Expand Down
71 changes: 70 additions & 1 deletion src/models/strategyContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<AlphaFiReceipt[]> {
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<TransferRequestKey, TransferRequest> 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<Key,Value> wrapper to reach TransferRequest fields.
const fieldWrapperFields = raw.content.fields as Record<string, unknown>;
const transferRequestFields = (fieldWrapperFields.value as Record<string, unknown> | undefined)
?.fields as Record<string, unknown> | undefined;
const transferRequest = transferRequestFields ?? fieldWrapperFields;

const objectIdField = transferRequest.id;
const id =
typeof objectIdField === 'string'
? objectIdField
: typeof (objectIdField as Record<string, unknown> | undefined)?.id === 'string'
? ((objectIdField as Record<string, unknown>).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[] = [];

Expand Down Expand Up @@ -905,6 +973,7 @@ export class StrategyContext {
positionPoolMap,
clientAddress: typeof fields?.client_address === 'string' ? fields.client_address : '',
imageUrl,
transferRequest: null,
});
}

Expand Down
17 changes: 17 additions & 0 deletions src/models/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand All @@ -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;
};

/**
Expand Down
49 changes: 49 additions & 0 deletions src/services/transferReceipt.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 5 additions & 2 deletions src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading