Skip to content

Commit 6d49f1b

Browse files
feat(frontend): implement OneSec services (#12623)
# Motivation - Adds onesec-swap.services.ts with four exported functions that wrap the onesec-bridge SDK: fetchOneSecEvmToIcpQuote, fetchOneSecIcpToEvmQuote, executeOneSecIcpToEvmBridge, and executeOneSecEvmToIcpBridge - Quote functions share a private resolveQuoteFromPlan helper that runs the plan's fee-check step and maps the result to SwapMappedResult - EVM → ICP execution uses the forwarding address pattern: build plan → run fee step → retrieve deterministic forwarding address → send EVM tokens via OISY's send service → run remaining SDK steps (notify, validate, wait for ICP tx) - ICP → EVM execution runs all SDK steps sequentially, keeping the UI at INITIALIZATION for step 0 (fee check) and advancing to SWAP for steps 1+ - Adds a full Vitest suite (27 tests) covering guard conditions, both quote directions, both execution paths, progress reporting order, and all failure branches including setFailedProgressStep callbacks
1 parent c498274 commit 6d49f1b

4 files changed

Lines changed: 759 additions & 1 deletion

File tree

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
import { ONESEC_SWAP_ENABLED } from '$env/rest/onesec.env';
2+
import { send } from '$eth/services/send.services';
3+
import type { IcToken } from '$icp/types/ic-token';
4+
import { isIcToken } from '$icp/validation/ic-token.validation';
5+
import { getAgent } from '$lib/actors/agents.ic';
6+
import { authIdentity } from '$lib/derived/auth.derived';
7+
import { ProgressStepsSwap } from '$lib/enums/progress-steps';
8+
import {
9+
SwapProvider,
10+
type EvmQuoteParams,
11+
type IcpBridgeQuoteParams,
12+
type OneSecEvmToIcpParams,
13+
type OneSecIcpToEvmParams,
14+
type SwapMappedResult
15+
} from '$lib/types/swap';
16+
import { consoleError } from '$lib/utils/console.utils';
17+
import { isNetworkIdICP } from '$lib/utils/network.utils';
18+
import { computeReceiveAmount, ICP_LEDGER_TO_TOKEN } from '$lib/utils/onesec-swap.utils';
19+
import { parseToken } from '$lib/utils/parse.utils';
20+
import { isNullish, nonNullish } from '@dfinity/utils';
21+
import {
22+
EvmToIcpBridgeBuilder,
23+
IcpToEvmBridgeBuilder,
24+
type BridgingPlan,
25+
type EvmChain
26+
} from 'onesec-bridge';
27+
import { get } from 'svelte/store';
28+
29+
const resolveQuoteFromPlan = async ({
30+
plan,
31+
amount,
32+
decimals
33+
}: {
34+
plan: BridgingPlan;
35+
amount: bigint;
36+
decimals: number;
37+
}): Promise<SwapMappedResult | undefined> => {
38+
const feeStep = plan.nextStepToRun();
39+
if (!feeStep) {
40+
return;
41+
}
42+
43+
const status = await feeStep.run();
44+
if (status.state !== 'succeeded' || !status.expectedFee) {
45+
return;
46+
}
47+
48+
const transferFeeInUnits = status.expectedFee.transferFee().inUnits;
49+
const protocolFeeInPercent = status.expectedFee.protocolFeeInPercent();
50+
51+
return {
52+
provider: SwapProvider.ONE_SEC,
53+
receiveAmount: computeReceiveAmount({
54+
amount,
55+
transferFeeInUnits,
56+
protocolFeeInPercent,
57+
decimals
58+
}),
59+
swapDetails: { transferFeeInUnits, protocolFeeInPercent }
60+
};
61+
};
62+
63+
/**
64+
* Fetches a OneSec bridge quote for the EVM → ICP direction.
65+
* Called from evmSwapProviders when the source is EVM and destination is an ICP token.
66+
*/
67+
export const fetchOneSecEvmToIcpQuote = async ({
68+
sourceToken,
69+
destinationToken,
70+
amount
71+
}: EvmQuoteParams): Promise<SwapMappedResult | undefined> => {
72+
const identity = get(authIdentity);
73+
if (!ONESEC_SWAP_ENABLED || isNullish(identity) || !isNetworkIdICP(destinationToken.network.id)) {
74+
return;
75+
}
76+
77+
const entry = ICP_LEDGER_TO_TOKEN[(destinationToken as IcToken).ledgerCanisterId];
78+
if (isNullish(entry)) {
79+
return;
80+
}
81+
82+
try {
83+
const plan = await new EvmToIcpBridgeBuilder(sourceToken.network.name as EvmChain, entry.token)
84+
.receiver(identity.getPrincipal())
85+
.amountInUnits(amount)
86+
.forward();
87+
88+
return resolveQuoteFromPlan({ plan, amount, decimals: destinationToken.decimals });
89+
} catch (e) {
90+
consoleError(e);
91+
}
92+
};
93+
94+
/**
95+
* Fetches a OneSec bridge quote for the ICP → EVM direction.
96+
* Called from icpBridgeProviders when source is an ICP token and destination is EVM.
97+
*/
98+
export const fetchOneSecIcpToEvmQuote = async ({
99+
sourceToken,
100+
destinationToken,
101+
amount,
102+
userEthAddress
103+
}: IcpBridgeQuoteParams): Promise<SwapMappedResult | undefined> => {
104+
const identity = get(authIdentity);
105+
if (
106+
!ONESEC_SWAP_ENABLED ||
107+
isNullish(identity) ||
108+
!isIcToken(sourceToken) ||
109+
isNullish(userEthAddress)
110+
) {
111+
return;
112+
}
113+
114+
const entry = ICP_LEDGER_TO_TOKEN[sourceToken.ledgerCanisterId];
115+
if (isNullish(entry)) {
116+
return;
117+
}
118+
119+
try {
120+
const agent = await getAgent({ identity });
121+
const plan = await new IcpToEvmBridgeBuilder(
122+
agent,
123+
destinationToken.network.name as EvmChain,
124+
entry.token
125+
)
126+
.receiver(userEthAddress)
127+
.amountInUnits(amount)
128+
.build();
129+
130+
return resolveQuoteFromPlan({ plan, amount, decimals: destinationToken.decimals });
131+
} catch (e) {
132+
consoleError(e);
133+
}
134+
};
135+
136+
/**
137+
* Executes an ICP → EVM bridge via OneSec.
138+
*
139+
* Rebuilds the bridging plan (re-validating fees) and runs all steps in sequence,
140+
* reporting progress through the provided callback. Throws on failure; callers are
141+
* responsible for enabling the destination token and triggering a wallet refresh.
142+
*/
143+
export const executeOneSecIcpToEvmBridge = async ({
144+
identity,
145+
progress,
146+
sourceToken,
147+
destinationToken,
148+
swapAmount,
149+
userEthAddress,
150+
setFailedProgressStep
151+
}: OneSecIcpToEvmParams): Promise<void> => {
152+
const entry = ICP_LEDGER_TO_TOKEN[sourceToken.ledgerCanisterId];
153+
154+
if (isNullish(entry)) {
155+
throw new Error('Source token is not supported by the OneSec bridge');
156+
}
157+
158+
const parsedAmount = parseToken({
159+
value: `${swapAmount}`,
160+
unitName: sourceToken.decimals
161+
});
162+
163+
const agent = await getAgent({ identity });
164+
165+
const plan = await new IcpToEvmBridgeBuilder(
166+
agent,
167+
destinationToken.network.name as EvmChain,
168+
entry.token
169+
)
170+
.sender(identity.getPrincipal())
171+
.receiver(userEthAddress)
172+
.amountInUnits(parsedAmount)
173+
.build();
174+
175+
let step;
176+
while ((step = plan.nextStepToRun()) !== undefined) {
177+
// Step 0 is the fee-check step — keep the UI at INITIALIZATION.
178+
// Step 1+ are the actual bridge operations (approve, transfer, wait for EVM tx).
179+
if (step.index() >= 1) {
180+
progress(ProgressStepsSwap.SWAP);
181+
}
182+
183+
const result = await step.run();
184+
185+
if (result.state === 'failed') {
186+
setFailedProgressStep?.(ProgressStepsSwap.SWAP);
187+
throw new Error(result.error?.message ?? 'OneSec bridge step failed unexpectedly');
188+
}
189+
}
190+
};
191+
192+
/**
193+
* Executes an EVM → ICP bridge via OneSec using a forwarding address.
194+
*
195+
* Builds a forwarding plan, retrieves the deterministic forwarding address, sends the
196+
* EVM tokens to that address using OISY's send service, then runs the remaining SDK
197+
* steps (notify, validate, wait for ICP tx). Throws on failure; callers are responsible
198+
* for enabling the destination token and triggering a wallet refresh.
199+
*/
200+
export const executeOneSecEvmToIcpBridge = async ({
201+
identity,
202+
progress,
203+
sourceToken,
204+
destinationToken,
205+
swapAmount,
206+
userEthAddress,
207+
gas,
208+
maxFeePerGas,
209+
maxPriorityFeePerGas,
210+
setFailedProgressStep
211+
}: OneSecEvmToIcpParams): Promise<void> => {
212+
const entry = ICP_LEDGER_TO_TOKEN[destinationToken.ledgerCanisterId];
213+
if (isNullish(entry)) {
214+
throw new Error('Destination token is not supported by the OneSec bridge');
215+
}
216+
217+
const parsedAmount = parseToken({
218+
value: `${swapAmount}`,
219+
unitName: sourceToken.decimals
220+
});
221+
222+
const plan = await new EvmToIcpBridgeBuilder(sourceToken.network.name as EvmChain, entry.token)
223+
.receiver(identity.getPrincipal())
224+
.amountInUnits(parsedAmount)
225+
.forward();
226+
227+
// Step 0: fee validation
228+
let step = plan.nextStepToRun();
229+
if (nonNullish(step)) {
230+
const result = await step.run();
231+
if (result.state === 'failed') {
232+
setFailedProgressStep?.(ProgressStepsSwap.SWAP);
233+
throw new Error(result.error?.message ?? 'OneSec fee check failed');
234+
}
235+
}
236+
237+
// Step 1: compute forwarding address
238+
step = plan.nextStepToRun();
239+
if (isNullish(step)) {
240+
throw new Error('OneSec bridge plan is missing the forwarding address step');
241+
}
242+
243+
const addressResult = await step.run();
244+
if (addressResult.state === 'failed' || isNullish(addressResult.forwardingAddress)) {
245+
setFailedProgressStep?.(ProgressStepsSwap.SWAP);
246+
throw new Error(
247+
addressResult.error?.message ?? 'Failed to compute the OneSec forwarding address'
248+
);
249+
}
250+
251+
// Send EVM tokens to the forwarding address
252+
progress(ProgressStepsSwap.SWAP);
253+
await send({
254+
identity,
255+
token: sourceToken,
256+
from: userEthAddress,
257+
to: addressResult.forwardingAddress,
258+
amount: parsedAmount,
259+
sourceNetwork: sourceToken.network,
260+
gas,
261+
maxFeePerGas,
262+
maxPriorityFeePerGas
263+
});
264+
265+
// Run remaining steps: notify OneSec, validate receipt, wait for ICP tx
266+
while ((step = plan.nextStepToRun()) !== undefined) {
267+
const result = await step.run();
268+
if (result.state === 'failed') {
269+
setFailedProgressStep?.(ProgressStepsSwap.SWAP);
270+
throw new Error(result.error?.message ?? 'OneSec bridge step failed unexpectedly');
271+
}
272+
}
273+
};

src/frontend/src/lib/services/swap.services.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,9 @@ export const swapService = {
805805
},
806806
[SwapProvider.NEAR_INTENTS]: () => {
807807
throw new Error(get(i18n).swap.error.unexpected);
808+
},
809+
[SwapProvider.ONE_SEC]: () => {
810+
throw new Error(get(i18n).swap.error.unexpected);
808811
}
809812
};
810813

src/frontend/src/lib/types/swap.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ export enum SwapProvider {
3030
ICP_SWAP = 'icpSwap',
3131
KONG_SWAP = 'kongSwap',
3232
VELORA = 'velora',
33-
NEAR_INTENTS = 'nearIntents'
33+
NEAR_INTENTS = 'nearIntents',
34+
ONE_SEC = 'oneSec'
3435
}
3536

3637
export enum VeloraSwapTypes {
@@ -102,6 +103,13 @@ export type SwapMappedResult =
102103
receiveOutMinimum?: bigint;
103104
swapDetails: NearIntentsQuoteResponse;
104105
type?: string;
106+
}
107+
| {
108+
provider: SwapProvider.ONE_SEC;
109+
receiveAmount: bigint;
110+
receiveOutMinimum?: bigint;
111+
swapDetails: OneSecSwapDetails;
112+
type?: string;
105113
};
106114

107115
interface KongQuoteParams {
@@ -226,6 +234,46 @@ export interface GetWithdrawableTokenParams {
226234
destinationToken: IcTokenToggleable;
227235
}
228236

237+
export interface OneSecSwapDetails {
238+
transferFeeInUnits: bigint;
239+
protocolFeeInPercent: number;
240+
}
241+
242+
export interface OneSecIcpToEvmParams {
243+
identity: Identity;
244+
progress: (step: ProgressStepsSwap) => void;
245+
sourceToken: IcToken;
246+
destinationToken: Erc20Token;
247+
swapAmount: Amount;
248+
userEthAddress: EthAddress;
249+
setFailedProgressStep?: (step: ProgressStepsSwap) => void;
250+
}
251+
252+
export interface OneSecEvmToIcpParams extends RequiredTransactionFeeData {
253+
identity: Identity;
254+
progress: (step: ProgressStepsSwap) => void;
255+
sourceToken: Erc20Token;
256+
destinationToken: IcToken;
257+
swapAmount: Amount;
258+
userEthAddress: EthAddress;
259+
setFailedProgressStep?: (step: ProgressStepsSwap) => void;
260+
}
261+
262+
export interface IcpBridgeQuoteParams {
263+
sourceToken: Token;
264+
destinationToken: Token;
265+
amount: bigint;
266+
userEthAddress: OptionEthAddress;
267+
slippage: Slippage;
268+
}
269+
270+
export interface IcpBridgeSwapProviderConfig {
271+
key: SwapProvider;
272+
getQuote: (params: IcpBridgeQuoteParams) => Promise<SwapMappedResult | undefined>;
273+
isEnabled: boolean;
274+
getSupportedTokens?: () => Promise<Set<string>>;
275+
}
276+
229277
export interface SwapProvidersConfig {
230278
name: string;
231279
logo: string;

0 commit comments

Comments
 (0)