diff --git a/bindings/typescript-arkade/package-lock.json b/bindings/typescript-arkade/package-lock.json index fa32198c..17d9e172 100644 --- a/bindings/typescript-arkade/package-lock.json +++ b/bindings/typescript-arkade/package-lock.json @@ -1,12 +1,12 @@ { "name": "@sunnyln/lni-arkade", - "version": "0.2.16", + "version": "0.2.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@sunnyln/lni-arkade", - "version": "0.2.16", + "version": "0.2.20", "dependencies": { "@arkade-os/boltz-swap": "^0.3.3", "@arkade-os/sdk": "^0.4.4" @@ -21,12 +21,12 @@ "node": ">=20" }, "peerDependencies": { - "@sunnyln/lni": "^0.2.16" + "@sunnyln/lni": "^0.2.20" } }, "../typescript": { "name": "@sunnyln/lni", - "version": "0.2.16", + "version": "0.2.20", "dev": true, "dependencies": { "@getalby/sdk": "^7.0.0", diff --git a/bindings/typescript-arkade/package.json b/bindings/typescript-arkade/package.json index 4960a19c..99cbdc19 100644 --- a/bindings/typescript-arkade/package.json +++ b/bindings/typescript-arkade/package.json @@ -1,6 +1,6 @@ { "name": "@sunnyln/lni-arkade", - "version": "0.2.16", + "version": "0.2.20", "private": false, "description": "Optional Arkade Boltz adapter for @sunnyln/lni.", "type": "module", @@ -52,7 +52,7 @@ "test:integration": "NODE_TLS_REJECT_UNAUTHORIZED=0 node --env-file=../../crates/lni/.env ./node_modules/vitest/vitest.mjs run src/__tests__/integration/arkade-boltz.real.test.ts" }, "peerDependencies": { - "@sunnyln/lni": "^0.2.16" + "@sunnyln/lni": "^0.2.20" }, "dependencies": { "@arkade-os/boltz-swap": "^0.3.3", diff --git a/bindings/typescript-spark/examples/spark-expo-go/package-lock.json b/bindings/typescript-spark/examples/spark-expo-go/package-lock.json index b583aa20..e0092b0a 100644 --- a/bindings/typescript-spark/examples/spark-expo-go/package-lock.json +++ b/bindings/typescript-spark/examples/spark-expo-go/package-lock.json @@ -27,7 +27,7 @@ }, "../..": { "name": "@sunnyln/lni-spark", - "version": "0.2.16", + "version": "0.2.20", "dependencies": { "@buildonspark/spark-sdk": "^0.6.3", "@frosts/core": "^0.2.2-alpha.3", @@ -50,12 +50,12 @@ "node": ">=20" }, "peerDependencies": { - "@sunnyln/lni": "^0.2.16" + "@sunnyln/lni": "^0.2.20" } }, "../../../typescript": { "name": "@sunnyln/lni", - "version": "0.2.16", + "version": "0.2.20", "dependencies": { "@getalby/sdk": "^7.0.0", "@scure/base": "^2.0.0", diff --git a/bindings/typescript-spark/examples/spark-web/package-lock.json b/bindings/typescript-spark/examples/spark-web/package-lock.json index 4bdcac6e..cbc02968 100644 --- a/bindings/typescript-spark/examples/spark-web/package-lock.json +++ b/bindings/typescript-spark/examples/spark-web/package-lock.json @@ -20,7 +20,7 @@ }, "../..": { "name": "@sunnyln/lni-spark", - "version": "0.2.16", + "version": "0.2.20", "dependencies": { "@buildonspark/spark-sdk": "^0.6.3", "@frosts/core": "^0.2.2-alpha.3", @@ -43,12 +43,12 @@ "node": ">=20" }, "peerDependencies": { - "@sunnyln/lni": "^0.2.16" + "@sunnyln/lni": "^0.2.20" } }, "../../../typescript": { "name": "@sunnyln/lni", - "version": "0.2.16", + "version": "0.2.20", "dependencies": { "@getalby/sdk": "^7.0.0", "@scure/base": "^2.0.0", @@ -74,7 +74,7 @@ }, "../../../typescript-arkade": { "name": "@sunnyln/lni-arkade", - "version": "0.2.16", + "version": "0.2.20", "dependencies": { "@arkade-os/boltz-swap": "^0.3.3", "@arkade-os/sdk": "^0.4.4" @@ -89,7 +89,7 @@ "node": ">=20" }, "peerDependencies": { - "@sunnyln/lni": "^0.2.16" + "@sunnyln/lni": "^0.2.20" } }, "node_modules/@arkade-os/boltz-swap": { diff --git a/bindings/typescript-spark/package-lock.json b/bindings/typescript-spark/package-lock.json index 0803d953..7c634464 100644 --- a/bindings/typescript-spark/package-lock.json +++ b/bindings/typescript-spark/package-lock.json @@ -1,12 +1,12 @@ { "name": "@sunnyln/lni-spark", - "version": "0.2.16", + "version": "0.2.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@sunnyln/lni-spark", - "version": "0.2.16", + "version": "0.2.20", "dependencies": { "@buildonspark/spark-sdk": "^0.6.3", "@frosts/core": "^0.2.2-alpha.3", @@ -29,12 +29,12 @@ "node": ">=20" }, "peerDependencies": { - "@sunnyln/lni": "^0.2.16" + "@sunnyln/lni": "^0.2.20" } }, "../typescript": { "name": "@sunnyln/lni", - "version": "0.2.16", + "version": "0.2.20", "dev": true, "dependencies": { "@getalby/sdk": "^7.0.0", diff --git a/bindings/typescript-spark/package.json b/bindings/typescript-spark/package.json index 4aa26d35..0b09ddb6 100644 --- a/bindings/typescript-spark/package.json +++ b/bindings/typescript-spark/package.json @@ -1,6 +1,6 @@ { "name": "@sunnyln/lni-spark", - "version": "0.2.16", + "version": "0.2.20", "private": false, "description": "Optional Spark adapter for @sunnyln/lni with browser and Expo compatible pure TypeScript signer patching.", "type": "module", @@ -56,7 +56,7 @@ "test:integration": "NODE_TLS_REJECT_UNAUTHORIZED=0 node --env-file=../../crates/lni/.env ./node_modules/vitest/vitest.mjs run src/__tests__/integration/spark.real.test.ts" }, "peerDependencies": { - "@sunnyln/lni": "^0.2.16" + "@sunnyln/lni": "^0.2.20" }, "dependencies": { "@buildonspark/spark-sdk": "^0.6.3", diff --git a/bindings/typescript/README.md b/bindings/typescript/README.md index 5782b259..85ce22ca 100644 --- a/bindings/typescript/README.md +++ b/bindings/typescript/README.md @@ -4,7 +4,7 @@ Remote connect to major Lightning node implementations with one TypeScript inter - Supports major nodes: CLN, LND, Phoenixd - Supports protocols: BOLT11, BOLT12, NWC -- Includes custodial / hosted APIs: Strike, Speed, Blink +- Includes custodial / hosted APIs: Strike, Speed, and Galoy deployments such as Blink or Flash - Experimental Arkade Boltz support lives in the optional `@sunnyln/lni-arkade` package - Experimental Spark support lives in the optional `@sunnyln/lni-spark` package - LNURL + Lightning Address support (`user@domain.com`, `lnurl1...`) @@ -58,9 +58,138 @@ const status = await node.lookupInvoice({ paymentHash: invoice.paymentHash }); const txs = await node.listTransactions({ from: 0, limit: 10 }); ``` +### Galoy GraphQL Nodes + +Use the generic `galoy` kind for Blink, Flash, or another Galoy deployment. Configuration is semantic: applications choose a wallet, payment response shape, accepted statuses, and supported capabilities without supplying arbitrary GraphQL. + +Flash-style deployments can use an explicit wallet and a proofless/status-only payment response: + +```ts +import { createNode } from '@sunnyln/lni'; + +const flash = createNode({ + kind: 'galoy', + config: { + apiKey: process.env.FLASH_API_KEY!, + baseUrl: 'https://api.flashapp.me/graphql', + provider: { + id: 'flash', + name: 'Flash', + }, + wallet: { + mode: 'explicit', + id: process.env.FLASH_WALLET_ID!, + currency: 'USD', + }, + invoiceOperations: { + create: { + kind: 'unsupported', + }, + feeProbe: { + kind: 'usd', + denomination: 'usd-cents', + }, + }, + additionalHeaders: { + 'x-flash-client-capabilities': 'status-only-payments', + }, + payment: { + response: 'status-only', + acceptedStatuses: ['SUCCESS', 'PENDING', 'ALREADY_PAID'], + statusMapping: { + settled: ['SUCCESS', 'ALREADY_PAID'], + pending: ['PENDING'], + }, + }, + capabilities: { + transactionLookup: false, + transactionHistory: false, + invoiceEvents: false, + onchain: false, + }, + permissions: 'configured', + httpTimeout: 60, + }, +}); +``` + +For those defaults, `FlashNode` and `kind: 'flash'` provide a smaller convenience configuration: + +```ts +const flashNode = createNode({ + kind: 'flash', + config: { + apiKey: process.env.FLASH_API_KEY!, + walletId: process.env.FLASH_WALLET_ID!, + walletCurrency: 'USD', + additionalHeaders: { + 'x-flash-client-capabilities': 'status-only-payments', + }, + }, +}); +``` + +`FlashNode` defaults to `https://api.flashapp.me/graphql`; set `baseUrl` only to target another Flash-compatible deployment. +`FlashNode.createInvoice()` is intentionally unavailable because LNI supplies +`amountMsats`, while Flash's USD invoice mutation requires USD cents. Flash +payments remain available for documented BTC and USD wallets. USD fee probes +are cent-denominated, so their value is not exposed as `feeMsats`; it remains +zero until LNI has a denomination-aware fee result. + +Because Flash uses status-only payments, accepted proofless statuses can +resolve with an empty preimage. The shared `payInvoice()` response remains +unchanged. Call `FlashNode.payInvoiceWithStatus()` or +`GaloyNode.payInvoiceWithStatus()` for a `GaloyPaymentOutcome` containing the +legacy response under `payment`, plus `state` and `providerStatus`. `SUCCESS` +and `ALREADY_PAID` map to `settled`, while `PENDING` maps to `pending`. + +Traditional Blink behavior selects the first BTC wallet and requests the payment preimage: + +```ts +const blink = createNode({ + kind: 'galoy', + config: { + apiKey: process.env.BLINK_API_KEY!, + baseUrl: 'https://api.blink.sv/graphql', + provider: { + id: 'blink', + name: 'Blink', + }, + wallet: { + mode: 'currency', + currency: 'BTC', + }, + invoiceOperations: { + create: { + kind: 'btc', + denomination: 'sats', + }, + feeProbe: { + kind: 'btc', + denomination: 'sats', + }, + }, + payment: { + response: 'transaction-with-preimage', + acceptedStatuses: ['SUCCESS'], + }, + capabilities: { + transactionLookup: true, + transactionHistory: true, + invoiceEvents: true, + onchain: true, + }, + permissions: 'jwt-introspection', + httpTimeout: 60, + }, +}); +``` + +`BlinkNode` and `kind: 'blink'` remain available as deprecated compatibility wrappers around these Blink defaults. Additional headers cannot replace `x-api-key` or `content-type`. + ### On-chain Bitcoin Payments -On-chain payments use a prepare-then-pay flow so apps can show fees before executing a payment. This is currently implemented for `StrikeNode` and `BlinkNode`. +On-chain payments use a prepare-then-pay flow so apps can show fees before executing a payment. This is currently implemented for `StrikeNode` and BTC-configured Galoy nodes (including the `BlinkNode` compatibility wrapper). ```ts import { StrikeNode } from '@sunnyln/lni'; @@ -269,7 +398,9 @@ const invoice = await arkadeNode.createInvoice({ - `NwcNode` - `StrikeNode` - `SpeedNode` -- `BlinkNode` +- `createGaloyNode` / `kind: 'galoy'` +- `FlashNode` / `kind: 'flash'` +- `BlinkNode` / `kind: 'blink'` (deprecated compatibility wrapper) - LNURL helpers (`detectPaymentType`, `needsResolution`, `resolveToBolt11`, `getPaymentInfo`) - Decode helpers (`decode` for BOLT11, `decodeOffer` for BOLT12 offers) diff --git a/bindings/typescript/package-lock.json b/bindings/typescript/package-lock.json index 82d0cc00..050353f1 100644 --- a/bindings/typescript/package-lock.json +++ b/bindings/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@sunnyln/lni", - "version": "0.2.16", + "version": "0.2.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@sunnyln/lni", - "version": "0.2.16", + "version": "0.2.20", "dependencies": { "@getalby/sdk": "^7.0.0", "@scure/base": "^2.0.0", diff --git a/bindings/typescript/package.json b/bindings/typescript/package.json index e22ac5f7..d140c8ee 100644 --- a/bindings/typescript/package.json +++ b/bindings/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@sunnyln/lni", - "version": "0.2.16", + "version": "0.2.20", "private": false, "description": "Lightning Node Interface. Connect to CLN, LND, Phoenixd, NWC, Strike, Speed and Blink. Supports BOLT11, BOLT12, LNURL and Lightning Address.", "type": "module", diff --git a/bindings/typescript/src/__tests__/flash.test.ts b/bindings/typescript/src/__tests__/flash.test.ts new file mode 100644 index 00000000..d33ce773 --- /dev/null +++ b/bindings/typescript/src/__tests__/flash.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it, vi } from 'vitest'; +import { FlashNode, createNode } from '../index.js'; +import type { FetchLike } from '../types.js'; + +const BOLT11 = + 'lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpu9qrsgquk0rl77nj30yxdy8j9vdx85fkpmdla2087ne0xh8nhedh8w27kyke0lp53ut353s06fv3qfegext0eh0ymjpf39tuven09sam30g4vgpfna3rh'; +const PAYMENT_HASH = '0001020304050607080900010203040506070809000102030405060708090102'; + +function response(body: unknown): Response { + return new Response(JSON.stringify(body), { + headers: { 'content-type': 'application/json' }, + }); +} + +function requestBody(init?: RequestInit): { + query: string; + variables: { input: Record }; +} { + return JSON.parse(String(init?.body)); +} + +describe('FlashNode', () => { + it('is a thin explicit-wallet, status-only Galoy wrapper', async () => { + const queries: string[] = []; + const fetchMock = vi.fn(async (input, init) => { + expect(String(input)).toBe('https://api.flashapp.me/graphql'); + const body = requestBody(init); + queries.push(body.query); + expect(body.variables.input.walletId).toBe('wallet-usd'); + + const headers = new Headers(init?.headers); + expect(headers.get('x-api-key')).toBe('flash-key'); + expect(headers.get('content-type')).toBe('application/json'); + expect(headers.get('x-flash-client-capabilities')).toBe('proofless'); + + if (body.query.includes('lnUsdInvoiceFeeProbe')) { + return response({ + data: { lnUsdInvoiceFeeProbe: { amount: 50_000, errors: [] } }, + }); + } + return response({ + data: { lnInvoicePaymentSend: { status: 'PENDING', errors: [] } }, + }); + }); + + const node = createNode( + { + kind: 'flash', + config: { + apiKey: 'flash-key', + walletId: 'wallet-usd', + walletCurrency: 'USD', + additionalHeaders: { + 'x-flash-client-capabilities': 'proofless', + 'x-api-key': 'cannot-override', + 'content-type': 'text/plain', + }, + }, + }, + { fetch: fetchMock } + ); + + expect(node).toBeInstanceOf(FlashNode); + await expect(node.payInvoice({ invoice: BOLT11 })).resolves.toEqual({ + paymentHash: PAYMENT_HASH, + preimage: '', + feeMsats: 0, + }); + expect(queries).toHaveLength(2); + expect(queries.join('\n')).not.toContain('query Me'); + expect(queries[0]).toContain('lnUsdInvoiceFeeProbe'); + expect(queries[1]).not.toMatch(/\btransaction\s*\{/); + }); + + it('uses configured permissions with optional Galoy capabilities disabled', async () => { + const node = new FlashNode({ + apiKey: 'flash-key', + baseUrl: 'https://flash.test/graphql', + walletId: 'wallet-usd', + walletCurrency: 'USD', + }); + + await expect(node.getPermissions()).resolves.toMatchObject({ + getInfo: true, + createInvoice: false, + payInvoice: true, + lookupInvoice: false, + listTransactions: false, + onInvoiceEvents: false, + }); + }); + + it.each([ + ['SUCCESS', 'settled'], + ['ALREADY_PAID', 'settled'], + ['PENDING', 'pending'], + ] as const)('preserves accepted provider status %s as %s', async (providerStatus, state) => { + const fetchMock = vi.fn(async (_input, init) => { + const query = requestBody(init).query; + return query.includes('lnUsdInvoiceFeeProbe') + ? response({ data: { lnUsdInvoiceFeeProbe: { amount: 500, errors: [] } } }) + : response({ + data: { lnInvoicePaymentSend: { status: providerStatus, errors: [] } }, + }); + }); + const node = new FlashNode( + { + apiKey: 'flash-key', + walletId: 'wallet-usd', + walletCurrency: 'USD', + }, + { fetch: fetchMock } + ); + + await expect(node.payInvoiceWithStatus({ invoice: BOLT11 })).resolves.toEqual({ + payment: { + paymentHash: PAYMENT_HASH, + feeMsats: 0, + preimage: '', + }, + state, + providerStatus, + }); + }); + + it('rejects invoice creation without converting amountMsats or making a request', async () => { + const fetchMock = vi.fn(); + const node = new FlashNode( + { + apiKey: 'flash-key', + baseUrl: 'https://flash.test/custom-graphql', + walletId: 'wallet-usd', + walletCurrency: 'USD', + }, + { fetch: fetchMock } + ); + + await expect(node.createInvoice({ amountMsats: 123_000 })).rejects.toMatchObject({ + nwcCode: 'NOT_IMPLEMENTED', + operation: 'make_invoice', + provider: 'flash', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('does not assume an arbitrary non-BTC wallet supports USD fee probes', async () => { + const fetchMock = vi.fn(); + const node = new FlashNode( + { + apiKey: 'flash-key', + walletId: 'wallet-unknown', + walletCurrency: 'JMD', + }, + { fetch: fetchMock } + ); + + await expect(node.payInvoice({ invoice: BOLT11 })).rejects.toMatchObject({ + nwcCode: 'NOT_IMPLEMENTED', + operation: 'pay_invoice', + provider: 'flash', + message: expect.stringContaining('JMD'), + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/bindings/typescript/src/__tests__/galoy.test.ts b/bindings/typescript/src/__tests__/galoy.test.ts new file mode 100644 index 00000000..965c4889 --- /dev/null +++ b/bindings/typescript/src/__tests__/galoy.test.ts @@ -0,0 +1,774 @@ +import { describe, expect, it, vi } from 'vitest'; +import { decode } from '../decode.js'; +import { createNode } from '../factory.js'; +import { createGaloyNode } from '../nodes/galoy.js'; +import { NwcError } from '../errors.js'; +import type { FetchLike, GaloyConfig } from '../types.js'; + +const ONE_SAT_BOLT11S = [ + [ + 'mainnet', + 'lnbc10n1pj48ugqdphf38yjgz8v9kx77fqxys8xct5ypex2emjv4ehx6t0dcsxv6tcw36hyegpp5qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurssp5pqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyq9qrsgqcqpj85c7x06rkw8s97xtjaarx4y4sgumglauw96fkcdr3yatkshg23gj57pj350za5ppku4d4hl8p6xj9ty7t84z2594q9hl7vf4em9en8cp3rvsy3', + ], + [ + 'testnet', + 'lntb10n1pj48ugqdphf38yjgz8v9kx77fqxys8xct5ypex2emjv4ehx6t0dcsxv6tcw36hyegpp5qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurssp5pqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyq9qrsgqcqpjglkgmmeh8nlna32uhyqvpmrxk52er02glraz7jywwxg0tz0ahuxrtzanfvxjrugv2zuv8dxvakvk5p3fuxeexym8ff96m25s7ks750sqknxxzt', + ], + [ + 'regtest', + 'lnbcrt10n1pj48ugqdphf38yjgz8v9kx77fqxys8xct5ypex2emjv4ehx6t0dcsxv6tcw36hyegpp5qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurssp5pqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyq9qrsgqcqpj7n3tcnerf357qjapjupmduwrryy3vdfk63jh45ssw86v34cxtdc9kk2n8hlhgs9f6uprj3eaxz54fwp3w2rkafhphh05llhtjdqp4sqptf062s', + ], +] as const; +const BOLT11 = + 'lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpu9qrsgquk0rl77nj30yxdy8j9vdx85fkpmdla2087ne0xh8nhedh8w27kyke0lp53ut353s06fv3qfegext0eh0ymjpf39tuven09sam30g4vgpfna3rh'; +const AMOUNTLESS_BOLT11 = + 'lnbc1pj48ugqdplf38yjgz8v9kx77fqv9kk7atww3kx2umnypex2emjv4ehx6t0dcsxv6tcw36hyegpp5pyysjzgfpyysjzgfpyysjzgfpyysjzgfpyysjzgfpyysjzgfpyyssp5pg9q5zs2pg9q5zs2pg9q5zs2pg9q5zs2pg9q5zs2pg9q5zs2pg9q9qrsgqcqpjvcwldrltwv8ce6n00l8gl20vz5q3vu56hhmla07u39tmdy0ll6cs9crysytmdvugwrv2e6nwhfvlhd0mnjvskaefd43j9vdzjaggtygqe8yu0t'; +const PAYMENT_HASH = '0001020304050607080900010203040506070809000102030405060708090102'; + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + ...init, + headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) }, + }); +} + +function config(overrides: Partial = {}): GaloyConfig { + return { + apiKey: 'secret-api-key', + baseUrl: 'https://galoy.test/graphql', + provider: { id: 'flash', name: 'Flash' }, + wallet: { mode: 'explicit', id: 'wallet-jmd', currency: 'JMD' }, + invoiceOperations: { + create: { kind: 'unsupported' }, + feeProbe: { kind: 'usd', denomination: 'usd-cents' }, + }, + payment: { + response: 'status-only', + acceptedStatuses: ['SUCCESS', 'PENDING', 'ALREADY_PAID'], + statusMapping: { + settled: ['SUCCESS', 'ALREADY_PAID'], + pending: ['PENDING'], + }, + proofUnavailableErrorCodes: ['PROOF_UNAVAILABLE'], + }, + capabilities: { + transactionLookup: false, + transactionHistory: false, + invoiceEvents: false, + onchain: false, + }, + permissions: 'configured', + httpTimeout: 60, + ...overrides, + }; +} + +function bodyOf(init?: RequestInit): { + query: string; + variables?: Record; +} { + return JSON.parse(String(init?.body)); +} + +describe('createGaloyNode wallet and invoice behavior', () => { + it('uses explicit operation configuration instead of inferring from wallet currency', async () => { + const queries: string[] = []; + const fetchMock = vi.fn(async (_input, init) => { + const body = bodyOf(init); + queries.push(body.query); + expect(body.variables).toMatchObject({ input: { walletId: 'wallet-jmd' } }); + return jsonResponse({ + data: { + lnInvoiceCreate: { + invoice: { + paymentRequest: BOLT11, + paymentHash: PAYMENT_HASH, + satoshis: 21, + }, + errors: [], + }, + }, + }); + }); + + const transaction = await createGaloyNode( + config({ + invoiceOperations: { + create: { kind: 'btc', denomination: 'sats' }, + feeProbe: { kind: 'btc', denomination: 'sats' }, + }, + }), + { fetch: fetchMock } + ).createInvoice({ amountMsats: 21_000 }); + + expect(transaction.amountMsats).toBe(21_000); + expect(queries).toHaveLength(1); + expect(queries[0]).toContain('lnInvoiceCreate'); + expect(queries[0]).not.toContain('lnUsdInvoiceCreate'); + expect(queries[0]).not.toContain('query Me'); + }); + + it('does not convert amountMsats into USD cents', async () => { + const fetchMock = vi.fn(); + const node = createGaloyNode( + config({ + invoiceOperations: { + create: { kind: 'usd', denomination: 'usd-cents' }, + feeProbe: { kind: 'usd', denomination: 'usd-cents' }, + }, + }), + { fetch: fetchMock } + ); + + await expect(node.createInvoice({ amountMsats: 21_000 })).rejects.toMatchObject({ + nwcCode: 'NOT_IMPLEMENTED', + operation: 'make_invoice', + message: expect.stringContaining('USD-cent'), + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('selects the requested wallet currency and reports NOT_FOUND when absent', async () => { + const fetchMock = vi.fn(async () => + jsonResponse({ + data: { + me: { + defaultAccount: { + wallets: [ + { id: 'btc', walletCurrency: 'BTC', balance: 10 }, + { id: 'jmd', walletCurrency: 'JMD', balance: 20 }, + ], + }, + }, + }, + }) + ); + const jmdNode = createGaloyNode(config({ wallet: { mode: 'currency', currency: 'JMD' } }), { + fetch: fetchMock, + }); + await expect(jmdNode.getInfo()).resolves.toMatchObject({ + alias: 'Flash Node', + sendBalanceMsat: 0, + receiveBalanceMsat: 0, + }); + + const missingNode = createGaloyNode(config({ wallet: { mode: 'currency', currency: 'USD' } }), { + fetch: fetchMock, + }); + await expect(missingNode.getInfo()).rejects.toMatchObject({ + nwcCode: 'NOT_FOUND', + provider: 'flash', + message: expect.stringContaining('USD'), + }); + }); + + it('refreshes currency-wallet balances on each getInfo call', async () => { + let balance = 1; + const fetchMock = vi.fn(async () => + jsonResponse({ + data: { + me: { + defaultAccount: { + wallets: [{ id: 'btc', walletCurrency: 'BTC', balance: balance++ }], + }, + }, + }, + }) + ); + const node = createGaloyNode(config({ wallet: { mode: 'currency', currency: 'BTC' } }), { + fetch: fetchMock, + }); + + await expect(node.getInfo()).resolves.toMatchObject({ sendBalanceMsat: 1_000 }); + await expect(node.getInfo()).resolves.toMatchObject({ sendBalanceMsat: 2_000 }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('uses BTC create and fee-probe operations and converts BTC fees to msats', async () => { + const queries: string[] = []; + const fetchMock = vi.fn(async (_input, init) => { + const query = bodyOf(init).query; + queries.push(query); + if (query.includes('lnInvoiceFeeProbe')) { + return jsonResponse({ + data: { lnInvoiceFeeProbe: { amount: 3, errors: [] } }, + }); + } + return jsonResponse({ + data: { lnInvoicePaymentSend: { status: 'SUCCESS', errors: [] } }, + }); + }); + const node = createGaloyNode( + config({ + wallet: { mode: 'explicit', id: 'wallet-btc', currency: 'BTC' }, + invoiceOperations: { + create: { kind: 'btc', denomination: 'sats' }, + feeProbe: { kind: 'btc', denomination: 'sats' }, + }, + payment: { response: 'status-only', acceptedStatuses: ['SUCCESS'] }, + }), + { fetch: fetchMock } + ); + + await expect(node.payInvoice({ invoice: BOLT11 })).resolves.toEqual({ + paymentHash: PAYMENT_HASH, + preimage: '', + feeMsats: 3_000, + }); + expect(queries[0]).toContain('lnInvoiceFeeProbe'); + expect(queries[0]).not.toContain('lnUsdInvoiceFeeProbe'); + }); + + it('uses lnUsd fee probes and never treats non-BTC fees as satoshis', async () => { + const queries: string[] = []; + const fetchMock = vi.fn(async (_input, init) => { + const query = bodyOf(init).query; + queries.push(query); + if (query.includes('lnUsdInvoiceFeeProbe')) { + return jsonResponse({ + data: { lnUsdInvoiceFeeProbe: { amount: 99_999, errors: [] } }, + }); + } + return jsonResponse({ + data: { lnInvoicePaymentSend: { status: 'SUCCESS', errors: [] } }, + }); + }); + + await expect( + createGaloyNode(config(), { fetch: fetchMock }).payInvoice({ invoice: BOLT11 }) + ).resolves.toMatchObject({ feeMsats: 0 }); + expect(queries[0]).toContain('lnUsdInvoiceFeeProbe'); + }); +}); + +describe('createGaloyNode transport and payment modes', () => { + it('forwards additional headers without allowing auth or content-type overrides', async () => { + const fetchMock = vi.fn(async (_input, init) => { + const headers = new Headers(init?.headers); + expect(headers.get('x-flash-client-capabilities')).toBe('proofless'); + expect(headers.get('x-api-key')).toBe('secret-api-key'); + expect(headers.get('content-type')).toBe('application/json'); + return jsonResponse({ + data: { + lnInvoiceCreate: { + invoice: { + paymentRequest: BOLT11, + paymentHash: PAYMENT_HASH, + satoshis: 1, + }, + errors: [], + }, + }, + }); + }); + + const node = createGaloyNode( + config({ + invoiceOperations: { + create: { kind: 'btc', denomination: 'sats' }, + feeProbe: { kind: 'btc', denomination: 'sats' }, + }, + additionalHeaders: { + 'x-flash-client-capabilities': 'proofless', + 'X-API-Key': 'attacker-key', + 'Content-Type': 'text/plain', + }, + }), + { fetch: fetchMock } + ); + await node.createInvoice({ amountMsats: 1_000 }); + }); + + it('requests and returns a preimage only in transaction-with-preimage mode', async () => { + let paymentQuery = ''; + const fetchMock = vi.fn(async (_input, init) => { + const query = bodyOf(init).query; + if (query.includes('FeeProbe')) { + return jsonResponse({ + data: { lnInvoiceFeeProbe: { amount: 1, errors: [] } }, + }); + } + paymentQuery = query; + return jsonResponse({ + data: { + lnInvoicePaymentSend: { + status: 'SUCCESS', + errors: [], + transaction: { settlementVia: { preImage: 'provider-preimage' } }, + }, + }, + }); + }); + const node = createGaloyNode( + config({ + wallet: { mode: 'explicit', id: 'btc', currency: 'BTC' }, + invoiceOperations: { + create: { kind: 'btc', denomination: 'sats' }, + feeProbe: { kind: 'btc', denomination: 'sats' }, + }, + payment: { + response: 'transaction-with-preimage', + acceptedStatuses: ['SUCCESS'], + }, + }), + { fetch: fetchMock } + ); + + await expect(node.payInvoice({ invoice: BOLT11 })).resolves.toMatchObject({ + paymentHash: PAYMENT_HASH, + preimage: 'provider-preimage', + }); + expect(paymentQuery).toContain('transaction {'); + expect(paymentQuery).toContain('SettlementViaIntraLedger'); + }); + + it.each(ONE_SAT_BOLT11S)( + 'accepts a valid 1-sat %s invoice whose encoded amount begins with 1', + async (_network, invoice) => { + const queries: string[] = []; + const fetchMock = vi.fn(async (_input, init) => { + const query = bodyOf(init).query; + queries.push(query); + return query.includes('lnInvoiceFeeProbe') + ? jsonResponse({ data: { lnInvoiceFeeProbe: { amount: 1, errors: [] } } }) + : jsonResponse({ + data: { lnInvoicePaymentSend: { status: 'SUCCESS', errors: [] } }, + }); + }); + const node = createGaloyNode( + config({ + wallet: { mode: 'explicit', id: 'btc', currency: 'BTC' }, + invoiceOperations: { + create: { kind: 'btc', denomination: 'sats' }, + feeProbe: { kind: 'btc', denomination: 'sats' }, + }, + payment: { response: 'status-only', acceptedStatuses: ['SUCCESS'] }, + }), + { fetch: fetchMock } + ); + + expect(decode(invoice).amountMsats).toBe(1_000); + await expect(node.payInvoice({ invoice })).resolves.toMatchObject({ + paymentHash: '07'.repeat(32), + preimage: '', + feeMsats: 1_000, + }); + expect(queries).toHaveLength(2); + expect(queries[0]).toContain('lnInvoiceFeeProbe'); + expect(queries[1]).toContain('lnInvoicePaymentSend'); + } + ); + + it.each([undefined, 1_000])( + 'rejects a genuinely amountless invoice before any request (amountMsats: %s)', + async (amountMsats) => { + const fetchMock = vi.fn(); + const node = createGaloyNode(config(), { fetch: fetchMock }); + + await expect( + node.payInvoice({ invoice: AMOUNTLESS_BOLT11, amountMsats }) + ).rejects.toMatchObject({ + code: 'InvalidInput', + message: expect.stringContaining( + "Flash cannot pay amountless BOLT11 invoices because Galoy's payment mutation has no amount field." + ), + }); + expect(fetchMock).not.toHaveBeenCalled(); + } + ); + + it('lets the provider handle malformed invoices instead of calling them amountless', async () => { + const queries: string[] = []; + const fetchMock = vi.fn(async (_input, init) => { + const query = bodyOf(init).query; + queries.push(query); + return query.includes('lnUsdInvoiceFeeProbe') + ? jsonResponse({ data: { lnUsdInvoiceFeeProbe: { amount: 0, errors: [] } } }) + : jsonResponse({ + data: { + lnInvoicePaymentSend: { + status: 'FAILURE', + errors: [{ code: 'INVALID_INVOICE', message: 'Invalid invoice' }], + }, + }, + }); + }); + const node = createGaloyNode(config(), { fetch: fetchMock }); + + await expect(node.payInvoice({ invoice: 'not-a-bolt11-invoice' })).rejects.toMatchObject({ + nwcCode: 'PAYMENT_FAILED', + provider: 'flash', + providerCode: 'FAILURE', + providerMessage: 'Invalid invoice', + }); + expect(queries).toHaveLength(2); + expect(queries[0]).toContain('lnUsdInvoiceFeeProbe'); + expect(queries[1]).toContain('lnInvoicePaymentSend'); + }); + + it('normalizes missing payment and on-chain mutation payloads', async () => { + const paymentFetch = vi.fn(async (_input, init) => { + const query = bodyOf(init).query; + return query.includes('FeeProbe') + ? jsonResponse({ data: { lnInvoiceFeeProbe: { amount: 1, errors: [] } } }) + : jsonResponse({ data: {} }); + }); + const btcConfig = config({ + wallet: { mode: 'explicit', id: 'btc', currency: 'BTC' }, + invoiceOperations: { + create: { kind: 'btc', denomination: 'sats' }, + feeProbe: { kind: 'btc', denomination: 'sats' }, + }, + payment: { response: 'status-only', acceptedStatuses: ['SUCCESS'] }, + capabilities: { ...config().capabilities, onchain: true }, + }); + const node = createGaloyNode(btcConfig, { fetch: paymentFetch }); + + await expect(node.payInvoice({ invoice: BOLT11 })).rejects.toMatchObject({ + nwcCode: 'INTERNAL', + provider: 'flash', + message: expect.stringContaining('lnInvoicePaymentSend'), + }); + + const onchainFetch = vi.fn(async () => jsonResponse({ data: {} })); + const onchainNode = createGaloyNode(btcConfig, { fetch: onchainFetch }); + await expect( + onchainNode.prepareOnchainTransaction({ + address: 'bc1qexample', + amountSats: 10_000, + }) + ).rejects.toMatchObject({ + nwcCode: 'INTERNAL', + operation: 'prepare_onchain_transaction', + provider: 'flash', + message: expect.stringContaining('onChainTxFee'), + }); + await expect( + onchainNode.payOnchain({ + address: 'bc1qexample', + amountSats: 10_000, + feeSats: 1_000, + feePayer: 'sender', + fee: { type: 'default' }, + }) + ).rejects.toMatchObject({ + nwcCode: 'INTERNAL', + operation: 'pay_onchain', + provider: 'flash', + message: expect.stringContaining('onChainPaymentSend'), + }); + }); + + it.each(['SUCCESS', 'PENDING', 'ALREADY_PAID'])( + 'accepts configured proofless status %s and omits transaction from the query', + async (status) => { + let paymentQuery = ''; + const fetchMock = vi.fn(async (_input, init) => { + const query = bodyOf(init).query; + if (query.includes('FeeProbe')) { + return jsonResponse({ + data: { lnUsdInvoiceFeeProbe: { amount: 20, errors: [] } }, + }); + } + paymentQuery = query; + return jsonResponse({ + data: { lnInvoicePaymentSend: { status, errors: [] } }, + }); + }); + + const node = createGaloyNode(config(), { fetch: fetchMock }); + await expect(node.payInvoiceWithStatus({ invoice: BOLT11 })).resolves.toEqual({ + payment: { + paymentHash: PAYMENT_HASH, + preimage: '', + feeMsats: 0, + }, + state: status === 'PENDING' ? 'pending' : 'settled', + providerStatus: status, + }); + expect(paymentQuery).not.toMatch(/\btransaction\s*\{/); + } + ); + + it('does not turn an accepted status into a failure when no proof is available', async () => { + const fetchMock = vi.fn(async (_input, init) => { + const query = bodyOf(init).query; + if (query.includes('FeeProbe')) { + return jsonResponse({ + data: { lnUsdInvoiceFeeProbe: { amount: 20, errors: [] } }, + }); + } + return jsonResponse({ + data: { + lnInvoicePaymentSend: { + status: 'PENDING', + errors: [{ code: 'PROOF_UNAVAILABLE', message: 'No proof is exposed' }], + }, + }, + }); + }); + + const node = createGaloyNode(config(), { fetch: fetchMock }); + await expect(node.payInvoiceWithStatus({ invoice: BOLT11 })).resolves.toEqual({ + payment: { + paymentHash: PAYMENT_HASH, + preimage: '', + feeMsats: 0, + }, + state: 'pending', + providerStatus: 'PENDING', + }); + }); + + it('surfaces unexpected payload errors even when the status is accepted', async () => { + const sensitiveProviderMessage = [ + `payment_request=${BOLT11}`, + `payment_hash=${PAYMENT_HASH}`, + 'payment_secret=payment-secret', + 'preimage=provider-preimage', + 'access_token=access-token', + 'api_key=secret-api-key', + ].join(' '); + const fetchMock = vi.fn(async (_input, init) => { + const query = bodyOf(init).query; + if (query.includes('FeeProbe')) { + return jsonResponse({ + data: { lnUsdInvoiceFeeProbe: { amount: 20, errors: [] } }, + }); + } + return jsonResponse({ + data: { + lnInvoicePaymentSend: { + status: 'SUCCESS', + errors: [{ code: 'UNEXPECTED_PROVIDER_ERROR', message: sensitiveProviderMessage }], + }, + }, + }); + }); + + try { + await createGaloyNode(config(), { fetch: fetchMock }).payInvoice({ invoice: BOLT11 }); + throw new Error('expected payInvoice to fail'); + } catch (error) { + expect(error).toMatchObject({ + nwcCode: 'PAYMENT_FAILED', + provider: 'flash', + providerCode: 'UNEXPECTED_PROVIDER_ERROR', + }); + const serialized = JSON.stringify(error); + for (const secret of [ + BOLT11, + PAYMENT_HASH, + 'payment-secret', + 'provider-preimage', + 'access-token', + 'secret-api-key', + ]) { + expect(serialized).not.toContain(secret); + } + } + }); + + it('uses configured provider metadata for rejected statuses and redacts secrets', async () => { + const fetchMock = vi.fn(async (_input, init) => { + const query = bodyOf(init).query; + if (query.includes('FeeProbe')) { + return jsonResponse({ + data: { lnUsdInvoiceFeeProbe: { amount: 0, errors: [] } }, + }); + } + return jsonResponse({ + data: { lnInvoicePaymentSend: { status: 'DECLINED', errors: [] } }, + }); + }); + const node = createGaloyNode( + config({ payment: { response: 'status-only', acceptedStatuses: ['SUCCESS'] } }), + { fetch: fetchMock } + ); + + await expect(node.payInvoice({ invoice: BOLT11 })).rejects.toMatchObject({ + provider: 'flash', + providerCode: 'DECLINED', + providerMessage: 'DECLINED', + message: expect.stringContaining('Flash'), + }); + + const secretFetch = vi.fn(async () => + jsonResponse( + { + message: 'bad request', + paymentRequest: BOLT11, + preImage: 'secret-preimage', + apiKey: 'secret-api-key', + }, + { status: 400 } + ) + ); + try { + await createGaloyNode(config({ wallet: { mode: 'currency', currency: 'JMD' } }), { + fetch: secretFetch, + }).getInfo(); + throw new Error('expected getInfo to fail'); + } catch (error) { + expect(error).toBeInstanceOf(NwcError); + expect(JSON.stringify(error)).not.toContain(BOLT11); + expect(JSON.stringify(error)).not.toContain('secret-preimage'); + expect(JSON.stringify(error)).not.toContain('secret-api-key'); + } + }); +}); + +describe('createGaloyNode capabilities and compatibility', () => { + it('reports configured permissions and blocks disabled transaction methods without queries', async () => { + const fetchMock = vi.fn(); + const node = createGaloyNode(config(), { fetch: fetchMock }); + + await expect(node.getPermissions()).resolves.toMatchObject({ + getInfo: true, + createInvoice: false, + payInvoice: true, + decode: true, + lookupInvoice: false, + listTransactions: false, + onInvoiceEvents: false, + }); + await expect(node.lookupInvoice({ paymentHash: PAYMENT_HASH })).rejects.toMatchObject({ + nwcCode: 'NOT_IMPLEMENTED', + provider: 'flash', + }); + await expect(node.listTransactions({ from: 0, limit: 10 })).rejects.toMatchObject({ + nwcCode: 'NOT_IMPLEMENTED', + provider: 'flash', + }); + await expect( + node.onInvoiceEvents( + { paymentHash: PAYMENT_HASH, pollingDelaySec: 1, maxPollingSec: 1 }, + () => {} + ) + ).rejects.toMatchObject({ + nwcCode: 'NOT_IMPLEMENTED', + provider: 'flash', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('does not expose on-chain methods for non-BTC wallets', async () => { + const fetchMock = vi.fn(); + const node = createGaloyNode( + config({ capabilities: { ...config().capabilities, onchain: true } }), + { fetch: fetchMock } + ); + await expect( + node.prepareOnchainTransaction({ address: 'address', amountSats: 1 }) + ).rejects.toMatchObject({ + nwcCode: 'NOT_IMPLEMENTED', + provider: 'flash', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('supports kind galoy and keeps kind blink behavior through the generic transport', async () => { + const galoyFetch = vi.fn(async () => + jsonResponse({ + data: { + lnInvoiceCreate: { + invoice: { + paymentRequest: BOLT11, + paymentHash: PAYMENT_HASH, + satoshis: 1, + }, + errors: [], + }, + }, + }) + ); + const galoy = createNode( + { + kind: 'galoy', + config: config({ + invoiceOperations: { + create: { kind: 'btc', denomination: 'sats' }, + feeProbe: { kind: 'btc', denomination: 'sats' }, + }, + }), + }, + { fetch: galoyFetch } + ); + await expect(galoy.createInvoice({ amountMsats: 1_000 })).resolves.toMatchObject({ + amountMsats: 1_000, + }); + + const blinkFetch = vi.fn(async (_input, init) => { + const body = bodyOf(init); + expect(new Headers(init?.headers).get('x-api-key')).toBe('blink-key'); + expect(body.query).toContain('query Me'); + return jsonResponse({ + data: { + me: { + defaultAccount: { + wallets: [{ id: 'btc', walletCurrency: 'BTC', balance: 2 }], + }, + }, + }, + }); + }); + const blink = createNode( + { kind: 'blink', config: { apiKey: 'blink-key' } }, + { fetch: blinkFetch } + ); + await expect(blink.getInfo()).resolves.toMatchObject({ + alias: 'Blink Node', + sendBalanceMsat: 2_000, + }); + }); + + it('bounds transaction lookup to 1,000 scanned provider records', async () => { + let pageNumber = 0; + const fetchMock = vi.fn(async (_input, init) => { + const body = bodyOf(init); + const first = Number(body.variables?.first); + const currentPage = pageNumber++; + expect(first).toBe(100); + return jsonResponse({ + data: { + me: { + defaultAccount: { + transactions: { + edges: Array.from({ length: first }, (_, index) => ({ + cursor: `cursor-${currentPage}-${index}`, + node: { + id: `transaction-${currentPage}-${index}`, + createdAt: 1, + direction: 'RECEIVE', + status: 'SUCCESS', + settlementAmount: 1, + settlementCurrency: 'BTC', + initiationVia: { + __typename: 'InitiationViaLn', + paymentHash: `not-${PAYMENT_HASH}`, + }, + }, + })), + pageInfo: { + hasNextPage: true, + endCursor: `page-${currentPage + 1}`, + }, + }, + }, + }, + }, + }); + }); + const node = createGaloyNode( + config({ + capabilities: { ...config().capabilities, transactionLookup: true }, + }), + { fetch: fetchMock } + ); + + await expect(node.lookupInvoice({ paymentHash: PAYMENT_HASH })).rejects.toMatchObject({ + nwcCode: 'NOT_FOUND', + }); + expect(fetchMock).toHaveBeenCalledTimes(10); + }); +}); diff --git a/bindings/typescript/src/errors.ts b/bindings/typescript/src/errors.ts index b0cdc426..35b8a21d 100644 --- a/bindings/typescript/src/errors.ts +++ b/bindings/typescript/src/errors.ts @@ -27,6 +27,8 @@ export type NwcErrorOperation = | 'get_balance' | 'make_invoice' | 'pay_invoice' + | 'prepare_onchain_transaction' + | 'pay_onchain' | 'lookup_invoice' | 'list_transactions'; diff --git a/bindings/typescript/src/factory.ts b/bindings/typescript/src/factory.ts index c9342951..12b7c23a 100644 --- a/bindings/typescript/src/factory.ts +++ b/bindings/typescript/src/factory.ts @@ -1,4 +1,6 @@ import { BlinkNode } from './nodes/blink.js'; +import { FlashNode } from './nodes/flash.js'; +import { createGaloyNode, type GaloyNode } from './nodes/galoy.js'; import { ClnNode } from './nodes/cln.js'; import { LndNode } from './nodes/lnd.js'; import { NwcNode } from './nodes/nwc.js'; @@ -9,6 +11,8 @@ import type { BackendNodeConfig, BlinkConfig, ClnConfig, + FlashConfig, + GaloyConfig, LightningNode, LndConfig, NodeRequestOptions, @@ -42,6 +46,17 @@ export function createNode( input: { kind: 'speed'; config: SpeedConfig }, options?: NodeRequestOptions ): SpeedNode; +export function createNode( + input: { kind: 'galoy'; config: GaloyConfig }, + options?: NodeRequestOptions +): GaloyNode; +export function createNode( + input: { kind: 'flash'; config: FlashConfig }, + options?: NodeRequestOptions +): FlashNode; +/** + * @deprecated Use the `galoy` kind with Blink defaults. + */ export function createNode( input: { kind: 'blink'; config: BlinkConfig }, options?: NodeRequestOptions @@ -64,6 +79,10 @@ export function createNode( return new StrikeNode(input.config, options); case 'speed': return new SpeedNode(input.config, options); + case 'galoy': + return createGaloyNode(input.config, options); + case 'flash': + return new FlashNode(input.config, options); case 'blink': return new BlinkNode(input.config, options); default: diff --git a/bindings/typescript/src/index.ts b/bindings/typescript/src/index.ts index 78f2572d..8e53ac3f 100644 --- a/bindings/typescript/src/index.ts +++ b/bindings/typescript/src/index.ts @@ -10,4 +10,6 @@ export { LndNode } from './nodes/lnd.js'; export { NwcNode } from './nodes/nwc.js'; export { StrikeNode } from './nodes/strike.js'; export { SpeedNode } from './nodes/speed.js'; +export { createGaloyNode, type GaloyNode } from './nodes/galoy.js'; +export { DEFAULT_FLASH_GRAPHQL_URL, FlashNode } from './nodes/flash.js'; export { BlinkNode } from './nodes/blink.js'; diff --git a/bindings/typescript/src/internal/permissions.ts b/bindings/typescript/src/internal/permissions.ts index 5cd6d37e..90c8cbe4 100644 --- a/bindings/typescript/src/internal/permissions.ts +++ b/bindings/typescript/src/internal/permissions.ts @@ -260,7 +260,7 @@ export function getStrikeOauthPermissions(accessToken: string): Permissions | nu return normalizePermissions(permissions); } -export function getBlinkTokenPermissions(token: string): Permissions | null { +export function getGaloyTokenPermissions(token: string): Permissions | null { const payload = decodeJwtPayload(token); if (!payload) { return null; @@ -278,6 +278,9 @@ export function getBlinkTokenPermissions(token: string): Permissions | null { return normalizePermissions(permissions); } +/** @deprecated Use `getGaloyTokenPermissions`. */ +export const getBlinkTokenPermissions = getGaloyTokenPermissions; + function permissionsFromValues(values: string[]): Permissions { const has = (permission: string) => hasPermission(values, permission); diff --git a/bindings/typescript/src/nodes/blink.ts b/bindings/typescript/src/nodes/blink.ts index befe5402..b92d4b45 100644 --- a/bindings/typescript/src/nodes/blink.ts +++ b/bindings/typescript/src/nodes/blink.ts @@ -1,1056 +1,129 @@ -import { LniError, NwcError, type NwcErrorCode, type NwcErrorOperation } from '../errors.js'; -import { decode as decodeBolt11, decodeBolt11ToJson, decodeOfferToJson } from '../decode.js'; -import { - mapProviderMessage, - throwNormalizedProviderError, - type ProviderErrorInfo, -} from '../internal/error-normalization.js'; -import { requestJson, resolveFetch, toTimeoutMs } from '../internal/http.js'; -import { getBlinkTokenPermissions } from '../internal/permissions.js'; -import { pollInvoiceEvents } from '../internal/polling.js'; -import { - emptyNodeInfo, - emptyTransaction, - matchesSearch, - satsToMsats, -} from '../internal/transform.js'; -import { - DEFAULT_ONCHAIN_FEE_GUARDRAIL, - InvoiceType, - type BlinkConfig, - type CreateInvoiceParams, - type CreateOfferParams, - type InvoiceEventCallback, - type LightningNode, - type ListTransactionsParams, - type LookupInvoiceParams, - type NodeInfo, - type NodeRequestOptions, - type Offer, - type OnInvoiceEventParams, - type OnchainFeeGuardrail, - type OnchainFeePayer, - type OnchainFeePreference, - type OnchainPayments, - type OnchainTransaction, - type PayInvoiceParams, - type PayInvoiceResponse, - type PayOnchainOptions, - type PayOnchainResponse, - type Permissions, - type PrepareOnchainTransactionParams, - type Transaction, +import { createGaloyNode, type GaloyNode } from './galoy.js'; +import type { + BlinkConfig, + CreateInvoiceParams, + CreateOfferParams, + InvoiceEventCallback, + LightningNode, + ListTransactionsParams, + LookupInvoiceParams, + NodeInfo, + NodeRequestOptions, + Offer, + OnInvoiceEventParams, + OnchainPayments, + OnchainTransaction, + PayInvoiceParams, + PayInvoiceResponse, + PayOnchainOptions, + PayOnchainResponse, + Permissions, + PrepareOnchainTransactionParams, + Transaction, } from '../types.js'; -interface GraphQLError { - message: string; - code?: string; - path?: string[]; -} - -interface GraphQLResponse { - data?: T; - errors?: GraphQLError[]; -} - -interface BlinkMeQuery { - me: { - defaultAccount: { - wallets: BlinkWallet[]; - }; - }; -} - -interface BlinkWallet { - id: string; - walletCurrency: string; - balance: number; -} - -interface BlinkInvoiceCreateResponse { - lnInvoiceCreate: { - invoice?: { - paymentRequest: string; - paymentHash: string; - satoshis: number; - }; - errors?: GraphQLError[]; - }; -} - -interface BlinkFeeProbeResponse { - lnInvoiceFeeProbe: { - amount?: number; - errors?: GraphQLError[]; - }; -} - -interface BlinkPaymentSendResponse { - lnInvoicePaymentSend: { - status: string; - transaction?: { - settlementVia?: { - preImage?: string; - }; - }; - errors?: GraphQLError[]; - }; -} - -interface BlinkOnchainTxFeeResponse { - onChainTxFee: { - amount?: number; - }; -} - -interface BlinkOnchainPaymentSendResponse { - onChainPaymentSend: { - status: string; - transaction?: { - id?: string; - settlementAmount?: number; - settlementCurrency?: string; - settlementFee?: number; - settlementVia?: { - __typename: string; - transactionHash?: string; - }; - }; - errors?: GraphQLError[]; - }; -} - -interface BlinkTransactionsQuery { - me: { - defaultAccount: { - transactions: { - edges: Array<{ - cursor: string; - node: { - id: string; - createdAt: number; - direction: 'SEND' | 'RECEIVE'; - status: string; - memo?: string; - settlementAmount?: number; - settlementCurrency?: string; - settlementFee?: number; - initiationVia?: { - __typename: string; - paymentHash?: string; - }; - settlementVia?: { - __typename: string; - preImage?: string; - }; - }; - }>; - pageInfo: { - hasNextPage: boolean; - endCursor?: string | null; - }; - }; - }; - }; -} - -type BlinkTransactionNode = - BlinkTransactionsQuery['me']['defaultAccount']['transactions']['edges'][number]['node']; - -interface BlinkTransactionsPage { - transactions: Transaction[]; - nextCursor: string | null; -} - -function defaultOnchainFee(): OnchainFeePreference { - return { type: 'default' }; -} - -function resolveBlinkFeeSpeed(fee: OnchainFeePreference): 'FAST' | 'MEDIUM' | 'SLOW' { - if (fee.type === 'default') { - return 'FAST'; - } - - if (fee.type !== 'speed') { - throw new LniError( - 'InvalidInput', - `Blink payOnchain does not support ${fee.type} fee preferences.` - ); - } - - switch (fee.speed) { - case 'fast': - return 'FAST'; - case 'normal': - return 'MEDIUM'; - case 'slow': - return 'SLOW'; - case 'free': - throw new LniError( - 'InvalidInput', - 'Blink payOnchain does not support free on-chain fee speed.' - ); - } -} - -function resolveBlinkFeePayer(feePayer?: OnchainFeePayer): OnchainFeePayer { - if (feePayer === 'recipient') { - throw new LniError('InvalidInput', 'Blink payOnchain only supports sender-paid on-chain fees.'); - } - - return 'sender'; -} - -function normalizeOnchainState(state?: string): PayOnchainResponse['state'] { - switch (state?.toUpperCase()) { - case 'PENDING': - return 'pending'; - case 'SUCCESS': - case 'ALREADY_PAID': - return 'completed'; - case 'FAILED': - case 'FAILURE': - return 'failed'; - default: - return state?.toLowerCase() ?? 'pending'; - } -} - -function assertValidOnchainAmount(amountSats: number): void { - if (!Number.isSafeInteger(amountSats) || amountSats <= 0) { - throw new LniError('InvalidInput', 'payOnchain requires a positive integer amountSats.'); - } -} - -function assertValidGuardrailLimit(value: number, name: string): void { - if (!Number.isFinite(value) || value < 0) { - throw new LniError('InvalidInput', `${name} must be a non-negative finite number.`); - } - - if (name.endsWith('maxFeeSats') && !Number.isSafeInteger(value)) { - throw new LniError('InvalidInput', `${name} must be a safe integer.`); - } -} - -function resolveOnchainFeeGuardrail( - options?: PayOnchainOptions -): Required | undefined { - if (options?.dangerouslyDisableFeeGuardrail) { - return undefined; - } - - const guardrail = { - maxFeeSats: options?.feeGuardrail?.maxFeeSats ?? DEFAULT_ONCHAIN_FEE_GUARDRAIL.maxFeeSats, - maxFeePercent: - options?.feeGuardrail?.maxFeePercent ?? DEFAULT_ONCHAIN_FEE_GUARDRAIL.maxFeePercent, - }; - - assertValidGuardrailLimit(guardrail.maxFeeSats, 'feeGuardrail.maxFeeSats'); - assertValidGuardrailLimit(guardrail.maxFeePercent, 'feeGuardrail.maxFeePercent'); - - return guardrail; -} - -function assertOnchainFeeGuardrail( - transaction: OnchainTransaction, - options?: PayOnchainOptions -): void { - const guardrail = resolveOnchainFeeGuardrail(options); - if (!guardrail) { - return; - } - - const { feeSats } = transaction; - if (feeSats === undefined) { - throw new LniError( - 'InvalidInput', - 'Cannot pay on-chain transaction because feeSats is unknown. Re-prepare the transaction or pass dangerouslyDisableFeeGuardrail: true.' - ); - } - - if (!Number.isSafeInteger(feeSats) || feeSats < 0) { - throw new LniError( - 'InvalidInput', - 'Cannot pay on-chain transaction because feeSats is invalid.' - ); - } - - if (!Number.isSafeInteger(transaction.amountSats) || transaction.amountSats <= 0) { - throw new LniError( - 'InvalidInput', - 'Cannot pay on-chain transaction because amountSats is invalid.' - ); - } - - if (feeSats > guardrail.maxFeeSats) { - throw new LniError( - 'InvalidInput', - `On-chain fee ${feeSats} sats exceeds guardrail maxFeeSats ${guardrail.maxFeeSats}.` - ); - } - - const feePercent = (feeSats / transaction.amountSats) * 100; - if (feePercent > guardrail.maxFeePercent) { - throw new LniError( - 'InvalidInput', - `On-chain fee ${feePercent.toFixed(2)}% exceeds guardrail maxFeePercent ${guardrail.maxFeePercent}%.` - ); - } -} - -function blinkTransactionAmountToSats( - amount: number | undefined, - currency: string | undefined -): number | undefined { - return currency === 'BTC' && amount !== undefined ? Math.abs(amount) : undefined; -} - -function blinkTransactionMemo(transaction: OnchainTransaction): string | undefined { - const raw = transaction.raw; - if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { - return undefined; - } - - const memo = (raw as { memo?: unknown }).memo; - return typeof memo === 'string' && memo.length > 0 ? memo : undefined; -} - -function blinkProviderInfoFromErrors(errors: GraphQLError[]): ProviderErrorInfo { - const firstActionable = errors.find((error) => mapBlinkCode(error.code) !== undefined); - const firstCoded = errors.find((error) => error.code !== undefined); - const selected = firstActionable ?? firstCoded ?? errors[0]; - return { - code: selected?.code, - message: errors.map((error) => error.message).join(', '), - }; -} - -function mapBlinkCode(code: unknown): NwcErrorCode | undefined { - const normalized = String(code ?? '').toUpperCase(); - - if (normalized.includes('AUTHENTICATION') || normalized.includes('UNAUTHORIZED')) { - return 'UNAUTHORIZED'; - } - if ( - normalized.includes('FORBIDDEN') || - normalized.includes('PERMISSION') || - normalized.includes('SCOPE') - ) { - return 'RESTRICTED'; - } - if (normalized.includes('INSUFFICIENT') || normalized.includes('BALANCE')) { - return 'INSUFFICIENT_BALANCE'; - } - if (normalized.includes('LIMIT') || normalized.includes('QUOTA')) { - return 'QUOTA_EXCEEDED'; - } - if ( - normalized.includes('INVALID_INVOICE') || - normalized.includes('NO_ROUTE') || - normalized.includes('PAYMENT') - ) { - return 'PAYMENT_FAILED'; - } - - return undefined; -} - -function mapBlinkProviderError(info: ProviderErrorInfo): NwcErrorCode | undefined { - const code = mapBlinkCode(info.code); - if (code) { - return code; - } - - return mapProviderMessage(info.message); -} - -function blinkNwcError( - code: NwcErrorCode, - message: string, - operation: NwcErrorOperation, - info?: ProviderErrorInfo -): NwcError { - return new NwcError(code, message, { - operation, - provider: 'blink', - providerCode: info?.code, - providerStatus: info?.status, - providerMessage: info?.message ?? message, - }); -} - -function blinkErrorsToNwcError( - errors: GraphQLError[], - operation: NwcErrorOperation, - fallbackCode: NwcErrorCode = 'OTHER' -): NwcError { - const info = blinkProviderInfoFromErrors(errors); - return blinkNwcError( - mapBlinkProviderError(info) ?? fallbackCode, - info.message ?? 'Blink GraphQL error', - operation, - info - ); -} - -function throwBlinkProviderError(error: unknown, operation: NwcErrorOperation): never { - throwNormalizedProviderError(error, { - provider: 'blink', - operation, - mapProviderError: mapBlinkProviderError, - }); -} - +/** + * Backward-compatible Blink adapter. + * + * @deprecated Use `createGaloyNode` or `createNode({ kind: 'galoy', ... })`. + */ export class BlinkNode implements LightningNode, OnchainPayments { - private readonly fetchFn; - private readonly timeoutMs?: number; - private readonly baseUrl: string; - private cachedWalletId?: string; - private static readonly MAX_TRANSACTION_FETCH = 1000; - private static readonly DEFAULT_PAGE_SIZE = 100; - - private static readonly ME_QUERY = ` - query Me { - me { - defaultAccount { - wallets { - id - walletCurrency - balance - } - } - } - } - `; - - private static readonly TRANSACTIONS_QUERY = ` - query TransactionsQuery($first: Int, $after: String) { - me { - defaultAccount { - transactions(first: $first, after: $after) { - edges { - cursor - node { - id - createdAt - direction - status - memo - settlementAmount - settlementCurrency - settlementFee - initiationVia { - __typename - ... on InitiationViaLn { - paymentHash - } - } - settlementVia { - __typename - ... on SettlementViaLn { - preImage - } - } - } - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } - `; - - constructor( - private readonly config: BlinkConfig, - options: NodeRequestOptions = {} - ) { - this.fetchFn = resolveFetch(options.fetch); - this.timeoutMs = toTimeoutMs(config.httpTimeout); - this.baseUrl = config.baseUrl ?? 'https://api.blink.sv/graphql'; - } - - private headers(extra?: HeadersInit): HeadersInit { - return { - 'x-api-key': this.config.apiKey, - 'content-type': 'application/json', - ...(extra ?? {}), - }; - } - - private async gql( - query: string, - variables: Record | undefined, - operation: NwcErrorOperation - ): Promise { - let payload: GraphQLResponse; - try { - payload = await requestJson>(this.fetchFn, this.baseUrl, { - method: 'POST', - headers: this.headers(), - json: { - query, - variables, - }, - timeoutMs: this.timeoutMs, - }); - } catch (error) { - throwBlinkProviderError(error, operation); - } - - if (payload.errors?.length) { - throw blinkErrorsToNwcError(payload.errors, operation); - } - - if (!payload.data) { - throw blinkNwcError('INTERNAL', 'No data in Blink GraphQL response.', operation); - } - - return payload.data; - } - - private async getBtcWallet(): Promise { - const response = await this.gql(BlinkNode.ME_QUERY, undefined, 'get_info'); - const wallet = response.me.defaultAccount.wallets.find((item) => item.walletCurrency === 'BTC'); - - if (!wallet) { - throw blinkNwcError('NOT_FOUND', 'No BTC wallet found in Blink account.', 'get_info'); - } - - this.cachedWalletId = wallet.id; - return wallet; - } - - private async getBtcWalletId(): Promise { - if (this.cachedWalletId) { - return this.cachedWalletId; - } - - const wallet = await this.getBtcWallet(); - this.cachedWalletId = wallet.id; - return wallet.id; - } - - async getPermissions(): Promise { - const permissions = getBlinkTokenPermissions(this.config.apiKey); - if (!permissions) { - throw new LniError( - 'InvalidInput', - 'Blink API keys cannot be introspected. Use a JWT-style token with scopes or manually test permissions against Blink GraphQL operations.' - ); - } - - return permissions; - } - - async getInfo(): Promise { - const wallet = await this.getBtcWallet(); - const sats = wallet.balance; - - return emptyNodeInfo({ - alias: 'Blink Node', - network: 'mainnet', - sendBalanceMsat: satsToMsats(sats), - receiveBalanceMsat: satsToMsats(sats), - }); - } - - async createInvoice(params: CreateInvoiceParams): Promise { - if ((params.invoiceType ?? InvoiceType.Bolt11) !== InvoiceType.Bolt11) { - throw blinkNwcError( - 'NOT_IMPLEMENTED', - 'Bolt12 is not implemented for BlinkNode.', - 'make_invoice' - ); - } - - const walletId = await this.getBtcWalletId(); + private readonly node: GaloyNode; - const query = ` - mutation LnInvoiceCreate($input: LnInvoiceCreateInput!) { - lnInvoiceCreate(input: $input) { - invoice { - paymentRequest - paymentHash - satoshis - } - errors { - code - message - path - } - } - } - `; - - const response = await this.gql( - query, + constructor(config: BlinkConfig, options: NodeRequestOptions = {}) { + this.node = createGaloyNode( { - input: { - amount: Math.floor((params.amountMsats ?? 0) / 1000), - walletId, - memo: params.description, + apiKey: config.apiKey, + baseUrl: config.baseUrl ?? 'https://api.blink.sv/graphql', + provider: { + id: 'blink', + name: 'Blink', }, - }, - 'make_invoice' - ); - - if (response.lnInvoiceCreate.errors?.length) { - throw blinkErrorsToNwcError(response.lnInvoiceCreate.errors, 'make_invoice'); - } - - const invoice = response.lnInvoiceCreate.invoice; - if (!invoice) { - throw blinkNwcError( - 'INTERNAL', - 'No invoice returned from Blink invoice creation.', - 'make_invoice' - ); - } - - return emptyTransaction({ - type: 'incoming', - invoice: invoice.paymentRequest, - paymentHash: invoice.paymentHash, - amountMsats: satsToMsats(invoice.satoshis), - createdAt: Math.floor(Date.now() / 1000), - description: params.description ?? '', - descriptionHash: params.descriptionHash ?? '', - payerNote: '', - externalId: '', - }); - } - - async payInvoice(params: PayInvoiceParams): Promise { - const walletId = await this.getBtcWalletId(); - - const feeProbe = await this.gql( - ` - mutation lnInvoiceFeeProbe($input: LnInvoiceFeeProbeInput!) { - lnInvoiceFeeProbe(input: $input) { - errors { - code - message - path - } - amount - } - } - `, - { - input: { - paymentRequest: params.invoice, - walletId, + wallet: { + mode: 'currency', + currency: 'BTC', }, - }, - 'pay_invoice' - ); - - if (feeProbe.lnInvoiceFeeProbe.errors?.length) { - throw blinkErrorsToNwcError( - feeProbe.lnInvoiceFeeProbe.errors, - 'pay_invoice', - 'PAYMENT_FAILED' - ); - } - - const payment = await this.gql( - ` - mutation LnInvoicePaymentSend($input: LnInvoicePaymentInput!) { - lnInvoicePaymentSend(input: $input) { - status - errors { - code - message - path - } - transaction { - settlementVia { - ... on SettlementViaLn { - preImage - } - ... on SettlementViaIntraLedger { - preImage - } - } - } - } - } - `, - { - input: { - paymentRequest: params.invoice, - walletId, + invoiceOperations: { + create: { kind: 'btc', denomination: 'sats' }, + feeProbe: { kind: 'btc', denomination: 'sats' }, }, + payment: { + response: 'transaction-with-preimage', + acceptedStatuses: ['SUCCESS'], + }, + capabilities: { + transactionLookup: true, + transactionHistory: true, + invoiceEvents: true, + onchain: true, + }, + permissions: 'jwt-introspection', + httpTimeout: config.httpTimeout, }, - 'pay_invoice' + options ); - - if (payment.lnInvoicePaymentSend.errors?.length) { - throw blinkErrorsToNwcError( - payment.lnInvoicePaymentSend.errors, - 'pay_invoice', - 'PAYMENT_FAILED' - ); - } - - if (payment.lnInvoicePaymentSend.status !== 'SUCCESS') { - const status = payment.lnInvoicePaymentSend.status; - throw blinkNwcError( - status === 'FAILED' ? 'PAYMENT_FAILED' : 'OTHER', - `Blink payment failed with status ${status}`, - 'pay_invoice', - { code: status, message: status } - ); - } - - const preimage = payment.lnInvoicePaymentSend.transaction?.settlementVia?.preImage ?? ''; - let paymentHash = ''; - try { - paymentHash = decodeBolt11(params.invoice).payment_hash ?? ''; - } catch { - // The payment succeeded, so preserve the provider result even if the invoice cannot decode. - } - return { - paymentHash, - preimage, - feeMsats: satsToMsats(feeProbe.lnInvoiceFeeProbe.amount ?? 0), - }; } - async prepareOnchainTransaction( - params: PrepareOnchainTransactionParams - ): Promise { - const amountSats = params.amountSats; - assertValidOnchainAmount(amountSats); + getPermissions(): Promise { + return this.node.getPermissions(); + } - const fee = params.fee ?? defaultOnchainFee(); - const feePayer = resolveBlinkFeePayer(params.feePayer); - const speed = resolveBlinkFeeSpeed(fee); - const walletId = await this.getBtcWalletId(); + getInfo(): Promise { + return this.node.getInfo(); + } - const response = await this.gql( - ` - query onChainTxFee($walletId: WalletId!, $address: OnChainAddress!, $amount: SatAmount!, $speed: PayoutSpeed!) { - onChainTxFee(walletId: $walletId, address: $address, amount: $amount, speed: $speed) { - amount - } - } - `, - { - walletId, - address: params.address, - amount: amountSats, - speed, - }, - 'pay_invoice' - ); + createInvoice(params: CreateInvoiceParams): Promise { + return this.node.createInvoice(params); + } - const feeSats = response.onChainTxFee.amount; + payInvoice(params: PayInvoiceParams): Promise { + return this.node.payInvoice(params); + } - return { - address: params.address, - amountSats, - feeSats, - totalAmountSats: feeSats === undefined ? undefined : amountSats + feeSats, - recipientAmountSats: amountSats, - feePayer, - fee, - raw: { - walletId, - speed, - memo: params.description, - fee: response.onChainTxFee, - }, - }; + prepareOnchainTransaction(params: PrepareOnchainTransactionParams): Promise { + return this.node.prepareOnchainTransaction(params); } - async payOnchain( + payOnchain( transaction: OnchainTransaction, options?: PayOnchainOptions ): Promise { - assertValidOnchainAmount(transaction.amountSats); - resolveBlinkFeePayer(transaction.feePayer); - const speed = resolveBlinkFeeSpeed(transaction.fee); - assertOnchainFeeGuardrail(transaction, options); - - const walletId = await this.getBtcWalletId(); - const memo = blinkTransactionMemo(transaction); - const payment = await this.gql( - ` - mutation onChainPaymentSend($input: OnChainPaymentSendInput!) { - onChainPaymentSend(input: $input) { - status - transaction { - id - settlementAmount - settlementCurrency - settlementFee - settlementVia { - __typename - ... on SettlementViaOnChain { - transactionHash - } - } - } - errors { - code - message - path - } - } - } - `, - { - input: { - address: transaction.address, - amount: transaction.amountSats, - walletId, - memo, - speed, - }, - }, - 'pay_invoice' - ); - - if (payment.onChainPaymentSend.errors?.length) { - throw blinkErrorsToNwcError( - payment.onChainPaymentSend.errors, - 'pay_invoice', - 'PAYMENT_FAILED' - ); - } - - const paymentTransaction = payment.onChainPaymentSend.transaction; - const feeSats = - blinkTransactionAmountToSats( - paymentTransaction?.settlementFee, - paymentTransaction?.settlementCurrency - ) ?? transaction.feeSats; - const amountSats = - blinkTransactionAmountToSats( - paymentTransaction?.settlementAmount, - paymentTransaction?.settlementCurrency - ) ?? transaction.amountSats; - - return { - paymentId: paymentTransaction?.id, - txid: paymentTransaction?.settlementVia?.transactionHash, - state: normalizeOnchainState(payment.onChainPaymentSend.status), - address: transaction.address, - amountSats, - feeSats, - totalAmountSats: feeSats === undefined ? transaction.totalAmountSats : amountSats + feeSats, - recipientAmountSats: transaction.recipientAmountSats ?? transaction.amountSats, - raw: payment.onChainPaymentSend, - }; - } - - async createOffer(_params: CreateOfferParams): Promise { - throw blinkNwcError( - 'NOT_IMPLEMENTED', - 'Bolt12 is not implemented for BlinkNode.', - 'make_invoice' - ); - } - - async getOffer(_search?: string): Promise { - throw blinkNwcError( - 'NOT_IMPLEMENTED', - 'Bolt12 is not implemented for BlinkNode.', - 'lookup_invoice' - ); + return this.node.payOnchain(transaction, options); } - async listOffers(_search?: string): Promise { - throw blinkNwcError( - 'NOT_IMPLEMENTED', - 'Bolt12 is not implemented for BlinkNode.', - 'list_transactions' - ); + createOffer(params: CreateOfferParams): Promise { + return this.node.createOffer(params); } - async payOffer( - _offer: string, - _amountMsats: number, - _payerNote?: string - ): Promise { - throw blinkNwcError( - 'NOT_IMPLEMENTED', - 'Bolt12 is not implemented for BlinkNode.', - 'pay_invoice' - ); + getOffer(search?: string): Promise { + return this.node.getOffer(search); } - private mapTransaction(node: BlinkTransactionNode): Transaction { - const paymentHash = - node.initiationVia?.__typename === 'InitiationViaLn' - ? (node.initiationVia.paymentHash ?? '') - : ''; - const preimage = - node.settlementVia?.__typename === 'SettlementViaLn' - ? (node.settlementVia.preImage ?? '') - : ''; - - const amountMsats = - node.settlementCurrency === 'BTC' ? satsToMsats(Math.abs(node.settlementAmount ?? 0)) : 0; - const feeMsats = - node.settlementCurrency === 'BTC' ? satsToMsats(Math.abs(node.settlementFee ?? 0)) : 0; - - return emptyTransaction({ - type: node.direction === 'SEND' ? 'outgoing' : 'incoming', - paymentHash, - preimage, - amountMsats, - feesPaid: feeMsats, - createdAt: node.createdAt, - settledAt: node.status === 'SUCCESS' ? node.createdAt : 0, - description: node.memo ?? '', - descriptionHash: '', - payerNote: '', - externalId: node.id, - }); + listOffers(search?: string): Promise { + return this.node.listOffers(search); } - private async listTransactionsPage(args: { - first: number; - after?: string | null; - paymentHash?: string; - search?: string; - }): Promise { - const response: BlinkTransactionsQuery = await this.gql( - BlinkNode.TRANSACTIONS_QUERY, - { - first: Math.max(args.first, 1), - after: args.after ?? null, - }, - args.paymentHash ? 'lookup_invoice' : 'list_transactions' - ); - - const page: BlinkTransactionsQuery['me']['defaultAccount']['transactions'] = - response.me.defaultAccount.transactions; - const edges = page.edges; - const transactions = edges - .map(({ node }) => this.mapTransaction(node)) - .filter((tx) => { - if (args.paymentHash && tx.paymentHash !== args.paymentHash) { - return false; - } - return matchesSearch(tx, args.search); - }); - - if (!page.pageInfo.hasNextPage) { - return { - transactions, - nextCursor: null, - }; - } - - const nextCursor: string | null = - page.pageInfo.endCursor ?? edges[edges.length - 1]?.cursor ?? null; - return { - transactions, - nextCursor: nextCursor && nextCursor !== args.after ? nextCursor : null, - }; + payOffer(offer: string, amountMsats: number, payerNote?: string): Promise { + return this.node.payOffer(offer, amountMsats, payerNote); } - async lookupInvoice(params: LookupInvoiceParams): Promise { - if (!params.paymentHash) { - throw new LniError('InvalidInput', 'lookupInvoice requires paymentHash for BlinkNode.'); - } - - let after: string | null = null; - - while (true) { - const page = await this.listTransactionsPage({ - first: 100, - after, - paymentHash: params.paymentHash, - search: params.search, - }); - - const match = page.transactions.find((tx) => tx.paymentHash === params.paymentHash); - if (match) { - return match; - } - - if (!page.nextCursor) { - break; - } - - after = page.nextCursor; - } - - throw blinkNwcError( - 'NOT_FOUND', - `Transaction not found for payment hash: ${params.paymentHash}`, - 'lookup_invoice' - ); + lookupInvoice(params: LookupInvoiceParams): Promise { + return this.node.lookupInvoice(params); } - async listTransactions(params: ListTransactionsParams): Promise { - const limit = - params.limit > 0 - ? params.limit - : Math.min(BlinkNode.MAX_TRANSACTION_FETCH, BlinkNode.DEFAULT_PAGE_SIZE * 10); - const from = Math.max(params.from, 0); - const pageSize = Math.max(Math.min(limit, BlinkNode.DEFAULT_PAGE_SIZE), 1); - - let after: string | null = null; - let skipped = 0; - const transactions: Transaction[] = []; - - while (transactions.length < limit) { - const page = await this.listTransactionsPage({ - first: pageSize, - after, - paymentHash: params.paymentHash, - search: params.search, - }); - if (!page.transactions.length && !page.nextCursor) { - break; - } - - for (const tx of page.transactions) { - if (skipped < from) { - skipped += 1; - continue; - } - - transactions.push(tx); - if (transactions.length >= limit) { - break; - } - } - - if (!page.nextCursor) { - break; - } - - after = page.nextCursor; - } - - return transactions; + listTransactions(params: ListTransactionsParams): Promise { + return this.node.listTransactions(params); } - async decode(str: string): Promise { - return decodeBolt11ToJson(str); + decode(str: string): Promise { + return this.node.decode(str); } - async decodeOffer(offer: string): Promise { - return decodeOfferToJson(offer); + decodeOffer(offer: string): Promise { + return this.node.decodeOffer(offer); } - async onInvoiceEvents( - params: OnInvoiceEventParams, - callback: InvoiceEventCallback - ): Promise { - await pollInvoiceEvents({ - params, - callback, - lookup: () => this.lookupInvoice({ paymentHash: params.paymentHash, search: params.search }), - }); + onInvoiceEvents(params: OnInvoiceEventParams, callback: InvoiceEventCallback): Promise { + return this.node.onInvoiceEvents(params, callback); } } diff --git a/bindings/typescript/src/nodes/flash.ts b/bindings/typescript/src/nodes/flash.ts new file mode 100644 index 00000000..e86c3e52 --- /dev/null +++ b/bindings/typescript/src/nodes/flash.ts @@ -0,0 +1,144 @@ +import { createGaloyNode, type GaloyNode } from './galoy.js'; +import type { + CreateInvoiceParams, + CreateOfferParams, + FlashConfig, + GaloyPaymentOutcome, + InvoiceEventCallback, + LightningNode, + ListTransactionsParams, + LookupInvoiceParams, + NodeInfo, + NodeRequestOptions, + Offer, + OnInvoiceEventParams, + PayInvoiceParams, + PayInvoiceResponse, + Permissions, + Transaction, +} from '../types.js'; + +export const DEFAULT_FLASH_GRAPHQL_URL = 'https://api.flashapp.me/graphql'; + +function flashFeeProbeOperation(walletCurrency: string) { + switch (walletCurrency.toUpperCase()) { + case 'BTC': + return { kind: 'btc', denomination: 'sats' } as const; + case 'USD': + return { kind: 'usd', denomination: 'usd-cents' } as const; + default: + return { kind: 'unsupported' } as const; + } +} + +/** + * Flash adapter backed by the generic Galoy GraphQL implementation. + * + * Use `createGaloyNode` directly when a Flash deployment exposes capabilities + * beyond these conservative Flash defaults. + * + * Accepted proofless statuses can resolve with an empty preimage because + * status-only Galoy responses do not include proof data. Use + * `payInvoiceWithStatus()` when a resolved `PENDING` payment must remain + * distinguishable from settlement. + */ +export class FlashNode implements LightningNode { + private readonly node: GaloyNode; + + constructor(config: FlashConfig, options: NodeRequestOptions = {}) { + this.node = createGaloyNode( + { + apiKey: config.apiKey, + baseUrl: config.baseUrl ?? DEFAULT_FLASH_GRAPHQL_URL, + provider: { + id: 'flash', + name: 'Flash', + }, + wallet: { + mode: 'explicit', + id: config.walletId, + currency: config.walletCurrency, + }, + invoiceOperations: { + create: { kind: 'unsupported' }, + feeProbe: flashFeeProbeOperation(config.walletCurrency), + }, + payment: { + response: 'status-only', + acceptedStatuses: config.acceptedStatuses ?? ['SUCCESS', 'PENDING', 'ALREADY_PAID'], + statusMapping: { + settled: ['SUCCESS', 'ALREADY_PAID'], + pending: ['PENDING'], + }, + proofUnavailableErrorCodes: ['PROOF_UNAVAILABLE'], + }, + capabilities: { + transactionLookup: false, + transactionHistory: false, + invoiceEvents: false, + onchain: false, + }, + permissions: 'configured', + additionalHeaders: config.additionalHeaders, + httpTimeout: config.httpTimeout, + }, + options + ); + } + + getPermissions(): Promise { + return this.node.getPermissions(); + } + + getInfo(): Promise { + return this.node.getInfo(); + } + + createInvoice(params: CreateInvoiceParams): Promise { + return this.node.createInvoice(params); + } + + payInvoice(params: PayInvoiceParams): Promise { + return this.node.payInvoice(params); + } + + payInvoiceWithStatus(params: PayInvoiceParams): Promise { + return this.node.payInvoiceWithStatus(params); + } + + createOffer(params: CreateOfferParams): Promise { + return this.node.createOffer(params); + } + + getOffer(search?: string): Promise { + return this.node.getOffer(search); + } + + listOffers(search?: string): Promise { + return this.node.listOffers(search); + } + + payOffer(offer: string, amountMsats: number, payerNote?: string): Promise { + return this.node.payOffer(offer, amountMsats, payerNote); + } + + lookupInvoice(params: LookupInvoiceParams): Promise { + return this.node.lookupInvoice(params); + } + + listTransactions(params: ListTransactionsParams): Promise { + return this.node.listTransactions(params); + } + + decode(str: string): Promise { + return this.node.decode(str); + } + + decodeOffer(offer: string): Promise { + return this.node.decodeOffer(offer); + } + + onInvoiceEvents(params: OnInvoiceEventParams, callback: InvoiceEventCallback): Promise { + return this.node.onInvoiceEvents(params, callback); + } +} diff --git a/bindings/typescript/src/nodes/galoy.ts b/bindings/typescript/src/nodes/galoy.ts new file mode 100644 index 00000000..ff637b8a --- /dev/null +++ b/bindings/typescript/src/nodes/galoy.ts @@ -0,0 +1,1349 @@ +import { LniError, NwcError, type NwcErrorCode, type NwcErrorOperation } from '../errors.js'; +import { decode as decodeBolt11, decodeBolt11ToJson, decodeOfferToJson } from '../decode.js'; +import { + mapProviderMessage, + throwNormalizedProviderError, + type ProviderErrorInfo, +} from '../internal/error-normalization.js'; +import { requestJson, resolveFetch, toTimeoutMs } from '../internal/http.js'; +import { getGaloyTokenPermissions } from '../internal/permissions.js'; +import { pollInvoiceEvents } from '../internal/polling.js'; +import { + emptyNodeInfo, + emptyTransaction, + matchesSearch, + satsToMsats, +} from '../internal/transform.js'; +import { + DEFAULT_ONCHAIN_FEE_GUARDRAIL, + InvoiceType, + type GaloyConfig, + type GaloyInvoiceOperation, + type GaloyPaymentOutcome, + type CreateInvoiceParams, + type CreateOfferParams, + type InvoiceEventCallback, + type LightningNode, + type ListTransactionsParams, + type LookupInvoiceParams, + type NodeInfo, + type NodeRequestOptions, + type Offer, + type OnInvoiceEventParams, + type OnchainFeeGuardrail, + type OnchainFeePayer, + type OnchainFeePreference, + type OnchainPayments, + type OnchainTransaction, + type PayInvoiceParams, + type PayInvoiceResponse, + type PayOnchainOptions, + type PayOnchainResponse, + type Permissions, + type PrepareOnchainTransactionParams, + type Transaction, +} from '../types.js'; + +interface GraphQLError { + message: string; + code?: string; + path?: string[]; +} + +interface GraphQLResponse { + data?: T; + errors?: GraphQLError[]; +} + +interface GaloyMeQuery { + me: { + defaultAccount: { + wallets: GaloyWallet[]; + }; + }; +} + +interface GaloyWallet { + id: string; + walletCurrency: string; + balance?: number; +} + +interface GaloyInvoiceCreateResult { + invoice?: { + paymentRequest: string; + paymentHash: string; + satoshis: number; + }; + errors?: GraphQLError[]; +} + +interface GaloyInvoiceCreateResponse { + lnInvoiceCreate?: GaloyInvoiceCreateResult; + lnUsdInvoiceCreate?: GaloyInvoiceCreateResult; +} + +interface GaloyFeeProbeResult { + amount?: number; + errors?: GraphQLError[]; +} + +interface GaloyFeeProbeResponse { + lnInvoiceFeeProbe?: GaloyFeeProbeResult; + lnUsdInvoiceFeeProbe?: GaloyFeeProbeResult; +} + +interface GaloyPaymentSendResponse { + lnInvoicePaymentSend?: { + status: string; + transaction?: { + settlementVia?: { + preImage?: string; + }; + }; + errors?: GraphQLError[]; + }; +} + +interface GaloyOnchainTxFeeResponse { + onChainTxFee?: { + amount?: number; + }; +} + +interface GaloyOnchainPaymentSendResponse { + onChainPaymentSend?: { + status: string; + transaction?: { + id?: string; + settlementAmount?: number; + settlementCurrency?: string; + settlementFee?: number; + settlementVia?: { + __typename: string; + transactionHash?: string; + }; + }; + errors?: GraphQLError[]; + }; +} + +type GaloyInvoiceOperationFamily = { + createField: 'lnInvoiceCreate' | 'lnUsdInvoiceCreate'; + createInput: 'LnInvoiceCreateInput' | 'LnUsdInvoiceCreateInput'; + feeField: 'lnInvoiceFeeProbe' | 'lnUsdInvoiceFeeProbe'; + feeInput: 'LnInvoiceFeeProbeInput' | 'LnUsdInvoiceFeeProbeInput'; +}; + +function invoiceOperationFamily( + operation: Exclude +): GaloyInvoiceOperationFamily { + if (operation.kind === 'btc') { + return { + createField: 'lnInvoiceCreate', + createInput: 'LnInvoiceCreateInput', + feeField: 'lnInvoiceFeeProbe', + feeInput: 'LnInvoiceFeeProbeInput', + }; + } + + return { + createField: 'lnUsdInvoiceCreate', + createInput: 'LnUsdInvoiceCreateInput', + feeField: 'lnUsdInvoiceFeeProbe', + feeInput: 'LnUsdInvoiceFeeProbeInput', + }; +} + +interface GaloyTransactionsQuery { + me: { + defaultAccount: { + transactions: { + edges: Array<{ + cursor: string; + node: { + id: string; + createdAt: number; + direction: 'SEND' | 'RECEIVE'; + status: string; + memo?: string; + settlementAmount?: number; + settlementCurrency?: string; + settlementFee?: number; + initiationVia?: { + __typename: string; + paymentHash?: string; + }; + settlementVia?: { + __typename: string; + preImage?: string; + }; + }; + }>; + pageInfo: { + hasNextPage: boolean; + endCursor?: string | null; + }; + }; + }; + }; +} + +type GaloyTransactionNode = + GaloyTransactionsQuery['me']['defaultAccount']['transactions']['edges'][number]['node']; + +interface GaloyTransactionsPage { + transactions: Transaction[]; + nextCursor: string | null; + scannedCount: number; +} + +function defaultOnchainFee(): OnchainFeePreference { + return { type: 'default' }; +} + +function resolveGaloyFeeSpeed(fee: OnchainFeePreference): 'FAST' | 'MEDIUM' | 'SLOW' { + if (fee.type === 'default') { + return 'FAST'; + } + + if (fee.type !== 'speed') { + throw new LniError( + 'InvalidInput', + `Galoy on-chain payments do not support ${fee.type} fee preferences.` + ); + } + + switch (fee.speed) { + case 'fast': + return 'FAST'; + case 'normal': + return 'MEDIUM'; + case 'slow': + return 'SLOW'; + case 'free': + throw new LniError( + 'InvalidInput', + 'Galoy on-chain payments do not support free on-chain fee speed.' + ); + } +} + +function resolveGaloyFeePayer(feePayer?: OnchainFeePayer): OnchainFeePayer { + if (feePayer === 'recipient') { + throw new LniError('InvalidInput', 'Galoy on-chain payments only support sender-paid fees.'); + } + + return 'sender'; +} + +function normalizeOnchainState(state?: string): PayOnchainResponse['state'] { + switch (state?.toUpperCase()) { + case 'PENDING': + return 'pending'; + case 'SUCCESS': + case 'ALREADY_PAID': + return 'completed'; + case 'FAILED': + case 'FAILURE': + return 'failed'; + default: + return state?.toLowerCase() ?? 'pending'; + } +} + +function assertValidOnchainAmount(amountSats: number): void { + if (!Number.isSafeInteger(amountSats) || amountSats <= 0) { + throw new LniError('InvalidInput', 'payOnchain requires a positive integer amountSats.'); + } +} + +function assertValidGuardrailLimit(value: number, name: string): void { + if (!Number.isFinite(value) || value < 0) { + throw new LniError('InvalidInput', `${name} must be a non-negative finite number.`); + } + + if (name.endsWith('maxFeeSats') && !Number.isSafeInteger(value)) { + throw new LniError('InvalidInput', `${name} must be a safe integer.`); + } +} + +function resolveOnchainFeeGuardrail( + options?: PayOnchainOptions +): Required | undefined { + if (options?.dangerouslyDisableFeeGuardrail) { + return undefined; + } + + const guardrail = { + maxFeeSats: options?.feeGuardrail?.maxFeeSats ?? DEFAULT_ONCHAIN_FEE_GUARDRAIL.maxFeeSats, + maxFeePercent: + options?.feeGuardrail?.maxFeePercent ?? DEFAULT_ONCHAIN_FEE_GUARDRAIL.maxFeePercent, + }; + + assertValidGuardrailLimit(guardrail.maxFeeSats, 'feeGuardrail.maxFeeSats'); + assertValidGuardrailLimit(guardrail.maxFeePercent, 'feeGuardrail.maxFeePercent'); + + return guardrail; +} + +function assertOnchainFeeGuardrail( + transaction: OnchainTransaction, + options?: PayOnchainOptions +): void { + const guardrail = resolveOnchainFeeGuardrail(options); + if (!guardrail) { + return; + } + + const { feeSats } = transaction; + if (feeSats === undefined) { + throw new LniError( + 'InvalidInput', + 'Cannot pay on-chain transaction because feeSats is unknown. Re-prepare the transaction or pass dangerouslyDisableFeeGuardrail: true.' + ); + } + + if (!Number.isSafeInteger(feeSats) || feeSats < 0) { + throw new LniError( + 'InvalidInput', + 'Cannot pay on-chain transaction because feeSats is invalid.' + ); + } + + if (!Number.isSafeInteger(transaction.amountSats) || transaction.amountSats <= 0) { + throw new LniError( + 'InvalidInput', + 'Cannot pay on-chain transaction because amountSats is invalid.' + ); + } + + if (feeSats > guardrail.maxFeeSats) { + throw new LniError( + 'InvalidInput', + `On-chain fee ${feeSats} sats exceeds guardrail maxFeeSats ${guardrail.maxFeeSats}.` + ); + } + + const feePercent = (feeSats / transaction.amountSats) * 100; + if (feePercent > guardrail.maxFeePercent) { + throw new LniError( + 'InvalidInput', + `On-chain fee ${feePercent.toFixed(2)}% exceeds guardrail maxFeePercent ${guardrail.maxFeePercent}%.` + ); + } +} + +function galoyTransactionAmountToSats( + amount: number | undefined, + currency: string | undefined +): number | undefined { + return currency === 'BTC' && amount !== undefined ? Math.abs(amount) : undefined; +} + +function galoyTransactionMemo(transaction: OnchainTransaction): string | undefined { + const raw = transaction.raw; + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return undefined; + } + + const memo = (raw as { memo?: unknown }).memo; + return typeof memo === 'string' && memo.length > 0 ? memo : undefined; +} + +function galoyProviderInfoFromErrors(errors: GraphQLError[]): ProviderErrorInfo { + const firstActionable = errors.find((error) => mapGaloyCode(error.code) !== undefined); + const firstCoded = errors.find((error) => error.code !== undefined); + const selected = firstActionable ?? firstCoded ?? errors[0]; + return { + code: selected?.code, + message: errors.map((error) => error.message).join(', '), + }; +} + +function mapGaloyCode(code: unknown): NwcErrorCode | undefined { + const normalized = String(code ?? '').toUpperCase(); + + if (normalized.includes('AUTHENTICATION') || normalized.includes('UNAUTHORIZED')) { + return 'UNAUTHORIZED'; + } + if ( + normalized.includes('FORBIDDEN') || + normalized.includes('PERMISSION') || + normalized.includes('SCOPE') + ) { + return 'RESTRICTED'; + } + if (normalized.includes('INSUFFICIENT') || normalized.includes('BALANCE')) { + return 'INSUFFICIENT_BALANCE'; + } + if (normalized.includes('LIMIT') || normalized.includes('QUOTA')) { + return 'QUOTA_EXCEEDED'; + } + if ( + normalized.includes('INVALID_INVOICE') || + normalized.includes('NO_ROUTE') || + normalized.includes('PAYMENT') + ) { + return 'PAYMENT_FAILED'; + } + + return undefined; +} + +function mapGaloyProviderError(info: ProviderErrorInfo): NwcErrorCode | undefined { + const code = mapGaloyCode(info.code); + if (code) { + return code; + } + + return mapProviderMessage(info.message); +} + +function redactSensitiveText(value: string): string { + return value + .replace( + /("(?:apiKey|api_key|x-api-key|authorization|token|paymentRequest|payment_request|paymentHash|payment_hash|paymentSecret|payment_secret|preImage|preimage)"\s*:\s*")[^"]*(")/gi, + '$1$2' + ) + .replace( + /\b(api[_-]?key|x-api-key|authorization|access[_-]?token|token|payment[_-]?request|payment[_-]?hash|payment[_-]?secret|preimage)\s*[:=]\s*[^\s,;]+/gi, + '$1=' + ) + .replace(/\b(?:(?:lnbc|lntb|lnbcrt)[a-z0-9]+|(?:lno|lnr|lni)1[a-z0-9]+)\b/gi, '') + .replace(/\b[0-9a-f]{64}\b/gi, ''); +} + +class GaloyNodeImplementation implements LightningNode, OnchainPayments { + private readonly fetchFn; + private readonly timeoutMs?: number; + private readonly baseUrl: string; + private cachedWallet?: GaloyWallet; + private static readonly MAX_TRANSACTION_FETCH = 1000; + private static readonly DEFAULT_PAGE_SIZE = 100; + + private static readonly ME_QUERY = ` + query Me { + me { + defaultAccount { + wallets { + id + walletCurrency + balance + } + } + } + } + `; + + private static readonly TRANSACTIONS_QUERY = ` + query TransactionsQuery($first: Int, $after: String) { + me { + defaultAccount { + transactions(first: $first, after: $after) { + edges { + cursor + node { + id + createdAt + direction + status + memo + settlementAmount + settlementCurrency + settlementFee + initiationVia { + __typename + ... on InitiationViaLn { + paymentHash + } + } + settlementVia { + __typename + ... on SettlementViaLn { + preImage + } + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + } + `; + + constructor( + private readonly config: GaloyConfig, + options: NodeRequestOptions = {} + ) { + this.fetchFn = resolveFetch(options.fetch); + this.timeoutMs = toTimeoutMs(config.httpTimeout); + this.baseUrl = config.baseUrl; + } + + private headers(): Headers { + const headers = new Headers(this.config.additionalHeaders); + headers.set('x-api-key', this.config.apiKey); + headers.set('content-type', 'application/json'); + return headers; + } + + private sanitizeText(value: string): string { + const sanitized = redactSensitiveText(value); + return this.config.apiKey ? sanitized.split(this.config.apiKey).join('') : sanitized; + } + + private nwcError( + code: NwcErrorCode, + message: string, + operation: NwcErrorOperation, + info?: ProviderErrorInfo + ): NwcError { + const safeMessage = this.sanitizeText(message); + return new NwcError(code, safeMessage, { + operation, + provider: this.config.provider.id, + providerCode: typeof info?.code === 'string' ? this.sanitizeText(info.code) : info?.code, + providerStatus: info?.status, + providerMessage: this.sanitizeText(info?.message ?? safeMessage), + }); + } + + private errorsToNwcError( + errors: GraphQLError[], + operation: NwcErrorOperation, + fallbackCode: NwcErrorCode = 'OTHER' + ): NwcError { + const info = galoyProviderInfoFromErrors(errors); + return this.nwcError( + mapGaloyProviderError(info) ?? fallbackCode, + info.message ?? `${this.config.provider.name} GraphQL error`, + operation, + info + ); + } + + private throwProviderError(error: unknown, operation: NwcErrorOperation): never { + let safeError = error; + if (error instanceof LniError) { + safeError = new LniError(error.code, this.sanitizeText(error.message), { + status: error.status, + body: error.body ? this.sanitizeText(error.body) : undefined, + }); + } + throwNormalizedProviderError(safeError, { + provider: this.config.provider.id, + operation, + mapProviderError: mapGaloyProviderError, + }); + } + + private async gql( + query: string, + variables: Record | undefined, + operation: NwcErrorOperation + ): Promise { + let payload: GraphQLResponse; + try { + payload = await requestJson>(this.fetchFn, this.baseUrl, { + method: 'POST', + headers: this.headers(), + json: { + query, + variables, + }, + timeoutMs: this.timeoutMs, + }); + } catch (error) { + this.throwProviderError(error, operation); + } + + if (payload.errors?.length) { + throw this.errorsToNwcError(payload.errors, operation); + } + + if (!payload.data) { + throw this.nwcError( + 'INTERNAL', + `No data in ${this.config.provider.name} GraphQL response.`, + operation + ); + } + + return payload.data; + } + + private async resolveWallet(refresh = false): Promise { + if (!refresh && this.cachedWallet) { + return this.cachedWallet; + } + + if (this.config.wallet.mode === 'explicit') { + this.cachedWallet = { + id: this.config.wallet.id, + walletCurrency: this.config.wallet.currency, + }; + return this.cachedWallet; + } + + const requestedCurrency = this.config.wallet.currency; + const response = await this.gql( + GaloyNodeImplementation.ME_QUERY, + undefined, + 'get_info' + ); + const wallet = response.me.defaultAccount.wallets.find( + (item) => item.walletCurrency.toUpperCase() === requestedCurrency.toUpperCase() + ); + + if (!wallet) { + throw this.nwcError( + 'NOT_FOUND', + `No ${requestedCurrency} wallet found for ${this.config.provider.name}.`, + 'get_info' + ); + } + + this.cachedWallet = wallet; + return wallet; + } + + private notImplemented(message: string, operation: NwcErrorOperation): NwcError { + return this.nwcError('NOT_IMPLEMENTED', message, operation); + } + + private canCreateInvoice(): boolean { + return this.config.invoiceOperations.create.kind === 'btc'; + } + + private assertOnchainEnabled(operation: NwcErrorOperation): void { + if (!this.config.capabilities.onchain) { + throw this.notImplemented( + `On-chain payments are disabled for ${this.config.provider.name}.`, + operation + ); + } + } + + private async resolveBtcOnchainWallet(operation: NwcErrorOperation): Promise { + this.assertOnchainEnabled(operation); + const wallet = await this.resolveWallet(); + if (wallet.walletCurrency.toUpperCase() !== 'BTC') { + throw this.notImplemented( + `On-chain payments require a BTC wallet for ${this.config.provider.name}.`, + operation + ); + } + return wallet; + } + + async getPermissions(): Promise { + if (this.config.permissions === 'configured') { + return { + getInfo: true, + createInvoice: this.canCreateInvoice(), + payInvoice: true, + createOffer: false, + getOffer: false, + listOffers: false, + payOffer: false, + lookupInvoice: this.config.capabilities.transactionLookup, + listTransactions: this.config.capabilities.transactionHistory, + decode: true, + onInvoiceEvents: this.config.capabilities.invoiceEvents, + }; + } + + const permissions = getGaloyTokenPermissions(this.config.apiKey); + if (!permissions) { + throw new LniError( + 'InvalidInput', + `${this.config.provider.name} API keys cannot be introspected. Use a JWT-style token with scopes or configure permissions explicitly.` + ); + } + + return { + ...permissions, + createInvoice: permissions.createInvoice && this.canCreateInvoice(), + lookupInvoice: permissions.lookupInvoice && this.config.capabilities.transactionLookup, + listTransactions: permissions.listTransactions && this.config.capabilities.transactionHistory, + onInvoiceEvents: permissions.onInvoiceEvents && this.config.capabilities.invoiceEvents, + }; + } + + async getInfo(): Promise { + // Wallet identity is stable, but account discovery also returns the mutable + // balance used by getInfo. Refresh currency-selected wallets on every call. + const wallet = await this.resolveWallet(this.config.wallet.mode === 'currency'); + const sats = + wallet.walletCurrency.toUpperCase() === 'BTC' && wallet.balance !== undefined + ? wallet.balance + : 0; + + return emptyNodeInfo({ + alias: `${this.config.provider.name} Node`, + network: 'mainnet', + sendBalanceMsat: satsToMsats(sats), + receiveBalanceMsat: satsToMsats(sats), + }); + } + + async createInvoice(params: CreateInvoiceParams): Promise { + if ((params.invoiceType ?? InvoiceType.Bolt11) !== InvoiceType.Bolt11) { + throw this.notImplemented( + `Bolt12 is not implemented for ${this.config.provider.name}.`, + 'make_invoice' + ); + } + + const operation = this.config.invoiceOperations.create; + if (operation.kind === 'unsupported') { + throw this.notImplemented( + `Invoice creation is disabled for ${this.config.provider.name}.`, + 'make_invoice' + ); + } + if (operation.kind === 'usd') { + throw this.notImplemented( + `${this.config.provider.name} USD invoice creation requires a USD-cent amount, which cannot be represented safely by LNI's amountMsats input.`, + 'make_invoice' + ); + } + + const wallet = await this.resolveWallet(); + const family = invoiceOperationFamily(operation); + + const query = ` + mutation ${family.createField}($input: ${family.createInput}!) { + ${family.createField}(input: $input) { + invoice { + paymentRequest + paymentHash + satoshis + } + errors { + code + message + path + } + } + } + `; + + const response = await this.gql( + query, + { + input: { + amount: Math.floor((params.amountMsats ?? 0) / 1000), + walletId: wallet.id, + memo: params.description, + }, + }, + 'make_invoice' + ); + + const result = response[family.createField]; + if (!result) { + throw this.nwcError( + 'INTERNAL', + `No ${family.createField} result returned from ${this.config.provider.name}.`, + 'make_invoice' + ); + } + if (result.errors?.length) { + throw this.errorsToNwcError(result.errors, 'make_invoice'); + } + + const invoice = result.invoice; + if (!invoice) { + throw this.nwcError( + 'INTERNAL', + `No invoice returned from ${this.config.provider.name} invoice creation.`, + 'make_invoice' + ); + } + + return emptyTransaction({ + type: 'incoming', + invoice: invoice.paymentRequest, + paymentHash: invoice.paymentHash, + amountMsats: satsToMsats(invoice.satoshis), + createdAt: Math.floor(Date.now() / 1000), + description: params.description ?? '', + descriptionHash: params.descriptionHash ?? '', + payerNote: '', + externalId: '', + }); + } + + async payInvoice(params: PayInvoiceParams): Promise { + return (await this.payInvoiceWithStatus(params)).payment; + } + + async payInvoiceWithStatus(params: PayInvoiceParams): Promise { + try { + if (decodeBolt11(params.invoice).amountMsats === undefined) { + throw new LniError( + 'InvalidInput', + `${this.config.provider.name} cannot pay amountless BOLT11 invoices because Galoy's payment mutation has no amount field.` + ); + } + } catch (error) { + if (error instanceof LniError) { + throw error; + } + // Let the provider return its normal invalid-invoice error when decoding fails. + } + + const wallet = await this.resolveWallet(); + const feeProbeOperation = this.config.invoiceOperations.feeProbe; + if (feeProbeOperation.kind === 'unsupported') { + throw this.notImplemented( + `Lightning fee probing is not configured for ${this.config.provider.name} wallet currency ${wallet.walletCurrency}.`, + 'pay_invoice' + ); + } + const family = invoiceOperationFamily(feeProbeOperation); + + const feeProbe = await this.gql( + ` + mutation ${family.feeField}($input: ${family.feeInput}!) { + ${family.feeField}(input: $input) { + errors { + code + message + path + } + amount + } + } + `, + { + input: { + paymentRequest: params.invoice, + walletId: wallet.id, + }, + }, + 'pay_invoice' + ); + + const feeResult = feeProbe[family.feeField]; + if (!feeResult) { + throw this.nwcError( + 'INTERNAL', + `No ${family.feeField} result returned from ${this.config.provider.name}.`, + 'pay_invoice' + ); + } + if (feeResult.errors?.length) { + throw this.errorsToNwcError(feeResult.errors, 'pay_invoice', 'PAYMENT_FAILED'); + } + + const transactionSelection = + this.config.payment.response === 'transaction-with-preimage' + ? ` + transaction { + settlementVia { + ... on SettlementViaLn { + preImage + } + ... on SettlementViaIntraLedger { + preImage + } + } + } + ` + : ''; + const payment = await this.gql( + ` + mutation LnInvoicePaymentSend($input: LnInvoicePaymentInput!) { + lnInvoicePaymentSend(input: $input) { + status + errors { + code + message + path + } + ${transactionSelection} + } + } + `, + { + input: { + paymentRequest: params.invoice, + walletId: wallet.id, + }, + }, + 'pay_invoice' + ); + + const paymentResult = payment.lnInvoicePaymentSend; + if (!paymentResult) { + throw this.nwcError( + 'INTERNAL', + `No lnInvoicePaymentSend result returned from ${this.config.provider.name}.`, + 'pay_invoice' + ); + } + + const status = paymentResult.status; + if (!this.config.payment.acceptedStatuses.includes(status)) { + const providerInfo = paymentResult.errors?.length + ? galoyProviderInfoFromErrors(paymentResult.errors) + : undefined; + throw this.nwcError( + providerInfo + ? (mapGaloyProviderError(providerInfo) ?? 'PAYMENT_FAILED') + : status === 'FAILED' || status === 'FAILURE' + ? 'PAYMENT_FAILED' + : 'OTHER', + `${this.config.provider.name} payment was not accepted: ${status}${ + providerInfo?.message ? ` (${providerInfo.message})` : '' + }`, + 'pay_invoice', + { + code: status, + message: providerInfo?.message ?? status, + } + ); + } + + const preimage = paymentResult.transaction?.settlementVia?.preImage ?? ''; + const proofUnavailableCodes = new Set( + (this.config.payment.proofUnavailableErrorCodes ?? []).map((code) => code.toUpperCase()) + ); + const unexpectedErrors = (paymentResult.errors ?? []).filter((error) => { + const code = error.code?.toUpperCase(); + return !(!preimage && code !== undefined && proofUnavailableCodes.has(code)); + }); + if (unexpectedErrors.length) { + throw this.errorsToNwcError(unexpectedErrors, 'pay_invoice', 'PAYMENT_FAILED'); + } + + let paymentHash = ''; + try { + paymentHash = decodeBolt11(params.invoice).payment_hash ?? ''; + } catch { + // The payment succeeded, so preserve the provider result even if the invoice cannot decode. + } + const statusMapping = this.config.payment.statusMapping; + const state = statusMapping + ? statusMapping.settled.includes(status) + ? ('settled' as const) + : statusMapping.pending.includes(status) + ? ('pending' as const) + : ('accepted' as const) + : ('accepted' as const); + + return { + payment: { + paymentHash, + preimage, + feeMsats: feeProbeOperation.kind === 'btc' ? satsToMsats(feeResult.amount ?? 0) : 0, + }, + state, + providerStatus: status, + }; + } + + async prepareOnchainTransaction( + params: PrepareOnchainTransactionParams + ): Promise { + this.assertOnchainEnabled('prepare_onchain_transaction'); + const amountSats = params.amountSats; + assertValidOnchainAmount(amountSats); + + const fee = params.fee ?? defaultOnchainFee(); + const feePayer = resolveGaloyFeePayer(params.feePayer); + const speed = resolveGaloyFeeSpeed(fee); + const walletId = (await this.resolveBtcOnchainWallet('prepare_onchain_transaction')).id; + + const response = await this.gql( + ` + query onChainTxFee($walletId: WalletId!, $address: OnChainAddress!, $amount: SatAmount!, $speed: PayoutSpeed!) { + onChainTxFee(walletId: $walletId, address: $address, amount: $amount, speed: $speed) { + amount + } + } + `, + { + walletId, + address: params.address, + amount: amountSats, + speed, + }, + 'prepare_onchain_transaction' + ); + + const feeResult = response.onChainTxFee; + if (!feeResult) { + throw this.nwcError( + 'INTERNAL', + `No onChainTxFee result returned from ${this.config.provider.name}.`, + 'prepare_onchain_transaction' + ); + } + const feeSats = feeResult.amount; + + return { + address: params.address, + amountSats, + feeSats, + totalAmountSats: feeSats === undefined ? undefined : amountSats + feeSats, + recipientAmountSats: amountSats, + feePayer, + fee, + raw: { + walletId, + speed, + memo: params.description, + fee: feeResult, + }, + }; + } + + async payOnchain( + transaction: OnchainTransaction, + options?: PayOnchainOptions + ): Promise { + this.assertOnchainEnabled('pay_onchain'); + assertValidOnchainAmount(transaction.amountSats); + resolveGaloyFeePayer(transaction.feePayer); + const speed = resolveGaloyFeeSpeed(transaction.fee); + assertOnchainFeeGuardrail(transaction, options); + + const walletId = (await this.resolveBtcOnchainWallet('pay_onchain')).id; + const memo = galoyTransactionMemo(transaction); + const payment = await this.gql( + ` + mutation onChainPaymentSend($input: OnChainPaymentSendInput!) { + onChainPaymentSend(input: $input) { + status + transaction { + id + settlementAmount + settlementCurrency + settlementFee + settlementVia { + __typename + ... on SettlementViaOnChain { + transactionHash + } + } + } + errors { + code + message + path + } + } + } + `, + { + input: { + address: transaction.address, + amount: transaction.amountSats, + walletId, + memo, + speed, + }, + }, + 'pay_onchain' + ); + + const paymentResult = payment.onChainPaymentSend; + if (!paymentResult) { + throw this.nwcError( + 'INTERNAL', + `No onChainPaymentSend result returned from ${this.config.provider.name}.`, + 'pay_onchain' + ); + } + + if (paymentResult.errors?.length) { + throw this.errorsToNwcError(paymentResult.errors, 'pay_onchain', 'PAYMENT_FAILED'); + } + + const paymentTransaction = paymentResult.transaction; + const feeSats = + galoyTransactionAmountToSats( + paymentTransaction?.settlementFee, + paymentTransaction?.settlementCurrency + ) ?? transaction.feeSats; + const amountSats = + galoyTransactionAmountToSats( + paymentTransaction?.settlementAmount, + paymentTransaction?.settlementCurrency + ) ?? transaction.amountSats; + + return { + paymentId: paymentTransaction?.id, + txid: paymentTransaction?.settlementVia?.transactionHash, + state: normalizeOnchainState(paymentResult.status), + address: transaction.address, + amountSats, + feeSats, + totalAmountSats: feeSats === undefined ? transaction.totalAmountSats : amountSats + feeSats, + recipientAmountSats: transaction.recipientAmountSats ?? transaction.amountSats, + raw: paymentResult, + }; + } + + async createOffer(_params: CreateOfferParams): Promise { + throw this.notImplemented( + `Bolt12 is not implemented for ${this.config.provider.name}.`, + 'make_invoice' + ); + } + + async getOffer(_search?: string): Promise { + throw this.notImplemented( + `Bolt12 is not implemented for ${this.config.provider.name}.`, + 'lookup_invoice' + ); + } + + async listOffers(_search?: string): Promise { + throw this.notImplemented( + `Bolt12 is not implemented for ${this.config.provider.name}.`, + 'list_transactions' + ); + } + + async payOffer( + _offer: string, + _amountMsats: number, + _payerNote?: string + ): Promise { + throw this.notImplemented( + `Bolt12 is not implemented for ${this.config.provider.name}.`, + 'pay_invoice' + ); + } + + private mapTransaction(node: GaloyTransactionNode): Transaction { + const paymentHash = + node.initiationVia?.__typename === 'InitiationViaLn' + ? (node.initiationVia.paymentHash ?? '') + : ''; + const preimage = + node.settlementVia?.__typename === 'SettlementViaLn' + ? (node.settlementVia.preImage ?? '') + : ''; + + const amountMsats = + node.settlementCurrency === 'BTC' ? satsToMsats(Math.abs(node.settlementAmount ?? 0)) : 0; + const feeMsats = + node.settlementCurrency === 'BTC' ? satsToMsats(Math.abs(node.settlementFee ?? 0)) : 0; + + return emptyTransaction({ + type: node.direction === 'SEND' ? 'outgoing' : 'incoming', + paymentHash, + preimage, + amountMsats, + feesPaid: feeMsats, + createdAt: node.createdAt, + settledAt: node.status === 'SUCCESS' ? node.createdAt : 0, + description: node.memo ?? '', + descriptionHash: '', + payerNote: '', + externalId: node.id, + }); + } + + private async listTransactionsPage(args: { + first: number; + after?: string | null; + paymentHash?: string; + search?: string; + }): Promise { + const response: GaloyTransactionsQuery = await this.gql( + GaloyNodeImplementation.TRANSACTIONS_QUERY, + { + first: Math.max(args.first, 1), + after: args.after ?? null, + }, + args.paymentHash ? 'lookup_invoice' : 'list_transactions' + ); + + const page: GaloyTransactionsQuery['me']['defaultAccount']['transactions'] = + response.me.defaultAccount.transactions; + const edges = page.edges; + const transactions = edges + .map(({ node }) => this.mapTransaction(node)) + .filter((tx) => { + if (args.paymentHash && tx.paymentHash !== args.paymentHash) { + return false; + } + return matchesSearch(tx, args.search); + }); + + if (!page.pageInfo.hasNextPage) { + return { + transactions, + nextCursor: null, + scannedCount: edges.length, + }; + } + + const nextCursor: string | null = + page.pageInfo.endCursor ?? edges[edges.length - 1]?.cursor ?? null; + return { + transactions, + nextCursor: nextCursor && nextCursor !== args.after ? nextCursor : null, + scannedCount: edges.length, + }; + } + + async lookupInvoice(params: LookupInvoiceParams): Promise { + if (!this.config.capabilities.transactionLookup) { + throw this.notImplemented( + `Transaction lookup is disabled for ${this.config.provider.name}.`, + 'lookup_invoice' + ); + } + if (!params.paymentHash) { + throw new LniError('InvalidInput', 'lookupInvoice requires paymentHash for a Galoy node.'); + } + + let after: string | null = null; + let scanned = 0; + + while (scanned < GaloyNodeImplementation.MAX_TRANSACTION_FETCH) { + const page = await this.listTransactionsPage({ + first: Math.min( + GaloyNodeImplementation.DEFAULT_PAGE_SIZE, + GaloyNodeImplementation.MAX_TRANSACTION_FETCH - scanned + ), + after, + paymentHash: params.paymentHash, + search: params.search, + }); + + const match = page.transactions.find((tx) => tx.paymentHash === params.paymentHash); + if (match) { + return match; + } + scanned += page.scannedCount; + + if (!page.nextCursor || page.scannedCount === 0) { + break; + } + + after = page.nextCursor; + } + + throw this.nwcError( + 'NOT_FOUND', + 'Transaction not found for the requested payment hash.', + 'lookup_invoice' + ); + } + + async listTransactions(params: ListTransactionsParams): Promise { + if (!this.config.capabilities.transactionHistory) { + throw this.notImplemented( + `Transaction history is disabled for ${this.config.provider.name}.`, + 'list_transactions' + ); + } + const requestedLimit = + params.limit > 0 + ? params.limit + : Math.min( + GaloyNodeImplementation.MAX_TRANSACTION_FETCH, + GaloyNodeImplementation.DEFAULT_PAGE_SIZE * 10 + ); + const limit = Math.min(requestedLimit, GaloyNodeImplementation.MAX_TRANSACTION_FETCH); + const from = Math.max(params.from, 0); + const pageSize = Math.max(Math.min(limit, GaloyNodeImplementation.DEFAULT_PAGE_SIZE), 1); + + let after: string | null = null; + let skipped = 0; + let scanned = 0; + const transactions: Transaction[] = []; + + while (transactions.length < limit && scanned < GaloyNodeImplementation.MAX_TRANSACTION_FETCH) { + const page = await this.listTransactionsPage({ + first: Math.min(pageSize, GaloyNodeImplementation.MAX_TRANSACTION_FETCH - scanned), + after, + paymentHash: params.paymentHash, + search: params.search, + }); + if (!page.transactions.length && !page.nextCursor) { + break; + } + scanned += page.scannedCount; + + for (const tx of page.transactions) { + if (skipped < from) { + skipped += 1; + continue; + } + + transactions.push(tx); + if (transactions.length >= limit) { + break; + } + } + + if (!page.nextCursor || page.scannedCount === 0) { + break; + } + + after = page.nextCursor; + } + + return transactions; + } + + async decode(str: string): Promise { + return decodeBolt11ToJson(str); + } + + async decodeOffer(offer: string): Promise { + return decodeOfferToJson(offer); + } + + async onInvoiceEvents( + params: OnInvoiceEventParams, + callback: InvoiceEventCallback + ): Promise { + if (!this.config.capabilities.invoiceEvents) { + throw this.notImplemented( + `Invoice events are disabled for ${this.config.provider.name}.`, + 'lookup_invoice' + ); + } + if (!this.config.capabilities.transactionLookup) { + throw this.notImplemented( + `Invoice events require transaction lookup for ${this.config.provider.name}.`, + 'lookup_invoice' + ); + } + await pollInvoiceEvents({ + params, + callback, + lookup: () => this.lookupInvoice({ paymentHash: params.paymentHash, search: params.search }), + }); + } +} + +export type GaloyNode = LightningNode & + OnchainPayments & { + /** + * Pay an invoice while preserving Galoy's accepted provider status. + * + * The shared `payInvoice()` method remains source-compatible and returns + * only `PayInvoiceResponse`. + */ + payInvoiceWithStatus(params: PayInvoiceParams): Promise; + }; + +export function createGaloyNode(config: GaloyConfig, options: NodeRequestOptions = {}): GaloyNode { + return new GaloyNodeImplementation(config, options); +} diff --git a/bindings/typescript/src/types.ts b/bindings/typescript/src/types.ts index 5aca6cb2..c0e9a2a4 100644 --- a/bindings/typescript/src/types.ts +++ b/bindings/typescript/src/types.ts @@ -250,6 +250,107 @@ export interface BlinkConfig { httpTimeout?: number; } +export interface FlashConfig { + apiKey: string; + baseUrl?: string; + walletId: string; + /** + * Flash payment fee probing is currently documented for BTC and USD. + * Other currencies remain unsupported until their operation family and fee + * denomination are confirmed. + */ + walletCurrency: string; + additionalHeaders?: Record; + acceptedStatuses?: readonly string[]; + httpTimeout?: number; +} + +export type GaloyWalletConfig = + | { + mode: 'explicit'; + id: string; + currency: string; + } + | { + mode: 'currency'; + currency: string; + }; + +export interface GaloyPaymentConfig { + response: 'transaction-with-preimage' | 'status-only'; + acceptedStatuses: readonly string[]; + /** + * Optional semantic mapping used to distinguish accepted-but-pending + * payments from settled payments. + */ + statusMapping?: { + settled: readonly string[]; + pending: readonly string[]; + }; + /** + * Provider error codes that specifically mean payment proof is unavailable. + * These are nonfatal only when the payment status is otherwise accepted and + * no preimage was returned. + */ + proofUnavailableErrorCodes?: readonly string[]; +} + +export type GaloyPaymentState = 'settled' | 'pending' | 'accepted'; + +/** + * Galoy-specific payment details available without changing the shared + * LightningNode PayInvoiceResponse contract. + */ +export interface GaloyPaymentOutcome { + payment: PayInvoiceResponse; + state: GaloyPaymentState; + providerStatus: string; +} + +export type GaloyInvoiceOperation = + /** Galoy's BTC operation family with satoshi-denominated amounts. */ + | { + kind: 'btc'; + denomination: 'sats'; + } + /** Galoy's USD operation family with USD-cent-denominated amounts. */ + | { + kind: 'usd'; + denomination: 'usd-cents'; + } + /** No safe GraphQL operation is known for this action. */ + | { + kind: 'unsupported'; + }; + +export interface GaloyInvoiceOperationsConfig { + create: GaloyInvoiceOperation; + feeProbe: GaloyInvoiceOperation; +} + +export interface GaloyCapabilities { + transactionLookup: boolean; + transactionHistory: boolean; + invoiceEvents: boolean; + onchain: boolean; +} + +export interface GaloyConfig { + apiKey: string; + baseUrl: string; + provider: { + id: string; + name: string; + }; + wallet: GaloyWalletConfig; + invoiceOperations: GaloyInvoiceOperationsConfig; + payment: GaloyPaymentConfig; + capabilities: GaloyCapabilities; + permissions: 'jwt-introspection' | 'configured'; + additionalHeaders?: Record; + httpTimeout?: number; +} + export interface Permissions { getInfo: boolean; createInvoice: boolean; @@ -288,7 +389,17 @@ export interface OnchainPayments { ): Promise; } -export type BackendNodeKind = 'phoenixd' | 'cln' | 'lnd' | 'nwc' | 'strike' | 'speed' | 'blink'; +export type BackendNodeKind = + | 'phoenixd' + | 'cln' + | 'lnd' + | 'nwc' + | 'strike' + | 'speed' + | 'galoy' + | 'flash' + /** @deprecated Use `galoy` with Blink defaults instead. */ + | 'blink'; export type BackendNodeConfig = | { kind: 'phoenixd'; config: PhoenixdConfig } @@ -297,6 +408,9 @@ export type BackendNodeConfig = | { kind: 'nwc'; config: NwcConfig } | { kind: 'strike'; config: StrikeConfig } | { kind: 'speed'; config: SpeedConfig } + | { kind: 'galoy'; config: GaloyConfig } + | { kind: 'flash'; config: FlashConfig } + /** @deprecated Use `galoy` with Blink defaults instead. */ | { kind: 'blink'; config: BlinkConfig }; export interface PaymentInfo { diff --git a/crates/lni/blink/api.rs b/crates/lni/blink/api.rs index 291f2e6b..228b279a 100644 --- a/crates/lni/blink/api.rs +++ b/crates/lni/blink/api.rs @@ -1,671 +1,51 @@ -use std::str::FromStr; -use std::time::Duration; - -use lightning_invoice::Bolt11Invoice; - -use super::types::*; use super::BlinkConfig; -use crate::error_normalization::{ - map_provider_message, nwc_error, provider_error_from_response, transport_error, - ProviderErrorInfo, -}; -use crate::types::NodeInfo; use crate::{ - ApiError, CreateInvoiceParams, InvoiceType, Offer, OnInvoiceEventCallback, - OnInvoiceEventParams, OnchainFeePayer, OnchainFeePreference, OnchainFeePreferenceType, - OnchainFeeSpeed, OnchainTransaction, PayInvoiceParams, PayInvoiceResponse, PayOnchainOptions, + ApiError, CreateInvoiceParams, Offer, OnInvoiceEventCallback, OnInvoiceEventParams, + OnchainTransaction, PayInvoiceParams, PayInvoiceResponse, PayOnchainOptions, PayOnchainResponse, PrepareOnchainTransactionParams, Transaction, }; -use reqwest::header; - -// Docs: https://dev.blink.sv/ - -fn map_blink_provider_error(info: &ProviderErrorInfo) -> Option<&'static str> { - let code = info.code.as_deref().unwrap_or_default().to_uppercase(); - if code.contains("AUTHENTICATION") || code.contains("UNAUTHORIZED") { - return Some("UNAUTHORIZED"); - } - if code.contains("FORBIDDEN") || code.contains("PERMISSION") || code.contains("SCOPE") { - return Some("RESTRICTED"); - } - if code.contains("INSUFFICIENT") || code.contains("BALANCE") { - return Some("INSUFFICIENT_BALANCE"); - } - if code.contains("LIMIT") || code.contains("QUOTA") { - return Some("QUOTA_EXCEEDED"); - } - if code.contains("INVALID_INVOICE") || code.contains("NO_ROUTE") || code.contains("PAYMENT") { - return Some("PAYMENT_FAILED"); - } - - map_provider_message(info.message.as_deref()) -} - -fn blink_graphql_error(errors: &[GraphQLError], fallback_code: &str) -> ApiError { - let first = errors.first(); - let info = ProviderErrorInfo { - code: first.and_then(|error| error.code.clone()), - status: None, - message: Some( - errors - .iter() - .map(|error| error.message.clone()) - .collect::>() - .join(", "), - ), - }; - let code = map_blink_provider_error(&info).unwrap_or(fallback_code); - nwc_error( - code, - info.message - .unwrap_or_else(|| "Blink GraphQL error".to_string()), - ) -} - -fn client(config: &BlinkConfig) -> reqwest::Client { - let mut headers = reqwest::header::HeaderMap::new(); - - match header::HeaderValue::from_str(&config.api_key) { - Ok(api_key_header) => headers.insert("X-API-KEY", api_key_header), - Err(_) => { - eprintln!("Failed to create API key header"); - return reqwest::ClientBuilder::new() - .default_headers(headers) - .build() - .unwrap_or_else(|_| reqwest::Client::new()); - } - }; - - headers.insert( - "Content-Type", - header::HeaderValue::from_static("application/json"), - ); - - // Create HTTP client with optional SOCKS5 proxy following Strike pattern - if let Some(proxy_url) = config.socks5_proxy.clone() { - if !proxy_url.is_empty() { - // Accept invalid certificates when using SOCKS5 proxy - let client_builder = reqwest::Client::builder() - .default_headers(headers.clone()) - .danger_accept_invalid_certs(true); - - match reqwest::Proxy::all(&proxy_url) { - Ok(proxy) => { - let mut builder = client_builder.proxy(proxy); - if config.http_timeout.is_some() { - builder = builder.timeout(std::time::Duration::from_secs( - config.http_timeout.unwrap_or_default() as u64, - )); - } - match builder.build() { - Ok(client) => return client, - Err(_) => {} // Fall through to default client creation - } - } - Err(_) => {} // Fall through to default client creation - } - } - } - - // Default client creation - let mut client_builder = reqwest::Client::builder().default_headers(headers); - if config.accept_invalid_certs.unwrap_or(false) { - client_builder = client_builder.danger_accept_invalid_certs(true); - } - if config.http_timeout.is_some() { - client_builder = client_builder.timeout(std::time::Duration::from_secs( - config.http_timeout.unwrap_or_default() as u64, - )); - } - client_builder - .build() - .unwrap_or_else(|_| reqwest::Client::new()) -} - -async fn execute_graphql_query( - config: &BlinkConfig, - query: &str, - variables: Option, - operation: &str, -) -> Result -where - T: for<'de> serde::Deserialize<'de>, -{ - let client = client(config); - let request = GraphQLRequest { - query: query.to_string(), - variables, - }; - - let response = client - .post( - config - .base_url - .as_deref() - .unwrap_or("https://api.blink.sv/graphql"), - ) - .json(&request) - .send() - .await - .map_err(|e| transport_error("blink", operation, e))?; - - if !response.status().is_success() { - let status = response.status(); - let error_text = response.text().await.unwrap_or_default(); - return Err(provider_error_from_response( - "blink", - operation, - status, - error_text, - map_blink_provider_error, - )); - } - - let response_text = response.text().await.map_err(|e| ApiError::Http { - reason: format!("Failed to read GraphQL response: {}", e), - })?; - let graphql_response: GraphQLResponse = - serde_json::from_str(&response_text).map_err(|e| ApiError::Json { - reason: format!( - "Failed to parse GraphQL response: {} - Response: {}", - e, response_text - ), - })?; - - if let Some(errors) = graphql_response.errors { - return Err(blink_graphql_error(&errors, "OTHER")); - } - - graphql_response - .data - .ok_or_else(|| nwc_error("INTERNAL", "No data in GraphQL response")) -} - -async fn get_btc_wallet_id(config: &BlinkConfig) -> Result { - let query = r#" - query Me { - me { - defaultAccount { - wallets { - id - walletCurrency - balance - } - } - } - } - "#; - - let response: MeQuery = execute_graphql_query(config, query, None, "get_info").await?; - - let btc_wallet = response - .me - .default_account - .wallets - .into_iter() - .find(|w| w.wallet_currency == "BTC") - .ok_or_else(|| nwc_error("OTHER", "No BTC wallet found in account"))?; - - Ok(btc_wallet.id) -} - -fn assert_valid_onchain_amount(amount_sats: i64) -> Result<(), ApiError> { - if amount_sats <= 0 { - return Err(ApiError::InvalidInput( - "pay_onchain requires a positive amount_sats".to_string(), - )); - } - - Ok(()) -} - -fn default_onchain_fee() -> OnchainFeePreference { - OnchainFeePreference { - preference_type: OnchainFeePreferenceType::Default, - speed: None, - target_conf: None, - sats_per_vbyte: None, - backend: None, - } -} - -fn resolve_blink_fee_speed(fee: &OnchainFeePreference) -> Result<&'static str, ApiError> { - match fee.preference_type { - OnchainFeePreferenceType::Default => Ok("FAST"), - OnchainFeePreferenceType::Speed => match fee.speed.clone().unwrap_or(OnchainFeeSpeed::Fast) - { - OnchainFeeSpeed::Fast => Ok("FAST"), - OnchainFeeSpeed::Normal => Ok("MEDIUM"), - OnchainFeeSpeed::Slow => Ok("SLOW"), - OnchainFeeSpeed::Free => Err(ApiError::InvalidInput( - "Blink pay_onchain does not support free on-chain fee speed".to_string(), - )), - }, - OnchainFeePreferenceType::Backend - | OnchainFeePreferenceType::TargetConf - | OnchainFeePreferenceType::SatsPerVbyte => Err(ApiError::InvalidInput(format!( - "Blink pay_onchain does not support {:?} fee preferences", - fee.preference_type - ))), - } -} - -fn resolve_blink_fee_payer( - fee_payer: Option, -) -> Result { - match fee_payer.unwrap_or(OnchainFeePayer::Sender) { - OnchainFeePayer::Sender => Ok(OnchainFeePayer::Sender), - OnchainFeePayer::Recipient => Err(ApiError::InvalidInput( - "Blink pay_onchain only supports sender-paid on-chain fees".to_string(), - )), - } -} - -fn assert_valid_guardrail_limit(value: f64, name: &str) -> Result<(), ApiError> { - if !value.is_finite() || value < 0.0 { - return Err(ApiError::InvalidInput(format!( - "{} must be a non-negative finite number", - name - ))); - } - - Ok(()) -} - -fn assert_onchain_fee_guardrail( - transaction: &OnchainTransaction, - options: PayOnchainOptions, -) -> Result<(), ApiError> { - if options.dangerously_disable_fee_guardrail { - return Ok(()); - } - - let default_guardrail = crate::types::OnchainFeeGuardrail::default(); - let guardrail = options.fee_guardrail.unwrap_or_default(); - let max_fee_sats = guardrail - .max_fee_sats - .or(default_guardrail.max_fee_sats) - .unwrap_or(crate::types::DEFAULT_ONCHAIN_MAX_FEE_SATS); - let max_fee_percent = guardrail - .max_fee_percent - .or(default_guardrail.max_fee_percent) - .unwrap_or(crate::types::DEFAULT_ONCHAIN_MAX_FEE_PERCENT); - - if max_fee_sats < 0 { - return Err(ApiError::InvalidInput( - "fee_guardrail.max_fee_sats must be non-negative".to_string(), - )); - } - assert_valid_guardrail_limit(max_fee_percent, "fee_guardrail.max_fee_percent")?; - - let fee_sats = transaction.fee_sats.ok_or_else(|| { - ApiError::InvalidInput( - "Cannot pay on-chain transaction because fee_sats is unknown. Re-prepare the transaction or pass dangerously_disable_fee_guardrail: true.".to_string(), - ) - })?; - - if fee_sats < 0 { - return Err(ApiError::InvalidInput( - "Cannot pay on-chain transaction because fee_sats is invalid".to_string(), - )); - } - - if transaction.amount_sats <= 0 { - return Err(ApiError::InvalidInput( - "Cannot pay on-chain transaction because amount_sats is invalid".to_string(), - )); - } - - if fee_sats > max_fee_sats { - return Err(ApiError::InvalidInput(format!( - "On-chain fee {} sats exceeds guardrail max_fee_sats {}", - fee_sats, max_fee_sats - ))); - } - let fee_percent = (fee_sats as f64 / transaction.amount_sats as f64) * 100.0; - if fee_percent > max_fee_percent { - return Err(ApiError::InvalidInput(format!( - "On-chain fee {:.2}% exceeds guardrail max_fee_percent {}%", - fee_percent, max_fee_percent - ))); - } - - Ok(()) -} - -fn normalize_onchain_state(status: &str) -> String { - match status.to_uppercase().as_str() { - "PENDING" => "pending".to_string(), - "SUCCESS" | "ALREADY_PAID" => "completed".to_string(), - "FAILED" | "FAILURE" => "failed".to_string(), - state => state.to_lowercase(), - } +fn galoy(config: &BlinkConfig) -> crate::galoy::GaloyConfig { + config.into() } -fn blink_transaction_amount_to_sats(amount: Option, currency: Option<&String>) -> Option { - if currency.map(|currency| currency == "BTC").unwrap_or(false) { - amount.map(|amount| amount.abs()) - } else { - None - } -} - -fn blink_transaction_memo(transaction: &OnchainTransaction) -> Option { - let raw = transaction.raw.as_deref()?; - let value: serde_json::Value = serde_json::from_str(raw).ok()?; - value - .get("memo") - .and_then(|memo| memo.as_str()) - .filter(|memo| !memo.is_empty()) - .map(|memo| memo.to_string()) +pub(crate) fn bolt12_not_implemented() -> Result { + Err(crate::error_normalization::nwc_error( + "NOT_IMPLEMENTED", + "Bolt12 not implemented for Blink", + )) } -pub async fn get_info(config: &BlinkConfig) -> Result { - let query = r#" - query Me { - me { - defaultAccount { - wallets { - id - walletCurrency - balance - } - } - } - } - "#; - - let response: MeQuery = execute_graphql_query(config, query, None, "get_info").await?; - - let btc_wallet = response - .me - .default_account - .wallets - .iter() - .find(|w| w.wallet_currency == "BTC"); - - let balance_sats = btc_wallet.map(|w| w.balance).unwrap_or(0); - let balance_msats = balance_sats * 1000; - - Ok(NodeInfo { - alias: "Blink Node".to_string(), - color: "".to_string(), - pubkey: "".to_string(), - network: "mainnet".to_string(), - block_height: 0, - block_hash: "".to_string(), - send_balance_msat: balance_msats, - receive_balance_msat: balance_msats, - fee_credit_balance_msat: 0, - unsettled_send_balance_msat: 0, - unsettled_receive_balance_msat: 0, - pending_open_send_balance: 0, - pending_open_receive_balance: 0, - }) +pub async fn get_info(config: &BlinkConfig) -> Result { + crate::galoy::api::get_info(&galoy(config)).await } pub async fn create_invoice( config: &BlinkConfig, - invoice_params: CreateInvoiceParams, + params: CreateInvoiceParams, ) -> Result { - match invoice_params.get_invoice_type() { - InvoiceType::Bolt11 => { - let wallet_id = get_btc_wallet_id(config).await?; - - let amount_sats = invoice_params.amount_msats.unwrap_or(0) / 1000; - - let query = r#" - mutation LnInvoiceCreate($input: LnInvoiceCreateInput!) { - lnInvoiceCreate(input: $input) { - invoice { - paymentRequest - paymentHash - paymentSecret - satoshis - } - errors { - message - } - } - } - "#; - - let variables = serde_json::json!({ - "input": { - "amount": amount_sats.to_string(), - "walletId": wallet_id, - "memo": invoice_params.description - } - }); - - let response: LnInvoiceCreateResponse = - execute_graphql_query(config, query, Some(variables), "make_invoice").await?; - - if let Some(errors) = &response.ln_invoice_create.errors { - if !errors.is_empty() { - return Err(blink_graphql_error(errors, "OTHER")); - } - } - - let invoice = response - .ln_invoice_create - .invoice - .ok_or_else(|| nwc_error("INTERNAL", "No invoice data in response"))?; - - // Parse the BOLT11 invoice to get expiry - let expires_at = match Bolt11Invoice::from_str(&invoice.payment_request) { - Ok(bolt11) => bolt11 - .expires_at() - .map(|duration| duration.as_secs() as i64) - .unwrap_or(0), - Err(_) => 0, - }; - - Ok(Transaction { - type_: "incoming".to_string(), - invoice: invoice.payment_request, - preimage: "".to_string(), - payment_hash: invoice.payment_hash, - amount_msats: invoice.satoshis * 1000, - fees_paid: 0, - created_at: chrono::Utc::now().timestamp(), - expires_at, - settled_at: 0, - description: invoice_params.description.unwrap_or_default(), - description_hash: invoice_params.description_hash.unwrap_or_default(), - payer_note: Some("".to_string()), - external_id: Some("".to_string()), - }) - } - InvoiceType::Bolt12 => Err(nwc_error( - "NOT_IMPLEMENTED", - "Bolt12 not implemented for Blink", - )), - } + crate::galoy::api::create_invoice(&galoy(config), params).await } pub async fn pay_invoice( config: &BlinkConfig, - invoice_params: PayInvoiceParams, + params: PayInvoiceParams, ) -> Result { - let wallet_id = get_btc_wallet_id(config).await?; - - // First probe the fee - let fee_probe_query = r#" - mutation lnInvoiceFeeProbe($input: LnInvoiceFeeProbeInput!) { - lnInvoiceFeeProbe(input: $input) { - errors { - message - } - amount - } - } - "#; - - let fee_probe_variables = serde_json::json!({ - "input": { - "paymentRequest": invoice_params.invoice, - "walletId": wallet_id - } - }); - - let fee_response: LnInvoiceFeeProbeResponse = execute_graphql_query( - config, - fee_probe_query, - Some(fee_probe_variables), - "pay_invoice", - ) - .await?; - - let fee_msats = if let Some(errors) = &fee_response.ln_invoice_fee_probe.errors { - if !errors.is_empty() { - return Err(blink_graphql_error(errors, "PAYMENT_FAILED")); - } else { - fee_response.ln_invoice_fee_probe.amount.unwrap_or(0) * 1000 - } - } else { - fee_response.ln_invoice_fee_probe.amount.unwrap_or(0) * 1000 - }; - - // Now send the payment - let payment_query = r#" - mutation LnInvoicePaymentSend($input: LnInvoicePaymentInput!) { - lnInvoicePaymentSend(input: $input) { - status - errors { - message - path - code - } - transaction { - settlementVia { - __typename - ... on SettlementViaLn { - preImage - } - ... on SettlementViaIntraLedger { - preImage - } - } - } - } - } - "#; - - let payment_variables = serde_json::json!({ - "input": { - "paymentRequest": invoice_params.invoice, - "walletId": wallet_id - } - }); - - let payment_response: LnInvoicePaymentSendResponse = execute_graphql_query( - config, - payment_query, - Some(payment_variables), - "pay_invoice", - ) - .await?; - - if let Some(errors) = &payment_response.ln_invoice_payment_send.errors { - if !errors.is_empty() { - return Err(blink_graphql_error(errors, "PAYMENT_FAILED")); - } - } - - if payment_response.ln_invoice_payment_send.status != "SUCCESS" { - let status = payment_response.ln_invoice_payment_send.status; - return Err(nwc_error( - if status == "FAILED" { - "PAYMENT_FAILED" - } else { - "OTHER" - }, - format!("Payment failed with status: {}", status), - )); - } - - // Extract payment hash from the BOLT11 invoice - let payment_hash = match Bolt11Invoice::from_str(&invoice_params.invoice) { - Ok(invoice) => format!("{:x}", invoice.payment_hash()), - Err(_) => "".to_string(), - }; - let preimage = match payment_response - .ln_invoice_payment_send - .transaction - .and_then(|transaction| transaction.settlement_via) - { - Some(SettlementVia::SettlementViaLn { pre_image }) - | Some(SettlementVia::SettlementViaIntraLedger { pre_image }) => { - pre_image.unwrap_or_default() - } - _ => "".to_string(), - }; - - Ok(PayInvoiceResponse { - payment_hash, - preimage, - fee_msats, - }) + crate::galoy::api::pay_invoice(&galoy(config), params).await } pub async fn prepare_onchain_transaction( config: &BlinkConfig, params: PrepareOnchainTransactionParams, ) -> Result { - assert_valid_onchain_amount(params.amount_sats)?; - - let fee = params.fee.clone().unwrap_or_else(default_onchain_fee); - let fee_payer = resolve_blink_fee_payer(params.fee_payer.clone())?; - let speed = resolve_blink_fee_speed(&fee)?; - let wallet_id = get_btc_wallet_id(config).await?; - - let query = r#" - query onChainTxFee($walletId: WalletId!, $address: OnChainAddress!, $amount: SatAmount!, $speed: PayoutSpeed!) { - onChainTxFee(walletId: $walletId, address: $address, amount: $amount, speed: $speed) { - amount - } - } - "#; - - let variables = serde_json::json!({ - "walletId": wallet_id, - "address": params.address.clone(), - "amount": params.amount_sats, - "speed": speed, - }); - - let response: OnChainTxFeeResponse = - execute_graphql_query(config, query, Some(variables), "pay_invoice").await?; - let fee_sats = response.on_chain_tx_fee.amount; - - Ok(OnchainTransaction { - id: None, - address: params.address, - amount_sats: params.amount_sats, - fee_sats, - total_amount_sats: fee_sats.map(|fee_sats| params.amount_sats + fee_sats), - recipient_amount_sats: Some(params.amount_sats), - fee_payer, - fee, - expires_at: None, - estimated_delivery_seconds: None, - raw: Some( - serde_json::json!({ - "speed": speed, - "memo": params.description, - }) - .to_string(), - ), - }) + crate::galoy::api::prepare_onchain_transaction(&galoy(config), params).await } pub async fn pay_onchain( config: &BlinkConfig, transaction: OnchainTransaction, ) -> Result { - pay_onchain_with_options(config, transaction, PayOnchainOptions::default()).await + crate::galoy::api::pay_onchain(&galoy(config), transaction).await } pub async fn pay_onchain_with_options( @@ -673,120 +53,26 @@ pub async fn pay_onchain_with_options( transaction: OnchainTransaction, options: PayOnchainOptions, ) -> Result { - assert_valid_onchain_amount(transaction.amount_sats)?; - let _fee_payer = resolve_blink_fee_payer(Some(transaction.fee_payer.clone()))?; - let speed = resolve_blink_fee_speed(&transaction.fee)?; - assert_onchain_fee_guardrail(&transaction, options)?; - - let wallet_id = get_btc_wallet_id(config).await?; - let memo = blink_transaction_memo(&transaction); - let query = r#" - mutation onChainPaymentSend($input: OnChainPaymentSendInput!) { - onChainPaymentSend(input: $input) { - status - transaction { - id - settlementAmount - settlementCurrency - settlementFee - settlementVia { - __typename - ... on SettlementViaOnChain { - transactionHash - } - } - } - errors { - message - } - } - } - "#; - - let variables = serde_json::json!({ - "input": { - "address": transaction.address.clone(), - "amount": transaction.amount_sats, - "walletId": wallet_id, - "memo": memo, - "speed": speed, - } - }); - - let response: OnChainPaymentSendResponse = - execute_graphql_query(config, query, Some(variables), "pay_invoice").await?; - let payment = response.on_chain_payment_send; - - if let Some(errors) = &payment.errors { - if !errors.is_empty() { - return Err(blink_graphql_error(errors, "PAYMENT_FAILED")); - } - } - - let payment_transaction = payment.transaction; - let payment_id = payment_transaction.as_ref().and_then(|tx| tx.id.clone()); - let txid = payment_transaction - .as_ref() - .and_then(|tx| match tx.settlement_via.as_ref() { - Some(SettlementVia::SettlementViaOnChain { transaction_hash }) => { - transaction_hash.clone() - } - _ => None, - }); - let fee_sats = payment_transaction - .as_ref() - .and_then(|tx| { - blink_transaction_amount_to_sats(tx.settlement_fee, tx.settlement_currency.as_ref()) - }) - .or(transaction.fee_sats); - let amount_sats = payment_transaction - .as_ref() - .and_then(|tx| { - blink_transaction_amount_to_sats(tx.settlement_amount, tx.settlement_currency.as_ref()) - }) - .unwrap_or(transaction.amount_sats); - - Ok(PayOnchainResponse { - payment_id, - txid, - state: normalize_onchain_state(&payment.status), - address: transaction.address, - amount_sats, - fee_sats, - total_amount_sats: fee_sats - .map(|fee_sats| transaction.amount_sats + fee_sats) - .or(transaction.total_amount_sats), - recipient_amount_sats: transaction - .recipient_amount_sats - .or(Some(transaction.amount_sats)), - created_at: None, - raw: None, - }) + crate::galoy::api::pay_onchain_with_options(&galoy(config), transaction, options).await } -pub async fn decode(str: String) -> Result { - crate::utils::decode_bolt11(str) +pub async fn decode(value: String) -> Result { + crate::galoy::api::decode(value).await } pub async fn decode_offer(offer: String) -> Result { - crate::utils::decode_offer(offer) + crate::galoy::api::decode_offer(offer).await } pub async fn get_offer(_config: &BlinkConfig, _search: Option) -> Result { - Err(nwc_error( - "NOT_IMPLEMENTED", - "Bolt12 not implemented for Blink", - )) + bolt12_not_implemented() } pub async fn list_offers( _config: &BlinkConfig, _search: Option, ) -> Result, ApiError> { - Err(nwc_error( - "NOT_IMPLEMENTED", - "Bolt12 not implemented for Blink", - )) + bolt12_not_implemented() } pub async fn create_offer( @@ -795,10 +81,7 @@ pub async fn create_offer( _description: Option, _expiry: Option, ) -> Result { - Err(nwc_error( - "NOT_IMPLEMENTED", - "Bolt12 not implemented for Blink", - )) + bolt12_not_implemented() } pub async fn fetch_invoice_from_offer( @@ -807,10 +90,7 @@ pub async fn fetch_invoice_from_offer( _amount_msats: i64, _payer_note: Option, ) -> Result { - Err(nwc_error( - "NOT_IMPLEMENTED", - "Bolt12 not implemented for Blink", - )) + bolt12_not_implemented() } pub async fn pay_offer( @@ -819,10 +99,7 @@ pub async fn pay_offer( _amount_msats: i64, _payer_note: Option, ) -> Result { - Err(nwc_error( - "NOT_IMPLEMENTED", - "Bolt12 not implemented for Blink", - )) + bolt12_not_implemented() } pub async fn lookup_invoice( @@ -832,26 +109,7 @@ pub async fn lookup_invoice( limit: Option, search: Option, ) -> Result { - let target_payment_hash = payment_hash.unwrap_or_default(); - - // Get transactions and look for the specific payment hash, using parameters or defaults - let transactions = - list_transactions(config, from.unwrap_or(0), limit.unwrap_or(100), search).await?; - - let transaction = transactions - .into_iter() - .find(|t| t.payment_hash == target_payment_hash) - .ok_or_else(|| { - nwc_error( - "NOT_FOUND", - format!( - "Transaction not found for payment hash: {}", - target_payment_hash - ), - ) - })?; - - Ok(transaction) + crate::galoy::api::lookup_invoice(&galoy(config), payment_hash, from, limit, search).await } pub async fn list_transactions( @@ -860,208 +118,14 @@ pub async fn list_transactions( limit: i64, search: Option, ) -> Result, ApiError> { - let query = r#" - query TransactionsQuery($first: Int, $last: Int, $after: String, $before: String) { - me { - defaultAccount { - transactions(first: $first, last: $last, after: $after, before: $before) { - edges { - cursor - node { - id - createdAt - direction - status - memo - settlementAmount - settlementCurrency - settlementFee - settlementDisplayAmount - settlementDisplayCurrency - settlementDisplayFee - settlementPrice { - base - offset - currencyUnit - } - initiationVia { - __typename - ... on InitiationViaLn { - paymentHash - } - } - settlementVia { - __typename - ... on SettlementViaLn { - preImage - } - ... on SettlementViaIntraLedger { - preImage - } - } - } - } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor - } - } - } - } - } - "#; - - // Simple approach: map limit directly to $first, handle from with client-side skip - // This is cleaner than trying to convert integer offsets to opaque cursors - let variables = serde_json::json!({ - "first": (from + limit) as i32, // Fetch enough to skip 'from' records - "last": serde_json::Value::Null, - "after": serde_json::Value::Null, - "before": serde_json::Value::Null - }); - - let response: TransactionsQuery = - execute_graphql_query(config, query, Some(variables), "list_transactions").await?; - - let mut all_transactions = Vec::new(); - - for edge in response.me.default_account.transactions.edges { - let node = edge.node; - - // Extract Lightning-specific information - let payment_hash = match node.initiation_via { - Some(InitiationVia::InitiationViaLn { payment_hash }) => payment_hash, - _ => "".to_string(), - }; - - let preimage = match node.settlement_via { - Some(SettlementVia::SettlementViaLn { pre_image }) - | Some(SettlementVia::SettlementViaIntraLedger { pre_image }) => { - pre_image.unwrap_or_default() - } - _ => "".to_string(), - }; - - // Handle amount conversion based on settlement currency - let (amount_msats, fees_paid) = if let Some(currency) = &node.settlement_currency { - if currency == "BTC" { - // BTC amounts are in satoshis, convert to millisatoshis - let amount = (node.settlement_amount.unwrap_or(0).abs()) * 1000; - let fees = (node.settlement_fee.unwrap_or(0).abs()) * 1000; - (amount, fees) - } else if currency == "USD" { - // USD amounts - for now return 0 as we can't meaningfully convert to satoshis - // without current exchange rate data - (0, 0) - } else { - // Other currencies - (0, 0) - } - } else { - // No settlement currency available - (0, 0) - }; - - // Use the timestamp directly since it's already a Unix timestamp - let created_at = node.created_at; - - let settled_at = if node.status == "SUCCESS" { - created_at - } else { - 0 - }; - - all_transactions.push(Transaction { - type_: if node.direction == "SEND" { - "outgoing" - } else { - "incoming" - } - .to_string(), - invoice: "".to_string(), // Not available from this query - preimage, - payment_hash, - amount_msats, - fees_paid, - created_at, - expires_at: 0, // Not available from this query - settled_at, - description: node.memo.unwrap_or_default(), - description_hash: "".to_string(), - payer_note: Some("".to_string()), - external_id: Some(node.id), - }); - } - - // Apply client-side search filtering if search term is provided - if let Some(search_term) = search { - let search_lower = search_term.to_lowercase(); - all_transactions.retain(|tx| { - tx.description.to_lowercase().contains(&search_lower) - || tx.payment_hash.to_lowercase().contains(&search_lower) - || tx.preimage.to_lowercase().contains(&search_lower) - }); - } - - // Apply client-side pagination: skip 'from' records and take 'limit' records - let skip_count = from as usize; - let take_count = limit as usize; - - if skip_count < all_transactions.len() { - let end_index = std::cmp::min(skip_count + take_count, all_transactions.len()); - all_transactions = all_transactions[skip_count..end_index].to_vec(); - } else { - all_transactions.clear(); - } - - Ok(all_transactions) + crate::galoy::api::list_transactions(&galoy(config), from, limit, search).await } -// Core logic shared by both implementations -pub async fn poll_invoice_events( - config: &BlinkConfig, - params: OnInvoiceEventParams, - mut callback: F, -) where +pub async fn poll_invoice_events(config: &BlinkConfig, params: OnInvoiceEventParams, callback: F) +where F: FnMut(String, Option), { - let start_time = std::time::Instant::now(); - loop { - if start_time.elapsed() > Duration::from_secs(params.max_polling_sec as u64) { - // timeout - callback("failure".to_string(), None); - break; - } - - let (status, transaction) = match lookup_invoice( - config, - params.payment_hash.clone(), - None, - None, - params.search.clone(), - ) - .await - { - Ok(transaction) => { - if transaction.settled_at > 0 { - ("success".to_string(), Some(transaction)) - } else { - ("pending".to_string(), Some(transaction)) - } - } - Err(_) => ("error".to_string(), None), - }; - - callback(status.clone(), transaction.clone()); - - if status == "success" || status == "failure" { - break; - } - - tokio::time::sleep(Duration::from_secs(params.polling_delay_sec as u64)).await; - } + crate::galoy::api::poll_invoice_events(&galoy(config), params, callback).await } pub async fn on_invoice_events( @@ -1069,10 +133,23 @@ pub async fn on_invoice_events( params: OnInvoiceEventParams, callback: std::sync::Arc, ) { - poll_invoice_events(&config, params, move |status, tx| match status.as_str() { - "success" => callback.success(tx), - "pending" => callback.pending(tx), - "failure" | _ => callback.failure(tx), - }) - .await; + crate::galoy::api::on_invoice_events(galoy(&config), params, callback).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn bolt12_errors_keep_the_legacy_blink_shape() { + let error = get_offer(&BlinkConfig::default(), None) + .await + .expect_err("Blink Bolt12 should remain unsupported"); + + assert!(matches!( + error, + ApiError::Nwc { ref code, ref message } + if code == "NOT_IMPLEMENTED" && message == "Bolt12 not implemented for Blink" + )); + } } diff --git a/crates/lni/blink/lib.rs b/crates/lni/blink/lib.rs index 1a326325..7abba647 100644 --- a/crates/lni/blink/lib.rs +++ b/crates/lni/blink/lib.rs @@ -49,6 +49,49 @@ impl Default for BlinkConfig { } } +impl From<&BlinkConfig> for crate::galoy::GaloyConfig { + fn from(config: &BlinkConfig) -> Self { + Self { + api_key: config.api_key.clone(), + base_url: config + .base_url + .clone() + .unwrap_or_else(|| "https://api.blink.sv/graphql".to_string()), + provider: crate::galoy::GaloyProvider { + id: "blink".to_string(), + name: "Blink".to_string(), + }, + wallet: crate::galoy::GaloyWalletConfig::Currency { + currency: "BTC".to_string(), + }, + invoice_operations: crate::galoy::GaloyInvoiceOperationsConfig { + create: crate::galoy::GaloyInvoiceOperation::BtcSats, + fee_probe: crate::galoy::GaloyInvoiceOperation::BtcSats, + }, + payment: crate::galoy::GaloyPaymentConfig { + response: crate::galoy::GaloyPaymentResponse::TransactionWithPreimage, + accepted_statuses: vec!["SUCCESS".to_string()], + status_mapping: None, + proof_unavailable_error_codes: vec![], + }, + capabilities: crate::galoy::GaloyCapabilities { + transaction_lookup: true, + transaction_history: true, + invoice_events: true, + onchain: true, + }, + permissions: crate::galoy::GaloyPermissionsMode::JwtIntrospection, + additional_headers: None, + http_timeout: config.http_timeout, + socks5_proxy: config.socks5_proxy.clone(), + accept_invalid_certs: config.accept_invalid_certs, + } + } +} + +/// Backward-compatible Blink wrapper around the generic Galoy implementation. +/// +/// Deprecated: prefer [`crate::galoy::GaloyNode`]. #[cfg_attr(feature = "napi_rs", napi(object))] #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] #[derive(Debug, Clone)] @@ -69,11 +112,9 @@ impl BlinkNode { #[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] impl BlinkNode { pub async fn get_permissions(&self) -> Result { - crate::permissions::parse_blink_token_permissions(&self.config.api_key).ok_or_else(|| { - ApiError::InvalidInput( - "Blink API keys cannot be introspected. Use a JWT-style token with scopes or manually test permissions against Blink GraphQL operations.".to_string(), - ) - }) + crate::galoy::GaloyNode::new((&self.config).into()) + .get_permissions() + .await } pub async fn get_info(&self) -> Result { @@ -117,9 +158,7 @@ impl BlinkNode { } pub async fn create_offer(&self, _params: CreateOfferParams) -> Result { - Err(ApiError::Api { - reason: "create_offer not implemented for BlinkNode".to_string(), - }) + crate::blink::api::bolt12_not_implemented() } pub async fn get_offer(&self, search: Option) -> Result { @@ -185,6 +224,9 @@ crate::impl_lightning_node!(BlinkNode); #[cfg(test)] mod tests { use super::*; + use crate::galoy::{ + GaloyInvoiceOperation, GaloyPaymentResponse, GaloyPermissionsMode, GaloyWalletConfig, + }; use crate::{ InvoiceType, OnchainFeePayer, OnchainFeePreference, OnchainFeePreferenceType, OnchainFeeSpeed, PrepareOnchainTransactionParams, @@ -196,6 +238,37 @@ mod tests { const ONCHAIN_SEND_CONFIRMATION: &str = "I_UNDERSTAND_THIS_BROADCASTS_BITCOIN"; + #[test] + fn supplies_legacy_blink_galoy_defaults() { + let galoy = crate::galoy::GaloyConfig::from(&BlinkConfig::default()); + assert_eq!(galoy.provider.id, "blink"); + assert_eq!(galoy.provider.name, "Blink"); + assert_eq!( + galoy.wallet, + GaloyWalletConfig::Currency { + currency: "BTC".to_string() + } + ); + assert_eq!( + galoy.invoice_operations.create, + GaloyInvoiceOperation::BtcSats + ); + assert_eq!( + galoy.invoice_operations.fee_probe, + GaloyInvoiceOperation::BtcSats + ); + assert_eq!( + galoy.payment.response, + GaloyPaymentResponse::TransactionWithPreimage + ); + assert_eq!(galoy.payment.accepted_statuses, ["SUCCESS"]); + assert_eq!(galoy.permissions, GaloyPermissionsMode::JwtIntrospection); + assert!(galoy.capabilities.transaction_lookup); + assert!(galoy.capabilities.transaction_history); + assert!(galoy.capabilities.invoice_events); + assert!(galoy.capabilities.onchain); + } + lazy_static! { static ref BASE_URL: String = { dotenv().ok(); diff --git a/crates/lni/blink/types.rs b/crates/lni/blink/types.rs index 47a4de34..40d1596e 100644 --- a/crates/lni/blink/types.rs +++ b/crates/lni/blink/types.rs @@ -1,360 +1 @@ -use serde::{Deserialize, Serialize}; - -// GraphQL request/response structures -#[derive(Debug, Serialize)] -pub struct GraphQLRequest { - pub query: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub variables: Option, -} - -#[derive(Debug, Deserialize)] -pub struct GraphQLResponse { - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub errors: Option>, -} - -#[derive(Debug, Deserialize)] -pub struct GraphQLError { - pub message: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub code: Option, -} - -// User and account structures -#[derive(Debug, Deserialize)] -pub struct MeQuery { - pub me: User, -} - -#[derive(Debug, Deserialize)] -pub struct User { - #[serde(rename = "defaultAccount")] - pub default_account: Account, -} - -#[derive(Debug, Deserialize)] -pub struct Account { - pub wallets: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub transactions: Option, -} - -#[derive(Debug, Deserialize)] -pub struct Wallet { - pub id: String, - #[serde(rename = "walletCurrency")] - pub wallet_currency: String, // "BTC" or "USD" - pub balance: i64, -} - -// Invoice creation structures -#[derive(Debug, Serialize)] -pub struct LnInvoiceCreateInput { - pub amount: String, // Amount in satoshis as string - #[serde(rename = "walletId")] - pub wallet_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub memo: Option, -} - -#[derive(Debug, Deserialize)] -pub struct LnInvoiceCreateResponse { - #[serde(rename = "lnInvoiceCreate")] - pub ln_invoice_create: LnInvoiceCreateResult, -} - -#[derive(Debug, Deserialize)] -pub struct LnInvoiceCreateResult { - #[serde(skip_serializing_if = "Option::is_none")] - pub invoice: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub errors: Option>, -} - -#[derive(Debug, Deserialize)] -pub struct Invoice { - #[serde(rename = "paymentRequest")] - pub payment_request: String, - #[serde(rename = "paymentHash")] - pub payment_hash: String, - #[serde(rename = "paymentSecret")] - pub payment_secret: String, - pub satoshis: i64, -} - -// Payment structures -#[derive(Debug, Serialize)] -pub struct LnInvoicePaymentInput { - #[serde(rename = "paymentRequest")] - pub payment_request: String, - #[serde(rename = "walletId")] - pub wallet_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub memo: Option, -} - -#[derive(Debug, Deserialize)] -pub struct LnInvoicePaymentSendResponse { - #[serde(rename = "lnInvoicePaymentSend")] - pub ln_invoice_payment_send: LnInvoicePaymentResult, -} - -#[derive(Debug, Deserialize)] -pub struct LnInvoicePaymentResult { - pub status: String, // "SUCCESS", "FAILURE", "PENDING" - pub transaction: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub errors: Option>, -} - -#[derive(Debug, Deserialize)] -pub struct LnInvoicePaymentTransaction { - #[serde(rename = "settlementVia")] - pub settlement_via: Option, -} - -// On-chain payment structures -#[derive(Debug, Deserialize)] -pub struct OnChainTxFeeResponse { - #[serde(rename = "onChainTxFee")] - pub on_chain_tx_fee: OnChainTxFee, -} - -#[derive(Debug, Deserialize)] -pub struct OnChainTxFee { - pub amount: Option, -} - -#[derive(Debug, Deserialize)] -pub struct OnChainPaymentSendResponse { - #[serde(rename = "onChainPaymentSend")] - pub on_chain_payment_send: OnChainPaymentSendResult, -} - -#[derive(Debug, Deserialize)] -pub struct OnChainPaymentSendResult { - pub status: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub transaction: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub errors: Option>, -} - -#[derive(Debug, Deserialize)] -pub struct OnChainPaymentTransaction { - pub id: Option, - #[serde(rename = "settlementAmount")] - pub settlement_amount: Option, - #[serde(rename = "settlementCurrency")] - pub settlement_currency: Option, - #[serde(rename = "settlementFee")] - pub settlement_fee: Option, - #[serde(rename = "settlementVia")] - pub settlement_via: Option, -} - -// Fee probe structures -#[derive(Debug, Serialize)] -pub struct LnInvoiceFeeProbeInput { - #[serde(rename = "paymentRequest")] - pub payment_request: String, - #[serde(rename = "walletId")] - pub wallet_id: String, -} - -#[derive(Debug, Deserialize)] -pub struct LnInvoiceFeeProbeResponse { - #[serde(rename = "lnInvoiceFeeProbe")] - pub ln_invoice_fee_probe: LnInvoiceFeeProbeResult, -} - -#[derive(Debug, Deserialize)] -pub struct LnInvoiceFeeProbeResult { - #[serde(skip_serializing_if = "Option::is_none")] - pub amount: Option, // Fee amount in satoshis - #[serde(skip_serializing_if = "Option::is_none")] - pub errors: Option>, -} - -// Transaction structures -#[derive(Debug, Deserialize)] -pub struct TransactionConnection { - pub edges: Vec, - #[serde(rename = "pageInfo")] - pub page_info: PageInfo, -} - -#[derive(Debug, Deserialize)] -pub struct TransactionEdge { - pub cursor: String, - pub node: Transaction, -} - -#[derive(Debug, Deserialize)] -pub struct Transaction { - pub id: String, - #[serde(rename = "createdAt")] - pub created_at: i64, // Unix timestamp - pub direction: String, // "SEND" or "RECEIVE" - pub status: String, // "SUCCESS", "FAILURE", "PENDING" - #[serde(skip_serializing_if = "Option::is_none")] - pub memo: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(rename = "settlementAmount")] - pub settlement_amount: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(rename = "settlementCurrency")] - pub settlement_currency: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(rename = "settlementFee")] - pub settlement_fee: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(rename = "settlementDisplayAmount")] - pub settlement_display_amount: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(rename = "settlementDisplayCurrency")] - pub settlement_display_currency: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(rename = "settlementDisplayFee")] - pub settlement_display_fee: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(rename = "settlementPrice")] - pub settlement_price: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(rename = "initiationVia")] - pub initiation_via: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(rename = "settlementVia")] - pub settlement_via: Option, -} - -#[derive(Debug, Deserialize)] -pub struct Price { - pub base: i64, - pub offset: i64, - #[serde(rename = "currencyUnit")] - pub currency_unit: String, -} - -#[derive(Debug, Deserialize)] -#[serde(tag = "__typename")] -pub enum InitiationVia { - #[serde(rename = "InitiationViaLn")] - InitiationViaLn { - #[serde(rename = "paymentHash")] - payment_hash: String, - }, - #[serde(rename = "InitiationViaOnChain")] - InitiationViaOnChain { - #[serde(skip_serializing_if = "Option::is_none")] - address: Option, - }, - #[serde(rename = "InitiationViaIntraLedger")] - InitiationViaIntraLedger {}, -} - -#[derive(Debug, Deserialize)] -#[serde(tag = "__typename")] -pub enum SettlementVia { - #[serde(rename = "SettlementViaLn")] - SettlementViaLn { - #[serde(rename = "preImage")] - pre_image: Option, - }, - #[serde(rename = "SettlementViaOnChain")] - SettlementViaOnChain { - #[serde(rename = "transactionHash")] - transaction_hash: Option, - }, - #[serde(rename = "SettlementViaIntraLedger")] - SettlementViaIntraLedger { - #[serde(rename = "preImage")] - pre_image: Option, - }, -} - -#[derive(Debug, Deserialize)] -pub struct PageInfo { - #[serde(rename = "hasNextPage")] - pub has_next_page: bool, - #[serde(rename = "hasPreviousPage")] - pub has_previous_page: bool, - #[serde(rename = "startCursor")] - pub start_cursor: Option, - #[serde(rename = "endCursor")] - pub end_cursor: Option, -} - -// Query variables for transactions -#[derive(Debug, Serialize)] -pub struct TransactionQueryVariables { - pub first: Option, - pub after: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub last: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub before: Option, -} - -#[derive(Debug, Deserialize)] -pub struct TransactionsQuery { - pub me: UserWithTransactions, -} - -#[derive(Debug, Deserialize)] -pub struct UserWithTransactions { - #[serde(rename = "defaultAccount")] - pub default_account: AccountWithTransactions, -} - -#[derive(Debug, Deserialize)] -pub struct AccountWithTransactions { - pub transactions: TransactionConnection, -} - -#[cfg(test)] -mod tests { - use super::*; - - fn send_preimage(settlement_type: &str) -> String { - let response: LnInvoicePaymentSendResponse = serde_json::from_value(serde_json::json!({ - "lnInvoicePaymentSend": { - "status": "SUCCESS", - "errors": [], - "transaction": { - "settlementVia": { - "__typename": settlement_type, - "preImage": "settled-preimage" - } - } - } - })) - .expect("payment response should deserialize"); - - match response - .ln_invoice_payment_send - .transaction - .and_then(|transaction| transaction.settlement_via) - { - Some(SettlementVia::SettlementViaLn { pre_image }) - | Some(SettlementVia::SettlementViaIntraLedger { pre_image }) => { - pre_image.unwrap_or_default() - } - _ => String::new(), - } - } - - #[test] - fn deserializes_ln_and_intra_ledger_send_preimages() { - assert_eq!(send_preimage("SettlementViaLn"), "settled-preimage"); - assert_eq!( - send_preimage("SettlementViaIntraLedger"), - "settled-preimage" - ); - } -} +pub use crate::galoy::types::*; diff --git a/crates/lni/cln/lib.rs b/crates/lni/cln/lib.rs index 152604c4..1403196e 100644 --- a/crates/lni/cln/lib.rs +++ b/crates/lni/cln/lib.rs @@ -487,10 +487,6 @@ mod tests { match NODE.list_transactions(params).await { Ok(txns) => { dbg!(&txns); - assert!( - txns.len() >= 0, - "Should contain at least zero or one transaction" - ); } Err(e) => { panic!("Failed to lookup transactions: {:?}", e); diff --git a/crates/lni/flash/lib.rs b/crates/lni/flash/lib.rs new file mode 100644 index 00000000..370a656b --- /dev/null +++ b/crates/lni/flash/lib.rs @@ -0,0 +1,325 @@ +use std::collections::HashMap; + +#[cfg(not(feature = "uniffi"))] +use crate::LightningNode; +use crate::{ + ApiError, CreateInvoiceParams, CreateOfferParams, ListTransactionsParams, LookupInvoiceParams, + NodeInfo, Offer, PayInvoiceParams, PayInvoiceResponse, Permissions, Transaction, +}; + +pub const DEFAULT_FLASH_GRAPHQL_URL: &str = "https://api.flashapp.me/graphql"; + +fn flash_fee_probe_operation(wallet_currency: &str) -> crate::galoy::GaloyInvoiceOperation { + if wallet_currency.eq_ignore_ascii_case("BTC") { + crate::galoy::GaloyInvoiceOperation::BtcSats + } else if wallet_currency.eq_ignore_ascii_case("USD") { + crate::galoy::GaloyInvoiceOperation::UsdCents + } else { + crate::galoy::GaloyInvoiceOperation::Unsupported + } +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Clone)] +pub struct FlashConfig { + pub api_key: String, + pub base_url: Option, + pub wallet_id: String, + pub wallet_currency: String, + pub additional_headers: Option>, + pub accepted_statuses: Option>, + pub http_timeout: Option, + pub socks5_proxy: Option, + pub accept_invalid_certs: Option, +} + +impl std::fmt::Debug for FlashConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let additional_header_names = self + .additional_headers + .as_ref() + .map(|headers| headers.keys().cloned().collect::>()); + f.debug_struct("FlashConfig") + .field("api_key", &"") + .field("base_url", &self.base_url) + .field("wallet_id", &self.wallet_id) + .field("wallet_currency", &self.wallet_currency) + .field("additional_header_names", &additional_header_names) + .field("accepted_statuses", &self.accepted_statuses) + .field("http_timeout", &self.http_timeout) + .field("socks5_proxy", &"") + .field("accept_invalid_certs", &self.accept_invalid_certs) + .finish() + } +} + +impl From<&FlashConfig> for crate::galoy::GaloyConfig { + fn from(config: &FlashConfig) -> Self { + Self { + api_key: config.api_key.clone(), + base_url: config + .base_url + .clone() + .unwrap_or_else(|| DEFAULT_FLASH_GRAPHQL_URL.to_string()), + provider: crate::galoy::GaloyProvider { + id: "flash".to_string(), + name: "Flash".to_string(), + }, + wallet: crate::galoy::GaloyWalletConfig::Explicit { + id: config.wallet_id.clone(), + currency: config.wallet_currency.clone(), + }, + invoice_operations: crate::galoy::GaloyInvoiceOperationsConfig { + create: crate::galoy::GaloyInvoiceOperation::Unsupported, + fee_probe: flash_fee_probe_operation(&config.wallet_currency), + }, + payment: crate::galoy::GaloyPaymentConfig { + response: crate::galoy::GaloyPaymentResponse::StatusOnly, + accepted_statuses: config.accepted_statuses.clone().unwrap_or_else(|| { + vec![ + "SUCCESS".to_string(), + "PENDING".to_string(), + "ALREADY_PAID".to_string(), + ] + }), + status_mapping: Some(crate::galoy::GaloyPaymentStatusMapping { + settled: vec!["SUCCESS".to_string(), "ALREADY_PAID".to_string()], + pending: vec!["PENDING".to_string()], + }), + proof_unavailable_error_codes: vec!["PROOF_UNAVAILABLE".to_string()], + }, + capabilities: crate::galoy::GaloyCapabilities { + transaction_lookup: false, + transaction_history: false, + invoice_events: false, + onchain: false, + }, + permissions: crate::galoy::GaloyPermissionsMode::Configured, + additional_headers: config.additional_headers.clone(), + http_timeout: config.http_timeout, + socks5_proxy: config.socks5_proxy.clone(), + accept_invalid_certs: config.accept_invalid_certs, + } + } +} + +/// Flash adapter backed by the generic Galoy GraphQL implementation. +/// +/// Status-only payments may return an empty preimage. Use +/// [`FlashNode::pay_invoice_with_status`] when the caller must distinguish a +/// resolved `PENDING` payment from settlement. +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +#[derive(Debug, Clone)] +pub struct FlashNode { + pub config: FlashConfig, +} + +#[cfg_attr(feature = "uniffi", uniffi::export)] +impl FlashNode { + #[cfg_attr(feature = "uniffi", uniffi::constructor)] + pub fn new(config: FlashConfig) -> Self { + Self { config } + } + + fn galoy(&self) -> crate::galoy::GaloyNode { + crate::galoy::GaloyNode::new((&self.config).into()) + } +} + +#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] +impl FlashNode { + pub async fn get_permissions(&self) -> Result { + self.galoy().get_permissions().await + } + + pub async fn get_info(&self) -> Result { + self.galoy().get_info().await + } + + pub async fn create_invoice( + &self, + params: CreateInvoiceParams, + ) -> Result { + self.galoy().create_invoice(params).await + } + + pub async fn pay_invoice( + &self, + params: PayInvoiceParams, + ) -> Result { + self.galoy().pay_invoice(params).await + } + + /// Pay an invoice while retaining Flash's accepted provider status. + pub async fn pay_invoice_with_status( + &self, + params: PayInvoiceParams, + ) -> Result { + self.galoy().pay_invoice_with_status(params).await + } + + pub async fn create_offer(&self, params: CreateOfferParams) -> Result { + self.galoy().create_offer(params).await + } + + pub async fn get_offer(&self, search: Option) -> Result { + self.galoy().get_offer(search).await + } + + pub async fn list_offers(&self, search: Option) -> Result, ApiError> { + self.galoy().list_offers(search).await + } + + pub async fn pay_offer( + &self, + offer: String, + amount_msats: i64, + payer_note: Option, + ) -> Result { + self.galoy() + .pay_offer(offer, amount_msats, payer_note) + .await + } + + pub async fn lookup_invoice( + &self, + params: LookupInvoiceParams, + ) -> Result { + self.galoy().lookup_invoice(params).await + } + + pub async fn list_transactions( + &self, + params: ListTransactionsParams, + ) -> Result, ApiError> { + self.galoy().list_transactions(params).await + } + + pub async fn decode(&self, value: String) -> Result { + self.galoy().decode(value).await + } + + pub async fn decode_offer(&self, offer: String) -> Result { + self.galoy().decode_offer(offer).await + } + + pub async fn on_invoice_events( + &self, + params: crate::types::OnInvoiceEventParams, + callback: std::sync::Arc, + ) { + self.galoy().on_invoice_events(params, callback).await + } +} + +crate::impl_lightning_node!(FlashNode); + +#[cfg(test)] +mod tests { + use super::*; + use crate::galoy::{ + GaloyInvoiceOperation, GaloyPaymentResponse, GaloyPermissionsMode, GaloyWalletConfig, + }; + + fn config() -> FlashConfig { + FlashConfig { + api_key: "top-secret-api-key".to_string(), + base_url: None, + wallet_id: "wallet-usd".to_string(), + wallet_currency: "USD".to_string(), + additional_headers: None, + accepted_statuses: None, + http_timeout: Some(60), + socks5_proxy: None, + accept_invalid_certs: Some(false), + } + } + + #[test] + fn supplies_flash_galoy_defaults() { + let galoy = crate::galoy::GaloyConfig::from(&config()); + assert_eq!(galoy.provider.id, "flash"); + assert_eq!(galoy.provider.name, "Flash"); + assert_eq!(galoy.base_url, DEFAULT_FLASH_GRAPHQL_URL); + assert_eq!( + galoy.wallet, + GaloyWalletConfig::Explicit { + id: "wallet-usd".to_string(), + currency: "USD".to_string(), + } + ); + assert_eq!( + galoy.invoice_operations.create, + GaloyInvoiceOperation::Unsupported + ); + assert_eq!( + galoy.invoice_operations.fee_probe, + GaloyInvoiceOperation::UsdCents + ); + assert_eq!(galoy.payment.response, GaloyPaymentResponse::StatusOnly); + assert_eq!( + galoy.payment.accepted_statuses, + ["SUCCESS", "PENDING", "ALREADY_PAID"] + ); + assert_eq!(galoy.permissions, GaloyPermissionsMode::Configured); + assert!(!galoy.capabilities.transaction_lookup); + assert!(!galoy.capabilities.transaction_history); + assert!(!galoy.capabilities.invoice_events); + assert!(!galoy.capabilities.onchain); + } + + #[tokio::test] + async fn reports_invoice_creation_as_unsupported() { + let node = FlashNode::new(config()); + let permissions = node + .get_permissions() + .await + .expect("configured permissions should resolve"); + assert!(!permissions.create_invoice); + + let error = node + .create_invoice(CreateInvoiceParams { + amount_msats: Some(123_000), + ..Default::default() + }) + .await + .expect_err("Flash invoice creation must be disabled"); + assert!(matches!( + error, + ApiError::Nwc { ref code, ref message } + if code == "NOT_IMPLEMENTED" && message.contains("[flash]") + )); + } + + #[test] + fn arbitrary_non_btc_wallet_does_not_select_usd_operations() { + let mut config = config(); + config.wallet_currency = "JMD".to_string(); + let galoy = crate::galoy::GaloyConfig::from(&config); + assert_eq!( + galoy.invoice_operations.fee_probe, + GaloyInvoiceOperation::Unsupported + ); + } + + #[test] + fn allows_the_default_endpoint_to_be_overridden() { + let mut config = config(); + config.base_url = Some("https://flash.test/custom-graphql".to_string()); + let galoy = crate::galoy::GaloyConfig::from(&config); + assert_eq!(galoy.base_url, "https://flash.test/custom-graphql"); + } + + #[test] + fn debug_redacts_credentials_and_header_values() { + let mut config = config(); + config.additional_headers = Some(HashMap::from([( + "x-flash-secret".to_string(), + "header-secret".to_string(), + )])); + let debug = format!("{config:?}"); + assert!(!debug.contains("top-secret-api-key")); + assert!(!debug.contains("header-secret")); + assert!(debug.contains("x-flash-secret")); + } +} diff --git a/crates/lni/galoy/api.rs b/crates/lni/galoy/api.rs new file mode 100644 index 00000000..4bb2e2a7 --- /dev/null +++ b/crates/lni/galoy/api.rs @@ -0,0 +1,2260 @@ +use std::str::FromStr; +use std::time::Duration; + +use lightning_invoice::Bolt11Invoice; +use once_cell::sync::Lazy; +use regex::Regex; + +use super::types::*; +use super::{ + GaloyConfig, GaloyInvoiceOperation, GaloyPaymentOutcome, GaloyPaymentResponse, + GaloyPaymentState, GaloyWalletConfig, +}; +use crate::error_normalization::{ + map_provider_message, nwc_error, provider_error_from_response, transport_error, + ProviderErrorInfo, +}; +use crate::types::NodeInfo; +use crate::{ + ApiError, CreateInvoiceParams, InvoiceType, Offer, OnInvoiceEventCallback, + OnInvoiceEventParams, OnchainFeePayer, OnchainFeePreference, OnchainFeePreferenceType, + OnchainFeeSpeed, OnchainTransaction, PayInvoiceParams, PayInvoiceResponse, PayOnchainOptions, + PayOnchainResponse, PrepareOnchainTransactionParams, Transaction, +}; +use reqwest::header; + +static SENSITIVE_TEXT: Lazy = Lazy::new(|| { + Regex::new( + r"(?i)\b(?:(?:lnbc|lntb|lnbcrt)[a-z0-9]+|(?:lno|lnr|lni)1[a-z0-9]+)\b|\b[0-9a-f]{64}\b", + ) + .expect("sensitive-value regex must compile") +}); + +static SENSITIVE_LABEL_VALUE: Lazy = Lazy::new(|| { + Regex::new( + r"(?i)\b(api[_-]?key|x-api-key|authorization|access[_-]?token|token|payment[_-]?request|payment[_-]?hash|payment[_-]?secret|preimage)\s*[:=]\s*[^\s,;]+", + ) + .expect("sensitive-label regex must compile") +}); + +fn map_galoy_provider_error(info: &ProviderErrorInfo) -> Option<&'static str> { + let code = info.code.as_deref().unwrap_or_default().to_uppercase(); + if code.contains("AUTHENTICATION") || code.contains("UNAUTHORIZED") { + return Some("UNAUTHORIZED"); + } + if code.contains("FORBIDDEN") || code.contains("PERMISSION") || code.contains("SCOPE") { + return Some("RESTRICTED"); + } + if code.contains("INSUFFICIENT") || code.contains("BALANCE") { + return Some("INSUFFICIENT_BALANCE"); + } + if code.contains("LIMIT") || code.contains("QUOTA") { + return Some("QUOTA_EXCEEDED"); + } + if code.contains("INVALID_INVOICE") || code.contains("NO_ROUTE") || code.contains("PAYMENT") { + return Some("PAYMENT_FAILED"); + } + + map_provider_message(info.message.as_deref()) +} + +fn redact_json_value(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(fields) => { + for (key, value) in fields { + let normalized = key + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .collect::() + .to_ascii_lowercase(); + if matches!( + normalized.as_str(), + "apikey" + | "xapikey" + | "authorization" + | "accesstoken" + | "token" + | "paymentrequest" + | "paymenthash" + | "paymentsecret" + | "preimage" + ) { + *value = serde_json::Value::String("".to_string()); + } else { + redact_json_value(value); + } + } + } + serde_json::Value::Array(values) => { + for value in values { + redact_json_value(value); + } + } + serde_json::Value::String(text) => { + *text = SENSITIVE_TEXT.replace_all(text, "").to_string(); + } + _ => {} + } +} + +fn sanitize_text(config: &GaloyConfig, text: impl AsRef) -> String { + let mut text = if config.api_key.is_empty() { + text.as_ref().to_string() + } else { + text.as_ref().replace(&config.api_key, "") + }; + if let Some(proxy_url) = config.socks5_proxy.as_deref() { + if !proxy_url.is_empty() { + text = text.replace(proxy_url, ""); + } + } + if let Ok(mut value) = serde_json::from_str::(&text) { + redact_json_value(&mut value); + return value.to_string(); + } + let text = SENSITIVE_TEXT.replace_all(&text, ""); + SENSITIVE_LABEL_VALUE + .replace_all(&text, "$1=") + .to_string() +} + +fn provider_nwc_error(config: &GaloyConfig, code: &str, message: impl AsRef) -> ApiError { + nwc_error( + code, + format!( + "[{}] {}", + config.provider.id, + sanitize_text(config, message) + ), + ) +} + +fn attach_provider(config: &GaloyConfig, error: ApiError) -> ApiError { + match error { + ApiError::Nwc { code, message } => provider_nwc_error(config, &code, message), + other => other, + } +} + +fn galoy_graphql_error( + config: &GaloyConfig, + errors: &[GraphQLError], + fallback_code: &str, +) -> ApiError { + let first = errors.first(); + let info = ProviderErrorInfo { + code: first.and_then(|error| error.code.clone()), + status: None, + message: Some( + errors + .iter() + .map(|error| error.message.clone()) + .collect::>() + .join(", "), + ), + }; + let code = map_galoy_provider_error(&info).unwrap_or(fallback_code); + provider_nwc_error( + config, + code, + info.message + .unwrap_or_else(|| format!("{} GraphQL error", config.provider.name)), + ) +} + +fn client(config: &GaloyConfig) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + + if let Some(additional_headers) = &config.additional_headers { + for (name, value) in additional_headers { + let Ok(name) = header::HeaderName::from_str(name) else { + return Err(ApiError::InvalidInput(format!( + "Invalid additional Galoy header name: {}", + name + ))); + }; + if name == header::CONTENT_TYPE || name.as_str().eq_ignore_ascii_case("x-api-key") { + continue; + } + let value = header::HeaderValue::from_str(value).map_err(|_| { + ApiError::InvalidInput(format!( + "Invalid value for additional Galoy header {}", + name + )) + })?; + headers.insert(name, value); + } + } + + let api_key_header = header::HeaderValue::from_str(&config.api_key) + .map_err(|_| ApiError::InvalidInput("Invalid Galoy API key header value".to_string()))?; + headers.insert("X-API-KEY", api_key_header); + + headers.insert( + "Content-Type", + header::HeaderValue::from_static("application/json"), + ); + + let mut client_builder = reqwest::Client::builder().default_headers(headers); + if config.accept_invalid_certs.unwrap_or(false) { + client_builder = client_builder.danger_accept_invalid_certs(true); + } + if let Some(http_timeout) = config.http_timeout { + client_builder = + client_builder.timeout(std::time::Duration::from_secs(http_timeout.max(0) as u64)); + } + if let Some(proxy_url) = config + .socks5_proxy + .as_deref() + .filter(|proxy_url| !proxy_url.is_empty()) + { + let proxy = reqwest::Proxy::all(proxy_url).map_err(|error| ApiError::Http { + reason: sanitize_text( + config, + format!( + "Failed to configure {} SOCKS5 proxy: {}", + config.provider.name, error + ), + ), + })?; + client_builder = client_builder.proxy(proxy); + } + + client_builder.build().map_err(|error| ApiError::Http { + reason: sanitize_text( + config, + format!( + "Failed to build {} HTTP client: {}", + config.provider.name, error + ), + ), + }) +} + +async fn execute_graphql_query( + config: &GaloyConfig, + query: &str, + variables: Option, + operation: &str, +) -> Result +where + T: for<'de> serde::Deserialize<'de>, +{ + let client = client(config)?; + let request = GraphQLRequest { + query: query.to_string(), + variables, + }; + + let response = client + .post(&config.base_url) + .json(&request) + .send() + .await + .map_err(|e| transport_error(&config.provider.id, operation, e))?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(attach_provider( + config, + provider_error_from_response( + &config.provider.id, + operation, + status, + sanitize_text(config, error_text), + map_galoy_provider_error, + ), + )); + } + + let response_text = response.text().await.map_err(|e| ApiError::Http { + reason: format!("Failed to read GraphQL response: {}", e), + })?; + let graphql_response: GraphQLResponse = + serde_json::from_str(&response_text).map_err(|e| ApiError::Json { + reason: format!( + "Failed to parse GraphQL response: {} - Response: {}", + e, + sanitize_text(config, &response_text) + ), + })?; + + if let Some(errors) = graphql_response.errors { + return Err(galoy_graphql_error(config, &errors, "OTHER")); + } + + graphql_response.data.ok_or_else(|| { + provider_nwc_error( + config, + "INTERNAL", + format!("No data in {} GraphQL response", config.provider.name), + ) + }) +} + +async fn resolve_wallet(config: &GaloyConfig) -> Result { + if let GaloyWalletConfig::Explicit { id, currency } = &config.wallet { + return Ok(Wallet { + id: id.clone(), + wallet_currency: currency.clone(), + balance: None, + }); + } + + let requested_currency = config.wallet.currency(); + let query = r#" + query Me { + me { + defaultAccount { + wallets { + id + walletCurrency + balance + } + } + } + } + "#; + + let response: MeQuery = execute_graphql_query(config, query, None, "get_info").await?; + + response + .me + .default_account + .wallets + .into_iter() + .find(|wallet| { + wallet + .wallet_currency + .eq_ignore_ascii_case(requested_currency) + }) + .ok_or_else(|| { + provider_nwc_error( + config, + "NOT_FOUND", + format!( + "No {} wallet found for {}", + requested_currency, config.provider.name + ), + ) + }) +} + +fn assert_onchain_enabled(config: &GaloyConfig) -> Result<(), ApiError> { + if config.capabilities.onchain { + Ok(()) + } else { + Err(provider_nwc_error( + config, + "NOT_IMPLEMENTED", + format!( + "On-chain payments are disabled for {}", + config.provider.name + ), + )) + } +} + +async fn resolve_btc_onchain_wallet(config: &GaloyConfig) -> Result { + assert_onchain_enabled(config)?; + let wallet = resolve_wallet(config).await?; + if wallet.wallet_currency.eq_ignore_ascii_case("BTC") { + Ok(wallet) + } else { + Err(provider_nwc_error( + config, + "NOT_IMPLEMENTED", + format!( + "On-chain payments require a BTC wallet for {}", + config.provider.name + ), + )) + } +} + +fn assert_valid_onchain_amount(amount_sats: i64) -> Result<(), ApiError> { + if amount_sats <= 0 { + return Err(ApiError::InvalidInput( + "pay_onchain requires a positive amount_sats".to_string(), + )); + } + + Ok(()) +} + +fn default_onchain_fee() -> OnchainFeePreference { + OnchainFeePreference { + preference_type: OnchainFeePreferenceType::Default, + speed: None, + target_conf: None, + sats_per_vbyte: None, + backend: None, + } +} + +fn resolve_galoy_fee_speed(fee: &OnchainFeePreference) -> Result<&'static str, ApiError> { + match fee.preference_type { + OnchainFeePreferenceType::Default => Ok("FAST"), + OnchainFeePreferenceType::Speed => match fee.speed.clone().unwrap_or(OnchainFeeSpeed::Fast) + { + OnchainFeeSpeed::Fast => Ok("FAST"), + OnchainFeeSpeed::Normal => Ok("MEDIUM"), + OnchainFeeSpeed::Slow => Ok("SLOW"), + OnchainFeeSpeed::Free => Err(ApiError::InvalidInput( + "Galoy on-chain payments do not support free fee speed".to_string(), + )), + }, + OnchainFeePreferenceType::Backend + | OnchainFeePreferenceType::TargetConf + | OnchainFeePreferenceType::SatsPerVbyte => Err(ApiError::InvalidInput(format!( + "Galoy on-chain payments do not support {:?} fee preferences", + fee.preference_type + ))), + } +} + +fn resolve_galoy_fee_payer( + fee_payer: Option, +) -> Result { + match fee_payer.unwrap_or(OnchainFeePayer::Sender) { + OnchainFeePayer::Sender => Ok(OnchainFeePayer::Sender), + OnchainFeePayer::Recipient => Err(ApiError::InvalidInput( + "Galoy on-chain payments only support sender-paid fees".to_string(), + )), + } +} + +fn assert_valid_guardrail_limit(value: f64, name: &str) -> Result<(), ApiError> { + if !value.is_finite() || value < 0.0 { + return Err(ApiError::InvalidInput(format!( + "{} must be a non-negative finite number", + name + ))); + } + + Ok(()) +} + +fn assert_onchain_fee_guardrail( + transaction: &OnchainTransaction, + options: PayOnchainOptions, +) -> Result<(), ApiError> { + if options.dangerously_disable_fee_guardrail { + return Ok(()); + } + + let default_guardrail = crate::types::OnchainFeeGuardrail::default(); + let guardrail = options.fee_guardrail.unwrap_or_default(); + let max_fee_sats = guardrail + .max_fee_sats + .or(default_guardrail.max_fee_sats) + .unwrap_or(crate::types::DEFAULT_ONCHAIN_MAX_FEE_SATS); + let max_fee_percent = guardrail + .max_fee_percent + .or(default_guardrail.max_fee_percent) + .unwrap_or(crate::types::DEFAULT_ONCHAIN_MAX_FEE_PERCENT); + + if max_fee_sats < 0 { + return Err(ApiError::InvalidInput( + "fee_guardrail.max_fee_sats must be non-negative".to_string(), + )); + } + assert_valid_guardrail_limit(max_fee_percent, "fee_guardrail.max_fee_percent")?; + + let fee_sats = transaction.fee_sats.ok_or_else(|| { + ApiError::InvalidInput( + "Cannot pay on-chain transaction because fee_sats is unknown. Re-prepare the transaction or pass dangerously_disable_fee_guardrail: true.".to_string(), + ) + })?; + + if fee_sats < 0 { + return Err(ApiError::InvalidInput( + "Cannot pay on-chain transaction because fee_sats is invalid".to_string(), + )); + } + + if transaction.amount_sats <= 0 { + return Err(ApiError::InvalidInput( + "Cannot pay on-chain transaction because amount_sats is invalid".to_string(), + )); + } + + if fee_sats > max_fee_sats { + return Err(ApiError::InvalidInput(format!( + "On-chain fee {} sats exceeds guardrail max_fee_sats {}", + fee_sats, max_fee_sats + ))); + } + + let fee_percent = (fee_sats as f64 / transaction.amount_sats as f64) * 100.0; + if fee_percent > max_fee_percent { + return Err(ApiError::InvalidInput(format!( + "On-chain fee {:.2}% exceeds guardrail max_fee_percent {}%", + fee_percent, max_fee_percent + ))); + } + + Ok(()) +} + +fn normalize_onchain_state(status: &str) -> String { + match status.to_uppercase().as_str() { + "PENDING" => "pending".to_string(), + "SUCCESS" | "ALREADY_PAID" => "completed".to_string(), + "FAILED" | "FAILURE" => "failed".to_string(), + state => state.to_lowercase(), + } +} + +fn galoy_transaction_amount_to_sats(amount: Option, currency: Option<&String>) -> Option { + if currency.map(|currency| currency == "BTC").unwrap_or(false) { + amount.map(|amount| amount.abs()) + } else { + None + } +} + +fn galoy_transaction_memo(transaction: &OnchainTransaction) -> Option { + let raw = transaction.raw.as_deref()?; + let value: serde_json::Value = serde_json::from_str(raw).ok()?; + value + .get("memo") + .and_then(|memo| memo.as_str()) + .filter(|memo| !memo.is_empty()) + .map(|memo| memo.to_string()) +} + +pub async fn get_info(config: &GaloyConfig) -> Result { + let wallet = resolve_wallet(config).await?; + let balance_sats = if wallet.wallet_currency.eq_ignore_ascii_case("BTC") { + wallet.balance.unwrap_or(0) + } else { + 0 + }; + let balance_msats = balance_sats * 1000; + + Ok(NodeInfo { + alias: format!("{} Node", config.provider.name), + color: "".to_string(), + pubkey: "".to_string(), + network: "mainnet".to_string(), + block_height: 0, + block_hash: "".to_string(), + send_balance_msat: balance_msats, + receive_balance_msat: balance_msats, + fee_credit_balance_msat: 0, + unsettled_send_balance_msat: 0, + unsettled_receive_balance_msat: 0, + pending_open_send_balance: 0, + pending_open_receive_balance: 0, + }) +} + +pub async fn create_invoice( + config: &GaloyConfig, + invoice_params: CreateInvoiceParams, +) -> Result { + match invoice_params.get_invoice_type() { + InvoiceType::Bolt11 => { + match config.invoice_operations.create { + GaloyInvoiceOperation::Unsupported => { + return Err(provider_nwc_error( + config, + "NOT_IMPLEMENTED", + format!("Invoice creation is disabled for {}", config.provider.name), + )); + } + GaloyInvoiceOperation::UsdCents => { + return Err(provider_nwc_error( + config, + "NOT_IMPLEMENTED", + format!( + "{} USD invoice creation requires a USD-cent amount, which cannot be represented safely by LNI's amount_msats input", + config.provider.name + ), + )); + } + GaloyInvoiceOperation::BtcSats => {} + } + + let wallet = resolve_wallet(config).await?; + let field = "lnInvoiceCreate"; + let input_type = "LnInvoiceCreateInput"; + + let amount_sats = invoice_params.amount_msats.unwrap_or(0) / 1000; + + let query = r#" + mutation __FIELD__($input: __INPUT__!) { + __FIELD__(input: $input) { + invoice { + paymentRequest + paymentHash + satoshis + } + errors { + code + message + path + } + } + } + "# + .replace("__FIELD__", field) + .replace("__INPUT__", input_type); + + let variables = serde_json::json!({ + "input": { + "amount": amount_sats.to_string(), + "walletId": wallet.id, + "memo": invoice_params.description + } + }); + + let response: LnInvoiceCreateResponse = + execute_graphql_query(config, &query, Some(variables), "make_invoice").await?; + let result = response.ln_invoice_create.ok_or_else(|| { + provider_nwc_error( + config, + "INTERNAL", + format!("No {} result in response", field), + ) + })?; + + if let Some(errors) = &result.errors { + if !errors.is_empty() { + return Err(galoy_graphql_error(config, errors, "OTHER")); + } + } + + let invoice = result + .invoice + .ok_or_else(|| provider_nwc_error(config, "INTERNAL", "No invoice in response"))?; + + // Parse the BOLT11 invoice to get expiry + let expires_at = match Bolt11Invoice::from_str(&invoice.payment_request) { + Ok(bolt11) => bolt11 + .expires_at() + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0), + Err(_) => 0, + }; + + Ok(Transaction { + type_: "incoming".to_string(), + invoice: invoice.payment_request, + preimage: "".to_string(), + payment_hash: invoice.payment_hash, + amount_msats: invoice.satoshis * 1000, + fees_paid: 0, + created_at: chrono::Utc::now().timestamp(), + expires_at, + settled_at: 0, + description: invoice_params.description.unwrap_or_default(), + description_hash: invoice_params.description_hash.unwrap_or_default(), + payer_note: Some("".to_string()), + external_id: Some("".to_string()), + }) + } + InvoiceType::Bolt12 => Err(provider_nwc_error( + config, + "NOT_IMPLEMENTED", + format!("Bolt12 is not implemented for {}", config.provider.name), + )), + } +} + +pub async fn pay_invoice( + config: &GaloyConfig, + invoice_params: PayInvoiceParams, +) -> Result { + Ok(pay_invoice_with_status(config, invoice_params) + .await? + .payment) +} + +pub async fn pay_invoice_with_status( + config: &GaloyConfig, + invoice_params: PayInvoiceParams, +) -> Result { + let parses_without_amount = Bolt11Invoice::from_str(&invoice_params.invoice) + .map(|invoice| invoice.amount_milli_satoshis().is_none()) + .unwrap_or(false); + if parses_without_amount { + return Err(ApiError::InvalidInput(format!( + "{} cannot pay amountless BOLT11 invoices because Galoy's payment mutation has no amount field", + config.provider.name + ))); + } + + let wallet = resolve_wallet(config).await?; + let fee_operation = config.invoice_operations.fee_probe; + let (fee_field, fee_input_type) = match fee_operation { + GaloyInvoiceOperation::BtcSats => ("lnInvoiceFeeProbe", "LnInvoiceFeeProbeInput"), + GaloyInvoiceOperation::UsdCents => ("lnUsdInvoiceFeeProbe", "LnUsdInvoiceFeeProbeInput"), + GaloyInvoiceOperation::Unsupported => { + return Err(provider_nwc_error( + config, + "NOT_IMPLEMENTED", + format!( + "Lightning fee probing is not configured for {} wallet currency {}", + config.provider.name, wallet.wallet_currency + ), + )); + } + }; + + let fee_probe_query = r#" + mutation __FIELD__($input: __INPUT__!) { + __FIELD__(input: $input) { + errors { + code + message + path + } + amount + } + } + "# + .replace("__FIELD__", fee_field) + .replace("__INPUT__", fee_input_type); + + let fee_probe_variables = serde_json::json!({ + "input": { + "paymentRequest": invoice_params.invoice, + "walletId": wallet.id + } + }); + + let fee_response: LnInvoiceFeeProbeResponse = execute_graphql_query( + config, + &fee_probe_query, + Some(fee_probe_variables), + "pay_invoice", + ) + .await?; + let fee_result = match fee_operation { + GaloyInvoiceOperation::BtcSats => fee_response.ln_invoice_fee_probe, + GaloyInvoiceOperation::UsdCents => fee_response.ln_usd_invoice_fee_probe, + GaloyInvoiceOperation::Unsupported => unreachable!("unsupported fee probes return above"), + } + .ok_or_else(|| { + provider_nwc_error( + config, + "INTERNAL", + format!("No {} result in response", fee_field), + ) + })?; + + if let Some(errors) = &fee_result.errors { + if !errors.is_empty() { + return Err(galoy_graphql_error(config, errors, "PAYMENT_FAILED")); + } + } + let fee_msats = if fee_operation == GaloyInvoiceOperation::BtcSats { + fee_result.amount.unwrap_or(0) * 1000 + } else { + 0 + }; + + let transaction_selection = match config.payment.response { + GaloyPaymentResponse::TransactionWithPreimage => { + r#" + transaction { + settlementVia { + __typename + ... on SettlementViaLn { + preImage + } + ... on SettlementViaIntraLedger { + preImage + } + } + } + "# + } + GaloyPaymentResponse::StatusOnly => "", + }; + let payment_query = r#" + mutation LnInvoicePaymentSend($input: LnInvoicePaymentInput!) { + lnInvoicePaymentSend(input: $input) { + status + errors { + message + path + code + } + __TRANSACTION_SELECTION__ + } + } + "# + .replace("__TRANSACTION_SELECTION__", transaction_selection); + + let payment_variables = serde_json::json!({ + "input": { + "paymentRequest": invoice_params.invoice, + "walletId": wallet.id + } + }); + + let payment_response: LnInvoicePaymentSendResponse = execute_graphql_query( + config, + &payment_query, + Some(payment_variables), + "pay_invoice", + ) + .await?; + + let status = payment_response.ln_invoice_payment_send.status.clone(); + if !config.payment.accepted_statuses.contains(&status) { + if let Some(errors) = &payment_response.ln_invoice_payment_send.errors { + if !errors.is_empty() { + let provider_message = errors + .iter() + .map(|error| error.message.as_str()) + .collect::>() + .join(", "); + let info = ProviderErrorInfo { + code: errors.first().and_then(|error| error.code.clone()), + status: None, + message: Some(provider_message.clone()), + }; + return Err(provider_nwc_error( + config, + map_galoy_provider_error(&info).unwrap_or("PAYMENT_FAILED"), + format!( + "{} payment was not accepted with status {} ({})", + config.provider.name, status, provider_message + ), + )); + } + } + return Err(provider_nwc_error( + config, + if status == "FAILED" || status == "FAILURE" { + "PAYMENT_FAILED" + } else { + "OTHER" + }, + format!( + "{} payment was not accepted with status {}", + config.provider.name, status + ), + )); + } + + let preimage = match payment_response + .ln_invoice_payment_send + .transaction + .as_ref() + .and_then(|transaction| transaction.settlement_via.as_ref()) + { + Some(SettlementVia::SettlementViaLn { pre_image }) + | Some(SettlementVia::SettlementViaIntraLedger { pre_image }) => { + pre_image.clone().unwrap_or_default() + } + _ => "".to_string(), + }; + + if let Some(errors) = &payment_response.ln_invoice_payment_send.errors { + let unexpected_errors = errors + .iter() + .filter(|error| { + let proof_unavailable = preimage.is_empty() + && error.code.as_deref().is_some_and(|code| { + config + .payment + .proof_unavailable_error_codes + .iter() + .any(|accepted| accepted.eq_ignore_ascii_case(code)) + }); + !proof_unavailable + }) + .cloned() + .collect::>(); + if !unexpected_errors.is_empty() { + return Err(galoy_graphql_error( + config, + &unexpected_errors, + "PAYMENT_FAILED", + )); + } + } + + // Extract payment hash from the BOLT11 invoice + let payment_hash = match Bolt11Invoice::from_str(&invoice_params.invoice) { + Ok(invoice) => format!("{:x}", invoice.payment_hash()), + Err(_) => "".to_string(), + }; + let state = if let Some(mapping) = &config.payment.status_mapping { + if mapping.settled.contains(&status) { + GaloyPaymentState::Settled + } else if mapping.pending.contains(&status) { + GaloyPaymentState::Pending + } else { + GaloyPaymentState::Accepted + } + } else { + GaloyPaymentState::Accepted + }; + + Ok(GaloyPaymentOutcome { + payment: PayInvoiceResponse { + payment_hash, + preimage, + fee_msats, + }, + state, + provider_status: status, + }) +} + +pub async fn prepare_onchain_transaction( + config: &GaloyConfig, + params: PrepareOnchainTransactionParams, +) -> Result { + assert_onchain_enabled(config)?; + assert_valid_onchain_amount(params.amount_sats)?; + + let fee = params.fee.clone().unwrap_or_else(default_onchain_fee); + let fee_payer = resolve_galoy_fee_payer(params.fee_payer.clone())?; + let speed = resolve_galoy_fee_speed(&fee)?; + let wallet_id = resolve_btc_onchain_wallet(config).await?.id; + + let query = r#" + query onChainTxFee($walletId: WalletId!, $address: OnChainAddress!, $amount: SatAmount!, $speed: PayoutSpeed!) { + onChainTxFee(walletId: $walletId, address: $address, amount: $amount, speed: $speed) { + amount + } + } + "#; + + let variables = serde_json::json!({ + "walletId": wallet_id, + "address": params.address.clone(), + "amount": params.amount_sats, + "speed": speed, + }); + + let response: OnChainTxFeeResponse = execute_graphql_query( + config, + query, + Some(variables), + "prepare_onchain_transaction", + ) + .await?; + let fee_sats = response.on_chain_tx_fee.amount; + + Ok(OnchainTransaction { + id: None, + address: params.address, + amount_sats: params.amount_sats, + fee_sats, + total_amount_sats: fee_sats.map(|fee_sats| params.amount_sats + fee_sats), + recipient_amount_sats: Some(params.amount_sats), + fee_payer, + fee, + expires_at: None, + estimated_delivery_seconds: None, + raw: Some( + serde_json::json!({ + "speed": speed, + "memo": params.description, + }) + .to_string(), + ), + }) +} + +pub async fn pay_onchain( + config: &GaloyConfig, + transaction: OnchainTransaction, +) -> Result { + pay_onchain_with_options(config, transaction, PayOnchainOptions::default()).await +} + +pub async fn pay_onchain_with_options( + config: &GaloyConfig, + transaction: OnchainTransaction, + options: PayOnchainOptions, +) -> Result { + assert_onchain_enabled(config)?; + assert_valid_onchain_amount(transaction.amount_sats)?; + let _fee_payer = resolve_galoy_fee_payer(Some(transaction.fee_payer.clone()))?; + let speed = resolve_galoy_fee_speed(&transaction.fee)?; + assert_onchain_fee_guardrail(&transaction, options)?; + + let wallet_id = resolve_btc_onchain_wallet(config).await?.id; + let memo = galoy_transaction_memo(&transaction); + let query = r#" + mutation onChainPaymentSend($input: OnChainPaymentSendInput!) { + onChainPaymentSend(input: $input) { + status + transaction { + id + settlementAmount + settlementCurrency + settlementFee + settlementVia { + __typename + ... on SettlementViaOnChain { + transactionHash + } + } + } + errors { + message + } + } + } + "#; + + let variables = serde_json::json!({ + "input": { + "address": transaction.address.clone(), + "amount": transaction.amount_sats, + "walletId": wallet_id, + "memo": memo, + "speed": speed, + } + }); + + let response: OnChainPaymentSendResponse = + execute_graphql_query(config, query, Some(variables), "pay_onchain").await?; + let payment = response.on_chain_payment_send; + + if let Some(errors) = &payment.errors { + if !errors.is_empty() { + return Err(galoy_graphql_error(config, errors, "PAYMENT_FAILED")); + } + } + + let payment_transaction = payment.transaction; + let payment_id = payment_transaction.as_ref().and_then(|tx| tx.id.clone()); + let txid = payment_transaction + .as_ref() + .and_then(|tx| match tx.settlement_via.as_ref() { + Some(SettlementVia::SettlementViaOnChain { transaction_hash }) => { + transaction_hash.clone() + } + _ => None, + }); + let fee_sats = payment_transaction + .as_ref() + .and_then(|tx| { + galoy_transaction_amount_to_sats(tx.settlement_fee, tx.settlement_currency.as_ref()) + }) + .or(transaction.fee_sats); + let amount_sats = payment_transaction + .as_ref() + .and_then(|tx| { + galoy_transaction_amount_to_sats(tx.settlement_amount, tx.settlement_currency.as_ref()) + }) + .unwrap_or(transaction.amount_sats); + + Ok(PayOnchainResponse { + payment_id, + txid, + state: normalize_onchain_state(&payment.status), + address: transaction.address, + amount_sats, + fee_sats, + total_amount_sats: fee_sats + .map(|fee_sats| amount_sats + fee_sats) + .or(transaction.total_amount_sats), + recipient_amount_sats: transaction + .recipient_amount_sats + .or(Some(transaction.amount_sats)), + created_at: None, + raw: None, + }) +} + +pub async fn decode(str: String) -> Result { + crate::utils::decode_bolt11(str) +} + +pub async fn decode_offer(offer: String) -> Result { + crate::utils::decode_offer(offer) +} + +pub fn not_implemented( + config: &GaloyConfig, + feature: &str, + _operation: &str, +) -> Result { + Err(provider_nwc_error( + config, + "NOT_IMPLEMENTED", + format!( + "{} is not implemented for {}", + feature, config.provider.name + ), + )) +} + +pub async fn get_offer(config: &GaloyConfig, _search: Option) -> Result { + not_implemented(config, "Bolt12", "lookup_invoice") +} + +pub async fn list_offers( + config: &GaloyConfig, + _search: Option, +) -> Result, ApiError> { + not_implemented(config, "Bolt12", "list_transactions") +} + +pub async fn create_offer( + config: &GaloyConfig, + _amount_msats: Option, + _description: Option, + _expiry: Option, +) -> Result { + not_implemented(config, "Bolt12", "make_invoice") +} + +pub async fn fetch_invoice_from_offer( + config: &GaloyConfig, + _offer: String, + _amount_msats: i64, + _payer_note: Option, +) -> Result { + not_implemented(config, "Bolt12", "pay_invoice") +} + +pub async fn pay_offer( + config: &GaloyConfig, + _offer: String, + _amount_msats: i64, + _payer_note: Option, +) -> Result { + not_implemented(config, "Bolt12", "pay_invoice") +} + +pub async fn lookup_invoice( + config: &GaloyConfig, + payment_hash: Option, + from: Option, + limit: Option, + search: Option, +) -> Result { + if !config.capabilities.transaction_lookup { + return not_implemented(config, "Transaction lookup", "lookup_invoice"); + } + let target_payment_hash = payment_hash.unwrap_or_default(); + + let transactions = + list_transactions_impl(config, from.unwrap_or(0), limit.unwrap_or(100), search).await?; + + let transaction = transactions + .into_iter() + .find(|t| t.payment_hash == target_payment_hash) + .ok_or_else(|| { + provider_nwc_error( + config, + "NOT_FOUND", + "Transaction not found for the requested payment hash", + ) + })?; + + Ok(transaction) +} + +pub async fn list_transactions( + config: &GaloyConfig, + from: i64, + limit: i64, + search: Option, +) -> Result, ApiError> { + if !config.capabilities.transaction_history { + return not_implemented(config, "Transaction history", "list_transactions"); + } + list_transactions_impl(config, from, limit, search).await +} + +async fn list_transactions_impl( + config: &GaloyConfig, + from: i64, + limit: i64, + search: Option, +) -> Result, ApiError> { + let query = r#" + query TransactionsQuery($first: Int, $last: Int, $after: String, $before: String) { + me { + defaultAccount { + transactions(first: $first, last: $last, after: $after, before: $before) { + edges { + cursor + node { + id + createdAt + direction + status + memo + settlementAmount + settlementCurrency + settlementFee + settlementDisplayAmount + settlementDisplayCurrency + settlementDisplayFee + settlementPrice { + base + offset + currencyUnit + } + initiationVia { + __typename + ... on InitiationViaLn { + paymentHash + } + } + settlementVia { + __typename + ... on SettlementViaLn { + preImage + } + ... on SettlementViaIntraLedger { + preImage + } + } + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + } + } + } + "#; + + // Simple approach: map limit directly to $first, handle from with client-side skip + // This is cleaner than trying to convert integer offsets to opaque cursors + let normalized_from = from.max(0); + let normalized_limit = limit.clamp(1, 1000); + let fetch_count = normalized_from + .saturating_add(normalized_limit) + .clamp(1, 1000) as i32; + let variables = serde_json::json!({ + "first": fetch_count, // Fetch enough to skip 'from' records, within Galoy's safe cap + "last": serde_json::Value::Null, + "after": serde_json::Value::Null, + "before": serde_json::Value::Null + }); + + let response: TransactionsQuery = + execute_graphql_query(config, query, Some(variables), "list_transactions").await?; + + let mut all_transactions = Vec::new(); + + for edge in response.me.default_account.transactions.edges { + let node = edge.node; + + // Extract Lightning-specific information + let payment_hash = match node.initiation_via { + Some(InitiationVia::InitiationViaLn { payment_hash }) => payment_hash, + _ => "".to_string(), + }; + + let preimage = match node.settlement_via { + Some(SettlementVia::SettlementViaLn { pre_image }) + | Some(SettlementVia::SettlementViaIntraLedger { pre_image }) => { + pre_image.unwrap_or_default() + } + _ => "".to_string(), + }; + + // Handle amount conversion based on settlement currency + let (amount_msats, fees_paid) = if let Some(currency) = &node.settlement_currency { + if currency.eq_ignore_ascii_case("BTC") { + // BTC amounts are in satoshis, convert to millisatoshis + let amount = (node.settlement_amount.unwrap_or(0).abs()) * 1000; + let fees = (node.settlement_fee.unwrap_or(0).abs()) * 1000; + (amount, fees) + } else if currency == "USD" { + // USD amounts - for now return 0 as we can't meaningfully convert to satoshis + // without current exchange rate data + (0, 0) + } else { + // Other currencies + (0, 0) + } + } else { + // No settlement currency available + (0, 0) + }; + + // Use the timestamp directly since it's already a Unix timestamp + let created_at = node.created_at; + + let settled_at = if node.status == "SUCCESS" { + created_at + } else { + 0 + }; + + all_transactions.push(Transaction { + type_: if node.direction == "SEND" { + "outgoing" + } else { + "incoming" + } + .to_string(), + invoice: "".to_string(), // Not available from this query + preimage, + payment_hash, + amount_msats, + fees_paid, + created_at, + expires_at: 0, // Not available from this query + settled_at, + description: node.memo.unwrap_or_default(), + description_hash: "".to_string(), + payer_note: Some("".to_string()), + external_id: Some(node.id), + }); + } + + // Apply client-side search filtering if search term is provided + if let Some(search_term) = search { + let search_lower = search_term.to_lowercase(); + all_transactions.retain(|tx| { + tx.description.to_lowercase().contains(&search_lower) + || tx.payment_hash.to_lowercase().contains(&search_lower) + || tx.preimage.to_lowercase().contains(&search_lower) + }); + } + + // Apply client-side pagination: skip 'from' records and take 'limit' records + let skip_count = normalized_from as usize; + let take_count = normalized_limit as usize; + + if skip_count < all_transactions.len() { + let end_index = std::cmp::min(skip_count + take_count, all_transactions.len()); + all_transactions = all_transactions[skip_count..end_index].to_vec(); + } else { + all_transactions.clear(); + } + + Ok(all_transactions) +} + +// Core logic shared by both implementations +pub async fn poll_invoice_events( + config: &GaloyConfig, + params: OnInvoiceEventParams, + mut callback: F, +) where + F: FnMut(String, Option), +{ + if !config.capabilities.invoice_events || !config.capabilities.transaction_lookup { + callback("failure".to_string(), None); + return; + } + let start_time = std::time::Instant::now(); + let max_polling_sec = params.max_polling_sec.max(0) as u64; + let polling_delay_sec = params.polling_delay_sec.clamp(1, 3600) as u64; + loop { + if start_time.elapsed() > Duration::from_secs(max_polling_sec) { + // timeout + callback("failure".to_string(), None); + break; + } + + let (status, transaction) = match lookup_invoice( + config, + params.payment_hash.clone(), + None, + None, + params.search.clone(), + ) + .await + { + Ok(transaction) => { + if transaction.settled_at > 0 { + ("success".to_string(), Some(transaction)) + } else { + ("pending".to_string(), Some(transaction)) + } + } + Err(_) => ("error".to_string(), None), + }; + + callback(status.clone(), transaction.clone()); + + if status == "success" || status == "failure" { + break; + } + + tokio::time::sleep(Duration::from_secs(polling_delay_sec)).await; + } +} + +pub async fn on_invoice_events( + config: GaloyConfig, + params: OnInvoiceEventParams, + callback: std::sync::Arc, +) { + if !config.capabilities.invoice_events || !config.capabilities.transaction_lookup { + callback.failure(None); + return; + } + poll_invoice_events(&config, params, move |status, tx| match status.as_str() { + "success" => callback.success(tx), + "pending" => callback.pending(tx), + _ => callback.failure(tx), + }) + .await; +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + use tokio::sync::mpsc; + + use super::*; + use crate::galoy::{ + GaloyCapabilities, GaloyInvoiceOperation, GaloyInvoiceOperationsConfig, GaloyPaymentConfig, + GaloyPaymentStatusMapping, GaloyPermissionsMode, GaloyProvider, + }; + + const BOLT11: &str = "lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpu9qrsgquk0rl77nj30yxdy8j9vdx85fkpmdla2087ne0xh8nhedh8w27kyke0lp53ut353s06fv3qfegext0eh0ymjpf39tuven09sam30g4vgpfna3rh"; + const ONE_SAT_BOLT11S: [(&str, &str); 3] = [ + ( + "mainnet", + "lnbc10n1pj48ugqdphf38yjgz8v9kx77fqxys8xct5ypex2emjv4ehx6t0dcsxv6tcw36hyegpp5qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurssp5pqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyq9qrsgqcqpj85c7x06rkw8s97xtjaarx4y4sgumglauw96fkcdr3yatkshg23gj57pj350za5ppku4d4hl8p6xj9ty7t84z2594q9hl7vf4em9en8cp3rvsy3", + ), + ( + "testnet", + "lntb10n1pj48ugqdphf38yjgz8v9kx77fqxys8xct5ypex2emjv4ehx6t0dcsxv6tcw36hyegpp5qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurssp5pqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyq9qrsgqcqpjglkgmmeh8nlna32uhyqvpmrxk52er02glraz7jywwxg0tz0ahuxrtzanfvxjrugv2zuv8dxvakvk5p3fuxeexym8ff96m25s7ks750sqknxxzt", + ), + ( + "regtest", + "lnbcrt10n1pj48ugqdphf38yjgz8v9kx77fqxys8xct5ypex2emjv4ehx6t0dcsxv6tcw36hyegpp5qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurssp5pqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyq9qrsgqcqpj7n3tcnerf357qjapjupmduwrryy3vdfk63jh45ssw86v34cxtdc9kk2n8hlhgs9f6uprj3eaxz54fwp3w2rkafhphh05llhtjdqp4sqptf062s", + ), + ]; + const AMOUNTLESS_BOLT11: &str = "lnbc1pj48ugqdplf38yjgz8v9kx77fqv9kk7atww3kx2umnypex2emjv4ehx6t0dcsxv6tcw36hyegpp5pyysjzgfpyysjzgfpyysjzgfpyysjzgfpyysjzgfpyysjzgfpyyssp5pg9q5zs2pg9q5zs2pg9q5zs2pg9q5zs2pg9q5zs2pg9q5zs2pg9q9qrsgqcqpjvcwldrltwv8ce6n00l8gl20vz5q3vu56hhmla07u39tmdy0ll6cs9crysytmdvugwrv2e6nwhfvlhd0mnjvskaefd43j9vdzjaggtygqe8yu0t"; + + fn explicit_config(base_url: String, currency: &str) -> GaloyConfig { + GaloyConfig { + api_key: "server-api-key".to_string(), + base_url, + provider: GaloyProvider { + id: "flash".to_string(), + name: "Flash".to_string(), + }, + wallet: GaloyWalletConfig::Explicit { + id: "wallet-explicit".to_string(), + currency: currency.to_string(), + }, + invoice_operations: GaloyInvoiceOperationsConfig { + create: GaloyInvoiceOperation::Unsupported, + fee_probe: if currency.eq_ignore_ascii_case("BTC") { + GaloyInvoiceOperation::BtcSats + } else if currency.eq_ignore_ascii_case("USD") { + GaloyInvoiceOperation::UsdCents + } else { + GaloyInvoiceOperation::Unsupported + }, + }, + payment: GaloyPaymentConfig { + response: GaloyPaymentResponse::StatusOnly, + accepted_statuses: vec![ + "SUCCESS".to_string(), + "PENDING".to_string(), + "ALREADY_PAID".to_string(), + ], + status_mapping: Some(GaloyPaymentStatusMapping { + settled: vec!["SUCCESS".to_string(), "ALREADY_PAID".to_string()], + pending: vec!["PENDING".to_string()], + }), + proof_unavailable_error_codes: vec!["PROOF_UNAVAILABLE".to_string()], + }, + capabilities: GaloyCapabilities { + transaction_lookup: false, + transaction_history: false, + invoice_events: false, + onchain: false, + }, + permissions: GaloyPermissionsMode::Configured, + additional_headers: None, + http_timeout: Some(5), + socks5_proxy: None, + accept_invalid_certs: Some(false), + } + } + + fn find_header_end(bytes: &[u8]) -> Option { + bytes + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|position| position + 4) + } + + async fn test_server(responses: Vec) -> (String, mpsc::Receiver) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test server should bind"); + let address = listener.local_addr().expect("test server address"); + let (sender, receiver) = mpsc::channel(responses.len()); + + tokio::spawn(async move { + for response_body in responses { + let (mut stream, _) = listener.accept().await.expect("request should connect"); + let mut request = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + let read = stream.read(&mut buffer).await.expect("request should read"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + let Some(header_end) = find_header_end(&request) else { + continue; + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + if request.len() >= header_end + content_length { + break; + } + } + + sender + .send(String::from_utf8_lossy(&request).to_string()) + .await + .expect("request capture should send"); + + let body = response_body.to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .await + .expect("response should write"); + } + }); + + (format!("http://{}/graphql", address), receiver) + } + + fn graphql_body(request: &str) -> serde_json::Value { + let (_, body) = request + .split_once("\r\n\r\n") + .expect("HTTP request should have a body"); + serde_json::from_str(body).expect("GraphQL body should be JSON") + } + + #[test] + fn configured_proxy_fails_closed_without_leaking_credentials() { + let mut config = explicit_config("https://galoy.test/graphql".to_string(), "BTC"); + config.socks5_proxy = Some("socks5://user:password@[".to_string()); + + let error = client(&config).expect_err("invalid proxy should fail client creation"); + let message = format!("{error:?}"); + assert!(message.contains("SOCKS5 proxy")); + assert!(!message.contains("user")); + assert!(!message.contains("password")); + } + + #[tokio::test] + async fn one_sat_invoices_reach_fee_probe_and_payment() { + for (network, invoice) in ONE_SAT_BOLT11S { + let decoded = Bolt11Invoice::from_str(invoice) + .unwrap_or_else(|error| panic!("{network} fixture should decode: {error}")); + assert_eq!(decoded.amount_milli_satoshis(), Some(1_000)); + + let (base_url, mut requests) = test_server(vec![ + serde_json::json!({ + "data": { + "lnInvoiceFeeProbe": {"amount": 1, "errors": []} + } + }), + serde_json::json!({ + "data": { + "lnInvoicePaymentSend": { + "status": "SUCCESS", + "errors": [] + } + } + }), + ]) + .await; + let config = explicit_config(base_url, "BTC"); + + let payment = pay_invoice( + &config, + PayInvoiceParams { + invoice: invoice.to_string(), + ..Default::default() + }, + ) + .await + .unwrap_or_else(|error| panic!("{network} 1-sat payment should proceed: {error:?}")); + + assert_eq!(payment.payment_hash, "07".repeat(32)); + assert!(payment.preimage.is_empty()); + assert_eq!(payment.fee_msats, 1_000); + + let fee_request = graphql_body( + &requests + .recv() + .await + .unwrap_or_else(|| panic!("{network} fee request should be captured")), + ); + assert!(fee_request["query"] + .as_str() + .expect("fee query") + .contains("lnInvoiceFeeProbe")); + let payment_request = graphql_body( + &requests + .recv() + .await + .unwrap_or_else(|| panic!("{network} payment request should be captured")), + ); + assert!(payment_request["query"] + .as_str() + .expect("payment query") + .contains("lnInvoicePaymentSend")); + } + } + + #[tokio::test] + async fn amountless_invoice_is_rejected_before_graphql() { + let decoded = + Bolt11Invoice::from_str(AMOUNTLESS_BOLT11).expect("amountless fixture should decode"); + assert_eq!(decoded.amount_milli_satoshis(), None); + + for amount_msats in [None, Some(1_000)] { + let config = explicit_config("http://127.0.0.1:1/graphql".to_string(), "BTC"); + let error = pay_invoice( + &config, + PayInvoiceParams { + invoice: AMOUNTLESS_BOLT11.to_string(), + amount_msats, + ..Default::default() + }, + ) + .await + .expect_err("amountless Galoy payment should be rejected"); + + assert!(matches!( + error, + ApiError::InvalidInput(ref message) + if message.contains("Flash cannot pay amountless BOLT11 invoices") + )); + } + } + + #[tokio::test] + async fn malformed_invoices_are_left_for_the_provider_to_reject() { + let (base_url, mut requests) = test_server(vec![ + serde_json::json!({ + "data": { + "lnInvoiceFeeProbe": {"amount": 0, "errors": []} + } + }), + serde_json::json!({ + "data": { + "lnInvoicePaymentSend": { + "status": "FAILURE", + "errors": [{ + "code": "INVALID_INVOICE", + "message": "Invalid invoice" + }] + } + } + }), + ]) + .await; + let config = explicit_config(base_url, "BTC"); + + let error = pay_invoice( + &config, + PayInvoiceParams { + invoice: "not-a-bolt11-invoice".to_string(), + ..Default::default() + }, + ) + .await + .expect_err("provider should reject malformed invoice"); + + assert!(matches!( + error, + ApiError::Nwc { ref code, ref message } + if code == "PAYMENT_FAILED" + && message.contains("[flash]") + && message.contains("Invalid invoice") + )); + let fee_request = graphql_body(&requests.recv().await.expect("fee request")); + assert!(fee_request["query"] + .as_str() + .expect("fee query") + .contains("lnInvoiceFeeProbe")); + let payment_request = graphql_body(&requests.recv().await.expect("payment request")); + assert!(payment_request["query"] + .as_str() + .expect("payment query") + .contains("lnInvoicePaymentSend")); + } + + #[tokio::test] + async fn explicit_operations_are_not_inferred_from_wallet_currency_and_protect_headers() { + let (base_url, mut requests) = test_server(vec![serde_json::json!({ + "data": { + "lnInvoiceCreate": { + "invoice": { + "paymentRequest": BOLT11, + "paymentHash": "hash", + "satoshis": 21 + }, + "errors": [] + } + } + })]) + .await; + let mut config = explicit_config(base_url, "JMD"); + config.invoice_operations.create = GaloyInvoiceOperation::BtcSats; + config.additional_headers = Some(HashMap::from([ + ( + "x-flash-client-capabilities".to_string(), + "proofless".to_string(), + ), + ("x-api-key".to_string(), "attacker-key".to_string()), + ("content-type".to_string(), "text/plain".to_string()), + ])); + + let transaction = create_invoice( + &config, + CreateInvoiceParams { + amount_msats: Some(21_000), + ..Default::default() + }, + ) + .await + .expect("invoice should be created"); + assert_eq!(transaction.amount_msats, 21_000); + + let request = requests.recv().await.expect("request should be captured"); + let request_lower = request.to_ascii_lowercase(); + assert!(request_lower.contains("x-api-key: server-api-key")); + assert!(!request_lower.contains("attacker-key")); + assert!(request_lower.contains("content-type: application/json")); + assert!(request_lower.contains("x-flash-client-capabilities: proofless")); + let body = graphql_body(&request); + let query = body["query"].as_str().expect("query"); + assert!(query.contains("lnInvoiceCreate")); + assert!(!query.contains("lnUsdInvoiceCreate")); + assert!(!query.contains("query Me")); + assert_eq!(body["variables"]["input"]["walletId"], "wallet-explicit"); + } + + #[tokio::test] + async fn usd_cent_invoice_creation_is_rejected_before_graphql() { + let mut config = explicit_config("http://127.0.0.1:1/graphql".to_string(), "USD"); + config.invoice_operations.create = GaloyInvoiceOperation::UsdCents; + + let error = create_invoice( + &config, + CreateInvoiceParams { + amount_msats: Some(21_000), + ..Default::default() + }, + ) + .await + .expect_err("USD-cent invoice creation must not reinterpret amount_msats"); + + assert!(matches!( + error, + ApiError::Nwc { ref code, ref message } + if code == "NOT_IMPLEMENTED" && message.contains("USD-cent") + )); + } + + #[tokio::test] + async fn currency_wallet_mode_selects_currency_and_reports_missing_currency() { + let (base_url, mut requests) = test_server(vec![serde_json::json!({ + "data": { + "me": { + "defaultAccount": { + "wallets": [ + {"id": "btc", "walletCurrency": "BTC", "balance": 10}, + {"id": "jmd", "walletCurrency": "JMD", "balance": 500} + ] + } + } + } + })]) + .await; + let mut config = explicit_config(base_url, "JMD"); + config.wallet = GaloyWalletConfig::Currency { + currency: "JMD".to_string(), + }; + let info = get_info(&config).await.expect("JMD wallet should resolve"); + assert_eq!(info.alias, "Flash Node"); + assert_eq!(info.send_balance_msat, 0); + assert!( + graphql_body(&requests.recv().await.expect("request"))["query"] + .as_str() + .expect("query") + .contains("query Me") + ); + + let (missing_url, mut missing_requests) = test_server(vec![serde_json::json!({ + "data": { + "me": { + "defaultAccount": { + "wallets": [{"id": "btc", "walletCurrency": "BTC", "balance": 10}] + } + } + } + })]) + .await; + config.base_url = missing_url; + config.wallet = GaloyWalletConfig::Currency { + currency: "USD".to_string(), + }; + let error = get_info(&config).await.expect_err("USD should be missing"); + missing_requests + .recv() + .await + .expect("missing-wallet request"); + match error { + ApiError::Nwc { code, message } => { + assert_eq!(code, "NOT_FOUND"); + assert!(message.contains("[flash]")); + assert!(message.contains("USD")); + } + other => panic!("expected NWC error, got {other:?}"), + } + } + + #[tokio::test] + async fn status_only_accepts_pending_omits_transaction_and_returns_zero_non_btc_fee() { + let (base_url, mut requests) = test_server(vec![ + serde_json::json!({ + "data": { + "lnUsdInvoiceFeeProbe": {"amount": 99999, "errors": []} + } + }), + serde_json::json!({ + "data": { + "lnInvoicePaymentSend": { + "status": "PENDING", + "errors": [{ + "code": "PROOF_UNAVAILABLE", + "message": "No proof is exposed" + }] + } + } + }), + ]) + .await; + let config = explicit_config(base_url, "USD"); + + let outcome = pay_invoice_with_status( + &config, + PayInvoiceParams { + invoice: BOLT11.to_string(), + ..Default::default() + }, + ) + .await + .expect("PENDING should be accepted"); + assert_eq!(outcome.payment.fee_msats, 0); + assert!(outcome.payment.preimage.is_empty()); + assert!(!outcome.payment.payment_hash.is_empty()); + assert_eq!(outcome.state, GaloyPaymentState::Pending); + assert_eq!(outcome.provider_status, "PENDING"); + + let fee_query = graphql_body(&requests.recv().await.expect("fee request")); + assert!(fee_query["query"] + .as_str() + .expect("fee query") + .contains("lnUsdInvoiceFeeProbe")); + let payment_query = graphql_body(&requests.recv().await.expect("payment request")); + assert!(!payment_query["query"] + .as_str() + .expect("payment query") + .contains("transaction {")); + } + + #[tokio::test] + async fn configured_payment_statuses_preserve_settlement_state() { + for (status, expected_state) in [ + ("SUCCESS", GaloyPaymentState::Settled), + ("ALREADY_PAID", GaloyPaymentState::Settled), + ("PENDING", GaloyPaymentState::Pending), + ] { + let (base_url, _requests) = test_server(vec![ + serde_json::json!({ + "data": { + "lnUsdInvoiceFeeProbe": {"amount": 75, "errors": []} + } + }), + serde_json::json!({ + "data": { + "lnInvoicePaymentSend": { + "status": status, + "errors": [] + } + } + }), + ]) + .await; + let config = explicit_config(base_url, "USD"); + + let outcome = pay_invoice_with_status( + &config, + PayInvoiceParams { + invoice: BOLT11.to_string(), + ..Default::default() + }, + ) + .await + .expect("configured payment status should be accepted"); + + assert_eq!(outcome.state, expected_state); + assert_eq!(outcome.provider_status, status); + assert_eq!(outcome.payment.fee_msats, 0); + assert!(outcome.payment.preimage.is_empty()); + assert!(!outcome.payment.payment_hash.is_empty()); + } + } + + #[tokio::test] + async fn unexpected_errors_on_accepted_payments_are_surfaced_and_sanitized() { + let sensitive_hash = "a".repeat(64); + let provider_message = format!( + "unexpected paymentRequest={} paymentHash={} paymentSecret=payment-secret preimage=provider-preimage access_token=access-token api-key=server-api-key", + BOLT11, sensitive_hash + ); + let (base_url, _requests) = test_server(vec![ + serde_json::json!({ + "data": { + "lnUsdInvoiceFeeProbe": {"amount": 25, "errors": []} + } + }), + serde_json::json!({ + "data": { + "lnInvoicePaymentSend": { + "status": "SUCCESS", + "errors": [{ + "code": "UNEXPECTED_PROVIDER_ERROR", + "message": provider_message + }] + } + } + }), + ]) + .await; + let config = explicit_config(base_url, "USD"); + + let error = pay_invoice( + &config, + PayInvoiceParams { + invoice: BOLT11.to_string(), + ..Default::default() + }, + ) + .await + .expect_err("unexpected payload errors must fail accepted payments"); + + match error { + ApiError::Nwc { code, message } => { + assert_eq!(code, "PAYMENT_FAILED"); + assert!(message.contains("[flash]")); + for sensitive in [ + BOLT11, + sensitive_hash.as_str(), + "payment-secret", + "provider-preimage", + "access-token", + "server-api-key", + ] { + assert!( + !message.contains(sensitive), + "sensitive value leaked: {sensitive}" + ); + } + } + other => panic!("expected sanitized NWC error, got {other:?}"), + } + } + + #[tokio::test] + async fn unsupported_wallet_fee_operation_fails_before_graphql() { + let config = explicit_config("http://127.0.0.1:1/graphql".to_string(), "JMD"); + let error = pay_invoice( + &config, + PayInvoiceParams { + invoice: BOLT11.to_string(), + ..Default::default() + }, + ) + .await + .expect_err("JMD must not be inferred to use USD fee operations"); + + assert!(matches!( + error, + ApiError::Nwc { ref code, ref message } + if code == "NOT_IMPLEMENTED" + && message.contains("[flash]") + && message.contains("JMD") + )); + } + + #[tokio::test] + async fn transaction_mode_preserves_preimage_and_btc_fee_conversion() { + let (base_url, mut requests) = test_server(vec![ + serde_json::json!({ + "data": {"lnInvoiceFeeProbe": {"amount": 2, "errors": []}} + }), + serde_json::json!({ + "data": { + "lnInvoicePaymentSend": { + "status": "SUCCESS", + "errors": [], + "transaction": { + "settlementVia": { + "__typename": "SettlementViaLn", + "preImage": "settled-preimage" + } + } + } + } + }), + ]) + .await; + let mut config = explicit_config(base_url, "BTC"); + config.payment = GaloyPaymentConfig { + response: GaloyPaymentResponse::TransactionWithPreimage, + accepted_statuses: vec!["SUCCESS".to_string()], + status_mapping: None, + proof_unavailable_error_codes: vec![], + }; + + let response = pay_invoice( + &config, + PayInvoiceParams { + invoice: BOLT11.to_string(), + ..Default::default() + }, + ) + .await + .expect("payment should succeed"); + assert_eq!(response.fee_msats, 2_000); + assert_eq!(response.preimage, "settled-preimage"); + requests.recv().await.expect("fee request"); + let payment_query = graphql_body(&requests.recv().await.expect("payment request")); + assert!(payment_query["query"] + .as_str() + .expect("payment query") + .contains("transaction {")); + } + + #[tokio::test] + async fn onchain_total_uses_provider_settled_amount() { + let (base_url, mut requests) = test_server(vec![serde_json::json!({ + "data": { + "onChainPaymentSend": { + "status": "SUCCESS", + "transaction": { + "id": "tx-1", + "settlementAmount": 9000, + "settlementCurrency": "BTC", + "settlementFee": 500, + "settlementVia": { + "__typename": "SettlementViaOnChain", + "transactionHash": "txid-1" + } + }, + "errors": [] + } + } + })]) + .await; + let mut config = explicit_config(base_url, "BTC"); + config.capabilities.onchain = true; + let payment = pay_onchain( + &config, + OnchainTransaction { + id: None, + address: "bc1qexample".to_string(), + amount_sats: 10_000, + fee_sats: Some(500), + total_amount_sats: Some(10_500), + recipient_amount_sats: Some(10_000), + fee_payer: OnchainFeePayer::Sender, + fee: default_onchain_fee(), + expires_at: None, + estimated_delivery_seconds: None, + raw: None, + }, + ) + .await + .expect("on-chain payment should succeed"); + + assert_eq!(payment.amount_sats, 9_000); + assert_eq!(payment.fee_sats, Some(500)); + assert_eq!(payment.total_amount_sats, Some(9_500)); + let request = requests.recv().await.expect("payment request"); + assert!(graphql_body(&request)["query"] + .as_str() + .expect("query") + .contains("onChainPaymentSend")); + } + + #[tokio::test] + async fn transaction_fetch_count_is_saturating_and_bounded() { + let (base_url, mut requests) = test_server(vec![serde_json::json!({ + "data": { + "me": { + "defaultAccount": { + "transactions": { + "edges": [], + "pageInfo": { + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": null, + "endCursor": null + } + } + } + } + } + })]) + .await; + let mut config = explicit_config(base_url, "BTC"); + config.capabilities.transaction_history = true; + + let transactions = list_transactions(&config, i64::MAX, i64::MAX, None) + .await + .expect("bounded transaction query should succeed"); + assert!(transactions.is_empty()); + + let request = requests.recv().await.expect("transaction request"); + assert_eq!(graphql_body(&request)["variables"]["first"], 1000); + } + + #[tokio::test] + async fn negative_polling_values_timeout_without_wrapping_or_hot_looping() { + let mut config = explicit_config("http://127.0.0.1:1/graphql".to_string(), "BTC"); + config.capabilities.invoice_events = true; + config.capabilities.transaction_lookup = true; + let events = Arc::new(Mutex::new(Vec::new())); + let captured_events = events.clone(); + + poll_invoice_events( + &config, + OnInvoiceEventParams { + payment_hash: Some("hash".to_string()), + search: None, + polling_delay_sec: -1, + max_polling_sec: -1, + }, + move |status, _| { + captured_events.lock().expect("events lock").push(status); + }, + ) + .await; + + assert_eq!(events.lock().expect("events lock").as_slice(), ["failure"]); + } + + #[tokio::test] + async fn disabled_capabilities_fail_before_graphql_and_sensitive_values_are_redacted() { + let config = explicit_config("http://127.0.0.1:1/graphql".to_string(), "JMD"); + let lookup_error = lookup_invoice(&config, Some("a".repeat(64)), None, None, None) + .await + .expect_err("lookup should be disabled"); + match lookup_error { + ApiError::Nwc { code, message } => { + assert_eq!(code, "NOT_IMPLEMENTED"); + assert!(message.contains("[flash]")); + } + other => panic!("expected NWC error, got {other:?}"), + } + + let history_error = list_transactions(&config, 0, 10, None) + .await + .expect_err("history should be disabled"); + assert!(matches!( + history_error, + ApiError::Nwc { ref code, .. } if code == "NOT_IMPLEMENTED" + )); + + let onchain_error = prepare_onchain_transaction( + &config, + PrepareOnchainTransactionParams { + address: "address".to_string(), + amount_sats: 1, + fee: None, + fee_payer: None, + description: None, + idempotency_key: None, + }, + ) + .await + .expect_err("on-chain should be disabled"); + assert!(matches!( + onchain_error, + ApiError::Nwc { ref code, .. } if code == "NOT_IMPLEMENTED" + )); + + let mut non_btc_onchain_config = config.clone(); + non_btc_onchain_config.capabilities.onchain = true; + let non_btc_onchain_error = prepare_onchain_transaction( + &non_btc_onchain_config, + PrepareOnchainTransactionParams { + address: "address".to_string(), + amount_sats: 1, + fee: None, + fee_payer: None, + description: None, + idempotency_key: None, + }, + ) + .await + .expect_err("non-BTC on-chain should be unavailable"); + assert!(matches!( + non_btc_onchain_error, + ApiError::Nwc { ref code, .. } if code == "NOT_IMPLEMENTED" + )); + + let diagnostic = sanitize_text( + &config, + serde_json::json!({ + "apiKey": "server-api-key", + "access_token": "access-token", + "paymentRequest": BOLT11, + "paymentHash": "a".repeat(64), + "paymentSecret": "secret", + "preImage": "preimage" + }) + .to_string(), + ); + assert!(!diagnostic.contains("server-api-key")); + assert!(!diagnostic.contains("access-token")); + assert!(!diagnostic.contains(BOLT11)); + assert!(!diagnostic.contains(&"a".repeat(64))); + assert!(!diagnostic.contains("preimage")); + } +} diff --git a/crates/lni/galoy/lib.rs b/crates/lni/galoy/lib.rs new file mode 100644 index 00000000..1446b483 --- /dev/null +++ b/crates/lni/galoy/lib.rs @@ -0,0 +1,316 @@ +use std::collections::HashMap; + +use crate::types::NodeInfo; +#[cfg(not(feature = "uniffi"))] +use crate::LightningNode; +use crate::{ + ApiError, CreateInvoiceParams, CreateOfferParams, ListTransactionsParams, LookupInvoiceParams, + Offer, OnchainTransaction, PayInvoiceParams, PayInvoiceResponse, PayOnchainOptions, + PayOnchainResponse, Permissions, PrepareOnchainTransactionParams, Transaction, +}; + +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GaloyWalletConfig { + Explicit { id: String, currency: String }, + Currency { currency: String }, +} + +impl GaloyWalletConfig { + pub fn currency(&self) -> &str { + match self { + Self::Explicit { currency, .. } | Self::Currency { currency } => currency, + } + } +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GaloyPaymentResponse { + TransactionWithPreimage, + StatusOnly, +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GaloyPaymentConfig { + pub response: GaloyPaymentResponse, + pub accepted_statuses: Vec, + pub status_mapping: Option, + pub proof_unavailable_error_codes: Vec, +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GaloyPaymentStatusMapping { + pub settled: Vec, + pub pending: Vec, +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GaloyPaymentState { + Settled, + Pending, + Accepted, +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Debug)] +pub struct GaloyPaymentOutcome { + pub payment: PayInvoiceResponse, + pub state: GaloyPaymentState, + pub provider_status: String, +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GaloyInvoiceOperation { + BtcSats, + UsdCents, + Unsupported, +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GaloyInvoiceOperationsConfig { + pub create: GaloyInvoiceOperation, + pub fee_probe: GaloyInvoiceOperation, +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GaloyCapabilities { + pub transaction_lookup: bool, + pub transaction_history: bool, + pub invoice_events: bool, + pub onchain: bool, +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GaloyPermissionsMode { + JwtIntrospection, + Configured, +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Clone)] +pub struct GaloyProvider { + pub id: String, + pub name: String, +} + +impl std::fmt::Debug for GaloyProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GaloyProvider") + .field("id", &self.id) + .field("name", &self.name) + .finish() + } +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Clone)] +pub struct GaloyConfig { + pub api_key: String, + pub base_url: String, + pub provider: GaloyProvider, + pub wallet: GaloyWalletConfig, + pub invoice_operations: GaloyInvoiceOperationsConfig, + pub payment: GaloyPaymentConfig, + pub capabilities: GaloyCapabilities, + pub permissions: GaloyPermissionsMode, + pub additional_headers: Option>, + pub http_timeout: Option, + pub socks5_proxy: Option, + pub accept_invalid_certs: Option, +} + +impl std::fmt::Debug for GaloyConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let additional_header_names = self + .additional_headers + .as_ref() + .map(|headers| headers.keys().cloned().collect::>()); + f.debug_struct("GaloyConfig") + .field("api_key", &"") + .field("base_url", &self.base_url) + .field("provider", &self.provider) + .field("wallet", &self.wallet) + .field("payment", &self.payment) + .field("capabilities", &self.capabilities) + .field("permissions", &self.permissions) + .field("additional_header_names", &additional_header_names) + .field("http_timeout", &self.http_timeout) + .field("socks5_proxy", &"") + .field("accept_invalid_certs", &self.accept_invalid_certs) + .finish() + } +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +#[derive(Debug, Clone)] +pub struct GaloyNode { + pub config: GaloyConfig, +} + +#[cfg_attr(feature = "uniffi", uniffi::export)] +impl GaloyNode { + #[cfg_attr(feature = "uniffi", uniffi::constructor)] + pub fn new(config: GaloyConfig) -> Self { + Self { config } + } +} + +#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] +impl GaloyNode { + fn can_create_invoice(&self) -> bool { + self.config.invoice_operations.create == GaloyInvoiceOperation::BtcSats + } + + pub async fn get_permissions(&self) -> Result { + match self.config.permissions { + GaloyPermissionsMode::Configured => Ok(Permissions { + get_info: true, + create_invoice: self.can_create_invoice(), + pay_invoice: true, + create_offer: false, + get_offer: false, + list_offers: false, + pay_offer: false, + lookup_invoice: self.config.capabilities.transaction_lookup, + list_transactions: self.config.capabilities.transaction_history, + decode: true, + on_invoice_events: self.config.capabilities.invoice_events, + }), + GaloyPermissionsMode::JwtIntrospection => { + let mut permissions = + crate::permissions::parse_galoy_token_permissions(&self.config.api_key) + .ok_or_else(|| { + ApiError::InvalidInput(format!( + "{} API keys cannot be introspected. Use a JWT-style token or configured permissions.", + self.config.provider.name + )) + })?; + permissions.lookup_invoice &= self.config.capabilities.transaction_lookup; + permissions.list_transactions &= self.config.capabilities.transaction_history; + permissions.on_invoice_events &= self.config.capabilities.invoice_events; + permissions.create_invoice &= self.can_create_invoice(); + Ok(permissions) + } + } + } + + pub async fn get_info(&self) -> Result { + crate::galoy::api::get_info(&self.config).await + } + + pub async fn create_invoice( + &self, + params: CreateInvoiceParams, + ) -> Result { + crate::galoy::api::create_invoice(&self.config, params).await + } + + pub async fn pay_invoice( + &self, + params: PayInvoiceParams, + ) -> Result { + crate::galoy::api::pay_invoice(&self.config, params).await + } + + /// Pay an invoice and retain the accepted provider status. + /// + /// This is additive to the shared [`crate::LightningNode::pay_invoice`] API so + /// existing consumers of [`PayInvoiceResponse`] remain source-compatible. + pub async fn pay_invoice_with_status( + &self, + params: PayInvoiceParams, + ) -> Result { + crate::galoy::api::pay_invoice_with_status(&self.config, params).await + } + + pub async fn prepare_onchain_transaction( + &self, + params: PrepareOnchainTransactionParams, + ) -> Result { + crate::galoy::api::prepare_onchain_transaction(&self.config, params).await + } + + pub async fn pay_onchain( + &self, + transaction: OnchainTransaction, + ) -> Result { + crate::galoy::api::pay_onchain(&self.config, transaction).await + } + + pub async fn pay_onchain_with_options( + &self, + transaction: OnchainTransaction, + options: PayOnchainOptions, + ) -> Result { + crate::galoy::api::pay_onchain_with_options(&self.config, transaction, options).await + } + + pub async fn create_offer(&self, _params: CreateOfferParams) -> Result { + crate::galoy::api::not_implemented(&self.config, "Bolt12", "make_invoice") + } + + pub async fn get_offer(&self, _search: Option) -> Result { + crate::galoy::api::not_implemented(&self.config, "Bolt12", "lookup_invoice") + } + + pub async fn list_offers(&self, _search: Option) -> Result, ApiError> { + crate::galoy::api::not_implemented(&self.config, "Bolt12", "list_transactions") + } + + pub async fn pay_offer( + &self, + _offer: String, + _amount_msats: i64, + _payer_note: Option, + ) -> Result { + crate::galoy::api::not_implemented(&self.config, "Bolt12", "pay_invoice") + } + + pub async fn lookup_invoice( + &self, + params: LookupInvoiceParams, + ) -> Result { + crate::galoy::api::lookup_invoice( + &self.config, + params.payment_hash, + None, + None, + params.search, + ) + .await + } + + pub async fn list_transactions( + &self, + params: ListTransactionsParams, + ) -> Result, ApiError> { + crate::galoy::api::list_transactions(&self.config, params.from, params.limit, params.search) + .await + } + + pub async fn decode(&self, value: String) -> Result { + crate::utils::decode_bolt11(value) + } + + pub async fn decode_offer(&self, offer: String) -> Result { + crate::utils::decode_offer(offer) + } + + pub async fn on_invoice_events( + &self, + params: crate::types::OnInvoiceEventParams, + callback: std::sync::Arc, + ) { + crate::galoy::api::on_invoice_events(self.config.clone(), params, callback).await + } +} + +crate::impl_lightning_node!(GaloyNode); diff --git a/crates/lni/galoy/types.rs b/crates/lni/galoy/types.rs new file mode 100644 index 00000000..171ef7ac --- /dev/null +++ b/crates/lni/galoy/types.rs @@ -0,0 +1,364 @@ +use serde::{Deserialize, Serialize}; + +// GraphQL request/response structures +#[derive(Debug, Serialize)] +pub struct GraphQLRequest { + pub query: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub variables: Option, +} + +#[derive(Debug, Deserialize)] +pub struct GraphQLResponse { + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub errors: Option>, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct GraphQLError { + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, +} + +// User and account structures +#[derive(Debug, Deserialize)] +pub struct MeQuery { + pub me: User, +} + +#[derive(Debug, Deserialize)] +pub struct User { + #[serde(rename = "defaultAccount")] + pub default_account: Account, +} + +#[derive(Debug, Deserialize)] +pub struct Account { + pub wallets: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub transactions: Option, +} + +#[derive(Debug, Deserialize)] +pub struct Wallet { + pub id: String, + #[serde(rename = "walletCurrency")] + pub wallet_currency: String, + pub balance: Option, +} + +// Invoice creation structures +#[derive(Debug, Serialize)] +pub struct LnInvoiceCreateInput { + pub amount: String, // Amount in satoshis as string + #[serde(rename = "walletId")] + pub wallet_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub memo: Option, +} + +#[derive(Debug, Deserialize)] +pub struct LnInvoiceCreateResponse { + #[serde(rename = "lnInvoiceCreate")] + pub ln_invoice_create: Option, + #[serde(rename = "lnUsdInvoiceCreate")] + pub ln_usd_invoice_create: Option, +} + +#[derive(Debug, Deserialize)] +pub struct LnInvoiceCreateResult { + #[serde(skip_serializing_if = "Option::is_none")] + pub invoice: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub errors: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct Invoice { + #[serde(rename = "paymentRequest")] + pub payment_request: String, + #[serde(rename = "paymentHash")] + pub payment_hash: String, + #[serde(rename = "paymentSecret")] + pub payment_secret: Option, + pub satoshis: i64, +} + +// Payment structures +#[derive(Debug, Serialize)] +pub struct LnInvoicePaymentInput { + #[serde(rename = "paymentRequest")] + pub payment_request: String, + #[serde(rename = "walletId")] + pub wallet_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub memo: Option, +} + +#[derive(Debug, Deserialize)] +pub struct LnInvoicePaymentSendResponse { + #[serde(rename = "lnInvoicePaymentSend")] + pub ln_invoice_payment_send: LnInvoicePaymentResult, +} + +#[derive(Debug, Deserialize)] +pub struct LnInvoicePaymentResult { + pub status: String, // "SUCCESS", "FAILURE", "PENDING" + pub transaction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub errors: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct LnInvoicePaymentTransaction { + #[serde(rename = "settlementVia")] + pub settlement_via: Option, +} + +// On-chain payment structures +#[derive(Debug, Deserialize)] +pub struct OnChainTxFeeResponse { + #[serde(rename = "onChainTxFee")] + pub on_chain_tx_fee: OnChainTxFee, +} + +#[derive(Debug, Deserialize)] +pub struct OnChainTxFee { + pub amount: Option, +} + +#[derive(Debug, Deserialize)] +pub struct OnChainPaymentSendResponse { + #[serde(rename = "onChainPaymentSend")] + pub on_chain_payment_send: OnChainPaymentSendResult, +} + +#[derive(Debug, Deserialize)] +pub struct OnChainPaymentSendResult { + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub transaction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub errors: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct OnChainPaymentTransaction { + pub id: Option, + #[serde(rename = "settlementAmount")] + pub settlement_amount: Option, + #[serde(rename = "settlementCurrency")] + pub settlement_currency: Option, + #[serde(rename = "settlementFee")] + pub settlement_fee: Option, + #[serde(rename = "settlementVia")] + pub settlement_via: Option, +} + +// Fee probe structures +#[derive(Debug, Serialize)] +pub struct LnInvoiceFeeProbeInput { + #[serde(rename = "paymentRequest")] + pub payment_request: String, + #[serde(rename = "walletId")] + pub wallet_id: String, +} + +#[derive(Debug, Deserialize)] +pub struct LnInvoiceFeeProbeResponse { + #[serde(rename = "lnInvoiceFeeProbe")] + pub ln_invoice_fee_probe: Option, + #[serde(rename = "lnUsdInvoiceFeeProbe")] + pub ln_usd_invoice_fee_probe: Option, +} + +#[derive(Debug, Deserialize)] +pub struct LnInvoiceFeeProbeResult { + #[serde(skip_serializing_if = "Option::is_none")] + pub amount: Option, // Denomination depends on the configured operation family. + #[serde(skip_serializing_if = "Option::is_none")] + pub errors: Option>, +} + +// Transaction structures +#[derive(Debug, Deserialize)] +pub struct TransactionConnection { + pub edges: Vec, + #[serde(rename = "pageInfo")] + pub page_info: PageInfo, +} + +#[derive(Debug, Deserialize)] +pub struct TransactionEdge { + pub cursor: String, + pub node: Transaction, +} + +#[derive(Debug, Deserialize)] +pub struct Transaction { + pub id: String, + #[serde(rename = "createdAt")] + pub created_at: i64, // Unix timestamp + pub direction: String, // "SEND" or "RECEIVE" + pub status: String, // "SUCCESS", "FAILURE", "PENDING" + #[serde(skip_serializing_if = "Option::is_none")] + pub memo: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "settlementAmount")] + pub settlement_amount: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "settlementCurrency")] + pub settlement_currency: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "settlementFee")] + pub settlement_fee: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "settlementDisplayAmount")] + pub settlement_display_amount: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "settlementDisplayCurrency")] + pub settlement_display_currency: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "settlementDisplayFee")] + pub settlement_display_fee: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "settlementPrice")] + pub settlement_price: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "initiationVia")] + pub initiation_via: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "settlementVia")] + pub settlement_via: Option, +} + +#[derive(Debug, Deserialize)] +pub struct Price { + pub base: i64, + pub offset: i64, + #[serde(rename = "currencyUnit")] + pub currency_unit: String, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "__typename")] +pub enum InitiationVia { + #[serde(rename = "InitiationViaLn")] + InitiationViaLn { + #[serde(rename = "paymentHash")] + payment_hash: String, + }, + #[serde(rename = "InitiationViaOnChain")] + InitiationViaOnChain { + #[serde(skip_serializing_if = "Option::is_none")] + address: Option, + }, + #[serde(rename = "InitiationViaIntraLedger")] + InitiationViaIntraLedger {}, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "__typename")] +pub enum SettlementVia { + #[serde(rename = "SettlementViaLn")] + SettlementViaLn { + #[serde(rename = "preImage")] + pre_image: Option, + }, + #[serde(rename = "SettlementViaOnChain")] + SettlementViaOnChain { + #[serde(rename = "transactionHash")] + transaction_hash: Option, + }, + #[serde(rename = "SettlementViaIntraLedger")] + SettlementViaIntraLedger { + #[serde(rename = "preImage")] + pre_image: Option, + }, +} + +#[derive(Debug, Deserialize)] +pub struct PageInfo { + #[serde(rename = "hasNextPage")] + pub has_next_page: bool, + #[serde(rename = "hasPreviousPage")] + pub has_previous_page: bool, + #[serde(rename = "startCursor")] + pub start_cursor: Option, + #[serde(rename = "endCursor")] + pub end_cursor: Option, +} + +// Query variables for transactions +#[derive(Debug, Serialize)] +pub struct TransactionQueryVariables { + pub first: Option, + pub after: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub before: Option, +} + +#[derive(Debug, Deserialize)] +pub struct TransactionsQuery { + pub me: UserWithTransactions, +} + +#[derive(Debug, Deserialize)] +pub struct UserWithTransactions { + #[serde(rename = "defaultAccount")] + pub default_account: AccountWithTransactions, +} + +#[derive(Debug, Deserialize)] +pub struct AccountWithTransactions { + pub transactions: TransactionConnection, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn send_preimage(settlement_type: &str) -> String { + let response: LnInvoicePaymentSendResponse = serde_json::from_value(serde_json::json!({ + "lnInvoicePaymentSend": { + "status": "SUCCESS", + "errors": [], + "transaction": { + "settlementVia": { + "__typename": settlement_type, + "preImage": "settled-preimage" + } + } + } + })) + .expect("payment response should deserialize"); + + match response + .ln_invoice_payment_send + .transaction + .and_then(|transaction| transaction.settlement_via) + { + Some(SettlementVia::SettlementViaLn { pre_image }) + | Some(SettlementVia::SettlementViaIntraLedger { pre_image }) => { + pre_image.unwrap_or_default() + } + _ => String::new(), + } + } + + #[test] + fn deserializes_ln_and_intra_ledger_send_preimages() { + assert_eq!(send_preimage("SettlementViaLn"), "settled-preimage"); + assert_eq!( + send_preimage("SettlementViaIntraLedger"), + "settled-preimage" + ); + } +} diff --git a/crates/lni/lib.rs b/crates/lni/lib.rs index eeb66c25..1dc512b8 100644 --- a/crates/lni/lib.rs +++ b/crates/lni/lib.rs @@ -260,6 +260,23 @@ pub mod blink { pub use lib::{BlinkConfig, BlinkNode}; } +pub mod galoy { + pub mod api; + pub mod lib; + pub mod types; + pub use lib::{ + GaloyCapabilities, GaloyConfig, GaloyInvoiceOperation, GaloyInvoiceOperationsConfig, + GaloyNode, GaloyPaymentConfig, GaloyPaymentOutcome, GaloyPaymentResponse, + GaloyPaymentState, GaloyPaymentStatusMapping, GaloyPermissionsMode, GaloyProvider, + GaloyWalletConfig, + }; +} + +pub mod flash { + pub mod lib; + pub use lib::{FlashConfig, FlashNode, DEFAULT_FLASH_GRAPHQL_URL}; +} + pub mod speed { pub mod api; pub mod lib; @@ -372,6 +389,18 @@ pub fn create_blink_node(config: blink::BlinkConfig) -> Arc { Arc::new(blink::BlinkNode::new(config)) } +/// Create a configurable Galoy GraphQL node as a polymorphic LightningNode. +#[cfg_attr(feature = "uniffi", uniffi::export)] +pub fn create_galoy_node(config: galoy::GaloyConfig) -> Arc { + Arc::new(galoy::GaloyNode::new(config)) +} + +/// Create a Flash node backed by the generic Galoy implementation. +#[cfg_attr(feature = "uniffi", uniffi::export)] +pub fn create_flash_node(config: flash::FlashConfig) -> Arc { + Arc::new(flash::FlashNode::new(config)) +} + /// Create a Phoenixd node as a polymorphic LightningNode #[cfg_attr(feature = "uniffi", uniffi::export)] pub fn create_phoenixd_node(config: phoenixd::PhoenixdConfig) -> Arc { diff --git a/crates/lni/permissions.rs b/crates/lni/permissions.rs index 8f6fce1c..947a8711 100644 --- a/crates/lni/permissions.rs +++ b/crates/lni/permissions.rs @@ -225,7 +225,7 @@ pub fn parse_strike_oauth_permissions(access_token: &str) -> Option Some(normalize_permissions(permissions)) } -pub fn parse_blink_token_permissions(token: &str) -> Option { +pub fn parse_galoy_token_permissions(token: &str) -> Option { let payload = decode_jwt_payload(token)?; let scopes: Vec = read_scope_values(&payload) .into_iter() @@ -245,6 +245,10 @@ pub fn parse_blink_token_permissions(token: &str) -> Option { Some(normalize_permissions(permissions)) } +pub fn parse_blink_token_permissions(token: &str) -> Option { + parse_galoy_token_permissions(token) +} + fn permissions_from_values(values: &[String]) -> Permissions { let has = |permission: &str| { values diff --git a/readme.md b/readme.md index d3224173..1b2727cf 100644 --- a/readme.md +++ b/readme.md @@ -110,6 +110,179 @@ const listTxnParams = { const txns = await node.listTransactions(listTxnParams); ``` +Galoy GraphQL (Blink, Flash, and compatible deployments) +-------------------------------------------------------- + +Galoy is modeled as a protocol rather than a brand. TypeScript applications use `kind: 'galoy'`; Rust applications use `GaloyNode` or `create_galoy_node`. The legacy Blink APIs remain as compatibility wrappers. + +TypeScript Flash-style configuration with an explicit USD wallet and a status-only payment response: + +```typescript +const flash = createNode({ + kind: 'galoy', + config: { + apiKey: process.env.FLASH_API_KEY!, + baseUrl: 'https://api.flashapp.me/graphql', + provider: { id: 'flash', name: 'Flash' }, + wallet: { + mode: 'explicit', + id: process.env.FLASH_WALLET_ID!, + currency: 'USD', + }, + invoiceOperations: { + create: { kind: 'unsupported' }, + feeProbe: { + kind: 'usd', + denomination: 'usd-cents', + }, + }, + additionalHeaders: { + 'x-flash-client-capabilities': 'status-only-payments', + }, + payment: { + response: 'status-only', + acceptedStatuses: ['SUCCESS', 'PENDING', 'ALREADY_PAID'], + statusMapping: { + settled: ['SUCCESS', 'ALREADY_PAID'], + pending: ['PENDING'], + }, + }, + capabilities: { + transactionLookup: false, + transactionHistory: false, + invoiceEvents: false, + onchain: false, + }, + permissions: 'configured', + httpTimeout: 60, + }, +}); +``` + +The equivalent convenience wrapper supplies those Flash defaults: + +```typescript +const flashNode = createNode({ + kind: 'flash', + config: { + apiKey: process.env.FLASH_API_KEY!, + walletId: process.env.FLASH_WALLET_ID!, + walletCurrency: 'USD', + additionalHeaders: { + 'x-flash-client-capabilities': 'status-only-payments', + }, + }, +}); +``` + +`FlashNode` defaults to `https://api.flashapp.me/graphql`; set `baseUrl` only to target another Flash-compatible deployment. +Flash invoice creation is intentionally disabled: LNI's invoice API supplies +millisatoshis, while Flash's USD invoice mutation requires USD cents. USD fee +probe values are also cent-denominated and therefore remain `feeMsats: 0`. + +Because Flash uses status-only payments, accepted proofless payments can resolve +with an empty preimage. TypeScript keeps the shared `PayInvoiceResponse` +unchanged; call `FlashNode.payInvoiceWithStatus()` or +`GaloyNode.payInvoiceWithStatus()` for a `GaloyPaymentOutcome` containing +`state` and `providerStatus`. `SUCCESS` and `ALREADY_PAID` are settled, while +`PENDING` remains pending. + +TypeScript Blink configuration with currency-based BTC wallet selection: + +```typescript +const blink = createNode({ + kind: 'galoy', + config: { + apiKey: process.env.BLINK_API_KEY!, + baseUrl: 'https://api.blink.sv/graphql', + provider: { id: 'blink', name: 'Blink' }, + wallet: { mode: 'currency', currency: 'BTC' }, + invoiceOperations: { + create: { kind: 'btc', denomination: 'sats' }, + feeProbe: { kind: 'btc', denomination: 'sats' }, + }, + payment: { + response: 'transaction-with-preimage', + acceptedStatuses: ['SUCCESS'], + }, + capabilities: { + transactionLookup: true, + transactionHistory: true, + invoiceEvents: true, + onchain: true, + }, + permissions: 'jwt-introspection', + httpTimeout: 60, + }, +}); +``` + +The equivalent Rust configuration uses strongly typed modes: + +```rust +use lni::galoy::{ + GaloyCapabilities, GaloyConfig, GaloyInvoiceOperation, + GaloyInvoiceOperationsConfig, GaloyNode, GaloyPaymentConfig, + GaloyPaymentResponse, GaloyPaymentStatusMapping, GaloyPermissionsMode, + GaloyProvider, GaloyWalletConfig, +}; + +let flash = GaloyNode::new(GaloyConfig { + api_key, + base_url, + provider: GaloyProvider { + id: "flash".to_string(), + name: "Flash".to_string(), + }, + wallet: GaloyWalletConfig::Explicit { + id: wallet_id, + currency: "USD".to_string(), + }, + invoice_operations: GaloyInvoiceOperationsConfig { + create: GaloyInvoiceOperation::Unsupported, + fee_probe: GaloyInvoiceOperation::UsdCents, + }, + payment: GaloyPaymentConfig { + response: GaloyPaymentResponse::StatusOnly, + accepted_statuses: vec![ + "SUCCESS".to_string(), + "PENDING".to_string(), + "ALREADY_PAID".to_string(), + ], + status_mapping: Some(GaloyPaymentStatusMapping { + settled: vec!["SUCCESS".to_string(), "ALREADY_PAID".to_string()], + pending: vec!["PENDING".to_string()], + }), + proof_unavailable_error_codes: vec!["PROOF_UNAVAILABLE".to_string()], + }, + capabilities: GaloyCapabilities { + transaction_lookup: false, + transaction_history: false, + invoice_events: false, + onchain: false, + }, + permissions: GaloyPermissionsMode::Configured, + additional_headers: None, + http_timeout: Some(60), + socks5_proxy: None, + accept_invalid_certs: Some(false), +}); +``` + +Invoice operations are selected explicitly rather than inferred from “not +BTC.” `BtcSats`, `UsdCents`, and `Unsupported` prevent arbitrary regional +wallets from being routed to USD-specific GraphQL mutations. Non-BTC balances +and fee-probe amounts are never interpreted as satoshis. Additional headers +cannot override `x-api-key` or `content-type`. + +Rust also exports `FlashConfig`, `FlashNode`, and `create_flash_node` with the +same conservative Flash defaults and unchanged `FlashConfig` shape. +The shared Rust `PayInvoiceResponse` is also unchanged for source compatibility. +Call `FlashNode::pay_invoice_with_status` or +`GaloyNode::pay_invoice_with_status` to receive a `GaloyPaymentOutcome` with +typed `state` and the original `provider_status`; the ordinary +`LightningNode::pay_invoice` method continues to return the legacy response. + #### Decode Decode helpers are pure local functions. They do not require node config.