diff --git a/.env.example b/.env.example index b0ec0b2..1009876 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,9 @@ PARTNER_ID= # Override the directory holding config.json / config-testnet.json (default: ~/.config/acp) ACP_CONFIG_DIR= + +# Geodesics API key for `acp swap` (free at https://console.geodesics.ai) +GEODESICS_API_KEY= + +# Override the Geodesics API base URL (default: https://api.geodesics.ai; https-only) +GEODESICS_API_URL= diff --git a/bin/acp.ts b/bin/acp.ts index 0ce6840..3b03e07 100755 --- a/bin/acp.ts +++ b/bin/acp.ts @@ -21,6 +21,7 @@ import { registerCardCommands } from "../src/commands/card"; import { registerComputeCommands } from "../src/commands/compute"; import { registerSkillCommands } from "../src/commands/skill"; import { registerTradeCommands } from "../src/commands/trade"; +import { registerSwapCommands } from "../src/commands/swap"; const require = createRequire(import.meta.url); @@ -68,5 +69,6 @@ registerCardCommands(program); registerComputeCommands(program); registerSkillCommands(program); registerTradeCommands(program); +registerSwapCommands(program); program.parse(); diff --git a/package-lock.json b/package-lock.json index 1535e3e..3dc4492 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.29", "license": "ISC", "dependencies": { + "@geodesics-protocol/sdk": "^0.8.2", "@privy-io/node": "^0.11.0", "@virtuals-protocol/acp-node": "^0.3.0-beta.40", "@virtuals-protocol/acp-node-v2": "^0.1.10", @@ -1409,6 +1410,15 @@ "@ethersproject/strings": "^5.8.0" } }, + "node_modules/@geodesics-protocol/sdk": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@geodesics-protocol/sdk/-/sdk-0.8.2.tgz", + "integrity": "sha512-ycTqMRtrKA/8zo/xG5liofRMDGnfj60zL9AGYwZ0WdzGBLc8svs0oR4/qlS4gKG0Nl1b275cxIL5tbBHva1RlA==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/@hpke/chacha20poly1305": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@hpke/chacha20poly1305/-/chacha20poly1305-1.8.0.tgz", diff --git a/package.json b/package.json index 5653624..c512428 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "web3" ], "dependencies": { + "@geodesics-protocol/sdk": "^0.8.2", "@privy-io/node": "^0.11.0", "@virtuals-protocol/acp-node": "^0.3.0-beta.40", "@virtuals-protocol/acp-node-v2": "^0.1.10", diff --git a/scripts/geodesicsSwap.test.ts b/scripts/geodesicsSwap.test.ts new file mode 100644 index 0000000..57d8310 --- /dev/null +++ b/scripts/geodesicsSwap.test.ts @@ -0,0 +1,889 @@ +// Standalone harness (run via `npx tsx`) exercising `acp swap`'s executeSwap + +// signer bridges. The CLI has no jest runner, so this is a tsx assertion +// script (same pattern as tradeLoopSign.test.ts): it stubs the Geodesics +// client's fetch and fake providers, and verifies the EVM bridge signs the +// operation hash as raw bytes, collects a delegation authorization for a +// first swap from a new chain, and rejects a signer that authorizes the wrong +// target; the Solana bridge signs the whole prebuilt transaction; the SOL fee +// pipe funds and retries a refused same-chain swap (and refuses without +// --confirm-pipe); quotes surface price-impact data and gate high impact +// behind --accept-impact; canonical token symbols resolve chain-scoped; and a +// settlement-wait timeout maps to a typed TIMEOUT carrying the swap id. + +import assert from "node:assert"; +import { getAddress } from "viem"; +import { + CHAIN_IDS, + createGeodesicsClient, + GeodesicsTimeoutError, + type QuoteRequest, + type QuoteResponse, +} from "@geodesics-protocol/sdk"; +import type { + IEvmProviderAdapter, + ISolanaProviderAdapter, +} from "@virtuals-protocol/acp-node-v2"; +import { + assertImpactAccepted, + assertSufficientSolanaBalance, + createGeodesicsSolanaSwapSigner, + createGeodesicsSwapSigner, + executeSwap, + preflightSolanaQuote, + resolveInputToken, + resolveOutputToken, + resolveSolanaInputToken, + runGeodesics, +} from "../src/commands/swap"; +import { CliError } from "../src/lib/errors"; + +const WALLET = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const DELEGATION_TARGET = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const GEO_OP_HASH = `0x${"ab".repeat(32)}`; +const SIGNATURE = `0x${"11".repeat(65)}`; +const SOLANA_WALLET = "7dHbWXmci3dT8UFYWYZweBLXgycu7Y3iL6trKn1Y7ARj"; +const UNSIGNED_SOLANA_TX = "dW5zaWduZWQtc29sYW5hLXR4"; +const SIGNED_SOLANA_TX = "c2lnbmVkLXNvbGFuYS10eA=="; + +interface RecordedCall { + kind: "message" | "send" | "authorization" | "solana-tx"; + chainId: number; + payload: unknown; +} +interface RecordedRequest { + method: string; + path: string; + body: unknown; +} + +type FakeAuthorizationSigner = { + signAuthorization(request: { + contractAddress: string; + chainId: number; + nonce: number; + }): Promise<{ + address: string; + nonce: number; + chainId: number; + yParity: number | string; + r: string; + s: string; + }>; +}; + +function fakeProvider( + calls: RecordedCall[], + opts: { + withAuthorizationSigner?: boolean; + echoAddress?: string; + readContractBalance?: bigint; + } = {} +): IEvmProviderAdapter { + const unsupported = (method: string) => async () => { + throw new Error(`${method} should not be called in this test`); + }; + const provider: IEvmProviderAdapter & { + signer?: FakeAuthorizationSigner; + } = { + providerName: "fake", + getAddress: async () => WALLET, + getSupportedChainIds: async () => [8453], + getNetworkContext: unsupported("getNetworkContext"), + sendCalls: unsupported("sendCalls"), + getTransactionReceipt: unsupported("getTransactionReceipt"), + readContract: + opts.readContractBalance !== undefined + ? async () => opts.readContractBalance + : unsupported("readContract"), + getLogs: unsupported("getLogs"), + getBlockNumber: unsupported("getBlockNumber"), + signTypedData: unsupported("signTypedData"), + async signMessage(chainId: number, message: string) { + calls.push({ kind: "message", chainId, payload: message }); + return SIGNATURE; + }, + async sendTransaction(chainId: number, call) { + calls.push({ kind: "send", chainId, payload: call }); + return "0x1111111111111111111111111111111111111111"; + }, + }; + if (opts.withAuthorizationSigner) { + provider.signer = { + async signAuthorization(request) { + calls.push({ + kind: "authorization", + chainId: request.chainId, + payload: request, + }); + return { + address: opts.echoAddress ?? request.contractAddress, + nonce: request.nonce, + chainId: request.chainId, + yParity: "0x1", + r: `0x${"22".repeat(32)}`, + s: `0x${"33".repeat(32)}`, + }; + }, + }; + } + return provider; +} + +function fakeClient( + requests: RecordedRequest[], + delegation: Record, + quoteExtras: Record = {} +) { + const routes = (path: string): unknown => { + if (path.endsWith("/swap/quote")) { + return { + geoQuoteToken: "quote-token", + origin: "evm", + output: "4990000", + feeBps: 12.5, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + ...quoteExtras, + }; + } + if (path.includes("/swap/delegation/")) { + return delegation; + } + if (path.endsWith("/swap/build-geo-op")) { + return { geoOpHash: GEO_OP_HASH, geoOpToken: "op-token" }; + } + if (path.endsWith("/swap/build-solana-tx")) { + return { + unsignedTransaction: UNSIGNED_SOLANA_TX, + geoOpToken: "sol-op-token", + }; + } + if (path.endsWith("/swap/submit")) { + return { swapId: "swap-1", status: "pending" }; + } + if (path.endsWith("/swap/submit-solana")) { + return { swapId: "swap-1", status: "pending" }; + } + if (path.includes("/swap/status/")) { + return { + swapId: "swap-1", + status: "settled", + originTxHash: "0xorigin", + deliveryTxHash: "0xdelivery", + }; + } + throw new Error(`Unexpected path: ${path}`); + }; + const fetchStub: typeof fetch = async (input, init) => { + const requestUrl = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + const { pathname } = new URL(requestUrl); + requests.push({ + method: init?.method ?? "GET", + path: pathname, + body: typeof init?.body === "string" ? JSON.parse(init.body) : undefined, + }); + return new Response(JSON.stringify(routes(pathname)), { status: 200 }); + }; + return createGeodesicsClient({ + baseUrl: "https://fake.test", + apiKey: "test-key", + fetch: fetchStub, + }); +} + +const request: QuoteRequest = { + originChain: 8453, + destinationChain: 4663, + inputToken: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + outputToken: "0x5fc5360d0400a0fd4f2af552add042d716f1d168", + amount: "5000000", + walletAddress: WALLET, +}; + +const delegatedResponse = { + chainId: 8453, + walletAddress: WALLET, + delegated: true, + originReady: true, + delegatedTo: DELEGATION_TARGET, +}; + +const undelegatedResponse = { + chainId: 8453, + walletAddress: WALLET, + delegated: false, + originReady: false, + delegationTarget: DELEGATION_TARGET, + accountNonce: 7, +}; + +function findRequest( + requests: RecordedRequest[], + pathSuffix: string +): RecordedRequest | undefined { + return requests.find((recorded) => recorded.path.endsWith(pathSuffix)); +} + +function bodyField(recorded: RecordedRequest | undefined, field: string): unknown { + if (recorded === undefined || typeof recorded.body !== "object") { + return undefined; + } + return recorded.body === null + ? undefined + : Reflect.get(recorded.body, field); +} + +async function testSignsOperationHashAsRawBytes() { + const calls: RecordedCall[] = []; + const requests: RecordedRequest[] = []; + const client = fakeClient(requests, delegatedResponse); + + const result = await executeSwap( + client, + createGeodesicsSwapSigner(fakeProvider(calls, { withAuthorizationSigner: true })), + request, + true + ); + + assert.strictEqual(result.status, "settled"); + assert.strictEqual(result.swapId, "swap-1"); + assert.strictEqual(result.deliveryTxHash, "0xdelivery"); + const messageSigns = calls.filter((c) => c.kind === "message"); + assert.strictEqual(messageSigns.length, 1, "signed the op hash once"); + assert.strictEqual( + messageSigns[0].payload, + GEO_OP_HASH, + "signed the geoOpHash hex string (the adapter signs 0x strings as raw bytes)" + ); + assert.strictEqual( + calls.filter((c) => c.kind === "authorization").length, + 0, + "no authorization on an already-delegated origin" + ); + const submit = findRequest(requests, "/swap/submit"); + assert.ok(submit, "posted /swap/submit"); + assert.strictEqual( + bodyField(submit, "signature"), + SIGNATURE, + "posted the raw signature back" + ); + assert.strictEqual( + bodyField(findRequest(requests, "/swap/build-geo-op"), "authorization"), + undefined, + "build carries no authorization when delegated" + ); + console.log("✓ delegated origin: signs op hash, no authorization"); +} + +async function testFirstSwapCollectsAuthorization() { + const calls: RecordedCall[] = []; + const requests: RecordedRequest[] = []; + const client = fakeClient(requests, undelegatedResponse); + + const result = await executeSwap( + client, + createGeodesicsSwapSigner(fakeProvider(calls, { withAuthorizationSigner: true })), + request, + true + ); + + assert.strictEqual(result.status, "settled"); + const authorizations = calls.filter((c) => c.kind === "authorization"); + assert.strictEqual(authorizations.length, 1, "asked the signer service once"); + assert.deepStrictEqual( + authorizations[0].payload, + { contractAddress: DELEGATION_TARGET, chainId: 8453, nonce: 7 }, + "authorization request carries the server-supplied target and nonce" + ); + const build = findRequest(requests, "/swap/build-geo-op"); + assert.ok(build, "posted /swap/build-geo-op"); + assert.deepStrictEqual( + bodyField(build, "authorization"), + { + chainId: 8453, + address: getAddress(DELEGATION_TARGET), + nonce: 7, + yParity: 1, + r: `0x${"22".repeat(32)}`, + s: `0x${"33".repeat(32)}`, + }, + "build carries the mapped authorization (request fields authoritative, yParity normalized)" + ); + console.log( + "✓ first swap from a new chain: authorization rides in the build" + ); +} + +async function testRejectsWrongDelegationTarget() { + const calls: RecordedCall[] = []; + const requests: RecordedRequest[] = []; + const client = fakeClient(requests, undelegatedResponse); + + let threw: unknown; + try { + await executeSwap( + client, + createGeodesicsSwapSigner( + fakeProvider(calls, { + withAuthorizationSigner: true, + echoAddress: "0xcccccccccccccccccccccccccccccccccccccccc", + }) + ), + request, + true + ); + } catch (err) { + threw = err; + } + assert.ok(threw instanceof Error, "wrong-target authorization throws"); + assert.match( + threw.message, + /different delegation target/, + "names the mismatch" + ); + assert.ok( + findRequest(requests, "/swap/build-geo-op") === undefined, + "nothing was built or submitted" + ); + console.log( + "✓ signer authorizing a different target is rejected before build" + ); +} + +async function testSurfacesQuotePricingAndWarnings() { + const calls: RecordedCall[] = []; + const requests: RecordedRequest[] = []; + const impactMessage = + "Estimated loss is about 6.8% of your input value including price impact and fees, proceed only if intended."; + const client = fakeClient(requests, delegatedResponse, { + inputUsd: "5.00", + outputUsd: "4.66", + priceImpactBps: -680, + warnings: [{ code: "HIGH_PRICE_IMPACT", message: impactMessage }], + }); + + const result = await executeSwap( + client, + createGeodesicsSwapSigner(fakeProvider(calls, { withAuthorizationSigner: true })), + request, + true + ); + + assert.strictEqual(result.status, "settled"); + assert.strictEqual( + result.priceImpactBps, + -680, + "the executed quote's price impact rides on the result" + ); + assert.strictEqual( + result.warnings, + undefined, + "the pre-trade impact notice does not ride a settled result" + ); + console.log("✓ price impact rides the result; the pre-trade notice does not"); +} + +function quoteFixture(extras: Partial = {}): QuoteResponse { + return { + geoQuoteToken: "quote-token", + origin: "evm", + output: "4990000", + feeBps: 12.5, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + ...extras, + }; +} + +function expectCliError( + run: () => unknown, + code: string, + messagePattern: RegExp, + label: string +): void { + let threw: unknown; + try { + run(); + } catch (err) { + threw = err; + } + assert.ok(threw instanceof CliError, `${label}: throws a CliError`); + assert.strictEqual(threw.code, code, `${label}: error code`); + assert.match( + `${threw.message}\n${threw.recovery ?? ""}`, + messagePattern, + `${label}: message or recovery` + ); +} + +function testResolvesTokenSymbols() { + const usdcBase = resolveInputToken("usdc", 8453); + assert.strictEqual( + usdcBase.address, + getAddress("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"), + "usdc on Base resolves to the canonical address" + ); + assert.strictEqual(usdcBase.decimals, 6, "alias supplies decimals"); + assert.strictEqual( + resolveInputToken("USDC", 8453).address, + usdcBase.address, + "symbol resolution is case-insensitive" + ); + + const rawAddress = resolveInputToken( + "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + 8453 + ); + assert.strictEqual( + rawAddress.address, + usdcBase.address, + "raw addresses pass through checksummed" + ); + assert.strictEqual( + rawAddress.decimals, + undefined, + "raw addresses read decimals on-chain, not from the table" + ); + + expectCliError( + () => resolveInputToken("pol", 137), + "VALIDATION_ERROR", + /native gas token/, + "native-token input" + ); + expectCliError( + () => resolveInputToken("usdc", 4663), + "VALIDATION_ERROR", + /Symbols known on this chain: eth, usdg/, + "unknown symbol on the chain" + ); + + assert.strictEqual( + resolveOutputToken("usdg", 4663), + "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168", + "usdg output on Robinhood Chain resolves" + ); + assert.strictEqual( + resolveOutputToken("eth", 8453), + "0x0000000000000000000000000000000000000000", + "native output resolves to the native id" + ); + assert.strictEqual( + resolveOutputToken("sol", 792703809), + "11111111111111111111111111111111", + "sol output resolves to the native SOL id" + ); + assert.strictEqual( + resolveOutputToken("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", 792703809), + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "raw Solana mints pass through" + ); + expectCliError( + () => resolveOutputToken("usdc", 4663), + "VALIDATION_ERROR", + /Symbols known on this chain/, + "unknown output symbol" + ); + console.log("✓ canonical symbols resolve per chain; misses fail with hints"); +} + +function testGatesHighPriceImpact() { + const impactMessage = + "Estimated loss is about 6.8% of your input value including price impact and fees, proceed only if intended."; + const flagged = quoteFixture({ + priceImpactBps: -680, + warnings: [{ code: "HIGH_PRICE_IMPACT", message: impactMessage }], + }); + + assertImpactAccepted(quoteFixture(), false); + assertImpactAccepted(flagged, true); + expectCliError( + () => assertImpactAccepted(flagged, false), + "PRICE_IMPACT_HIGH", + /Estimated loss is about 6\.8%/, + "high-impact quote without --accept-impact" + ); + console.log("✓ high price impact refuses without --accept-impact"); +} + +function fakeSolanaProvider(calls: RecordedCall[]): ISolanaProviderAdapter { + const unsupported = (method: string) => async () => { + throw new Error(`${method} should not be called in this test`); + }; + const unsupportedSync = + (method: string) => + (): never => { + throw new Error(`${method} should not be called in this test`); + }; + const provider: ISolanaProviderAdapter & { + signTransactionViaPrivy(txBase64: string): Promise; + } = { + providerName: "fake-solana", + getAddress: async () => SOLANA_WALLET, + getSupportedChainIds: async () => [501], + getNetworkContext: unsupported("getNetworkContext"), + getCluster: unsupported("getCluster"), + getRpc: unsupportedSync("getRpc"), + getSigner: unsupportedSync("getSigner"), + signMessage: unsupported("signMessage"), + sendInstructions: unsupported("sendInstructions"), + async signTransactionViaPrivy(txBase64: string) { + calls.push({ kind: "solana-tx", chainId: 501, payload: txBase64 }); + return SIGNED_SOLANA_TX; + }, + }; + return provider; +} + +const solanaRequest: QuoteRequest = { + originChain: CHAIN_IDS.solana, + destinationChain: 4663, + inputToken: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + outputToken: "0x5fc5360d0400a0fd4f2af552add042d716f1d168", + amount: "2000000", + walletAddress: SOLANA_WALLET, + recipient: WALLET, +}; + +async function testSolanaOriginSignsWholeTransaction() { + const calls: RecordedCall[] = []; + const requests: RecordedRequest[] = []; + const client = fakeClient(requests, delegatedResponse, { origin: "solana" }); + + const result = await executeSwap( + client, + createGeodesicsSolanaSwapSigner(fakeSolanaProvider(calls)), + solanaRequest, + true + ); + + assert.strictEqual(result.status, "settled"); + const transactionSigns = calls.filter((call) => call.kind === "solana-tx"); + assert.strictEqual(transactionSigns.length, 1, "signed the transaction once"); + assert.strictEqual( + transactionSigns[0].payload, + UNSIGNED_SOLANA_TX, + "signed the server-built unsigned transaction" + ); + const submitSolana = findRequest(requests, "/swap/submit-solana"); + assert.ok(submitSolana, "posted /swap/submit-solana"); + assert.strictEqual( + bodyField(submitSolana, "signedTransaction"), + SIGNED_SOLANA_TX, + "posted the signed transaction back" + ); + assert.strictEqual( + bodyField(submitSolana, "geoOpToken"), + "sol-op-token", + "echoed the sealed op token" + ); + assert.ok( + findRequest(requests, "/swap/build-geo-op") === undefined, + "the EVM build lane was never touched" + ); + console.log("✓ Solana origin: signs the whole prebuilt transaction"); +} + +function fakePipeClient(requests: RecordedRequest[]) { + let solanaQuoteAttempts = 0; + const routes = ( + path: string, + body: Record | undefined + ): { status: number; payload: unknown } => { + if (path.endsWith("/swap/quote")) { + if (body?.originChain === CHAIN_IDS.solana) { + solanaQuoteAttempts += 1; + if (solanaQuoteAttempts === 1) { + return { + status: 409, + payload: { + code: "NEEDS_SOL_TOPUP", + message: "Solana wallet holds 0 lamports (< minimum required).", + }, + }; + } + return { + status: 200, + payload: { + geoQuoteToken: "sol-quote-token", + origin: "solana", + output: "1980000", + feeBps: 12.5, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + }; + } + return { + status: 200, + payload: { + geoQuoteToken: "pipe-quote-token", + origin: "evm", + output: "0.009", + feeBps: 12.5, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + }; + } + if (path.includes("/swap/delegation/")) { + return { status: 200, payload: delegatedResponse }; + } + if (path.endsWith("/swap/build-geo-op")) { + return { + status: 200, + payload: { geoOpHash: GEO_OP_HASH, geoOpToken: "op-token" }, + }; + } + if (path.endsWith("/swap/submit")) { + return { status: 200, payload: { swapId: "pipe-1", status: "pending" } }; + } + if (path.includes("/swap/status/")) { + return { + status: 200, + payload: { swapId: "pipe-1", status: "settled" }, + }; + } + throw new Error(`Unexpected path: ${path}`); + }; + const fetchStub: typeof fetch = async (input, init) => { + const requestUrl = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + const { pathname } = new URL(requestUrl); + const parsedBody = + typeof init?.body === "string" ? JSON.parse(init.body) : undefined; + requests.push({ + method: init?.method ?? "GET", + path: pathname, + body: parsedBody, + }); + const { status, payload } = routes(pathname, parsedBody); + return new Response(JSON.stringify(payload), { status }); + }; + return { + client: createGeodesicsClient({ + baseUrl: "https://fake.test", + apiKey: "test-key", + fetch: fetchStub, + }), + solanaQuoteCount: () => solanaQuoteAttempts, + }; +} + +async function testPreflightPipesSolThenRetries() { + const calls: RecordedCall[] = []; + const requests: RecordedRequest[] = []; + const { client, solanaQuoteCount } = fakePipeClient(requests); + const provider = fakeProvider(calls, { + withAuthorizationSigner: true, + readContractBalance: 2_000_000n, + }); + + const quoted = await preflightSolanaQuote( + client, + provider, + solanaRequest, + true, + getAddress(WALLET), + true + ); + + assert.strictEqual(quoted.origin, "solana", "the retried quote came back"); + assert.strictEqual(solanaQuoteCount(), 2, "quoted, piped, re-quoted"); + const pipeQuote = requests.find( + (recorded) => + recorded.path.endsWith("/swap/quote") && + bodyField(recorded, "originChain") === CHAIN_IDS.base + ); + assert.ok(pipeQuote, "the pipe quoted an EVM basis-chain origin"); + assert.strictEqual( + bodyField(pipeQuote, "recipient"), + SOLANA_WALLET, + "the pipe delivers SOL to the agent's Solana wallet" + ); + assert.strictEqual( + calls.filter((call) => call.kind === "message").length, + 1, + "the pipe swap signed with the EVM wallet" + ); + assert.ok(findRequest(requests, "/swap/submit"), "the pipe swap submitted"); + console.log("✓ Solana origin without SOL: pipes a stable into SOL, retries"); +} + +async function testPreflightRefusesWithoutConfirmPipe() { + const calls: RecordedCall[] = []; + const requests: RecordedRequest[] = []; + const { client } = fakePipeClient(requests); + const provider = fakeProvider(calls, { readContractBalance: 2_000_000n }); + + let threw: unknown; + try { + await preflightSolanaQuote( + client, + provider, + solanaRequest, + false, + getAddress(WALLET), + true + ); + } catch (err) { + threw = err; + } + assert.ok(threw instanceof CliError, "refusal surfaces as a CliError"); + assert.strictEqual(threw.code, "INSUFFICIENT_GAS", "typed INSUFFICIENT_GAS"); + assert.match( + threw.recovery ?? "", + /--confirm-pipe/, + "the recovery names the flag" + ); + assert.ok( + findRequest(requests, "/swap/submit") === undefined, + "nothing was piped or submitted" + ); + console.log("✓ Solana origin without SOL and no --confirm-pipe: typed refusal"); +} + +async function testMapsSettlementTimeout() { + let threw: unknown; + try { + await runGeodesics(() => + Promise.reject(new GeodesicsTimeoutError("swap-slow-1", "pending")) + ); + } catch (err) { + threw = err; + } + assert.ok(threw instanceof CliError, "timeout surfaces as a CliError"); + assert.strictEqual(threw.code, "TIMEOUT", "typed TIMEOUT"); + assert.match( + threw.message, + /swap-slow-1.*pending/, + "carries the swap id and last status" + ); + assert.match( + threw.recovery ?? "", + /usually still settles/, + "the recovery says the swap may still settle" + ); + console.log("✓ settlement-wait timeout maps to typed TIMEOUT with swap id"); +} + +async function testSolanaBalancePreflight() { + const USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; + const SOL_NATIVE = "11111111111111111111111111111111"; + const reads = (lamports: bigint, splBase: bigint) => ({ + getLamports: async () => lamports, + getSplBalance: async () => splBase, + }); + + let threw: unknown; + try { + await assertSufficientSolanaBalance( + reads(0n, 1_000_000n), + SOLANA_WALLET, + USDC_MINT, + 2_000_000n, + 6, + true + ); + } catch (err) { + threw = err; + } + assert.ok(threw instanceof CliError, "insufficient SPL throws"); + assert.strictEqual(threw.code, "INSUFFICIENT_BALANCE"); + assert.match(threw.message, /holds 1 USDC on solana/, "alias-named message"); + + threw = undefined; + try { + await assertSufficientSolanaBalance( + reads(9_523_436n, 0n), + SOLANA_WALLET, + SOL_NATIVE, + 9_000_000n, + 9, + true + ); + } catch (err) { + threw = err; + } + assert.ok(threw instanceof CliError, "sub-rent-exempt remainder throws"); + assert.strictEqual(threw.code, "VALIDATION_ERROR"); + assert.match(threw.message, /rent-exempt/, "names the rent rule"); + + await assertSufficientSolanaBalance( + reads(9_523_436n, 0n), + SOLANA_WALLET, + SOL_NATIVE, + 9_523_436n, + 9, + true + ); + await assertSufficientSolanaBalance( + reads(0n, 5_000_000n), + SOLANA_WALLET, + USDC_MINT, + 2_000_000n, + 6, + true + ); + await assertSufficientSolanaBalance( + { + getLamports: async () => { + throw new Error("rpc down"); + }, + getSplBalance: async () => { + throw new Error("rpc down"); + }, + }, + SOLANA_WALLET, + USDC_MINT, + 2_000_000n, + 6, + true + ); + console.log( + "✓ Solana balance preflight: typed refusals, rent-band guard, read-failure continues" + ); +} + +function testResolvesSolanaInputTokens() { + const usdcInput = resolveSolanaInputToken("usdc"); + assert.strictEqual( + usdcInput.token, + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "usdc resolves to the canonical mint" + ); + assert.strictEqual(usdcInput.decimals, 6, "the alias supplies decimals"); + expectCliError( + () => resolveSolanaInputToken("weth"), + "VALIDATION_ERROR", + /Solana-origin inputs: sol, usdc, usdt/, + "unsupported Solana-origin input" + ); + console.log("✓ Solana-origin inputs resolve via the canonical aliases"); +} + +async function main() { + await testSignsOperationHashAsRawBytes(); + await testFirstSwapCollectsAuthorization(); + await testRejectsWrongDelegationTarget(); + await testSurfacesQuotePricingAndWarnings(); + testResolvesTokenSymbols(); + testGatesHighPriceImpact(); + await testSolanaOriginSignsWholeTransaction(); + await testPreflightPipesSolThenRetries(); + await testPreflightRefusesWithoutConfirmPipe(); + await testMapsSettlementTimeout(); + await testSolanaBalancePreflight(); + testResolvesSolanaInputTokens(); + console.log("\nAll acp swap bridge tests passed."); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/commands/swap.ts b/src/commands/swap.ts new file mode 100644 index 0000000..04a5a8f --- /dev/null +++ b/src/commands/swap.ts @@ -0,0 +1,1277 @@ +// `acp swap` -- gasless cross-chain swaps via Geodesics (docs.geodesics.ai). +// Complements `acp trade` with a different execution model: the Geodesics API +// quotes and builds the whole route server-side, the wallet key signs ONE +// payload (a 32-byte operation hash via EIP-191 for EVM origins, the whole +// prebuilt transaction for Solana origins), the server submits, and the CLI +// polls to settlement. Cross-chain swaps are gasless on every origin -- the +// wallet needs no native gas token and no fee float on any chain, Solana +// included -- and the first swap from an EVM chain the wallet has never used +// onboards it inside that same swap (a one-time EIP-7702 delegation +// authorization signed through the wallet's signer service; no separate +// activation transaction). +// +// The one exception is a SAME-CHAIN Solana swap, which needs a little SOL +// (~0.005) for network fees. A swap that lacks it fails fast with the +// server's typed refusal; --confirm-pipe approves funding it in-line by +// piping ~1.5 of a basis-chain stable (Base USDC or Robinhood Chain USDG, +// whichever the wallet holds) into SOL, then retrying. +// +// Flag vocabulary mirrors `acp trade` (--token-in/--chain-in/--amount-in/ +// --token-out/--chain-out/--recipient/--slippage/--dry-run). Tokens are +// addresses first, plus one convenience: canonical symbols (usdc, usdt, usdg, +// weth, virtual, eth, pol, matic, bnb, sol) resolve client-side through the +// SDK's published chain-scoped alias table. Resolution is exact or an error -- +// a symbol the chain does not have never remaps to another token or chain, +// and the resolved address is echoed in the output, so what gets quoted is +// always explicit. --amount-in is human units (decimals from the alias table, +// else read on-chain), or "max" for the wallet's full balance. +// +// Quotes carry the venue-priced USD value of both sides and the all-in value +// change in basis points (negative = the output is worth less). A quote the +// server flags with its high-price-impact warning is refused unless +// --accept-impact is passed; --dry-run always just shows the numbers. +// +// Env: GEODESICS_API_KEY (required -- free key at console.geodesics.ai); +// GEODESICS_API_URL (optional override, https-only like the trade endpoint). +// Everything else -- wallet, signer, auth -- is the standard `acp configure` + +// `acp agent add-signer` setup shared with every other signing command. + +import type { Command } from "commander"; +import type { Address } from "viem"; +import { formatUnits, getAddress, isAddress, isHex, parseUnits } from "viem"; +import { + CHAIN_IDS, + createGeodesicsClient, + GeodesicsApiError, + type GeodesicsClient, + GeodesicsTimeoutError, + type QuoteRequest, + type QuoteResponse, + type ResolvedTokenAlias, + resolveTokenAlias, + type SignedAuthorization, + type SupportedChainId, + type SwapProgress, + type SwapSigner, + tokenAliasesForChain, +} from "@geodesics-protocol/sdk"; +import { address as solanaAddress } from "@solana/kit"; +import { + getSplTokenBalance, + type IEvmProviderAdapter, + type ISolanaProviderAdapter, +} from "@virtuals-protocol/acp-node-v2"; +import { isJson, isTTY, outputError, outputResult } from "../lib/output"; +import { CliError, type ErrorCode } from "../lib/errors"; +import { parseChainArg } from "../lib/chains"; +import { + createProviderAdapter, + createSolanaProviderAdapter, + getSolanaWalletAddress, + getWalletAddress, +} from "../lib/agentFactory"; +import { withApprovalGate } from "../lib/walletGate"; + +const GEODESICS_API_URL = "https://api.geodesics.ai"; +const SIGNING_CHAIN_ID = CHAIN_IDS.base; +const SOLANA_MAINNET_PRIVY_CHAIN_ID = 501; +const SOL_TOPUP_STABLE_UNITS = "1.5"; + +const SUPPORTED_CHAIN_IDS: ReadonlySet = new Set( + Object.values(CHAIN_IDS) +); + +function isSupportedChain(chainId: number): chainId is SupportedChainId { + return SUPPORTED_CHAIN_IDS.has(chainId); +} + +function supportedChainSummary(): string { + return Object.entries(CHAIN_IDS) + .map(([name, id]) => `${name} (${id})`) + .join(", "); +} + +// ---------- Command registration ---------- + +export function registerSwapCommands(program: Command): void { + program + .command("swap") + .description( + "Gasless cross-chain swap via Geodesics: sign one hash, receive the " + + "output token on the destination chain in seconds. Costs come out of " + + "the input -- no gas token or fee float needed on any chain. " + + "Tokens: contract addresses, or canonical symbols like usdc/usdg/weth. " + + "See `acp swap --help`." + ) + .addHelpText( + "after", + "\nSupported chains (pass the id or the name): Ethereum (1), Base (8453), Arbitrum (42161),\n" + + " Optimism (10), Polygon (137), BNB (56), Robinhood Chain (4663 / robinhood), Solana (sol).\n" + + "\nTokens: pass a contract address (always works), or a canonical symbol -- usdc, usdt,\n" + + "usdg, weth, virtual, eth, pol, matic, bnb, sol -- resolved client-side per chain.\n" + + "A symbol the chain does not have is an error, never a substitution, and the resolved\n" + + "address is echoed in the output: what you pass is exactly what gets quoted.\n" + + "--amount-in is in human units, or 'max' for the full balance.\n" + + "\nEvery quote reports the USD value of both sides and the all-in value change in\n" + + "basis points (negative = the output is worth less). A swap the server flags for\n" + + "high price impact is refused unless --accept-impact is passed.\n" + + "\nFirst swap from a new EVM chain: the wallet is onboarded inside the swap itself,\n" + + "gasless -- no prior balance or activation needed on that chain.\n" + + "\nSolana works both ways, gasless like everywhere else. As the destination, the\n" + + "output is delivered to the agent's Solana wallet (or --recipient). As the ORIGIN\n" + + "(inputs: sol, usdc, usdt), the agent's Solana wallet signs the prebuilt transaction\n" + + "and needs no SOL at all for cross-chain swaps. Only a SAME-CHAIN Solana swap needs\n" + + "a little SOL (~0.005) for fees -- pass --confirm-pipe to fund that automatically.\n" + + "\nExamples:\n" + + " # 5 USDC on Base -> USDG on Robinhood Chain (canonical symbols)\n" + + " acp swap --token-in usdc --chain-in base --amount-in 5 --token-out usdg --chain-out robinhood --json\n" + + " # The same swap with explicit addresses (any ERC-20 works by address)\n" + + " acp swap --token-in 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 --chain-in base --amount-in 5 \\\n" + + " --token-out 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168 --chain-out robinhood --json\n" + + " # Preview only: route, fee, USD in/out, price impact -- nothing signed\n" + + " acp swap --token-in usdc --chain-in base --amount-in 5 --token-out usdg --chain-out 4663 --dry-run --json\n" + + " # Swap the wallet's ENTIRE balance back (the safe way to return a delivery)\n" + + " acp swap --token-in usdg --chain-in robinhood --amount-in max --token-out usdc --chain-out base --json\n" + + " # Solana-origin: swap the agent's Solana USDC to USDG on Robinhood Chain, no SOL needed\n" + + " acp swap --token-in usdc --chain-in sol --amount-in 2 --token-out usdg --chain-out robinhood --json\n" + ) + .requiredOption( + "--token-in ", + "Input token: contract address on the origin chain, or a canonical symbol (e.g. usdc)" + ) + .requiredOption( + "--chain-in ", + "Origin chain id or name (e.g. 8453, base, robinhood)" + ) + .requiredOption( + "--amount-in ", + "Input amount in human units, or 'max' for the wallet's full balance" + ) + .requiredOption( + "--token-out ", + "Output token on the destination chain: address, native/SPL id, or a canonical symbol" + ) + .requiredOption( + "--chain-out ", + "Destination chain id or name (e.g. 4663, robinhood, sol)" + ) + .option( + "--recipient ", + "Output recipient (default: the agent's wallet on the destination chain, its Solana wallet for a Solana destination)" + ) + .option( + "--slippage ", + "Max slippage as a percent, e.g. 1 = 1% (server default per route if omitted)" + ) + .option( + "--dry-run", + "Quote only: route, fee, USD value both sides, price impact -- nothing signed or submitted", + false + ) + .option( + "--accept-impact", + "Execute even when the quote carries the server's high-price-impact warning", + false + ) + .option( + "--confirm-pipe", + "If a same-chain Solana swap needs SOL for fees, approve piping ~1.5 of a basis-chain stable into SOL first", + false + ) + .action(async (opts, cmd) => { + const json = isJson(cmd); + try { + await runSwap(opts, json); + } catch (err) { + outputError(json, err instanceof Error ? err : String(err)); + } + }); +} + +// ---------- Swap flow ---------- + +async function runSwap( + opts: Record, + json: boolean +): Promise { + if (process.env.IS_TESTNET === "true") { + throw new CliError( + "`acp swap` supports mainnet only.", + "VALIDATION_ERROR", + "Unset IS_TESTNET to swap." + ); + } + const originChain = resolveSwapChain(opts.chainIn); + const destinationChain = resolveSwapChain(opts.chainOut); + const originIsSolana = originChain === CHAIN_IDS.solana; + const outputToken = resolveOutputToken( + String(opts.tokenOut), + destinationChain + ); + const slippageBps = resolveSlippageBps(opts.slippage); + + const geodesics = createGeodesicsClient({ + baseUrl: resolveGeodesicsUrl(), + apiKey: requireGeodesicsApiKey(), + }); + const evmWalletAddress = parseEvmAddress( + getWalletAddress(), + "the active agent wallet" + ); + const recipient = await resolveRecipientWithDefaults( + opts.recipient, + originIsSolana, + destinationChain, + evmWalletAddress + ); + + const provider = await createProviderAdapter(); + + let request: QuoteRequest; + let evmPreflight: + | { token: Address; amount: bigint; decimals: number } + | undefined; + let solanaPreflight: + | { token: string; amount: bigint; decimals: number } + | undefined; + if (originIsSolana) { + const input = resolveSolanaInputToken(String(opts.tokenIn)); + const amount = resolveSolanaInputAmount( + String(opts.amountIn), + input.decimals + ); + const solanaWalletAddress = await getSolanaWalletAddress(); + request = { + originChain, + destinationChain, + inputToken: input.token, + outputToken, + amount, + walletAddress: solanaWalletAddress, + ...(recipient !== undefined ? { recipient } : {}), + ...(slippageBps !== undefined ? { slippageBps } : {}), + }; + solanaPreflight = { + token: input.token, + amount: BigInt(amount), + decimals: input.decimals, + }; + } else { + const input = resolveInputToken(String(opts.tokenIn), originChain); + const decimals = + input.decimals ?? + (await readTokenDecimals(provider, originChain, input.address)); + const amount = await resolveInputAmount( + provider, + originChain, + input.address, + evmWalletAddress, + String(opts.amountIn), + decimals + ); + request = { + originChain, + destinationChain, + inputToken: input.address, + outputToken, + amount, + walletAddress: evmWalletAddress, + ...(recipient !== undefined ? { recipient } : {}), + ...(slippageBps !== undefined ? { slippageBps } : {}), + }; + evmPreflight = { token: input.address, amount: BigInt(amount), decimals }; + } + + if (opts.dryRun) { + const quote = await runGeodesics(() => geodesics.quote(request)); + outputResult(json, { + dryRun: true, + inputToken: request.inputToken, + outputToken: request.outputToken, + output: quote.output, + feeBps: quote.feeBps, + ...(quote.inputUsd !== undefined ? { inputUsd: quote.inputUsd } : {}), + ...(quote.outputUsd !== undefined ? { outputUsd: quote.outputUsd } : {}), + ...(quote.priceImpactBps !== undefined + ? { priceImpactBps: quote.priceImpactBps } + : {}), + expiresAt: quote.expiresAt, + originChain, + destinationChain, + ...(quote.warnings && quote.warnings.length > 0 + ? { warnings: quote.warnings.map((warning) => warning.message) } + : {}), + }); + return; + } + + if (evmPreflight !== undefined) { + await assertSufficientBalance( + provider, + originChain, + evmPreflight.token, + evmWalletAddress, + evmPreflight.amount, + evmPreflight.decimals, + json + ); + } + + if (!originIsSolana) { + const preflight = await runGeodesics(() => geodesics.quote(request)); + assertImpactAccepted(preflight, Boolean(opts.acceptImpact)); + reportQuotedImpact(json, preflight); + } + + let solanaSigner: SwapSigner | undefined; + if (originIsSolana) { + const solanaProvider = await createSolanaProviderAdapter( + SOLANA_MAINNET_PRIVY_CHAIN_ID + ); + if (solanaPreflight !== undefined) { + await assertSufficientSolanaBalance( + solanaBalanceReads(solanaProvider), + request.walletAddress, + solanaPreflight.token, + solanaPreflight.amount, + solanaPreflight.decimals, + json + ); + } + solanaSigner = createGeodesicsSolanaSwapSigner(solanaProvider); + } + const result = await withApprovalGate( + async (gatedProvider) => { + if (solanaSigner === undefined) { + return executeSwap( + geodesics, + createGeodesicsSwapSigner(gatedProvider), + request, + json + ); + } + const preflight = await preflightSolanaQuote( + geodesics, + gatedProvider, + request, + Boolean(opts.confirmPipe), + evmWalletAddress, + json + ); + assertImpactAccepted(preflight, Boolean(opts.acceptImpact)); + reportQuotedImpact(json, preflight); + return executeSwap(geodesics, solanaSigner, request, json); + }, + { json, deferSocket: true, evmProvider: Promise.resolve(provider) } + ); + outputResult(json, result); +} + +function reportQuotedImpact(json: boolean, quote: QuoteResponse): void { + if (quote.priceImpactBps === undefined) return; + progress( + json, + `Quoted value change ${(quote.priceImpactBps / 100).toFixed(2)}% (price impact + network costs + fee)` + ); +} + +export async function executeSwap( + client: GeodesicsClient, + signer: SwapSigner, + request: QuoteRequest, + json: boolean +): Promise> { + const result = await runGeodesics(() => + client.swap(request, signer, { + onProgress: (p) => progress(json, describeProgress(p)), + }) + ); + return { + swapId: result.swapId, + status: result.status, + output: result.output, + feeBps: result.feeBps, + ...(result.priceImpactBps !== undefined + ? { priceImpactBps: result.priceImpactBps } + : {}), + ...(result.originTxHash ? { originTxHash: result.originTxHash } : {}), + ...(result.deliveryTxHash ? { deliveryTxHash: result.deliveryTxHash } : {}), + ...(result.refundHint ? { refundHint: result.refundHint } : {}), + ...(result.warnings && result.warnings.length > 0 + ? { warnings: [...result.warnings] } + : {}), + }; +} + +// ---------- Signer bridge ---------- + +type DelegationAuthorizationRequest = { + chainId: number; + address: string; + nonce: number; +}; + +type SignAuthorizationViaService = (request: { + contractAddress: string; + chainId: number; + nonce: number; +}) => Promise; + +function getAuthorizationSigner( + provider: IEvmProviderAdapter +): SignAuthorizationViaService | undefined { + const signer: unknown = Reflect.get(provider, "signer"); + if (signer === null || typeof signer !== "object") return undefined; + const method: unknown = Reflect.get(signer, "signAuthorization"); + if (typeof method !== "function") return undefined; + return (request) => Promise.resolve(method.call(signer, request)); +} + +async function signDelegationAuthorization( + signAuthorizationViaService: SignAuthorizationViaService, + request: DelegationAuthorizationRequest +): Promise { + const rawAuthorization = await signAuthorizationViaService({ + contractAddress: request.address, + chainId: request.chainId, + nonce: request.nonce, + }); + if (rawAuthorization === null || typeof rawAuthorization !== "object") { + throw new CliError( + "The signer service returned no authorization object.", + "API_ERROR" + ); + } + const echoedAddress: unknown = Reflect.get(rawAuthorization, "address"); + if ( + typeof echoedAddress === "string" && + echoedAddress.toLowerCase() !== request.address.toLowerCase() + ) { + throw new CliError( + `Signer authorized a different delegation target: expected ${request.address}, got ${echoedAddress}`, + "API_ERROR" + ); + } + return { + chainId: request.chainId, + address: parseEvmAddress(request.address, "the delegation target"), + nonce: request.nonce, + yParity: normalizeYParity(Reflect.get(rawAuthorization, "yParity")), + r: ensureHex(Reflect.get(rawAuthorization, "r"), "r"), + s: ensureHex(Reflect.get(rawAuthorization, "s"), "s"), + }; +} + +export function createGeodesicsSwapSigner( + provider: IEvmProviderAdapter +): SwapSigner { + const signAuthorizationViaService = getAuthorizationSigner(provider); + return { + signMessage: (geoOpHash: string) => + provider.signMessage(SIGNING_CHAIN_ID, geoOpHash), + + sendTransaction: async ( + chainId: number, + call: { to: string; data: string } + ) => + provider.sendTransaction(chainId, { + to: parseEvmAddress(call.to, "the transaction target"), + data: ensureHex(call.data, "calldata"), + }), + + ...(signAuthorizationViaService + ? { + signAuthorization: (request: DelegationAuthorizationRequest) => + signDelegationAuthorization(signAuthorizationViaService, request), + } + : {}), + }; +} + +function getSolanaTransactionSigner( + provider: ISolanaProviderAdapter +): ((unsignedTransactionBase64: string) => Promise) | undefined { + const method: unknown = Reflect.get(provider, "signTransactionViaPrivy"); + if (typeof method !== "function") return undefined; + return async (unsignedTransactionBase64: string) => { + const signedTransaction: unknown = await method.call( + provider, + unsignedTransactionBase64 + ); + if (typeof signedTransaction !== "string" || signedTransaction.length === 0) { + throw new CliError( + "The Solana signer service returned no signed transaction.", + "API_ERROR" + ); + } + return signedTransaction; + }; +} + +export function createGeodesicsSolanaSwapSigner( + provider: ISolanaProviderAdapter +): SwapSigner { + const signTransactionViaService = getSolanaTransactionSigner(provider); + if (signTransactionViaService === undefined) { + throw new CliError( + "The agent's Solana signer service does not expose transaction signing.", + "NO_SIGNER", + "Update @virtuals-protocol/acp-node-v2; its Privy Solana adapter provides it." + ); + } + return { signSolanaTransaction: signTransactionViaService }; +} + +function normalizeYParity(value: unknown): 0 | 1 { + if (value === 0 || value === "0" || value === "0x0") return 0; + if (value === 1 || value === "1" || value === "0x1") return 1; + throw new CliError( + `Signer returned an unrecognized authorization yParity: ${String(value)}`, + "API_ERROR" + ); +} + +function ensureHex(value: unknown, label: string): `0x${string}` { + const candidate = + typeof value === "string" && !value.startsWith("0x") ? `0x${value}` : value; + if ( + typeof candidate !== "string" || + !isHex(candidate, { strict: true }) || + candidate.length % 2 !== 0 + ) { + throw new CliError( + `Signer returned a non-hex authorization ${label}.`, + "API_ERROR" + ); + } + return candidate; +} + +// ---------- Token resolution ---------- + +const EVM_NATIVE_ADDRESS = "0x0000000000000000000000000000000000000000"; + +function describeChain(chainId: number): string { + const entry = Object.entries(CHAIN_IDS).find(([, id]) => id === chainId); + return entry ? entry[0] : `chain ${chainId}`; +} + +function describeToken(chainId: SupportedChainId, tokenAddress: string): string { + const aliasNames: readonly string[] = tokenAliasesForChain(chainId); + for (const aliasName of aliasNames) { + const alias: ResolvedTokenAlias | undefined = resolveTokenAlias( + chainId, + aliasName + ); + if (alias === undefined) continue; + const candidate = String(alias.address); + const matches = + candidate === tokenAddress || + (tokenAddress.startsWith("0x") && + candidate.toLowerCase() === tokenAddress.toLowerCase()); + if (matches) { + return aliasName.toUpperCase(); + } + } + return tokenAddress; +} + +function knownSymbolsHint(chainId: SupportedChainId): string { + const symbols: readonly string[] = tokenAliasesForChain(chainId); + return symbols.length > 0 + ? `Symbols known on this chain: ${symbols.join(", ")}. Any token contract address also works.` + : "This chain has no known symbols; pass the token's contract address."; +} + +function validAliasDecimals(value: unknown): number { + const decimals = Number(value); + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 36) { + throw new CliError( + `The token alias table returned unexpected decimals: ${String(value)}.`, + "API_ERROR" + ); + } + return decimals; +} + +export function resolveInputToken( + tokenRef: string, + originChain: SupportedChainId +): { address: Address; decimals?: number } { + const alias: ResolvedTokenAlias | undefined = resolveTokenAlias( + originChain, + tokenRef + ); + if (alias !== undefined) { + if (alias.address === EVM_NATIVE_ADDRESS) { + throw new CliError( + `--token-in ${tokenRef}: the chain's native gas token cannot be the swap input yet.`, + "VALIDATION_ERROR", + "Swap an ERC-20 instead (e.g. a stable or the wrapped token); the native token IS supported as --token-out." + ); + } + return { + address: parseEvmAddress(String(alias.address), "--token-in"), + decimals: validAliasDecimals(alias.decimals), + }; + } + return { + address: parseEvmAddress(tokenRef, "--token-in", knownSymbolsHint(originChain)), + }; +} + +export function resolveOutputToken( + tokenRef: string, + destinationChain: SupportedChainId +): string { + const alias: ResolvedTokenAlias | undefined = resolveTokenAlias( + destinationChain, + tokenRef + ); + if (alias !== undefined) return String(alias.address); + if (destinationChain === CHAIN_IDS.solana) { + if (!SOLANA_ADDRESS_PATTERN.test(tokenRef.trim())) { + throw new CliError( + `--token-out is not a valid Solana token for this destination: "${tokenRef}".`, + "VALIDATION_ERROR", + knownSymbolsHint(destinationChain) + ); + } + return tokenRef.trim(); + } + return parseEvmAddress( + tokenRef, + "--token-out", + knownSymbolsHint(destinationChain) + ); +} + +export function resolveSolanaInputToken(tokenRef: string): { + token: string; + decimals: number; +} { + const alias: ResolvedTokenAlias | undefined = resolveTokenAlias( + CHAIN_IDS.solana, + tokenRef + ); + if (alias === undefined) { + const supported: readonly string[] = tokenAliasesForChain(CHAIN_IDS.solana); + throw new CliError( + `--token-in is not a supported Solana-origin input: "${tokenRef}".`, + "VALIDATION_ERROR", + `Solana-origin inputs: ${supported.join(", ")} (raw mint addresses are not resolved yet).` + ); + } + return { + token: String(alias.address), + decimals: validAliasDecimals(alias.decimals), + }; +} + +// ---------- Price impact gate ---------- + +export function assertImpactAccepted( + quote: QuoteResponse, + acceptImpact: boolean +): void { + const highImpact = quote.warnings?.find( + (warning) => warning.code === "HIGH_PRICE_IMPACT" + ); + if (highImpact === undefined || acceptImpact) return; + throw new CliError(highImpact.message, "PRICE_IMPACT_HIGH", + "Re-run with --accept-impact to execute anyway, or adjust --amount-in: tiny swaps lose mostly to fixed costs." + ); +} + +// ---------- Input validation ---------- + +function resolveSwapChain(input: unknown): SupportedChainId { + const raw = String(input).trim().toLowerCase(); + const chainId = + raw === "sol" || raw === "solana" + ? CHAIN_IDS.solana + : parseChainArg(String(input)); + if (!isSupportedChain(chainId)) { + throw new CliError( + `Chain "${String(input)}" is not supported by Geodesics.`, + "VALIDATION_ERROR", + `Supported chains: ${supportedChainSummary()}.` + ); + } + return chainId; +} + +function parseEvmAddress( + candidate: string, + label: string, + recovery?: string +): Address { + if (!isAddress(candidate, { strict: false })) { + throw new CliError( + `${label} is not a valid EVM address: "${candidate}".`, + "VALIDATION_ERROR", + recovery + ); + } + return getAddress(candidate); +} + +const SOLANA_ADDRESS_PATTERN = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/; + +async function resolveRecipientWithDefaults( + recipientOption: unknown, + originIsSolana: boolean, + destinationChain: SupportedChainId, + evmWalletAddress: Address +): Promise { + if (destinationChain === CHAIN_IDS.solana) { + if (recipientOption !== undefined) { + const candidate = String(recipientOption).trim(); + if (!SOLANA_ADDRESS_PATTERN.test(candidate)) { + throw new CliError( + `--recipient is not a valid Solana address: "${candidate}".`, + "VALIDATION_ERROR", + "A Solana destination needs a base58 recipient address." + ); + } + return candidate; + } + try { + return await getSolanaWalletAddress(); + } catch (err) { + if (err instanceof CliError && err.code === "NO_SOLANA_WALLET") { + throw new CliError( + "--recipient is required for a Solana destination (this agent has no Solana wallet).", + "VALIDATION_ERROR", + "Pass the receiving base58 Solana address." + ); + } + throw err; + } + } + if (recipientOption !== undefined) { + return parseEvmAddress(String(recipientOption), "--recipient"); + } + return originIsSolana ? evmWalletAddress : undefined; +} + +function resolveSlippageBps(slippageOption: unknown): number | undefined { + if (slippageOption === undefined) return undefined; + const percent = Number(slippageOption); + if (!Number.isFinite(percent) || percent <= 0 || percent > 50) { + throw new CliError( + `--slippage must be a percent between 0 (exclusive) and 50, got "${String(slippageOption)}".`, + "VALIDATION_ERROR", + "Example: --slippage 1 = 1% max slippage." + ); + } + return Math.round(percent * 100); +} + +function requireGeodesicsApiKey(): string { + const key = process.env.GEODESICS_API_KEY; + if (!key) { + throw new CliError( + "GEODESICS_API_KEY is not set.", + "VALIDATION_ERROR", + "Get a free API key at https://console.geodesics.ai and export GEODESICS_API_KEY." + ); + } + return key; +} + +function resolveGeodesicsUrl(): string { + const override = process.env.GEODESICS_API_URL?.trim(); + const base = (override || GEODESICS_API_URL).replace(/\/$/, ""); + let url: URL; + try { + url = new URL(base); + } catch { + throw new CliError( + `GEODESICS_API_URL is not a valid URL: ${base}`, + "VALIDATION_ERROR", + "The Geodesics API base URL must be https:// (http allowed only for localhost)." + ); + } + const isLoopback = + url.hostname === "localhost" || url.hostname === "127.0.0.1"; + if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback)) { + throw new CliError( + `Refusing to call a non-https Geodesics endpoint: ${base}`, + "VALIDATION_ERROR", + "The Geodesics API base URL must be https:// (http allowed only for localhost)." + ); + } + return base; +} + +// ---------- On-chain reads ---------- + +const ERC20_READS_ABI = [ + { + type: "function", + name: "decimals", + stateMutability: "view", + inputs: [], + outputs: [{ type: "uint8" }], + }, + { + type: "function", + name: "balanceOf", + stateMutability: "view", + inputs: [{ type: "address" }], + outputs: [{ type: "uint256" }], + }, +] as const; + +async function readTokenDecimals( + provider: IEvmProviderAdapter, + chainId: number, + token: Address +): Promise { + try { + const raw = await provider.readContract(chainId, { + address: token, + abi: ERC20_READS_ABI, + functionName: "decimals", + }); + const decimals = Number(raw); + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 36) { + throw new Error(`unexpected decimals value: ${String(raw)}`); + } + return decimals; + } catch (err) { + throw new CliError( + `Could not read decimals for ${token} on ${describeChain(chainId)}: ${err instanceof Error ? err.message : String(err)}`, + "VALIDATION_ERROR", + "Check that --token-in is an ERC-20 contract address on --chain-in." + ); + } +} + +async function readTokenBalance( + provider: IEvmProviderAdapter, + chainId: number, + token: Address, + walletAddress: Address +): Promise { + const raw = await provider.readContract(chainId, { + address: token, + abi: ERC20_READS_ABI, + functionName: "balanceOf", + args: [walletAddress], + }); + return BigInt(String(raw)); +} + +function parseExplicitAmount(amountIn: string, decimals: number): string { + let parsed: bigint; + try { + parsed = parseUnits(amountIn, decimals); + } catch { + throw new CliError( + `--amount-in is not a valid decimal amount: "${amountIn}".`, + "VALIDATION_ERROR", + "Pass a positive number in human units (e.g. 5 or 0.25)." + ); + } + if (parsed <= 0n) { + throw new CliError( + `--amount-in must be positive, got "${amountIn}".`, + "VALIDATION_ERROR", + "Pass a positive number in human units (e.g. 5 or 0.25)." + ); + } + return parsed.toString(); +} + +function resolveSolanaInputAmount(amountIn: string, decimals: number): string { + if (amountIn.trim().toLowerCase() === "max") { + throw new CliError( + "--amount-in max is not supported for a Solana origin yet.", + "VALIDATION_ERROR", + "Pass an explicit amount in human units (e.g. 2 or 0.25)." + ); + } + return parseExplicitAmount(amountIn, decimals); +} + +async function resolveInputAmount( + provider: IEvmProviderAdapter, + chainId: SupportedChainId, + token: Address, + walletAddress: Address, + amountIn: string, + decimals: number +): Promise { + if (amountIn.trim().toLowerCase() !== "max") { + return parseExplicitAmount(amountIn, decimals); + } + let balance: bigint; + try { + balance = await readTokenBalance(provider, chainId, token, walletAddress); + } catch (err) { + throw new CliError( + `Could not read the wallet's ${describeToken(chainId, token)} balance on ${describeChain(chainId)}: ${err instanceof Error ? err.message : String(err)}`, + "API_ERROR", + "Retry, or pass an explicit --amount-in." + ); + } + if (balance === 0n) { + throw new CliError( + `--amount-in max: the wallet holds no ${describeToken(chainId, token)} on ${describeChain(chainId)}.`, + "INSUFFICIENT_BALANCE", + "Fund the wallet or check --token-in/--chain-in." + ); + } + return balance.toString(); +} + +async function assertSufficientBalance( + provider: IEvmProviderAdapter, + chainId: SupportedChainId, + token: Address, + walletAddress: Address, + amount: bigint, + decimals: number, + json: boolean +): Promise { + let balance: bigint; + try { + balance = await readTokenBalance(provider, chainId, token, walletAddress); + } catch (err) { + progress( + json, + `Warning: could not read the origin balance for the preflight check (${err instanceof Error ? err.message : String(err)}); continuing.` + ); + return; + } + if (balance < amount) { + throw new CliError( + `The wallet holds ${formatUnits(balance, decimals)} ${describeToken(chainId, token)} on ${describeChain(chainId)} but this swap needs ${formatUnits(amount, decimals)} (network costs come out of the input).`, + "INSUFFICIENT_BALANCE", + "Fund the wallet or lower --amount-in (a prior swap's `output` is an estimate, not the settled balance), then retry." + ); + } +} + +// ---------- Solana balance preflight ---------- + +const SOLANA_RENT_EXEMPT_MIN_LAMPORTS = 890_880n; + +interface SolanaBalanceReads { + getLamports(owner: string): Promise; + getSplBalance(owner: string, mint: string): Promise; +} + +function solanaBalanceReads( + provider: ISolanaProviderAdapter +): SolanaBalanceReads { + const rpc = provider.getRpc(SOLANA_MAINNET_PRIVY_CHAIN_ID); + return { + getLamports: async (owner) => { + const { value } = await rpc.getBalance(solanaAddress(owner)).send(); + return BigInt(value); + }, + getSplBalance: async (owner, mint) => { + const { amount } = await getSplTokenBalance( + rpc, + solanaAddress(owner), + solanaAddress(mint) + ); + return amount; + }, + }; +} + + +export async function assertSufficientSolanaBalance( + reads: SolanaBalanceReads, + walletAddress: string, + token: string, + amount: bigint, + decimals: number, + json: boolean +): Promise { + const solNative: ResolvedTokenAlias | undefined = resolveTokenAlias( + CHAIN_IDS.solana, + "sol" + ); + const isNativeSol = + solNative !== undefined && String(solNative.address) === token; + let balance: bigint; + try { + balance = isNativeSol + ? await reads.getLamports(walletAddress) + : await reads.getSplBalance(walletAddress, token); + } catch (err) { + progress( + json, + `Warning: could not read the Solana balance for the preflight check (${err instanceof Error ? err.message : String(err)}); continuing.` + ); + return; + } + if (balance < amount) { + throw new CliError( + `The wallet holds ${formatUnits(balance, decimals)} ${describeToken(CHAIN_IDS.solana, token)} on solana but this swap needs ${formatUnits(amount, decimals)}.`, + "INSUFFICIENT_BALANCE", + "Fund the wallet or lower --amount-in, then retry." + ); + } + if (isNativeSol) { + const remainder = balance - amount; + if (remainder > 0n && remainder < SOLANA_RENT_EXEMPT_MIN_LAMPORTS) { + throw new CliError( + `--amount-in would leave ${formatUnits(remainder, 9)} SOL, below Solana's rent-exempt minimum (~${formatUnits(SOLANA_RENT_EXEMPT_MIN_LAMPORTS, 9)} SOL); the network rejects that.`, + "VALIDATION_ERROR", + "Lower --amount-in to leave at least 0.001 SOL, or swap the exact full balance." + ); + } + } +} + +// ---------- Solana-origin SOL fee pipe ---------- + +const PIPE_BASIS_CANDIDATES = [ + { chainId: CHAIN_IDS.base, label: "base", symbol: "usdc" }, + { chainId: CHAIN_IDS.robinhood, label: "robinhood", symbol: "usdg" }, +] as const; + +interface PipeBasis { + chainId: SupportedChainId; + label: string; + symbol: string; + address: Address; + decimals: number; + amount: bigint; +} + +async function resolvePipeBasis( + provider: IEvmProviderAdapter, + walletAddress: Address +): Promise { + const unverified: PipeBasis[] = []; + for (const candidate of PIPE_BASIS_CANDIDATES) { + const alias: ResolvedTokenAlias | undefined = resolveTokenAlias( + candidate.chainId, + candidate.symbol + ); + if (alias === undefined) continue; + const address = parseEvmAddress(String(alias.address), "the pipe stable"); + const decimals = validAliasDecimals(alias.decimals); + const basis: PipeBasis = { + chainId: candidate.chainId, + label: candidate.label, + symbol: candidate.symbol, + address, + decimals, + amount: parseUnits(SOL_TOPUP_STABLE_UNITS, decimals), + }; + try { + const balance = await readTokenBalance( + provider, + candidate.chainId, + address, + walletAddress + ); + if (balance >= basis.amount) return basis; + } catch { + unverified.push(basis); + } + } + return unverified[0]; +} + +async function pipeSolForSolanaOrigin( + client: GeodesicsClient, + provider: IEvmProviderAdapter, + evmWalletAddress: Address, + solanaWalletAddress: string, + json: boolean +): Promise { + const basis = await resolvePipeBasis(provider, evmWalletAddress); + const solNative: ResolvedTokenAlias | undefined = resolveTokenAlias( + CHAIN_IDS.solana, + "sol" + ); + if (basis === undefined || solNative === undefined) { + progress( + json, + `The wallet holds too little stable on ${PIPE_BASIS_CANDIDATES.map((c) => c.label).join(" or ")} to pipe SOL.` + ); + return false; + } + progress( + json, + `Piping ${SOL_TOPUP_STABLE_UNITS} ${basis.symbol.toUpperCase()} on ${basis.label} into SOL for network fees...` + ); + try { + const pipeSwap = await client.swap( + { + originChain: basis.chainId, + destinationChain: CHAIN_IDS.solana, + inputToken: basis.address, + outputToken: String(solNative.address), + amount: basis.amount.toString(), + walletAddress: evmWalletAddress, + recipient: solanaWalletAddress, + }, + createGeodesicsSwapSigner(provider), + { onboardDestination: false } + ); + if (pipeSwap.status !== "settled") { + progress( + json, + `The SOL pipe ended ${pipeSwap.status} (swapId ${pipeSwap.swapId}).` + ); + return false; + } + progress(json, "SOL piped; retrying the swap."); + return true; + } catch (pipeError) { + progress( + json, + `The SOL pipe failed: ${pipeError instanceof Error ? pipeError.message : String(pipeError)}` + ); + return false; + } +} + +export async function preflightSolanaQuote( + client: GeodesicsClient, + provider: IEvmProviderAdapter, + request: QuoteRequest, + confirmPipe: boolean, + evmWalletAddress: Address, + json: boolean +): Promise { + try { + return await client.quote(request); + } catch (quoteError) { + if ( + !(quoteError instanceof GeodesicsApiError) || + quoteError.code !== "NEEDS_SOL_TOPUP" + ) { + return runGeodesics(() => Promise.reject(quoteError)); + } + if (!confirmPipe) { + throw new CliError( + quoteError.message, + "INSUFFICIENT_GAS", + "Re-run with --confirm-pipe to fund it automatically (pipes ~1.5 of a basis-chain stable into SOL), or send ~0.005 SOL to the agent's Solana wallet." + ); + } + const piped = await pipeSolForSolanaOrigin( + client, + provider, + evmWalletAddress, + request.walletAddress, + json + ); + if (!piped) { + throw new CliError( + quoteError.message, + "INSUFFICIENT_GAS", + "The automatic SOL pipe did not complete; fund a basis-chain stable (Base USDC or Robinhood Chain USDG) or send ~0.005 SOL to the agent's Solana wallet, then retry." + ); + } + return runGeodesics(() => client.quote(request)); + } +} + +// ---------- Error mapping + output ---------- + +const GEODESICS_ERROR_MAP: Record< + string, + { code: ErrorCode; recovery: string } +> = { + INSUFFICIENT_BALANCE: { + code: "INSUFFICIENT_BALANCE", + recovery: + "The wallet does not hold the input amount on the origin chain -- check `acp wallet balance`.", + }, + NO_ROUTE: { + code: "NO_ROUTE", + recovery: + "No route for this token pair -- check both token addresses and chains.", + }, + NEEDS_LARGER_SIZE: { + code: "NEEDS_LARGER_SIZE", + recovery: + "The amount is too small to cover network costs from the input -- retry with a larger --amount-in.", + }, + SLIPPAGE: { + code: "SLIPPAGE_TOO_LOW", + recovery: + "Price moved past the tolerance -- retry with a higher --slippage.", + }, + UPSTREAM_TIMEOUT: { + code: "TIMEOUT", + recovery: + "The route timed out upstream -- retry; the quote may simply have expired.", + }, + UNSUPPORTED_DELEGATION: { + code: "VALIDATION_ERROR", + recovery: + "This wallet's on-chain setup on the origin chain is incompatible -- swap from a different origin chain.", + }, + NEEDS_SOL_TOPUP: { + code: "INSUFFICIENT_GAS", + recovery: + "The agent's Solana wallet needs a small SOL balance (~0.005) for network fees -- re-run the swap with --confirm-pipe to fund it from a basis-chain stable, or send SOL to the agent's Solana address.", + }, + NEEDS_DELEGATION: { + code: "API_ERROR", + recovery: + "The wallet is not onboarded on the origin chain and the signer could not onboard it in-swap -- retry once, then contact Geodesics support.", + }, + INVALID_AUTHORIZATION: { + code: "API_ERROR", + recovery: "The onboarding authorization went stale -- retry the swap.", + }, +}; + +export async function runGeodesics(fn: () => Promise): Promise { + try { + return await fn(); + } catch (err) { + if (err instanceof GeodesicsTimeoutError) { + throw new CliError( + `Timed out waiting for settlement of swap ${err.swapId} (last status: ${err.lastStatus}).`, + "TIMEOUT", + "The wait expired, not necessarily the swap -- it usually still settles. Check the destination balance shortly; only retry after confirming it did not settle." + ); + } + if (err instanceof GeodesicsApiError) { + const mapped = err.code ? GEODESICS_ERROR_MAP[err.code] : undefined; + throw new CliError( + err.message, + mapped?.code ?? "API_ERROR", + mapped?.recovery ?? "See https://docs.geodesics.ai for error reference." + ); + } + throw err; + } +} + +function describeProgress(progress: SwapProgress): string { + switch (progress.stage) { + case "quoted": { + const feePct = (progress.feeBps / 100).toFixed(3); + return `Quoted -- fee ${feePct}%, expires ${progress.expiresAt}`; + } + case "authorizing": + return `First swap from chain ${progress.chainId} -- onboarding rides inside the swap`; + case "submitted": + return `Submitted -- swap ${progress.swapId}`; + case "polled": + return `Status: ${progress.status}`; + default: + return progress.stage; + } +} + +function progress(json: boolean, msg: string): void { + if (json || !isTTY()) return; + process.stderr.write(`${msg}\n`); +} diff --git a/src/lib/chains.ts b/src/lib/chains.ts index fcf932e..6cb2b46 100644 --- a/src/lib/chains.ts +++ b/src/lib/chains.ts @@ -90,6 +90,11 @@ const CHAIN_ALIASES: Record = { zora: 7777777, hyperliquid: 1337, hl: 1337, + robinhood: 4663, + polygon: 137, + matic: 137, + bnb: 56, + bsc: 56, }; // Resolve a chain reference (numeric id, numeric string, or named alias like diff --git a/src/lib/errors.ts b/src/lib/errors.ts index f52f814..4e0375e 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -12,6 +12,9 @@ export type ErrorCode = | "TIMEOUT" | "SLIPPAGE_TOO_LOW" | "INSUFFICIENT_GAS" + | "INSUFFICIENT_BALANCE" + | "NO_ROUTE" + | "NEEDS_LARGER_SIZE" | "APPROVAL_REQUIRED" | "PRICE_IMPACT_HIGH";