Skip to content

Commit 985f9da

Browse files
committed
feat(LIVE-29443): add swap status wallet api
1 parent 4f8953a commit 985f9da

20 files changed

Lines changed: 1062 additions & 1 deletion

libs/exchange-module/src/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
SwapLiveError,
1313
type GetQuotesResponse,
1414
type GetQuotesWireArgs,
15+
type GetTransactionStatusResponse,
16+
type GetTransactionStatusWireArgs,
1517
} from "./types";
1618

1719
export * from "./types";
@@ -187,6 +189,18 @@ export class ExchangeModule extends CustomModule {
187189
return this.request<GetQuotesWireArgs, GetQuotesResponse>("custom.exchange.getQuotes", params);
188190
}
189191

192+
/**
193+
* Fetch swap transaction status from the Wallet API host.
194+
*/
195+
async getTransactionStatus(
196+
params: GetTransactionStatusWireArgs,
197+
): Promise<GetTransactionStatusResponse> {
198+
return this.request<GetTransactionStatusWireArgs, GetTransactionStatusResponse>(
199+
"custom.exchange.getTransactionStatus",
200+
params,
201+
);
202+
}
203+
190204
/**
191205
* Complete an exchange sell process by passing by the exchange content and its signature.
192206
* User will be prompted on its device to approve the sell exchange operation.

libs/exchange-module/src/types.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,43 @@ export type GetQuotesArgs = {
137137

138138
export type GetQuotesWireArgs = Omit<GetQuotesArgs, "signal">;
139139

140+
// Swap transaction status (`custom.exchange.getTransactionStatus`).
141+
142+
export type TransactionStatusInput = {
143+
swapId: string;
144+
provider?: string;
145+
};
146+
147+
export type TransactionStatusValue =
148+
| "pending"
149+
| "onhold"
150+
| "expired"
151+
| "finished"
152+
| "refunded"
153+
| "unknown";
154+
155+
export type GetTransactionStatusArgs = TransactionStatusInput & {
156+
signal?: AbortSignal;
157+
};
158+
159+
export type GetTransactionStatusWireArgs = Omit<GetTransactionStatusArgs, "signal">;
160+
161+
export type GetTransactionStatusResponse = {
162+
kind: "swap";
163+
swapId: string;
164+
provider?: string;
165+
status?: TransactionStatusValue;
166+
finalAmount?: string;
167+
fromAccountId?: string;
168+
toAccountId?: string;
169+
sentAmount?: string;
170+
receivedAmount?: string;
171+
feesAmount?: string;
172+
operationHash?: string;
173+
createdAt?: number;
174+
providerRequired?: boolean;
175+
};
176+
140177
export type TradeMethod = "fixed" | "float";
141178

142179
export type ProviderTypes = "DEX" | "CEX";

libs/ledger-live-common/src/exchange/swap/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ export type GetExchangeRates = (
165165
exchangeObject: ExchangeObject,
166166
) => Promise<(ExchangeRate & { expirationDate?: Date })[]>;
167167

168-
type ValidSwapStatus = "pending" | "onhold" | "expired" | "finished" | "refunded";
168+
type ValidSwapStatus = "pending" | "onhold" | "expired" | "finished" | "refunded" | "unknown";
169169

170170
export type SwapStatusRequest = {
171171
provider: string;
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { getMultipleStatus } from "../swap/getStatus";
2+
import { fetchTransactionSwapStatus } from "./fetchSwapStatus";
3+
4+
jest.mock("../swap/getStatus", () => ({
5+
getMultipleStatus: jest.fn(),
6+
}));
7+
8+
const mockGetMultipleStatus = getMultipleStatus as jest.MockedFunction<typeof getMultipleStatus>;
9+
10+
describe("fetchTransactionSwapStatus", () => {
11+
beforeEach(() => {
12+
mockGetMultipleStatus.mockReset();
13+
});
14+
15+
it("calls the batch status endpoint for a single swap id", async () => {
16+
mockGetMultipleStatus.mockResolvedValueOnce([
17+
{ provider: "lifi", swapId: "swap-1", status: "finished" },
18+
]);
19+
20+
await expect(
21+
fetchTransactionSwapStatus({ provider: "lifi", swapId: "swap-1" }),
22+
).resolves.toEqual({ provider: "lifi", swapId: "swap-1", status: "finished" });
23+
24+
expect(mockGetMultipleStatus).toHaveBeenCalledWith([
25+
{ provider: "lifi", swapId: "swap-1" },
26+
]);
27+
});
28+
29+
it("forwards optional transaction and operation ids", async () => {
30+
mockGetMultipleStatus.mockResolvedValueOnce([
31+
{ provider: "lifi", swapId: "swap-1", status: "pending" },
32+
]);
33+
34+
await fetchTransactionSwapStatus({
35+
provider: "lifi",
36+
swapId: "swap-1",
37+
transactionId: "0xhash",
38+
operationId: "operation-id",
39+
});
40+
41+
expect(mockGetMultipleStatus).toHaveBeenCalledWith([
42+
{
43+
provider: "lifi",
44+
swapId: "swap-1",
45+
transactionId: "0xhash",
46+
operationId: "operation-id",
47+
},
48+
]);
49+
});
50+
51+
it("returns undefined when the backend returns no row", async () => {
52+
mockGetMultipleStatus.mockResolvedValueOnce([]);
53+
54+
await expect(
55+
fetchTransactionSwapStatus({ provider: "lifi", swapId: "swap-1" }),
56+
).resolves.toBeUndefined();
57+
});
58+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { getMultipleStatus } from "../swap/getStatus";
2+
import type { SwapStatus, SwapStatusRequest } from "../swap/types";
3+
4+
export async function fetchTransactionSwapStatus(
5+
request: SwapStatusRequest,
6+
): Promise<SwapStatus | undefined> {
7+
const [status] = await getMultipleStatus([request]);
8+
return status;
9+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import BigNumber from "bignumber.js";
2+
import type { Account, Operation } from "@ledgerhq/types-live";
3+
import type { MappedSwapOperation } from "../swap/types";
4+
import { fromSwapOperation } from "./fromSwapOperation";
5+
6+
function makeAccount(id: string): Account {
7+
return { type: "Account", id } as unknown as Account;
8+
}
9+
10+
describe("fromSwapOperation", () => {
11+
it("maps a swap-history operation to the reduced transaction status request", () => {
12+
const operation = {
13+
hash: "0xhash",
14+
fee: new BigNumber("21000"),
15+
date: new Date("2026-01-02T03:04:05.000Z"),
16+
} as unknown as Operation;
17+
18+
const mapped: MappedSwapOperation = {
19+
provider: "lifi",
20+
swapId: "swap-1",
21+
status: "finished",
22+
fromAccount: makeAccount("from"),
23+
toAccount: makeAccount("to"),
24+
toExists: true,
25+
operation,
26+
fromAmount: new BigNumber("-100000"),
27+
toAmount: new BigNumber("200000"),
28+
finalAmount: new BigNumber("250000"),
29+
};
30+
31+
expect(fromSwapOperation(mapped)).toEqual({
32+
kind: "swap",
33+
swapId: "swap-1",
34+
provider: "lifi",
35+
});
36+
});
37+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { MappedSwapOperation } from "../swap/types";
2+
import type { SwapTransactionStatusParams } from "./types";
3+
4+
export function fromSwapOperation(
5+
mappedSwapOperation: MappedSwapOperation,
6+
): SwapTransactionStatusParams {
7+
const { provider, swapId } = mappedSwapOperation;
8+
9+
return {
10+
kind: "swap",
11+
swapId,
12+
provider,
13+
};
14+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export * from "./fetchSwapStatus";
2+
export * from "./fromSwapOperation";
3+
export * from "./parseParams";
4+
export * from "./statusController";
5+
export * from "./types";
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import {
2+
parseSwapTransactionStatusParams,
3+
sanitizeRedirectUrl,
4+
} from "./parseParams";
5+
6+
describe("sanitizeRedirectUrl", () => {
7+
it("keeps https and ledgerlive urls", () => {
8+
expect(sanitizeRedirectUrl("https://ledger.com/swap")).toBe("https://ledger.com/swap");
9+
expect(sanitizeRedirectUrl("ledgerlive://swap/history")).toBe("ledgerlive://swap/history");
10+
});
11+
12+
it("drops unsafe or malformed urls", () => {
13+
expect(sanitizeRedirectUrl("javascript:alert(1)")).toBeUndefined();
14+
expect(sanitizeRedirectUrl("/relative")).toBeUndefined();
15+
expect(sanitizeRedirectUrl("not a url")).toBeUndefined();
16+
});
17+
});
18+
19+
describe("parseSwapTransactionStatusParams", () => {
20+
it("parses the minimal swap deeplink payload", () => {
21+
expect(
22+
parseSwapTransactionStatusParams({
23+
swapId: "swap-1",
24+
provider: "changelly_v2",
25+
}),
26+
).toEqual({
27+
ok: true,
28+
params: {
29+
kind: "swap",
30+
swapId: "swap-1",
31+
provider: "changelly_v2",
32+
redirectUrl: undefined,
33+
},
34+
});
35+
});
36+
37+
it("allows provider to be omitted and trims optional public fields", () => {
38+
expect(
39+
parseSwapTransactionStatusParams({
40+
swapId: " swap-1 ",
41+
redirectUrl: " https://example.com/continue ",
42+
}),
43+
).toEqual({
44+
ok: true,
45+
params: {
46+
kind: "swap",
47+
swapId: "swap-1",
48+
provider: undefined,
49+
redirectUrl: "https://example.com/continue",
50+
},
51+
});
52+
});
53+
54+
it("rejects missing required fields", () => {
55+
expect(parseSwapTransactionStatusParams({ provider: "lifi" })).toMatchObject({
56+
ok: false,
57+
error: { code: "missing_swap_id" },
58+
});
59+
});
60+
61+
it("rejects unsupported kind", () => {
62+
expect(
63+
parseSwapTransactionStatusParams({ kind: "unknown" as never, swapId: "swap-1", provider: "lifi" }),
64+
).toMatchObject({ ok: false, error: { code: "unsupported_kind" } });
65+
});
66+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { SwapStatus } from "../swap/types";
2+
import type {
3+
SwapTransactionStatusParseResult,
4+
SwapTransactionStatusParamsError,
5+
SwapTransactionStatusRawParams,
6+
} from "./types";
7+
8+
const VALID_SWAP_STATUSES: ReadonlySet<SwapStatus["status"]> = new Set([
9+
"pending",
10+
"onhold",
11+
"expired",
12+
"finished",
13+
"refunded",
14+
"unknown",
15+
]);
16+
17+
function error(
18+
code: SwapTransactionStatusParamsError["code"],
19+
message: string,
20+
value?: string,
21+
): SwapTransactionStatusParseResult {
22+
return { ok: false, error: { code, message, value } };
23+
}
24+
25+
export function sanitizeRedirectUrl(raw: string | undefined): string | undefined {
26+
if (!raw) return undefined;
27+
const trimmed = raw.trim();
28+
if (!trimmed) return undefined;
29+
30+
let parsed: URL;
31+
try {
32+
parsed = new URL(trimmed);
33+
} catch {
34+
return undefined;
35+
}
36+
37+
if (parsed.protocol !== "https:" && parsed.protocol !== "ledgerlive:") {
38+
return undefined;
39+
}
40+
return trimmed;
41+
}
42+
43+
export function isValidSwapStatus(status: string): status is SwapStatus["status"] {
44+
return VALID_SWAP_STATUSES.has(status as SwapStatus["status"]);
45+
}
46+
47+
export function parseSwapTransactionStatusParams(
48+
raw: SwapTransactionStatusRawParams,
49+
): SwapTransactionStatusParseResult {
50+
const kind = raw.kind ?? "swap";
51+
if (kind !== "swap") {
52+
return error("unsupported_kind", "Only swap transaction status is supported", kind);
53+
}
54+
55+
const swapId = raw.swapId?.trim();
56+
if (!swapId) {
57+
return error("missing_swap_id", "Missing swapId", raw.swapId);
58+
}
59+
60+
return {
61+
ok: true,
62+
params: {
63+
kind,
64+
swapId,
65+
provider: optionalTrim(raw.provider),
66+
redirectUrl: sanitizeRedirectUrl(raw.redirectUrl),
67+
},
68+
};
69+
}
70+
71+
function optionalTrim(value: string | undefined): string | undefined {
72+
const trimmed = value?.trim();
73+
return trimmed ? trimmed : undefined;
74+
}

0 commit comments

Comments
 (0)