Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
42 changes: 11 additions & 31 deletions sdk/src/gateway/abi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,20 @@ export const offrampCaller = [
internalType: 'uint256',
},
{
name: 'satFeesMax',
name: 'satSolverFeeMax',
type: 'uint256',
internalType: 'uint256',
},
{
name: 'satAffiliateFee',
type: 'uint256',
internalType: 'uint256',
},
{
name: 'affiliateFeeRecipient',
type: 'address',
internalType: 'address',
},
{
name: 'creationDeadline',
type: 'uint256',
Expand Down Expand Up @@ -120,36 +130,6 @@ export const offrampCaller = [
outputs: [],
stateMutability: 'nonpayable',
},
{
type: 'function',
name: 'getOrderDetails',
inputs: [
{
name: 'orderId',
type: 'uint256',
internalType: 'uint256',
},
],
outputs: [
{
name: '',
type: 'tuple',
internalType: 'struct OfframpOrderDetails',
components: [
{ name: 'satAmountLocked', type: 'uint256', internalType: 'uint256' },
{ name: 'satFeesMax', type: 'uint256', internalType: 'uint256' },
{ name: 'owner', type: 'address', internalType: 'address' },
{ name: 'outputScript', type: 'bytes', internalType: 'bytes' },
{ name: 'status', type: 'uint8', internalType: 'enum OfframpOrderStatus' },
{ name: 'timestamp', type: 'uint256', internalType: 'uint256' },
{ name: 'token', type: 'address', internalType: 'address' },
{ name: 'solverOwner', type: 'address', internalType: 'address' },
{ name: 'solverRecipient', type: 'address', internalType: 'address' },
],
},
],
stateMutability: 'view',
},
] as const;

export const compoundV2CTokenAbi = parseAbi([
Expand Down
201 changes: 85 additions & 116 deletions sdk/src/gateway/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
import { claimDelayAbi, offrampCaller, strategyCaller } from './abi';
import { BaseClient } from './base-client';
import StrategyClient from './strategy';
import { ADDRESS_LOOKUP, getTokenAddress, getTokenDecimals, getTokenSlots } from './tokens';

Check warning on line 27 in sdk/src/gateway/client.ts

View workflow job for this annotation

GitHub Actions / Tests

'getTokenDecimals' is defined but never used
import {
BitcoinSigner,
BumpFeeParams,
Expand All @@ -45,7 +45,7 @@
OfframpOrderStatus,
OfframpQuote,
OfframpRawOrder,
OnchainOfframpOrderDetails,

Check warning on line 48 in sdk/src/gateway/client.ts

View workflow job for this annotation

GitHub Actions / Tests

'OnchainOfframpOrderDetails' is defined but never used
OnrampFeeBreakdownRaw,
OnrampOrder,
OnrampOrderResponse,
Expand All @@ -64,7 +64,7 @@
convertOrderDetailsToRaw,
formatBtc,
getChainConfig,
parseOrderStatus,

Check warning on line 67 in sdk/src/gateway/client.ts

View workflow job for this annotation

GitHub Actions / Tests

'parseOrderStatus' is defined but never used
slugify,
stripHexPrefix,
toHexScriptPubKey,
Expand All @@ -89,6 +89,16 @@
*/
export const ORDER_DEADLINE_IN_SECONDS = 30 * 60; // 30 minutes

/**
* Address of the Offramp Registry contract on the BOB Mainnet.
*/
export const MAINNET_OFFRAMP_REGISTRY_ADDRESS: Address = '0x3D65CD168f27aeddEb08Ca31CAC5e5C12F3BB16D'; // TODO: Replace with new v2 address

/**
* Address of the Offramp Registry contract on the BOB Testnet (Signet).
*/
export const TESTNET_OFFRAMP_REGISTRY_ADDRESS: Address = '0x70e5e53b4f48be863a5a076ff6038a91377da0dd'; // TODO: Replace with new v2 address

interface EvmWalletClientParams {
/**
* The wallet client used to interact with the EVM chain.
Expand Down Expand Up @@ -182,6 +192,37 @@
return this.chain.id;
}

private async mapRawOrderToOfframpOrder(order: OfframpRawOrder): Promise<OfframpOrder> {
const status = order.status as OfframpOrderStatus;
const offrampRegistryAddress = order.offrampRegistryAddress as Address;

const canOrderBeUnlocked = await this.canOrderBeUnlocked(
status,
Number(order.orderTimestamp),
offrampRegistryAddress
);

return {
orderId: BigInt(order.orderId),
token: order.token as Address,
satAmountLocked: BigInt(order.satAmountLocked),
satFeesMax: BigInt(order.satFeesMax),
status,
orderTimestamp: Number(order.orderTimestamp),
submitOrderEvmTx: order.submitOrderEvmTx,
refundedEvmTx: order.refundedEvmTx,
btcTx: order.btcTx,
shouldFeesBeBumped: order.shouldFeesBeBumped,
canOrderBeUnlocked,
offrampRegistryAddress,
satAffiliateFee: BigInt(order.satAffiliateFee),
affiliateFeeRecipient: order.affiliateFeeRecipient as Address,
offrampRegistryVersion: Number(order.offrampRegistryVersion),
bumpFeeAmountInSats: order.bumpFeeAmountInSats !== null ? BigInt(order.bumpFeeAmountInSats) : null,
userAddress: order.userAddress as Address,
};
}

/**
* Fetches a quote for token swaps between Bitcoin and BOB.
*
Expand Down Expand Up @@ -266,7 +307,7 @@

const [offrampOrder, offrampRegistryAddress, feeValues, gasPrice] = await Promise.all([
this.createOfframpOrder(offrampQuote, params),
this.fetchOfframpRegistryAddress(),
this.getOfframpRegistryAddress(),
publicClient.estimateFeesPerGas(),
publicClient.getGasPrice(),
]);
Expand Down Expand Up @@ -430,21 +471,17 @@
/**
* Fetches the offramp registry contract address.
*
* @returns Promise resolving to the registry contract address
* @returns The registry contract address.
*/
async fetchOfframpRegistryAddress(): Promise<Address> {
const response = await this.safeFetch(
`${this.baseUrl}/offramp-registry-address`,
undefined,
'Failed to fetch offramp registry contract address'
);
if (!response.ok) {
const errorData = await response.json().catch(() => null);
const apiMessage = errorData?.message;
const errorMessage = apiMessage || 'Failed to fetch offramp registry contract';
throw new Error(errorMessage);
getOfframpRegistryAddress(): Address {
const chainId = this.chainId;
if (chainId === bob.id) {
return MAINNET_OFFRAMP_REGISTRY_ADDRESS;
} else if (chainId === bobSepolia.id) {
return TESTNET_OFFRAMP_REGISTRY_ADDRESS;
} else {
throw new Error('Invalid output chain: could not find chain id');
}
return response.text() as Promise<Address>;
}

/**
Expand Down Expand Up @@ -534,6 +571,7 @@
fastestFeeRate: rawQuote.feeBreakdown.fastestFeeRate,
},
amountReceiveInSat: rawQuote.amountLockInSat - rawQuote.feeBreakdown.overallFeeSats,
affiliateFeeRecipient: zeroAddress as Address, //TODO: fix latter
};
}

Expand Down Expand Up @@ -564,7 +602,9 @@
offrampArgs: [
{
satAmountToLock: BigInt(quote.amountLockInSat),
satFeesMax: BigInt(quote.feeBreakdown.overallFeeSats),
satSolverFeeMax: BigInt(quote.feeBreakdown.overallFeeSats),
satAffiliateFee: BigInt(quote.feeBreakdown.affiliateFeeSats),
affiliateFeeRecipient: quote.affiliateFeeRecipient,
creationDeadline: BigInt(quote.deadline),
outputScript: receiverAddress as Hex,
token: quote.token,
Expand All @@ -583,40 +623,27 @@
*/
async bumpFeeForOfframpOrder({
orderId,
offrampRegistryAddress,
walletClient,
publicClient,
}: BumpFeeParams & EvmWalletClientParams): Promise<Hash> {
// check order status via viem should be Active/Accepted
const orderDetails: OnchainOfframpOrderDetails = await this.fetchOfframpOrder(orderId);
const orderDetails = await this.fetchOfframpOrder(orderId, offrampRegistryAddress);

if (orderDetails.status !== 'Active') {
throw new Error(`Offramp order needs to be Active for bumping fees`);
}

const [shouldFeesBeBumped, newFeeSat, error] = await this.getBumpFeeRequirement(
orderDetails.token,
orderDetails.satAmountLocked,
orderDetails.satFeesMax,
orderDetails.owner
);

if (error) {
throw new Error(`Unable to calculate a new quote for the order. reason: (${error.toString()}).`);
}

if (!shouldFeesBeBumped) {
throw new Error(
`Current fees (${orderDetails.satFeesMax.toString()} sat) are sufficient to satisfy the order, as the new required fees (${newFeeSat.toString()} sat) are lower or equal.`
);
// Ensure bump fee is required
if (orderDetails.bumpFeeAmountInSats === null) {
throw new Error(`No need to bump fees, the current fees are sufficient`);
}

const offrampRegistryAddress: Address = await this.fetchOfframpRegistryAddress();

const { request } = await publicClient.simulateContract({
address: offrampRegistryAddress,
abi: offrampCaller,
functionName: 'bumpFeeOfExistingOrder',
args: [orderId, BigInt(newFeeSat)],
args: [orderId, BigInt(orderDetails.bumpFeeAmountInSats)],
account: walletClient.account,
});

Expand All @@ -636,19 +663,18 @@
async unlockOfframpOrder({
orderId,
receiver,
offrampRegistryAddress,
walletClient,
publicClient,
}: UnlockOrderParams & EvmWalletClientParams): Promise<Hash> {
// check order status via viem should be Active/Accepted
const orderDetails: OnchainOfframpOrderDetails = await this.fetchOfframpOrder(orderId);
const orderDetails: OfframpOrder = await this.fetchOfframpOrder(orderId, offrampRegistryAddress); // Use API to get status

// Processed and refunded order can't be unlocked
if (orderDetails.status == 'Processed' || orderDetails.status == 'Refunded') {
throw new Error(`Offramp order already processed / refunded`);
}

const offrampRegistryAddress: Address = await this.fetchOfframpRegistryAddress();

// Active order can be unlocked and Accepted order can be unlocked after delay
if (
!(await this.canOrderBeUnlocked(orderDetails.status, orderDetails.orderTimestamp, offrampRegistryAddress))
Expand Down Expand Up @@ -683,63 +709,8 @@
'Failed to fetch offramp orders'
);
const rawOrders: OfframpRawOrder[] = await response.json();
const offrampRegistryAddress: Address = await this.fetchOfframpRegistryAddress();

return Promise.all(
rawOrders.map(async (order) => {
const status = order.status as OfframpOrderStatus;
const canOrderBeUnlocked = await this.canOrderBeUnlocked(
status,
Number(order.orderTimestamp),
offrampRegistryAddress
);

return {
...order,
status,
token: order.token as Address,
orderId: BigInt(order.orderId.toString()),
satAmountLocked: BigInt(order.satAmountLocked.toString()),
satFeesMax: BigInt(order.satFeesMax.toString()),
orderTimestamp: Number(order.orderTimestamp),
shouldFeesBeBumped: order.shouldFeesBeBumped,
canOrderBeUnlocked,
offrampRegistryAddress,
};
})
);
}

/**
* Determines if an offramp order requires a fee bump based on current market rates.
*
* @param token Token address
* @param satAmountLocked Amount locked in satoshis
* @param satFeesMax Current maximum fee in satoshis
* @returns Promise resolving to [shouldBump, newFeeSat, error?]
* @throws {Error} If quote fetch fails
*/
private async getBumpFeeRequirement(
token: Address,
satAmountLocked: bigint,
satFeesMax: bigint,
userAddress: Address
): Promise<[boolean, bigint, string?]> {
const decimals = getTokenDecimals(token);
if (decimals === undefined) {
throw new Error('Tokens with less than 8 decimals are not supported');
}

const amountInToken = satAmountLocked * BigInt(10 ** (decimals - 8));

try {
const offrampQuote = await this.fetchOfframpQuote(token, amountInToken, userAddress);
const shouldBump = satFeesMax < offrampQuote.feeBreakdown.overallFeeSats;
return [shouldBump, BigInt(offrampQuote.feeBreakdown.overallFeeSats)];
} catch (err) {
// Return false and 0n with an error message if fetching the quote fails
throw new Error(`Error fetching offramp quote: ${err.message || err}`);
}
return Promise.all(rawOrders.map((order) => this.mapRawOrderToOfframpOrder(order)));
}

async canOrderBeUnlocked(
Expand Down Expand Up @@ -768,32 +739,30 @@
* Fetches on-chain details for a specific offramp order.
*
* @param orderId The order ID
* @param registryAddress The registry Address the order ID belongs to
* @returns Promise resolving to on-chain order details
*/
private async fetchOfframpOrder(orderId: bigint): Promise<OnchainOfframpOrderDetails> {
const offrampRegistryAddress: Address = await this.fetchOfframpRegistryAddress();
const publicClient = viemClient(this.chain);

const order = await publicClient.readContract({
address: offrampRegistryAddress,
abi: offrampCaller,
functionName: 'getOrderDetails',
args: [orderId],
private async fetchOfframpOrder(orderId: bigint, registryAddress: Address): Promise<OfframpOrder> {
const queryParams = new URLSearchParams({
registryAddress: registryAddress.toString(),
orderId: orderId.toString(),
});

return {
orderId,
token: order.token as Address,
satAmountLocked: order.satAmountLocked,
satFeesMax: order.satFeesMax,
owner: order.owner as Address,
solverOwner: order.solverOwner !== (zeroAddress as Address) ? (order.solverOwner as Address) : null,
solverRecipient:
order.solverRecipient !== (zeroAddress as Address) ? (order.solverRecipient as Address) : null,
outputScript: order.outputScript,
status: parseOrderStatus(Number(order.status)) as OnchainOfframpOrderDetails['status'],
orderTimestamp: Number(order.timestamp),
};
const response = await this.safeFetch(
`${this.baseUrl}/offramp-order?${queryParams}`,
undefined,
'Failed to fetch offramp order'
);

if (!response.ok) {
const errorData = await response.json().catch(() => null);
const apiMessage = errorData?.message;
const errorMessage = apiMessage || `Failed to get offramp order`;
throw new Error(`${errorMessage}`);
}

const offrampRawOrder: OfframpRawOrder = await response.json();
return await this.mapRawOrderToOfframpOrder(offrampRawOrder);
}

/**
Expand Down Expand Up @@ -939,7 +908,7 @@
const tokenAddress = getTokenAddress(this.chainId, params.fromToken.toLowerCase());
const [offrampOrder, offrampRegistryAddress] = await Promise.all([
this.createOfframpOrder(data, params),
this.fetchOfframpRegistryAddress(),
this.getOfframpRegistryAddress(),
]);

const accountAddress = walletClient.account?.address ?? (params.fromUserAddress as Address);
Expand Down
Loading