Skip to content
Merged
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
38 changes: 28 additions & 10 deletions api/_bridges/cctp/strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ import {
} from "./utils/constants";
import {
buildCctpTxHyperEvmToHyperCore,
getAmountToHyperCore,
isHyperEvmToHyperCoreRoute,
isToHyperCore,
} from "./utils/hypercore";

const name = "cctp";
Expand Down Expand Up @@ -129,15 +131,23 @@ export function getCctpBridgeStrategy(): BridgeStrategy {
inputToken,
outputToken,
exactInputAmount,
recipient: _recipient,
recipient,
message: _message,
}: GetExactInputBridgeQuoteParams) => {
assertSupportedRoute({ inputToken, outputToken });

const outputAmount = ConvertDecimals(
inputToken.decimals,
outputToken.decimals
)(exactInputAmount);
const outputAmount = isToHyperCore(outputToken.chainId)
? await getAmountToHyperCore({
inputToken,
outputToken,
inputOrOutput: "input",
amount: exactInputAmount,
recipient,
})
: ConvertDecimals(
inputToken.decimals,
outputToken.decimals
)(exactInputAmount);

return {
bridgeQuote: {
Expand All @@ -158,15 +168,23 @@ export function getCctpBridgeStrategy(): BridgeStrategy {
outputToken,
minOutputAmount,
forceExactOutput: _forceExactOutput,
recipient: _recipient,
recipient,
message: _message,
}: GetOutputBridgeQuoteParams) => {
assertSupportedRoute({ inputToken, outputToken });

const inputAmount = ConvertDecimals(
outputToken.decimals,
inputToken.decimals
)(minOutputAmount);
const inputAmount = isToHyperCore(outputToken.chainId)
? await getAmountToHyperCore({
inputToken,
outputToken,
inputOrOutput: "output",
amount: minOutputAmount,
recipient,
})
: ConvertDecimals(
outputToken.decimals,
inputToken.decimals
)(minOutputAmount);

return {
bridgeQuote: {
Expand Down
65 changes: 65 additions & 0 deletions api/_bridges/cctp/utils/hypercore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { tagIntegratorId, tagSwapApiMarker } from "../../../_integrator-id";
import { InvalidParamError } from "../../../_errors";
import { CHAIN_IDs } from "../../../_constants";
import { Token } from "../../../_dexes/types";
import { ConvertDecimals } from "../../../_utils";
import { accountExistsOnHyperCore } from "../../../_hypercore";

const HYPERCORE_ACCOUNT_CREATION_FEE_USDC = 1;

const CORE_WALLET_ADDRESSES = {
// Currently deployed only on HyperEVM Testnet
Expand All @@ -17,6 +21,67 @@ const CORE_DEPOSIT_WALLET_ABI = [
"function deposit(uint256 amount)",
];

export function isToHyperCore(destinationChainId: number) {
return [CHAIN_IDs.HYPERCORE, CHAIN_IDs.HYPERCORE_TESTNET].includes(
destinationChainId
);
}

export async function getAmountToHyperCore(params: {
inputToken: Token;
outputToken: Token;
inputOrOutput: "input" | "output";
amount: BigNumber;
recipient?: string;
}) {
const { inputToken, outputToken, inputOrOutput, amount, recipient } = params;

if (!recipient) {
throw new InvalidParamError({
message: "CCTP: Recipient is not provided",
param: "recipient",
});
}

const recipientExists = await accountExistsOnHyperCore({
account: recipient,
chainId: inputToken.chainId,
});

if (recipientExists) {
return inputOrOutput === "input"
? ConvertDecimals(inputToken.decimals, outputToken.decimals)(amount) // return output amount
: ConvertDecimals(outputToken.decimals, inputToken.decimals)(amount); // return input amount
}

// If recipient does not exist on HyperCore, consider 1 USDC account creation fee
const accountCreationFee = ethers.utils.parseUnits(
HYPERCORE_ACCOUNT_CREATION_FEE_USDC.toString(),
inputOrOutput === "input" ? inputToken.decimals : outputToken.decimals
);

// If provided amount is `inputAmount`, subtract account creation fee and return required output amount
if (inputOrOutput === "input") {
const outputAmount = amount.sub(accountCreationFee);
if (outputAmount.lte(0)) {
throw new InvalidParamError({
message: "CCTP: Amount must exceed account creation fee",
param: "amount",
});
}
return ConvertDecimals(
inputToken.decimals,
outputToken.decimals
)(outputAmount);
}

// If provided amount is `outputAmount`, add account creation fee and return required input amount
return ConvertDecimals(
outputToken.decimals,
inputToken.decimals
)(amount.add(accountCreationFee));
}

export function isHyperEvmToHyperCoreRoute(params: {
inputToken: Token;
outputToken: Token;
Expand Down
14 changes: 12 additions & 2 deletions api/_hypercore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,18 @@ export async function getBalanceOnHyperCore(params: {
return BigNumber.from(decodedQueryResult[0].toString());
}

export async function accountExistsOnHyperCore(params: { account: string }) {
const provider = getProvider(CHAIN_IDs.HYPEREVM);
export async function accountExistsOnHyperCore(params: {
account: string;
chainId?: number;
}) {
const chainId = params.chainId ?? CHAIN_IDs.HYPEREVM;

if (![CHAIN_IDs.HYPEREVM, CHAIN_IDs.HYPEREVM_TESTNET].includes(chainId)) {
throw new Error("Can't check account existence on non-HyperCore chain");
}

const provider = getProvider(chainId);

const balanceCoreCalldata = ethers.utils.defaultAbiCoder.encode(
["address"],
[params.account]
Expand Down
168 changes: 166 additions & 2 deletions test/api/_bridges/cctp/utils/hypercore.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,172 @@
import { isHyperEvmToHyperCoreRoute } from "../../../../../api/_bridges/cctp/utils/hypercore";
import { BigNumber } from "ethers";

import {
isHyperEvmToHyperCoreRoute,
getAmountToHyperCore,
} from "../../../../../api/_bridges/cctp/utils/hypercore";
import { CHAIN_IDs } from "../../../../../api/_constants";
import { TOKEN_SYMBOLS_MAP } from "../../../../../api/_constants";
import * as hypercoreModule from "../../../../../api/_hypercore";

jest.mock("../../../../../api/_hypercore");

describe("bridges/cctp/utils/hypercore", () => {
const mockAccountExistsOnHyperCore =
hypercoreModule.accountExistsOnHyperCore as jest.MockedFunction<
typeof hypercoreModule.accountExistsOnHyperCore
>;

beforeEach(() => {
jest.clearAllMocks();
});

describe("#getAmountToHyperCore()", () => {
const inputToken = {
...TOKEN_SYMBOLS_MAP.USDC,
address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.HYPEREVM],
chainId: CHAIN_IDs.HYPEREVM,
};

const outputToken = {
...TOKEN_SYMBOLS_MAP.USDC,
address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.HYPERCORE],
chainId: CHAIN_IDs.HYPERCORE,
};

test("should throw error if recipient is not provided", async () => {
const params = {
inputToken,
outputToken,
inputOrOutput: "input" as const,
amount: BigNumber.from(1_000_000),
};

await expect(getAmountToHyperCore(params)).rejects.toThrow(
"CCTP: Recipient is not provided"
);
});

test("should return correct output amount when recipient exists (input mode)", async () => {
mockAccountExistsOnHyperCore.mockResolvedValue(true);

const params = {
inputToken,
outputToken,
inputOrOutput: "input" as const,
amount: BigNumber.from(1_000_000),
recipient: "0x1234567890123456789012345678901234567890",
};

const result = await getAmountToHyperCore(params);
expect(result).toEqual(params.amount); // Same amount, same decimals
});

test("should return correct input amount when recipient exists (output mode)", async () => {
mockAccountExistsOnHyperCore.mockResolvedValue(true);

const params = {
inputToken,
outputToken,
inputOrOutput: "output" as const,
amount: BigNumber.from(2_000_000),
recipient: "0x1234567890123456789012345678901234567890",
};

const result = await getAmountToHyperCore(params);
expect(result).toEqual(params.amount); // Same amount, same decimals
});

test("should subtract account creation fee when recipient doesn't exist (input mode)", async () => {
mockAccountExistsOnHyperCore.mockResolvedValue(false);

const params = {
inputToken,
outputToken,
inputOrOutput: "input" as const,
amount: BigNumber.from(10_000_000),
recipient: "0x1234567890123456789012345678901234567890",
};

const outputAmount = await getAmountToHyperCore(params);
// Should be 10 - 1 = 9 USDC (9000000)
expect(outputAmount.toNumber()).toEqual(9_000_000);
});

test("should add account creation fee when recipient doesn't exist (output mode)", async () => {
mockAccountExistsOnHyperCore.mockResolvedValue(false);

const params = {
inputToken,
outputToken,
inputOrOutput: "output" as const,
amount: BigNumber.from(5_000_000),
recipient: "0x1234567890123456789012345678901234567890",
};

const inputAmount = await getAmountToHyperCore(params);
// Should be 5 + 1 = 6 USDC (6000000)
expect(inputAmount.toNumber()).toEqual(6_000_000);
});

test("should throw error if account creation fee is greater than input amount", async () => {
mockAccountExistsOnHyperCore.mockResolvedValue(false);

const params = {
inputToken,
outputToken,
inputOrOutput: "input" as const,
amount: BigNumber.from(500_000), // 0.5 USDC (less than 1 USDC fee)
recipient: "0x1234567890123456789012345678901234567890",
};

await expect(getAmountToHyperCore(params)).rejects.toThrow(
"CCTP: Amount must exceed account creation fee"
);
});

test("should handle different token decimals when recipient exists", async () => {
mockAccountExistsOnHyperCore.mockResolvedValue(true);

// This case might happen when CCTP supports USDC-SPOT
const outputTokenWith8Decimals = {
...outputToken,
decimals: 8,
};

const params = {
inputToken,
outputToken: outputTokenWith8Decimals,
inputOrOutput: "input" as const,
amount: BigNumber.from(1_000_000),
recipient: "0x1234567890123456789012345678901234567890",
};

const outputAmount = await getAmountToHyperCore(params);
expect(outputAmount.toNumber()).toEqual(100_000_000);
});

test("should handle different token decimals when recipient doesn't exist (input mode)", async () => {
mockAccountExistsOnHyperCore.mockResolvedValue(false);

// This case might happen when CCTP supports USDC-SPOT
const outputTokenWith8Decimals = {
...outputToken,
decimals: 8,
};

const params = {
inputToken,
outputToken: outputTokenWith8Decimals,
inputOrOutput: "input" as const,
amount: BigNumber.from(10_000_000),
recipient: "0x1234567890123456789012345678901234567890",
};

const outputAmount = await getAmountToHyperCore(params);
expect(outputAmount.toNumber()).toEqual(900_000_000);
});
});

describe("bridges -> cctp -> hypercore utils", () => {
describe("#isHyperEvmToHyperCoreRoute()", () => {
test("should return true for HyperEVM -> HyperCore", () => {
const params = {
Expand Down
Loading