@robertprp/intents-sdk
Version:
Shogun Network Intent-based cross-chain swaps SDK
328 lines (290 loc) • 11.8 kB
text/typescript
import { sha256, toBytes, type TypedDataDefinition } from 'viem';
import { isEvmChain, ChainID, chainIdToChainTypeMap, type SupportedChain } from '../../chains.js';
import { ValidationError } from '../../errors/index.js';
import {
type ChainPreparedData,
type CrossChainUserIntentRequest,
type ExecutionDetails,
type Hash,
type SourceChainData,
} from '../../types/intent.js';
import { CrossChainOrderValidator } from '../../utils/order-validator.js';
import { Parsers } from '../../utils/parsers.js';
import { type ExtraTransfer } from './common.js';
import type { ApiResponse } from '../../types/api.js';
import { BaseSDK } from '../sdk.js';
import { getEVMCrossChainOrderTypedData } from '../evm/order-signature.js';
import { getSolanaCrossChainOrderInstructions } from '../solana/order-instructions.js';
import { getSuiOrderTransaction } from '../sui/order-transaction.js';
import { QuoteProvider } from '../../utils/quote/aggregator.js';
export type CreateCrossChainOrderParams = {
/** Source chain ID where tokens will be sent from */
sourceChainId: SupportedChain;
/** Token address on the source chain to be swapped */
sourceTokenAddress: string;
/** Amount of source tokens to swap with decimals */
sourceTokenAmount: bigint;
/** Destination chain ID where tokens will be received */
destinationChainId: SupportedChain;
/** Token address on the destination chain to receive */
destinationTokenAddress: string;
/** Minimum amount of destination tokens to receive */
destinationTokenMinAmount?: bigint;
/** Recipient wallet address on the destination chain */
destinationAddress: string;
/** Minimum amount of stablecoins in the intermediate swap */
minStablecoinAmount?: bigint;
/** Timestamp (in seconds) after which the order expires */
deadline: number;
/** Extra transfers to be made */
extraTransfers?: ExtraTransfer[];
/** Stop loss max out */
stopLossMaxOut?: bigint;
/** Take profit min out */
takeProfitMinOut?: bigint;
};
/**
* Represents a X-chain swap order
* Contains all the information needed to execute the order on both source and destination chains
*/
export class CrossChainOrder {
/** User's wallet address that initiates the order */
public user: string;
/** Source chain ID where tokens will be sent from */
public sourceChainId: SupportedChain;
/** Token address on the source chain to be swapped */
public sourceTokenAddress: string;
/** Amount of source tokens to swap */
public sourceTokenAmount: bigint;
/** Destination chain ID where tokens will be received */
public destinationChainId: SupportedChain;
/** Token address on the destination chain to receive */
public destinationTokenAddress: string;
/** Minimum amount of destination tokens to receive */
public destinationTokenMinAmount: bigint;
/** Recipient wallet address on the destination chain */
public destinationAddress: string;
/** Minimum amount of stablecoins in the intermediate swap */
public minStablecoinAmount: bigint;
/** Timestamp (in seconds) after which the order expires */
public deadline: number;
/** Extra transfers to be made */
public extraTransfers?: ExtraTransfer[];
/** Stop loss max out */
public stopLossMaxOut?: bigint;
/** Take profit min out */
public takeProfitMinOut?: bigint;
private constructor(params: CreateCrossChainOrderParams & { user: string }) {
this.user = params.user;
this.sourceChainId = params.sourceChainId;
this.sourceTokenAddress = params.sourceTokenAddress;
this.sourceTokenAmount = params.sourceTokenAmount;
this.destinationChainId = params.destinationChainId;
this.destinationTokenAddress = params.destinationTokenAddress;
this.destinationTokenMinAmount = params.destinationTokenMinAmount ?? 1n;
this.destinationAddress = params.destinationAddress;
this.minStablecoinAmount = params.minStablecoinAmount ?? 1n;
this.deadline = params.deadline;
this.extraTransfers = params.extraTransfers;
this.stopLossMaxOut = params.stopLossMaxOut;
this.takeProfitMinOut = params.takeProfitMinOut;
}
/**
* Factory method to create and validate a new Order instance
* @param input Order parameters
* @returns Validated Order instance
* @throws {ValidationError} If order validation fails
*/
public static async create(input: CreateCrossChainOrderParams & { user: string }): Promise<CrossChainOrder> {
// Validate first on creation
await new CrossChainOrderValidator().validateOrder({ ...input, user: input.user });
const { minStablecoinAmount, destinationTokenMinAmount } = await this.calculateAmountOutMin(input);
const order = new CrossChainOrder({
...input,
minStablecoinAmount: minStablecoinAmount,
destinationTokenMinAmount: destinationTokenMinAmount,
user: input.user,
});
if (isEvmChain(order.sourceChainId)) {
return order;
}
const randomPreparedData = order.getRandomPreparedData();
const intentRequest = order.toIntentRequest(randomPreparedData);
await BaseSDK.validateCrossChainOrder(intentRequest);
return order;
}
/// This is needed because API requires the prepared data to be sent
/// In the cases of Solana and Sui, if we want the real data, we must send the order on-chain before validating.
/// And that is something we cannot do before validating the order on the API side.
private getRandomPreparedData(): ChainPreparedData {
const chainId = this.sourceChainId;
const chainType = chainIdToChainTypeMap[chainId];
switch (chainType) {
case 'EVM': {
return {
nonce: String(Math.floor(Math.random() * 10000000)),
signature: '0x0000000000000000000000000000000000000000000000000000000000000000',
};
}
case 'Solana': {
return { orderPubkey: 'DFNAjFAvS4GF98Tp1kiyLvEHM3wjGXibCfF86nnmhuVc' };
}
case 'Sui': {
const digest = 'FQWndwYJhNQUoHyvR8UuhGURC2EKx9eWErFm9Tc2DggF';
return { transactionHash: digest };
}
default: {
throw new Error('Chain type not supported');
}
}
}
private static async calculateAmountOutMin(
input: CreateCrossChainOrderParams,
): Promise<{ minStablecoinAmount: bigint; destinationTokenMinAmount: bigint }> {
const { destinationTokenMinAmount, stopLossMaxOut, minStablecoinAmount } = input;
if (stopLossMaxOut !== undefined) {
return {
destinationTokenMinAmount: 1n,
minStablecoinAmount: 1n,
};
}
if (!minStablecoinAmount || !destinationTokenMinAmount) {
const quote = await QuoteProvider.getQuote({
sourceChainId: input.sourceChainId,
tokenIn: input.sourceTokenAddress,
amount: input.sourceTokenAmount,
destChainId: input.destinationChainId,
tokenOut: input.destinationTokenAddress,
});
return {
destinationTokenMinAmount: quote.estimatedAmountOutReduced,
minStablecoinAmount: quote.estimatedAmountInAsMinStablecoinAmount,
};
}
return {
destinationTokenMinAmount,
minStablecoinAmount,
};
}
/**
* Gets the execution details for the destination chain
* These details are used to complete the order on the destination chain
* @returns Structured execution details object
*/
public getExecutionDetails(): ExecutionDetails {
return {
destChainId: this.destinationChainId,
tokenOut: this.destinationTokenAddress,
destinationAddress: this.destinationAddress,
amountOutMin: this.destinationTokenMinAmount,
extraTransfers: this.extraTransfers,
};
}
public executionDetailsHashToBytes(): Uint8Array {
const executionDetailsHash = this.getExecutionDetailsHash().slice(2);
const executionHashByteSlice = Buffer.from(executionDetailsHash, 'hex');
return new Uint8Array(executionHashByteSlice);
}
/**
* Generates a cryptographic hash of the execution details
* This hash is used to verify order integrity across chains
* @returns SHA-256 hash of the execution details as a 0x-prefixed hex string
* @throws {ValidationError} If hash generation fails
*/
public getExecutionDetailsHash(): Hash {
try {
const executionDetails = this.getExecutionDetails();
const executionDetailsString = JSON.stringify(executionDetails, Parsers.bigIntReplacer);
const bytes = toBytes(executionDetailsString);
return sha256(bytes);
} catch (error) {
throw new ValidationError(
'Failed to generate execution details hash',
error instanceof Error ? error : new Error(String(error)),
);
}
}
public toIntentRequest(preparedData: ChainPreparedData): CrossChainUserIntentRequest {
const sourceChain = this.sourceChainId;
const sourceChainType = chainIdToChainTypeMap[sourceChain];
const executionDetails = JSON.stringify(this.getExecutionDetails(), Parsers.bigIntReplacer);
return {
genericData: this.toSourceChainData(),
executionDetails,
chainSpecificData: {
[sourceChainType]: preparedData,
},
};
}
/**
* Converts the order to the format required for source chain processing
* Used when sending the order to the auctioneer for execution
* @returns Object containing source chain-specific data
*/
public toSourceChainData() {
let data: SourceChainData = {
user: this.user,
srcChainId: this.sourceChainId,
tokenIn: this.sourceTokenAddress,
amountIn: this.sourceTokenAmount,
minStablecoinsAmount: this.minStablecoinAmount,
deadline: this.deadline,
executionDetailsHash: this.getExecutionDetailsHash(),
extraTransfers: this.extraTransfers,
};
if (this.stopLossMaxOut !== undefined) {
data.stopLossMaxOut = this.stopLossMaxOut;
}
if (this.takeProfitMinOut !== undefined) {
data.takeProfitMinOut = this.takeProfitMinOut;
}
return data;
}
/**
* Serializes the order to a JSON-compatible object
* Converts bigint values to strings to ensure proper JSON serialization
* @returns JSON-serializable representation of the order
*/
public toJSON() {
return {
user: this.user,
sourceChainId: this.sourceChainId as number,
sourceTokenAddress: this.sourceTokenAddress,
sourceTokenAmount: this.sourceTokenAmount.toString(),
destinationChainId: this.destinationChainId as number,
destinationTokenAddress: this.destinationTokenAddress,
destinationTokenMinAmount: this.destinationTokenMinAmount.toString(),
destinationAddress: this.destinationAddress,
minStablecoinAmount: this.minStablecoinAmount.toString(),
deadline: this.deadline,
executionDetailsHash: this.getExecutionDetailsHash(),
extraTransfers: this.extraTransfers,
stopLossMaxOut: this.stopLossMaxOut,
takeProfitMinOut: this.takeProfitMinOut,
};
}
public sendToAuctioneer(preparedData: ChainPreparedData): Promise<ApiResponse> {
return BaseSDK.sendCrossChainOrder({
order: this,
preparedData,
});
}
public async toEVMTypedData(): Promise<{ orderTypedData: TypedDataDefinition; nonce: bigint }> {
if (!isEvmChain(this.sourceChainId)) {
throw new ValidationError('Source chain is not EVM');
}
return getEVMCrossChainOrderTypedData(this);
}
public async toSolanaInstructionsByteArray() {
if (this.sourceChainId !== ChainID.Solana) {
throw new ValidationError('Source chain is not Solana');
}
return getSolanaCrossChainOrderInstructions(this);
}
public async toSuiTransaction() {
if (this.sourceChainId !== ChainID.Sui) {
throw new ValidationError('Source chain is not Sui');
}
return getSuiOrderTransaction(this);
}
}