diff --git a/api/src/index.ts b/api/src/index.ts index 7ff557f..ec8ee8a 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -135,7 +135,7 @@ app.get("/health", (c) => c.json({ ok: true, service: "api" })); // Swap proxy (CORS workaround for web UI) app.get("/v1/swaps/:id", async (c) => { const id = c.req.param("id"); - const upstream = await fetch(`https://apilendaswap.lendasat.com/swap/${encodeURIComponent(id)}`); + const upstream = await fetch(`https://api.lendaswap.com/swap/${encodeURIComponent(id)}`); if (!upstream.ok) { const text = await upstream.text(); throw new HTTPException(upstream.status === 400 ? 404 : (upstream.status as any), { diff --git a/cli/package.json b/cli/package.json index 9f1f3e0..ff4e7de 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,7 +20,7 @@ "dependencies": { "@arkade-os/boltz-swap": "^0.3.8", "@arkade-os/sdk": "^0.4.10", - "@lendasat/lendaswap-sdk-pure": "^0.0.2", + "@lendasat/lendaswap-sdk-pure": "^0.2.25", "@noble/hashes": "^2.0.1", "@scure/base": "^2.0.0", "@scure/btc-signer": "^2.0.1", diff --git a/cli/src/commands/receive.ts b/cli/src/commands/receive.ts index ca8a064..00774aa 100644 --- a/cli/src/commands/receive.ts +++ b/cli/src/commands/receive.ts @@ -8,6 +8,7 @@ import { toStablecoinToken, resolveCurrency, parseBtcAmount, + parseStablecoinAmount, } from "../utils/token.js"; import type { CashConfig } from "../config.js"; import type { ParsedArgs } from "minimist"; @@ -37,7 +38,7 @@ export async function handleReceive( if (resolved !== "btc" && !where) { if (!amountStr) { return outputError( - `Missing --amount <${currency}> (e.g. --amount 10 for 10 ${currency.toUpperCase()})` + `Missing --amount <${currency}> (e.g. --amount 1.02 for $1.02 ${currency.toUpperCase()})` ); } const amount = parseFloat(amountStr); @@ -78,7 +79,7 @@ export async function handleReceive( return outputError( resolved === "btc" ? `Missing --amount (${currency === "sats" ? "sats" : "BTC"} required for lightning invoices)` - : `Missing --amount <${resolved}> (e.g. --amount 10 for 10 ${resolved.toUpperCase()})` + : `Missing --amount <${resolved}> (e.g. --amount 1.02 for $1.02 ${resolved.toUpperCase()})` ); } @@ -90,8 +91,9 @@ export async function handleReceive( if (sats === null) return outputError(`Invalid amount: ${amountStr}`); amount = sats; } else { - amount = parseFloat(amountStr); - if (isNaN(amount) || amount <= 0) return outputError(`Invalid amount: ${amountStr}`); + const units = parseStablecoinAmount(amountStr, resolved, where); + if (units === null) return outputError(`Invalid amount: ${amountStr}`); + amount = units; } } diff --git a/cli/src/commands/send.ts b/cli/src/commands/send.ts index afdc8a7..4e3090a 100644 --- a/cli/src/commands/send.ts +++ b/cli/src/commands/send.ts @@ -10,6 +10,7 @@ import { toEvmChain, resolveCurrency, parseBtcAmount, + parseStablecoinAmount, } from "../utils/token.js"; import type { ParsedArgs } from "minimist"; @@ -96,8 +97,9 @@ export async function handleSend( if (sats === null) return outputError(`Invalid amount: ${amountStr}`); amount = sats; } else { - amount = parseFloat(amountStr); - if (isNaN(amount) || amount <= 0) return outputError(`Invalid amount: ${amountStr}`); + const units = parseStablecoinAmount(amountStr, resolved, where); + if (units === null) return outputError(`Invalid amount: ${amountStr}`); + amount = units; } if (!isValidWhere(where)) { diff --git a/cli/src/commands/swap.ts b/cli/src/commands/swap.ts index 701143e..55fce94 100644 --- a/cli/src/commands/swap.ts +++ b/cli/src/commands/swap.ts @@ -3,7 +3,7 @@ import { outputSuccess, outputError } from "../output.js"; import { getDaemonUrl, daemonGet } from "../daemonClient.js"; import type { ParsedArgs } from "minimist"; -const LENDASWAP_API = "https://apilendaswap.lendasat.com"; +const LENDASWAP_API = "https://api.lendaswap.com"; export async function handleSwap(ctx: CashContext, args: ParsedArgs): Promise { const swapId = (args._[1] as string) || (args.id as string); diff --git a/cli/src/server.ts b/cli/src/server.ts index 3cb641d..86e7f99 100644 --- a/cli/src/server.ts +++ b/cli/src/server.ts @@ -5,7 +5,7 @@ import type { AuthMonitor } from "./authMonitor.js"; import type { WebhookRegistry, SwapEventType } from "./notifier.js"; import type { EvmChain, StablecoinToken, StablecoinSwapInfo, StablecoinSwapStatus } from "@clw-cash/skills"; -const LENDASWAP_API = "https://apilendaswap.lendasat.com"; +const LENDASWAP_API = "https://api.lendaswap.com"; async function fetchRemoteSwap(swapId: string): Promise | null> { try { diff --git a/cli/src/utils/token.ts b/cli/src/utils/token.ts index 81199f5..1fb88af 100644 --- a/cli/src/utils/token.ts +++ b/cli/src/utils/token.ts @@ -1,4 +1,4 @@ -import type { StablecoinToken, EvmChain } from "@clw-cash/skills"; +import { toSmallestUnit, type StablecoinToken, type EvmChain } from "@clw-cash/skills"; const TOKEN_MAP: Record> = { usdt: { polygon: "usdt0_pol", ethereum: "usdt_eth", arbitrum: "usdt_arb" }, @@ -66,3 +66,14 @@ export function toStablecoinToken(currency: string, chain: string): StablecoinTo export function toEvmChain(where: string): EvmChain { return where as EvmChain; } + +/** + * Parse a human-readable stablecoin amount (e.g. "1.02" for $1.02) + * and return the value in smallest token units (e.g. 1020000 for USDC). + */ +export function parseStablecoinAmount(amountStr: string, currency: string, where: string): number | null { + const value = parseFloat(amountStr); + if (isNaN(value) || value <= 0) return null; + const token = toStablecoinToken(currency, where); + return toSmallestUnit(value, token); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac67b56..910c6db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,8 +58,8 @@ importers: specifier: ^0.4.10 version: 0.4.10 '@lendasat/lendaswap-sdk-pure': - specifier: ^0.0.2 - version: 0.0.2 + specifier: ^0.2.25 + version: 0.2.25(typescript@5.9.3)(zod@4.3.6) '@noble/hashes': specifier: ^2.0.1 version: 2.0.1 @@ -160,8 +160,8 @@ importers: specifier: workspace:* version: link:../remote-signer-identity '@lendasat/lendaswap-sdk-pure': - specifier: ^0.0.2 - version: 0.0.2 + specifier: ^0.2.25 + version: 0.2.25(typescript@5.9.3)(zod@4.3.6) devDependencies: '@types/node': specifier: ^22.13.4 @@ -173,8 +173,11 @@ importers: web: dependencies: '@lendasat/lendaswap-sdk-pure': - specifier: ^0.0.2 - version: 0.0.2 + specifier: ^0.2.25 + version: 0.2.25(typescript@5.9.3)(zod@4.3.6) + events: + specifier: ^3.3.0 + version: 3.3.0 viem: specifier: ^2.0.0 version: 2.45.3(typescript@5.9.3)(zod@4.3.6) @@ -744,8 +747,16 @@ packages: '@kukks/bitcoin-descriptors@3.1.0': resolution: {integrity: sha512-7HOCZjufw3DIIDebx7/ZOeol6khkWoccM6vzee6t5bXOJoj/6ISdssEDhjxDgy88in/I39pKz+kzFukxK5eqVw==} - '@lendasat/lendaswap-sdk-pure@0.0.2': - resolution: {integrity: sha512-4N9ugC/5ja7p5U0pRfRhopxu7Nld5u94117kklucmjDd8zdLd4BUdIVXObLhwgze+/WvAaU6jCp/S3Maks54Lw==} + '@lendasat/lendaswap-sdk-pure@0.2.25': + resolution: {integrity: sha512-8JFtAOEd+NakflFump8fPLABMNdWd72BLo+lWmoZi9dmSv6s/XyS24Fvopl8nsgNmFBunaUMgX9nPdbBRVSs4w==} + peerDependencies: + '@circle-fin/adapter-viem-v2': ^1.8.0 + '@circle-fin/bridge-kit': ^1.8.0 + peerDependenciesMeta: + '@circle-fin/adapter-viem-v2': + optional: true + '@circle-fin/bridge-kit': + optional: true '@marcbachmann/cel-js@7.3.1': resolution: {integrity: sha512-P6o26TvjStT8V8+8EF+yq9Pp7ZFV00bpiUMbssr76XbIZGxaB+NNWeBp6WNxOrR9gp0JPzvJueCKHpOs5LE9PQ==} @@ -772,6 +783,10 @@ packages: resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==} engines: {node: '>= 20.19.0'} + '@noble/curves@2.2.0': + resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==} + engines: {node: '>= 20.19.0'} + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} @@ -784,6 +799,10 @@ packages: resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + '@noble/secp256k1@3.0.0': resolution: {integrity: sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==} @@ -1038,6 +1057,17 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@zerodev/ecdsa-validator@5.4.9': + resolution: {integrity: sha512-9NVE8/sQIKRo42UOoYKkNdmmHJY8VlT4t+2MHD2ipLg21cpbY9fS17TGZh61+Bl3qlqc8pP23I6f89z9im7kuA==} + peerDependencies: + '@zerodev/sdk': ^5.4.13 + viem: ^2.28.0 + + '@zerodev/sdk@5.5.10': + resolution: {integrity: sha512-WVyj2XR9F6zK2GdXrvappx7yo6zoJ46cWe42dOIArp3xDjFBninjA4O1O94MwohB0G+yFqtXNsEU+WxdE67SgQ==} + peerDependencies: + viem: ^2.28.0 + abitype@1.2.3: resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} peerDependencies: @@ -1181,8 +1211,8 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - dexie@4.3.0: - resolution: {integrity: sha512-5EeoQpJvMKHe6zWt/FSIIuRa3CWlZeIl6zKXt+Lz7BU6RoRRLgX9dZEynRfXrkLcldKYCBiz7xekTEylnie1Ug==} + dexie@4.4.2: + resolution: {integrity: sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw==} dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} @@ -1239,6 +1269,10 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} @@ -1466,11 +1500,11 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - openapi-fetch@0.15.2: - resolution: {integrity: sha512-rdYTzUmSsJevmNqg7fwUVGuKc2Gfb9h6ph74EVPkPfIGJaZTfqdIbJahtbJ3qg1LKinln30hqZniLnKpH0RJBg==} + openapi-fetch@0.17.0: + resolution: {integrity: sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==} - openapi-typescript-helpers@0.0.15: - resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} + openapi-typescript-helpers@0.1.0: + resolution: {integrity: sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==} ox@0.12.1: resolution: {integrity: sha512-uU0llpthaaw4UJoXlseCyBHmQ3bLrQmz9rRLIAUHqv46uHuae9SE+ukYBRIPVCnlEnHKuWjDUcDFHWx9gbGNoA==} @@ -1480,6 +1514,14 @@ packages: typescript: optional: true + ox@0.12.4: + resolution: {integrity: sha512-+P+C7QzuwPV8lu79dOwjBKfB2CbnbEXe/hfyyrff1drrO1nOOj3Hc87svHfcW1yneRr3WXaKr6nz11nq+/DF9Q==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -1781,6 +1823,14 @@ packages: typescript: optional: true + viem@2.46.3: + resolution: {integrity: sha512-2LJS+Hyh2sYjHXQtzfv1kU9pZx9dxFzvoU/ZKIcn0FNtOU0HQuIICuYdWtUDFHaGXbAdVo8J1eCvmjkL9JVGwg==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + vite@6.4.1: resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -2313,17 +2363,32 @@ snapshots: '@scure/bip32': 2.0.1 '@scure/btc-signer': 2.0.1 - '@lendasat/lendaswap-sdk-pure@0.0.2': + '@lendasat/lendaswap-sdk-pure@0.2.25(typescript@5.9.3)(zod@4.3.6)': dependencies: - '@arkade-os/sdk': 0.3.13 - '@noble/hashes': 1.8.0 - '@scure/bip32': 1.7.0 - '@scure/bip39': 1.6.0 + '@arkade-os/sdk': 0.4.10 + '@noble/curves': 2.2.0 + '@noble/hashes': 2.2.0 + '@scure/base': 2.0.0 + '@scure/bip32': 2.0.1 + '@scure/bip39': 2.0.1 '@scure/btc-signer': 2.0.1 - dexie: 4.3.0 - openapi-fetch: 0.15.2 + '@zerodev/ecdsa-validator': 5.4.9(@zerodev/sdk@5.5.10(viem@2.46.3(typescript@5.9.3)(zod@4.3.6)))(viem@2.46.3(typescript@5.9.3)(zod@4.3.6)) + '@zerodev/sdk': 5.5.10(viem@2.45.3(typescript@5.9.3)(zod@4.3.6)) + dexie: 4.4.2 + openapi-fetch: 0.17.0 + viem: 2.46.3(typescript@5.9.3)(zod@4.3.6) optionalDependencies: better-sqlite3: 12.6.2 + transitivePeerDependencies: + - '@react-native-async-storage/async-storage' + - bufferutil + - expo + - expo-background-task + - expo-sqlite + - expo-task-manager + - typescript + - utf-8-validate + - zod '@marcbachmann/cel-js@7.3.1': {} @@ -2345,12 +2410,18 @@ snapshots: dependencies: '@noble/hashes': 2.0.1 + '@noble/curves@2.2.0': + dependencies: + '@noble/hashes': 2.2.0 + '@noble/hashes@1.8.0': {} '@noble/hashes@2.0.0': {} '@noble/hashes@2.0.1': {} + '@noble/hashes@2.2.0': {} + '@noble/secp256k1@3.0.0': {} '@poppinss/colors@4.1.6': @@ -2591,6 +2662,16 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + '@zerodev/ecdsa-validator@5.4.9(@zerodev/sdk@5.5.10(viem@2.46.3(typescript@5.9.3)(zod@4.3.6)))(viem@2.46.3(typescript@5.9.3)(zod@4.3.6))': + dependencies: + '@zerodev/sdk': 5.5.10(viem@2.45.3(typescript@5.9.3)(zod@4.3.6)) + viem: 2.46.3(typescript@5.9.3)(zod@4.3.6) + + '@zerodev/sdk@5.5.10(viem@2.45.3(typescript@5.9.3)(zod@4.3.6))': + dependencies: + semver: 7.7.4 + viem: 2.45.3(typescript@5.9.3)(zod@4.3.6) + abitype@1.2.3(typescript@5.9.3)(zod@4.3.6): optionalDependencies: typescript: 5.9.3 @@ -2704,7 +2785,7 @@ snapshots: detect-libc@2.1.2: {} - dexie@4.3.0: {} + dexie@4.4.2: {} dunder-proto@1.0.1: dependencies: @@ -2800,6 +2881,8 @@ snapshots: eventemitter3@5.0.1: {} + events@3.3.0: {} + eventsource-parser@3.0.6: {} eventsource@4.1.0: @@ -3030,11 +3113,11 @@ snapshots: dependencies: wrappy: 1.0.2 - openapi-fetch@0.15.2: + openapi-fetch@0.17.0: dependencies: - openapi-typescript-helpers: 0.0.15 + openapi-typescript-helpers: 0.1.0 - openapi-typescript-helpers@0.0.15: {} + openapi-typescript-helpers@0.1.0: {} ox@0.12.1(typescript@5.9.3)(zod@4.3.6): dependencies: @@ -3051,6 +3134,21 @@ snapshots: transitivePeerDependencies: - zod + ox@0.12.4(typescript@5.9.3)(zod@4.3.6): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@4.3.6) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + parseurl@1.3.3: {} path-to-regexp@6.3.0: {} @@ -3437,6 +3535,23 @@ snapshots: - utf-8-validate - zod + viem@2.46.3(typescript@5.9.3)(zod@4.3.6): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@4.3.6) + isows: 1.0.7(ws@8.18.3) + ox: 0.12.4(typescript@5.9.3)(zod@4.3.6) + ws: 8.18.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + vite@6.4.1(@types/node@22.19.11)(tsx@4.21.0): dependencies: esbuild: 0.25.12 diff --git a/skills/package.json b/skills/package.json index 0a96223..6928355 100644 --- a/skills/package.json +++ b/skills/package.json @@ -13,7 +13,7 @@ "@arkade-os/boltz-swap": "^0.3.8", "@arkade-os/sdk": "^0.4.10", "@clw-cash/sdk": "workspace:*", - "@lendasat/lendaswap-sdk-pure": "^0.0.2" + "@lendasat/lendaswap-sdk-pure": "^0.2.25" }, "devDependencies": { "@types/node": "^22.13.4", diff --git a/skills/src/skills/lendaswap.ts b/skills/src/skills/lendaswap.ts index 9ea46e8..7fa10f6 100644 --- a/skills/src/skills/lendaswap.ts +++ b/skills/src/skills/lendaswap.ts @@ -6,6 +6,8 @@ import { type WalletStorage, type SwapStorage as LendaSwapStorage, type SwapStatus as LendaSwapStatus, + type Chain, + type TokenInfo, } from "@lendasat/lendaswap-sdk-pure"; import type { StablecoinSwapSkill, @@ -32,6 +34,43 @@ export const TOKEN_DECIMALS: Record = { usdt_arb: 6, }; +const TOKEN_SYMBOLS: Record = { + usdc_pol: ["USDC"], + usdc_eth: ["USDC"], + usdc_arb: ["USDC"], + usdt0_pol: ["USDT0", "USDT"], + usdt_eth: ["USDT"], + usdt_arb: ["USDT"], +}; + +const TOKEN_CHAINS: Record = { + usdc_pol: "137", + usdc_eth: "1", + usdc_arb: "42161", + usdt0_pol: "137", + usdt_eth: "1", + usdt_arb: "42161", +}; + +const EVM_CHAIN_IDS = { + polygon: 137, + ethereum: 1, + arbitrum: 42161, +} as const; + +function toNumber(value: string | number | bigint | undefined | null): number { + if (value == null) return 0; + return Number(value); +} + +function tokenId(token: string | TokenInfo): string { + return typeof token === "string" ? token : token.token_id; +} + +function tokenChain(token: StablecoinToken): Chain { + return TOKEN_CHAINS[token]; +} + export function mapSwapStatus( sdkStatus: LendaSwapStatus ): StablecoinSwapStatus { @@ -57,6 +96,7 @@ export function mapSwapStatus( case "clientrefundedserverrefunded": return "refunded"; case "clientinvalidfunded": + case "serverwontfund": return "failed"; default: return "pending"; @@ -94,6 +134,7 @@ export class LendaSwapSkill implements StablecoinSwapSkill { private readonly referralCode?: string; private readonly config: LendaSwapSkillConfig; private client: Client | null = null; + private tokens: TokenInfo[] | null = null; constructor(config: LendaSwapSkillConfig) { this.wallet = config.wallet; @@ -111,7 +152,7 @@ export class LendaSwapSkill implements StablecoinSwapSkill { .withSwapStorage(this.config.swapStorage || new InMemorySwapStorage()); if (this.config.apiUrl) builder.withBaseUrl(this.config.apiUrl); - if (this.config.apiKey) builder.withApiKey(this.config.apiKey); + if (this.config.apiKey) builder.withOrgCode(this.config.apiKey); if (this.config.esploraUrl) builder.withEsploraUrl(this.config.esploraUrl); if (this.config.arkadeServerUrl) builder.withArkadeServerUrl(this.config.arkadeServerUrl); @@ -121,6 +162,26 @@ export class LendaSwapSkill implements StablecoinSwapSkill { return this.client; } + private async resolveToken(token: StablecoinToken): Promise { + const client = await this.getClient(); + if (!this.tokens) { + const tokens = await client.getTokens(); + this.tokens = tokens.evm_tokens; + } + + const symbols = TOKEN_SYMBOLS[token]; + const chain = tokenChain(token); + const info = this.tokens.find( + (candidate) => + candidate.chain === chain && symbols.includes(candidate.symbol.toUpperCase()) + ); + + if (!info) { + throw new Error(`LendaSwap token ${token} is not available`); + } + return info; + } + async isAvailable(): Promise { try { const client = await this.getClient(); @@ -146,24 +207,23 @@ export class LendaSwapSkill implements StablecoinSwapSkill { targetToken: StablecoinToken ): Promise { const client = await this.getClient(); - const quote = await client.getQuote( - "btc_arkade", - targetToken, - sourceAmount - ); - - const rate = parseFloat(quote.exchange_rate); - const netSats = sourceAmount - quote.protocol_fee - quote.network_fee; - const targetAmount = (netSats / 1e8) * rate; + const token = await this.resolveToken(targetToken); + const quote = await client.getQuote({ + sourceChain: "Arkade", + sourceToken: "btc", + targetChain: token.chain, + targetToken: token.token_id, + sourceAmount, + }); return { sourceToken: "btc_arkade", targetToken, - sourceAmount, - targetAmount, - exchangeRate: rate, + sourceAmount: toNumber(quote.source_amount), + targetAmount: toNumber(quote.net_target_amount), + exchangeRate: parseFloat(quote.exchange_rate), fee: { - amount: quote.protocol_fee + quote.network_fee, + amount: quote.protocol_fee + quote.network_fee + quote.gasless_network_fee, percentage: quote.protocol_fee_rate * 100, }, expiresAt: new Date(Date.now() + 60_000), @@ -175,24 +235,23 @@ export class LendaSwapSkill implements StablecoinSwapSkill { sourceToken: StablecoinToken ): Promise { const client = await this.getClient(); - const quote = await client.getQuote( - sourceToken, - "btc_arkade", - sourceAmount - ); - - const rate = parseFloat(quote.exchange_rate); - const grossSats = (sourceAmount / rate) * 1e8; - const targetAmount = grossSats - quote.protocol_fee - quote.network_fee; + const token = await this.resolveToken(sourceToken); + const quote = await client.getQuote({ + sourceChain: token.chain, + sourceToken: token.token_id, + targetChain: "Arkade", + targetToken: "btc", + sourceAmount, + }); return { sourceToken, targetToken: "btc_arkade", - sourceAmount, - targetAmount: Math.max(0, Math.floor(targetAmount)), - exchangeRate: rate, + sourceAmount: toNumber(quote.net_source_amount), + targetAmount: toNumber(quote.net_target_amount), + exchangeRate: parseFloat(quote.exchange_rate), fee: { - amount: quote.protocol_fee + quote.network_fee, + amount: quote.protocol_fee + quote.network_fee + quote.gasless_network_fee, percentage: quote.protocol_fee_rate * 100, }, expiresAt: new Date(Date.now() + 60_000), @@ -203,44 +262,39 @@ export class LendaSwapSkill implements StablecoinSwapSkill { params: BtcToStablecoinParams ): Promise { const client = await this.getClient(); + const token = await this.resolveToken(params.targetToken); - const result = await client.createArkadeToEvmSwap({ + const result = await client.createArkadeToEvmSwapGeneric({ targetAddress: params.targetAddress, - targetToken: params.targetToken, - targetChain: params.targetChain, - sourceAmount: params.sourceAmount, - targetAmount: params.targetAmount, + tokenAddress: token.token_id, + evmChainId: EVM_CHAIN_IDS[params.targetChain], + sourceAmount: params.sourceAmount != null ? BigInt(params.sourceAmount) : undefined, + targetAmount: params.targetAmount != null ? BigInt(params.targetAmount) : undefined, referralCode: params.referralCode || this.referralCode, }); const resp = result.response; + const sourceAmount = toNumber(resp.source_amount); + const targetAmount = toNumber(resp.target_amount); const fundingTxid = await this.wallet.sendBitcoin({ - address: resp.htlc_address_arkade, - amount: resp.source_amount, + address: resp.btc_vhtlc_address, + amount: sourceAmount, }); - const exchangeRate = - resp.source_amount > 0 && resp.target_amount > 0 - ? resp.target_amount / (resp.source_amount / 1e8) - : 0; - return { swapId: resp.id, status: "funded", - sourceAmount: resp.source_amount, - targetAmount: resp.target_amount, - exchangeRate, + sourceAmount, + targetAmount, + exchangeRate: sourceAmount > 0 ? targetAmount / (sourceAmount / 1e8) : 0, fee: { amount: resp.fee_sats, - percentage: - resp.source_amount > 0 - ? (resp.fee_sats / resp.source_amount) * 100 - : 0, + percentage: sourceAmount > 0 ? (resp.fee_sats / sourceAmount) * 100 : 0, }, expiresAt: new Date(resp.vhtlc_refund_locktime * 1000), - paymentDetails: { address: resp.htlc_address_arkade }, - htlcAddressEvm: resp.htlc_address_evm, + paymentDetails: { address: resp.btc_vhtlc_address }, + htlcAddressEvm: resp.evm_htlc_address, fundingTxid, }; } @@ -249,79 +303,75 @@ export class LendaSwapSkill implements StablecoinSwapSkill { params: StablecoinToBtcParams ): Promise { const client = await this.getClient(); + const token = await this.resolveToken(params.sourceToken); const arkAddress = params.targetAddress || (await this.wallet.getAddress()); - const result = await client.createEvmToArkadeSwap({ - sourceChain: params.sourceChain, - sourceToken: params.sourceToken, - sourceAmount: params.sourceAmount, + const result = await client.createEvmToArkadeSwapGeneric({ targetAddress: arkAddress, + tokenAddress: token.token_id, + evmChainId: EVM_CHAIN_IDS[params.sourceChain], + sourceAmount: BigInt(params.sourceAmount), userAddress: params.userAddress || "0x0000000000000000000000000000000000000000", referralCode: params.referralCode || this.referralCode, }); const resp = result.response; - - const exchangeRate = - resp.source_amount > 0 && resp.target_amount > 0 - ? (resp.source_amount / resp.target_amount) * 1e8 - : 0; + const sourceAmount = toNumber(resp.source_amount); + const targetAmount = toNumber(resp.target_amount); return { swapId: resp.id, status: mapSwapStatus(resp.status), - sourceAmount: resp.source_amount, - targetAmount: resp.target_amount, - exchangeRate, + sourceAmount, + targetAmount, + exchangeRate: targetAmount > 0 ? (sourceAmount / targetAmount) * 1e8 : 0, fee: { amount: resp.fee_sats, - percentage: - resp.source_amount > 0 - ? (resp.fee_sats / resp.target_amount) * 100 - : 0, + percentage: targetAmount > 0 ? (resp.fee_sats / targetAmount) * 100 : 0, }, expiresAt: new Date(resp.evm_refund_locktime * 1000), paymentDetails: { - address: resp.htlc_address_evm, - callData: resp.source_token_address, + address: resp.evm_htlc_address, + callData: resp.source_token.token_id, }, - htlcAddressEvm: resp.htlc_address_evm, + htlcAddressEvm: resp.evm_htlc_address, }; } async getSwapStatus(swapId: string): Promise { const client = await this.getClient(); const data = await client.getSwap(swapId, { updateStorage: true }); + const sourceAmount = toNumber(data.source_amount); + const targetAmount = toNumber(data.target_amount); const direction = - data.direction === "evm_to_btc" + data.direction === "evm_to_arkade" || data.direction === "evm_to_bitcoin" ? ("stablecoin_to_btc" as const) : ("btc_to_stablecoin" as const); const status = mapSwapStatus(data.status); - const exchangeRate = - data.source_amount > 0 && data.target_amount > 0 + sourceAmount > 0 && targetAmount > 0 ? direction === "btc_to_stablecoin" - ? data.target_amount / (data.source_amount / 1e8) - : (data.source_amount / data.target_amount) * 1e8 + ? targetAmount / (sourceAmount / 1e8) + : (sourceAmount / targetAmount) * 1e8 : 0; return { id: swapId, direction, status, - sourceToken: data.source_token, - targetToken: data.target_token, - sourceAmount: data.source_amount, - targetAmount: data.target_amount, + sourceToken: tokenId(data.source_token), + targetToken: tokenId(data.target_token), + sourceAmount, + targetAmount, exchangeRate, createdAt: new Date(data.created_at), completedAt: status === "completed" ? new Date() : undefined, txid: - ("evm_htlc_claim_txid" in data - ? data.evm_htlc_claim_txid - : undefined) ?? undefined, + ("evm_claim_txid" in data ? data.evm_claim_txid : undefined) ?? + ("btc_claim_txid" in data ? data.btc_claim_txid : undefined) ?? + undefined, }; } @@ -355,22 +405,15 @@ export class LendaSwapSkill implements StablecoinSwapSkill { async getAvailablePairs(): Promise { const client = await this.getClient(); - const pairs = await client.getAssetPairs(); - - return pairs.map((pair) => { - const p = pair as typeof pair & { - min_amount?: number; - max_amount?: number; - fee_rate?: number; - }; - return { - from: p.source.token_id, - to: p.target.token_id, - minAmount: p.min_amount ?? 0, - maxAmount: p.max_amount ?? 0, - feePercentage: p.fee_rate != null ? p.fee_rate * 100 : 0, - }; - }); + const pairs = await client.getSwapPairs(); + + return pairs.pairs.map((pair) => ({ + from: pair.source, + to: pair.target, + minAmount: pair.min_sats ?? 0, + maxAmount: pair.max_sats ?? 0, + feePercentage: pair.fee_percentage ?? 0, + })); } async claimSwap(swapId: string): Promise { @@ -392,32 +435,22 @@ export class LendaSwapSkill implements StablecoinSwapSkill { const client = await this.getClient(); const data = await client.getSwap(swapId, { updateStorage: true }); - if (data.direction === "evm_to_btc") { + if (data.direction === "evm_to_arkade" || data.direction === "evm_to_bitcoin") { return { success: false, message: - "This is an EVM→BTC swap. Use getEvmRefundCallData() to get the EVM refund transaction data.", + "This is an EVM-sourced swap. Use getEvmRefundCallData() to get the EVM refund transaction data.", }; } - let destinationAddress = options?.destinationAddress; - if (!destinationAddress) { - if (data.source_token === "btc_arkade") { - destinationAddress = await this.wallet.getAddress(); - } else { - destinationAddress = await this.wallet.getBoardingAddress(); - } - } - - const result = await client.refundSwap(swapId, { destinationAddress: destinationAddress! }); + const destinationAddress = options?.destinationAddress || (await this.wallet.getAddress()); + const result = await client.refundSwap(swapId, { destinationAddress }); return { success: result.success, message: result.message, txId: result.txId, - refundAmount: result.refundAmount - ? Number(result.refundAmount) - : undefined, + refundAmount: result.refundAmount ? Number(result.refundAmount) : undefined, }; } @@ -425,23 +458,22 @@ export class LendaSwapSkill implements StablecoinSwapSkill { swapId: string, tokenDecimals: number ): Promise { + void tokenDecimals; const client = await this.getClient(); - const data = await client.getEvmFundingCallData(swapId, tokenDecimals); + const swap = await client.getSwap(swapId, { updateStorage: true }); + const chainId = Number(swap.source_token.chain); + const data = await client.getCoordinatorFundingCallDataPermit2(swapId, chainId); return { approve: { to: data.approve.to, data: data.approve.data }, - createSwap: { to: data.createSwap.to, data: data.createSwap.data }, + createSwap: { to: data.executeAndCreate.to, data: data.executeAndCreate.data }, }; } async getEvmRefundCallData(swapId: string): Promise { - const client = await this.getClient(); - const data = await client.getEvmRefundCallData(swapId); - return { - to: data.to, - data: data.data, - timelockExpired: data.timelockExpired, - timelockExpiry: data.timelockExpiry, - }; + void swapId; + throw new Error( + "EVM refund call data is not exposed by the current LendaSwap SDK. Use the collaborative refund helpers instead." + ); } getWallet(): Wallet { @@ -456,36 +488,38 @@ export class LendaSwapSkill implements StablecoinSwapSkill { swapId: string; response: { status: LendaSwapStatus; - source_token: string; - target_token: string; - source_amount: number; - target_amount: number; + source_token: string | TokenInfo; + target_token: string | TokenInfo; + source_amount: number | string; + target_amount: number | string; created_at: string; direction: string; }; }): StablecoinSwapInfo { const resp = stored.response; + const sourceAmount = toNumber(resp.source_amount); + const targetAmount = toNumber(resp.target_amount); const direction = - resp.direction === "evm_to_btc" + resp.direction === "evm_to_arkade" || resp.direction === "evm_to_bitcoin" ? ("stablecoin_to_btc" as const) : ("btc_to_stablecoin" as const); const status = mapSwapStatus(resp.status); const exchangeRate = - resp.source_amount > 0 && resp.target_amount > 0 + sourceAmount > 0 && targetAmount > 0 ? direction === "btc_to_stablecoin" - ? resp.target_amount / (resp.source_amount / 1e8) - : (resp.source_amount / resp.target_amount) * 1e8 + ? targetAmount / (sourceAmount / 1e8) + : (sourceAmount / targetAmount) * 1e8 : 0; return { id: stored.swapId, direction, status, - sourceToken: resp.source_token, - targetToken: resp.target_token, - sourceAmount: resp.source_amount, - targetAmount: resp.target_amount, + sourceToken: tokenId(resp.source_token), + targetToken: tokenId(resp.target_token), + sourceAmount, + targetAmount, exchangeRate, createdAt: new Date(resp.created_at), completedAt: status === "completed" ? new Date() : undefined, diff --git a/web/package.json b/web/package.json index 477ecca..1a55e35 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,8 @@ "preview": "vite preview" }, "dependencies": { - "@lendasat/lendaswap-sdk-pure": "^0.0.2", + "@lendasat/lendaswap-sdk-pure": "^0.2.25", + "events": "^3.3.0", "viem": "^2.0.0" }, "devDependencies": { diff --git a/web/src/main.ts b/web/src/main.ts index 130e3c9..0355e20 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -83,6 +83,22 @@ async function main() { fundTo = params.funding.fundTo; fundData = params.funding.fundData; swapId = params.swapId; + } else if (params.swapId) { + // Pre-created by CLI — derive current Permit2 funding call data with the SDK. + swapId = params.swapId; + setStep("preparing-tx"); + const callData = await getFundingCallData(swapId, selectedToken); + approveTo = callData.approve.to; + approveData = callData.approve.data; + fundTo = callData.createSwap.to; + fundData = callData.createSwap.data; + params.funding = { + approveTo, + approveData, + fundTo, + fundData, + approveSpender: callData.approve.spender, + }; } else { // Web creates the swap (chain selected by sender or from URL) setStep("creating-swap"); @@ -92,11 +108,19 @@ async function main() { updateDebug({ swapId }); setStep("preparing-tx"); + if (!swapId) throw new Error("Swap creation did not return a swap ID"); const callData = await getFundingCallData(swapId, selectedToken); approveTo = callData.approve.to; approveData = callData.approve.data; fundTo = callData.createSwap.to; fundData = callData.createSwap.data; + params.funding = { + approveTo, + approveData, + fundTo, + fundData, + approveSpender: callData.approve.spender, + }; } // 2. Approve token spend (skip if allowance already sufficient) @@ -105,7 +129,7 @@ async function main() { address: approveTo as `0x${string}`, abi: erc20Abi, functionName: "allowance", - args: [address, fundTo as `0x${string}`], + args: [address, (params.funding?.approveSpender ?? fundTo) as `0x${string}`], }); updateDebug({ currentAllowance: currentAllowance.toString(), requiredAmount: requiredAmount.toString() }); diff --git a/web/src/params.ts b/web/src/params.ts index fbe4bd9..c20a08d 100644 --- a/web/src/params.ts +++ b/web/src/params.ts @@ -5,6 +5,7 @@ export interface FundingCallData { approveData: string; fundTo: string; fundData: string; + approveSpender?: string; } export interface PaymentParams { @@ -90,6 +91,12 @@ const TOKEN_TO_CHAIN: Record = { usdt0_pol: "polygon", usdt_eth: "ethereum", usdt_arb: "arbitrum", }; +const CHAIN_ID_TO_CHAIN: Record = { + "137": "polygon", + "1": "ethereum", + "42161": "arbitrum", +}; + /** All supported chains for sender-side chain selection */ export const SUPPORTED_CHAINS = ["polygon", "arbitrum", "ethereum"] as const; @@ -99,6 +106,16 @@ export const CURRENCY_CHAIN_TO_TOKEN: Record> = { usdt: { polygon: "usdt0_pol", ethereum: "usdt_eth", arbitrum: "usdt_arb" }, }; +function tokenInfoToLegacyId(token: { chain?: string; symbol?: string; token_id?: string } | string | null | undefined): string { + if (typeof token === "string") return token; + const chain = CHAIN_ID_TO_CHAIN[String(token?.chain ?? "")]; + const symbol = token?.symbol?.toLowerCase(); + if (!chain || !symbol) return token?.token_id ?? ""; + if (symbol === "usdc") return CURRENCY_CHAIN_TO_TOKEN.usdc[chain] ?? token?.token_id ?? ""; + if (symbol === "usdt" || symbol === "usdt0") return CURRENCY_CHAIN_TO_TOKEN.usdt[chain] ?? token?.token_id ?? ""; + return token?.token_id ?? ""; +} + /** Statuses where funding has NOT yet happened — safe to show pay button */ const FUNDABLE_STATUSES = new Set([ "pending", @@ -155,8 +172,9 @@ export async function parseParams(): Promise { } const swap = await resp.json(); - const token = swap.source_token as string; - const chain = TOKEN_TO_CHAIN[token] ?? ""; + const sourceToken = swap.source_token; + const token = tokenInfoToLegacyId(sourceToken); + const chain = TOKEN_TO_CHAIN[token] ?? CHAIN_ID_TO_CHAIN[String(sourceToken?.chain ?? "")] ?? ""; const status = swap.status as string; // Build funding call data from swap response @@ -172,7 +190,7 @@ export async function parseParams(): Promise { } return { - amount: swap.source_amount, + amount: Number(swap.source_amount) / 10 ** (sourceToken?.decimals ?? TOKEN_DECIMALS[token] ?? 6), token, chain, to: swap.htlc_address_arkade ?? swap.target_address ?? "", @@ -201,7 +219,7 @@ export async function parseParams(): Promise { return { amount, token, chain, to, swapId, status: "pending", - funding: { approveTo, approveData, fundTo, fundData }, + funding: { approveTo, approveData, fundTo, fundData, approveSpender: fundTo }, }; } diff --git a/web/src/swap.ts b/web/src/swap.ts index ad146df..ebf619a 100644 --- a/web/src/swap.ts +++ b/web/src/swap.ts @@ -2,11 +2,33 @@ import { Client, IdbWalletStorage, IdbSwapStorage, + PERMIT2_ADDRESS, + type Chain, + type TokenInfo, } from "@lendasat/lendaswap-sdk-pure"; import type { PaymentParams } from "./params.js"; -import { TOKEN_DECIMALS } from "./params.js"; +import { CHAIN_IDS, TOKEN_DECIMALS } from "./params.js"; let client: Client | null = null; +let tokens: TokenInfo[] | null = null; + +const TOKEN_SYMBOLS: Record = { + usdc_pol: ["USDC"], + usdc_eth: ["USDC"], + usdc_arb: ["USDC"], + usdt0_pol: ["USDT0", "USDT"], + usdt_eth: ["USDT"], + usdt_arb: ["USDT"], +}; + +const TOKEN_CHAINS: Record = { + usdc_pol: "137", + usdc_eth: "1", + usdc_arb: "42161", + usdt0_pol: "137", + usdt_eth: "1", + usdt_arb: "42161", +}; async function getClient(): Promise { if (client) return client; @@ -17,25 +39,50 @@ async function getClient(): Promise { return client; } +async function resolveToken(token: string): Promise { + const c = await getClient(); + if (!tokens) { + const list = await c.getTokens(); + tokens = list.evm_tokens; + } + + const symbols = TOKEN_SYMBOLS[token]; + const chain = TOKEN_CHAINS[token]; + const info = tokens.find( + (candidate) => + candidate.chain === chain && symbols?.includes(candidate.symbol.toUpperCase()) + ); + + if (!info) throw new Error(`Unsupported token: ${token}`); + return info; +} + /** * Create a new swap (legacy flow — only used when swapId is not provided). */ export async function createSwap(params: PaymentParams, userAddress: string) { const c = await getClient(); - const result = await c.createEvmToArkadeSwap({ - sourceChain: params.chain, - sourceToken: params.token, - sourceAmount: params.amount, + const token = await resolveToken(params.token); + return c.createEvmToArkadeSwapGeneric({ targetAddress: params.to, + tokenAddress: token.token_id, + evmChainId: CHAIN_IDS[params.chain], + sourceAmount: BigInt(Math.round(params.amount * 10 ** (TOKEN_DECIMALS[params.token] ?? 6))), userAddress, }); - return result; } export async function getFundingCallData(swapId: string, token: string) { const c = await getClient(); - const decimals = TOKEN_DECIMALS[token] ?? 6; - return c.getEvmFundingCallData(swapId, decimals); + const chainId = Number(TOKEN_CHAINS[token]); + const funding = await c.getCoordinatorFundingCallDataPermit2(swapId, chainId); + return { + approve: { + ...funding.approve, + spender: PERMIT2_ADDRESS, + }, + createSwap: funding.executeAndCreate, + }; } const TERMINAL_STATUSES = new Set([ @@ -49,6 +96,7 @@ const TERMINAL_STATUSES = new Set([ "clientrefundedserverfunded", "clientrefundedserverrefunded", "clientinvalidfunded", + "serverwontfund", ]); const SUCCESS_STATUSES = new Set([ diff --git a/web/vite.config.ts b/web/vite.config.ts index 659fcc2..963b8ec 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -4,6 +4,11 @@ export default defineConfig({ root: "src", envDir: "..", base: "/", + resolve: { + alias: { + events: "events", + }, + }, build: { outDir: "../dist", emptyOutDir: true,