diff --git a/package.json b/package.json index f701bc881e..542debdcf8 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,9 @@ "path-to-regexp@>=0.1.7": "^0.1.12", "path-to-regexp@>=6.3.0": "^6.3.0", "next": "^15.2.3", - "form-data": "^4.0.4" + "form-data": "^4.0.4", + "@interledger/stream-receiver": "workspace:*", + "@interledger/pay": "workspace:*" } } } diff --git a/packages/backend/package.json b/packages/backend/package.json index a94a1ef918..5d604fe3a6 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -8,7 +8,7 @@ "test:sincemain:cov": "pnpm test:sincemain --coverage", "knex": "knex", "generate": "graphql-codegen --config codegen.yml", - "build:deps": "pnpm --filter token-introspection build", + "build:deps": "pnpm --filter @interledger/stream-receiver build && pnpm --filter @interledger/pay build && pnpm --filter token-introspection build", "build": "pnpm build:deps && pnpm clean && tsc --build tsconfig.json && pnpm copy-files", "clean": "rm -fr dist/", "copy-files": "cp src/graphql/schema.graphql dist/graphql/ && cp -r ./src/openapi ./dist/", diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index a6710ecc8c..32ca4f7261 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -202,7 +202,10 @@ export const Config = { sendTenantWebhooksToOperator: envBool( 'SEND_TENANT_WEBHOOKS_TO_OPERATOR', false - ) + ), + kycAseDecisionUrl: process.env.KYC_ASE_DECISION_URL, + kycDecisionMaxWaitMs: envInt('KYC_DECISION_MAX_WAIT_MS', 1500), + kycDecisionSafetyMarginMs: envInt('KYC_DECISION_SAFETY_MARGIN_MS', 100) } function parseRedisTlsConfig( diff --git a/packages/backend/src/payment-method/ilp/connector/core/middleware/index.ts b/packages/backend/src/payment-method/ilp/connector/core/middleware/index.ts index 3e84c260de..d87e9f25d6 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/middleware/index.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/middleware/index.ts @@ -18,3 +18,4 @@ export * from './rate-limit' export * from './reduce-expiry' export * from './throughput' export * from './validate-fulfillment' +export * from './kyc-decision' diff --git a/packages/backend/src/payment-method/ilp/connector/core/middleware/kyc-decision.ts b/packages/backend/src/payment-method/ilp/connector/core/middleware/kyc-decision.ts new file mode 100644 index 0000000000..b895b6208c --- /dev/null +++ b/packages/backend/src/payment-method/ilp/connector/core/middleware/kyc-decision.ts @@ -0,0 +1,65 @@ +import { Errors } from 'ilp-packet' +import { ILPContext, ILPMiddleware } from '../rafiki' +import { StreamState } from './stream-address' + +export function createKycDecisionMiddleware(): ILPMiddleware { + return async ( + ctx: ILPContext, + next: () => Promise + ): Promise => { + const { config, logger, redis } = ctx.services + + if (!ctx.state.streamDestination || !ctx.state.hasAdditionalData) { + await next() + return + } + + const incomingPaymentId = ctx.state.streamDestination + const connectionId = ctx.state.connectionId ?? 'unknown' + const cacheKey = `kyc_decision:${incomingPaymentId}:${connectionId}` + + // Bounded polling: wait for decision up to (packet expiry - safetyMs) or maxWaitMs + const safetyMs = Number.isFinite(config.kycDecisionSafetyMarginMs) + ? config.kycDecisionSafetyMarginMs + : 100 + const maxWaitMs = Number.isFinite(config.kycDecisionMaxWaitMs) + ? config.kycDecisionMaxWaitMs + : 1500 + + const expiresAt = ctx.request.prepare.expiresAt + const now = Date.now() + const timeRemaining = Math.max(0, expiresAt.getTime() - now - safetyMs) + const deadline = now + Math.min(timeRemaining, maxWaitMs) + const pollIntervalMs = 50 + + const readDecision = async (): Promise => { + try { + const value = await redis.get(cacheKey) + return value ?? undefined + } catch (e) { + logger.warn({ e, incomingPaymentId }, 'decision read failed') + return + } + } + + let decision = await readDecision() + while (!decision && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, pollIntervalMs)) + decision = await readDecision() + } + + if (!decision) { + decision = 'No response from ASE' + } + + if (decision === 'KYC allowed') { + await next() + return + } + + throw new Errors.FinalApplicationError( + 'Data failed verification', + Buffer.from(decision, 'utf8') + ) + } +} diff --git a/packages/backend/src/payment-method/ilp/connector/core/middleware/stream-address.ts b/packages/backend/src/payment-method/ilp/connector/core/middleware/stream-address.ts index df52b5de1d..bf72152b94 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/middleware/stream-address.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/middleware/stream-address.ts @@ -6,6 +6,8 @@ import { AuthState } from './auth' export interface StreamState { streamDestination?: string streamServer?: StreamServer + hasAdditionalData?: boolean + connectionId?: string } export function createStreamAddressMiddleware(): ILPMiddleware { @@ -34,8 +36,36 @@ export function createStreamAddressMiddleware(): ILPMiddleware { ctx.request.prepare.destination ) || undefined - stopTimer() - await next() + // Decode frames to check for additional data + try { + if (ctx.state.streamServer && ctx.state.streamDestination) { + const replyOrMoney = ctx.state.streamServer.createReply( + ctx.request.prepare + ) + const frames = (replyOrMoney as any).dataFrames as + | Array<{ streamId: number; offset: string; data: Buffer }> + | undefined + const payload = frames?.length + ? frames.find((f) => f.streamId === 1)?.data ?? frames[0].data + : undefined + + if (payload && payload.length > 0) { + ctx.services.logger.info( + { payload: payload.toString('utf8') }, + 'STREAM additional data received' + ) + } + + ctx.state.hasAdditionalData = !!payload?.length + if (typeof (replyOrMoney as any).connectionId === 'string') { + ctx.state.connectionId = (replyOrMoney as any).connectionId + } + //TODO Here we should store STREAM data payload i.e. db call in webhook events table + } + } finally { + stopTimer() + await next() + } } } diff --git a/packages/backend/src/payment-method/ilp/connector/index.ts b/packages/backend/src/payment-method/ilp/connector/index.ts index 05a3091439..04c1ee0425 100644 --- a/packages/backend/src/payment-method/ilp/connector/index.ts +++ b/packages/backend/src/payment-method/ilp/connector/index.ts @@ -24,7 +24,8 @@ import { createOutgoingThroughputMiddleware, createOutgoingValidateFulfillmentMiddleware, createStreamAddressMiddleware, - createStreamController + createStreamController, + createKycDecisionMiddleware } from './core' import { TelemetryService } from '../../../telemetry/service' import { TenantSettingService } from '../../../tenants/settings/service' @@ -76,6 +77,7 @@ export async function createConnectorService({ // Incoming Rules createIncomingErrorHandlerMiddleware(ilpAddress), createStreamAddressMiddleware(), + createKycDecisionMiddleware(), createAccountMiddleware(), createIncomingMaxPacketAmountMiddleware(), createIncomingRateLimitMiddleware({}), diff --git a/packages/backend/src/payment-method/ilp/service.ts b/packages/backend/src/payment-method/ilp/service.ts index 633f01ea71..21c02b5109 100644 --- a/packages/backend/src/payment-method/ilp/service.ts +++ b/packages/backend/src/payment-method/ilp/service.ts @@ -340,7 +340,12 @@ async function pay( const destination = resolveIlpDestination(receiver) try { - const receipt = await Pay.pay({ plugin, destination, quote }) + const receipt = await Pay.pay({ + plugin, + destination, + quote, + appData: Buffer.from('hello kyc') + }) if (receipt.error) { throw receipt.error @@ -445,5 +450,6 @@ export const retryableIlpErrors: { [Pay.PaymentError.InsufficientExchangeRate]: true, [Pay.PaymentError.RateProbeFailed]: true, [Pay.PaymentError.IdleTimeout]: true, - [Pay.PaymentError.ClosedByReceiver]: true + [Pay.PaymentError.ClosedByReceiver]: true, + [Pay.PaymentError.AppDataRejected]: false } diff --git a/packages/pay/CHANGELOG.md b/packages/pay/CHANGELOG.md new file mode 100644 index 0000000000..12ee9c4e84 --- /dev/null +++ b/packages/pay/CHANGELOG.md @@ -0,0 +1,120 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [0.4.0-alpha.9](https://github.com/interledgerjs/interledgerjs/compare/@interledger/pay@0.4.0-alpha.8...@interledger/pay@0.4.0-alpha.9) (2022-09-28) + +**Note:** Version bump only for package @interledger/pay + +# [0.4.0-alpha.8](https://github.com/interledgerjs/interledgerjs/compare/@interledger/pay@0.4.0-alpha.7...@interledger/pay@0.4.0-alpha.8) (2022-08-18) + +**Note:** Version bump only for package @interledger/pay + +# [0.4.0-alpha.7](https://github.com/interledgerjs/interledgerjs/compare/@interledger/pay@0.4.0-alpha.6...@interledger/pay@0.4.0-alpha.7) (2022-06-20) + +### Features + +- **ilp-pay:** allow connection url as payment destination ([#290](https://github.com/interledgerjs/interledgerjs/issues/290)) ([fdfd4e6](https://github.com/interledgerjs/interledgerjs/commit/fdfd4e638399e40b675f75be01eb7c3e08e9545c)) + +# [0.4.0-alpha.6](https://github.com/interledgerjs/interledgerjs/compare/@interledger/pay@0.4.0-alpha.5...@interledger/pay@0.4.0-alpha.6) (2022-06-10) + +**Note:** Version bump only for package @interledger/pay + +# [0.4.0-alpha.5](https://github.com/interledgerjs/interledgerjs/compare/@interledger/pay@0.4.0-alpha.4...@interledger/pay@0.4.0-alpha.5) (2022-05-04) + +**Note:** Version bump only for package @interledger/pay + +# [0.4.0-alpha.4](https://github.com/interledgerjs/interledgerjs/compare/@interledger/pay@0.4.0-alpha.3...@interledger/pay@0.4.0-alpha.4) (2022-04-27) + +### Bug Fixes + +- fixing eslint issues ([6093679](https://github.com/interledgerjs/interledgerjs/commit/6093679060d9f27911e2fd3f0dbbf15ebae6f538)) +- tests which broke due to updated tooling ([eea42af](https://github.com/interledgerjs/interledgerjs/commit/eea42af4530c00cbd0736a962aed92251ac136cd)) + +### BREAKING CHANGES + +- Add `isConnected` property to the Plugin interface in ilp-plugin. This property should have already been there and most plugins are likely to implement it because it is required in other contexts. For example, ilp-protocol-stream requires it. + +# [0.4.0-alpha.3](https://github.com/interledgerjs/interledgerjs/compare/@interledger/pay@0.4.0-alpha.2...@interledger/pay@0.4.0-alpha.3) (2022-04-11) + +### Bug Fixes + +- specify 'Content-Type' in POST request ([67fef5d](https://github.com/interledgerjs/interledgerjs/commit/67fef5d2fecbc4da4106161ad397ca34e788d12c)) + +# [0.4.0-alpha.2](https://github.com/interledgerjs/interledgerjs/compare/@interledger/pay@0.4.0-alpha.1...@interledger/pay@0.4.0-alpha.2) (2022-04-01) + +**Note:** Version bump only for package @interledger/pay + +# [0.4.0-alpha.1](https://github.com/interledgerjs/interledgerjs/compare/@interledger/pay@0.4.0-alpha.0...@interledger/pay@0.4.0-alpha.1) (2022-03-30) + +### Bug Fixes + +- **pay:** fix log.debug format string ([e4377d0](https://github.com/interledgerjs/interledgerjs/commit/e4377d06a2b5761b051bcfe8257ba90471e19dcf)) + +### Features + +- **pay:** create Incoming Payment in setupPayment ([8af8d35](https://github.com/interledgerjs/interledgerjs/commit/8af8d35d3ebcf052cfb813048becb816d50c253a)) + +# [0.4.0-alpha.0](https://github.com/interledgerjs/interledgerjs/compare/@interledger/pay@0.3.3...@interledger/pay@0.4.0-alpha.0) (2022-03-21) + +### Features + +- **pay:** update to Open Payments v2 ([#262](https://github.com/interledgerjs/interledgerjs/issues/262)) ([82da805](https://github.com/interledgerjs/interledgerjs/commit/82da8058a1e545519b84589b6543442a755dbf0c)) + +## [0.3.3](https://github.com/interledgerjs/interledgerjs/compare/@interledger/pay@0.3.2...@interledger/pay@0.3.3) (2021-11-09) + +### Bug Fixes + +- **pay:** invoice url can be distinct from account url ([dd67b42](https://github.com/interledgerjs/interledgerjs/commit/dd67b42faef9a35e5291b0f3300072982c9f6a4c)) + +## [0.3.2](https://github.com/interledgerjs/interledgerjs/compare/@interledger/pay@0.3.1...@interledger/pay@0.3.2) (2021-10-25) + +### Bug Fixes + +- **pay:** fix flaky test ([8e218c0](https://github.com/interledgerjs/interledgerjs/commit/8e218c034aa763700391995fcfbc50f47c01ff97)) + +## [0.3.1](https://github.com/interledgerjs/interledgerjs/compare/@interledger/pay@0.3.0...@interledger/pay@0.3.1) (2021-10-07) + +**Note:** Version bump only for package @interledger/pay + +# [0.3.0](https://github.com/interledgerjs/interledgerjs/compare/@interledger/pay@0.2.2...@interledger/pay@0.3.0) (2021-08-03) + +### Bug Fixes + +- **pay:** comments ([4cd20c8](https://github.com/interledgerjs/interledgerjs/commit/4cd20c8b2dd80d0f72042913649bbd3a36a21461)) +- **pay:** more comments ([b06a959](https://github.com/interledgerjs/interledgerjs/commit/b06a959eacb917ba629caf1e902d4277a1162ead)) +- **pay:** Quote vs IntQuote ([ff1ad66](https://github.com/interledgerjs/interledgerjs/commit/ff1ad661a400810a911292077c9b398776dd06a6)) +- **pay:** use BigInt instead of Int for api ([0f8a814](https://github.com/interledgerjs/interledgerjs/commit/0f8a8144f5f6f2331a05d6883842c1a4f5096731)) +- address comments ([cc286ce](https://github.com/interledgerjs/interledgerjs/commit/cc286cea8e17380bc4a7db351cc45209d2bf43fe)) + +### Features + +- **pay:** allow http payment pointers ([9118a03](https://github.com/interledgerjs/interledgerjs/commit/9118a03c2a05f34a9d66660eae99c81ad580a3c1)) +- composable, stateless top-level functions ([20d92ce](https://github.com/interledgerjs/interledgerjs/commit/20d92ce1d4d6f4a3807164a14ec7d1b5aa968e1d)) +- **pay:** internal api, send-only, receipts ([8ff7c2c](https://github.com/interledgerjs/interledgerjs/commit/8ff7c2cca1a3c8ab2f1a293eb04c0b07e05a7eaa)) +- **pay:** top-level api, docs, spsp improvements ([82537ee](https://github.com/interledgerjs/interledgerjs/commit/82537ee1d845d400a3e9a9351ad4d5ddd0c293d9)) + +## [0.2.2](https://github.com/interledgerjs/interledgerjs/compare/@interledger/pay@0.2.1...@interledger/pay@0.2.2) (2020-07-27) + +**Note:** Version bump only for package @interledger/pay + +## [0.2.1](https://github.com/interledgerjs/interledgerjs/compare/@interledger/pay@0.2.0...@interledger/pay@0.2.1) (2020-07-27) + +**Note:** Version bump only for package @interledger/pay + +# 0.2.0 (2020-07-24) + +### Bug Fixes + +- only share address to fetch asset details ([af8eb92](https://github.com/interledgerjs/interledgerjs/commit/af8eb920eea859951fc8e826541b9f8588e2f138)) + +### Features + +- **pay:** add backoff to pacer ([15c2de4](https://github.com/interledgerjs/interledgerjs/commit/15c2de48d3e6f21559488ff6125d30419ad28cda)) +- **pay:** discover precise max packet amount ([dfe2164](https://github.com/interledgerjs/interledgerjs/commit/dfe2164dcd30d0d3cbe9f3b5275b6561bbb1f355)) +- estimate duration, min delivery amount ([d0f2ace](https://github.com/interledgerjs/interledgerjs/commit/d0f2ace899c1f28cff64b747f051603c8bc3eea2)) +- robust amount strategy, rate errors ([fdcb132](https://github.com/interledgerjs/interledgerjs/commit/fdcb1324e5e8285da528b60b5c23098324efb9dc)) +- **pay:** open payments support ([2d4ba19](https://github.com/interledgerjs/interledgerjs/commit/2d4ba19275b444e46845a9114537b624d939f5ae)) +- stateless stream receiver ([aed91d8](https://github.com/interledgerjs/interledgerjs/commit/aed91d85c06aa73af77a8c3891d388257b74ede8)) +- STREAM payment library alpha, ci updates ([#17](https://github.com/interledgerjs/interledgerjs/issues/17)) ([4e128bc](https://github.com/interledgerjs/interledgerjs/commit/4e128bcee372144c1324a73e8b51223a0b133f2e)) diff --git a/packages/pay/README.md b/packages/pay/README.md new file mode 100644 index 0000000000..d2bb04f033 --- /dev/null +++ b/packages/pay/README.md @@ -0,0 +1,442 @@ +## `pay` :money_with_wings: + +> Send payments over Interledger using STREAM + +[![NPM Package](https://img.shields.io/npm/v/@interledger/pay.svg?style=flat&logo=npm)](https://npmjs.org/package/@interledger/pay) +[![GitHub Actions](https://img.shields.io/github/workflow/status/interledgerjs/interledgerjs/master.svg?style=flat&logo=github)](https://github.com/interledgerjs/interledgerjs/actions?query=workflow%3Amaster) +[![Codecov](https://img.shields.io/codecov/c/github/interledgerjs/interledgerjs/master.svg?logo=codecov&flag=pay)](https://codecov.io/gh/interledgerjs/interledgerjs/tree/master/packages/pay/src) +[![Prettier](https://img.shields.io/badge/code_style-prettier-brightgreen.svg)](https://prettier.io/) + +## Install + +```bash +npm i @interledger/pay +``` + +Or using Yarn: + +```bash +yarn add @interledger/pay +``` + +## Guide + +### Flow + +1. Call **[`setupPayment`](#setuppayment)** to resolve the payment details, destination asset, and/or Incoming Payment +1. Add custom logic before continuing, or catch error +1. Call **[`startQuote`](#startquote)** to probe the exchange rate, discover the max packet amount, and compute payment limits +1. Add custom logic to authorize payment for maximum source amount, or catch error +1. Call **[`pay`](#pay)** to execute the payment +1. Add custom logic to handle payment outcome or error + +### Pay an Incoming Payment + +> Fixed delivery amount payment + +```js +import { + setupPayment, + startQuote, + pay, + closeConnection +} from '@interledger/pay' + +async function run() { + let plugin /* Plugin instance */ + + const destination = await setupPayment({ + plugin, + destinationPayment: + 'https://mywallet.example/accounts/alice/incoming-payments/04ef492f-94af-488e-8808-3ea95685c992' + }) + + const quote = await startQuote({ + plugin, + destination, + sourceAsset: { + assetCode: 'USD', + assetScale: 9 + } + }) + // { + // maxSourceAmount: 1_950n, + // lowEstimatedExchangeRate: 115, + // highEstimatedExchangeRate: 135, + // minExchangeRate: 110, + // } + + // Verify the max source amount is appropriate and perform or cancel the payment + const receipt = await pay({ plugin, destination, quote }) + console.log(receipt) + // { + // amountSent: 1_910n, + // amountDelivered: BigInt(234_000), + // ... + // } + + await closeConnection(plugin, destination) +} +``` + +### Pay an Incoming Payment via a Connection URL + +> Fixed delivery amount payment + +```js +import { + setupPayment, + startQuote, + pay, + closeConnection +} from '@interledger/pay' + +async function run() { + let plugin /* Plugin instance */ + + const destination = await setupPayment({ + plugin, + destinationConnection: + 'https://mywallet.example/bddcc820-c8a1-4a15-b768-95ea2a4ed37b' + }) + + const quote = await startQuote({ + plugin, + destination, + sourceAsset: { + assetCode: 'USD', + assetScale: 9 + } + }) + // { + // maxSourceAmount: 1_950n, + // lowEstimatedExchangeRate: 115, + // highEstimatedExchangeRate: 135, + // minExchangeRate: 110, + // } + + // Verify the max source amount is appropriate and perform or cancel the payment + const receipt = await pay({ plugin, destination, quote }) + + await closeConnection(plugin, destination) +} +``` + +### Pay to a [Payment Pointer](https://paymentpointers.org/) + +> Fixed source amount payment + +```js +import { setupPayment, startQuote, pay, closeConnection } from '@interledger/pay' + +async function run() { + let plugin /* Plugin instance */ + + const destination = await setupPayment({ + plugin, + paymentPointer: '$rafiki.money/p/example', + }) + + const quote = await startQuote( + plugin, + amountToSend: '314159', + sourceAmount: { + assetCode: 'EUR', + assetScale: 6, + }, + destination + }) + + const receipt = await pay({ plugin, destination, quote }) + + await closeConnection(plugin, destination) +} +``` + +### Units + +[On Interledger assets and denominations](https://interledger.org/rfcs/0038-settlement-engines/#units-and-quantities): + +> Asset amounts may be represented using any arbitrary denomination. For example, one U.S. dollar may be represented as \$1 or 100 cents, each of which is equivalent in value. Likewise, one Bitcoin may be represented as 1 BTC or 100,000,000 satoshis. +> +> A **standard unit** is the typical unit of value for a particular asset, such as \$1 in the case of U.S. dollars, or 1 BTC in the case of Bitcoin. +> +> A **fractional unit** represents some unit smaller than the standard unit, but with greater precision. Examples of fractional monetary units include one cent (\$0.01 USD), or 1 satoshi (0.00000001 BTC). +> +> An **asset scale** is the difference in orders of magnitude between the standard unit and a corresponding fractional unit. More formally, the asset scale is a non-negative integer (0, 1, 2, …) such that one standard unit equals the value of `10^(scale)` corresponding fractional units. If the fractional unit equals the standard unit, then the asset scale is 0. +> +> For example, one cent represents an asset scale of 2 in the case of USD, whereas one satoshi represents an asset scale of 8 in the case of Bitcoin. + +To simplify accounting, all amounts are represented as unsigned integers in a fractional unit of the asset corresponding to the source asset scale provided, or the destination asset scale resolved from the receiver. + +Since applications need to debit the source amount in their own system before executing a payment, this assumes they also know their own source asset and denomination. Therefore, it's not useful to resolve this information dynamically, such as using [IL-DCP](https://interledger.org/rfcs/0031-dynamic-configuration-protocol/), which also delays connection establishment. + +### Amounts + +Pay leverages JavaScript [`BigInt`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt) for arbitrarily large integers using its own wrapper for strongly-typed arithmetic operations. + +Amounts returned by Pay use these exported classes and interfaces: + +- **[`Int`](https://github.com/interledgerjs/interledgerjs/blob/master/packages/pay/src/utils.ts#L38)** — Class representing non-negative integers. +- **[`PositiveInt`](https://github.com/interledgerjs/interledgerjs/blob/master/packages/pay/src/utils.ts#L193)** — Interface narrowing **`Int`**, representing non-negative, non-zero integers. (In this context, zero is not considered signed). +- **[`Ratio`](https://github.com/interledgerjs/interledgerjs/blob/master/packages/pay/src/utils.ts#L234)** — Class representing a ratio of two integers: a non-negative numerator, and a non-negative, non-zero denominator. +- **[`PositiveRatio`](https://github.com/interledgerjs/interledgerjs/blob/master/packages/pay/src/utils.ts#L326)** — Interface narrowing **`Ratio`**, representing a ratio of two non-negative, non-zero integers. + +**`Int`** and **`Ratio`** offer utility methods for integer operations and comparisons. They may also be converted to/from `number`, `string`, `bigint`, and [`Long`](https://github.com/dcodeIO/Long.js/). + +**`Int`** and **`Ratio`** prevent divide-by-zero errors and enforce the internal `bigint` is always non-negative. They also provide type guards for **`PositiveInt`** to reduce unnecessary code paths. For example, if one integer is greater than another, that integer must always be non-zero, and can be safely used as a ratio denominator without any divide-by-zero branch. + +### Exchange Rates + +Pay is designed to provide strict guarantees of the amount that will be delivered. + +During the quote step, the application provides Pay with prices for the source and destination assets and its own acceptable slippage percentage, which Pay uses to calculate a minimum exchange rate and corresponding minimum destination amount it will enforce for the payment. Exchange rates are represented as the ratio between a destination amount and a source amount, in fractional units. + +Then, Pay probes the recipient to determine the real exchange rate over that path. If it sufficiently exceeds the minimum exchange rate, Pay will allow the payment to proceed. Otherwise, it's not possible to complete the payment. For instance, connectors may have applied a poor rate or charged too much in fees, the max packet size might be too small to avoid rounding errors, or incorrect assets/scales were provided. + +Since STREAM payments are packetized, Pay may not be able to complete a payment if, for instance, the sender and receiver become disconnected during the payment. However, Pay guarantees payments never exhaust their quoted maximum source amount without satisfying their quoted minimum delivery amount. Every delivered packet meets or exceeds the quoted minimum exchange rate (\*with the exception of the final one, as necessary). + +### Error Handling + +If setup or quoting fails, Pay will reject the Promise with a variant of the **[`PaymentError`](#paymenterror)** enum. For example: + +```js +import { setupPayment, PaymentError } from '@interledger/pay' + +try { + await setupPayment({ ... }) +} catch (err) { + if (err === PaymentError.InvalidPaymentPointer) { + console.log('Payment pointer is invalid!') + } + + // ... +} +``` + +Similarly, if an error was encountered during the payment itself, it will include an `error` property on the result which is a **[`PaymentError`](#paymenterror)** variant. + +A predicate function, **`isPaymentError`**, is also exported to check if any value is a variant of the enum. + +### Payment Pointers + +Pay exports the **[`AccountUrl`](https://github.com/interledgerjs/interledgerjs/blob/master/packages/pay/src/payment-pointer.ts#L13)** utility to validate payment pointers and SPSP/Open Payments account URLs. Since payment pointers identify unique Interledger accounts, Pay parses them so they can be compared against external references to the same account. + +### Connection Security + +Some applications may find it useful for multiple Pay library instances to send over a single STREAM connection, such as quoting in one process, and sending money in another. + +In this case, the client application must track key security parameters, such as the request count, which STREAM relies on for monotonically increasing sequence numbers and secure acknowledgements of each request. + +Pay uses a **[`Counter`](https://github.com/interledgerjs/interledgerjs/blob/master/packages/pay/src/controllers/sequence.ts#L6)** instance, passed in-process via the **[`ResolvedPayment`](#resolvedpayment)** object, to track how many packets have been sent. Applications that resume connections **MUST** use the counter instance to fetch how many packets have been sent, then create a new counter with the existing request count to pass to new Pay instances that use the same connection. + +Other connection invariants applications should enforce: + +1. **Only one** Pay instance (any actively running call to `startQuote`, `pay`, or `closeConnection`) can send over a single connection at one time. +1. After a connection is closed via calling `closeConnection`, those connection details may no longer be used for sending. + +## API + +#### `setupPayment` + +> `(options:`**[`SetupOptions`](#setupoptions)**`) => Promise<`**[`ResolvedPayment`](#resolvedpayment)**`>` + +Resolve destination details and asset of the payment in order to establish a STREAM connection. + +#### `startQuote` + +> `(options:`**[`QuoteOptions`](#quoteoptions)**`) => Promise<`**[`Quote`](#quote)**`>` + +Perform a rate probe: discover path max packet amount, probe the real exchange rate, and compute the minimum exchange rate and bounds of the payment. + +#### `pay` + +> `(options:`**[`PayOptions`](#payoptions)**`) => Promise<`**[`PaymentProgress`](#paymentprogress)**`>` + +Send the payment: send a series of packets to attempt the payment within the completion criteria and limits of the provided quote. + +#### `closeConnection` + +> `(plugin:`**[`Plugin`](https://github.com/interledger/rfcs/blob/master/deprecated/0024-ledger-plugin-interface-2/0024-ledger-plugin-interface-2.md)**`, destination:`**[`ResolvedPayment`](#resolvedpayment)**`) => Promise` + +If the connection was established, notify receiver to close the connection. For stateless receivers, this may have no effect. + +#### `SetupOptions` + +> Interface + +Parameters to setup and resolve payment details from the recipient. + +| Property | Type | Description | +| :--------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`plugin`** | **[`Plugin`](https://github.com/interledger/rfcs/blob/master/deprecated/0024-ledger-plugin-interface-2/0024-ledger-plugin-interface-2.md)** | Plugin to send packets over a connected Interledger network (no receive functionality is necessary). Pay does not call `connect` or `disconnect` on the plugin, so the application must perform that manually. | +| **`destinationAccount`** (_Optional_) | `string` | SPSP Payment pointer or SPSP account URL to query STREAM connection credentials and exchange asset details. Example: `$rafiki.money/p/alice`. Either **`destinationAccount`** , **`destinationPayment`**, or **`destinationConnection`** must be provided. | +| **`destinationPayment`** (_Optional_) | `string` | [Open Payments Incoming Payment URL](https://docs.openpayments.guide) to query the details for a fixed-delivery payment. The amount to deliver and destination asset details will automatically be resolved from the Incoming Payment. Either **`destinationAccount`** , **`destinationPayment`**, or **`destinationConnection`** must be provided. | +| **`destinationConnection`** (_Optional_) | `string` | [Open Payments STREAM Connection URL](https://docs.openpayments.guide) to query STREAM connection credentials and exchange asset details for a fixed-delivery payment. Either **`destinationAccount`** , **`destinationPayment`**, or **`destinationConnection`** must be provided. | +| **`amountToDeliver`** (_Optional_) | **[`Amount`](#amount)** | Fixed amount of the created Incoming Payment, in base units of the destination asset.

Note: this option requires the destination asset to be known in advance. The application must ensure the destination asset resolved via STREAM is the expected asset and denomination. | + +#### `ResolvedPayment` + +> Interface + +Resolved destination details of a proposed payment, such as the destination asset, Incoming Payment, and STREAM credentials, ready to perform a quote. + +| Property | Type | Description | +| :------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`destinationAsset`** | **[`AssetDetails`](#assetdetails)** | Destination asset and denomination, resolved using Open Payments or STREAM, or provided directly. | +| **`destinationAddress`** | `string` | ILP address of the destination STREAM recipient, uniquely identifying this connection. | +| **`sharedSecret`** | `Uint8Array` | 32-byte seed to derive keys to encrypt STREAM messages and generate ILP packet fulfillments. | +| **`destinationPaymentDetails`** (_Optional_) | **[`IncomingPayment`](#incomingpayment)** | Open Payments Incoming Payment metadata, if the payment pays into an Incoming Payment. | +| **`accountUrl`** (_Optional_) | `string` | URL of the recipient Open Payments/SPSP account (with well-known path, and stripped trailing slash). Each payment pointer and its corresponding account URL identifies a unique payment recipient. Not applicable if Open Payments STREAM Connection URL or STREAM credentials were provided directly. | +| **`destinationAccount`** (_Optional_) | `string` | Payment pointer, prefixed with "\$", corresponding to the recipient Open Payments/SPSP account. Each payment pointer and its corresponding account URL identifies a unique payment recipient. Not applicable if STREAM credentials were provided directly. | +| **`requestCounter`** | **[`Counter`](https://github.com/interledgerjs/interledgerjs/blob/master/packages/pay/src/controllers/sequence.ts#L6)** | Strict counter of how many packets have been sent, to safely resume a connection | + +#### `QuoteOptions` + +> Interface + +Limits and target to quote a payment and probe the rate. + +| Property | Type | Description | +| :--------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`plugin`** | **[`Plugin`](https://github.com/interledger/rfcs/blob/master/deprecated/0024-ledger-plugin-interface-2/0024-ledger-plugin-interface-2.md)** | Plugin to send packets over a connected Interledger network (no receive functionality is necessary). Pay does not call `connect` or `disconnect` on the plugin, so the application must perform that manually. | +| **`destination`** | **[`ResolvedPayment`](#resolvedpayment)** | Resolved destination details of the payment, including the asset, Incoming Payment, and connection establishment information. | +| **`sourceAsset`** (_Optional_) | **[`AssetDetails`](#assetdetails)** | Source asset and denomination for the sender. Required to compute the minimum exchange rate, unless slippage is 100%. | +| **`amountToSend`** (_Optional_) | `string`, `number`, `bigint` or **[`Int`](#amounts)** | Fixed amount to send to the recipient, in base units of the sending asset. Either **`amountToSend`**, **`amountToDeliver`**, or **`destinationPayment`** must be provided, in order to determine how much to pay. | +| **`amountToDeliver`** (_Optional_) | `string`, `number`, `bigint` or **[`Int`](#amounts)** | Fixed amount to deliver to the recipient, in base units of the destination asset. **`destinationPayment`** is recommended method to send fixed delivery payments, but this option enables sending a fixed-delivery payment to an SPSP server that doesn't support Open Payments.

Note: this option requires the destination asset to be known in advance. The application must ensure the destination asset resolved via STREAM is the expected asset and denomination. | +| **`prices`** (_Optional_) | `{ [string]: number }` | Object of asset codes to prices in a standardized base asset to compute exchange rates. For example, using U.S. dollars as a base asset: `{ USD: 1, EUR: 1.09, BTC: 8806.94 }`.

If the source and destination assets are the same, a 1:1 rate will be used as the basis, so **`prices`** doesn't need to be provided. It may also be omitted if the slippage is set to 100%, since no minimum exchange rates will be enforced. | +| **`slippage`** (_Optional_) | `number` | Percentage to subtract from the external exchange rate to determine the minimum acceptable exchange rate and destination amount for each packet, between `0` and `1` (inclusive). Defaults to `0.01`, or 1% slippage below the exchange rate computed from the given **`prices`**.

If `1` is provided for a fixed source amount payment, no minimum exchange rate will be enforced. For fixed delivery payments, slippage cannot be 100%. | + +#### `Quote` + +> Interface + +Parameters of payment execution and the projected outcome of a payment. + +| Property | Type | Description | +| :------------------------------ | :-------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`paymentType`** | **[`PaymentType`](#paymenttype)** | The completion criteria of the payment. For fixed source amount payments, `"FixedSend"`; for Incoming Payments and fixed delivery payments, `"FixedDelivery"`. | +| **`maxSourceAmount`** | `bigint` | Maximum amount that will be sent in the base unit and asset of the sending account. This is intended to be presented to the user or agent before authorizing a fixed delivery payment. For fixed source amount payments, this will be the provided **`amountToSend`**. | +| **`minDeliveryAmount`** | `bigint` | Minimum amount that will be delivered if the payment completes, in the base unit and asset of the receiving account. For fixed delivery payments, this will be the provided **`amountToDeliver`** or amount of the Incoming Payment. | +| **`maxPacketAmount`** | `bigint` | Discovered maximum packet amount allowed over this payment path. | +| **`minExchangeRate`** | **[`Ratio`](#amounts)** | Aggregate exchange rate the payment is guaranteed to meet, as a ratio of destination base units to source base units. Corresponds to the minimum exchange rate enforced on each packet (\*except for the final packet) to ensure sufficient money gets delivered. For strict bookkeeping, use `maxSourceAmount` instead. | +| **`lowEstimatedExchangeRate`** | **[`Ratio`](#amounts)** | Lower bound of probed exchange rate over the path (inclusive). Ratio of destination base units to source base units | +| **`highEstimatedExchangeRate`** | **[`Ratio`](#amounts)** | Upper bound of probed exchange rate over the path (exclusive). Ratio of destination base units to source base units | + +#### `PayOptions` + +> Interface + +Payment execution parameters. + +| Property | Type | Description | +| :--------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`plugin`** | **[`Plugin`](https://github.com/interledger/rfcs/blob/master/deprecated/0024-ledger-plugin-interface-2/0024-ledger-plugin-interface-2.md)** | Plugin to send packets over a connected Interledger network (no receive functionality is necessary). Pay does not call `connect` or `disconnect` on the plugin, so the application must perform that manually. | +| **`destination`** | **[`ResolvedPayment`](#resolvedpayment)** | Resolved destination details of the payment, including the asset, Incoming Payment, and connection establishment information. | +| **`quote`** | **[`Quote`](#quote)** | Parameters and rates to enforce during payment execution. | +| **`progressHandler`** (_Optional_) | `(progress:`**[`PaymentProgress`](#paymentprogress)**`) => void` | Callback to process streaming updates as packets are sent and received, such as to perform accounting while the payment is in progress. Handler will be called for all fulfillable packets and replies before the payment resolves. | + +#### `PaymentProgress` + +> Interface + +Intermediate state or outcome of the payment, to account for sent/delivered amounts. If the payment failed, the **`error`** property is included. + +| Property | Type | Description | +| :------------------------------- | :-------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------- | +| **`error`** (_Optional_) | **[PaymentError](#paymenterror)** | Error state, if the payment failed. | +| **`amountSent`** | `bigint` | Amount sent and fulfilled, in base units of the source asset. | +| **`amountDelivered`** | `bigint` | Amount delivered to the recipient, in base units of the destination asset. | +| **`sourceAmountInFlight`** | `bigint` | Amount sent that is yet to be fulfilled or rejected, in base units of the source asset. | +| **`destinationAmountInFlight`** | `bigint` | Estimate of the amount that may be delivered from in-flight packets, in base units of the destination asset. | +| **`streamReceipt`** (_Optional_) | `Uint8Array` | Latest [STREAM receipt](https://interledger.org/rfcs/0039-stream-receipts/) to provide proof-of-delivery to a 3rd party verifier. | + +#### `IncomingPayment` + +> Interface + +[Open Payments Incoming Payment](https://docs.openpayments.guide) metadata + +| Property | Type | Description | +| :------------------- | :--------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`id`** | `string` | URL used to query and identify the Incoming Payment. | +| **`paymentPointer`** | `string` | URL of the recipient Open Payments account to which incoming payments will be credited (with well-known path, and stripped trailing slash). Each payment pointer and its corresponding `paymentPointer` identifies a unique payment recipient. | +| **`completed`** | `boolean` | Describes whether the Incoming Payment has completed receiving funds. | +| **`incomingAmount`** | **[`Amount`](#amount)** (Optional) | Fixed destination amount that must be delivered to complete payment of the Incoming Payment. | +| **`receivedAmount`** | **[`Amount`](#amount)** | Amount that has already been paid toward the Incoming Payment. | +| **`expiresAt`** | `number` (Optional) | UNIX timestamp in milliseconds after which payments toward the Incoming Payment will no longer be accepted. | +| **`description`** | `string` (Optional) | Human-readable description of what is provided in return for completion of the Incoming Payment. | +| **`externalRef`** | `string` (Optional) | Human-readable external reference that can be used by external systems to reconcile this payment with outside systems. | + +#### `Amount` + +> Interface + +Amount details of an [`IncomingPayment`](#incomingpayment). + +| Property | Type | Description | +| :--------------- | :------- | :---------------------------------------------------------------------------------- | +| **`value`** | `bigint` | Amount, in base units. | +| **`assetScale`** | `number` | Precision of the asset denomination: number of decimal places of the ordinary unit. | +| **`assetCode`** | `string` | Asset code or symbol identifying the currency of the account. | + +#### `AssetDetails` + +> Interface + +Asset and denomination for an Interledger account (source or destination asset) + +| Property | Type | Description | +| :---------- | :------- | :---------------------------------------------------------------------------------- | +| **`scale`** | `number` | Precision of the asset denomination: number of decimal places of the ordinary unit. | +| **`code`** | `string` | Asset code or symbol identifying the currency of the account. | + +#### `PaymentType` + +> String enum + +Completion criteria of the payment + +| Variant | Description | +| :------------------ | :--------------------------------------------------------------------------- | +| **`FixedSend`** | Send up to a maximum source amount | +| **`FixedDelivery`** | Send to meet a minimum delivery amount, bounding the source amount and rates | + +#### `PaymentError` + +> String enum + +Payment error states + +##### Errors likely caused by the user + +| Variant | Description | +| :----------------------------- | :----------------------------------------------------------------- | +| **`InvalidPaymentPointer`** | Payment pointer or SPSP URL is syntactically invalid | +| **`InvalidCredentials`** | No valid STREAM credentials or URL to fetch them was provided | +| **`InvalidSlippage`** | Slippage percentage is not between 0 and 1 (inclusive) | +| **`UnknownSourceAsset`** | Source asset or denomination was not provided | +| **`UnknownPaymentTarget`** | No fixed source amount or fixed destination amount was provided | +| **`InvalidSourceAmount`** | Fixed source amount is not a positive integer | +| **`InvalidDestinationAmount`** | Fixed delivery amount is not a positive integer | +| **`UnenforceableDelivery`** | Minimum exchange rate of 0 cannot enforce a fixed-delivery payment | + +##### Errors likely caused by the receiver, connectors, or other externalities + +| Variant | Description | +| :------------------------------ | :--------------------------------------------------------------------------------------------------- | +| **`QueryFailed`** | Failed to query the Open Payments or SPSP server, or received an invalid response | +| **`IncomingPaymentCompleted`** | Incoming payment was already completed by the Open Payments server, so no payment is necessary | +| **`IncomingPaymentExpired`** | Incoming payment has already expired, so no payment is possible | +| **`ConnectorError`** | Cannot send over this path due to an ILP Reject error | +| **`EstablishmentFailed`** | No authentic reply from receiver: packets may not have been delivered | +| **`UnknownDestinationAsset`** | Destination asset details are unknown or the receiver never provided them | +| **`DestinationAssetConflict`** | Receiver sent conflicting destination asset details | +| **`ExternalRateUnavailable`** | Failed to compute minimum rate: prices for source or destination assets were invalid or not provided | +| **`RateProbeFailed`** | Rate probe failed to establish the exchange rate or discover path max packet amount | +| **`InsufficientExchangeRate`** | Real exchange rate is less than minimum exchange rate with slippage | +| **`IdleTimeout`** | No packets were fulfilled within timeout | +| **`ClosedByReceiver`** | Receiver closed the connection or stream, terminating the payment | +| **`IncompatibleReceiveMax`** | Estimated destination amount exceeds the receiver's limit | +| **`ReceiverProtocolViolation`** | Receiver violated the STREAM protocol, misrepresenting delivered amounts | +| **`MaxSafeEncryptionLimit`** | Encrypted maximum number of packets using the key for this connection | diff --git a/packages/pay/jest.config.js b/packages/pay/jest.config.js new file mode 100644 index 0000000000..1d6dbb726a --- /dev/null +++ b/packages/pay/jest.config.js @@ -0,0 +1,9 @@ +/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ +module.exports = { + collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**'], + coverageReporters: ['text', 'lcov'], + coverageDirectory: 'coverage', + preset: 'ts-jest', + testEnvironment: 'node', + testPathIgnorePatterns: ['/node_modules/', '/dist'] +} diff --git a/packages/pay/package.json b/packages/pay/package.json new file mode 100644 index 0000000000..11c011c090 --- /dev/null +++ b/packages/pay/package.json @@ -0,0 +1,45 @@ +{ + "name": "@interledger/pay", + "description": "Send payments over Interledger", + "version": "0.4.0-alpha.9", + "author": "Interledger Team ", + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "files": [ + "dist/**/*.js", + "dist/**/*.js.map", + "dist/**/*.d.ts", + "!dist/test/**/*" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "test": "jest", + "cover": "jest --coverage", + "codecov": "curl -s https://codecov.io/bash | bash -s - -s coverage -F pay" + }, + "dependencies": { + "abort-controller": "^3.0.0", + "ilp-logger": "^1.4.5-alpha.2", + "ilp-packet": "^3.1.4-alpha.2", + "ilp-protocol-stream": "^2.7.2-alpha.2", + "oer-utils": "^5.1.3-alpha.2", + "long": "^4.0.0", + "node-fetch": "^2.6.6" + }, + "devDependencies": { + "axios": "1.4.0", + "@interledger/stream-receiver": "0.3.3-alpha.3", + "@types/long": "4.0.2", + "@types/node-fetch": "2.6.4", + "get-port": "5.1.1", + "ilp-connector": "23.0.2", + "ilp-plugin-http": "1.6.1", + "nock": "13.3.1", + "reduct": "3.3.1", + "testcontainers": "9.8.0" + } +} diff --git a/packages/pay/src/controllers/app-data.ts b/packages/pay/src/controllers/app-data.ts new file mode 100644 index 0000000000..90ec5c0310 --- /dev/null +++ b/packages/pay/src/controllers/app-data.ts @@ -0,0 +1,48 @@ +import { PaymentError } from '..' +import { RequestState, StreamController } from '.' +import { RequestBuilder, StreamReply, StreamRequest } from '../request' +import { StreamDataFrame } from 'ilp-protocol-stream/dist/src/packet' + +// Injects application data on the first STREAM packet and stops the payment if that packet is rejected. +export class AppDataController implements StreamController { + private readonly appData?: Buffer + private readonly streamId: number + private hasInjected = false + private failFast = false + + constructor(appData?: Uint8Array | string | Buffer, streamId = 1) { + this.streamId = streamId + if (appData) { + this.appData = Buffer.isBuffer(appData) ? appData : Buffer.from(appData) + } + } + + buildRequest(request: RequestBuilder): RequestState { + if (!this.appData || this.hasInjected) { + return RequestState.Ready() + } + + request.addFrames(new StreamDataFrame(this.streamId, 0, this.appData)) + this.hasInjected = true + this.failFast = true + + return RequestState.Ready() + } + + applyRequest({ log }: StreamRequest): (reply: StreamReply) => PaymentError | void { + const shouldFailFast = this.failFast + this.failFast = false + + if (!shouldFailFast) { + return () => undefined + } + + return (reply: StreamReply) => { + if (reply.isReject()) { + log.error('ending payment: packet carrying application data was rejected') + return PaymentError.AppDataRejected + } + } + } +} + diff --git a/packages/pay/src/controllers/asset-details.ts b/packages/pay/src/controllers/asset-details.ts new file mode 100644 index 0000000000..6b21624bb6 --- /dev/null +++ b/packages/pay/src/controllers/asset-details.ts @@ -0,0 +1,89 @@ +import { + ConnectionAssetDetailsFrame, + FrameType +} from 'ilp-protocol-stream/dist/src/packet' +import { StreamController } from '.' +import { PaymentError } from '..' +import { PaymentDestination } from '../open-payments' +import { StreamReply } from '../request' + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any +export const isValidAssetDetails = (o: any): o is AssetDetails => + typeof o === 'object' && + o !== null && + typeof o.code === 'string' && + isValidAssetScale(o.scale) + +export const isValidAssetScale = (o: unknown): o is number => + typeof o === 'number' && o >= 0 && o <= 255 && Number.isInteger(o) + +/** Asset and denomination of an Interledger account */ +export interface AssetDetails { + /** Precision of the asset denomination: number of decimal places of the normal unit */ + scale: number + /** Asset code or symbol identifying the currency of the account */ + code: string +} + +/** + * Track destination asset details from the STREAM receiver and + * check for conflicts with existing asset details + */ +export class AssetDetailsController implements StreamController { + private destinationAsset?: AssetDetails + + constructor({ destinationAsset }: PaymentDestination) { + this.destinationAsset = destinationAsset + } + + getDestinationAsset(): AssetDetails | undefined { + return this.destinationAsset + } + + applyRequest(): (reply: StreamReply) => PaymentError | void { + return ({ frames, log }: StreamReply) => { + const newAssetDetails = (frames ?? []) + .filter( + (frame): frame is ConnectionAssetDetailsFrame => + frame.type === FrameType.ConnectionAssetDetails + ) + .map( + (frame): AssetDetails => ({ + code: frame.sourceAssetCode, + scale: frame.sourceAssetScale + }) + ) + + for (const { code: assetCode, scale: assetScale } of newAssetDetails) { + // Only set destination details if we don't already know them + if (!this.destinationAsset) { + log.debug( + 'got destination asset details: %s %s', + assetCode, + assetScale + ) + // Packet deserialization should already ensure the asset scale is limited to u8: + // https://github.com/interledgerjs/ilp-protocol-stream/blob/8551fd498f1ff313da72f63891b9fa428212c31a/src/packet.ts#L274 + this.destinationAsset = { + code: assetCode, + scale: assetScale + } + } + // If the destination asset details changed, end the payment + else if ( + this.destinationAsset.code !== assetCode || + this.destinationAsset.scale !== assetScale + ) { + log.error( + 'ending payment: remote unexpectedly changed destination asset from %s %s to %s %s', + this.destinationAsset.code, + this.destinationAsset.scale, + assetCode, + assetScale + ) + return PaymentError.DestinationAssetConflict + } + } + } + } +} diff --git a/packages/pay/src/controllers/establishment.ts b/packages/pay/src/controllers/establishment.ts new file mode 100644 index 0000000000..2d1e1ff7d9 --- /dev/null +++ b/packages/pay/src/controllers/establishment.ts @@ -0,0 +1,45 @@ +import { RequestState, StreamController } from '.' +import { IlpAddress } from 'ilp-packet' +import { StreamReply, RequestBuilder } from '../request' +import { + ConnectionMaxDataFrame, + ConnectionMaxStreamIdFrame +} from 'ilp-protocol-stream/dist/src/packet' +import { PaymentDestination } from '../open-payments' +import { PaymentSender } from '../senders/payment' + +/** Direct packets to the receiver to establish the connection and share limits */ +export class EstablishmentController implements StreamController { + private readonly destinationAddress: IlpAddress + private isConnected = false + + constructor({ destinationAddress }: PaymentDestination) { + this.destinationAddress = destinationAddress + } + + didConnect(): boolean { + return this.isConnected + } + + buildRequest(request: RequestBuilder): RequestState { + request.setDestinationAddress(this.destinationAddress) + + if (!this.isConnected) { + request.addFrames( + // Disallow any new streams (and only the client can open stream 1) + new ConnectionMaxStreamIdFrame(PaymentSender.DEFAULT_STREAM_ID), + // Disallow incoming data + new ConnectionMaxDataFrame(0) + ) + } + + return RequestState.Ready() + } + + applyRequest(): (reply: StreamReply) => void { + return (reply: StreamReply) => { + // Ready sending connection limits in each packet until we receive an authenticated response + this.isConnected = this.isConnected || reply.isAuthentic() + } + } +} diff --git a/packages/pay/src/controllers/exchange-rate.ts b/packages/pay/src/controllers/exchange-rate.ts new file mode 100644 index 0000000000..ba7fe12411 --- /dev/null +++ b/packages/pay/src/controllers/exchange-rate.ts @@ -0,0 +1,129 @@ +import { StreamController } from '.' +import { StreamReply, StreamRequest } from '../request' +import { Int, PositiveInt, PositiveRatio, Ratio } from '../utils' + +/** Track realized exchange rates and estimate source/destination amounts */ +export class ExchangeRateController implements StreamController { + constructor( + /** Realized exchange rate is greater than or equal to this ratio (inclusive): destination / source */ + private lowerBoundRate: Ratio = Ratio.of(Int.ZERO, Int.ONE), + + /** Realized exchange rate is less than this ratio (exclusive): (destination + 1) / source */ + private upperBoundRate: PositiveRatio = Ratio.of(Int.MAX_U64, Int.ONE) + ) {} + + applyRequest({ + sourceAmount, + log + }: StreamRequest): (reply: StreamReply) => void { + return ({ destinationAmount }: StreamReply) => { + // Discard 0 amount packets + if (!sourceAmount.isPositive()) { + return + } + + // Only track the rate for authentic STREAM replies + if (!destinationAmount) { + return + } + + // Since intermediaries floor packet amounts, the exchange rate cannot be precisely computed: + // it's only known with some margin however. However, as we send packets of varying sizes, + // the upper and lower bounds should converge closer and closer to the real exchange rate. + const packetUpperBoundRate = Ratio.of( + destinationAmount.add(Int.ONE), + sourceAmount + ) + const packetLowerBoundRate = Ratio.of(destinationAmount, sourceAmount) + + // If the exchange rate fluctuated and is "out of bounds," reset it + const shouldResetExchangeRate = + packetUpperBoundRate.isLessThanOrEqualTo(this.lowerBoundRate) || + packetLowerBoundRate.isGreaterThanOrEqualTo(this.upperBoundRate) + if (shouldResetExchangeRate) { + log.debug( + 'exchange rate changed. resetting to [%s, %s]', + packetLowerBoundRate, + packetUpperBoundRate + ) + this.upperBoundRate = packetUpperBoundRate + this.lowerBoundRate = packetLowerBoundRate + return + } + + if (packetLowerBoundRate.isGreaterThan(this.lowerBoundRate)) { + log.debug( + 'increasing probed rate lower bound from %s to %s', + this.lowerBoundRate, + packetLowerBoundRate + ) + this.lowerBoundRate = packetLowerBoundRate + } + + if (packetUpperBoundRate.isLessThan(this.upperBoundRate)) { + log.debug( + 'reducing probed rate upper bound from %s to %s', + this.upperBoundRate, + packetUpperBoundRate + ) + this.upperBoundRate = packetUpperBoundRate + } + } + } + + getLowerBoundRate(): Ratio { + return this.lowerBoundRate + } + + getUpperBoundRate(): PositiveRatio { + return this.upperBoundRate + } + + /** + * Estimate the delivered amount from the given source amount. + * (1) Low-end estimate: at least this amount will get delivered, if the rate hasn't fluctuated. + * (2) High-end estimate: no more than this amount will get delivered, if the rate hasn't fluctuated. + * + * Cap the destination amounts at the max U64, since that's the most that an ILP packet can credit. + */ + estimateDestinationAmount(sourceAmount: Int): [Int, Int] { + const lowEndDestination = sourceAmount + .multiplyFloor(this.lowerBoundRate) + .orLesser(Int.MAX_U64) + + // Since upper bound exchange rate is exclusive: + // If source amount converts exactly to an integer, destination amount MUST be 1 unit less + // If source amount doesn't convert precisely, we can't narrow it any better than that amount, floored ¯\_(ツ)_/¯ + const highEndDestination = sourceAmount + .multiplyCeil(this.upperBoundRate) + .saturatingSubtract(Int.ONE) + .orLesser(Int.MAX_U64) + + return [lowEndDestination, highEndDestination] + } + + /** + * Estimate the source amount that delivers the given destination amount. + * (1) Low-end estimate (may under-deliver, won't over-deliver): lowest source amount + * that *may* deliver the given destination amount, if the rate hasn't fluctuated. + * (2) High-end estimate (won't under-deliver, may over-deliver): lowest source amount that + * delivers at least the given destination amount, if the rate hasn't fluctuated. + * + * Returns `undefined` if the rate is 0 and it may not be possible to deliver anything. + */ + estimateSourceAmount( + destinationAmount: PositiveInt + ): [PositiveInt, PositiveInt] | undefined { + // If the exchange rate is a packet that delivered 0, the source amount is undefined + const lowerBoundRate = this.lowerBoundRate.reciprocal() + if (!lowerBoundRate) { + return + } + + const lowEndSource = destinationAmount + .multiplyFloor(this.upperBoundRate.reciprocal()) + .add(Int.ONE) + const highEndSource = destinationAmount.multiplyCeil(lowerBoundRate) + return [lowEndSource, highEndSource] + } +} diff --git a/packages/pay/src/controllers/expiry.ts b/packages/pay/src/controllers/expiry.ts new file mode 100644 index 0000000000..66dc407728 --- /dev/null +++ b/packages/pay/src/controllers/expiry.ts @@ -0,0 +1,18 @@ +import { RequestState, StreamController } from '.' +import { RequestBuilder } from '../request' + +export class ExpiryController implements StreamController { + /** + * Maximum duration that a ILP Prepare can be in-flight before it should be rejected, in milliseconds. + * This is longer than the payment timeout duration to account for the min message + * window each connector may subtract from the expiry. + */ + private static DEFAULT_PACKET_EXPIRY_MS = 20_000 + + buildRequest(request: RequestBuilder): RequestState { + request.setExpiry( + new Date(Date.now() + ExpiryController.DEFAULT_PACKET_EXPIRY_MS) + ) + return RequestState.Ready() + } +} diff --git a/packages/pay/src/controllers/failure.ts b/packages/pay/src/controllers/failure.ts new file mode 100644 index 0000000000..5d384239e2 --- /dev/null +++ b/packages/pay/src/controllers/failure.ts @@ -0,0 +1,53 @@ +import { StreamController } from '.' +import { + ConnectionCloseFrame, + FrameType, + ErrorCode, + StreamCloseFrame +} from 'ilp-protocol-stream/dist/src/packet' +import { PaymentSender } from '../senders/payment' +import { PaymentError } from '..' +import { IlpError } from 'ilp-packet' +import { StreamReply, StreamRequest } from '../request' + +/** Controller to end a payment on ILP errors */ +export class FailureController implements StreamController { + applyRequest({ + log + }: StreamRequest): (reply: StreamReply) => PaymentError | void { + return (reply: StreamReply) => { + const closeFrame = reply.frames?.find( + (frame): frame is ConnectionCloseFrame | StreamCloseFrame => + frame.type === FrameType.ConnectionClose || + (frame.type === FrameType.StreamClose && + frame.streamId.equals(PaymentSender.DEFAULT_STREAM_ID)) + ) + if (closeFrame) { + log.error( + 'ending payment: receiver closed the connection. reason=%s message="%s"', + ErrorCode[closeFrame.errorCode], + closeFrame.errorMessage + ) + return PaymentError.ClosedByReceiver + } + + // Ignore Fulfills, temporary errors, F08, F99, R01 + if (!reply.isReject()) { + return + } + const { code } = reply.ilpReject + if ( + code[0] === 'T' || + code === IlpError.F08_AMOUNT_TOO_LARGE || + code === IlpError.F99_APPLICATION_ERROR || + code === IlpError.R01_INSUFFICIENT_SOURCE_AMOUNT + ) { + return + } + + // On any other error, end the payment immediately + log.error('ending payment: %s error', code) + return PaymentError.ConnectorError + } + } +} diff --git a/packages/pay/src/controllers/index.ts b/packages/pay/src/controllers/index.ts new file mode 100644 index 0000000000..b8c4b9efd4 --- /dev/null +++ b/packages/pay/src/controllers/index.ts @@ -0,0 +1,146 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { PaymentError } from '..' +import { StreamRequest, StreamReply, RequestBuilder } from '../request' + +/** + * Controllers orchestrate when packets are sent, their amounts, and data. + * Each controller implements its own business logic to handle a different part of the payment or STREAM protocol. + */ +export interface StreamController { + /** + * Controllers iteratively construct the next request and signal the status of the request attempt: + * - `RequestState.Ready` -- ready to apply and send this request, + * - `RequestState.Error` -- to immediately end the send loop with an error, + * - `RequestState.Schedule` -- to cancel this request attempt and try again at a later time, + * - `RequestState.Yield` -- to cancel this request attempt and not directly schedule another. + * + * If any controller does not signal `Ready`, that request attempt will be cancelled. + * + * Note: since subsequent controllers may change the request or cancel it, + * no side effects should be performed here. + * + * @param request Proposed ILP Prepare and STREAM request + */ + buildRequest?(request: RequestBuilder): RequestState + + /** + * Apply side effects before sending an ILP Prepare over STREAM. Return a callback function to apply + * side effects from the corresponding ILP Fulfill or ILP Reject and STREAM reply. + * + * `applyRequest` is called for all controllers synchronously when the sending controller queues the + * request to be sent. + * + * The returned reply handler may also return an error to immediately end the send loop. + * + * @param request Finalized amounts and data of the ILP Prepare and STREAM request + */ + applyRequest?( + request: StreamRequest + ): ((reply: StreamReply) => PaymentError | void) | undefined +} + +export enum SendStateType { + /** Finish send loop successfully */ + Done, + /** Finish send loop with an error */ + Error, + /** Schedule another request attempt later. If applicable, cancels current attempt */ + Schedule, + /** Do not schedule another attempt. If applicable, cancels current attempt */ + Yield, + /** Ready to send and apply a request */ + Ready, + /** Commit to send and apply the request */ + Send +} + +/** States each controller may signal when building the next request */ +export type RequestState = Error | Schedule | Yield | Ready + +/** States the sender may signal to determine the next state of the send loop */ +export type SendState = Error | Schedule | Yield | Send | Done + +type Error = { + type: SendStateType.Error + value: PaymentError +} + +/** Immediately end the loop and payment with an error. */ +const Error = (error: PaymentError): Error => ({ + type: SendStateType.Error, + value: error +}) + +type Schedule = { + type: SendStateType.Schedule + delay: Promise +} + +/** + * Schedule another request attempt after the delay, or as soon as possible if + * no delay was provided. + */ +const Schedule = (delay?: Promise): Schedule => ({ + type: SendStateType.Schedule, + delay: delay ?? Promise.resolve() +}) + +type Yield = { + type: SendStateType.Yield +} + +/** Don't immediately schedule another request attempt. If applicable, cancel the current attempt. */ +const Yield = (): Yield => ({ type: SendStateType.Yield }) + +type Done = { + type: SendStateType.Done + value: T +} + +/** Immediately resolve the send loop as successful. */ +const Done = (value: T): Done => ({ + type: SendStateType.Done, + value +}) + +type Ready = { + type: SendStateType.Ready +} + +/** Ready for this request to be immediately applied and sent. */ +const Ready = (): Ready => ({ + type: SendStateType.Ready +}) + +type Send = { + type: SendStateType.Send + applyReply: (reply: StreamReply) => Done | Schedule | Yield | Error +} + +/** + * Apply and send the request. + * + * @param applyReply Callback to apply side effects from the reply, called synchronously after all other + * controllers' reply handlers. The handler may resolve the the send loop, return an error, or re-schedule an attempt. + */ +const Send = ( + applyReply: (reply: StreamReply) => Done | Schedule | Yield | Error +): Send => ({ + type: SendStateType.Send, + applyReply +}) + +export const RequestState = { + Ready, + Error, + Schedule, + Yield +} + +export const SendState = { + Done, + Error, + Schedule, + Yield, + Send +} diff --git a/packages/pay/src/controllers/max-packet.ts b/packages/pay/src/controllers/max-packet.ts new file mode 100644 index 0000000000..5d703376a5 --- /dev/null +++ b/packages/pay/src/controllers/max-packet.ts @@ -0,0 +1,220 @@ +import { Reader } from 'oer-utils' +import { StreamController } from './' +import { Int, PositiveInt, Ratio } from '../utils' +import { Logger } from 'ilp-logger' +import { PaymentError } from '..' +import { IlpError } from 'ilp-packet' +import { StreamReject, StreamReply, StreamRequest } from '../request' +import { RateProbe } from '../senders/rate-probe' + +/** How the maximum packet amount is known or discovered */ +enum MaxPacketState { + /** Initial state before any F08 errors have been encountered */ + UnknownMax, + /** F08 errors included metadata to communicate the precise max packet amount */ + PreciseMax, + /** + * F08 errors isolated an upper max packet amount, but didn't communicate it precisely. + * Discover the exact max packet amount through probing. + */ + ImpreciseMax +} + +/** Max packet amount and how it was discovered */ +type MaxPacketAmount = + | { + type: MaxPacketState.PreciseMax + /** Precise max packet amount communicated from F08 errors */ + maxPacketAmount: PositiveInt + } + | { + type: MaxPacketState.ImpreciseMax + /** Max packet amount is known to be less than this, but isn't known precisely */ + maxPacketAmount: PositiveInt + } + | { + type: MaxPacketState.UnknownMax + } + +/** Controller to limit packet amount based on F08 errors */ +export class MaxPacketAmountController implements StreamController { + /** Max packet amount and how it was discovered */ + private state: MaxPacketAmount + + /** + * Greatest amount the recipient acknowledged to have received. + * Note: this is always reduced so it's never greater than the max packet amount + */ + private verifiedPathCapacity = Int.ZERO + + constructor(preciseMaxPacketAmount?: PositiveInt) { + this.state = preciseMaxPacketAmount + ? { + type: MaxPacketState.PreciseMax, + maxPacketAmount: preciseMaxPacketAmount + } + : { + type: MaxPacketState.UnknownMax + } + } + + /** + * Return a limit on the amount of the next packet: the precise max packet amount, + * or a probe amount if the precise max packet amount is yet to be discovered. + */ + getNextMaxPacketAmount(): PositiveInt | undefined { + switch (this.state.type) { + case MaxPacketState.PreciseMax: + return this.state.maxPacketAmount + + // Use a binary search to discover the precise max + case MaxPacketState.ImpreciseMax: + // Always positive: + // - If verifiedCapacity=0, maxPacketAmount / 2 must round up to 1 + // - If verifiedCapacity=maxPacketAmount, + // verifiedCapacity is positive, so adding it will always be positive + return this.state.maxPacketAmount + .saturatingSubtract(this.verifiedPathCapacity) + .divideCeil(Int.TWO) + .add(this.verifiedPathCapacity) as PositiveInt + + case MaxPacketState.UnknownMax: + return undefined + } + } + + /** Did we verify the precise max packet amount or a large path capacity? */ + isProbeComplete(): boolean { + const verifiedPreciseMax = + this.state.type === MaxPacketState.PreciseMax && + this.verifiedPathCapacity.isEqualTo(this.state.maxPacketAmount) + const verifiedLargeCapacity = + this.state.type === MaxPacketState.UnknownMax && + this.verifiedPathCapacity.isGreaterThanOrEqualTo( + RateProbe.MAX_PROBE_AMOUNT + ) + return verifiedPreciseMax || verifiedLargeCapacity + } + + /** Return the current upper bound on the max packet amount */ + getMaxPacketAmountLimit(): PositiveInt { + return this.state.type === MaxPacketState.UnknownMax + ? Int.MAX_U64 + : this.state.maxPacketAmount + } + + applyRequest({ sourceAmount }: StreamRequest) { + return (reply: StreamReply): PaymentError | void => { + if ( + reply.isReject() && + reply.ilpReject.code === IlpError.F08_AMOUNT_TOO_LARGE + ) { + return this.reduceMaxPacketAmount(reply, sourceAmount) + } else if (reply.isAuthentic()) { + this.adjustPathCapacity(reply.log, sourceAmount) + } + } + } + + /** Decrease the path max packet amount in response to F08 errors */ + private reduceMaxPacketAmount( + reply: StreamReject, + sourceAmount: Int + ): PaymentError | void { + const { log, ilpReject } = reply + + let newMax: Int + let isPreciseMax: boolean + try { + const reader = Reader.from(ilpReject.data) + const remoteReceived = Int.from(reader.readUInt64Long()) + const remoteMaximum = Int.from(reader.readUInt64Long()) + + log.debug( + 'handling F08. remote received: %s, remote max: %s', + remoteReceived, + remoteMaximum + ) + + // F08 is invalid if they received less than their own maximum! + // This check ensures that remoteReceived is always > 0 + if (!remoteReceived.isGreaterThan(remoteMaximum)) { + return + } + + // Convert remote max packet amount into source units + const exchangeRate = Ratio.of(sourceAmount, remoteReceived) + newMax = remoteMaximum.multiplyFloor(exchangeRate) // newMax <= source amount since remoteMaximum / remoteReceived is < 1 + isPreciseMax = true + } catch (_) { + // If no metadata was included, the only thing we can infer is that the amount we sent was too high + log.debug( + 'handling F08 without metadata. source amount: %s', + sourceAmount + ) + newMax = sourceAmount.saturatingSubtract(Int.ONE) + isPreciseMax = false + } + + // Special case if max packet is 0 or rounds to 0 + if (!newMax.isPositive()) { + log.debug('ending payment: max packet amount is 0, cannot send over path') + return PaymentError.ConnectorError + } + + if (this.state.type === MaxPacketState.UnknownMax) { + log.debug('setting initial max packet amount to %s', newMax) + } else if (newMax.isLessThan(this.state.maxPacketAmount)) { + log.debug( + 'reducing max packet amount from %s to %s', + this.state.maxPacketAmount, + newMax + ) + } else { + return // Ignore F08s that don't lower the max packet amount + } + + this.state = { + type: isPreciseMax + ? MaxPacketState.PreciseMax + : MaxPacketState.ImpreciseMax, + maxPacketAmount: newMax + } + + this.adjustPathCapacity(log, this.verifiedPathCapacity) + } + + /** + * Increase the greatest amount acknowledged by the recipient, which + * indicates the path is capable of sending packets of at least that amount + */ + private adjustPathCapacity(log: Logger, ackAmount: Int) { + const newPathCapacity = this.verifiedPathCapacity + .orGreater(ackAmount) + .orLesser(this.getMaxPacketAmountLimit()) + if (newPathCapacity.isGreaterThan(this.verifiedPathCapacity)) { + log.debug( + 'increasing greatest path packet amount from %s to %s', + this.verifiedPathCapacity, + newPathCapacity + ) + } + + this.verifiedPathCapacity = newPathCapacity + + if ( + this.state.type === MaxPacketState.ImpreciseMax && + this.verifiedPathCapacity.isEqualTo(this.state.maxPacketAmount) + ) { + // Binary search from F08s without metadata is complete: discovered precise max + log.debug( + 'discovered precise max packet amount: %s', + this.state.maxPacketAmount + ) + this.state = { + type: MaxPacketState.PreciseMax, + maxPacketAmount: this.state.maxPacketAmount + } + } + } +} diff --git a/packages/pay/src/controllers/pacer.ts b/packages/pay/src/controllers/pacer.ts new file mode 100644 index 0000000000..4e81c5bbb2 --- /dev/null +++ b/packages/pay/src/controllers/pacer.ts @@ -0,0 +1,117 @@ +import { RequestState, StreamController } from '.' +import { sleep } from '../utils' +import { StreamReply } from '../request' + +/** + * Flow controller to send packets at a consistent cadence + * and prevent sending more packets than the network can handle + */ +export class PacingController implements StreamController { + /** Initial number of packets to send in 1 second interval (25ms delay between packets) */ + private static DEFAULT_PACKETS_PER_SECOND = 40 + + /** Always try to send at least 1 packet in 1 second (unless RTT is very high) */ + private static MIN_PACKETS_PER_SECOND = 1 + + /** Maximum number of packets to send in a 1 second interval, after ramp up (5ms delay) */ + private static MAX_PACKETS_PER_SECOND = 200 + + /** Additive increase of packets per second rate on authentic reply */ + private static PACKETS_PER_SECOND_INCREASE_TERM = 0.5 + + /** Multiplicative decrease of packets per second rate on transient error */ + private static PACKETS_PER_SECOND_DECREASE_FACTOR = 0.5 + + /** RTT to use for pacing before an average can be ascertained */ + private static DEFAULT_ROUND_TRIP_TIME_MS = 200 + + /** Weight to compute next RTT average. Halves weight of past round trips every ~5 flights */ + private static ROUND_TRIP_AVERAGE_WEIGHT = 0.9 + + /** Maximum number of packets to have in-flight, yet to receive a Fulfill or Reject */ + private static MAX_INFLIGHT_PACKETS = 20 + + /** UNIX timestamp when most recent packet was sent */ + private lastPacketSentTime = 0 + + /** Exponential weighted moving average of the round trip time */ + private averageRoundTrip = PacingController.DEFAULT_ROUND_TRIP_TIME_MS + + /** Rate of packets to send per second. This shouldn't ever be 0, but may become a small fraction */ + private packetsPerSecond = PacingController.DEFAULT_PACKETS_PER_SECOND + + /** Number of in-flight requests */ + private inFlightCount = 0 + + /** + * Rate to send packets, in packets / millisecond, using packet rate limit and round trip time. + * Corresponds to the ms delay between each packet + */ + getPacketFrequency(): number { + const packetsPerSecondDelay = 1000 / this.packetsPerSecond + const maxInFlightDelay = + this.averageRoundTrip / PacingController.MAX_INFLIGHT_PACKETS + + return Math.max(packetsPerSecondDelay, maxInFlightDelay) + } + + /** Earliest UNIX timestamp when the pacer will allow the next packet to be sent */ + getNextPacketSendTime(): number { + const delayDuration = this.getPacketFrequency() + return this.lastPacketSentTime + delayDuration + } + + buildRequest(): RequestState { + const durationUntilNextPacket = this.getNextPacketSendTime() - Date.now() + return durationUntilNextPacket > 0 + ? RequestState.Schedule(sleep(durationUntilNextPacket)) + : this.inFlightCount >= PacingController.MAX_INFLIGHT_PACKETS + ? RequestState.Yield() // Assumes sender will schedule another attempt when in-flight requests complete + : RequestState.Ready() + } + + applyRequest(): (reply: StreamReply) => void { + const sentTime = Date.now() + this.lastPacketSentTime = sentTime + + this.inFlightCount++ + + return (reply: StreamReply) => { + this.inFlightCount-- + + // Only update the RTT if we know the request got to the recipient + if (reply.isAuthentic()) { + const roundTripTime = Math.max(Date.now() - sentTime, 0) + this.averageRoundTrip = + this.averageRoundTrip * PacingController.ROUND_TRIP_AVERAGE_WEIGHT + + roundTripTime * (1 - PacingController.ROUND_TRIP_AVERAGE_WEIGHT) + } + + // TODO Add separate liquidity congestion controller/logic, don't backoff in time on T04s + + // If we encounter a temporary error that's not related to liquidity, + // exponentially backoff the rate of packet sending + if (reply.isReject() && reply.ilpReject.code[0] === 'T') { + const reducedRate = Math.max( + PacingController.MIN_PACKETS_PER_SECOND, + this.packetsPerSecond * + PacingController.PACKETS_PER_SECOND_DECREASE_FACTOR // Fractional rates are fine + ) + reply.log.debug( + 'handling %s. backing off to %s packets / second', + reply.ilpReject.code, + reducedRate.toFixed(3) + ) + this.packetsPerSecond = reducedRate + } + // If the packet got through, additive increase of sending rate, up to some maximum + else if (reply.isAuthentic()) { + this.packetsPerSecond = Math.min( + PacingController.MAX_PACKETS_PER_SECOND, + this.packetsPerSecond + + PacingController.PACKETS_PER_SECOND_INCREASE_TERM + ) + } + } + } +} diff --git a/packages/pay/src/controllers/sequence.ts b/packages/pay/src/controllers/sequence.ts new file mode 100644 index 0000000000..be76183788 --- /dev/null +++ b/packages/pay/src/controllers/sequence.ts @@ -0,0 +1,48 @@ +import { RequestState, StreamController } from '.' +import { PaymentError } from '..' +import { RequestBuilder } from '../request' +import { isNonNegativeInteger, NonNegativeInteger } from '../utils' + +export class Counter { + private constructor(private count: NonNegativeInteger) {} + + static from(count: number): Counter | undefined { + if (isNonNegativeInteger(count)) { + return new Counter(count) + } + } + + increment(): void { + this.count++ + } + + getCount(): NonNegativeInteger { + return this.count + } +} + +/** Track the sequence number of outgoing packets */ +export class SequenceController implements StreamController { + private static PACKET_LIMIT = (2 ** 31) as NonNegativeInteger + + constructor(private readonly counter: Counter) {} + + buildRequest(request: RequestBuilder): RequestState { + // Destroy the connection after 2^31 packets are sent for encryption safety: + // https://github.com/interledger/rfcs/blob/master/0029-stream/0029-stream.md#513-maximum-number-of-packets-per-connection + if (this.counter.getCount() >= SequenceController.PACKET_LIMIT) { + request.log.error( + 'ending payment: cannot exceed max safe sequence number.' + ) + return RequestState.Error(PaymentError.MaxSafeEncryptionLimit) + } else { + request.setSequence(this.counter.getCount()) + return RequestState.Ready() + } + } + + applyRequest(): undefined { + this.counter.increment() + return // Required by TS for `undefined` return type + } +} diff --git a/packages/pay/src/controllers/timeout.ts b/packages/pay/src/controllers/timeout.ts new file mode 100644 index 0000000000..4c0a8e5dbb --- /dev/null +++ b/packages/pay/src/controllers/timeout.ts @@ -0,0 +1,39 @@ +import { RequestState, StreamController } from '.' +import { PaymentError } from '..' +import { StreamReply, StreamRequest } from '../request' + +export class TimeoutController implements StreamController { + /** Number of milliseconds since the last Fulfill was received before the payment should fail */ + private static MAX_DURATION_SINCE_LAST_FULFILL = 10_000 + + /** UNIX millisecond timestamp after which the payment should fail is no fulfill was received */ + private deadline?: number + + buildRequest(request: StreamRequest): RequestState { + if (this.deadline && Date.now() > this.deadline) { + request.log.error( + 'ending payment: no fulfill received before idle deadline.' + ) + return RequestState.Error(PaymentError.IdleTimeout) + } else { + return RequestState.Ready() + } + } + + applyRequest(): (reply: StreamReply) => void { + if (!this.deadline) { + this.resetDeadline() + } + + return (reply: StreamReply): void => { + if (reply.isFulfill()) { + this.resetDeadline() + } + } + } + + private resetDeadline(): void { + this.deadline = + Date.now() + TimeoutController.MAX_DURATION_SINCE_LAST_FULFILL + } +} diff --git a/packages/pay/src/index.ts b/packages/pay/src/index.ts new file mode 100644 index 0000000000..af9eb10e67 --- /dev/null +++ b/packages/pay/src/index.ts @@ -0,0 +1,494 @@ +import { AssetDetails, isValidAssetDetails } from './controllers/asset-details' +import { Counter } from './controllers/sequence' +import { + fetchPaymentDetails, + PaymentDestination, + Amount +} from './open-payments' +import { Plugin } from './request' +import { AssetProbe } from './senders/asset-probe' +import { ConnectionCloser } from './senders/connection-closer' +import { PaymentSender, PaymentType } from './senders/payment' +import { RateProbe } from './senders/rate-probe' +import { + Int, + isNonNegativeRational, + NonNegativeRational, + PositiveInt, + PositiveRatio, + Ratio +} from './utils' + +export { IncomingPayment } from './open-payments' +export { AccountUrl } from './payment-pointer' +export { + Int, + PositiveInt, + PositiveRatio, + Ratio, + Counter, + PaymentType, + AssetDetails +} + +/** Recipient-provided details to resolve payment parameters, and connected ILP uplink */ +export interface SetupOptions { + /** Plugin to send ILP packets over the network */ + plugin: Plugin + /** Payment pointer, Open Payments or SPSP account URL to query STREAM connection credentials */ + destinationAccount?: string + /** Open Payments Incoming Payment URL to resolve details and credentials to pay a fixed-delivery payment */ + destinationPayment?: string + /** Open Payments Connection URL to resolve STREAM connection credentials */ + destinationConnection?: string + /** Fixed amount to deliver to the recipient, in base units of destination asset */ + amountToDeliver?: Amount + /** For testing purposes: symmetric key to encrypt STREAM messages. Requires `destinationAddress` */ + sharedSecret?: Uint8Array + /** For testing purposes: ILP address of the STREAM receiver to send outgoing packets. Requires `sharedSecret` */ + destinationAddress?: string + /** For testing purposes: asset details of the STREAM recipient, overriding STREAM and Incoming Payment. Requires `destinationAddress` */ + destinationAsset?: AssetDetails +} + +/** Resolved destination details of a proposed payment, such as the destination asset, Incoming Payment, and STREAM credentials, ready to perform a quote */ +export interface ResolvedPayment extends PaymentDestination { + /** Asset and denomination of the receiver's Interedger account */ + destinationAsset: AssetDetails + /** Strict counter of how many packets have been sent, to safely resume a connection */ + requestCounter: Counter +} + +/** Limits and target to quote a payment and probe the rate */ +export interface QuoteOptions { + /** Plugin to send ILP packets over the network */ + plugin: Plugin + /** Resolved destination details of the payment to establish connection with recipient */ + destination: ResolvedPayment + /** Asset and denomination of the sending account */ + sourceAsset?: AssetDetails + /** Fixed amount to send to the recipient, in base units of source asset */ + amountToSend?: Int | string | number | bigint + /** Fixed amount to deliver to the recipient, in base units of destination asset */ + amountToDeliver?: Int | string | number | bigint + /** Percentage to subtract from an external exchange rate to determine the minimum acceptable exchange rate */ + slippage?: number + /** Set of asset codes -> price in a standardized base asset, to compute minimum exchange rates */ + prices?: { + [assetCode: string]: number + } +} + +/** Parameters of payment execution and the projected outcome of a payment */ +export interface Quote { + /** How payment completion is ascertained: fixed send amount or fixed delivery amount */ + readonly paymentType: PaymentType + /** Maximum amount that will be sent in source units */ + readonly maxSourceAmount: bigint + /** Minimum amount that will be delivered if the payment fully completes */ + readonly minDeliveryAmount: bigint + /** Discovered maximum packet amount allowed over this payment path */ + readonly maxPacketAmount: bigint + /** Lower bound of probed exchange rate over the path (inclusive). Ratio of destination base units to source base units */ + readonly lowEstimatedExchangeRate: Ratio + /** Upper bound of probed exchange rate over the path (exclusive). Ratio of destination base units to source base units */ + readonly highEstimatedExchangeRate: PositiveRatio + /** Minimum exchange rate used to enforce rates. Ratio of destination base units to source base units */ + readonly minExchangeRate: Ratio +} + +/** Quote with stricter types, for internal library use */ +export type IntQuote = Omit< + Quote, + 'maxSourceAmount' | 'minDeliveryAmount' | 'maxPacketAmount' +> & { + readonly maxSourceAmount: PositiveInt + readonly minDeliveryAmount: Int + readonly maxPacketAmount: PositiveInt +} + +/** Options before immediately executing payment */ +export interface PayOptions { + /** Plugin to send ILP packets over the network */ + plugin: Plugin + /** Destination details of the payment to establish connection with recipient */ + destination: ResolvedPayment + /** Parameters of payment execution */ + quote: Quote + /** + * Callback to process streaming updates as packets are sent and received, + * such as to perform accounting while the payment is in progress. + * + * Handler will be called for all fulfillable packets and replies before the payment resolves. + */ + progressHandler?: (progress: PaymentProgress) => void + /** Optional application data to include as a single StreamData frame on the first packet */ + appData?: Uint8Array | string | Buffer +} + +/** Intermediate state or outcome of the payment, to account for sent/delivered amounts */ +export interface PaymentProgress { + /** Error state, if payment failed */ + error?: PaymentError + /** Amount sent and fulfilled, in base units of the source asset. ≥0 */ + amountSent: bigint + /** Amount delivered to recipient, in base units of the destination asset. ≥0 */ + amountDelivered: bigint + /** Amount sent that is yet to be fulfilled or rejected, in base units of the source asset. ≥0 */ + sourceAmountInFlight: bigint + /** Estimate of the amount that may be delivered from in-flight packets, in base units of the destination asset. ≥0 */ + destinationAmountInFlight: bigint + /** Latest [STREAM receipt](https://interledger.org/rfcs/0039-stream-receipts/) to provide proof-of-delivery to a 3rd party verifier */ + streamReceipt?: Uint8Array +} + +/** Payment error states */ +export enum PaymentError { + /** + * Errors likely caused by the library user + */ + + /** Payment pointer or SPSP URL is syntactically invalid */ + InvalidPaymentPointer = 'InvalidPaymentPointer', + /** STREAM credentials (shared secret and destination address) were not provided or invalid */ + InvalidCredentials = 'InvalidCredentials', + /** Slippage percentage is not between 0 and 1 (inclusive) */ + InvalidSlippage = 'InvalidSlippage', + /** Source asset or denomination was not provided */ + UnknownSourceAsset = 'UnknownSourceAsset', + /** No fixed source amount or fixed destination amount was provided */ + UnknownPaymentTarget = 'UnknownPaymentTarget', + /** Fixed source amount is invalid or too precise for the source account */ + InvalidSourceAmount = 'InvalidSourceAmount', + /** Fixed delivery amount is invalid or too precise for the destination account */ + InvalidDestinationAmount = 'InvalidDestinationAmount', + /** Minimum exchange rate is 0 after subtracting slippage and cannot enforce a fixed-delivery payment */ + UnenforceableDelivery = 'UnenforceableDelivery', + /** Invalid quote parameters provided */ + InvalidQuote = 'InvalidQuote', + /** Invalid destination like an Open Payments account URL provided */ + InvalidDestination = 'InvalidDestination', + + /** + * Errors likely caused by the receiver, connectors, or other externalities + */ + + /** Failed to query an account or Incoming Payment from an Open Payments or SPSP server */ + QueryFailed = 'QueryFailed', + /** Incoming payment was already completed */ + IncomingPaymentCompleted = 'IncomingPaymentCompleted', + /** Incoming payment already expired */ + IncomingPaymentExpired = 'IncomingPaymentExpired', + /** Cannot send over this path due to an ILP Reject error */ + ConnectorError = 'ConnectorError', + /** No authentic reply from receiver: packets may not have been delivered */ + EstablishmentFailed = 'EstablishmentFailed', + /** Destination asset details are unknown or the receiver never provided them */ + UnknownDestinationAsset = 'UnknownDestinationAsset', + /** Receiver sent conflicting destination asset details */ + DestinationAssetConflict = 'DestinationAssetConflict', + /** Receiver rejected the first packet containing application data */ + AppDataRejected = 'AppDataRejected', + /** Failed to compute minimum rate: prices for source or destination assets were invalid or not provided */ + ExternalRateUnavailable = 'ExternalRateUnavailable', + /** Rate probe failed to establish the exchange rate or discover path max packet amount */ + RateProbeFailed = 'RateProbeFailed', + /** Real exchange rate is less than minimum exchange rate with slippage */ + InsufficientExchangeRate = 'InsufficientExchangeRate', + /** No packets were fulfilled within timeout */ + IdleTimeout = 'IdleTimeout', + /** Receiver closed the connection or stream, terminating the payment */ + ClosedByReceiver = 'ClosedByReceiver', + /** Estimated destination amount exceeds the receiver's limit */ + IncompatibleReceiveMax = 'IncompatibleReceiveMax', + /** Receiver violated the STREAM protocol, misrepresenting delivered amounts */ + ReceiverProtocolViolation = 'ReceiverProtocolViolation', + /** Encrypted maximum number of packets using the key for this connection */ + MaxSafeEncryptionLimit = 'MaxSafeEncryptionLimit' +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types +export const isPaymentError = (o: any): o is PaymentError => + Object.values(PaymentError).includes(o) + +/** Resolve destination details and asset of the payment in order to establish a STREAM connection */ +export const setupPayment = async ( + options: SetupOptions +): Promise => { + // Determine STREAM credentials, amount to pay, and destination details + // by performing Open Payments/SPSP queries, or using the provided info + const destinationDetailsOrError = await fetchPaymentDetails(options) + if (isPaymentError(destinationDetailsOrError)) { + throw destinationDetailsOrError + } + const destinationDetails = destinationDetailsOrError + + // Use STREAM to fetch the destination asset (returns immediately if asset is already known) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const requestCounter = Counter.from(0)! + const assetOrError = await new AssetProbe( + options.plugin, + destinationDetails, + requestCounter + ).start() + if (isPaymentError(assetOrError)) { + throw assetOrError + } + const destinationAsset = assetOrError + + return { + ...destinationDetails, + destinationAsset, + requestCounter + } +} + +/** Perform a rate probe: discover path max packet amount, probe the real exchange rate, and compute the minimum exchange rate and bounds of the payment. */ +export const startQuote = async (options: QuoteOptions): Promise => { + const rateProbe = new RateProbe(options) + const { log } = rateProbe + const { destinationPaymentDetails, destinationAsset } = options.destination + + if (destinationPaymentDetails) { + if (destinationPaymentDetails.completed) { + log.debug('quote failed: Incoming Payment is already completed.') + // In Incoming Payment case, STREAM connection is yet to be established since no asset probe + throw PaymentError.IncomingPaymentCompleted + } + if ( + destinationPaymentDetails.expiresAt && + destinationPaymentDetails.expiresAt <= Date.now() + ) { + log.debug('quote failed: Incoming Payment is expired.') + // In Incoming Payment case, STREAM connection is yet to be established since no asset probe + throw PaymentError.IncomingPaymentExpired + } + } + + // Validate the amounts to set the target for the payment + let target: { + type: PaymentType + amount: PositiveInt + } + + if ( + destinationPaymentDetails && + typeof destinationPaymentDetails.incomingAmount !== 'undefined' + ) { + const remainingToDeliver = Int.from( + destinationPaymentDetails.incomingAmount.value - + destinationPaymentDetails.receivedAmount.value + ) + if (!remainingToDeliver || !remainingToDeliver.isPositive()) { + // Return this error here instead of in `setupPayment` so consumer can access the resolved Incoming Payment + log.debug( + 'quote failed: Incoming Payment was already paid. incomingAmount=%s receivedAmount=%s', + destinationPaymentDetails.incomingAmount, + destinationPaymentDetails.receivedAmount + ) + // In Incoming Payment case, STREAM connection is yet to be established since no asset probe + throw PaymentError.IncomingPaymentCompleted + } + + target = { + type: PaymentType.FixedDelivery, + amount: remainingToDeliver + } + } else if (typeof options.amountToDeliver !== 'undefined') { + const amountToDeliver = Int.from(options.amountToDeliver) + if (!amountToDeliver || !amountToDeliver.isPositive()) { + log.debug('invalid config: amount to deliver is not a positive integer') + throw PaymentError.InvalidDestinationAmount + } + + target = { + type: PaymentType.FixedDelivery, + amount: amountToDeliver + } + } + // Validate the target amount is non-zero and compatible with the precision of the accounts + else if (typeof options.amountToSend !== 'undefined') { + const amountToSend = Int.from(options.amountToSend) + if (!amountToSend || !amountToSend.isPositive()) { + log.debug('invalid config: amount to send is not a positive integer') + throw PaymentError.InvalidSourceAmount + } + + target = { + type: PaymentType.FixedSend, + amount: amountToSend + } + } else { + log.debug( + 'invalid config: no Incoming Payment with existing incomingAmount, amount to send, or amount to deliver was provided' + ) + throw PaymentError.UnknownPaymentTarget + } + + // Validate the slippage + const slippage = options.slippage ?? 0.01 + if (!isNonNegativeRational(slippage) || slippage > 1) { + log.debug('invalid config: slippage is not a number between 0 and 1') + throw PaymentError.InvalidSlippage + } + + // No source asset or minimum rate computation if 100% slippage + let externalRate: number + if (slippage === 1) { + externalRate = 0 + } else { + // Validate source asset details + const { sourceAsset } = options + if (!isValidAssetDetails(sourceAsset)) { + log.debug('invalid config: no source asset details were provided') + throw PaymentError.UnknownSourceAsset + } + + // Compute minimum exchange rate, or 1:1 if assets are the same. + if (sourceAsset.code === destinationAsset.code) { + externalRate = 1 + } else { + const sourcePrice = options.prices?.[sourceAsset.code] + const destinationPrice = options.prices?.[destinationAsset.code] + + // Ensure the prices are defined, finite, and denominator > 0 + if ( + !isNonNegativeRational(sourcePrice) || + !isNonNegativeRational(destinationPrice) || + destinationPrice === 0 + ) { + log.debug( + 'quote failed: no external rate available from %s to %s', + sourceAsset.code, + destinationAsset.code + ) + throw PaymentError.ExternalRateUnavailable + } + + // This seems counterintuitive because rates are destination amount / source amount, + // but each price *is a rate*, not an amount. + // For example: sourcePrice => USD/ABC, destPrice => USD/XYZ, externalRate => XYZ/ABC + externalRate = sourcePrice / destinationPrice + } + + // Scale rate and apply slippage + // prettier-ignore + externalRate = + externalRate * + (1 - slippage) * + 10 ** (destinationAsset.scale - sourceAsset.scale) + } + + const minExchangeRate = Ratio.from(externalRate as NonNegativeRational) + log.debug('calculated min exchange rate of %s', minExchangeRate) + + // Perform rate probe: probe realized rate and discover path max packet amount + log.debug('starting quote.') + const rateProbeResult = await rateProbe.start() + if (isPaymentError(rateProbeResult)) { + throw rateProbeResult + } + log.debug('quote complete.') + + // Set the amounts to pay/deliver and perform checks to determine + // if this is possible given the probed & minimum rates + const { + lowEstimatedExchangeRate, + highEstimatedExchangeRate, + maxPacketAmount + } = rateProbeResult + + // From rate probe, source amount of lowerBoundRate should be the maxPacketAmount. + // So, no rounding error is possible as long as minRate is at least the probed rate. + // ceil(maxPacketAmount * minExchangeRate) >= floor(maxPacketAmount * lowerBoundRate) + // ceil(maxPacketAmount * minExchangeRate) >= lowerBoundRate.delivered + if (!lowEstimatedExchangeRate.isGreaterThanOrEqualTo(minExchangeRate)) { + log.debug( + 'quote failed: probed exchange rate of %s does not exceed minimum of %s', + lowEstimatedExchangeRate, + minExchangeRate + ) + throw PaymentError.InsufficientExchangeRate + } + + // At each hop, up to 1 unit of the local asset before the conversion + // is "lost" to rounding when the outgoing amount is floored. + // If a small packet is sent, such as the final one in the payment, + // it may not meet its minimum destination amount since the rounding + // error caused a shortfall. + + // To address this, allow up to 1 source unit to *not* be delivered. + // This is accounted for and allowed within the quoted maximum source amount. + + let maxSourceAmount: PositiveInt + let minDeliveryAmount: Int + + if (target.type === PaymentType.FixedSend) { + maxSourceAmount = target.amount + minDeliveryAmount = target.amount + .saturatingSubtract(Int.ONE) + .multiplyCeil(minExchangeRate) + } else if (!minExchangeRate.isPositive()) { + log.debug( + 'quote failed: unenforceable payment delivery. min exchange rate is 0' + ) + throw PaymentError.UnenforceableDelivery + } else { + // Consider that we're trying to discover the maximum original integer value that + // delivered the target delivery amount. If it converts back into a decimal + // source amount, it's safe to floor, since we assume each portion of the target + // delivery amount was already ceil-ed and delivered at greater than the minimum rate. + // + // Then, add one to account for the source unit allowed lost to a rounding error. + maxSourceAmount = target.amount + .multiplyFloor(minExchangeRate.reciprocal()) + .add(Int.ONE) + minDeliveryAmount = target.amount + } + + return { + paymentType: target.type, + lowEstimatedExchangeRate, + highEstimatedExchangeRate, + minExchangeRate, + maxPacketAmount: maxPacketAmount.value, + maxSourceAmount: maxSourceAmount.value, + minDeliveryAmount: minDeliveryAmount.value + } +} + +/** Send the payment: send a series of packets to attempt the payment within the completion criteria and limits of the provided quote. */ +export const pay = async (options: PayOptions): Promise => { + const maxSourceAmount = Int.from(options.quote.maxSourceAmount) + const minDeliveryAmount = Int.from(options.quote.minDeliveryAmount) + const maxPacketAmount = Int.from(options.quote.maxPacketAmount) + if (!maxSourceAmount || !maxSourceAmount.isPositive()) + throw PaymentError.InvalidQuote + if (!minDeliveryAmount) throw PaymentError.InvalidQuote + if (!maxPacketAmount || !maxPacketAmount.isPositive()) + throw PaymentError.InvalidQuote + + const sender = new PaymentSender({ + ...options, + quote: { + ...options.quote, + maxSourceAmount, + minDeliveryAmount, + maxPacketAmount + } + }) + const error = await sender.start() + + return { + ...(isPaymentError(error) && { error }), + ...sender.getProgress() + } +} + +/** Notify receiver to close the connection */ +export const closeConnection = async ( + plugin: Plugin, + destination: ResolvedPayment +): Promise => { + await new ConnectionCloser(plugin, destination).start() +} diff --git a/packages/pay/src/open-payments.ts b/packages/pay/src/open-payments.ts new file mode 100644 index 0000000000..8fd32d0152 --- /dev/null +++ b/packages/pay/src/open-payments.ts @@ -0,0 +1,496 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-empty-function */ +import { Int, isNonNegativeRational, sleep } from './utils' +import fetch, { Response, RequestInit } from 'node-fetch' +import { PaymentError, SetupOptions } from '.' +import createLogger from 'ilp-logger' +import { + AssetDetails, + isValidAssetScale, + isValidAssetDetails +} from './controllers/asset-details' +import { IlpAddress, isValidIlpAddress } from 'ilp-packet' +import AbortController from 'abort-controller' +import { AccountUrl, createHttpUrl } from './payment-pointer' + +const SHARED_SECRET_BYTE_LENGTH = 32 +const OPEN_PAYMENT_QUERY_ACCEPT_HEADER = 'application/json' +const ACCOUNT_QUERY_ACCEPT_HEADER = `${OPEN_PAYMENT_QUERY_ACCEPT_HEADER}, application/spsp4+json` + +const log = createLogger('ilp-pay:query') + +/** + * Destination details of the payment, such the asset, Incoming Payment, and STREAM credentials to + * establish an authenticated connection with the receiver + */ +export interface PaymentDestination { + /** 32-byte seed to derive keys to encrypt STREAM messages and generate ILP packet fulfillments */ + sharedSecret: Buffer + /** ILP address of the recipient, identifying this connection, which is used to send packets to their STREAM server */ + destinationAddress: IlpAddress + /** Asset and denomination of the receiver's Interledger account */ + destinationAsset?: AssetDetails + /** Open Payments Incoming Payment metadata, if the payment pays into an Incoming Payment */ + destinationPaymentDetails?: IncomingPayment + /** + * URL of the recipient Open Payments/SPSP account (with well-known path, and stripped trailing slash). + * Each payment pointer and its corresponding account URL identifies a unique payment recipient. + * Not applicable if STREAM credentials were provided directly. + */ + accountUrl?: string + /** + * Payment pointer, prefixed with "$", corresponding to the recipient Open Payments/SPSP account. + * Each payment pointer and its corresponding account URL identifies a unique payment recipient. + * Not applicable if STREAM credentials were provided directly. + */ + destinationAccount?: string +} + +/** [Open Payments Account](https://docs.openpayments.guide) metadata */ +export interface Account { + /** URL identifying the Account */ + id: string + /** A public name for the account */ + publicName: string + /** Asset code or symbol identifying the currency of the account */ + assetCode: string + /** Precision of the asset denomination: number of decimal places of the normal unit */ + assetScale: number + /** The URL of the authorization server endpoint for getting grants and access tokens for this account **/ + authServer: string +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types +const isAccount = (o: any): o is Account => !!validateOpenPaymentsAccount(o) + +/** [Open Payments Incoming Payment](https://docs.openpayments.guide) metadata */ +export interface IncomingPayment { + /** URL identifying the Incoming Payment */ + id: string + /** URL identifying the account into which payments toward the Incoming Payment will be credited */ + paymentPointer: string + /** Describes whether the Incoming Payment has completed receiving funds */ + completed: boolean + /** UNIX timestamp in milliseconds when payments toward the Incoming Payment will no longer be accepted */ + expiresAt?: number + /** Human-readable description of the Incoming Payment */ + description?: string + /** Human-readable external reference of the Incoming Payment */ + externalRef?: string + /** Fixed destination amount that must be delivered to complete payment of the Incoming Payment. */ + incomingAmount?: Amount + /** Amount that has already been paid toward the Incoming Payment. */ + receivedAmount: Amount +} + +export interface Amount { + // Amount, in base units. ≥0 + value: bigint + /** Asset code or symbol identifying the currency of the account */ + assetCode: string + /** Precision of the asset denomination: number of decimal places of the normal unit */ + assetScale: number +} + +/** Validate and resolve the details provided by recipient to execute the payment */ +export const fetchPaymentDetails = async ( + options: Partial +): Promise => { + const { + destinationPayment, + destinationConnection, + destinationAccount, + sharedSecret, + destinationAddress, + destinationAsset + } = options + + // Check that only one of destinationPayment, destinationConnection, destinationAccount, or STREAM credentials are provided + if ( + Object.values({ + destinationPayment, + destinationConnection, + destinationAccount, + destinationAddress + }).filter((e) => e !== undefined).length > 1 + ) { + log.debug( + 'invalid config: more that one of destinationPayment, destinationConnection, destinationAccount, or STREAM credentials provided' + ) + return PaymentError.InvalidDestination + } + + // Resolve Incoming Payment and STREAM credentials + if (destinationPayment) { + return queryIncomingPayment(destinationPayment) + } + // Resolve STREAM credentials from Open Payments Connection URL + else if (destinationConnection) { + return queryConnection(destinationConnection) + } + // Resolve STREAM credentials from SPSP query at payment pointer + else if (destinationAccount) { + const account = await queryAccount(destinationAccount) + if (isAccount(account)) { + return PaymentError.InvalidDestination + } else { + return account + } + } + // STREAM credentials were provided directly + else if ( + isSharedSecretBuffer(sharedSecret) && + isValidIlpAddress(destinationAddress) && + (!destinationAsset || isValidAssetDetails(destinationAsset)) + ) { + log.warn( + 'using custom STREAM credentials. destinationPayment or destinationAccount are recommended to setup a STREAM payment' + ) + return { + sharedSecret, + destinationAddress, + destinationAsset + } + } + // No STREAM credentials or method to resolve them + else { + log.debug( + 'invalid config: no destinationPayment, destinationConnection, destinationAccount, or STREAM credentials provided' + ) + return PaymentError.InvalidCredentials + } +} + +/** Fetch an Incoming Payment and STREAM credentials from an Open Payments account */ +const queryIncomingPayment = async ( + url: string +): Promise => { + if (!createHttpUrl(url)) { + log.debug('destinationPayment query failed: URL not HTTP/HTTPS.') + return PaymentError.QueryFailed + } + + return fetchJson(url, OPEN_PAYMENT_QUERY_ACCEPT_HEADER) + .then(async (data) => { + const credentials = await validateOpenPaymentsCredentials(data) + const incomingPayment = validateOpenPaymentsIncomingPayment(data) + + if (incomingPayment && credentials) { + return { + accountUrl: incomingPayment.paymentPointer, + destinationPaymentDetails: incomingPayment, + ...credentials + } + } + log.debug('destinationPayment query returned an invalid response.') + }) + .catch((err) => + log.debug('destinationPayment query failed: %s', err?.message) + ) + .then((res) => res || PaymentError.QueryFailed) +} + +/** Query the payment pointer, Open Payments server, or SPSP server for credentials to establish a STREAM connection */ +export const queryAccount = async ( + destinationAccount: string +): Promise => { + const accountUrl = + AccountUrl.fromPaymentPointer(destinationAccount) ?? + AccountUrl.fromUrl(destinationAccount) + if (!accountUrl) { + log.debug( + 'payment pointer or account url is invalid: %s', + destinationAccount + ) + return PaymentError.InvalidPaymentPointer + } + + return fetchJson(accountUrl.toEndpointUrl(), ACCOUNT_QUERY_ACCEPT_HEADER) + .then( + (data) => + validateOpenPaymentsAccount(data) ?? + validateSpspCredentials(data) ?? + log.debug('payment pointer query returned no valid STREAM credentials.') + ) + .catch((err) => log.debug('payment pointer query failed: %s', err)) + .then((res) => + res + ? isAccount(res) + ? res + : { + ...res, + accountUrl: accountUrl.toString(), + destinationAccount: accountUrl.toPaymentPointer() + } + : PaymentError.QueryFailed + ) +} + +/** Query an Open Payments Connection endpoint for STREAM credentials*/ +const queryConnection = async ( + url: string +): Promise => { + if (!createHttpUrl(url)) { + log.debug('destinationPayment query failed: URL not HTTP/HTTPS.') + return PaymentError.QueryFailed + } + return fetchJson(url, OPEN_PAYMENT_QUERY_ACCEPT_HEADER) + .then( + (data) => + validateConnectionCredentials(data) ?? + log.debug('payment pointer query returned no valid STREAM credentials.') + ) + .catch((err) => log.debug('payment pointer query failed: %s', err)) + .then((res) => (res ? res : PaymentError.QueryFailed)) +} + +/** Perform an HTTP request using `fetch` with timeout and retries. Resolve with parsed JSON, reject otherwise. */ +const fetchJson = async ( + url: string, + acceptHeader: string, + timeout = 3000, + remainingRetries = [10, 500, 2500] // Retry up to 3 times with increasing backoff +): Promise => { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeout) + + const retryDelay = remainingRetries.shift() + + return fetch(url, { + redirect: 'follow', + headers: { + Accept: acceptHeader + }, + // @types/node-fetch isn't compatible with abort-controller + signal: controller.signal as RequestInit['signal'] + }) + .then( + async (res: Response) => { + // If server error, retry after delay + if ((res.status >= 500 || res.status === 429) && retryDelay) { + await sleep(retryDelay) + return fetchJson(url, acceptHeader, timeout, remainingRetries) + } + + // Parse JSON on HTTP 2xx, otherwise error + return res.ok ? res.json() : Promise.reject() + }, + async (err: Error) => { + // Only handle timeout (abort) errors. Use two `then` callbacks instead + // of then/catch so JSON parsing errors, etc. are not caught here. + if (err.name !== 'AbortError' && retryDelay) { + await sleep(retryDelay) + return fetchJson(url, acceptHeader, timeout, remainingRetries) + } + + throw err + } + ) + .finally(() => clearTimeout(timer)) +} + +const validateSharedSecretBase64 = (o: any): Buffer | undefined => { + if (typeof o === 'string') { + const sharedSecret = Buffer.from(o, 'base64') + if (sharedSecret.byteLength === SHARED_SECRET_BYTE_LENGTH) { + return sharedSecret + } + } +} + +const isSharedSecretBuffer = (o: any): o is Buffer => + Buffer.isBuffer(o) && o.byteLength === SHARED_SECRET_BYTE_LENGTH + +/** Validate the input is a number or string in the range of a u64 integer, and transform into `Int` */ +const validateUInt64 = (o: any): Int | undefined => { + if (!['string', 'number'].includes(typeof o)) { + return + } + + const n = Int.from(o) + if (n?.isLessThanOrEqualTo(Int.MAX_U64)) { + return n + } +} + +const isNonNullObject = (o: any): o is Record => + typeof o === 'object' && o !== null + +/** Transform the Open Payments server response into a validated Account */ +const validateOpenPaymentsAccount = (o: any): Account | undefined => { + if (!isNonNullObject(o)) { + return + } + + const { id, publicName, assetCode, assetScale, authServer } = o + + if ( + typeof id !== 'string' || + !(typeof publicName === 'string' || publicName === undefined) || + typeof assetCode !== 'string' || + !isValidAssetScale(assetScale) || + typeof authServer !== 'string' + ) { + return + } + + if (!AccountUrl.fromUrl(id)) return + + // TODO Should the given Account URL be validated against the `id` URL in the Account itself? + + return { + id, + publicName, + assetCode, + assetScale, + authServer + } +} + +/** Transform the Open Payments server response into a validated IncomingPayment */ +const validateOpenPaymentsIncomingPayment = ( + o: any, + expectedAmount?: Amount +): IncomingPayment | undefined => { + if (!isNonNullObject(o)) { + return + } + + const { + id, + paymentPointer, + completed, + incomingAmount: unvalidatedIncomingAmount, + receivedAmount: unvalidatedReceivedAmount, + expiresAt: expiresAtIso, + description, + externalRef + } = o + const expiresAt = expiresAtIso ? Date.parse(expiresAtIso) : undefined // `NaN` if date is invalid + const incomingAmount = validateOpenPaymentsAmount(unvalidatedIncomingAmount) + const receivedAmount = validateOpenPaymentsAmount(unvalidatedReceivedAmount) + + if ( + typeof id !== 'string' || + typeof paymentPointer !== 'string' || + typeof completed !== 'boolean' || + !(typeof description === 'string' || description === undefined) || + !(typeof externalRef === 'string' || externalRef === undefined) || + !(isNonNegativeRational(expiresAt) || expiresAt === undefined) || + incomingAmount === null || + !receivedAmount + ) { + return + } + + if (expectedAmount) { + if ( + incomingAmount?.value !== expectedAmount.value || + incomingAmount?.assetCode !== expectedAmount.assetCode || + incomingAmount?.assetScale !== expectedAmount.assetScale + ) { + return + } + } + + if (!AccountUrl.fromUrl(id)) return + if (!AccountUrl.fromUrl(paymentPointer)) return + + // TODO Should the given Incoming Payment URL be validated against the `id` URL in the Incoming Payment itself? + + return { + id, + paymentPointer, + completed, + expiresAt, + description, + externalRef, + receivedAmount, + incomingAmount + } +} + +/** Validate Open Payments STREAM credentials and asset details */ +const validateOpenPaymentsCredentials = async ( + o: any +): Promise => { + if (!isNonNullObject(o)) { + return + } + + const { ilpStreamConnection, receivedAmount } = o + if (!receivedAmount) return + let details + if (typeof ilpStreamConnection === 'string') { + details = await fetchJson( + ilpStreamConnection, + OPEN_PAYMENT_QUERY_ACCEPT_HEADER + ) + } else { + details = ilpStreamConnection + } + const { ilpAddress: destinationAddress, sharedSecret: sharedSecretBase64 } = + details + const sharedSecret = validateSharedSecretBase64(sharedSecretBase64) + const destinationAmount = validateOpenPaymentsAmount(receivedAmount) + if ( + !sharedSecret || + !isValidIlpAddress(destinationAddress) || + !destinationAmount + ) { + return + } + + return { + destinationAsset: { + code: destinationAmount.assetCode, + scale: destinationAmount.assetScale + }, + destinationAddress, + sharedSecret + } +} + +/** Validate and transform the SPSP server response into STREAM credentials */ +const validateSpspCredentials = (o: any): PaymentDestination | undefined => { + if (!isNonNullObject(o)) { + return + } + + const { destination_account: destinationAddress, shared_secret } = o + const sharedSecret = validateSharedSecretBase64(shared_secret) + if (sharedSecret && isValidIlpAddress(destinationAddress)) { + return { destinationAddress, sharedSecret } + } +} + +/** Validate and transform the Open Payments connection endpoint response into STREAM credentials */ +const validateConnectionCredentials = ( + o: any +): PaymentDestination | undefined => { + if (!isNonNullObject(o)) { + return + } + + const { ilpAddress: destinationAddress, sharedSecret: sharedSecretBase64 } = o + const sharedSecret = validateSharedSecretBase64(sharedSecretBase64) + if (sharedSecret && isValidIlpAddress(destinationAddress)) { + return { destinationAddress, sharedSecret } + } +} + +const validateOpenPaymentsAmount = ( + o: Record +): Amount | undefined | null => { + if (o === undefined) return undefined + const { value, assetScale, assetCode } = o + const amountInt = validateUInt64(value) + if ( + amountInt && + isValidAssetScale(assetScale) && + typeof assetCode === 'string' + ) { + return { value: amountInt.value, assetCode, assetScale } + } else { + return null + } +} diff --git a/packages/pay/src/payment-pointer.ts b/packages/pay/src/payment-pointer.ts new file mode 100644 index 0000000000..555e093402 --- /dev/null +++ b/packages/pay/src/payment-pointer.ts @@ -0,0 +1,126 @@ +export const createHttpUrl = ( + rawUrl: string, + base?: string +): URL | undefined => { + try { + const url = new URL(rawUrl, base) + if (url.protocol === 'https:' || url.protocol === 'http:') { + return url + } + } catch (_) { + return + } +} + +/** URL of a unique account payable over Interledger, queryable via SPSP or Open Payments */ +export class AccountUrl { + private static DEFAULT_PATH = '/.well-known/pay' + + /** Protocol of the URL */ + private protocol: string + + /** Domain name of the URL */ + private hostname: string + + /** Path with stripped trailing slash, or `undefined` for default, well-known account path */ + private path?: string + + /** Query string and/or fragment. Empty string for PP, optional for the full URL format */ + private suffix: string + + /** Parse a [payment pointer](https://paymentpoiners.org) prefixed with "$" */ + static fromPaymentPointer(paymentPointer: string): AccountUrl | undefined { + if (!paymentPointer.startsWith('$')) { + return + } + + /** + * From paymentpointers.org/syntax-resolution/: + * + * "...the Payment Pointer syntax only supports a host which excludes the userinfo and port. + * The Payment Pointer syntax also excludes the query and fragment parts that are allowed in the URL syntax. + * + * Payment Pointers that do not meet the limited syntax of this profile MUST be + * considered invalid and should not be used to resolve a URL." + */ + const url = createHttpUrl('https://' + paymentPointer.substring(1)) + if ( + !url || // URL was invalid + url.username !== '' || + url.password !== '' || + url.port !== '' || + url.search !== '' || // No query params + url.hash !== '' // No fragment + ) { + return + } + + return new AccountUrl(url) + } + + /** Parse SPSP/Open Payments account URL. Must be HTTPS/HTTP, contain no credentials, and no port. */ + static fromUrl(rawUrl: string): AccountUrl | undefined { + const url = createHttpUrl(rawUrl) + if (!url || url.username !== '' || url.password !== '' || url.port !== '') { + return + } + + // Don't error if query string or fragment is included -- allowed from URL format + return new AccountUrl(url) + } + + private constructor(url: URL) { + this.protocol = url.protocol + this.hostname = url.hostname + + // Strip trailing slash. If empty, `URL` still adds back the initial slash + const pathname = url.pathname.replace(/\/$/, '') + + // Don't set the path if it corresponds to the default + if (!(pathname === '' || pathname === AccountUrl.DEFAULT_PATH)) { + this.path = pathname + } + + // Empty for payment pointers (fails), optional for full URL variant + this.suffix = url.search + url.hash + } + + /** Endpoint URL for SPSP queries to the account. Includes query string and/or fragment */ + toEndpointUrl(): string { + return ( + this.protocol + + '//' + + this.hostname + + (this.path ?? AccountUrl.DEFAULT_PATH) + + this.suffix + ) + } + + /** Endpoint URL for SPSP queries to the account. Excludes query string and/or fragment */ + toBaseUrl(): string { + return ( + this.protocol + + '//' + + this.hostname + + (this.path ?? AccountUrl.DEFAULT_PATH) + ) + } + + /** + * SPSP/Open Payments account URL, identifying a unique account. Use this for comparing sameness between + * accounts. + */ + toString(): string { + return this.toEndpointUrl() + } + + /** + * Unique payment pointer for this SPSP or Open Payments account. Stripped trailing slash. + * Returns undefined when the protocol is not "https". + * Returns undefined when there is a query string or fragment. + */ + toPaymentPointer(): string | undefined { + if (this.protocol !== 'https:' || this.suffix !== '') return + return '$' + this.hostname + (this.path ?? '') + } +} diff --git a/packages/pay/src/request.ts b/packages/pay/src/request.ts new file mode 100644 index 0000000000..bf4367cf31 --- /dev/null +++ b/packages/pay/src/request.ts @@ -0,0 +1,432 @@ +import { Logger } from 'ilp-logger' +import { + deserializeIlpReply, + IlpError, + IlpErrorCode, + IlpPacketType, + IlpReject, + IlpReply, + isFulfill, + isReject, + serializeIlpPrepare, + IlpAddress +} from 'ilp-packet' +import { randomBytes } from 'crypto' +import { + Frame, + FrameType, + Packet as StreamPacket +} from 'ilp-protocol-stream/dist/src/packet' +import { + generateEncryptionKey, + generateFulfillmentKey, + hmac, + Int, + sha256, + timeout +} from './utils' + +export interface Plugin { + sendData(data: Buffer): Promise +} + +/** Generate keys and serialization function to send ILP Prepares over STREAM */ +export const generateKeys = ( + plugin: Plugin, + sharedSecret: Buffer +): ((request: StreamRequest) => Promise) => { + const encryptionKey = generateEncryptionKey(sharedSecret) + const fulfillmentKey = generateFulfillmentKey(sharedSecret) + + return async (request: StreamRequest): Promise => { + // Create the STREAM request packet + const { + sequence, + sourceAmount, + destinationAddress, + minDestinationAmount, + frames, + isFulfillable, + expiresAt, + log + } = request + + const streamRequest = new StreamPacket( + sequence, + IlpPacketType.Prepare.valueOf(), + minDestinationAmount.toLong(), + frames + ) + + const data = await streamRequest.serializeAndEncrypt(encryptionKey) + + let executionCondition: Buffer + let fulfillment: Buffer | undefined + + if (isFulfillable) { + fulfillment = hmac(fulfillmentKey, data) + executionCondition = sha256(fulfillment) + log.debug( + 'sending Prepare. amount=%s minDestinationAmount=%s frames=[%s]', + sourceAmount, + minDestinationAmount, + frames.map((f) => FrameType[f.type]).join() + ) + } else { + executionCondition = randomBytes(32) + log.debug( + 'sending unfulfillable Prepare. amount=%s frames=[%s]', + sourceAmount, + frames.map((f) => FrameType[f.type]).join() + ) + } + + log.trace('loading Prepare with frames: %o', frames) + + // Create and serialize the ILP Prepare + const preparePacket = serializeIlpPrepare({ + destination: destinationAddress, + amount: sourceAmount.toString(), // Max packet amount controller always limits this to U64 + executionCondition, + expiresAt, + data + }) + + // Send the packet! + const pendingReply = plugin + .sendData(preparePacket) + .then((data) => { + try { + return deserializeIlpReply(data) + } catch (_) { + return createReject(IlpError.F01_INVALID_PACKET) + } + }) + .catch((err) => { + log.error('failed to send Prepare:', err) + return createReject(IlpError.T00_INTERNAL_ERROR) + }) + .then((ilpReply) => { + if ( + !isFulfill(ilpReply) || + !fulfillment || + ilpReply.fulfillment.equals(fulfillment) + ) { + return ilpReply + } + + log.error( + 'got invalid fulfillment: %h. expected: %h, condition: %h', + ilpReply.fulfillment, + fulfillment, + executionCondition + ) + return createReject(IlpError.F05_WRONG_CONDITION) + }) + + // Await reply and timeout if the packet expires + const timeoutDuration = expiresAt.getTime() - Date.now() + const ilpReply: IlpReply = await timeout( + timeoutDuration, + pendingReply + ).catch(() => { + log.error('request timed out.') + return createReject(IlpError.R00_TRANSFER_TIMED_OUT) + }) + + const streamReply = await StreamPacket.decryptAndDeserialize( + encryptionKey, + ilpReply.data + ).catch(() => undefined) + + if (isFulfill(ilpReply)) { + log.debug('got Fulfill. sentAmount=%s', sourceAmount) + } else if (isReject(ilpReply)) { + log.debug( + 'got %s Reject: %s', + ilpReply.code, + ILP_ERROR_CODES[ilpReply.code as IlpErrorCode] + ) + + if (ilpReply.message.length > 0 || ilpReply.triggeredBy.length > 0) { + log.trace( + 'Reject message="%s" triggeredBy=%s', + ilpReply.message, + ilpReply.triggeredBy + ) + } + } else { + throw new Error('ILP response is neither fulfillment nor rejection') + } + + let responseFrames: Frame[] | undefined + let destinationAmount: Int | undefined + + // Validate the STREAM reply from recipient + if (streamReply) { + if (streamReply.sequence.notEquals(sequence)) { + log.error( + 'discarding STREAM reply: received invalid sequence %s', + streamReply.sequence + ) + } else if ( + +streamReply.ilpPacketType === IlpPacketType.Reject && + isFulfill(ilpReply) + ) { + // If receiver claimed they sent a Reject but we got a Fulfill, they lied! + // If receiver said they sent a Fulfill but we got a Reject, that's possible + log.error( + 'discarding STREAM reply: received Fulfill, but recipient claims they sent a Reject' + ) + } else { + responseFrames = streamReply.frames + destinationAmount = Int.from(streamReply.prepareAmount) + + log.debug( + 'got authentic STREAM reply. receivedAmount=%s frames=[%s]', + destinationAmount, + responseFrames.map((f) => FrameType[f.type]).join() + ) + log.trace('STREAM reply frames: %o', responseFrames) + } + } else if ( + (isFulfill(ilpReply) || + ilpReply.code !== IlpError.F08_AMOUNT_TOO_LARGE) && + ilpReply.data.byteLength > 0 + ) { + // If there's data in a Fulfill or non-F08 reject, it is expected to be a valid STREAM packet + log.warn('data in reply unexpectedly failed decryption.') + } + + return isFulfill(ilpReply) + ? new StreamFulfill(log, responseFrames, destinationAmount) + : new StreamReject(log, ilpReply, responseFrames, destinationAmount) + } +} + +/** Mapping of ILP error codes to its error message */ +const ILP_ERROR_CODES = { + // Final errors + F00: 'bad request', + F01: 'invalid packet', + F02: 'unreachable', + F03: 'invalid amount', + F04: 'insufficient destination amount', + F05: 'wrong condition', + F06: 'unexpected payment', + F07: 'cannot receive', + F08: 'amount too large', + F99: 'application error', + // Temporary errors + T00: 'internal error', + T01: 'peer unreachable', + T02: 'peer busy', + T03: 'connector busy', + T04: 'insufficient liquidity', + T05: 'rate limited', + T99: 'application error', + // Relative errors + R00: 'transfer timed out', + R01: 'insufficient source amount', + R02: 'insufficient timeout', + R99: 'application error' +} + +/** Construct a simple ILP Reject packet */ +const createReject = (code: IlpError): IlpReject => ({ + code, + message: '', + triggeredBy: '', + data: Buffer.alloc(0) +}) + +/** Amounts and data to send a unique ILP Prepare over STREAM */ +export interface StreamRequest { + /** ILP address of the recipient account */ + destinationAddress: IlpAddress + /** Expiration timestamp when the ILP Prepare is void */ + expiresAt: Date + /** Sequence number of the STREAM packet (u32) */ + sequence: number + /** Amount to send in the ILP Prepare */ + sourceAmount: Int + /** Minimum destination amount to tell the recipient ("prepare amount") */ + minDestinationAmount: Int + /** Frames to load within the STREAM packet */ + frames: Frame[] + /** Should the recipient be allowed to fulfill this request, or should it use a random condition? */ + isFulfillable: boolean + /** Logger namespaced to this connection and request sequence number */ + log: Logger +} + +/** Builder to construct the next ILP Prepare and STREAM request */ +export class RequestBuilder implements StreamRequest { + private request: StreamRequest + + constructor(request?: Partial) { + this.request = { + destinationAddress: 'private.example' as IlpAddress, + expiresAt: new Date(), + sequence: 0, + sourceAmount: Int.ZERO, + minDestinationAmount: Int.ZERO, + frames: [], + isFulfillable: false, + log: new Logger('ilp-pay'), + ...request + } + } + + get destinationAddress(): IlpAddress { + return this.request.destinationAddress + } + + get expiresAt(): Date { + return this.request.expiresAt + } + + get sequence(): number { + return this.request.sequence + } + + get sourceAmount(): Int { + return this.request.sourceAmount + } + + get minDestinationAmount(): Int { + return this.request.minDestinationAmount + } + + get frames(): Frame[] { + return this.request.frames + } + + get isFulfillable(): boolean { + return this.request.isFulfillable + } + + get log(): Logger { + return this.request.log + } + + /** Set the ILP address of the destination of the ILP Prepare */ + setDestinationAddress(address: IlpAddress): this { + this.request.destinationAddress = address + return this + } + + /** Set the expiration time of the ILP Prepare */ + setExpiry(expiresAt: Date): this { + this.request.expiresAt = expiresAt + return this + } + + /** Set the sequence number of STREAM packet, to correlate the reply */ + setSequence(sequence: number): this { + this.request.sequence = sequence + this.request.log = this.request.log.extend(sequence.toString()) + return this + } + + /** Set the source amount of the ILP Prepare */ + setSourceAmount(sourceAmount: Int): this { + this.request.sourceAmount = sourceAmount + return this + } + + /** Set the minimum destination amount for the receiver to fulfill the ILP Prepare */ + setMinDestinationAmount(minDestinationAmount: Int): this { + this.request.minDestinationAmount = minDestinationAmount + return this + } + + /** Add frames to include for the STREAM receiver */ + addFrames(...frames: Frame[]): this { + this.request.frames = [...this.request.frames, ...frames] + return this + } + + /** Enable the STREAM receiver to fulfill this ILP Prepare. By default, a random, unfulfillable condition is used. */ + enableFulfillment(): this { + this.request.isFulfillable = true + return this + } + + build(): StreamRequest { + return { ...this.request } + } +} + +export interface StreamReply { + /** Logger namespaced to this connection and request sequence number */ + readonly log: Logger + /** Parsed frames from the STREAM response packet. Omitted if no authentic STREAM reply */ + readonly frames?: Frame[] + /** Amount the recipient claimed to receive. Omitted if no authentic STREAM reply */ + readonly destinationAmount?: Int + /** + * Did the recipient authenticate that they received the STREAM request packet? + * If they responded with a Fulfill or valid STREAM reply, they necessarily decoded the request + */ + isAuthentic(): boolean + /** Is this an ILP Reject packet? */ + isReject(): this is StreamReject + /** Is this an ILP Fulfill packet? */ + isFulfill(): this is StreamFulfill +} + +export class StreamFulfill implements StreamReply { + readonly log: Logger + readonly frames?: Frame[] + readonly destinationAmount?: Int + + constructor(log: Logger, frames?: Frame[], destinationAmount?: Int) { + this.log = log + this.frames = frames + this.destinationAmount = destinationAmount + } + + isAuthentic(): boolean { + return true + } + + isReject(): this is StreamReject { + return false + } + + isFulfill(): this is StreamFulfill { + return true + } +} + +export class StreamReject implements StreamReply { + readonly log: Logger + readonly frames?: Frame[] + readonly destinationAmount?: Int + readonly ilpReject: IlpReject + + constructor( + log: Logger, + ilpReject: IlpReject, + frames?: Frame[], + destinationAmount?: Int + ) { + this.log = log + this.ilpReject = ilpReject + this.frames = frames + this.destinationAmount = destinationAmount + } + + isAuthentic(): boolean { + return !!this.frames && !!this.destinationAmount + } + + isReject(): this is StreamReject { + return true + } + + isFulfill(): this is StreamFulfill { + return false + } +} diff --git a/packages/pay/src/senders/asset-probe.ts b/packages/pay/src/senders/asset-probe.ts new file mode 100644 index 0000000000..195f1a3a49 --- /dev/null +++ b/packages/pay/src/senders/asset-probe.ts @@ -0,0 +1,102 @@ +import { SendState, StreamController } from '../controllers' +import { + AssetDetails, + AssetDetailsController +} from '../controllers/asset-details' +import { PaymentError } from '..' +import { ConnectionNewAddressFrame } from 'ilp-protocol-stream/dist/src/packet' +import { IlpAddress } from 'ilp-packet' +import { EstablishmentController } from '../controllers/establishment' +import { ExpiryController } from '../controllers/expiry' +import { Counter, SequenceController } from '../controllers/sequence' +import { Plugin, RequestBuilder } from '../request' +import { StreamSender } from '.' +import { PaymentDestination } from '../open-payments' + +/** Send requests that trigger receiver to respond with asset details */ +export class AssetProbe extends StreamSender { + private requestCount = 0 + private replyCount = 0 + + private readonly establishmentController: EstablishmentController + private readonly assetController: AssetDetailsController + protected readonly controllers: StreamController[] + + constructor( + plugin: Plugin, + destination: PaymentDestination, + counter: Counter + ) { + super(plugin, destination) + + this.establishmentController = new EstablishmentController(destination) + this.assetController = new AssetDetailsController(destination) + + this.controllers = [ + new SequenceController(counter), + this.establishmentController, + new ExpiryController(), + this.assetController + ] + } + + // Immediately send two packets to "request" the destination asset details + nextState(request: RequestBuilder): SendState { + const assetDetails = this.assetController.getDestinationAsset() + if (assetDetails) { + return SendState.Done(assetDetails) + } + + if (this.requestCount === 0) { + /** + * `ConnectionNewAddress` with an empty string will trigger `ilp-protocol-stream` + * to respond with asset details but *not* trigger a send loop. + * + * However, Interledger.rs will reject this packet since it considers the frame invalid. + */ + request.addFrames(new ConnectionNewAddressFrame('')).build() + request.log.debug('requesting asset details (1 of 2).') + } else if (this.requestCount === 1) { + /** + * `ConnectionNewAddress` with a non-empty string is the only way to trigger Interledger.rs + * to respond with asset details. + * + * But since `ilp-protocol-stream` would trigger a send loop and terminate the payment + * to a send-only client, insert a dummy segment before the connection token. + * Interledger.rs should handle the packet, but `ilp-protocol-stream` should reject it + * without triggering a send loop. + */ + const segments = request.destinationAddress.split('.') + const destinationAddress = [ + ...segments.slice(0, -1), + '_', + ...segments.slice(-1) + ] + .join('.') + .substring(0, 1023) as IlpAddress + request + .addFrames(new ConnectionNewAddressFrame('private.SEND_ONLY_CLIENT')) + .setDestinationAddress(destinationAddress) + .build() + request.log.debug('requesting asset details (2 of 2).') + } else { + return SendState.Yield() + } + + this.requestCount++ + return SendState.Send(() => { + this.replyCount++ + if (this.replyCount === 1) { + return SendState.Yield() + } + + const didConnect = this.establishmentController.didConnect() + const assetDetails = this.assetController.getDestinationAsset() + return !didConnect + ? SendState.Error(PaymentError.EstablishmentFailed) + : !assetDetails + ? SendState.Error(PaymentError.UnknownDestinationAsset) + : SendState.Done(assetDetails) + }) + } +} diff --git a/packages/pay/src/senders/connection-closer.ts b/packages/pay/src/senders/connection-closer.ts new file mode 100644 index 0000000000..f2b99bf9e0 --- /dev/null +++ b/packages/pay/src/senders/connection-closer.ts @@ -0,0 +1,43 @@ +import { SendState, StreamController } from '../controllers' +import { + ConnectionCloseFrame, + ErrorCode +} from 'ilp-protocol-stream/dist/src/packet' +import { EstablishmentController } from '../controllers/establishment' +import { SequenceController } from '../controllers/sequence' +import { ExpiryController } from '../controllers/expiry' +import { Plugin, RequestBuilder } from '../request' +import { StreamSender } from '.' +import { ResolvedPayment } from '..' + +/** Send a best-effort `ConnectionClose` frame if necessary, and resolve if it was sent */ +export class ConnectionCloser extends StreamSender { + private sentCloseFrame = false + + protected readonly controllers: StreamController[] + + constructor(plugin: Plugin, destination: ResolvedPayment) { + super(plugin, destination) + + this.controllers = [ + new SequenceController(destination.requestCounter), + new EstablishmentController(destination), + new ExpiryController() + ] + } + + nextState(request: RequestBuilder): SendState { + if (this.sentCloseFrame) { + return SendState.Yield() // Don't schedule another attempt + } + this.sentCloseFrame = true + request.log.debug('trying to send connection close frame.') + + request.addFrames(new ConnectionCloseFrame(ErrorCode.NoError, '')) + + return SendState.Send(() => + // After request completes, finish send loop + SendState.Done(undefined) + ) + } +} diff --git a/packages/pay/src/senders/index.ts b/packages/pay/src/senders/index.ts new file mode 100644 index 0000000000..fee3edea43 --- /dev/null +++ b/packages/pay/src/senders/index.ts @@ -0,0 +1,214 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + StreamController, + SendState, + SendStateType, + RequestState +} from '../controllers' +import { + RequestBuilder, + generateKeys, + StreamRequest, + StreamReply +} from '../request' +import { isPaymentError, PaymentError } from '..' +import { PaymentDestination } from '../open-payments' +import createLogger, { Logger } from 'ilp-logger' +import { Plugin } from '../request' +import { sha256 } from '../utils' + +/** + * Orchestrates all business rules to schedule and send a series of ILP/STREAM requests + * to one unique destination. + * + * Sends and commits each request, and tracks completion criteria to + * resolve the send loop to its own value. + * + * While other controllers hold "veto" power over individual request attempts, + * only the sender explicitly commits to sending each request. + */ +export abstract class StreamSender { + /** Queue for side effects from requests */ + private readonly requestScheduler = new Scheduler<() => SendState>() + + /** Queue for side effects from replies */ + private readonly replyScheduler = new Scheduler<() => SendState>() + + /** Send an ILP Prepare over STREAM, then parse and authenticate the reply */ + private readonly sendRequest: (request: StreamRequest) => Promise + + /** Order of STREAM controllers to iteratively build a request or cancel the attempt */ + protected abstract readonly controllers: StreamController[] + + /** + * Track completion criteria to finalize and send this request attempt, + * end the send loop, or re-schedule. + * + * Return state of the send loop: + * - `SendState.Send` -- to send the request, applying side effects through all controllers in order, + * - `SendState.Done` -- to resolve the send loop as successful, + * - `SendState.Error` -- to end send loop with an error, + * - `SendState.Schedule` -- to cancel this request attempt and try again at a later time, + * - `SendState.Yield` -- to cancel this request attempt and not directly schedule another. + * + * @param request Proposed ILP Prepare and STREAM request + * @param lookup Lookup or create an instance of another controller. Each connection instantiates a single controller per constructor + */ + protected abstract nextState(request: RequestBuilder): SendState + + /** Logger namespaced to this connection */ + readonly log: Logger + + constructor(plugin: Plugin, destinationDetails: PaymentDestination) { + const { destinationAddress, sharedSecret } = destinationDetails + + const connectionId = sha256(Buffer.from(destinationAddress)) + .toString('hex') + .slice(0, 6) + this.log = createLogger(`ilp-pay:${connectionId}`) + + this.sendRequest = generateKeys(plugin, sharedSecret) + } + + private trySending(): SendState { + const request = new RequestBuilder({ log: this.log }) + const requestState = [...this.controllers.values()].reduce( + (state, controller) => + state.type === SendStateType.Ready + ? controller.buildRequest?.(request) ?? state + : state, + RequestState.Ready() + ) + if (requestState.type !== SendStateType.Ready) { + return requestState // Cancel this attempt + } + + // If committing and sending this request, continue + const state = this.nextState(request) + if (state.type !== SendStateType.Send) { + return state // Cancel this attempt + } + + // Synchronously apply the request + const replyHandlers = this.controllers.map((c) => c.applyRequest?.(request)) + + // Asynchronously send the request and queue the reply side effects as another task + const task = this.sendRequest(request).then((reply) => () => { + // Apply side effects from all controllers and StreamSender, then return the first error or next state + // (For example, even if a payment error occurs in a controller, it shouldn't return + // immediately since that packet still needs to be correctly accounted for) + const error = replyHandlers + .map((apply) => apply?.(reply)) + .find(isPaymentError) + const newState = state.applyReply(reply) + return error ? SendState.Error(error) : newState + }) + + this.replyScheduler.queue(task) + + return SendState.Schedule() // Schedule another attempt immediately + } + + /** + * Send a series of requests, initiated by the given STREAM sender, + * until it completes its send loop or a payment error is encountered. + * + * Only one send loop can run at a time. A STREAM connection + * may run successive send loops for different functions or phases. + */ + async start(): Promise { + // Queue initial attempt to send a request + this.requestScheduler.queue(Promise.resolve(this.trySending.bind(this))) + + for (;;) { + const applyEffects = await Promise.race([ + this.replyScheduler.next(), + this.requestScheduler.next() + ]) + const state = applyEffects() + + switch (state.type) { + case SendStateType.Done: + case SendStateType.Error: + await this.replyScheduler.complete() // Wait to process outstanding requests + return state.value + + case SendStateType.Schedule: + this.requestScheduler.queue( + state.delay.then(() => this.trySending.bind(this)) + ) + } + } + } +} + +/** + * Task scheduler: a supercharged `Promise.race`. + * + * Queue "tasks", which are Promises resolving with a function. The scheduler aggregates + * all pending tasks, where `next()` resolves to the task which resolves first. Critically, + * this also *includes any tasks also queued while awaiting the aggregate Promise*. + * Then, executing the resolved function removes the task, so the remaining + * pending tasks can also be aggregated and awaited. + */ +class Scheduler any> { + /** Set of tasks yet to be executed */ + private pendingTasks = new Set>() + + /** + * Resolves to the task of the first event to resolve. + * Replaced with a new tick each time a task is executed + */ + private nextTick = new PromiseResolver() + + /** + * Resolve to the pending task which resolves first, including existing tasks + * and any added after this is called. + */ + next(): Promise { + this.nextTick = new PromiseResolver() + this.pendingTasks.forEach((task) => { + this.resolveTick(task) + }) + + return this.nextTick.promise + } + + /** + * Execute all pending tasks immediately when they resolve, + * then resolve after all have resolved. + */ + async complete(): Promise { + return Promise.all( + [...this.pendingTasks].map((promise) => promise.then((run) => run())) + ) + } + + /** Schedule a task, which is Promise resolving to a function to execute */ + queue(task: Promise): void { + this.pendingTasks.add(task) + this.resolveTick(task) + } + + /** + * Resolve the current tick when the given task resolves. Wrap + * the task's function to remove it as pending if it's executed. + */ + private async resolveTick(task: Promise): Promise { + const run = await task + this.nextTick.resolve(((...args: Parameters): ReturnType => { + this.pendingTasks.delete(task) + return run(...args) + })) + } +} + +/** Promise that can be resolved or rejected outside its executor callback. */ +class PromiseResolver { + resolve!: (value: T) => void + reject!: () => void + readonly promise = new Promise((resolve, reject) => { + this.resolve = resolve + this.reject = reject + }) +} diff --git a/packages/pay/src/senders/payment.ts b/packages/pay/src/senders/payment.ts new file mode 100644 index 0000000000..9ea9bcf71e --- /dev/null +++ b/packages/pay/src/senders/payment.ts @@ -0,0 +1,326 @@ +import { RequestState, SendState, StreamController } from '../controllers' +import { Int } from '../utils' +import { + StreamMaxMoneyFrame, + FrameType, + StreamMoneyFrame, + StreamReceiptFrame, +} from 'ilp-protocol-stream/dist/src/packet' +import { MaxPacketAmountController } from '../controllers/max-packet' +import { ExchangeRateController } from '../controllers/exchange-rate' +import { IntQuote, PaymentError, PaymentProgress, PayOptions } from '..' +import { RequestBuilder, StreamReply } from '../request' +import { PacingController } from '../controllers/pacer' +import { AssetDetailsController } from '../controllers/asset-details' +import { TimeoutController } from '../controllers/timeout' +import { FailureController } from '../controllers/failure' +import { ExpiryController } from '../controllers/expiry' +import { EstablishmentController } from '../controllers/establishment' +import { SequenceController } from '../controllers/sequence' +import { decodeReceipt, Receipt as StreamReceipt } from 'ilp-protocol-stream' +import { StreamSender } from '.' +import { AppDataController } from '../controllers/app-data' + +/** Completion criteria of the payment */ +export enum PaymentType { + /** Send up to a maximum source amount */ + FixedSend = 'FixedSend', + /** Send to meet a minimum delivery amount, bounding the source amount and rates */ + FixedDelivery = 'FixedDelivery', +} + +type PaymentSenderOptions = Omit & { quote: IntQuote } + +/** Controller to track the payment status and compute amounts to send and deliver */ +export class PaymentSender extends StreamSender { + static DEFAULT_STREAM_ID = 1 + + /** Total amount sent and fulfilled, in scaled units of the sending account */ + private amountSent = Int.ZERO + + /** Total amount delivered and fulfilled, in scaled units of the receiving account */ + private amountDelivered = Int.ZERO + + /** Amount sent that is yet to be fulfilled or rejected, in scaled units of the sending account */ + private sourceAmountInFlight = Int.ZERO + + /** Estimate of the amount that may be delivered from in-flight packets, in scaled units of the receiving account */ + private destinationAmountInFlight = Int.ZERO + + /** Was the rounding error shortfall applied to an in-flight or delivered packet? */ + private appliedRoundingCorrection = false + + /** Maximum amount the recipient can receive on the default stream */ + private remoteReceiveMax?: Int + + /** Greatest STREAM receipt and amount, to prove delivery to a third-party verifier */ + private latestReceipt?: { + totalReceived: Int + buffer: Buffer + } + + /** Payment execution and minimum rates */ + private readonly quote: IntQuote + + /** Callback to pass updates as packets are sent and received */ + private readonly progressHandler?: (status: PaymentProgress) => void + + protected readonly controllers: StreamController[] + + private readonly rateCalculator: ExchangeRateController + private readonly maxPacketController: MaxPacketAmountController + + constructor({ plugin, destination, quote, progressHandler, appData }: PaymentSenderOptions) { + super(plugin, destination) + const { requestCounter } = destination + + this.quote = quote + this.progressHandler = progressHandler + + this.maxPacketController = new MaxPacketAmountController(this.quote.maxPacketAmount) + this.rateCalculator = new ExchangeRateController( + quote.lowEstimatedExchangeRate, + quote.highEstimatedExchangeRate + ) + + const appDataController = + appData + ? new AppDataController(appData, PaymentSender.DEFAULT_STREAM_ID) + : undefined + + this.controllers = [ + new SequenceController(requestCounter), + new EstablishmentController(destination), + new ExpiryController(), + ...(appDataController ? [appDataController] : []), + new FailureController(), + new TimeoutController(), + this.maxPacketController, + new AssetDetailsController(destination), + new PacingController(), + this.rateCalculator, + ] + + this.log.debug('starting payment.') + } + + nextState(request: RequestBuilder): SendState { + const { log } = request + + // Ensure we never overpay the maximum source amount + const availableToSend = this.quote.maxSourceAmount + .saturatingSubtract(this.amountSent) + .saturatingSubtract(this.sourceAmountInFlight) + if (!availableToSend.isPositive()) { + // If we've sent as much as we can, next attempt will only be scheduled after an in-flight request finishes + return SendState.Yield() + } + + // Compute source amount (always positive) + const maxPacketAmount = this.maxPacketController.getNextMaxPacketAmount() + let sourceAmount = availableToSend.orLesser(maxPacketAmount).orLesser(Int.MAX_U64) + + // Does this request complete the payment, so should the rounding correction be applied? + let completesPayment = false + + // Apply fixed delivery limits + if (this.quote.paymentType === PaymentType.FixedDelivery) { + const remainingToDeliver = this.quote.minDeliveryAmount + .saturatingSubtract(this.amountDelivered) + .saturatingSubtract(this.destinationAmountInFlight) + if (!remainingToDeliver.isPositive()) { + // If we've already sent enough to potentially complete the payment, + // next attempt will only be scheduled after an in-flight request finishes + return SendState.Yield() + } + + const sourceAmountDeliveryLimit = + this.rateCalculator.estimateSourceAmount(remainingToDeliver)?.[1] + if (!sourceAmountDeliveryLimit) { + log.warn('payment cannot complete: exchange rate dropped to 0') + return SendState.Error(PaymentError.InsufficientExchangeRate) + } + + sourceAmount = sourceAmount.orLesser(sourceAmountDeliveryLimit) + completesPayment = sourceAmount.isEqualTo(sourceAmountDeliveryLimit) + } else { + completesPayment = sourceAmount.isEqualTo(availableToSend) + } + + // Enforce the minimum exchange rate. + // Allow up to 1 source unit to be lost to rounding only *on the final packet*. + const applyCorrection = completesPayment && !this.appliedRoundingCorrection + const minDestinationAmount = applyCorrection + ? sourceAmount.saturatingSubtract(Int.ONE).multiplyCeil(this.quote.minExchangeRate) + : sourceAmount.multiplyCeil(this.quote.minExchangeRate) + + // If the min destination amount isn't met, the rate dropped and payment cannot be completed. + const [projectedDestinationAmount, highEndDestinationAmount] = + this.rateCalculator.estimateDestinationAmount(sourceAmount) + if (projectedDestinationAmount.isLessThan(minDestinationAmount)) { + log.warn('payment cannot complete: exchange rate dropped below minimum') + return RequestState.Error(PaymentError.InsufficientExchangeRate) + } + + // Rate calculator caps projected destination amounts to U64, + // so that checks against `minDestinationAmount` overflowing U64 range + + // Update in-flight amounts (request will be applied synchronously) + this.sourceAmountInFlight = this.sourceAmountInFlight.add(sourceAmount) + this.destinationAmountInFlight = this.destinationAmountInFlight.add(highEndDestinationAmount) + this.appliedRoundingCorrection = applyCorrection + + this.progressHandler?.(this.getProgress()) + + request + .setSourceAmount(sourceAmount) + .setMinDestinationAmount(minDestinationAmount) + .enableFulfillment() + .addFrames(new StreamMoneyFrame(PaymentSender.DEFAULT_STREAM_ID, 1)) + + return SendState.Send((reply) => { + // Delivered amount must be *at least* the minimum acceptable amount we told the receiver + // No matter what, since they fulfilled it, we must assume they got at least the minimum + const destinationAmount = minDestinationAmount.orGreater(reply.destinationAmount) + + if (reply.isFulfill()) { + this.amountSent = this.amountSent.add(sourceAmount) + this.amountDelivered = this.amountDelivered.add(destinationAmount) + + log.debug( + 'accounted for fulfill. sent=%s delivered=%s minDestination=%s', + sourceAmount, + destinationAmount, + minDestinationAmount + ) + } + + if (reply.isReject() && reply.destinationAmount?.isLessThan(minDestinationAmount)) { + log.debug( + 'packet rejected for insufficient rate. received=%s minDestination=%s', + reply.destinationAmount, + minDestinationAmount + ) + } + + // Update in-flight amounts + this.sourceAmountInFlight = this.sourceAmountInFlight.saturatingSubtract(sourceAmount) + this.destinationAmountInFlight = + this.destinationAmountInFlight.saturatingSubtract(highEndDestinationAmount) + // If this packet failed (e.g. for some other reason), refund the delivery deficit so it may be retried + if (reply.isReject() && applyCorrection) { + this.appliedRoundingCorrection = false + } + + log.debug( + 'payment sent %s of %s (max). inflight=%s', + this.amountSent, + this.quote.maxSourceAmount, + this.sourceAmountInFlight + ) + log.debug( + 'payment delivered %s of %s (min). inflight=%s (destination units)', + this.amountDelivered, + this.quote.minDeliveryAmount, + this.destinationAmountInFlight + ) + + this.updateStreamReceipt(reply) + + this.progressHandler?.(this.getProgress()) + + // Handle protocol violations after all accounting has been performed + if (reply.isFulfill()) { + if (!reply.destinationAmount) { + // Technically, an intermediary could strip the data so we can't ascertain whose fault this is + log.warn('ending payment: packet fulfilled with no authentic STREAM data') + return SendState.Error(PaymentError.ReceiverProtocolViolation) + } else if (reply.destinationAmount.isLessThan(minDestinationAmount)) { + log.warn( + 'ending payment: receiver violated procotol. packet fulfilled below min exchange rate. delivered=%s minDestination=%s', + destinationAmount, + minDestinationAmount + ) + return SendState.Error(PaymentError.ReceiverProtocolViolation) + } + } + + const paidFixedSend = + this.quote.paymentType === PaymentType.FixedSend && + this.amountSent.isEqualTo(this.quote.maxSourceAmount) // Amount in flight is always 0 if this is true + if (paidFixedSend) { + log.debug('payment complete: paid fixed source amount.') + return SendState.Done(this.getProgress()) + } + + const paidFixedDelivery = + this.quote.paymentType === PaymentType.FixedDelivery && + this.amountDelivered.isGreaterThanOrEqualTo(this.quote.minDeliveryAmount) && + !this.sourceAmountInFlight.isPositive() + if (paidFixedDelivery) { + log.debug('payment complete: paid fixed destination amount.') + return SendState.Done(this.getProgress()) + } + + this.remoteReceiveMax = + this.updateReceiveMax(reply)?.orGreater(this.remoteReceiveMax) ?? this.remoteReceiveMax + if (this.remoteReceiveMax?.isLessThan(this.quote.minDeliveryAmount)) { + log.error( + 'ending payment: minimum delivery amount is too much for recipient. minDelivery=%s receiveMax=%s', + this.quote.minDeliveryAmount, + this.remoteReceiveMax + ) + return SendState.Error(PaymentError.IncompatibleReceiveMax) + } + + // Since payment isn't complete yet, immediately queue attempt to send more money + // (in case we were at max in flight previously) + return SendState.Schedule() + }) + } + + getProgress(): PaymentProgress { + return { + streamReceipt: this.latestReceipt?.buffer, + amountSent: this.amountSent.value, + amountDelivered: this.amountDelivered.value, + sourceAmountInFlight: this.sourceAmountInFlight.value, + destinationAmountInFlight: this.destinationAmountInFlight.value, + } + } + + private updateReceiveMax({ frames }: StreamReply): Int | undefined { + return frames + ?.filter((frame): frame is StreamMaxMoneyFrame => frame.type === FrameType.StreamMaxMoney) + .filter((frame) => frame.streamId.equals(PaymentSender.DEFAULT_STREAM_ID)) + .map((frame) => Int.from(frame.receiveMax))?.[0] + } + + private updateStreamReceipt({ log, frames }: StreamReply): void { + // Check for receipt frame + // No need to check streamId, since we only send over stream=1 + const receiptBuffer = frames?.find( + (frame): frame is StreamReceiptFrame => frame.type === FrameType.StreamReceipt + )?.receipt + if (!receiptBuffer) { + return + } + + // Decode receipt, discard if invalid + let receipt: StreamReceipt + try { + receipt = decodeReceipt(receiptBuffer) + } catch (_) { + return + } + + const newTotalReceived = Int.from(receipt.totalReceived) + if (!this.latestReceipt || newTotalReceived.isGreaterThan(this.latestReceipt.totalReceived)) { + log.debug('updated latest stream receipt for %s', newTotalReceived) + this.latestReceipt = { + totalReceived: newTotalReceived, + buffer: receiptBuffer, + } + } + } +} diff --git a/packages/pay/src/senders/rate-probe.ts b/packages/pay/src/senders/rate-probe.ts new file mode 100644 index 0000000000..fa0223dd97 --- /dev/null +++ b/packages/pay/src/senders/rate-probe.ts @@ -0,0 +1,122 @@ +import { PaymentError, QuoteOptions } from '..' +import { SendState, StreamController } from '../controllers' +import { Int, PositiveInt, PositiveRatio, Ratio } from '../utils' +import { MaxPacketAmountController } from '../controllers/max-packet' +import { ExchangeRateController } from '../controllers/exchange-rate' +import { SequenceController } from '../controllers/sequence' +import { EstablishmentController } from '../controllers/establishment' +import { ExpiryController } from '../controllers/expiry' +import { FailureController } from '../controllers/failure' +import { AssetDetailsController } from '../controllers/asset-details' +import { PacingController } from '../controllers/pacer' +import { RequestBuilder } from '../request' +import { StreamSender } from '.' + +export interface ProbeResult { + maxPacketAmount: PositiveInt + lowEstimatedExchangeRate: Ratio + highEstimatedExchangeRate: PositiveRatio +} + +/** Establish exchange rate bounds and path max packet amount capacity with test packets */ +export class RateProbe extends StreamSender { + /** Duration in milliseconds before the rate probe fails */ + private static TIMEOUT = 10_000 + + /** Largest test packet amount */ + static MAX_PROBE_AMOUNT = Int.from(1_000_000_000_000) as PositiveInt + + /** + * Initial barage of test packets amounts left to send (10^12 ... 10^3). + * Amounts < 1000 units are less likely to offer sufficient precision for quoting + */ + private readonly remainingTestAmounts = [ + Int.ZERO, // Shares limits & ensures connection is established, in case no asset probe + Int.from(10 ** 12), + Int.from(10 ** 11), + Int.from(10 ** 10), + Int.from(10 ** 9), + Int.from(10 ** 8), + Int.from(10 ** 7), + Int.from(10 ** 6), + Int.from(10 ** 5), + Int.from(10 ** 4), + Int.from(10 ** 3) + ] as Int[] + + /** + * Amounts of all in-flight packets from subsequent (non-initial) probe packets, + * to ensure the same amount isn't sent continuously + */ + private readonly inFlightAmounts = new Set() + + /** UNIX timestamp when the rate probe fails */ + private deadline?: number + + protected readonly controllers: StreamController[] + + private maxPacketController: MaxPacketAmountController + private rateCalculator: ExchangeRateController + + constructor({ plugin, destination }: QuoteOptions) { + super(plugin, destination) + const { requestCounter } = destination + + this.rateCalculator = new ExchangeRateController() + this.maxPacketController = new MaxPacketAmountController() + + // prettier-ignore + this.controllers = [ + new SequenceController(requestCounter), // Log sequence number in subsequent controllers + new EstablishmentController(destination), // Set destination address for all requests + new ExpiryController(), // Set expiry for all requests + new FailureController(), // Fail fast on terminal rejects or connection closes + this.maxPacketController, // Fail fast if max packet amount is 0 + new AssetDetailsController(destination), // Fail fast on destination asset conflicts + new PacingController(), // Limit frequency of requests + this.rateCalculator, + ] + } + + nextState(request: RequestBuilder): SendState { + if (!this.deadline) { + this.deadline = Date.now() + RateProbe.TIMEOUT + } else if (Date.now() > this.deadline) { + request.log.error( + 'rate probe failed. did not establish rate and/or path capacity' + ) + return SendState.Error(PaymentError.RateProbeFailed) + } + + const probeAmount = this.remainingTestAmounts.shift() + if (!probeAmount || this.inFlightAmounts.has(probeAmount.value)) { + return SendState.Yield() + } + + // Send and commit the test packet + request.setSourceAmount(probeAmount) + this.inFlightAmounts.add(probeAmount.value) + return SendState.Send(() => { + this.inFlightAmounts.delete(probeAmount.value) + + // If we further narrowed the max packet amount, use that amount next. + // Otherwise, no max packet limit is known, so retry this amount. + const nextProbeAmount = + this.maxPacketController.getNextMaxPacketAmount() ?? probeAmount + if ( + !this.remainingTestAmounts.some((n) => n.isEqualTo(nextProbeAmount)) + ) { + this.remainingTestAmounts.push(nextProbeAmount) + } + + // Resolve rate probe if verified path capacity (ensures a rate is also known) + return this.maxPacketController.isProbeComplete() + ? SendState.Done({ + lowEstimatedExchangeRate: this.rateCalculator.getLowerBoundRate(), + highEstimatedExchangeRate: this.rateCalculator.getUpperBoundRate(), + maxPacketAmount: this.maxPacketController.getMaxPacketAmountLimit() + }) + : SendState.Schedule() // Try sending another probing packet to narrow max packet amount + }) + } +} diff --git a/packages/pay/src/utils.ts b/packages/pay/src/utils.ts new file mode 100644 index 0000000000..7e7f26ff7f --- /dev/null +++ b/packages/pay/src/utils.ts @@ -0,0 +1,346 @@ +import Long from 'long' +import { createHash, createHmac } from 'crypto' + +const HASH_ALGORITHM = 'sha256' +const ENCRYPTION_KEY_STRING = Buffer.from('ilp_stream_encryption', 'utf8') +const FULFILLMENT_GENERATION_STRING = Buffer.from( + 'ilp_stream_fulfillment', + 'utf8' +) + +export const sha256 = (preimage: Buffer): Buffer => + createHash(HASH_ALGORITHM).update(preimage).digest() + +export const hmac = (key: Buffer, message: Buffer): Buffer => + createHmac(HASH_ALGORITHM, key).update(message).digest() + +export const generateEncryptionKey = (sharedSecret: Buffer): Buffer => + hmac(sharedSecret, ENCRYPTION_KEY_STRING) + +export const generateFulfillmentKey = (sharedSecret: Buffer): Buffer => + hmac(sharedSecret, FULFILLMENT_GENERATION_STRING) + +const SHIFT_32 = BigInt(4294967296) + +/** + * Return a rejected Promise if the given Promise does not resolve within the timeout, + * or return the resolved value of the Promise + */ +export const timeout = ( + duration: number, + promise: Promise +): Promise => { + let timer: NodeJS.Timeout + return Promise.race([ + new Promise((_, reject) => { + timer = setTimeout(reject, duration) + }), + promise.finally(() => clearTimeout(timer)) + ]) +} + +/** Wait and resolve after the given number of milliseconds */ +export const sleep = (ms: number): Promise => + new Promise((r) => setTimeout(r, ms)) + +/** Integer greater than or equal to 0 */ +export class Int { + readonly value: bigint + + static ZERO = new Int(BigInt(0)) + static ONE = new Int(BigInt(1)) as PositiveInt + static TWO = new Int(BigInt(2)) as PositiveInt + static MAX_U64 = new Int(BigInt('18446744073709551615')) as PositiveInt + + private constructor(n: bigint) { + this.value = n + } + + static from(n: T): T + static from(n: Long): Int + static from(n: NonNegativeInteger): Int + static from(n: number): Int | undefined + static from(n: bigint): Int | undefined + static from(n: string): Int | undefined + static from(n: Int | bigint | number | string): Int | undefined // Necessary for amounts passed during setup + static from( + n: T + ): Int | undefined { + if (n instanceof Int) { + return new Int(n.value) + } else if (typeof n === 'bigint') { + return Int.fromBigint(n) + } else if (typeof n === 'string') { + return Int.fromString(n) + } else if (typeof n === 'number') { + return Int.fromNumber(n) + } else if (Long.isLong(n)) { + return Int.fromLong(n) + } + } + + private static fromBigint(n: bigint): Int | undefined { + if (n >= 0) { + return new Int(n) + } + } + + private static fromString(n: string): Int | undefined { + try { + return Int.fromBigint(BigInt(n)) + // eslint-disable-next-line no-empty + } catch (_) {} + } + + private static fromNumber(n: NonNegativeInteger): Int + private static fromNumber(n: number): Int | undefined + private static fromNumber(n: T): Int | undefined { + if (isNonNegativeInteger(n)) { + return new Int(BigInt(n)) + } + } + + private static fromLong(n: Long): Int { + const lsb = BigInt(n.getLowBitsUnsigned()) + const gsb = BigInt(n.getHighBitsUnsigned()) + return new Int(lsb + SHIFT_32 * gsb) + } + + add(n: PositiveInt): PositiveInt + add(n: Int): Int + add(n: T): Int { + return new Int(this.value + n.value) + } + + saturatingSubtract(n: Int): Int { + return this.value >= n.value ? new Int(this.value - n.value) : Int.ZERO + } + + multiply(n: Int): Int { + return new Int(this.value * n.value) + } + + multiplyFloor(r: Ratio): Int { + return new Int((this.value * r.a.value) / r.b.value) + } + + multiplyCeil(r: Ratio): Int { + return this.multiply(r.a).divideCeil(r.b) + } + + divide(d: PositiveInt): Int { + return new Int(this.value / d.value) + } + + divideCeil(d: PositiveInt): Int { + // Simple algorithm with no modulo/conditional: https://medium.com/@arunistime/how-div-round-up-works-179f1a2113b5 + return new Int((this.value + d.value - BigInt(1)) / d.value) + } + + modulo(n: PositiveInt): Int { + return new Int(this.value % n.value) + } + + isEqualTo(n: Int): boolean { + return this.value === n.value + } + + isGreaterThan(n: Int): this is PositiveInt { + return this.value > n.value + } + + isGreaterThanOrEqualTo(n: PositiveInt): this is PositiveInt + isGreaterThanOrEqualTo(n: Int): boolean + isGreaterThanOrEqualTo(n: T): boolean { + return this.value >= n.value + } + + isLessThan(n: Int): boolean { + return this.value < n.value + } + + isLessThanOrEqualTo(n: Int): boolean { + return this.value <= n.value + } + + isPositive(): this is PositiveInt { + return this.value > 0 + } + + orLesser(n?: Int): Int { + return !n ? this : this.value <= n.value ? this : n + } + + orGreater(n: PositiveInt): PositiveInt + orGreater(n?: Int): Int + orGreater(n: T): Int { + return !n ? this : this.value >= n.value ? this : n + } + + toString(): string { + return this.value.toString() + } + + toLong(): Long | undefined { + if (this.isGreaterThan(Int.MAX_U64)) { + return + } + + const lsb = BigInt.asUintN(32, this.value) + const gsb = (this.value - lsb) / SHIFT_32 + return new Long(Number(lsb), Number(gsb), true) + } + + valueOf(): number { + return Number(this.value) + } + + toRatio(): Ratio { + return Ratio.of(this, Int.ONE) + } +} + +/** Integer greater than 0 */ +export interface PositiveInt extends Int { + add(n: Int): PositiveInt + multiply(n: PositiveInt): PositiveInt + multiply(n: Int): Int + multiplyCeil(r: PositiveRatio): PositiveInt + multiplyCeil(r: Ratio): Int + divideCeil(n: PositiveInt): PositiveInt + isEqualTo(n: Int): n is PositiveInt + isLessThan(n: Int): n is PositiveInt + isLessThanOrEqualTo(n: Int): n is PositiveInt + isPositive(): true + orLesser(n?: PositiveInt): PositiveInt + orLesser(n: Int): Int + orGreater(n?: Int): PositiveInt + toRatio(): PositiveRatio +} + +declare class Tag { + protected __nominal: N +} + +export type Brand = T & Tag + +/** Finite number greater than or equal to 0 */ +export type NonNegativeRational = Brand + +/** Is the given number greater than or equal to 0, not `NaN`, and not `Infinity`? */ +export const isNonNegativeRational = (o: unknown): o is NonNegativeRational => + typeof o === 'number' && Number.isFinite(o) && o >= 0 + +/** Integer greater than or equal to 0 */ +export type NonNegativeInteger = Brand + +/** Is the given number an integer (not `NaN` nor `Infinity`) and greater than or equal to 0? */ +export const isNonNegativeInteger = (o: number): o is NonNegativeInteger => + Number.isInteger(o) && o >= 0 + +/** + * Ratio of two integers: a numerator greater than or equal to 0, + * and a denominator greater than 0 + */ +export class Ratio { + /** Numerator */ + readonly a: Int + /** Denominator */ + readonly b: PositiveInt + + private constructor(a: Int, b: PositiveInt) { + this.a = a + this.b = b + } + + static of(a: PositiveInt, b: PositiveInt): PositiveRatio + static of(a: Int, b: PositiveInt): Ratio + static of(a: T, b: PositiveInt): Ratio { + return new Ratio(a, b) + } + + /** + * Convert a number (not `NaN`, `Infinity` or negative) into a Ratio. + * Zero becomes 0/1. + */ + static from(n: NonNegativeRational): Ratio + static from(n: number): Ratio | undefined + static from(n: T): Ratio | undefined { + if (!isNonNegativeRational(n)) { + return + } + + let e = 1 + while (!Number.isInteger(n * e)) { + e *= 10 + } + + const a = Int.from(n * e) as Int + const b = Int.from(e) as PositiveInt + return new Ratio(a, b) + } + + reciprocal(): PositiveRatio | undefined { + if (this.a.isPositive()) { + return Ratio.of(this.b, this.a) + } + } + + floor(): bigint { + return this.a.divide(this.b).value + } + + ceil(): bigint { + return this.a.divideCeil(this.b).value + } + + isEqualTo(r: Ratio): boolean { + return this.a.value * r.b.value === this.b.value * r.a.value + } + + isGreaterThan(r: Ratio): this is PositiveRatio { + return this.a.value * r.b.value > this.b.value * r.a.value + } + + isGreaterThanOrEqualTo(r: PositiveRatio): this is PositiveRatio + isGreaterThanOrEqualTo(r: Ratio): boolean + isGreaterThanOrEqualTo(r: T): boolean { + return this.a.value * r.b.value >= this.b.value * r.a.value + } + + isLessThan(r: Ratio): boolean { + return this.a.value * r.b.value < this.b.value * r.a.value + } + + isLessThanOrEqualTo(r: Ratio): boolean { + return this.a.value * r.b.value <= this.b.value * r.a.value + } + + isPositive(): this is PositiveRatio { + return this.a.isPositive() + } + + valueOf(): number { + return +this.a / +this.b + } + + toString(): string { + return this.valueOf().toString() + } + + toJSON(): [string, string] { + return [this.a.toString(), this.b.toString()] + } +} + +/** Ratio of two integers greater than 0 */ +export interface PositiveRatio extends Ratio { + readonly a: PositiveInt + readonly b: PositiveInt + + reciprocal(): PositiveRatio + isEqualTo(r: Ratio): r is PositiveRatio + isLessThan(r: Ratio): r is PositiveRatio + isLessThanOrEqualTo(r: Ratio): r is PositiveRatio + isPositive(): true +} diff --git a/packages/pay/test/helpers/plugin.ts b/packages/pay/test/helpers/plugin.ts new file mode 100644 index 0000000000..6c7b50bf84 --- /dev/null +++ b/packages/pay/test/helpers/plugin.ts @@ -0,0 +1,217 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-empty-function */ +import { DataHandler, PluginInstance } from 'ilp-connector/dist/types/plugin' +import { EventEmitter } from 'events' +import { Int, Ratio, sleep } from '../../src/utils' +import { + deserializeIlpPrepare, + IlpError, + IlpPrepare, + IlpReply, + isFulfill, + isIlpReply, + serializeIlpReply +} from 'ilp-packet' +import { Writer } from 'oer-utils' +import { StreamServer } from '@interledger/stream-receiver' +import { AssetDetails } from '../../src' +import { Plugin } from 'ilp-protocol-stream/dist/src/util/plugin-interface' + +export type Middleware = ( + prepare: IlpPrepare, + next: SendPrepare +) => Promise + +type SendPrepare = (prepare: IlpPrepare) => Promise + +export const createPlugin = (...middlewares: Middleware[]): Plugin => { + const send = middlewares.reduceRight( + (next, middleware) => (prepare: IlpPrepare) => middleware(prepare, next), + () => Promise.reject() + ) + + return { + async connect() {}, + async disconnect() {}, + isConnected() { + return true + }, + registerDataHandler() {}, + deregisterDataHandler() {}, + async sendData(data: Buffer): Promise { + const prepare = deserializeIlpPrepare(data) + const reply = await send(prepare) + return serializeIlpReply(reply) + } + } +} + +export const createMaxPacketMiddleware = + (amount: Int): Middleware => + async (prepare, next) => { + if (Int.from(prepare.amount)!.isLessThanOrEqualTo(amount)) { + return next(prepare) + } + + const writer = new Writer(16) + writer.writeUInt64(prepare.amount) // Amount received + writer.writeUInt64(amount.toLong()!) // Maximum + + return { + code: IlpError.F08_AMOUNT_TOO_LARGE, + message: '', + triggeredBy: '', + data: writer.getBuffer() + } + } + +export class RateBackend { + constructor( + private incomingAsset: AssetDetails, + private outgoingAsset: AssetDetails, + private prices: { [assetCode: string]: number }, + private spread = 0 + ) {} + + setSpread(spread: number): void { + this.spread = spread + } + + getRate(): number { + const sourcePrice = this.prices[this.incomingAsset.code] ?? 1 + const destPrice = this.prices[this.outgoingAsset.code] ?? 1 + + // prettier-ignore + return (sourcePrice / destPrice) * + 10 ** (this.outgoingAsset.scale - this.incomingAsset.scale) * + (1 - this.spread) + } +} + +export const createRateMiddleware = + (converter: RateBackend): Middleware => + async (prepare, next) => { + const rate = Ratio.from(converter.getRate())! + const amount = Int.from(prepare.amount)!.multiplyFloor(rate).toString() + return next({ + ...prepare, + amount + }) + } + +export const createSlippageMiddleware = + (spread: number): Middleware => + async (prepare, next) => + next({ + ...prepare, + amount: Int.from(prepare.amount)! + .multiplyFloor(Ratio.from(1 - spread)!) + .toString() + }) + +export const createStreamReceiver = + (server: StreamServer): Middleware => + async (prepare) => { + const moneyOrReply = server.createReply(prepare) + return isIlpReply(moneyOrReply) ? moneyOrReply : moneyOrReply.accept() + } + +export const createLatencyMiddleware = + (min: number, max: number): Middleware => + async (prepare, next) => { + await sleep(getRandomFloat(min, max)) + const reply = await next(prepare) + await sleep(getRandomFloat(min, max)) + return reply + } + +export const createBalanceTracker = (): { + totalReceived: () => Int + middleware: Middleware +} => { + let totalReceived = Int.ZERO + return { + totalReceived: () => totalReceived, + middleware: async (prepare, next) => { + const reply = await next(prepare) + if (isFulfill(reply)) { + totalReceived = totalReceived.add(Int.from(prepare.amount)!) + } + return reply + } + } +} + +const getRandomFloat = (min: number, max: number) => + Math.random() * (max - min) + min + +const defaultDataHandler = async (): Promise => { + throw new Error('No data handler registered') +} + +export class MirrorPlugin + extends EventEmitter + implements Plugin, PluginInstance +{ + public mirror?: MirrorPlugin + + public dataHandler: DataHandler = defaultDataHandler + + private readonly minNetworkLatency: number + private readonly maxNetworkLatency: number + + constructor(minNetworkLatency = 10, maxNetworkLatency = 50) { + super() + this.minNetworkLatency = minNetworkLatency + this.maxNetworkLatency = maxNetworkLatency + } + + static createPair( + minNetworkLatency?: number, + maxNetworkLatency?: number + ): [MirrorPlugin, MirrorPlugin] { + const pluginA = new MirrorPlugin(minNetworkLatency, maxNetworkLatency) + const pluginB = new MirrorPlugin(minNetworkLatency, maxNetworkLatency) + + pluginA.mirror = pluginB + pluginB.mirror = pluginA + + return [pluginA, pluginB] + } + + async connect(): Promise {} + + async disconnect(): Promise {} + + isConnected(): boolean { + return true + } + + async sendData(data: Buffer): Promise { + if (this.mirror) { + await this.addNetworkDelay() + const response = await this.mirror.dataHandler(data) + await this.addNetworkDelay() + return response + } else { + throw new Error('Not connected') + } + } + + registerDataHandler(handler: DataHandler): void { + this.dataHandler = handler + } + + deregisterDataHandler(): void { + this.dataHandler = defaultDataHandler + } + + async sendMoney(): Promise {} + + registerMoneyHandler(): void {} + + deregisterMoneyHandler(): void {} + + private async addNetworkDelay() { + await sleep(getRandomFloat(this.minNetworkLatency, this.maxNetworkLatency)) + } +} diff --git a/packages/pay/test/helpers/rate-backend.ts b/packages/pay/test/helpers/rate-backend.ts new file mode 100644 index 0000000000..988f9f9ab7 --- /dev/null +++ b/packages/pay/test/helpers/rate-backend.ts @@ -0,0 +1,56 @@ +/* eslint-disable @typescript-eslint/no-empty-function, @typescript-eslint/explicit-module-boundary-types */ +import { BackendInstance } from 'ilp-connector/dist/types/backend' +import { Injector } from 'reduct' +import Config from 'ilp-connector/dist/services/config' +import Accounts from 'ilp-connector/dist/services/accounts' + +export class CustomBackend implements BackendInstance { + protected deps: Injector + + protected prices: { + [symbol: string]: number + } = {} + protected spread?: number + + constructor(deps: Injector) { + this.deps = deps + } + + async getRate( + sourceAccount: string, + destinationAccount: string + ): Promise { + const sourceInfo = this.deps(Accounts).getInfo(sourceAccount) + if (!sourceInfo) { + throw new Error('unable to fetch account info for source account.') + } + + const destInfo = this.deps(Accounts).getInfo(destinationAccount) + if (!destInfo) { + throw new Error('unable to fetch account info for destination account.') + } + + const sourcePrice = this.prices[sourceInfo.assetCode] ?? 1 + const destPrice = this.prices[destInfo.assetCode] ?? 1 + + const rate = + (sourcePrice / destPrice) * + 10 ** (destInfo.assetScale - sourceInfo.assetScale) + + const spread = this.spread ?? this.deps(Config).spread ?? 0 + return rate * (1 - spread) + } + + setPrices(prices: { [symbol: string]: number }): void { + this.prices = prices + } + + setSpread(spread: number): void { + this.spread = spread + } + + async connect() {} + async disconnect() {} + async submitPacket() {} + async submitPayment() {} +} diff --git a/packages/pay/test/integration.spec.ts b/packages/pay/test/integration.spec.ts new file mode 100644 index 0000000000..010f331879 --- /dev/null +++ b/packages/pay/test/integration.spec.ts @@ -0,0 +1,314 @@ +import { randomBytes } from 'crypto' +import nock from 'nock' +import { + GenericContainer, + Network, + StartedNetwork, + StartedTestContainer, + Wait +} from 'testcontainers' +import Axios from 'axios' +import PluginHttp from 'ilp-plugin-http' +import getPort from 'get-port' +import { describe, it, expect, afterAll } from '@jest/globals' +import { pay, ResolvedPayment, setupPayment, startQuote } from '../src' +import { Plugin } from 'ilp-protocol-stream/dist/src/util/plugin-interface' + +describe('interledger.rs integration', () => { + let network: StartedNetwork | undefined + let redisContainer: StartedTestContainer | undefined + let rustNodeContainer: StartedTestContainer | undefined + let plugin: Plugin | undefined + + it('pays to SPSP server', async () => { + network = await new Network().start() + + // Setup Redis + redisContainer = await new GenericContainer('redis') + .withName('redis_rs') + .withNetworkMode(network.getName()) + .start() + + // Setup the Rust connector + const adminAuthToken = 'admin' + rustNodeContainer = await new GenericContainer( + 'interledgerrs/ilp-node:latest' + ) + .withEnvironment({ + ILP_SECRET_SEED: randomBytes(32).toString('hex'), + ILP_ADMIN_AUTH_TOKEN: adminAuthToken, + ILP_DATABASE_URL: `redis://redis_rs:6379`, + ILP_ILP_ADDRESS: 'g.corp', + ILP_HTTP_BIND_ADDRESS: '0.0.0.0:7770' + }) + .withName('connector') + .withNetworkMode(network.getName()) + .withExposedPorts(7770) + .withWaitStrategy(Wait.forLogMessage('HTTP API listening')) + .start() + + // Since payment pointers MUST use HTTPS and using a local self-signed cert/CA puts + // constraints on the environment running this test, just manually mock an HTTPS proxy to the Rust SPSP server + const host = `${rustNodeContainer.getHost()}:${rustNodeContainer.getMappedPort(7770)}` + const scope = nock('https://mywallet.com') + .get('/.well-known/pay') + .matchHeader('Accept', /application\/spsp4\+json*./) + .delay(1000) + .reply(200, () => + Axios.get(`http://${host}/accounts/receiver/spsp`).then( + (res) => res.data + ) + ) + + // Create receiver account + await Axios.post( + `http://${host}/accounts`, + { + username: 'receiver', + asset_code: 'EUR', + asset_scale: 6, + // Required to interact with the account over its HTTP API + ilp_over_http_outgoing_token: 'password', + ilp_over_http_incoming_token: 'password' + }, + { + headers: { + Authorization: `Bearer ${adminAuthToken}` + } + } + ) + + const senderPort = await getPort() + plugin = new PluginHttp({ + incoming: { + port: senderPort, + staticToken: 'password' + }, + outgoing: { + url: `http://${host}/accounts/sender/ilp`, + staticToken: 'password' + } + }) + await plugin.connect() + + // Create account for sender to connect to + await Axios.post( + `http://${host}/accounts`, + { + username: 'sender', + asset_code: 'EUR', + asset_scale: 6, + routing_relation: 'child', + ilp_over_http_url: `http://localhost:${senderPort}`, + ilp_over_http_outgoing_token: 'password', + ilp_over_http_incoming_token: 'password', + max_packet_amount: '2000' + }, + { + headers: { + Authorization: `Bearer ${adminAuthToken}` + } + } + ) + + const amountToSend = BigInt(100_000) // 0.1 EUR, ~50 packets @ max packet amount of 2000 + const destination = (await setupPayment({ + plugin, + destinationAccount: '$mywallet.com' + })) as ResolvedPayment + const quote = await startQuote({ + plugin, + destination, + amountToSend, + sourceAsset: { + code: 'EUR', + scale: 6 + } + }) + + const receipt = await pay({ plugin, destination, quote }) + expect(receipt.amountSent).toBe(amountToSend) + expect(receipt.amountDelivered).toBe(amountToSend) // Exchange rate is 1:1 + + // Check the balance + const { data } = await Axios({ + method: 'GET', + url: `http://${host}/accounts/receiver/balance`, + headers: { + Authorization: 'Bearer password' + } + }) + // Interledger.rs balances are in normal units + expect(data.balance).toBe(0.1) + + scope.done() + }, 30_000) + + afterAll(async () => { + await plugin?.disconnect() + + await rustNodeContainer?.stop() + await redisContainer?.stop() + + await network?.stop() + }) +}) + +describe('interledger4j integration', () => { + let network: StartedNetwork | undefined + let redisContainer: StartedTestContainer | undefined + let connectorContainer: StartedTestContainer | undefined + let plugin: Plugin | undefined + + it('pays to SPSP server', async () => { + network = await new Network().start() + + // Setup Redis + redisContainer = await new GenericContainer('redis') + .withName('redis_4j') + .withNetworkMode(network.getName()) + .start() + + // Setup the Java connector + const adminPassword = 'admin' + connectorContainer = await new GenericContainer( + 'interledger4j/java-ilpv4-connector:0.5.1' + ) + .withEnvironment({ + // Hostname of Redis container + 'redis.host': 'redis_4j', + 'interledger.connector.adminPassword': adminPassword, + 'interledger.connector.spsp.serverSecret': + randomBytes(32).toString('base64'), + 'interledger.connector.enabledFeatures.localSpspFulfillmentEnabled': + 'true', + 'interledger.connector.enabledProtocols.spspEnabled': 'true' + }) + .withNetworkMode(network.getName()) + .withExposedPorts(8080) + .withWaitStrategy(Wait.forLogMessage('STARTED INTERLEDGER CONNECTOR')) + .start() + + // Since payment pointers MUST use HTTPS and using a local self-signed cert/CA puts + // constraints on the environment running this test, just manually mock an HTTPS proxy to the SPSP server + const host = `${connectorContainer.getHost()}:${connectorContainer.getMappedPort(8080)}` + const scope = nock('https://mywallet.com') + .get('/.well-known/pay') + .matchHeader('Accept', /application\/spsp4\+json*./) + .delay(500) + .reply(200, () => + Axios.get(`http://${host}/receiver`, { + headers: { + Accept: 'application/spsp4+json' + } + }).then((res) => res.data) + ) + + // Create receiver account + await Axios.post( + `http://${host}/accounts`, + { + accountId: 'receiver', + accountRelationship: 'PEER', + linkType: 'ILP_OVER_HTTP', + assetCode: 'USD', + assetScale: '6', + sendRoutes: true, + receiveRoutes: true, + customSettings: { + 'ilpOverHttp.incoming.auth_type': 'SIMPLE', + 'ilpOverHttp.incoming.simple.auth_token': 'password' + } + }, + { + auth: { + username: 'admin', + password: adminPassword + } + } + ) + + const senderPort = await getPort() + plugin = new PluginHttp({ + incoming: { + port: senderPort, + staticToken: 'password' + }, + outgoing: { + url: `http://${host}/accounts/sender/ilp`, + staticToken: 'password' + } + }) + await plugin.connect() + + // Create account for sender to connect to + await Axios.post( + `http://${host}/accounts`, + { + accountId: 'sender', + accountRelationship: 'CHILD', + linkType: 'ILP_OVER_HTTP', + assetCode: 'USD', + assetScale: '6', + maximumPacketAmount: '400000', // $0.40 + sendRoutes: true, + receiveRoutes: true, + customSettings: { + 'ilpOverHttp.incoming.auth_type': 'SIMPLE', + 'ilpOverHttp.incoming.simple.auth_token': 'password' + } + }, + { + auth: { + username: 'admin', + password: adminPassword + } + } + ) + + const amountToSend = BigInt(9_800_000) // $9.80 + const destination = (await setupPayment({ + plugin, + destinationAccount: `$mywallet.com` + })) as ResolvedPayment + const quote = await startQuote({ + plugin, + destination, + amountToSend, + sourceAsset: { + code: 'USD', + scale: 6 + } + }) + const { maxSourceAmount, minDeliveryAmount } = quote + + const receipt = await pay({ plugin, destination, quote }) + expect(receipt.amountSent).toBe(amountToSend) + expect(receipt.amountSent).toBeLessThanOrEqual(maxSourceAmount) + + // Check the balance + const { data } = await Axios({ + method: 'GET', + url: `http://${host}/accounts/receiver/balance`, + auth: { + username: 'admin', + password: adminPassword + } + }) + + const netBalance = BigInt(data.accountBalance.netBalance) + expect(receipt.amountDelivered).toEqual(netBalance) + expect(minDeliveryAmount).toBeLessThanOrEqual(netBalance) + + scope.done() + }, 60_000) + + afterAll(async () => { + await plugin?.disconnect() + + await connectorContainer?.stop() + await redisContainer?.stop() + + await network?.stop() + }, 10_000) +}) diff --git a/packages/pay/test/payment.spec.ts b/packages/pay/test/payment.spec.ts new file mode 100644 index 0000000000..5b0548c1bc --- /dev/null +++ b/packages/pay/test/payment.spec.ts @@ -0,0 +1,1897 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion, prefer-const */ +import { StreamServer } from '@interledger/stream-receiver' +import { describe, expect, it, jest } from '@jest/globals' +import { createApp } from 'ilp-connector' +import { + deserializeIlpPrepare, + IlpError, + isFulfill, + isIlpReply, + serializeIlpFulfill, + serializeIlpReject, + serializeIlpReply +} from 'ilp-packet' +import { + Connection, + createReceipt, + createServer, + DataAndMoneyStream +} from 'ilp-protocol-stream' +import { randomBytes } from 'crypto' +import { + ConnectionAssetDetailsFrame, + IlpPacketType, + Packet, + StreamReceiptFrame +} from 'ilp-protocol-stream/dist/src/packet' +import Long from 'long' +import { Writer } from 'oer-utils' +import reduct from 'reduct' +import { + closeConnection, + Int, + pay, + PaymentError, + PaymentType, + Ratio, + setupPayment, + startQuote +} from '../src' +import { SendStateType } from '../src/controllers' +import { Counter, SequenceController } from '../src/controllers/sequence' +import { RequestBuilder } from '../src/request' +import { PaymentSender } from '../src/senders/payment' +import { + generateEncryptionKey, + generateFulfillmentKey, + hmac, + sha256, + sleep +} from '../src/utils' +import { + createBalanceTracker, + createMaxPacketMiddleware, + createPlugin, + createRateMiddleware, + createSlippageMiddleware, + createStreamReceiver, + MirrorPlugin, + RateBackend +} from './helpers/plugin' +import { CustomBackend } from './helpers/rate-backend' + +const streamServer = new StreamServer({ + serverSecret: randomBytes(32), + serverAddress: 'private.larry' +}) +const streamReceiver = createStreamReceiver(streamServer) + +describe('fixed source payments', () => { + it('completes source amount payment with max packet amount', async () => { + const [alice1, alice2] = MirrorPlugin.createPair() + const [bob1, bob2] = MirrorPlugin.createPair() + + const prices = { + USD: 1, + XRP: 0.2041930991198592 + } + + // Override with rate backend for custom rates + let backend: CustomBackend + const deps = reduct( + (Constructor) => Constructor.name === 'RateBackend' && backend + ) + backend = new CustomBackend(deps) + backend.setPrices(prices) + + const app = createApp( + { + ilpAddress: 'test.larry', + spread: 0.014, // 1.4% slippage + accounts: { + alice: { + relation: 'child', + plugin: alice2, + assetCode: 'USD', + assetScale: 6, + maxPacketAmount: '5454' + }, + bob: { + relation: 'child', + plugin: bob1, + assetCode: 'XRP', + assetScale: 9 + } + } + }, + deps + ) + await app.listen() + + const streamServer = await createServer({ + plugin: bob2 + }) + + const connectionPromise = streamServer.acceptConnection() + streamServer.on('connection', (connection: Connection) => { + connection.on('stream', (stream: DataAndMoneyStream) => { + stream.setReceiveMax(Long.MAX_UNSIGNED_VALUE) + }) + }) + + const { sharedSecret, destinationAccount: destinationAddress } = + streamServer.generateAddressAndSecret() + + const amountToSend = BigInt(100427) + const destination = await setupPayment({ + destinationAddress, + sharedSecret, + plugin: alice1 + }) + const quote = await startQuote({ + plugin: alice1, + amountToSend, + sourceAsset: { + code: 'USD', + scale: 6 + }, + slippage: 0.015, + prices, + destination + }) + + expect(destination.destinationAsset).toEqual({ + code: 'XRP', + scale: 9 + }) + expect(quote.paymentType).toBe(PaymentType.FixedSend) + expect(destination.destinationAddress).toBe(destinationAddress) + expect(quote.maxSourceAmount).toBe(amountToSend) + + const receipt = await pay({ plugin: alice1, quote, destination }) + expect(receipt.error).toBeUndefined() + expect(receipt.amountSent).toBe(amountToSend) + + const serverConnection = await connectionPromise + expect(BigInt(serverConnection.totalReceived)).toBe(receipt.amountDelivered) + + await app.shutdown() + await streamServer.close() + }, 10_000) + + it('completes source amount payment if exchange rate is very close to minimum', async () => { + const [senderPlugin1, finalPacketPlugin] = MirrorPlugin.createPair() + + const senderPlugin2 = new MirrorPlugin() + senderPlugin2.mirror = senderPlugin1 + + const [receiverPlugin1, receiverPlugin2] = MirrorPlugin.createPair() + + const prices = { + BTC: 9814.04, + EUR: 1.13 + } + + // Override with rate backend for custom rates + let backend: CustomBackend + const deps = reduct( + (Constructor) => Constructor.name === 'RateBackend' && backend + ) + backend = new CustomBackend(deps) + backend.setPrices(prices) + + const app = createApp( + { + ilpAddress: 'private.larry', + spread: 0, + accounts: { + sender: { + relation: 'child', + assetCode: 'BTC', + assetScale: 8, + plugin: senderPlugin2, + maxPacketAmount: '1000' + }, + receiver: { + relation: 'child', + assetCode: 'EUR', + assetScale: 4, + plugin: receiverPlugin1 + } + } + }, + deps + ) + await app.listen() + + const streamServer = await createServer({ + plugin: receiverPlugin2 + }) + + streamServer.on('connection', (connection: Connection) => { + connection.on('stream', (stream: DataAndMoneyStream) => { + stream.setReceiveMax(Infinity) + }) + }) + + const { sharedSecret, destinationAccount: destinationAddress } = + streamServer.generateAddressAndSecret() + + // On the final packet, reject. This tests that the delivery shortfall + // correctly gets refunded, and after the packet is retried, + // the payment completes. + let failedOnFinalPacket = false + finalPacketPlugin.registerDataHandler(async (data) => { + const prepare = deserializeIlpPrepare(data) + if (prepare.amount === '2' && !failedOnFinalPacket) { + failedOnFinalPacket = true + return serializeIlpReject({ + code: IlpError.T02_PEER_BUSY, + message: '', + triggeredBy: '', + data: Buffer.alloc(0) + }) + } else { + return senderPlugin2.dataHandler(data) + } + }) + + const destination = await setupPayment({ + plugin: senderPlugin1, + destinationAddress, + sharedSecret + }) + const quote = await startQuote({ + plugin: senderPlugin1, + // Send 100,002 sats + // Max packet amount of 1000 means the final packet will try to send 2 units + // This rounds down to 0, but the delivery shortfall should ensure this is acceptable + amountToSend: 100_002, + sourceAsset: { + code: 'BTC', + scale: 8 + }, + slippage: 0.002, + prices, + destination + }) + expect(quote.maxSourceAmount).toBe(BigInt(100002)) + + const receipt = await pay({ plugin: senderPlugin1, quote, destination }) + expect(receipt.error).toBeUndefined() + expect(receipt.amountSent).toBe(BigInt(100002)) + expect(receipt.amountDelivered).toBeGreaterThanOrEqual( + quote.minDeliveryAmount + ) + + await app.shutdown() + await streamServer.close() + }) + + it('completes source amount payment with no latency', async () => { + const [alice1, alice2] = MirrorPlugin.createPair(0, 0) + const [bob1, bob2] = MirrorPlugin.createPair(0, 0) + + const app = createApp({ + ilpAddress: 'test.larry', + backend: 'one-to-one', + spread: 0, + accounts: { + alice: { + relation: 'child', + plugin: alice2, + assetCode: 'XYZ', + assetScale: 0, + maxPacketAmount: '1' + }, + bob: { + relation: 'child', + plugin: bob1, + assetCode: 'XYZ', + assetScale: 0 + } + } + }) + await app.listen() + + // Waiting before fulfilling packets tests whether the number of packets in-flight is capped + // and tests the greatest number of packets that are sent in-flight at once + let numberPacketsInFlight = 0 + let highestNumberPacketsInFlight = 0 + const streamServer = await createServer({ + plugin: bob2, + shouldFulfill: async () => { + numberPacketsInFlight++ + await sleep(1000) + highestNumberPacketsInFlight = Math.max( + highestNumberPacketsInFlight, + numberPacketsInFlight + ) + numberPacketsInFlight-- + } + }) + + streamServer.on('connection', (connection: Connection) => { + connection.on('stream', (stream: DataAndMoneyStream) => { + stream.setReceiveMax(Long.MAX_UNSIGNED_VALUE) + }) + }) + + const { sharedSecret, destinationAccount: destinationAddress } = + streamServer.generateAddressAndSecret() + + // Send 100 total packets + const destination = await setupPayment({ + destinationAddress, + sharedSecret, + plugin: alice1 + }) + const quote = await startQuote({ + plugin: alice1, + destination, + amountToSend: 100, + sourceAsset: { + code: 'XYZ', + scale: 0 + }, + slippage: 1, + prices: {} + }) + + const receipt = await pay({ plugin: alice1, destination, quote }) + + expect(receipt.error).toBeUndefined() + expect(highestNumberPacketsInFlight).toBe(20) + expect(receipt.amountSent).toBe(BigInt(100)) + + await app.shutdown() + await streamServer.close() + }, 10_000) + + it('completes source amount payment with no rate enforcement', async () => { + const [alice1, alice2] = MirrorPlugin.createPair() + const [bob1, bob2] = MirrorPlugin.createPair() + + const prices = { + ABC: 3.2, + XYZ: 1.5 + } + + // Override with rate backend for custom rates + let backend: CustomBackend + const deps = reduct( + (Constructor) => Constructor.name === 'RateBackend' && backend + ) + backend = new CustomBackend(deps) + backend.setPrices(prices) + + const app = createApp( + { + ilpAddress: 'test.larry', + spread: 0.014, // 1.4% slippage + accounts: { + alice: { + relation: 'child', + plugin: alice2, + assetCode: 'ABC', + assetScale: 0, + maxPacketAmount: '1000' + }, + bob: { + relation: 'child', + plugin: bob1, + assetCode: 'XYZ', + assetScale: 0 + } + } + }, + deps + ) + await app.listen() + + const streamServer = await createServer({ + plugin: bob2 + }) + + streamServer.on('connection', (connection: Connection) => { + connection.on('stream', (stream: DataAndMoneyStream) => { + stream.setReceiveMax(Long.MAX_UNSIGNED_VALUE) + }) + }) + + const { sharedSecret, destinationAccount: destinationAddress } = + streamServer.generateAddressAndSecret() + + const destination = await setupPayment({ + destinationAddress, + sharedSecret, + plugin: alice1 + }) + const amountToSend = BigInt(10_000) + const quote = await startQuote({ + plugin: alice1, + destination, + amountToSend, + sourceAsset: { + code: 'ABC', + scale: 0 + }, + slippage: 1, // Disables rate enforcement + prices + }) + const { minExchangeRate } = quote + expect(minExchangeRate).toEqual(Ratio.of(Int.ZERO, Int.ONE)) + + const receipt = await pay({ plugin: alice1, destination, quote }) + expect(receipt.error).toBeUndefined() + expect(receipt.amountSent).toBe(amountToSend) + expect(receipt.amountDelivered).toBeGreaterThan(BigInt(0)) + + await app.shutdown() + await streamServer.close() + }) +}) + +describe('fixed delivery payments', () => { + it('delivers fixed destination amount with max packet amount', async () => { + // Internal rate: 0.1 + // Real rate after rounding error: 0.096875, or 320/31 + const balanceTracker = createBalanceTracker() + const plugin = createPlugin( + createMaxPacketMiddleware(Int.from(320)!), // Rounding error: 1/320 => 0.003125 + createRateMiddleware( + new RateBackend( + { code: 'USD', scale: 6 }, + { code: 'USD', scale: 5 }, + {}, + 0.01 + ) + ), + balanceTracker.middleware, + streamReceiver + ) + + // Setup a three packet payment: 320 delivers 31, 320 delivers 31, 11 delivers 1. + // This forces the final packet to be 1 unit, which tests the delivery deficit + // logic since that packet wouldn't otherwise meet the minimum exchange rate. + + const amountToDeliver = Int.from(63)! + const { sharedSecret, ilpAddress: destinationAddress } = + streamServer.generateCredentials() + + const destination = await setupPayment({ + destinationAddress, + destinationAsset: { + code: 'USD', + scale: 5 + }, + sharedSecret, + plugin + }) + const quote = await startQuote({ + plugin, + destination, + amountToDeliver, + sourceAsset: { + code: 'USD', + scale: 6 + }, + slippage: 0.03125 + }) + expect(quote.paymentType).toBe(PaymentType.FixedDelivery) + + const receipt = await pay({ + plugin, + destination, + quote, + // Tests progress handler logic + progressHandler: (receipt) => { + expect( + receipt.sourceAmountInFlight + receipt.amountSent + ).toBeLessThanOrEqual(quote.maxSourceAmount) + expect(balanceTracker.totalReceived().value).toBeGreaterThanOrEqual( + receipt.amountDelivered + ) + } + }) + + expect(receipt.error).toBeUndefined() + + expect(balanceTracker.totalReceived()).toEqual(amountToDeliver) + expect(receipt.amountDelivered).toBe(amountToDeliver.value) + + // Ensures this tests the edge case of the overdelivery logic + // so the amount sent is exactly the maximum value quoted + expect(receipt.amountSent).toEqual(quote.maxSourceAmount) + }, 10_000) + + it('delivers single-shot fixed destination amount with single-shot', async () => { + const plugin = createPlugin( + createRateMiddleware( + new RateBackend( + { code: 'USD', scale: 5 }, + { code: 'USD', scale: 6 }, + {}, + 0.031249 + ) + ), + createStreamReceiver(streamServer) + ) + + const { sharedSecret, ilpAddress: destinationAddress } = + streamServer.generateCredentials() + + const amountToDeliver = BigInt(630) + const destination = await setupPayment({ + destinationAddress, + sharedSecret, + plugin, + destinationAsset: { + code: 'USD', + scale: 6 + } + }) + const quote = await startQuote({ + plugin, + destination, + amountToDeliver, + sourceAsset: { + code: 'USD', + scale: 5 + }, + slippage: 0.03125 + }) + + const receipt = await pay({ plugin, quote, destination }) + expect(receipt.error).toBeUndefined() + expect(receipt.amountSent).toEqual(quote.maxSourceAmount) + expect(receipt.amountDelivered).toBeGreaterThanOrEqual(amountToDeliver) + + // Allowed delivery amount doesn't allow *too much* to be delivered + const maxDeliveryAmount = + quote.minDeliveryAmount + quote.minExchangeRate.ceil() + expect(receipt.amountDelivered).toBeLessThanOrEqual(maxDeliveryAmount) + }) + + it('delivers fixed destination amount with exchange rate greater than 1', async () => { + const prices = { + USD: 1, + EUR: 1.0805787579827757, + BTC: 9290.22557286273, + ETH: 208.46218430418685, + XRP: 0.2199704769864391, + JPY: 0.00942729201037, + GBP: 1.2344993179391268 + } + + // More complex topology with multiple conversions and sources of rounding error: + // BTC 8 -> USD 6 -> XRP 9 + const balanceTracker = createBalanceTracker() + const plugin = createPlugin( + // Tests multiple max packet amounts will get reduced + createMaxPacketMiddleware(Int.from(2_000_000)!), // 0.02 BTC (larger than $0.01) + createRateMiddleware( + new RateBackend( + { code: 'BTC', scale: 8 }, + { code: 'USD', scale: 6 }, + prices, + 0.005 + ) + ), + + // Tests correct max packet amount computation in remote asset + createMaxPacketMiddleware(Int.from(10_000)!), // $0.01 + createRateMiddleware( + new RateBackend( + { code: 'USD', scale: 6 }, + { code: 'XRP', scale: 9 }, + prices, + 0.0031 + ) + ), + + balanceTracker.middleware, + streamReceiver + ) + + const { sharedSecret, ilpAddress: destinationAddress } = + streamServer.generateCredentials() + + // (1 - 0.031) * (1 - 0.005) => 0.9919155 + + // Connector spread: 0.80845% + // Sender accepts up to: 0.85% + + const amountToDeliver = BigInt(10_000_000_000)! // 10 XRP, ~$2 at given prices + const destination = await setupPayment({ + destinationAddress, + destinationAsset: { + code: 'XRP', + scale: 9 + }, + sharedSecret, + plugin + }) + const quote = await startQuote({ + plugin, + destination, + amountToDeliver, + sourceAsset: { + code: 'BTC', + scale: 8 + }, + slippage: 0.0085, + prices + }) + const { maxSourceAmount, minExchangeRate } = quote + + const receipt = await pay({ plugin, destination, quote }) + + expect(receipt.amountDelivered).toBe(balanceTracker.totalReceived().value) + expect(receipt.amountDelivered).toBeGreaterThanOrEqual(amountToDeliver) + + // Ensure over-delivery is minimized to the equivalent of a single source unit, 1 satoshi, + // converted into destination units, drops of XRP: + const maxDeliveryAmount = amountToDeliver + minExchangeRate.ceil() + expect(receipt.amountDelivered).toBeLessThanOrEqual(maxDeliveryAmount) + expect(receipt.amountSent).toBeLessThanOrEqual(maxSourceAmount) + }, 10_000) + + it('fails if receive max is incompatible on fixed delivery payment', async () => { + const [senderPlugin1, senderPlugin2] = MirrorPlugin.createPair() + const [receiverPlugin1, receiverPlugin2] = MirrorPlugin.createPair() + + const app = createApp({ + ilpAddress: 'private.larry', + backend: 'one-to-one', + spread: 0, + accounts: { + sender: { + relation: 'child', + assetCode: 'ABC', + assetScale: 4, + plugin: senderPlugin2 + }, + receiver: { + relation: 'child', + assetCode: 'ABC', + assetScale: 4, + plugin: receiverPlugin1 + } + } + }) + await app.listen() + + const streamServer = await createServer({ + plugin: receiverPlugin2 + }) + + // Stream can receive up to 98, but we want to deliver 100 + streamServer.on('connection', (connection: Connection) => { + connection.on('stream', (stream: DataAndMoneyStream) => { + stream.setReceiveMax(98) + }) + }) + + const { sharedSecret, destinationAccount: destinationAddress } = + streamServer.generateAddressAndSecret() + + const destination = await setupPayment({ + plugin: senderPlugin1, + destinationAddress, + sharedSecret + }) + const quote = await startQuote({ + plugin: senderPlugin1, + destination, + amountToDeliver: Int.from(100_0000), + sourceAsset: { + code: 'ABC', + scale: 4 + } + }) + + // Note: ilp-protocol-stream only returns `StreamMaxMoney` if the packet sent money, so it can't error during the quoting flow! + const receipt = await pay({ plugin: senderPlugin1, destination, quote }) + expect(receipt.error).toBe(PaymentError.IncompatibleReceiveMax) + + await app.shutdown() + await streamServer.close() + }) + + it('fails if minimum exchange rate is 0 and cannot enforce delivery', async () => { + const [senderPlugin1, senderPlugin2] = MirrorPlugin.createPair() + const [receiverPlugin1, receiverPlugin2] = MirrorPlugin.createPair() + + const app = createApp({ + ilpAddress: 'private.larry', + defaultRoute: 'receiver', + backend: 'one-to-one', + spread: 0, + accounts: { + sender: { + relation: 'child', + assetCode: 'ABC', + assetScale: 4, + plugin: senderPlugin2 + }, + receiver: { + relation: 'child', + assetCode: 'XYZ', + assetScale: 2, + plugin: receiverPlugin1 + } + } + }) + await app.listen() + + const streamServer = await createServer({ + plugin: receiverPlugin2 + }) + + streamServer.on('connection', (connection: Connection) => { + connection.on('stream', (stream: DataAndMoneyStream) => { + stream.setReceiveMax(Infinity) + }) + }) + + const { sharedSecret, destinationAccount: destinationAddress } = + streamServer.generateAddressAndSecret() + + const destination = await setupPayment({ + plugin: senderPlugin1, + destinationAddress, + sharedSecret + }) + await expect( + startQuote({ + plugin: senderPlugin1, + destination, + amountToDeliver: Int.from(100_0000), + sourceAsset: { + code: 'ABC', + scale: 4 + }, + slippage: 1, + prices: { + ABC: 1, + XYZ: 1 + } + }) + ).rejects.toBe(PaymentError.UnenforceableDelivery) + + await app.shutdown() + await streamServer.close() + }) + + it('accounts for fulfilled packets even if data is corrupted', async () => { + const plugin = createPlugin( + createMaxPacketMiddleware(Int.from(20)!), + createSlippageMiddleware(0.01), + async (prepare, next) => { + // Strip data from Fulfills, track total received + const reply = await next(prepare) + if (isFulfill(reply)) { + return { ...reply, data: randomBytes(200) } + } else { + return reply + } + }, + streamReceiver + ) + + const asset = { + code: 'ABC', + scale: 0 + } + + const { sharedSecret, ilpAddress: destinationAddress } = + streamServer.generateCredentials() + + const destination = await setupPayment({ + plugin, + destinationAddress, + destinationAsset: asset, + sharedSecret + }) + const quote = await startQuote({ + plugin, + destination, + // Amount much larger than max packet, so test will fail unless sender fails fast + amountToDeliver: Int.from(1000000), + sourceAsset: asset, + slippage: 0.1 + }) + + const receipt = await pay({ plugin, destination, quote }) + + expect(receipt.error).toBe(PaymentError.ReceiverProtocolViolation) + expect(receipt.amountDelivered).toBe(BigInt(18)) // 20 unit packet, 1% slippage, minus 1 source unit + expect(receipt.amountSent).toBeLessThanOrEqual(quote.maxSourceAmount) + }, 10_000) + + it('accounts for delivered amounts if the recipient claims to receive less than minimum', async () => { + const [senderPlugin1, senderPlugin2] = MirrorPlugin.createPair() + const [receiverPlugin1, receiverPlugin2] = MirrorPlugin.createPair() + + const app = createApp({ + ilpAddress: 'private.larry', + backend: 'one-to-one', + spread: 0, + accounts: { + sender: { + relation: 'child', + assetCode: 'ABC', + assetScale: 0, + plugin: senderPlugin2, + maxPacketAmount: '20' + }, + receiver: { + relation: 'child', + assetCode: 'ABC', + assetScale: 0, + plugin: receiverPlugin1 + } + } + }) + await app.listen() + + const destinationAddress = 'private.larry.receiver' + const sharedSecret = randomBytes(32) + + const encryptionKey = generateEncryptionKey(sharedSecret) + const fulfillmentKey = generateFulfillmentKey(sharedSecret) + + // STREAM "receiver" that fulfills packets + let totalReceived = BigInt(0) + receiverPlugin2.registerDataHandler(async (data) => { + const prepare = deserializeIlpPrepare(data) + + const fulfillment = hmac(fulfillmentKey, prepare.data) + const isFulfillable = prepare.executionCondition.equals( + sha256(fulfillment) + ) + + const streamPacket = await Packet.decryptAndDeserialize( + encryptionKey, + prepare.data + ) + + if (isFulfillable) { + // On fulfillable packets, fulfill, but lie and say we only received 1 unit + totalReceived += Int.from(streamPacket.prepareAmount)!.value + const streamReply = new Packet( + streamPacket.sequence, + IlpPacketType.Fulfill, + 1 + ) + return serializeIlpFulfill({ + fulfillment, + data: await streamReply.serializeAndEncrypt(encryptionKey) + }) + } else { + // On test packets, reject and ACK as normal so the quote succeeds + const reject = new Packet( + streamPacket.sequence, + IlpPacketType.Reject, + prepare.amount, + [new ConnectionAssetDetailsFrame('ABC', 0)] + ) + return serializeIlpReject({ + code: IlpError.F99_APPLICATION_ERROR, + message: '', + triggeredBy: '', + data: await reject.serializeAndEncrypt(encryptionKey) + }) + } + }) + + const destination = await setupPayment({ + plugin: senderPlugin1, + destinationAddress, + sharedSecret + }) + const quote = await startQuote({ + plugin: senderPlugin1, + destination, + // Amount much larger than max packet, so test will fail unless sender fails fast + amountToDeliver: Int.from(100000), + sourceAsset: { + code: 'ABC', + scale: 0 + }, + slippage: 0.2 + }) + + const receipt = await pay({ plugin: senderPlugin1, destination, quote }) + expect(receipt.error).toBe(PaymentError.ReceiverProtocolViolation) + expect(receipt.amountDelivered).toEqual(totalReceived) + expect(receipt.amountSent).toBeLessThanOrEqual(quote.maxSourceAmount) + + await app.shutdown() + }) + + it('fails if the exchange rate drops to 0 during payment', async () => { + const [senderPlugin1, senderPlugin2] = MirrorPlugin.createPair() + const [receiverPlugin1, receiverPlugin2] = MirrorPlugin.createPair() + + const prices = { + USD: 1, + EUR: 1.13 + } + + // Override with rate backend for custom rates + let backend: CustomBackend + const deps = reduct( + (Constructor) => Constructor.name === 'RateBackend' && backend + ) + backend = new CustomBackend(deps) + backend.setPrices(prices) + + const app = createApp( + { + ilpAddress: 'test.larry', + spread: 0.01, // 1% spread + accounts: { + alice: { + relation: 'child', + plugin: senderPlugin2, + assetCode: 'USD', + assetScale: 4, + maxPacketAmount: '10000' // $1 + }, + bob: { + relation: 'child', + plugin: receiverPlugin1, + assetCode: 'EUR', + assetScale: 4 + } + } + }, + deps + ) + await app.listen() + + const streamServer = await createServer({ + plugin: receiverPlugin2 + }) + + const connectionPromise = streamServer.acceptConnection() + streamServer.on('connection', (connection: Connection) => { + connection.on('stream', (stream: DataAndMoneyStream) => { + stream.setReceiveMax(Infinity) + }) + }) + + const { sharedSecret, destinationAccount: destinationAddress } = + streamServer.generateAddressAndSecret() + + const destination = await setupPayment({ + destinationAddress, + sharedSecret, + plugin: senderPlugin1 + }) + const quote = await startQuote({ + plugin: senderPlugin1, + destination, + amountToDeliver: Int.from(10_0000), // 10 EUR + sourceAsset: { + code: 'USD', + scale: 4 + }, + slippage: 0.015, // 1.5% slippage allowed + prices + }) + + const serverConnection = await connectionPromise + + // Change exchange rate to 0 before the payment begins + backend.setSpread(1) + + const receipt = await pay({ plugin: senderPlugin1, destination, quote }) + expect(receipt.error).toBe(PaymentError.InsufficientExchangeRate) + expect(receipt.amountSent).toBe(BigInt(0)) + expect(receipt.amountDelivered).toBe(BigInt(0)) + expect(serverConnection.totalReceived).toBe('0') + + await app.shutdown() + await streamServer.close() + }) + + it('fails if the exchange rate drops below the minimum during the payment', async () => { + const [senderPlugin1, senderPlugin2] = MirrorPlugin.createPair(200, 200) + const [receiverPlugin1, receiverPlugin2] = MirrorPlugin.createPair(200, 200) + + const prices = { + USD: 1, + EUR: 1.13 + } + + // Override with rate backend for custom rates + let backend: CustomBackend + const deps = reduct( + (Constructor) => Constructor.name === 'RateBackend' && backend + ) + backend = new CustomBackend(deps) + backend.setPrices(prices) + + const app = createApp( + { + ilpAddress: 'test.larry', + spread: 0.01, // 1% spread + accounts: { + alice: { + relation: 'child', + plugin: senderPlugin2, + assetCode: 'USD', + assetScale: 4, + maxPacketAmount: '10000' // $1 + }, + bob: { + relation: 'child', + plugin: receiverPlugin1, + assetCode: 'EUR', + assetScale: 4 + } + } + }, + deps + ) + await app.listen() + + const streamServer = await createServer({ + plugin: receiverPlugin2 + }) + + const connectionPromise = streamServer.acceptConnection() + streamServer.on('connection', (connection: Connection) => { + connection.on('stream', (stream: DataAndMoneyStream) => { + stream.setReceiveMax(Infinity) + + stream.on('money', () => { + // Change exchange rate so it's just below the minimum + // Only the first packets that have already been routed will be delivered + backend.setSpread(0.016) + }) + }) + }) + + const { sharedSecret, destinationAccount: destinationAddress } = + streamServer.generateAddressAndSecret() + + const destination = await setupPayment({ + destinationAddress, + sharedSecret, + plugin: senderPlugin1 + }) + const quote = await startQuote({ + plugin: senderPlugin1, + destination, + amountToDeliver: Int.from(100_000), // 10 EUR + sourceAsset: { + code: 'USD', + scale: 4 + }, + slippage: 0.015, // 1.5% slippage allowed + prices + }) + + const serverConnection = await connectionPromise + + const receipt = await pay({ plugin: senderPlugin1, destination, quote }) + expect(receipt.error).toBe(PaymentError.InsufficientExchangeRate) + expect(receipt.amountDelivered).toBeLessThan(BigInt(100_000)) + expect(receipt.amountDelivered).toBe(BigInt(serverConnection.totalReceived)) + expect(receipt.amountSent).toBeLessThanOrEqual(quote.maxSourceAmount) + + await app.shutdown() + await streamServer.close() + }) +}) + +describe('payment execution', () => { + it('fails on final Reject errors', async () => { + const [senderPlugin, receiverPlugin] = MirrorPlugin.createPair() + + const asset = { + code: 'ABC', + scale: 0 + } + const app = createApp({ + ilpAddress: 'private.larry', + backend: 'one-to-one', + spread: 0, + accounts: { + default: { + relation: 'child', + assetCode: asset.code, + assetScale: asset.scale, + plugin: receiverPlugin + } + } + }) + await app.listen() + + const destination = await setupPayment({ + plugin: senderPlugin, + destinationAddress: 'private.unknown', // Non-routable address + destinationAsset: asset, + sharedSecret: Buffer.alloc(32) + }) + await expect( + startQuote({ + plugin: senderPlugin, + destination, + amountToSend: 12_345, + sourceAsset: asset + }) + ).rejects.toBe(PaymentError.ConnectorError) + + await app.shutdown() + }) + + it('handles invalid F08 errors', async () => { + const [senderPlugin1, senderPlugin2] = MirrorPlugin.createPair() + const [receiverPlugin1, receiverPlugin2] = MirrorPlugin.createPair() + + const streamServerPlugin = new MirrorPlugin() + streamServerPlugin.mirror = receiverPlugin1 + + const app = createApp({ + ilpAddress: 'test.larry', + backend: 'one-to-one', + spread: 0, + accounts: { + alice: { + relation: 'child', + plugin: senderPlugin2, + assetCode: 'USD', + assetScale: 2, + maxPacketAmount: '10' // $0.10 + }, + bob: { + relation: 'child', + plugin: receiverPlugin1, + assetCode: 'USD', + assetScale: 2 + } + } + }) + await app.listen() + + // On the first & second packets, reply with an invalid F08: amount received <= maximum. + // This checks against a potential divide-by-0 error + let sentFirstInvalidReply = false + let sentSecondInvalidReply = false + receiverPlugin2.registerDataHandler(async (data) => { + const prepare = deserializeIlpPrepare(data) + if (+prepare.amount >= 1) { + if (!sentFirstInvalidReply) { + sentFirstInvalidReply = true + + const writer = new Writer(16) + writer.writeUInt64(0) // Amount received + writer.writeUInt64(1) // Maximum + + return serializeIlpReject({ + code: IlpError.F08_AMOUNT_TOO_LARGE, + message: '', + triggeredBy: '', + data: writer.getBuffer() + }) + } else if (!sentSecondInvalidReply) { + sentSecondInvalidReply = true + + const writer = new Writer(16) + writer.writeUInt64(1) // Amount received + writer.writeUInt64(1) // Maximum + + return serializeIlpReject({ + code: IlpError.F08_AMOUNT_TOO_LARGE, + message: '', + triggeredBy: '', + data: writer.getBuffer() + }) + } + } + + // Otherwise, the STREAM server should handle the packet + return streamServerPlugin.dataHandler(data) + }) + + const streamServer = await createServer({ + plugin: streamServerPlugin + }) + + streamServer.on('connection', (connection: Connection) => { + connection.on('stream', (stream: DataAndMoneyStream) => { + stream.setReceiveMax(Infinity) + + stream.on('money', (amount: string) => { + // Test that it ignores the invalid F08, and uses + // the max packet amount of 10 + expect(amount).toBe('10') + }) + }) + }) + + const { sharedSecret, destinationAccount: destinationAddress } = + streamServer.generateAddressAndSecret() + + const destination = await setupPayment({ + destinationAddress, + sharedSecret, + plugin: senderPlugin1 + }) + const quote = await startQuote({ + plugin: senderPlugin1, + destination, + amountToSend: 100, + sourceAsset: { + code: 'USD', + scale: 2 + }, + slippage: 1, + prices: {} + }) + const receipt = await pay({ plugin: senderPlugin1, destination, quote }) + expect(receipt.error).toBeUndefined() + expect(receipt.amountSent).toBe(BigInt(100)) + expect(receipt.amountDelivered).toBe(BigInt(100)) + + await app.shutdown() + await streamServer.close() + }) + + it('retries on temporary errors', async () => { + const [senderPlugin1, senderPlugin2] = MirrorPlugin.createPair() + const [receiverPlugin1, receiverPlugin2] = MirrorPlugin.createPair() + + const app = createApp({ + ilpAddress: 'private.larry', + backend: 'one-to-one', + spread: 0, + accounts: { + sender: { + relation: 'child', + assetCode: 'ABC', + assetScale: 0, + plugin: senderPlugin2, + // Limit to 2 packets / 200ms + // should ensure a T05 error is encountered + rateLimit: { + capacity: 2, + refillCount: 2, + refillPeriod: 200 + }, + maxPacketAmount: '1' + }, + receiver: { + relation: 'child', + assetCode: 'ABC', + assetScale: 0, + plugin: receiverPlugin1 + } + } + }) + await app.listen() + + const streamServer = await createServer({ + plugin: receiverPlugin2 + }) + + streamServer.on('connection', (connection: Connection) => { + connection.on('stream', (stream: DataAndMoneyStream) => { + stream.setReceiveMax(Infinity) + }) + }) + + const { sharedSecret, destinationAccount: destinationAddress } = + streamServer.generateAddressAndSecret() + + // 20 units / 1 max packet amount => at least 20 packets + const amountToSend = 20 + const destination = await setupPayment({ + plugin: senderPlugin1, + sharedSecret, + destinationAddress + }) + const quote = await startQuote({ + plugin: senderPlugin1, + destination, + amountToSend, + sourceAsset: { + code: 'ABC', + scale: 0 + }, + slippage: 1, + prices: {} + }) + + const receipt = await pay({ plugin: senderPlugin1, destination, quote }) + expect(receipt.error).toBeUndefined() + expect(receipt.amountSent).toBe(BigInt(amountToSend)) + + await app.shutdown() + await streamServer.close() + }, 20_000) + + it('fails if no packets are fulfilled before idle timeout', async () => { + const [senderPlugin1, senderPlugin2] = MirrorPlugin.createPair() + const [receiverPlugin1, receiverPlugin2] = MirrorPlugin.createPair() + + const app = createApp({ + ilpAddress: 'private.larry', + backend: 'one-to-one', + spread: 0, + accounts: { + sender: { + relation: 'child', + assetCode: 'ABC', + assetScale: 0, + plugin: senderPlugin2 + }, + receiver: { + relation: 'child', + assetCode: 'ABC', + assetScale: 0, + plugin: receiverPlugin1 + } + } + }) + await app.listen() + + const streamServer = await createServer({ + plugin: receiverPlugin2, + // Reject all packets with an F99 reject -- block for 1s so the sender does't spam packets + shouldFulfill: () => new Promise((_, reject) => setTimeout(reject, 1000)) + }) + + streamServer.on('connection', (connection: Connection) => { + connection.on('stream', (stream: DataAndMoneyStream) => { + stream.setReceiveMax(Infinity) + }) + }) + + const { sharedSecret, destinationAccount: destinationAddress } = + streamServer.generateAddressAndSecret() + + const destination = await setupPayment({ + plugin: senderPlugin1, + sharedSecret, + destinationAddress + }) + const quote = await startQuote({ + plugin: senderPlugin1, + destination, + amountToSend: 10, + sourceAsset: { + code: 'ABC', + scale: 0 + } + }) + + const receipt = await pay({ plugin: senderPlugin1, destination, quote }) + expect(receipt.error).toBe(PaymentError.IdleTimeout) + expect(receipt.amountSent).toBe(BigInt(0)) + + await app.shutdown() + await streamServer.close() + }, 15_000) + + it('ends payment if the sequence number exceeds encryption safety', async () => { + const controller = new SequenceController(Counter.from(2 ** 31)!) + const { value } = controller.buildRequest(new RequestBuilder()) as { + type: SendStateType.Error + value: PaymentError + } + expect(value).toBe(PaymentError.MaxSafeEncryptionLimit) + }) + + it('ends payment if receiver closes the stream', async () => { + const [alice1, alice2] = MirrorPlugin.createPair() + const [bob1, bob2] = MirrorPlugin.createPair() + + const app = createApp({ + ilpAddress: 'test.larry', + backend: 'one-to-one', + spread: 0, + accounts: { + alice: { + relation: 'child', + plugin: alice2, + assetCode: 'USD', + assetScale: 2, + maxPacketAmount: '10' // $0.10 + }, + bob: { + relation: 'child', + plugin: bob1, + assetCode: 'USD', + assetScale: 2 + } + } + }) + await app.listen() + + const streamServer = await createServer({ + plugin: bob2 + }) + + streamServer.on('connection', (connection: Connection) => { + connection.on('stream', (stream: DataAndMoneyStream) => { + stream.setReceiveMax(Long.MAX_UNSIGNED_VALUE) + + stream.on('money', () => { + // End the stream after 20 units are received + if (+stream.totalReceived >= 20) { + stream.end() + } + }) + }) + }) + + const { sharedSecret, destinationAccount: destinationAddress } = + streamServer.generateAddressAndSecret() + + // Since we're sending $100,000, test will fail due to timeout + // if the connection isn't closed quickly + + const destination = await setupPayment({ + destinationAddress, + sharedSecret, + plugin: alice1 + }) + const quote = await startQuote({ + plugin: alice1, + destination, + amountToSend: 1000000000, + sourceAsset: { + code: 'USD', + scale: 2 + }, + slippage: 1, + prices: {} + }) + const receipt = await pay({ plugin: alice1, destination, quote }) + + expect(receipt.error).toBe(PaymentError.ClosedByReceiver) + expect(receipt.amountSent).toBe(BigInt(20)) // Only $0.20 was sent & received + expect(receipt.amountDelivered).toBe(BigInt(20)) + + await app.shutdown() + await streamServer.close() + }) + + it('ends payment if receiver closes the connection', async () => { + const plugin = createPlugin(async (prepare) => { + const moneyOrReply = streamServer.createReply(prepare) + return isIlpReply(moneyOrReply) + ? moneyOrReply + : moneyOrReply.finalDecline() + }) + + const { sharedSecret, ilpAddress: destinationAddress } = + streamServer.generateCredentials() + const destination = await setupPayment({ + destinationAsset: { + code: 'ABC', + scale: 0 + }, + destinationAddress, + sharedSecret, + plugin + }) + const quote = await startQuote({ + plugin, + destination, + amountToSend: 1, + slippage: 1 + }) + + const { error } = await pay({ plugin, destination, quote }) + expect(error).toBe(PaymentError.ClosedByReceiver) + }) + + it('works with 100% slippage', async () => { + const asset = { + code: 'ABC', + scale: 0 + } + + const plugin = createPlugin( + createRateMiddleware(new RateBackend(asset, asset, {}, 1)), + streamReceiver + ) + + const { sharedSecret, ilpAddress: destinationAddress } = + streamServer.generateCredentials() + const destination = await setupPayment({ + destinationAsset: asset, + destinationAddress, + sharedSecret, + plugin + }) + + const amountToSend = 1_000_000_000 + const quote = await startQuote({ + plugin, + destination, + amountToSend, + slippage: 1 // 100% + }) + expect(quote.minDeliveryAmount).toEqual(BigInt(0)) + expect(quote.minExchangeRate).toEqual(Ratio.of(Int.ZERO, Int.ONE)) + expect(quote.lowEstimatedExchangeRate.a).toEqual(Int.ZERO) + + const { amountSent, amountDelivered } = await pay({ + plugin, + destination, + quote + }) + expect(amountSent).toBe(BigInt(amountToSend)) + expect(amountDelivered).toBe(BigInt(0)) + }) + + it('rejects invalid quotes', async () => { + const plugin = createPlugin(streamReceiver) + const { sharedSecret, ilpAddress: destinationAddress } = + streamServer.generateCredentials() + const destination = await setupPayment({ + destinationAddress, + sharedSecret, + plugin, + destinationAsset: { + code: 'USD', + scale: 5 + } + }) + const quote = { + paymentType: PaymentType.FixedSend, + maxSourceAmount: BigInt(123), + minDeliveryAmount: BigInt(123), + maxPacketAmount: BigInt(123), + lowEstimatedExchangeRate: Ratio.of(Int.ZERO, Int.ONE), + highEstimatedExchangeRate: Ratio.of(Int.ONE, Int.ONE), + minExchangeRate: Ratio.of(Int.ZERO, Int.ONE) + } + + await expect( + pay({ + plugin, + destination, + quote: { + ...quote, + maxSourceAmount: BigInt(0) + } + }) + ).rejects.toBe(PaymentError.InvalidQuote) + + await expect( + pay({ + plugin, + destination, + quote: { + ...quote, + minDeliveryAmount: BigInt(-1) + } + }) + ).rejects.toBe(PaymentError.InvalidQuote) + + await expect( + pay({ + plugin, + destination, + quote: { + ...quote, + maxPacketAmount: BigInt(0) + } + }) + ).rejects.toBe(PaymentError.InvalidQuote) + }) +}) + +describe('stream receipts', () => { + it('reports receipts from ilp-protocol-stream server', async () => { + const [alice1, alice2] = MirrorPlugin.createPair() + const [bob1, bob2] = MirrorPlugin.createPair() + + const app = createApp({ + ilpAddress: 'test.larry', + backend: 'one-to-one', + spread: 0.05, // 5% + accounts: { + alice: { + relation: 'child', + plugin: alice2, + assetCode: 'ABC', + assetScale: 0, + maxPacketAmount: '1000' + }, + bob: { + relation: 'child', + plugin: bob1, + assetCode: 'ABC', + assetScale: 0 + } + } + }) + await app.listen() + + const streamServer = await createServer({ + plugin: bob2 + }) + + const connectionPromise = streamServer.acceptConnection() + streamServer.on('connection', (connection: Connection) => { + connection.on('stream', (stream: DataAndMoneyStream) => { + stream.setReceiveMax(Long.MAX_UNSIGNED_VALUE) + }) + }) + + const receiptNonce = randomBytes(16) + const receiptSecret = randomBytes(32) + const { sharedSecret, destinationAccount: destinationAddress } = + streamServer.generateAddressAndSecret({ + receiptNonce, + receiptSecret + }) + + const amountToSend = BigInt(10_000) // 10,000 units, 1,000 max packet => ~10 packets + const destination = await setupPayment({ + destinationAddress, + sharedSecret, + plugin: alice1 + }) + const quote = await startQuote({ + plugin: alice1, + destination, + amountToSend, + sourceAsset: { + code: 'ABC', + scale: 0 + }, + slippage: 0.1 // 10% + }) + + const receipt = await pay({ plugin: alice1, destination, quote }) + + const serverConnection = await connectionPromise + const totalReceived = BigInt(serverConnection.totalReceived) + + expect(receipt.error).toBeUndefined() + expect(receipt.amountSent).toBe(amountToSend) + expect(receipt.amountDelivered).toBe(totalReceived) + expect(receipt.streamReceipt).toEqual( + createReceipt({ + nonce: receiptNonce, + secret: receiptSecret, + streamId: PaymentSender.DEFAULT_STREAM_ID, + totalReceived: receipt.amountDelivered.toString() + }) + ) + + await app.shutdown() + await streamServer.close() + }) + + it('reports receipts received out of order', async () => { + const [senderPlugin, receiverPlugin] = MirrorPlugin.createPair(0, 0) + + const server = new StreamServer({ + serverSecret: randomBytes(32), + serverAddress: 'private.larry' + }) + + const receiptNonce = randomBytes(16) + const receiptSecret = randomBytes(32) + const { sharedSecret, ilpAddress: destinationAddress } = + server.generateCredentials({ + receiptSetup: { + nonce: receiptNonce, + secret: receiptSecret + } + }) + + let signedFirstReceipt = false + receiverPlugin.registerDataHandler(async (data) => { + const prepare = deserializeIlpPrepare(data) + + // 10 unit max packet size -> 2 packets to complete payment + if (+prepare.amount > 10) { + return serializeIlpReject({ + code: IlpError.F08_AMOUNT_TOO_LARGE, + message: '', + triggeredBy: '', + data: Buffer.alloc(0) + }) + } + + const moneyOrReply = server.createReply(prepare) + if (isIlpReply(moneyOrReply)) { + return serializeIlpReply(moneyOrReply) + } + + // Ensure the first receipt gets processed before signing the second one + if (signedFirstReceipt) { + await sleep(500) + } + + // First, sign a STREAM receipt for 10 units, then a receipt for 5 units + moneyOrReply.setTotalReceived(!signedFirstReceipt ? 10 : 5) + signedFirstReceipt = true + return serializeIlpReply(moneyOrReply.accept()) + }) + + const destination = await setupPayment({ + plugin: senderPlugin, + sharedSecret, + destinationAddress, + destinationAsset: { + code: 'ABC', + scale: 4 + } + }) + const quote = await startQuote({ + plugin: senderPlugin, + destination, + amountToDeliver: 20, + sourceAsset: { + code: 'ABC', + scale: 4 + }, + slippage: 0.5 + }) + + const { amountDelivered, streamReceipt } = await pay({ + plugin: senderPlugin, + destination, + quote + }) + expect(amountDelivered).toBe(BigInt(20)) + expect(streamReceipt).toEqual( + createReceipt({ + nonce: receiptNonce, + secret: receiptSecret, + streamId: PaymentSender.DEFAULT_STREAM_ID, + totalReceived: 10 // Greatest of receipts for 10 and 5 + }) + ) + }) + + it('discards invalid receipts', async () => { + const [senderPlugin, receiverPlugin] = MirrorPlugin.createPair() + + const sharedSecret = randomBytes(32) + const encryptionKey = generateEncryptionKey(sharedSecret) + const fulfillmentKey = generateFulfillmentKey(sharedSecret) + + // Create simple STREAM receiver that acks test packets, + // but replies with conflicting asset details + receiverPlugin.registerDataHandler(async (requestData) => { + const prepare = deserializeIlpPrepare(requestData) + + const fulfillment = hmac(fulfillmentKey, prepare.data) + const isFulfillable = prepare.executionCondition.equals( + sha256(fulfillment) + ) + + const streamRequest = await Packet.decryptAndDeserialize( + encryptionKey, + prepare.data + ) + + // Ack incoming packet. Include invalid receipt frame in reply + const streamReply = new Packet( + streamRequest.sequence, + isFulfillable ? IlpPacketType.Fulfill : IlpPacketType.Reject, + prepare.amount, + [new StreamReceiptFrame(Long.UONE, randomBytes(64))] + ) + const data = await streamReply.serializeAndEncrypt(encryptionKey) + + if (isFulfillable) { + return serializeIlpFulfill({ + fulfillment, + data + }) + } else { + return serializeIlpReject({ + code: IlpError.F99_APPLICATION_ERROR, + message: '', + triggeredBy: '', + data + }) + } + }) + + const destination = await setupPayment({ + plugin: senderPlugin, + sharedSecret, + destinationAddress: 'g.anyone', + destinationAsset: { + code: 'ABC', + scale: 4 + } + }) + const quote = await startQuote({ + plugin: senderPlugin, + destination, + amountToDeliver: 20, + sourceAsset: { + code: 'ABC', + scale: 4 + }, + slippage: 0 + }) + + const { amountDelivered, streamReceipt } = await pay({ + plugin: senderPlugin, + destination, + quote + }) + expect(amountDelivered).toBeGreaterThan(0) + expect(streamReceipt).toBeUndefined() + }) +}) + +describe('closes connection', () => { + jest.setTimeout(20e3) + + it('closes connection with ilp-protocol-stream', async () => { + const [senderPlugin, alicePlugin] = MirrorPlugin.createPair() + const [bobPlugin, receiverPlugin] = MirrorPlugin.createPair() + + const app = createApp({ + ilpAddress: 'test.larry', + accounts: { + alice: { + relation: 'child', + plugin: alicePlugin, + assetCode: 'USD', + assetScale: 0 + }, + bob: { + relation: 'child', + plugin: bobPlugin, + assetCode: 'USD', + assetScale: 0 + } + } + }) + await app.listen() + + const streamServer = await createServer({ + plugin: receiverPlugin + }) + + const connectionPromise = streamServer.acceptConnection() + streamServer.on('connection', (connection: Connection) => { + connection.on('stream', (stream: DataAndMoneyStream) => { + stream.setReceiveMax(Long.MAX_UNSIGNED_VALUE) + }) + }) + + const { sharedSecret, destinationAccount: destinationAddress } = + streamServer.generateAddressAndSecret() + + const destination = await setupPayment({ + plugin: senderPlugin, + sharedSecret, + destinationAddress + }) + + const serverConnection = await connectionPromise + + let isClosed = false + serverConnection.on('close', () => { + isClosed = true + }) + + await closeConnection(senderPlugin, destination) + expect(isClosed) + + await streamServer.close() + await app.shutdown() + }) +}) diff --git a/packages/pay/test/reply.spec.ts b/packages/pay/test/reply.spec.ts new file mode 100644 index 0000000000..567552cb8f --- /dev/null +++ b/packages/pay/test/reply.spec.ts @@ -0,0 +1,255 @@ +/* eslint-disable @typescript-eslint/no-empty-function, @typescript-eslint/no-non-null-assertion */ +import { describe, expect, it } from '@jest/globals' +import { randomBytes } from 'crypto' +import createLogger from 'ilp-logger' +import { + deserializeIlpPrepare, + IlpAddress, + IlpError, + serializeIlpFulfill, + serializeIlpPrepare, + serializeIlpReject +} from 'ilp-packet' +import { + ConnectionDataBlockedFrame, + ConnectionMaxStreamIdFrame, + IlpPacketType, + Packet +} from 'ilp-protocol-stream/dist/src/packet' +import { generateKeys, StreamReject } from '../src/request' +import { + generateEncryptionKey, + generateFulfillmentKey, + hmac, + Int +} from '../src/utils' + +const destinationAddress = 'private.bob' as IlpAddress +const sharedSecret = randomBytes(32) +const expiresAt = new Date(Date.now() + 30000) +const log = createLogger('ilp-pay') + +describe('validates replies', () => { + it('returns F01 if reply is not Fulfill or Reject', async () => { + const sendData = async () => + serializeIlpPrepare({ + destination: 'private.foo', + executionCondition: randomBytes(32), + expiresAt: new Date(Date.now() + 10000), + amount: '1', + data: Buffer.alloc(0) + }) + + const sendRequest = await generateKeys({ sendData }, sharedSecret) + + const reply = await sendRequest({ + destinationAddress, + expiresAt, + sequence: 20, + sourceAmount: Int.from(100)!, + minDestinationAmount: Int.from(99)!, + isFulfillable: true, + frames: [], + log + }) + + expect(reply.destinationAmount).toBeUndefined() + expect(reply.frames).toBeUndefined() + + expect(reply.isReject()) + expect((reply as StreamReject).ilpReject.code).toBe( + IlpError.F01_INVALID_PACKET + ) + }) + + it('returns R00 if packet times out', async () => { + // Data handler never resolves + const sendData = () => new Promise(() => {}) + + const sendRequest = await generateKeys({ sendData }, sharedSecret) + + const reply = await sendRequest({ + destinationAddress, + expiresAt: new Date(Date.now() + 1000), // Short expiry so test completes quickly + sequence: 31, + sourceAmount: Int.from(100)!, + minDestinationAmount: Int.from(99)!, + isFulfillable: true, + frames: [], + log + }) + + expect(reply.destinationAmount).toBeUndefined() + expect(reply.frames).toBeUndefined() + + expect(reply.isReject()) + expect((reply as StreamReject).ilpReject.code).toBe( + IlpError.R00_TRANSFER_TIMED_OUT + ) + }) + + it('returns T00 if the plugin throws an error', async () => { + const sendData = async () => { + throw new Error('Unable to process request') + } + + const sendRequest = await generateKeys({ sendData }, sharedSecret) + + const reply = await sendRequest({ + destinationAddress, + expiresAt, + sequence: 20, + sourceAmount: Int.from(100)!, + minDestinationAmount: Int.from(99)!, + isFulfillable: true, + frames: [], + log + }) + + expect(reply.destinationAmount).toBeUndefined() + expect(reply.frames).toBeUndefined() + + expect(reply.isReject()) + expect((reply as StreamReject).ilpReject.code).toBe( + IlpError.T00_INTERNAL_ERROR + ) + }) + + it('returns F05 on invalid fulfillment', async () => { + const sendData = async () => + serializeIlpFulfill({ + fulfillment: randomBytes(32), + data: Buffer.alloc(0) + }) + + const sendRequest = await generateKeys({ sendData }, sharedSecret) + + const reply = await sendRequest({ + destinationAddress, + expiresAt, + sequence: 20, + sourceAmount: Int.from(100)!, + minDestinationAmount: Int.from(99)!, + isFulfillable: true, + frames: [], + log + }) + + expect(reply.destinationAmount).toBeUndefined() + expect(reply.frames).toBeUndefined() + + expect(reply.isReject()) + expect((reply as StreamReject).ilpReject.code).toBe( + IlpError.F05_WRONG_CONDITION + ) + }) + + it('discards STREAM reply with invalid sequence', async () => { + const sendData = async (data: Buffer) => { + const prepare = deserializeIlpPrepare(data) + + const streamReply = new Packet(1, IlpPacketType.Fulfill, 100, [ + new ConnectionMaxStreamIdFrame(30) // Some random frame + ]) + + return serializeIlpFulfill({ + fulfillment: hmac(fulfillmentKey, prepare.data), + data: await streamReply.serializeAndEncrypt(encryptionKey) + }) + } + + const encryptionKey = generateEncryptionKey(sharedSecret) + const fulfillmentKey = generateFulfillmentKey(sharedSecret) + + const sendRequest = await generateKeys({ sendData }, sharedSecret) + + const reply = await sendRequest({ + destinationAddress, + expiresAt, + sequence: 20, + sourceAmount: Int.from(100)!, + minDestinationAmount: Int.from(99)!, + isFulfillable: true, + frames: [], + log + }) + + // Discards reply since the sequence # is not the same as the request + expect(reply.destinationAmount).toBeUndefined() + expect(reply.frames).toBeUndefined() + + expect(reply.isFulfill()) + }) + + it('discards STREAM reply if packet type is invalid', async () => { + const sendData = async (data: Buffer) => { + const prepare = deserializeIlpPrepare(data) + + // Receiver is claiming the packet is a reject, even though it's a Fulfill + const streamReply = new Packet(1, IlpPacketType.Reject, 100, [ + new ConnectionDataBlockedFrame(0) // Some random frame + ]) + + return serializeIlpFulfill({ + fulfillment: hmac(fulfillmentKey, prepare.data), + data: await streamReply.serializeAndEncrypt(encryptionKey) + }) + } + + const encryptionKey = generateEncryptionKey(sharedSecret) + const fulfillmentKey = generateFulfillmentKey(sharedSecret) + + const sendRequest = await generateKeys({ sendData }, sharedSecret) + + const reply = await sendRequest({ + destinationAddress, + expiresAt, + sequence: 1, + sourceAmount: Int.from(100)!, + minDestinationAmount: Int.from(99)!, + isFulfillable: true, + frames: [], + log + }) + + // Discards reply since packet type was invalid + expect(reply.destinationAmount).toBeUndefined() + expect(reply.frames).toBeUndefined() + + expect(reply.isFulfill()) + }) + + it('handles replies when decryption fails', async () => { + const replyData = randomBytes(100) + const sendData = async () => + serializeIlpReject({ + code: IlpError.F07_CANNOT_RECEIVE, + message: '', + triggeredBy: '', + data: replyData + }) + + const sendRequest = await generateKeys({ sendData }, sharedSecret) + + const reply = await sendRequest({ + destinationAddress, + expiresAt, + sequence: 1, + sourceAmount: Int.from(100)!, + minDestinationAmount: Int.from(99)!, + isFulfillable: true, + frames: [], + log + }) + + // No STREAM packet could be decrypted + expect(reply.destinationAmount).toBeUndefined() + expect(reply.frames).toBeUndefined() + + expect(reply.isReject()) + expect((reply as StreamReject).ilpReject.code).toBe( + IlpError.F07_CANNOT_RECEIVE + ) + expect((reply as StreamReject).ilpReject.data).toEqual(replyData) + }) +}) diff --git a/packages/pay/test/setup.spec.ts b/packages/pay/test/setup.spec.ts new file mode 100644 index 0000000000..bc28700377 --- /dev/null +++ b/packages/pay/test/setup.spec.ts @@ -0,0 +1,1790 @@ +/* eslint-disable prefer-const, @typescript-eslint/no-empty-function, @typescript-eslint/no-non-null-assertion */ +import { StreamServer } from '@interledger/stream-receiver' +import { describe, expect, it, jest } from '@jest/globals' +import { createApp } from 'ilp-connector' +import { IlpError } from 'ilp-packet' +import { + Connection, + createServer, + DataAndMoneyStream +} from 'ilp-protocol-stream' +import { randomBytes } from 'crypto' +import { + ConnectionAssetDetailsFrame, + IlpPacketType, + Packet +} from 'ilp-protocol-stream/dist/src/packet' +import Long from 'long' +import nock from 'nock' +import { PaymentError, PaymentType, setupPayment, startQuote } from '../src' +import { fetchPaymentDetails, Account } from '../src/open-payments' +import { generateEncryptionKey, Int } from '../src/utils' +import { + createMaxPacketMiddleware, + createPlugin, + createRateMiddleware, + createSlippageMiddleware, + createStreamReceiver, + MirrorPlugin, + RateBackend +} from './helpers/plugin' +import { CustomBackend } from './helpers/rate-backend' +import reduct from 'reduct' +import { URL } from 'url' + +interface setupNockOptions { + incomingPaymentId?: string | null + accountUrl?: string | null + destinationPayment?: string | null + completed?: boolean | string | null + incomingAmount?: NockAmount | null + receivedAmount?: NockAmount | null + expiresAt?: string | null + description?: string | null + externalRef?: string | null + connectionId?: string | null +} + +type NockAmount = { + value: string + assetCode: string + assetScale: number +} + +const plugin = createPlugin() +const streamServer = new StreamServer({ + serverSecret: randomBytes(32), + serverAddress: 'private.larry' +}) +const streamReceiver = createStreamReceiver(streamServer) +const uuid = () => '2646f447-542a-4f0a-a557-f7492b46265f' + +const setupNock = (options: setupNockOptions) => { + const { ilpAddress, sharedSecret } = streamServer.generateCredentials() + const incomingPaymentId = + options.incomingPaymentId !== null + ? options.incomingPaymentId || uuid() + : undefined + const accountUrl = + options.accountUrl !== null + ? options.accountUrl || 'https://wallet.example/alice' + : undefined + const destinationPayment = + options.destinationPayment !== null + ? options.destinationPayment || + `${accountUrl}/incoming-payments/${incomingPaymentId}` + : undefined + const completed = + options.completed !== null ? options.completed || false : undefined + const incomingAmount = + options.incomingAmount !== null + ? options.incomingAmount || { + value: '40000', + assetCode: 'USD', + assetScale: 4 + } + : undefined + const receivedAmount = + options.receivedAmount !== null + ? options.receivedAmount || { + value: '20000', + assetCode: 'USD', + assetScale: 4 + } + : undefined + const expiresAt = + options.expiresAt !== null + ? options.expiresAt !== undefined + ? options.expiresAt + : new Date(Date.now() + 60 * 60 * 1000 * 24).toISOString() // 1 day in the future + : undefined + const description = + options.description !== null ? options.description || 'Coffee' : undefined + const externalRef = + options.externalRef !== null ? options.externalRef || '#123' : undefined + const ilpStreamConnection = + options.connectionId !== null + ? options.connectionId + ? `https://wallet.example/${options.connectionId}` + : { + id: `https://wallet.example/${uuid()}`, + ilpAddress, + sharedSecret: sharedSecret.toString('base64') + } + : undefined + + nock('https://wallet.example') + .get(`/alice/incoming-payments/${incomingPaymentId}`) + .matchHeader('Accept', 'application/json') + .reply(200, { + id: destinationPayment, + paymentPointer: accountUrl, + completed, + incomingAmount, + receivedAmount, + expiresAt, + description, + externalRef, + ilpStreamConnection + }) + + if (typeof ilpStreamConnection === 'string') { + nock('https://wallet.example') + .get(`/${options.connectionId}`) + .matchHeader('Accept', 'application/json') + .reply(200, { + id: `https://wallet.example/${options.connectionId}`, + ilpAddress, + sharedSecret: sharedSecret.toString('base64') + }) + } + + return { + incomingPaymentId, + destinationPayment, + accountUrl, + completed, + incomingAmount, + receivedAmount, + expiresAt, + description, + externalRef, + ilpAddress, + sharedSecret + } +} + +describe('open payments', () => { + const destinationAddress = 'g.wallet.receiver.12345' + const sharedSecret = randomBytes(32) + const sharedSecretBase64 = sharedSecret.toString('base64') + const account: Account = { + id: 'https://wallet.example/alice', + publicName: 'alice', + assetCode: 'USD', + assetScale: 4, + authServer: 'https://auth.wallet.example' + } + const accountUrl = new URL(account.id) + + it('fails if more than one destination provided', async () => { + await expect( + fetchPaymentDetails({ + destinationPayment: `https://wallet.com/alice/incoming-payments/${uuid()}`, + destinationConnection: `https://wallet.com/${uuid()}` + }) + ).resolves.toBe(PaymentError.InvalidDestination) + }) + + it('quotes an Incoming Payment', async () => { + const prices = { + EUR: 1, + USD: 1.12 + } + + const plugin = createPlugin( + createRateMiddleware( + new RateBackend( + { code: 'EUR', scale: 3 }, + { code: 'USD', scale: 4 }, + prices + ) + ), + streamReceiver + ) + + const { + accountUrl, + destinationPayment, + expiresAt, + incomingAmount, + receivedAmount, + description, + externalRef, + ilpAddress, + sharedSecret + } = setupNock({}) + + const destination = await setupPayment({ + destinationPayment, + plugin + }) + const { minDeliveryAmount, minExchangeRate, paymentType } = + await startQuote({ + plugin, + destination, + prices, + sourceAsset: { + code: 'EUR', + scale: 4 + } + }) + + // Tests that it quotes the remaining amount to deliver in the Incoming Payment + expect(paymentType).toBe(PaymentType.FixedDelivery) + expect(minExchangeRate).toBeDefined() + expect(minDeliveryAmount).toBe(BigInt(40000 - 20000)) + expect(destination.destinationPaymentDetails).toMatchObject({ + id: destinationPayment, + paymentPointer: accountUrl, + expiresAt: new Date(expiresAt!).getTime(), + description, + receivedAmount: receivedAmount + ? { + value: BigInt(receivedAmount.value), + assetCode: receivedAmount.assetCode, + assetScale: receivedAmount.assetScale + } + : undefined, + incomingAmount: incomingAmount + ? { + value: BigInt(incomingAmount.value), + assetCode: incomingAmount.assetCode, + assetScale: incomingAmount.assetScale + } + : undefined, + externalRef + }) + expect(destination.destinationAsset).toMatchObject({ + code: 'USD', + scale: 4 + }) + expect(destination.destinationAddress).toBe(ilpAddress) + expect(destination.sharedSecret.equals(sharedSecret)) + expect(destination.accountUrl).toBe(accountUrl) + }) + + it('quotes an Incoming Payment without incomingAmount', async () => { + const prices = { + EUR: 1, + USD: 1.12 + } + + const plugin = createPlugin( + createRateMiddleware( + new RateBackend( + { code: 'EUR', scale: 3 }, + { code: 'USD', scale: 4 }, + prices + ) + ), + streamReceiver + ) + + const { + accountUrl, + destinationPayment, + expiresAt, + receivedAmount, + description, + externalRef, + ilpAddress, + sharedSecret + } = setupNock({ incomingAmount: null }) + + const destination = await setupPayment({ + destinationPayment, + plugin + }) + const { minDeliveryAmount, minExchangeRate, paymentType } = + await startQuote({ + plugin, + destination, + prices, + sourceAsset: { + code: 'EUR', + scale: 4 + }, + amountToSend: BigInt(400) + }) + + // Tests that it quotes the remaining amount to deliver in the Incoming Payment + expect(paymentType).toBe(PaymentType.FixedSend) + expect(minExchangeRate).toBeDefined() + expect(minDeliveryAmount).toBe(BigInt(353)) + expect(destination.destinationPaymentDetails).toMatchObject({ + id: destinationPayment, + paymentPointer: accountUrl, + expiresAt: new Date(expiresAt!).getTime(), + description, + receivedAmount: receivedAmount + ? { + value: BigInt(receivedAmount.value), + assetCode: receivedAmount.assetCode, + assetScale: receivedAmount.assetScale + } + : undefined, + externalRef + }) + expect(destination.destinationAsset).toMatchObject({ + code: 'USD', + scale: 4 + }) + expect(destination.destinationAddress).toBe(ilpAddress) + expect(destination.sharedSecret.equals(sharedSecret)) + expect(destination.accountUrl).toBe(accountUrl) + }) + + it('fails to quotes an Incoming Payment without incomingAmount, amountToSend or amountToDeliver', async () => { + const prices = { + EUR: 1, + USD: 1.12 + } + + const plugin = createPlugin( + createRateMiddleware( + new RateBackend( + { code: 'EUR', scale: 3 }, + { code: 'USD', scale: 4 }, + prices + ) + ), + streamReceiver + ) + + const { destinationPayment } = setupNock({ incomingAmount: null }) + + const destination = await setupPayment({ + destinationPayment, + plugin + }) + await expect(startQuote({ plugin, destination })).rejects.toBe( + PaymentError.UnknownPaymentTarget + ) + }) + + it('fails if Incoming Payment url is not HTTPS or HTTP', async () => { + await expect( + fetchPaymentDetails({ + destinationPayment: 'oops://this-is-a-wallet.co/incoming-payment/123' + }) + ).resolves.toBe(PaymentError.QueryFailed) + }) + + it('fails if given a payment pointer as an Incoming Payment url', async () => { + await expect( + fetchPaymentDetails({ destinationPayment: '$foo.money' }) + ).resolves.toBe(PaymentError.QueryFailed) + }) + + it('fails if the Incoming Payment was already paid', async () => { + const { destinationPayment } = setupNock({ + receivedAmount: { + value: '40300', // Paid $4.03 of $4 + assetCode: 'USD', + assetScale: 4 + } + }) + + const destination = await setupPayment({ + destinationPayment, + plugin + }) + await expect(startQuote({ plugin, destination })).rejects.toBe( + PaymentError.IncomingPaymentCompleted + ) + }) + + it('fails if the Incoming Payment was already completed', async () => { + const { destinationPayment } = setupNock({ + completed: true, + receivedAmount: { + value: '40000', // Paid $4.03 of $4 + assetCode: 'USD', + assetScale: 4 + } + }) + + const destination = await setupPayment({ + destinationPayment, + plugin + }) + await expect(startQuote({ plugin, destination })).rejects.toBe( + PaymentError.IncomingPaymentCompleted + ) + }) + + it('fails if the Incoming Payment has expired', async () => { + const { destinationPayment } = setupNock({ + expiresAt: new Date().toISOString() + }) + + const destination = await setupPayment({ + destinationPayment, + plugin + }) + await expect(startQuote({ plugin, destination })).rejects.toBe( + PaymentError.IncomingPaymentExpired + ) + }) + + it.each` + connectionId | description + ${undefined} | ${'connection details'} + ${'b4d6ead2-f7e6-42fd-932b-80c107977bff'} | ${'connection URL'} + `( + 'resolves and validates an Incoming Payment with $description', + async ({ connectionId }) => { + const { + accountUrl, + destinationPayment, + expiresAt, + incomingAmount, + receivedAmount, + description, + externalRef, + ilpAddress, + sharedSecret + } = setupNock({ connectionId }) + + await expect( + fetchPaymentDetails({ destinationPayment }) + ).resolves.toMatchObject({ + sharedSecret, + destinationAddress: ilpAddress, + destinationAsset: { + code: 'USD', + scale: 4 + }, + destinationPaymentDetails: { + receivedAmount: receivedAmount + ? { + value: BigInt(receivedAmount.value), + assetCode: receivedAmount.assetCode, + assetScale: receivedAmount.assetScale + } + : undefined, + incomingAmount: incomingAmount + ? { + value: BigInt(incomingAmount.value), + assetCode: incomingAmount.assetCode, + assetScale: incomingAmount.assetScale + } + : undefined, + id: destinationPayment, + paymentPointer: accountUrl, + expiresAt: new Date(expiresAt!).getTime(), + description, + externalRef + } + }) + } + ) + + it('resolves and validates an Incoming Payment if incomingAmount, expiresAt, description, and externalRef are missing', async () => { + const { + accountUrl, + destinationPayment, + receivedAmount, + ilpAddress, + sharedSecret + } = setupNock({ + incomingAmount: null, + expiresAt: null, + description: null, + externalRef: null + }) + + await expect( + fetchPaymentDetails({ destinationPayment }) + ).resolves.toMatchObject({ + sharedSecret, + destinationAddress: ilpAddress, + destinationAsset: { + code: 'USD', + scale: 4 + }, + destinationPaymentDetails: { + receivedAmount: receivedAmount + ? { + value: BigInt(receivedAmount.value), + assetCode: receivedAmount.assetCode, + assetScale: receivedAmount.assetScale + } + : undefined, + id: destinationPayment, + paymentPointer: accountUrl + } + }) + }) + + it('fails if Incoming Payment amounts are not positive and u64', async () => { + const { destinationPayment } = setupNock({ + incomingAmount: { + value: '100000000000000000000000000000000000000000000000000000000', + assetCode: 'USD', + assetScale: 5 + }, + receivedAmount: { + value: '-20', + assetCode: 'USD', + assetScale: 5 + } + }) + + await expect(fetchPaymentDetails({ destinationPayment })).resolves.toBe( + PaymentError.QueryFailed + ) + }) + + it('fails if completed cannot be parsed', async () => { + const { destinationPayment } = setupNock({ + completed: 'foo' + }) + + await expect(fetchPaymentDetails({ destinationPayment })).resolves.toBe( + PaymentError.QueryFailed + ) + }) + + it('fails if expiresAt cannot be parsed', async () => { + const { destinationPayment } = setupNock({ + expiresAt: 'foo' + }) + + await expect(fetchPaymentDetails({ destinationPayment })).resolves.toBe( + PaymentError.QueryFailed + ) + }) + + it('fails if Incoming Payment query times out', async () => { + const scope = nock('https://money.example').get(/.*/).delay(6000).reply(500) + await expect( + fetchPaymentDetails({ destinationPayment: 'https://money.example' }) + ).resolves.toBe(PaymentError.QueryFailed) + scope.done() + nock.abortPendingRequests() + }) + + it('fails if Incoming Payment query returns 4xx error', async () => { + const destinationPayment = 'https://example.com/foo' + const scope = nock('https://example.com').get('/foo').reply(404) // Query fails + await expect(fetchPaymentDetails({ destinationPayment })).resolves.toBe( + PaymentError.QueryFailed + ) + scope.done() + }) + + it('fails if Incoming Payment query response is invalid', async () => { + // Validates Incoming Payment must be a non-null object + const destinationPayment = 'https://open.mywallet.com/incoming-payments/123' + const scope1 = nock('https://open.mywallet.com') + .get('/incoming-payments/123') + .reply(200, '"not an Incoming Payment"') + await expect(fetchPaymentDetails({ destinationPayment })).resolves.toBe( + PaymentError.QueryFailed + ) + scope1.done() + + // Validates Incoming Payment must contain other details, not simply credentials + const scope2 = nock('https://open.mywallet.com') + .get('/incoming-payments/123') + .reply(200, { + sharedSecret: randomBytes(32).toString('base64'), + ilpAddress: 'private.larry.receiver' + }) + await expect(fetchPaymentDetails({ destinationPayment })).resolves.toBe( + PaymentError.QueryFailed + ) + scope2.done() + }) + + it('fails if account query fails', async () => { + const scope = nock(accountUrl.origin) + .get(accountUrl.pathname) + .matchHeader('Accept', /application\/json/) + .reply(500) + await expect( + fetchPaymentDetails({ destinationAccount: account.id }) + ).resolves.toBe(PaymentError.QueryFailed) + scope.done() + }) + + it('fails if account query times out', async () => { + const scope = nock(accountUrl.origin) + .get(accountUrl.pathname) + .matchHeader('Accept', /application\/json/) + .delay(7000) + .reply(500) + await expect( + fetchPaymentDetails({ destinationAccount: account.id }) + ).resolves.toBe(PaymentError.QueryFailed) + scope.done() + nock.abortPendingRequests() + }) + + it('fails if account query response is invalid', async () => { + // Account not an object + const scope = nock(accountUrl.origin) + .get(accountUrl.pathname) + .matchHeader('Accept', /application\/json/) + .reply(200, '"this is a string"') + await expect( + fetchPaymentDetails({ destinationAccount: account.id }) + ).resolves.toBe(PaymentError.QueryFailed) + scope.done() + }) + + it('fails if account id in query response is invalid', async () => { + // Account not an object + const scope = nock(accountUrl.origin) + .get(accountUrl.pathname) + .matchHeader('Accept', /application\/json/) + .reply(200, { ...account, id: 'helloworld' }) + await expect( + fetchPaymentDetails({ destinationAccount: account.id }) + ).resolves.toBe(PaymentError.QueryFailed) + scope.done() + }) + + it('fails if trying to pay to open payments account', async () => { + const accountScope = nock(accountUrl.origin) + .get(accountUrl.pathname) + .matchHeader('Accept', /application\/json/) + .reply(200, account) + await expect( + fetchPaymentDetails({ destinationAccount: account.id }) + ).resolves.toBe(PaymentError.InvalidDestination) + accountScope.done() + }) + + it('resolves credentials from connection url', async () => { + const connectionId = uuid() + const scope = nock('https://wallet.com') + .get(`/${connectionId}`) + .matchHeader('Accept', 'application/json') + .reply(200, { + id: `https://wallet.com/${connectionId}`, + ilpAddress: destinationAddress, + sharedSecret: sharedSecretBase64 + }) + + const credentials = await fetchPaymentDetails({ + destinationConnection: `https://wallet.com/${connectionId}` + }) + expect(credentials).toMatchObject({ + sharedSecret, + destinationAddress + }) + scope.done() + }) + + it('fails if connection query fails', async () => { + const connectionId = uuid() + const scope = nock('https://wallet.com').get(`/${connectionId}`).reply(500) + await expect( + fetchPaymentDetails({ + destinationConnection: `https://wallet.com/${connectionId}` + }) + ).resolves.toBe(PaymentError.QueryFailed) + scope.done() + }) + + it('fails if connection url in payment pointer', async () => { + await expect( + fetchPaymentDetails({ destinationConnection: `$wallet.com/${uuid()}` }) + ).resolves.toBe(PaymentError.QueryFailed) + }) + + it('fails if connection query times out', async () => { + const connectionId = uuid() + const scope = nock('https://wallet.com') + .get(`/${connectionId}`) + .delay(7000) + .reply(500) + await expect( + fetchPaymentDetails({ + destinationConnection: `https://wallet.com/${connectionId}` + }) + ).resolves.toBe(PaymentError.QueryFailed) + scope.done() + nock.abortPendingRequests() + }) + + it('fails if connection query response is invalid', async () => { + // Invalid shared secret + const connectionId = uuid() + const scope = nock('https://wallet.com') + .get(`/${connectionId}`) + .reply(200, { + id: `https://wallet.com/${connectionId}`, + ilpAddress: 'g.foo', + sharedSecret: 'Zm9v' + }) + await expect( + fetchPaymentDetails({ + destinationConnection: `https://wallet.com/${connectionId}` + }) + ).resolves.toBe(PaymentError.QueryFailed) + scope.done() + + // connection response not an object + const scope2 = nock('https://wallet.com') + .get(`/${connectionId}`) + .reply(200, '3') + await expect( + fetchPaymentDetails({ + destinationConnection: `https://wallet.com/${connectionId}` + }) + ).resolves.toBe(PaymentError.QueryFailed) + scope2.done() + }) + + it('resolves credentials from SPSP', async () => { + const scope = nock('https://alice.mywallet.com') + .get('/.well-known/pay') + .matchHeader('Accept', /application\/spsp4\+json*./) + .delay(1000) + .reply(200, { + destination_account: destinationAddress, + shared_secret: sharedSecretBase64 + }) + + const credentials = await fetchPaymentDetails({ + destinationAccount: '$alice.mywallet.com' + }) + expect(credentials).toMatchObject({ + sharedSecret, + destinationAddress, + accountUrl: 'https://alice.mywallet.com/.well-known/pay' + }) + scope.done() + }) + + it('fails if SPSP query fails', async () => { + const scope = nock('https://open.mywallet.com').get(/.*/).reply(500) + await expect( + fetchPaymentDetails({ destinationAccount: '$open.mywallet.com' }) + ).resolves.toBe(PaymentError.QueryFailed) + scope.done() + }) + + it('fails if SPSP query times out', async () => { + const scope = nock('https://open.mywallet.com') + .get(/.*/) + .delay(7000) + .reply(500) + await expect( + fetchPaymentDetails({ destinationAccount: '$open.mywallet.com' }) + ).resolves.toBe(PaymentError.QueryFailed) + scope.done() + nock.abortPendingRequests() + }) + + it('fails if SPSP query response is invalid', async () => { + // Invalid shared secret + const scope2 = nock('https://alice.mywallet.com') + .get('/.well-known/pay') + .reply(200, { + destination_account: 'g.foo', + shared_secret: 'Zm9v' + }) + await expect( + fetchPaymentDetails({ destinationAccount: '$alice.mywallet.com' }) + ).resolves.toBe(PaymentError.QueryFailed) + scope2.done() + + // SPSP account not an object + const scope3 = nock('https://wallet.example') + .get('/.well-known/pay') + .reply(200, '3') + await expect( + fetchPaymentDetails({ destinationAccount: '$wallet.example' }) + ).resolves.toBe(PaymentError.QueryFailed) + scope3.done() + }) + + it('follows SPSP redirect', async () => { + const scope1 = nock('https://wallet1.example/') + .get('/.well-known/pay') + .reply( + 307, // Temporary redirect + {}, + { + Location: 'https://wallet2.example/.well-known/pay' + } + ) + + const scope2 = nock('https://wallet2.example/') + .get('/.well-known/pay') + .matchHeader('Accept', /application\/spsp4\+json*./) + .reply(200, { + destination_account: destinationAddress, + shared_secret: sharedSecretBase64 + }) + + const credentials = await fetchPaymentDetails({ + destinationAccount: '$wallet1.example' + }) + expect(credentials).toMatchObject({ + sharedSecret, + destinationAddress + }) + scope1.done() + scope2.done() + }) + + it('fails on SPSP redirect to non-HTTPS endpoint', async () => { + const scope1 = nock('https://wallet1.example/') + .get('/.well-known/pay') + .reply( + 302, // Temporary redirect + {}, + { + Location: 'http://wallet2.example/.well-known/pay' + } + ) + + const scope2 = nock('https://wallet2.example/') + .get('/.well-known/pay') + .reply( + 302, // Temporary redirect + {}, + { + Location: 'http://wallet3.example/.well-known/pay' + } + ) + + await expect( + fetchPaymentDetails({ destinationAccount: '$wallet1.example' }) + ).resolves.toBe(PaymentError.QueryFailed) + + // Only the first request, should be resolved, ensure it doesn't follow insecure redirect + expect(scope1.isDone()) + expect(!scope2.isDone()) + nock.cleanAll() + }) + + it('fails if the payment pointer is semantically invalid', async () => { + await expect( + fetchPaymentDetails({ destinationAccount: 'ht$tps://example.com' }) + ).resolves.toBe(PaymentError.InvalidPaymentPointer) + }) + + it('fails if query part is included', async () => { + await expect( + fetchPaymentDetails({ destinationAccount: '$foo.co?id=12345678' }) + ).resolves.toBe(PaymentError.InvalidPaymentPointer) + }) + + it('fails if fragment part is included', async () => { + await expect( + fetchPaymentDetails({ destinationAccount: '$interledger.org#default' }) + ).resolves.toBe(PaymentError.InvalidPaymentPointer) + }) + + it('fails if account URL is not HTTPS or HTTP', async () => { + await expect( + fetchPaymentDetails({ destinationAccount: 'oops://ilp.wallet.com/alice' }) + ).resolves.toBe(PaymentError.InvalidPaymentPointer) + }) + + it('validates given STREAM credentials', async () => { + const sharedSecret = randomBytes(32) + const destinationAddress = 'test.foo.~hello~world' + await expect( + fetchPaymentDetails({ sharedSecret, destinationAddress }) + ).resolves.toMatchObject({ + sharedSecret, + destinationAddress + }) + }) + + it('fails if provided invalid STREAM credentials', async () => { + await expect( + fetchPaymentDetails({ + sharedSecret: randomBytes(31), + destinationAddress: 'private' + }) + ).resolves.toBe(PaymentError.InvalidCredentials) + }) + + it('fails if no mechanism to fetch STREAM credentials was provided', async () => { + await expect(fetchPaymentDetails({})).resolves.toBe( + PaymentError.InvalidCredentials + ) + }) +}) + +describe('setup flow', () => { + it('fails if given no payment pointer or STREAM credentials', async () => { + await expect( + setupPayment({ + plugin: new MirrorPlugin() + }) + ).rejects.toBe(PaymentError.InvalidCredentials) + }) + + it('fails given a semantically invalid payment pointer', async () => { + await expect( + setupPayment({ + plugin: new MirrorPlugin(), + destinationAccount: 'ht$tps://example.com' + }) + ).rejects.toBe(PaymentError.InvalidPaymentPointer) + }) + + it('fails if payment pointer cannot resolve', async () => { + await expect( + setupPayment({ + plugin: new MirrorPlugin(), + destinationAccount: 'https://wallet.co/foo/bar' + }) + ).rejects.toBe(PaymentError.QueryFailed) + }) + + it('fails if SPSP response is invalid', async () => { + const scope = nock('https://example4.com') + .get('/foo') + .reply(200, { meh: 'why?' }) + + await expect( + setupPayment({ + plugin: new MirrorPlugin(), + destinationAccount: 'https://example4.com/foo' + }) + ).rejects.toBe(PaymentError.QueryFailed) + scope.done() + }) + + it('establishes connection from SPSP and fetches asset details with STREAM', async () => { + const [senderPlugin1, senderPlugin2] = MirrorPlugin.createPair() + const [receiverPlugin1, receiverPlugin2] = MirrorPlugin.createPair() + + const app = createApp({ + ilpAddress: 'private.larry', + backend: 'one-to-one', + spread: 0, + accounts: { + sender: { + relation: 'child', + assetCode: 'ABC', + assetScale: 0, + plugin: senderPlugin2 + }, + receiver: { + relation: 'child', + assetCode: 'XYZ', + assetScale: 0, + plugin: receiverPlugin1 + } + } + }) + await app.listen() + + const streamServer = await createServer({ + plugin: receiverPlugin2 + }) + + const connectionHandler = jest.fn() + streamServer.on('connection', connectionHandler) + + const scope = nock('https://example5.com') + .get('/.well-known/pay') + .matchHeader('Accept', /application\/spsp4\+json*./) + .reply(() => { + const credentials = streamServer.generateAddressAndSecret() + + return [ + 200, + { + destination_account: credentials.destinationAccount, + shared_secret: credentials.sharedSecret.toString('base64') + }, + { 'Content-Type': 'application/spsp4+json' } + ] + }) + + const details = await setupPayment({ + destinationAccount: 'https://example5.com', + plugin: senderPlugin1 + }) + + expect(details.destinationAsset).toMatchObject({ + code: 'XYZ', + scale: 0 + }) + + // Connection should be able to be established after resolving payment pointer + expect(connectionHandler.mock.calls.length).toBe(1) + scope.done() + + await app.shutdown() + await streamServer.close() + }) + + it('fails on asset detail conflicts', async () => { + const sharedSecret = randomBytes(32) + const encryptionKey = generateEncryptionKey(sharedSecret) + + // Create simple STREAM receiver that acks test packets, + // but replies with conflicting asset details + const plugin = createPlugin(async (prepare) => { + const streamRequest = await Packet.decryptAndDeserialize( + encryptionKey, + prepare.data + ) + const streamReply = new Packet( + streamRequest.sequence, + IlpPacketType.Reject, + prepare.amount, + [ + new ConnectionAssetDetailsFrame('ABC', 2), + new ConnectionAssetDetailsFrame('XYZ', 2), + new ConnectionAssetDetailsFrame('XYZ', 3) + ] + ) + + return { + code: IlpError.F99_APPLICATION_ERROR, + message: '', + triggeredBy: '', + data: await streamReply.serializeAndEncrypt(encryptionKey) + } + }) + + await expect( + setupPayment({ + plugin: plugin, + destinationAddress: 'private.larry.receiver', + sharedSecret + }) + ).rejects.toBe(PaymentError.DestinationAssetConflict) + }) + + it('fails on asset probe if cannot establish connection', async () => { + const plugin = createPlugin(async () => ({ + code: IlpError.T01_PEER_UNREACHABLE, + message: '', + triggeredBy: '', + data: Buffer.alloc(0) + })) + + await expect( + setupPayment({ + plugin, + destinationAddress: 'private.larry.receiver', + sharedSecret: Buffer.alloc(32) + }) + ).rejects.toBe(PaymentError.EstablishmentFailed) + }, 15_000) +}) + +describe('quoting flow', () => { + it('fails if amount to send is not a positive integer', async () => { + const asset = { + code: 'ABC', + scale: 4 + } + const destination = await setupPayment({ + plugin, + destinationAsset: asset, + destinationAddress: 'private.foo', + sharedSecret: Buffer.alloc(32) + }) + + // Fails with negative source amount + await expect( + startQuote({ + plugin, + destination, + amountToSend: BigInt(-2), + sourceAsset: asset + }) + ).rejects.toBe(PaymentError.InvalidSourceAmount) + + // Fails with fractional source amount + await expect( + startQuote({ + plugin, + destination, + amountToSend: '3.14', + sourceAsset: asset + }) + ).rejects.toBe(PaymentError.InvalidSourceAmount) + + // Fails with 0 source amount + await expect( + startQuote({ + plugin, + destination, + amountToSend: 0, + sourceAsset: asset + }) + ).rejects.toBe(PaymentError.InvalidSourceAmount) + + // Fails with `NaN` source amount + await expect( + startQuote({ + plugin, + destination, + amountToSend: NaN, + sourceAsset: asset + }) + ).rejects.toBe(PaymentError.InvalidSourceAmount) + + // Fails with `Infinity` source amount + await expect( + startQuote({ + plugin, + destination, + amountToSend: Infinity, + sourceAsset: asset + }) + ).rejects.toBe(PaymentError.InvalidSourceAmount) + + // Fails with Int if source amount is 0 + await expect( + startQuote({ + plugin, + destination, + amountToSend: BigInt(0), + sourceAsset: asset + }) + ).rejects.toBe(PaymentError.InvalidSourceAmount) + }) + + it('fails if amount to deliver is not a positive integer', async () => { + const asset = { + code: 'ABC', + scale: 4 + } + const destination = await setupPayment({ + plugin, + destinationAsset: asset, + destinationAddress: 'private.foo', + sharedSecret: Buffer.alloc(32) + }) + + // Fails with negative source amount + await expect( + startQuote({ + plugin, + destination, + amountToDeliver: BigInt(-3), + sourceAsset: asset + }) + ).rejects.toBe(PaymentError.InvalidDestinationAmount) + + // Fails with fractional source amount + await expect( + startQuote({ + plugin, + destination, + amountToDeliver: '3.14', + sourceAsset: asset + }) + ).rejects.toBe(PaymentError.InvalidDestinationAmount) + + // Fails with 0 source amount + await expect( + startQuote({ + plugin, + destination, + amountToDeliver: 0, + sourceAsset: asset + }) + ).rejects.toBe(PaymentError.InvalidDestinationAmount) + + // Fails with `NaN` source amount + await expect( + startQuote({ + plugin, + destination, + amountToDeliver: NaN, + sourceAsset: asset + }) + ).rejects.toBe(PaymentError.InvalidDestinationAmount) + + // Fails with `Infinity` source amount + await expect( + startQuote({ + plugin, + destination, + amountToDeliver: Infinity, + sourceAsset: asset + }) + ).rejects.toBe(PaymentError.InvalidDestinationAmount) + + // Fails with Int if source amount is 0 + await expect( + startQuote({ + plugin, + destination, + amountToDeliver: BigInt(0), + sourceAsset: asset + }) + ).rejects.toBe(PaymentError.InvalidDestinationAmount) + }) + + it('fails if no Incoming Payment, amount to send or deliver was provided', async () => { + const plugin = new MirrorPlugin() + const asset = { + code: 'ABC', + scale: 3 + } + + const destination = await setupPayment({ + plugin, + destinationAddress: 'private.receiver', + destinationAsset: asset, + sharedSecret: randomBytes(32) + }) + await expect( + startQuote({ + plugin, + destination, + sourceAsset: asset + }) + ).rejects.toBe(PaymentError.UnknownPaymentTarget) + }) + + it('fails on quote if no test packets are delivered', async () => { + const plugin = createPlugin(async () => ({ + code: IlpError.T01_PEER_UNREACHABLE, + message: '', + triggeredBy: '', + data: Buffer.alloc(0) + })) + + const asset = { + code: 'USD', + scale: 6 + } + + const destination = await setupPayment({ + plugin, + destinationAddress: 'private.larry.receiver', + destinationAsset: asset, + sharedSecret: Buffer.alloc(32) + }) + await expect( + startQuote({ + plugin, + destination, + amountToSend: '1000', + sourceAsset: asset + }) + ).rejects.toBe(PaymentError.RateProbeFailed) + }, 15_000) + + it('fails if max packet amount is 0', async () => { + const destinationAddress = 'private.receiver' + const sharedSecret = randomBytes(32) + + const plugin = createPlugin(createMaxPacketMiddleware(Int.ZERO)) + + const destination = await setupPayment({ + plugin, + destinationAddress, + destinationAsset: { + code: 'ABC', + scale: 0 + }, + sharedSecret + }) + await expect( + startQuote({ + plugin, + destination, + amountToSend: 1000, + sourceAsset: { + code: 'ABC', + scale: 0 + } + }) + ).rejects.toBe(PaymentError.ConnectorError) + }) + + it('fails if receiver never shared destination asset details', async () => { + const plugin = createPlugin(streamReceiver) + + // Server will not reply with asset details since none were provided + const credentials = streamServer.generateCredentials() + + await expect( + setupPayment({ + plugin, + destinationAddress: credentials.ilpAddress, + sharedSecret: credentials.sharedSecret + }) + ).rejects.toBe(PaymentError.UnknownDestinationAsset) + }) + + it('fails if prices were not provided', async () => { + const { sharedSecret, ilpAddress: destinationAddress } = + streamServer.generateCredentials() + + const destination = await setupPayment({ + plugin, + destinationAddress, + destinationAsset: { + code: 'GBP', + scale: 0 + }, + sharedSecret + }) + await expect( + startQuote({ + plugin, + destination, + amountToSend: 100, + sourceAsset: { + code: 'JPY', + scale: 0 + } + }) + ).rejects.toBe(PaymentError.ExternalRateUnavailable) + }) + + it('fails if slippage is invalid', async () => { + const asset = { + code: 'ABC', + scale: 2 + } + + const destination = await setupPayment({ + plugin, + sharedSecret: Buffer.alloc(32), + destinationAddress: 'g.recipient', + destinationAsset: asset + }) + + await expect( + startQuote({ + plugin, + destination, + slippage: NaN, + amountToSend: 10, + sourceAsset: asset + }) + ).rejects.toBe(PaymentError.InvalidSlippage) + + await expect( + startQuote({ + plugin, + destination, + slippage: Infinity, + amountToSend: 10, + sourceAsset: asset + }) + ).rejects.toBe(PaymentError.InvalidSlippage) + + await expect( + startQuote({ + plugin, + destination, + slippage: 1.2, + amountToSend: 10, + sourceAsset: asset + }) + ).rejects.toBe(PaymentError.InvalidSlippage) + + await expect( + startQuote({ + plugin, + destination, + slippage: -0.0001, + amountToSend: 10, + sourceAsset: asset + }) + ).rejects.toBe(PaymentError.InvalidSlippage) + }) + + it('fails if source asset details are invalid', async () => { + const asset = { + code: 'ABC', + scale: 2 + } + + const destination = await setupPayment({ + plugin, + sharedSecret: Buffer.alloc(32), + destinationAddress: 'g.recipient', + destinationAsset: asset + }) + + await expect( + startQuote({ + plugin, + destination, + amountToSend: 10, + sourceAsset: { + code: 'ABC', + scale: NaN + } + }) + ).rejects.toBe(PaymentError.UnknownSourceAsset) + + await expect( + startQuote({ + plugin, + destination, + amountToSend: 10, + sourceAsset: { + code: 'KRW', + scale: Infinity + } + }) + ).rejects.toBe(PaymentError.UnknownSourceAsset) + + await expect( + startQuote({ + plugin, + destination, + amountToSend: 10, + sourceAsset: { + code: 'CNY', + scale: -20 + } + }) + ).rejects.toBe(PaymentError.UnknownSourceAsset) + + await expect( + startQuote({ + plugin, + destination, + amountToSend: 10, + sourceAsset: { + code: 'USD', + scale: 256 + } + }) + ).rejects.toBe(PaymentError.UnknownSourceAsset) + }) + + it('fails if no external price for the source asset exists', async () => { + const { sharedSecret, ilpAddress: destinationAddress } = + streamServer.generateCredentials({ + asset: { + code: 'ABC', + scale: 0 + } + }) + + const plugin = createPlugin(streamReceiver) + + const destination = await setupPayment({ + plugin, + destinationAddress, + sharedSecret + }) + await expect( + startQuote({ + plugin, + destination, + amountToSend: 100, + sourceAsset: { + code: 'some really weird currency', + scale: 0 + } + }) + ).rejects.toBe(PaymentError.ExternalRateUnavailable) + }) + + it('fails if no external price for the destination asset exists', async () => { + const { sharedSecret, ilpAddress: destinationAddress } = + streamServer.generateCredentials({ + asset: { + code: 'THIS_ASSET_CODE_DOES_NOT_EXIST', + scale: 0 + } + }) + + const plugin = createPlugin(streamReceiver) + + const destination = await setupPayment({ + plugin, + destinationAddress, + sharedSecret + }) + await expect( + startQuote({ + plugin, + destination, + amountToSend: 100, + sourceAsset: { + code: 'USD', + scale: 3 + } + }) + ).rejects.toBe(PaymentError.ExternalRateUnavailable) + }) + + it('fails if the external exchange rate is 0', async () => { + const plugin = createPlugin(streamReceiver) + + const { sharedSecret, ilpAddress: destinationAddress } = + streamServer.generateCredentials({ + asset: { + code: 'XYZ', + scale: 0 + } + }) + + const destination = await setupPayment({ + plugin, + destinationAddress, + sharedSecret + }) + await expect( + startQuote({ + plugin, + destination, + amountToSend: '1000', + sourceAsset: { + code: 'ABC', + scale: 0 + }, + prices: { + // Computing this rate would be a divide-by-0 error, + // so the rate is "unavailable" rather than quoted as 0 + ABC: 1, + XYZ: 0 + } + }) + ).rejects.toBe(PaymentError.ExternalRateUnavailable) + }) + + it('fails it the probed rate is below the minimum rate', async () => { + const plugin = createPlugin(createSlippageMiddleware(0.02), streamReceiver) + + const asset = { + code: 'ABC', + scale: 4 + } + + const { sharedSecret, ilpAddress: destinationAddress } = + streamServer.generateCredentials() + + const destination = await setupPayment({ + plugin, + destinationAddress, + destinationAsset: asset, + sharedSecret + }) + await expect( + startQuote({ + plugin, + destination, + amountToSend: '1000', + sourceAsset: asset, + slippage: 0.01 + }) + ).rejects.toBe(PaymentError.InsufficientExchangeRate) + }) + + it('fails if the probed rate is 0', async () => { + const sourceAsset = { + code: 'BTC', + scale: 8 + } + const destinationAsset = { + code: 'EUR', + scale: 0 + } + const prices = { + BTC: 9814.04, + EUR: 1.13 + } + + const plugin = createPlugin( + createMaxPacketMiddleware(Int.from(1000)!), + createRateMiddleware( + new RateBackend(sourceAsset, destinationAsset, prices) + ), + streamReceiver + ) + + const { sharedSecret, ilpAddress: destinationAddress } = + streamServer.generateCredentials() + + const destination = await setupPayment({ + plugin, + destinationAddress, + destinationAsset, + sharedSecret + }) + await expect( + startQuote({ + plugin, + destination, + amountToSend: '1000', + sourceAsset, + prices + }) + ).rejects.toBe(PaymentError.InsufficientExchangeRate) + }) + + it('fails if probed rate is very close to the minimum', async () => { + const [senderPlugin1, senderPlugin2] = MirrorPlugin.createPair() + const [receiverPlugin1, receiverPlugin2] = MirrorPlugin.createPair() + + const prices = { + BTC: 9814.04, + EUR: 1.13 + } + + // Override with rate backend for custom rates + let backend: CustomBackend + const deps = reduct( + (Constructor) => Constructor.name === 'RateBackend' && backend + ) + backend = new CustomBackend(deps) + backend.setPrices(prices) + + const sourceAsset = { + assetCode: 'BTC', + assetScale: 8 + } + + const app = createApp( + { + ilpAddress: 'private.larry', + spread: 0.0005, + accounts: { + sender: { + relation: 'child', + plugin: senderPlugin2, + maxPacketAmount: '1000', + ...sourceAsset + }, + receiver: { + relation: 'child', + assetCode: 'EUR', + assetScale: 6, + plugin: receiverPlugin1 + } + } + }, + deps + ) + await app.listen() + + const streamServer = await createServer({ + plugin: receiverPlugin2 + }) + + const { sharedSecret, destinationAccount: destinationAddress } = + streamServer.generateAddressAndSecret() + + streamServer.on('connection', (conn: Connection) => { + conn.on('stream', (stream: DataAndMoneyStream) => { + stream.setReceiveMax(Long.MAX_UNSIGNED_VALUE) + }) + }) + + const destination = await setupPayment({ + plugin: senderPlugin1, + destinationAddress, + sharedSecret + }) + await expect( + startQuote({ + plugin: senderPlugin1, + destination, + amountToSend: 100_000, + sourceAsset: { + code: 'BTC', + scale: 8 + }, + // Slippage/minExchangeRate is far too close to the real spread/rate + // to perform the payment without rounding errors, since the max packet + // amount of 1000 doesn't allow more precision. + slippage: 0.0005001, + prices + }) + ).rejects.toBe(PaymentError.InsufficientExchangeRate) + + await app.shutdown() + await streamServer.close() + }) + + it('discovers precise max packet amount from F08s without metadata', async () => { + const maxPacketAmount = 300324 + let largestAmountReceived = 0 + + let numberOfPackets = 0 + + const plugin = createPlugin( + // Tests the max packet state transition from precise -> imprecise + createMaxPacketMiddleware(Int.from(1_000_000)!), + // Add middleware to return F08 errors *without* metadata + // and track the greatest packet amount that's sent + async (prepare, next) => { + numberOfPackets++ + + if (+prepare.amount > maxPacketAmount) { + return { + code: IlpError.F08_AMOUNT_TOO_LARGE, + message: '', + triggeredBy: '', + data: Buffer.alloc(0) + } + } else { + largestAmountReceived = Math.max( + largestAmountReceived, + +prepare.amount + ) + return next(prepare) + } + }, + streamReceiver + ) + + const asset = { + code: 'ABC', + scale: 0 + } + + const { sharedSecret, ilpAddress: destinationAddress } = + streamServer.generateCredentials() + + const destination = await setupPayment({ + plugin, + sharedSecret, + destinationAddress, + destinationAsset: asset + }) + const { maxPacketAmount: discoveredMaxPacket } = await startQuote({ + plugin, + destination, + amountToSend: 40_000_000, + sourceAsset: asset + }) + + // If STREAM did discover the max packet amount, + // since the rate is 1:1, the largest packet the receiver got + // should be exactly the max packet amount + expect(largestAmountReceived).toBe(maxPacketAmount) + expect(discoveredMaxPacket.toString()).toBe(maxPacketAmount.toString()) + + // It should take relatively few packets to complete the binary search. + // Checks against duplicate amounts being sent in parallel + expect(numberOfPackets).toBeLessThan(40) + }, 10_000) + + it('supports 1:1 rate with no max packet amount', async () => { + const plugin = createPlugin(streamReceiver) + const { sharedSecret, ilpAddress: destinationAddress } = + streamServer.generateCredentials() + const asset = { + code: 'ABC', + scale: 0 + } + + const destination = await setupPayment({ + plugin, + sharedSecret, + destinationAddress, + destinationAsset: asset + }) + const { maxPacketAmount } = await startQuote({ + plugin, + destination, + amountToSend: 10, + sourceAsset: asset + }) + expect(maxPacketAmount).toBe(Int.MAX_U64.value) + }) +}) diff --git a/packages/pay/test/utils.spec.ts b/packages/pay/test/utils.spec.ts new file mode 100644 index 0000000000..ca925f442b --- /dev/null +++ b/packages/pay/test/utils.spec.ts @@ -0,0 +1,221 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { describe, it, expect } from '@jest/globals' +import { AccountUrl, Int, Ratio, PositiveInt, Counter } from '../src' +import Long from 'long' + +describe('account urls', () => { + it('AccountUrl#fromPaymentPointer', () => { + expect(AccountUrl.fromPaymentPointer('example.com')).toBeUndefined() + expect( + AccountUrl.fromPaymentPointer('$user:pass@example.com') + ).toBeUndefined() + expect(AccountUrl.fromPaymentPointer('$localhost:3000')).toBeUndefined() + expect( + AccountUrl.fromPaymentPointer('$example.com?foo=bar') + ).toBeUndefined() + expect(AccountUrl.fromPaymentPointer('$example.com#hash')).toBeUndefined() + + expect( + AccountUrl.fromPaymentPointer('$example.com/alice')!.toString() + ).toBe('https://example.com/alice') + }) + + it('AccountUrl#fromUrl', () => { + expect(AccountUrl.fromUrl('http://wallet.example')!.toString()).toBe( + 'http://wallet.example/.well-known/pay' + ) + expect( + AccountUrl.fromUrl('https://user:pass@wallet.example') + ).toBeUndefined() + expect(AccountUrl.fromUrl('https://wallet.example:8080/')).toBeUndefined() + + expect( + AccountUrl.fromUrl('https://wallet.example/account?foo=bar')!.toString() + ).toBe('https://wallet.example/account?foo=bar') + }) + + it('AccountUrl#toEndpointUrl', () => { + expect( + AccountUrl.fromPaymentPointer('$cool.wallet.co')!.toEndpointUrl() + ).toBe('https://cool.wallet.co/.well-known/pay') + expect( + AccountUrl.fromUrl( + 'https://user.example?someId=123#bleh' + )!.toEndpointUrl() + ).toBe('https://user.example/.well-known/pay?someId=123#bleh') + expect(AccountUrl.fromUrl('https://user.example')!.toEndpointUrl()).toBe( + 'https://user.example/.well-known/pay' + ) + }) + + it('AccountUrl#toBaseUrl', () => { + expect(AccountUrl.fromPaymentPointer('$cool.wallet.co')!.toBaseUrl()).toBe( + 'https://cool.wallet.co/.well-known/pay' + ) + expect( + AccountUrl.fromUrl('https://user.example?someId=123#bleh')!.toBaseUrl() + ).toBe('https://user.example/.well-known/pay') + expect(AccountUrl.fromUrl('https://user.example')!.toBaseUrl()).toBe( + 'https://user.example/.well-known/pay' + ) + }) + + it('AccountUrl#toString', () => { + expect(AccountUrl.fromPaymentPointer('$wallet.example')!.toString()).toBe( + 'https://wallet.example/.well-known/pay' + ) + expect( + AccountUrl.fromUrl( + 'https://wallet.example/user/account/?baz#bleh' + )!.toString() + ).toBe('https://wallet.example/user/account?baz#bleh') + }) + + it('AccountUrl#toPaymentPointer', () => { + expect( + AccountUrl.fromUrl('https://somewebsite.co/')!.toPaymentPointer() + ).toBe('$somewebsite.co') + expect( + AccountUrl.fromUrl('https://user.example?someId=123')!.toPaymentPointer() + ).toBeUndefined() + expect( + AccountUrl.fromUrl('https://example.com/bob/#hash')!.toPaymentPointer() + ).toBeUndefined() + expect( + AccountUrl.fromUrl('http://somewebsite.co/')!.toPaymentPointer() + ).toBeUndefined() + + expect( + AccountUrl.fromPaymentPointer('$example.com/')!.toPaymentPointer() + ).toBe('$example.com') + expect( + AccountUrl.fromPaymentPointer('$example.com/charlie/')!.toPaymentPointer() + ).toBe('$example.com/charlie') + expect( + AccountUrl.fromPaymentPointer('$example.com/charlie')!.toPaymentPointer() + ).toBe('$example.com/charlie') + }) +}) + +describe('integer operations', () => { + it('Int#from', () => { + expect(Int.from(Int.ONE)).toEqual(Int.ONE) + expect(Int.from(Int.MAX_U64)).toEqual(Int.MAX_U64) + + expect(Int.from('1000000000000000000000000000000000000')?.value).toBe( + BigInt('1000000000000000000000000000000000000') + ) + expect(Int.from('1')?.value).toBe(BigInt(1)) + expect(Int.from('0')?.value).toBe(BigInt(0)) + expect(Int.from('-2')).toBeUndefined() + expect(Int.from('2.14')).toBeUndefined() + + expect(Int.from(Long.UZERO)).toEqual(Int.ZERO) + expect(Int.from(Long.UONE)).toEqual(Int.ONE) + expect(Int.from(Long.MAX_UNSIGNED_VALUE)).toEqual(Int.MAX_U64) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(Int.from({} as any)).toBeUndefined() + }) + + it('Int#modulo', () => { + expect(Int.from(5)!.modulo(Int.from(3) as PositiveInt)).toEqual(Int.TWO) + expect(Int.from(45)!.modulo(Int.from(45) as PositiveInt)).toEqual(Int.ZERO) + }) + + it('Int#orLesser', () => { + const a = Int.ONE + const b = Int.ZERO + expect(a.orLesser()).toBe(a) + expect(a.orLesser(b)).toBe(b) + }) + + it('Int#toLong', () => { + expect(Int.from(1234)!.toLong()).toEqual(Long.fromNumber(1234, true)) + expect(Int.MAX_U64.toLong()).toEqual(Long.MAX_UNSIGNED_VALUE) + expect(Int.MAX_U64.add(Int.ONE).toLong()).toBeUndefined() + }) + + it('Int#toRatio', () => { + expect(Int.ONE.toRatio()).toEqual(Ratio.of(Int.ONE, Int.ONE)) + expect(Int.MAX_U64.toRatio()).toEqual(Ratio.of(Int.MAX_U64, Int.ONE)) + }) + + it('Ratio#from', () => { + expect(Ratio.from(2)).toEqual(Ratio.of(Int.TWO, Int.ONE)) + expect(Ratio.from(12.34)).toEqual( + Ratio.of(Int.from(1234)!, Int.from(100) as PositiveInt) + ) + expect(Ratio.from(0)).toEqual(Ratio.of(Int.ZERO, Int.ONE)) + expect(Ratio.from(NaN)).toBeUndefined() + expect(Ratio.from(Infinity)).toBeUndefined() + }) + + it('Ratio#floor', () => { + expect(Ratio.from(2.999)!.floor()).toEqual(Int.TWO.value) + expect(Ratio.from(0)!.floor()).toEqual(Int.ZERO.value) + expect(Ratio.from(100.1)!.floor()).toEqual(Int.from(100)!.value) + }) + + it('Ratio#ceil', () => { + expect(Ratio.from(2.999)!.ceil()).toEqual(BigInt(3)) + expect(Ratio.from(0)!.ceil()).toEqual(BigInt(0)) + expect(Ratio.from(100.1)!.ceil()).toEqual(BigInt(101)) + }) + + it('Ratio#reciprocal', () => { + expect(Ratio.of(Int.ONE, Int.TWO).reciprocal()).toEqual( + Ratio.of(Int.TWO, Int.ONE) + ) + expect(Ratio.of(Int.TWO, Int.ONE).reciprocal()).toEqual( + Ratio.of(Int.ONE, Int.TWO) + ) + expect(Ratio.of(Int.ZERO, Int.ONE).reciprocal()).toBeUndefined() + }) + + it('Ratio#isEqualTo', () => { + expect( + Ratio.of(Int.from(8)!, Int.TWO).isEqualTo(Ratio.of(Int.from(4)!, Int.ONE)) + ).toBe(true) + expect( + Ratio.of(Int.from(0)!, Int.TWO).isEqualTo(Ratio.of(Int.from(4)!, Int.ONE)) + ).toBe(false) + }) + + it('Ratio#toString', () => { + expect(Ratio.of(Int.from(4)!, Int.ONE).toString()).toBe('4') + expect(Ratio.of(Int.ONE, Int.TWO).toString()).toBe('0.5') + expect(Ratio.of(Int.ONE, Int.from(3) as PositiveInt).toString()).toBe( + (1 / 3).toString() + ) + }) + + it('Ratio#toJSON', () => { + expect(JSON.stringify(Ratio.of(Int.ZERO, Int.ONE))).toBe('["0","1"]') + expect( + Ratio.of(Int.from(821)!, Int.from(1200) as PositiveInt).toJSON() + ).toEqual(['821', '1200']) + }) +}) + +describe('counter', () => { + it('Counter#from', () => { + expect(Counter.from(NaN)).toBeUndefined() + expect(Counter.from(Infinity)).toBeUndefined() + expect(Counter.from(-1)).toBeUndefined() + expect(Counter.from(0)).toBeDefined() + expect(Counter.from(1)).toBeDefined() + }) + + it('Counter#getCount', () => { + expect(Counter.from(0)!.getCount()).toBe(0) + }) + + it('Counter#increment', () => { + const c = Counter.from(2)! + c.increment() + expect(c.getCount()).toBe(3) + c.increment() + expect(c.getCount()).toBe(4) + }) +}) diff --git a/packages/pay/tsconfig.build.json b/packages/pay/tsconfig.build.json new file mode 100644 index 0000000000..7027618be4 --- /dev/null +++ b/packages/pay/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "composite": true, + "baseUrl": ".", + "rootDir": ".", + "outDir": "dist", + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" + }, + "include": ["src/**/*"] +} diff --git a/packages/stream-receiver/CHANGELOG.md b/packages/stream-receiver/CHANGELOG.md new file mode 100644 index 0000000000..92b86c924c --- /dev/null +++ b/packages/stream-receiver/CHANGELOG.md @@ -0,0 +1,52 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.3.3-alpha.3](https://github.com/interledgerjs/interledgerjs/compare/@interledger/stream-receiver@0.3.3-alpha.2...@interledger/stream-receiver@0.3.3-alpha.3) (2022-09-28) + +**Note:** Version bump only for package @interledger/stream-receiver + +## [0.3.3-alpha.2](https://github.com/interledgerjs/interledgerjs/compare/@interledger/stream-receiver@0.3.3-alpha.1...@interledger/stream-receiver@0.3.3-alpha.2) (2022-08-18) + +**Note:** Version bump only for package @interledger/stream-receiver + +## [0.3.3-alpha.1](https://github.com/interledgerjs/interledgerjs/compare/@interledger/stream-receiver@0.3.3-alpha.0...@interledger/stream-receiver@0.3.3-alpha.1) (2022-05-04) + +**Note:** Version bump only for package @interledger/stream-receiver + +## [0.3.3-alpha.0](https://github.com/interledgerjs/interledgerjs/compare/@interledger/stream-receiver@0.3.2...@interledger/stream-receiver@0.3.3-alpha.0) (2022-04-27) + +**Note:** Version bump only for package @interledger/stream-receiver + +## [0.3.2](https://github.com/interledgerjs/interledgerjs/compare/@interledger/stream-receiver@0.3.1...@interledger/stream-receiver@0.3.2) (2021-10-25) + +**Note:** Version bump only for package @interledger/stream-receiver + +## [0.3.1](https://github.com/interledgerjs/interledgerjs/compare/@interledger/stream-receiver@0.3.0...@interledger/stream-receiver@0.3.1) (2021-10-07) + +**Note:** Version bump only for package @interledger/stream-receiver + +# [0.3.0](https://github.com/interledgerjs/interledgerjs/compare/@interledger/stream-receiver@0.2.2...@interledger/stream-receiver@0.3.0) (2021-08-03) + +### Bug Fixes + +- **pay:** comments ([4cd20c8](https://github.com/interledgerjs/interledgerjs/commit/4cd20c8b2dd80d0f72042913649bbd3a36a21461)) + +### Features + +- **pay:** top-level api, docs, spsp improvements ([82537ee](https://github.com/interledgerjs/interledgerjs/commit/82537ee1d845d400a3e9a9351ad4d5ddd0c293d9)) + +## [0.2.2](https://github.com/interledgerjs/interledgerjs/compare/@interledger/stream-receiver@0.2.1...@interledger/stream-receiver@0.2.2) (2020-07-27) + +**Note:** Version bump only for package @interledger/stream-receiver + +## [0.2.1](https://github.com/interledgerjs/interledgerjs/compare/@interledger/stream-receiver@0.2.0...@interledger/stream-receiver@0.2.1) (2020-07-27) + +**Note:** Version bump only for package @interledger/stream-receiver + +# 0.2.0 (2020-07-24) + +### Features + +- stateless stream receiver ([aed91d8](https://github.com/interledgerjs/interledgerjs/commit/aed91d85c06aa73af77a8c3891d388257b74ede8)) diff --git a/packages/stream-receiver/README.md b/packages/stream-receiver/README.md new file mode 100644 index 0000000000..d7db01e56e --- /dev/null +++ b/packages/stream-receiver/README.md @@ -0,0 +1,300 @@ +## `stream-receiver` :moneybag: + +> Simple & composable stateless STREAM receiver + +[![NPM Package](https://img.shields.io/npm/v/@interledger/stream-receiver.svg?style=flat&logo=npm)](https://npmjs.org/package/@interledger/stream-receiver) +[![GitHub Actions](https://img.shields.io/github/workflow/status/interledgerjs/interledgerjs/master.svg?style=flat&logo=github)](https://github.com/interledgerjs/interledgerjs/actions?query=workflow%3Amaster) +[![Codecov](https://img.shields.io/codecov/c/github/interledgerjs/interledgerjs/master.svg?logo=codecov&flag=stream_receiver)](https://codecov.io/gh/interledgerjs/interledgerjs/tree/master/packages/stream-receiver/src) +[![Prettier](https://img.shields.io/badge/code_style-prettier-brightgreen.svg)](https://prettier.io/) + +- [Overview](#overview) + - [Which version?](#which-version) +- [Install](#install) +- [Guide](#guide) + 1. [Generate server secret](#1-generate-server-secret) + 2. [Integrate an Open Payments server](#2-integrate-an-open-payments-server) + 3. [Integrate the STREAM server](#3-integrate-the-stream-server) + - [_Optional_: Perform cross-currency conversion](#optional-perform-cross-currency-conversion) + - [Reply to the ILP Prepare](#reply-to-the-ilp-prepare) + - [Credit balances](#credit-balances) +- [API](#api) + - **[`ServerOptions`](#serveroptions)** + - **[`StreamServer`](#streamserver)** + - **[`ConnectionDetails`](#connectiondetails)** + - **[`StreamCredentials`](#streamcredentials)** + - **[`IncomingMoney`](#incomingmoney)** + +## Overview + +[STREAM](https://interledger.org/rfcs/0029-stream/) is a protocol between a sender and receiver on the Interledger network to coordinate a payment of many smaller Interledger packets. First, a client requests credentials from a server: a shared encryption key and a unique ILP address of the recipient, which may be exchanged using [Open Payments](https://openpayments.dev) or [SPSP](https://interledger.org/rfcs/0009-simple-payment-setup-protocol/). Using these credentials, a STREAM client initiates a connection to a STREAM server by sending it an ILP packet, containing special, encrypted STREAM messages. Then, either the client or server may send additional ILP packets containing STREAM messages to one another with money and/or data over Interledger. + +`stream-receiver` is a STREAM server to "unlock" and accept incoming money. It's a simple function that takes an incoming ILP Prepare packet, validates and authenticates its STREAM data, and returns an ILP Fulfill or Reject with corresponding STREAM data to reply to the sender. The API consumer can choose to accept or decline incoming money before the packet is fulfilled, to simplify integration with their own balance tracking system. + +### Which version? + +[`ilp-protocol-stream`](https://github.com/interledgerjs/ilp-protocol-stream) is a general-purpose STREAM implementation that can operate as both a client or a server to simultaneously send & receive payments. By contrast, this module is recommended for integration with an Open Payments server, and is tailored to receive incoming payments. + +## Install + +```sh +npm i @interledger/stream-receiver +``` + +Or using Yarn: + +```sh +yarn add @interledger/stream-receiver +``` + +## Guide + +To receive STREAM payments on Interledger, these components are necessary: + +- **STREAM server**: to receive and fulfill incoming ILP packets via an Interledger connector, and coordinate the payment with the STREAM sender via STREAM messages +- **Open Payments/SPSP server**: an HTTP server to setup payments and share connection credentials with the sending client +- **Persistent data store** to track invoice balances and/or the total amount received over each STREAM connection + +This guide walks through how to wire these components together, and how they may be deployed by a wallet that services and accepts incoming Interledger payments on behalf of many users. + +### 1. Generate server secret + +First, the operator should randomly generate a 32 byte server secret seed, which is used in both the STREAM server and Open Payments/SPSP server. This secret is used to statelessly generate and derive connection credentials, so incoming ILP Prepare packets can be decrypted and fulfilled without persisting each set of credentials in a database. This also enables the STREAM server and Open Payments/SPSP server to operate in separate processes. + +An operator is recommended to periodically rotate their server secret. Any credentials generated using an older shared secret would not be accepted if it changes, but since credentials are ephemeral and designed to be used immediately, the effect for clients should be minimal. + +### 2. Integrate an Open Payments server + +An [Open Payments](https://openpayments.dev/) server hosts an HTTP API to setup and authorize payments, including invoice-based push payments and mandate-based pull payments. To implement the APIs for such a server, refer to the [full specification](https://docs.openpayments.dev/api). + +Here, we'll demonstrate how to generate connection credentials, referred to as _[payment details](https://docs.openpayments.dev/payments)_ in the Open Payments spec, and return them to the client. + +First, create a **[`StreamServer`](#streamserver)** with the base ILP address of the STREAM server and previously generated server secret: + +```js +import { StreamServer } from '@interledger/stream-receiver' + +const server = new StreamServer({ + serverSecret: Buffer.from(PROCESS.env.SERVER_SECRET, 'hex'), // Example: '61a55774643daa45bec703385ea6911dbaaaa9b4850b77884f2b8257eef05836' + serverAddress: PROCESS.env.SERVER_ADDRESS // Example: 'g.mywallet.receiver' +}) +``` + +Then, for the proper endpoints within the server (this snippet uses [Express](https://expressjs.com/)), generate and return a new set of connection credentials: + +```js +express().get('/.well-known/open-payments', (req, res) => { + const credentials = server.generateCredentials() + return req.json({ + ilpAddress: credentials.ilpAddress, + sharedSecret: credentials.sharedSecret.toString('base64') + }) +}) +``` + +The credentials include a unique ILP address to identify this connection, and a shared encryption key so the client can encrypt STREAM messages so other connectors cannot read or tamper with them. The encryption key also enables them to generate conditions for ILP Prepare packets that this STREAM server can fulfill. Note: these credentials are only valid when used with this STREAM server implementation, and not `ilp-procotol-stream`, or the Java or Rust implementations. + +To support [STREAM receipts](https://interledger.org/rfcs/0039-stream-receipts/), a feature that enables sender to prove to a third-party verifier how much has been delivered, input the nonce and secret from the request when generating credentials: + +```js +server.generateCredentials({ + receiptSetup: { + nonce: Buffer.from(req.headers['Receipt-Nonce'], 'base64'), + secret: Buffer.from(req.headers['Receipt-Secret'], 'base64') + } +}) +``` + +If generating credentials for an [Open Payments invoice](https://docs.openpayments.dev/invoices), the operator could encode necessary metadata into the generated credentials using the `paymentTag` option, such as an invoice ID: + +```js +server.generateCredentials({ + paymentTag: 'a6bbd8e4-864a-4e52-b037-7938e00e6537' +}) +``` + +The `paymentTag` will be exposed on each incoming packet so the operator can correlate it with the correct user. The operator can choose any format or data to encode into the `paymentTag`, so long as it's limited to ASCII characters. These details will be securely encrypted into the ILP address, so neither the sender nor any other connectors can read them. + +Lastly, only when generating credentials for an [SPSP](https://interledger.org/rfcs/0009-simple-payment-setup-protocol/) request (but unnecessary for Open Payments), the operator should provide the asset and denomination of the recipient, which will later be shared with the client over STREAM: + +```js +server.generateCredentials({ + asset: { + code: 'USD', + scale: 6 + } +}) +``` + +### 3. Integrate the STREAM server + +First, the operator must be connected to an Interledger network. They may operate one or multiple connector instances, such as the JavaScript [`ilp-connector`](https://github.com/interledgerjs/ilp-connector) or [Java connector](https://github.com/interledger4j/ilpv4-connector). + +To handle incoming ILP Prepare packets, they could use a custom [`ilp-connector` middleware](https://github.com/interledgerjs/ilp-connector#extensibility-middlewares), or a [plugin](https://github.com/interledgerjs?q=plugin&type=&language=) that connects to their connector, like so: + +```js +const plugin = new Plugin({ ... }) +await plugin.connect() + +plugin.registerDataHandler(async (data) => { + // STREAM server logic will be included here +}) +``` + +To integrate this STREAM server, the operator handles incoming ILP Prepare packets, and then uses this library to create the appropriate ILP reply packet with STREAM messages in response. This also allows them to intermix their own accounting logic to credit the incoming packet. + +As with the Open Payments/SPSP server, first, instantiate another **[`StreamServer`](#streamserver)** using the same secret and ILP address, which is used to re-derive the previously generated credentials for each connection: + +```js +import { StreamServer } from '@interledger/stream-receiver' + +const server = new StreamServer({ + serverSecret: Buffer.from(process.env.SERVER_SECRET, 'hex'), + serverAddress: process.env.SERVER_ADDRESS +}) +``` + +#### _Optional_: Perform cross-currency conversion + +Some deployments may receive payments on behalf of many user accounts denominated in different currencies, in which the operator performs foreign exchange into the final currency of each user account. The particular destination currency of each packet may be unknown until the user account is known. + +When the connection was generated, the operator should encode the metadata into the `paymentTag` field necessary to lookup the asset they should convert into, such as the user account or invoice that payment is attributed to. + +To extract the `paymentTag` from when the connection credentials were generated, provide the destination ILP address of an incoming ILP Prepare into the `decodePaymentTag` method of the **[`StreamServer`](#streamserver)**: + +```js +const prepare = deserializeIlpPrepare(data) +const tag = server.decodePaymentTag(prepare.destination) +``` + +If no payment tag was encoded, or the token in the ILP address could not be decrypted, `undefined` will be returned, otherwise the `paymentTag` will be returned as a `string`. + +Accordingly, adjust the `amount` field of the ILP Prepare, converting into the destination currency, before the STREAM server handles the packet. If foreign exchange is performed, it must be applied to all packets for a connection, including unfulfillable STREAM packets, since they're used for the STREAM sender to probe the exchange rate. + +#### Reply to the ILP Prepare + +Next, hand the ILP Prepare off to the STREAM server, providing the adjusted ILP Prepare into the `createReply` method on the **[`StreamServer`](#streamserver)**: + +```js +const moneyOrReply = server.createReply(prepare) +``` + +The STREAM server will ensure the packet is addressed correctly, decrypt the STREAM messages from the sender, validate they are authentic, ensure the packet meets its minimum exchange rate, and create appropriate STREAM messages in response. Then, the STREAM server will return an **[`IlpReject`](../ilp-packet/README.md#ilpreject)** packet, or an **[`IncomingMoney`](#incomingmoney)** instance, so the operator can optionally choose to accept or decline the incoming funds. + +If the STREAM server directly returns a reply packet, no funds can be received (for example, there were no authentic STREAM messages, or the sender may have restricted the packet as unfulfillable). Reply to the ILP Prepare with that ILP Reject or ILP Fulfill (which is only returned if the amount of the Prepare was 0): + +```js +import { isIlpReply, serializeIlpReply } from 'ilp-packet' + +// ... + +if (isIlpReply(moneyOrReply)) { + return serializeIlpReply(moneyOrReply) +} +``` + +Alternatively, the ILP Prepare contains funds that may be fulfilled. The operator can add their own asynchronous logic to choose to accept or decline the packet, and use the **[`IncomingMoney`](#incomingmoney)** instance to create the corresponding ILP reply packet. + +For instance, to accept the incoming money and create the corresponding ILP Fulfill packet, call the `accept` method: + +```js +serializeIlpFulfill(moneyOrReply.accept()) +``` + +If the recipient cannot accept the funds, it can choose to temporarily or permanently decline them. To temporarily decline, call `temporaryDecline`, which creates an ILP Reject that instructs the STREAM sender to send packets less frequently: + +```js +serializeIlpReject(moneyOrReply.temporaryDecline()) +``` + +Alternatively, call `finalDecline`, which creates an ILP Reject that instructs the sender to close the connection and stop sending packets altogether. + +#### Credit balances + +Before replying with an ILP Fulfill, the packet should be correctly accounted for. + +If the packet pays into an Open Payments invoice per the encoded `paymentTag`, the operator should credit that invoice with the delivered amount into its stateful balance system. If the packet is fulfilled, the STREAM server will inform the sender that the `amount` field of the ILP Prepare was the amount delivered to the recipient, which they will use for their own accounting. + +If the connection supports STREAM receipts, the operator should also track the total amount received over the connection in a stateful system per each `connectionId`. Then, before accepting or declining the money, they should set the total amount received, so the STREAM server can sign and include a receipt in its reply. For example: + +```js +const totalReceived = await addIncomingFunds( + connection.connectionId, + prepare.amount +) +moneyOrReply.setTotalReceived(totalReceived) + +// ... + +serializeIlpFulfill(moneyOrReply.accept()) +``` + +Since connections are very short-lived, the operator may periodically purge stale connection balances. Note: these connection balances are distinct from, for example, Open Payments invoice balances, and must be accounted for separately. + +## API + +Here, ILP packets are provided and returned _deserialized_ using interfaces exported from [`ilp-packet`](../ilp-packet): **[`IlpPrepare`](../ilp-packet/README.md#ilpprepare)**, **[`IlpFulfill`](../ilp-packet/README.md#ilpfulfill)**, and **[`IlpReject`](../ilp-packet/README.md#ilpreject)**. + +#### `ServerOptions` + +> Interface + +Parameters to statelessly generate new STREAM connection credentials and handle incoming packets for STREAM connections. + +| Property | Type | Description | +| :------------------ | :------- | :-------------------------------------------------------------------------------- | +| **`serverSecret`** | `Buffer` | Secret seed used to statelessly derive keys for many STREAM connections. | +| **`serverAddress`** | `string` | Base ILP address of this STREAM server to access it over the Interledger network. | + +#### `StreamServer` + +> `new (options: ServerOptions): StreamServer` + +Generate and validate STREAM connection credentials so a client may send packets to the STREAM server. This enables an Open Payments or SPSP server to generate new connections separately from the STREAM server and ILP-connected infrastructure, so long as they are configured with the same server secret and ILP address. + +| Property | Type | Description | +| :------------------------ | :--------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`generateCredentials`** | `(options?: ConnectionDetails): StreamCredentials` | Generate credentials to return to a STREAM client so they may establish a connection to this STREAM server. Throws if the receipt nonce or secret are invalid lengths, the asset scale was not 0-255, or that data cannot fit within an ILP address. | +| **`decodePaymentTag`** | `(destinationAddress: string): string \| undefined` | Extract the `paymentTag` from the given destination ILP address, or return `undefined` if the connection token is invalid or no payment tag was encoded. | +| **`createReply`** | `(prepare: IlpPrepare) => IncomingMoney \| IlpReply` | Process the incoming ILP Prepare within the STREAM server: ensure it's addressed to the server, decrypt the sender's STREAM messages, validate their authenticity, ensure the packet meets its minimum exchange rate, and create appropriate STREAM messages in response. If the packet does nto carry money, an `IlpReject` or `IlpFulfill` (if the Prepare was for 0) is directly returned. If the packet is valid and fulfillable, an **[`IncomingMoney`](#incomingmoney)** instance is returned to accept or decline the funds and generate the appropriate reply. | + +#### `ConnectionDetails` + +> Interface + +Application-layer metadata to encode within the credentials of a new STREAM connection. + +| Property | Type | Description | +| :------------------------ | :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`paymentTag`** | (_Optional_) `string` | Arbitrary data to attribute or handle an incoming payment. For example, an identifier to correlate which user account or invoice the payment should be credited to. | +| **`receiptSetup`** | (_Optional_) `Object` | Parameters to generate authentic STREAM receipts so a third party may verify incoming payments. | +| **`receiptSetup.nonce`** | `Buffer` | 16-byte STREAM receipt nonce | +| **`receiptSetup.secret`** | `Buffer` | 32-byte STREAM receipt secret | +| **`asset`** | (_Optional_) `Object` | Destination asset details of the recipient's Interledger account, to share with the sender. **Note**: should only be provided if generating credentials for an SPSP request, but is unnecessary for Open Payments. | +| **`asset.code`** | `string` | Asset code or symbol identifying the currency of the recipient account. | +| **`asset.scale`** | `number` | Precision of the asset denomination: number of decimal places of the ordinary unit, between 0 and 255 (inclusive). | + +#### `StreamCredentials` + +> Interface + +Credentials uniquely identifying a connection, to provide to a STREAM client to establish an authenticated connection with this receiver. + +| Property | Type | Description | +| :----------------- | :------- | :----------------------------------------------------------------------------------------------------------------------- | +| **`sharedSecret`** | `Buffer` | 32-byte seed to encrypt and decrypt STREAM messages, and generate ILP packet fulfillments. | +| **`ilpAddress`** | `string` | ILP address of the recipient account, identifying this connection, for the client to send packets to this STREAM server. | + +#### `IncomingMoney` + +> Interface + +Pending STREAM request and in-flight ILP Prepare with funds that may be fulfilled or rejected. + +| Property | Type | Description | +| :--------------------- | :-------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`connectionId`** | `string` | Unique identifier of this STREAM connection: SHA-256 hash of destination ILP address with token, hex-encoded. | +| **`paymentTag`** | `string \| undefined` | Arbitrary data to attribute or handle an incoming payment, encoded when the credentials were generated. | +| **`setTotalReceived`** | `(totalReceived: Long \| string \| number) => void` | Sign and include a STREAM receipt for the total amount received on this STREAM connection, per `connectionId`, including the additional amount from this packet. Amount must be within the u64 range. | +| **`accept`** | `() => IlpFulfill` | Create an ILP Fulfill to accept the money from this incoming ILP Prepare packet. | +| **`temporaryDecline`** | `() => IlpReject` | Create an ILP Reject to temporarily decline the incoming money: inform the STREAM sender to backoff in time. | +| **`finalDecline`** | `() => IlpReject` | Create an ILP Reject to inform the STREAM sender to close their connection. | diff --git a/packages/stream-receiver/jest.config.js b/packages/stream-receiver/jest.config.js new file mode 100644 index 0000000000..631a92583a --- /dev/null +++ b/packages/stream-receiver/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**'], + coverageReporters: ['text', 'lcov'], + coverageDirectory: 'coverage', + preset: 'ts-jest', + testEnvironment: 'node', + testPathIgnorePatterns: ['/node_modules/', '/dist'] +} diff --git a/packages/stream-receiver/package.json b/packages/stream-receiver/package.json new file mode 100644 index 0000000000..91e7a19977 --- /dev/null +++ b/packages/stream-receiver/package.json @@ -0,0 +1,33 @@ +{ + "name": "@interledger/stream-receiver", + "description": "Simple & composable stateless Interledger STREAM receiver", + "version": "0.3.3-alpha.3", + "author": "Interledger Team ", + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "files": [ + "dist/**/*.js", + "dist/**/*.js.map", + "dist/**/*.d.ts", + "!dist/test/**/*" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "test": "jest", + "cover": "jest --coverage", + "codecov": "curl -s https://codecov.io/bash | bash -s - -s coverage -F stream_receiver" + }, + "dependencies": { + "@types/long": "^4.0.2", + "ilp-logger": "^1.4.5-alpha.2", + "ilp-packet": "^3.1.4-alpha.2", + "ilp-protocol-ildcp": "2.2.4-alpha.2", + "ilp-protocol-stream": "^2.7.2-alpha.2", + "long": "^4.0.0", + "oer-utils": "^5.1.3-alpha.2" + } +} diff --git a/packages/stream-receiver/src/index.ts b/packages/stream-receiver/src/index.ts new file mode 100644 index 0000000000..a3980ed417 --- /dev/null +++ b/packages/stream-receiver/src/index.ts @@ -0,0 +1,496 @@ +import { isValidAssetScale } from 'ilp-protocol-ildcp' +import { Writer, Reader } from 'oer-utils' +import createLogger from 'ilp-logger' +import { + IlpReject, + IlpPrepare, + IlpFulfill, + IlpPacketType, + IlpAddress, + isValidIlpAddress, + IlpError, + IlpReply +} from 'ilp-packet' +import { + hmac, + sha256, + base64url, + ReplyBuilder, + decrypt, + encrypt +} from './utils' +import { + Packet, + ConnectionCloseFrame, + FrameType, + ErrorCode, + ConnectionAssetDetailsFrame, + StreamReceiptFrame +} from 'ilp-protocol-stream/dist/src/packet' +import { LongValue } from 'ilp-protocol-stream/dist/src/util/long' +import { createReceipt } from 'ilp-protocol-stream/dist/src/util/receipt' +import Long from 'long' + +/** Parameters to statelessly generate new STREAM connections and handle incoming packets for STREAM connections. */ +export interface ServerOptions { + /** Secret used to statelessly generate credentials for incoming STREAM connections. */ + serverSecret: Buffer + + /** Base ILP address of this STREAM server to access it over the Interledger network. */ + serverAddress: string +} + +/** Credentials for a client to setup a STREAM connection with a STREAM server */ +export interface StreamCredentials { + /** ILP address of the recipient account, identifying this connection, for the client to send packets to this STREAM server. */ + ilpAddress: IlpAddress + + /** 32-byte seed to encrypt and decrypt STREAM messages, and generate ILP packet fulfillments. */ + sharedSecret: Buffer +} + +/** Pending STREAM request and in-flight ILP Prepare with funds that may be fulfilled or rejected. */ +export interface IncomingMoney { + /** Unique identifier of this STREAM connection: SHA-256 hash of destination ILP address with token, hex-encoded */ + connectionId: string + + /** Arbitrary data to attribute or handle an incoming payment, encoded when the credentials were generated. */ + paymentTag?: string + + /** + * Sign and include and STREAM receipt for the total amount received on this STREAM connection, per `connectionId`, + * including the additional amount from this packet. Amount must be within the u64 range. + */ + setTotalReceived(totalReceived: LongValue): void + + /** Fulfill the money from this incoming ILP Prepare packet */ + accept(): IlpFulfill + + /** Temporarily decline the incoming money: inform STREAM sender to backoff in time (T00 Reject: Temporary Internal Error) */ + temporaryDecline(): IlpReject + + /** Inform the sender to close their connection */ + finalDecline(): IlpReject + + /** Application StreamData frames carried on this Prepare (if any). Experimental accessor. */ + dataFrames?: Array<{ streamId: number; offset: string; data: Buffer }> +} + +/** Application-layer metadata to encode within the credentials of a new STREAM connection. */ +interface ConnectionDetails { + /** + * Arbitrary data to attribute or handle an incoming payment. For example, an identifier to + * correlate which user account the payment should be credited to. + */ + paymentTag?: string + + /** Parameters to generate authentic STREAM receipts so a third party may verify incoming payments. */ + receiptSetup?: { + secret: Buffer + nonce: Buffer + } + + /** + * Destination asset details of the recipient's Interledger account, to share with the sender. + * Note: required for SPSP, but unnecessary for Open Payments credentials. + */ + asset?: { + code: string + scale: number + } +} + +/** + * Format of destination ILP address: + * serverAddress + "." + base64url(encrypt(connectionToken, serverSecret)) + * - Encrypted with AES-256-GCM + * - Random 12-byte IV ensures uniqueness + * + * connectionToken schema: + * =================================== + * + * - `flags` -- UInt8 -- Bit string of enabled features: (1) payment tag, (2) receipt details, (3) asset details + * + * (If payment tag is enabled...) + * - `paymentTag` -- VarOctetString + * + * (If receipts are enabled...) + * - `receiptNonce` -- 16 raw bytes + * - `receiptSecret` -- 32 raw bytes + * + * (If asset details are enabled...) + * - `assetCode` -- VarOctetString + * - `assetScale` -- UInt8 + */ + +/** + * Generate and validate STREAM connection credentials so a client may send packets to the STREAM server. + * This enables an Open Payments or SPSP server to generate new connections separately from the STREAM server + * and ILP-connected infrastructure, so long as they are configured with the same server secret and ILP address. + */ +export class StreamServer { + /** Constant to derive key to encrypt connection tokens in the ILP address */ + private static TOKEN_GENERATION_STRING = Buffer.from( + 'ilp_stream_connection_token' + ) + + /** Constant to derive shared secrets, combined with the connection token */ + private static SHARED_SECRET_GENERATION_STRING = Buffer.from( + 'ilp_stream_shared_secret' + ) + + /** Constant to derive packet decryption key, combined with the shared secret */ + private static ENCRYPTION_KEY_STRING = Buffer.from('ilp_stream_encryption') + + /** Constant to derive packet fulfillments, combined with the shared secret */ + private static FULFILLMENT_GENERATION_STRING = Buffer.from( + 'ilp_stream_fulfillment' + ) + + /** Pre-allocated Buffer to serialize connection tokens (safe since `generateCredentials` is synchronous) */ + private static TOKEN_GENERATION_BUFFER = Buffer.alloc(767) // Max # of base64 characters in ILP address + + /** Flag bits to flip to determine enabled features */ + private static TOKEN_FLAGS = { + PAYMENT_TAG: 1, // 2^0 + RECEIPTS: 2, // 2^1 + ASSET_DETAILS: 4 // 2^2 + } + + /** Base ILP address of the server accessible over its Interledger network */ + private serverAddress: IlpAddress + + /** Derived key for generating shared secrets */ + private sharedSecretKeyGen: Buffer + + /** Derived key for generating connection tokens */ + private connectionTokenKeyGen: Buffer + + constructor({ serverSecret, serverAddress }: ServerOptions) { + if (serverSecret.byteLength !== 32) { + throw new Error('Server secret must be 32 bytes') + } + + if (!isValidIlpAddress(serverAddress)) { + throw new Error('Invalid server base ILP address') + } + + this.serverAddress = serverAddress + this.sharedSecretKeyGen = hmac( + serverSecret, + StreamServer.SHARED_SECRET_GENERATION_STRING + ) + this.connectionTokenKeyGen = hmac( + serverSecret, + StreamServer.TOKEN_GENERATION_STRING + ) + } + + /** + * Generate credentials to return to a STREAM client so they may establish a connection to this STREAM server. + * Throws if the receipt nonce or secret are invalid lengths, the asset scale was not 0-255, + * or that data cannot fit within an ILP address. + */ + generateCredentials(options: ConnectionDetails = {}): StreamCredentials { + const { receiptSetup, asset } = options + + if (receiptSetup) { + if (receiptSetup.nonce.byteLength !== 16) { + throw new Error( + 'Failed to generate credentials: receipt nonce must be 16 bytes' + ) + } + + if (receiptSetup.secret.byteLength !== 32) { + throw new Error( + 'Failed to generate credentials: receipt secret must be 32 bytes' + ) + } + } + + if (asset && !isValidAssetScale(asset.scale)) { + throw new Error('Failed to generate credentials: invalid asset scale') + } + + const paymentTag = options.paymentTag + ? Buffer.from(options.paymentTag, 'ascii') + : undefined + + const flags = + (paymentTag ? StreamServer.TOKEN_FLAGS.PAYMENT_TAG : 0) | + (receiptSetup ? StreamServer.TOKEN_FLAGS.RECEIPTS : 0) | + (asset ? StreamServer.TOKEN_FLAGS.ASSET_DETAILS : 0) + + const writer = new Writer(StreamServer.TOKEN_GENERATION_BUFFER) + writer.writeUInt8(flags) + + if (paymentTag) { + writer.writeVarOctetString(paymentTag) + } + + if (receiptSetup) { + writer.write(receiptSetup.nonce) + writer.write(receiptSetup.secret) + } + + if (asset) { + writer.writeVarOctetString(Buffer.from(asset.code, 'utf8')) + writer.writeUInt8(asset.scale) + } + + const token = encrypt(this.connectionTokenKeyGen, writer.getBuffer()) + const sharedSecret = hmac(this.sharedSecretKeyGen, token) + + const destinationAddress = `${this.serverAddress}.${base64url(token)}` + if (!isValidIlpAddress(destinationAddress)) { + throw new Error( + 'Failed to generate credentials: too much data to encode within an ILP address' + ) + } + + return { + ilpAddress: destinationAddress, + sharedSecret + } + } + + private extractLocalAddressSegment( + destinationAddress: string + ): string | undefined { + const localAddressParts = destinationAddress + .slice(this.serverAddress.length + 1) + .split('.') + if ( + destinationAddress.startsWith(this.serverAddress + '.') && + !!localAddressParts[0] + ) { + return localAddressParts[0] + } + } + + private decryptToken(token: Buffer): ConnectionDetails | undefined { + try { + const details: ConnectionDetails = {} + const decryptedToken = decrypt(this.connectionTokenKeyGen, token) + + const reader = new Reader(decryptedToken) + const flags = reader.readUInt8Number() + + const hasPaymentTag = (flags & StreamServer.TOKEN_FLAGS.PAYMENT_TAG) !== 0 + const hasReceiptDetails = + (flags & StreamServer.TOKEN_FLAGS.RECEIPTS) !== 0 + const hasAssetDetails = + (flags & StreamServer.TOKEN_FLAGS.ASSET_DETAILS) !== 0 + + if (hasPaymentTag) { + details.paymentTag = reader.readVarOctetString().toString('ascii') + } + + if (hasReceiptDetails) { + details.receiptSetup = { + nonce: reader.read(16), + secret: reader.read(32) + } + } + + if (hasAssetDetails) { + details.asset = { + code: reader.readVarOctetString().toString(), + scale: reader.readUInt8Number() + } + } + + return details + } catch (_) { + // No-op: failed decryption or structurally invalid + } + } + + /** + * Extract the `paymentTag` from the given destination ILP address, or return `undefined` + * if the connection token is invalid or no payment tag was encoded. + */ + decodePaymentTag(destinationAddress: string): string | void { + const token = this.extractLocalAddressSegment(destinationAddress) + if (token) { + return this.decryptToken(Buffer.from(token, 'base64'))?.paymentTag + } + } + + /** + * Validate and decrypt the destination ILP address of an incoming ILP Prepare: + * ensure it's addressed to the server and decode encrypted metadata to attribute and handle the payment. + */ + createReply(prepare: IlpPrepare): IncomingMoney | IlpReply { + const connectionId = sha256( + Buffer.from(prepare.destination, 'ascii') + ).toString('hex') + const log = createLogger(`ilp-receiver:${connectionId.slice(0, 6)}`) + const reply = new ReplyBuilder().setIlpAddress(this.serverAddress) + + // Ensure the packet is addressed to us + const localSegment = this.extractLocalAddressSegment(prepare.destination) + if (!localSegment) { + log.trace( + 'got packet not addressed to the receiver. destination=%s', + prepare.destination + ) + return reply.buildReject(IlpError.F02_UNREACHABLE) + } + + const token = Buffer.from(localSegment, 'base64') + const connectionDetails = this.decryptToken(token) + if (!connectionDetails) { + log.trace( + 'invalid connection token: cannot attribute incoming packet. token=%s', + localSegment + ) + return reply.buildReject(IlpError.F06_UNEXPECTED_PAYMENT) + } + const { paymentTag, receiptSetup, asset } = connectionDetails + + log.debug('got incoming Prepare. amount: %s', prepare.amount) + + const sharedSecret = hmac(this.sharedSecretKeyGen, token) + const encryptionKey = hmac(sharedSecret, StreamServer.ENCRYPTION_KEY_STRING) + let streamRequest: Packet + try { + streamRequest = Packet._deserializeUnencrypted( + decrypt(encryptionKey, prepare.data) + ) + } catch (_) { + log.trace('rejecting with F06: failed to decrypt STREAM data') // Inauthentic, could be anyone + return reply.buildReject(IlpError.F06_UNEXPECTED_PAYMENT) + } + + if (+streamRequest.ilpPacketType !== IlpPacketType.Prepare) { + log.warn('rejecting with F00: invalid STREAM packet type') // Sender violated protocol, or intermediaries swapped valid STREAM packets + return reply.buildReject(IlpError.F00_BAD_REQUEST) // Client should not retry, but don't include STREAM data since the request was inauthentic + } + + log.debug( + 'got authentic STREAM request. sequence: %s, min destination amount: %s', + streamRequest.sequence, + streamRequest.prepareAmount + ) + log.trace('STREAM request frames: %o', streamRequest.frames) + + const dataFrames = streamRequest.frames + .filter((f) => f.type === FrameType.StreamData) + .map((f: any) => ({ + streamId: + Number(f.streamId?.toString ? f.streamId.toString() : f.streamId) || + 0, + offset: (f.offset?.toString && f.offset.toString()) || '0', + data: f.data as Buffer + })) + + reply + .setEncryptionKey(encryptionKey) + .setSequence(streamRequest.sequence) + .setReceivedAmount(prepare.amount) + + const closeFrame = streamRequest.frames.find( + (frame): frame is ConnectionCloseFrame => + frame.type === FrameType.ConnectionClose + ) + if (closeFrame) { + log.trace( + 'client closed connection, rejecting with F99. code="%s" message="%s"', + ErrorCode[closeFrame.errorCode], + closeFrame.errorMessage + ) + // Echo connection closes from the client + return reply + .addFrames(new ConnectionCloseFrame(ErrorCode.NoError, '')) + .buildReject(IlpError.F99_APPLICATION_ERROR) + } + + const isNewConnection = streamRequest.frames.some( + (frame) => frame.type === FrameType.ConnectionNewAddress + ) + if (isNewConnection && asset) { + log.trace( + 'got new client address, replying with asset details: %s %s', + asset.code, + asset.scale + ) + reply.addFrames(new ConnectionAssetDetailsFrame(asset.code, asset.scale)) + } + + /** + * Why no `StreamMaxMoney` frame in the reply? + * - Limits on how much money can be received should probably be negotiated + * at the application layer instead of STREAM. The API consumer can optionally + * limit the amount received by declining incoming money, and by default, STREAM + * senders assume there's no limit on the remote maximum + * - `ilp-protocol-stream` sender does not publicly expose the remote amount + * received on each individual stream (it's only tracked for backpressure), + * so it's unnecessary to reply with the total received on a per-stream basis + */ + + const receivedAmount = Long.fromString(prepare.amount, true) + const didReceiveMinimum = receivedAmount.greaterThanOrEqual( + streamRequest.prepareAmount + ) + if (!didReceiveMinimum) { + return reply.buildReject(IlpError.F99_APPLICATION_ERROR) + } + + const fulfillmentKey = hmac( + sharedSecret, + StreamServer.FULFILLMENT_GENERATION_STRING + ) + const fulfillment = hmac(fulfillmentKey, prepare.data) + const isFulfillable = sha256(fulfillment).equals(prepare.executionCondition) + if (!isFulfillable) { + return reply.buildReject(IlpError.F99_APPLICATION_ERROR) + } else if (receivedAmount.isZero()) { + /** + * `ilp-protocol-stream` as a client sometimes handles replies differently if they're sent back in an + * ILP Fulfill vs an ILP Reject, so 0 amount packets should be fulfilled. + * + * For example, replying to a `StreamClose` frame with an F99 Reject in Node 10 results in an infinite loop, + * since `ilp-protocol-stream` re-queues the frame and tries again. Replying with an ILP Fulfill prevents + * this (no money is received since the amount is 0). This issue does not occur in Node 12 (?). + * + * https://github.com/interledgerjs/ilp-protocol-stream/blob/7ad483f5fd1a1d1e4dc58d7eef6a437594646260/src/connection.ts#L1499-L1505 + */ + return reply.buildFulfill(fulfillment) + } + + return { + connectionId, + + paymentTag, + dataFrames: dataFrames.length > 0 ? dataFrames : undefined, + + setTotalReceived: (totalReceived: LongValue) => { + if (receiptSetup) { + /** + * Even if we receive money over multiple streams with different stream IDs, we only generate + * STREAM receipts for streamId=1. There should be no effect for the sender or verifier, + * since the total amount credited to this receipt nonce will still be the same. + * + * Per RFC, client MUST open streams starting with streamId=1. + */ + const receipt = createReceipt({ + ...receiptSetup, + totalReceived, + streamId: 1 + }) + reply.addFrames(new StreamReceiptFrame(1, receipt)) + } + }, + + accept: () => reply.buildFulfill(fulfillment), + + temporaryDecline: () => reply.buildReject(IlpError.T00_INTERNAL_ERROR), + + finalDecline: () => + reply + .addFrames(new ConnectionCloseFrame(ErrorCode.NoError, '')) + .buildReject(IlpError.F99_APPLICATION_ERROR) + } + } +} diff --git a/packages/stream-receiver/src/utils.ts b/packages/stream-receiver/src/utils.ts new file mode 100644 index 0000000000..5965416bed --- /dev/null +++ b/packages/stream-receiver/src/utils.ts @@ -0,0 +1,130 @@ +import { Frame, Packet } from 'ilp-protocol-stream/dist/src/packet' +import Long from 'long' +import { + IlpPacketType, + IlpAddress, + IlpFulfill, + IlpReject, + IlpErrorCode +} from 'ilp-packet' +import { + longFromValue, + LongValue +} from 'ilp-protocol-stream/dist/src/util/long' +import { + createHmac, + createHash, + createCipheriv, + createDecipheriv +} from 'crypto' +import { randomBytes } from 'ilp-protocol-stream/dist/src/crypto' + +const HASH_ALGORITHM = 'sha256' +const ENCRYPTION_ALGORITHM = 'aes-256-gcm' +const IV_LENGTH = 12 +const AUTH_TAG_LENGTH = 16 + +export const sha256 = (preimage: Buffer): Buffer => + createHash(HASH_ALGORITHM).update(preimage).digest() + +export const hmac = (key: Buffer, message: Buffer): Buffer => + createHmac(HASH_ALGORITHM, key).update(message).digest() + +export const encrypt = (key: Buffer, plaintext: Buffer): Buffer => { + const iv = randomBytes(12) + const cipher = createCipheriv(ENCRYPTION_ALGORITHM, key, iv) + + const ciphertext = [] + ciphertext.push(cipher.update(plaintext)) + ciphertext.push(cipher.final()) + const tag = cipher.getAuthTag() + ciphertext.unshift(iv, tag) + return Buffer.concat(ciphertext) +} + +export const decrypt = (key: Buffer, ciphertext: Buffer): Buffer => { + const nonce = ciphertext.slice(0, IV_LENGTH) + + // Buffer#slice is a reference to the same Buffer, but Node.js 10 + // throws an internal OpenSSL error if the auth tag uses the same + // Buffer as the ciphertext, so copy to a new Buffer instead. + const tag = Buffer.alloc(AUTH_TAG_LENGTH) + ciphertext.copy(tag, 0, IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH) + + const encrypted = ciphertext.slice(IV_LENGTH + AUTH_TAG_LENGTH) + const decipher = createDecipheriv(ENCRYPTION_ALGORITHM, key, nonce) + decipher.setAuthTag(tag) + + return Buffer.concat([decipher.update(encrypted), decipher.final()]) +} + +export const base64url = (buffer: Buffer): string => + buffer + .toString('base64') + .replace(/=+$/, '') + .replace(/\+/g, '-') + .replace(/\//g, '_') + +/** Construct an ILP Fulfill or ILP Reject with a STREAM packet */ +export class ReplyBuilder { + private encryptionKey?: Buffer + private sequence = Long.UZERO + private receivedAmount = Long.UZERO + private frames: Frame[] = [] + private ilpAddress = '' + + setIlpAddress(ilpAddress: IlpAddress): this { + this.ilpAddress = ilpAddress + return this + } + + setEncryptionKey(key: Buffer): this { + this.encryptionKey = key + return this + } + + setSequence(sequence: Long): this { + this.sequence = sequence + return this + } + + setReceivedAmount(receivedAmount: LongValue): this { + this.receivedAmount = longFromValue(receivedAmount, true) + return this + } + + addFrames(...frames: Frame[]): this { + this.frames.push(...frames) + return this + } + + private buildData(type: IlpPacketType): Buffer { + if (!this.encryptionKey) { + return Buffer.alloc(0) + } + + const streamPacket = new Packet( + this.sequence, + +type, + this.receivedAmount, + this.frames + )._serialize() + return encrypt(this.encryptionKey, streamPacket) + } + + buildFulfill(fulfillment: Buffer): IlpFulfill { + return { + fulfillment, + data: this.buildData(IlpPacketType.Fulfill) + } + } + + buildReject(code: IlpErrorCode): IlpReject { + return { + code, + message: '', + triggeredBy: this.ilpAddress, + data: this.buildData(IlpPacketType.Reject) + } + } +} diff --git a/packages/stream-receiver/test/index.spec.ts b/packages/stream-receiver/test/index.spec.ts new file mode 100644 index 0000000000..8b25e622c1 --- /dev/null +++ b/packages/stream-receiver/test/index.spec.ts @@ -0,0 +1,690 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import { describe, it, expect } from '@jest/globals' +import { StreamServer, IncomingMoney } from '../src' +import { randomBytes } from 'ilp-protocol-stream/dist/src/crypto' +import { + isIlpReply, + IlpError, + IlpReject, + isValidIlpAddress, + IlpPrepare, + deserializeIlpPrepare, + serializeIlpFulfill, + serializeIlpReply +} from 'ilp-packet' +import { base64url, encrypt, hmac, sha256 } from '../src/utils' +import { + Packet, + IlpPacketType, + ConnectionCloseFrame, + ErrorCode, + FrameType, + ConnectionNewAddressFrame, + StreamReceiptFrame +} from 'ilp-protocol-stream/dist/src/packet' +import Long from 'long' +import { verifyReceipt } from 'ilp-protocol-stream/dist/src/util/receipt' +import { createConnection } from 'ilp-protocol-stream' +import { Plugin } from 'ilp-protocol-stream/dist/src/util/plugin-interface' +import { serializeIldcpResponse } from 'ilp-protocol-ildcp' + +describe('StreamServer', () => { + const serverAddress = 'g.receiver' + const serverSecret = randomBytes(32) + const server = new StreamServer({ + serverAddress, + serverSecret + }) + + describe('constructor', () => { + it('throws if invalid server secret length', () => { + expect( + () => + new StreamServer({ + serverSecret: randomBytes(31), + serverAddress: 'g.me' + }) + ).toThrowError('Server secret must be 32 bytes') + }) + + it('throws if invalid ILP address', () => { + expect( + () => + new StreamServer({ + serverSecret: randomBytes(32), + serverAddress: 'foo@example.com' + }) + ).toThrowError('Invalid server base ILP address') + }) + }) + + describe('#generateCredentials', () => { + it('generates credentials with no options', () => { + const credentials = server.generateCredentials() + expect(isValidIlpAddress(credentials.ilpAddress)) + expect(credentials.sharedSecret.byteLength).toBe(32) + }) + + it('throws if invalid receipt nonce length', () => { + expect(() => + server.generateCredentials({ + receiptSetup: { + nonce: randomBytes(17), + secret: randomBytes(32) + } + }) + ).toThrowError( + 'Failed to generate credentials: receipt nonce must be 16 bytes' + ) + }) + + it('throws if invalid receipt secret length', () => { + expect(() => + server.generateCredentials({ + receiptSetup: { + nonce: randomBytes(16), + secret: Buffer.alloc(0) + } + }) + ).toThrowError( + 'Failed to generate credentials: receipt secret must be 32 bytes' + ) + }) + + it('throws if invalid asset scale', () => { + expect(() => { + server.generateCredentials({ + asset: { + scale: 256, + code: 'USD' + } + }) + }).toThrowError('Failed to generate credentials: invalid asset scale') + }) + + it('accepts credentials generated by a different instance', () => { + const server2 = new StreamServer({ + serverAddress, + serverSecret + }) + + const { ilpAddress } = server.generateCredentials({ + paymentTag: 'foo' + }) + expect(server2.decodePaymentTag(ilpAddress)).toBe('foo') + }) + + it('throws if too much data to encode in address', () => { + expect(() => + server.generateCredentials({ + paymentTag: 'a'.repeat(1023) + }) + ).toThrow() + }) + + it('throws if generated ILP address is too long', () => { + const server = new StreamServer({ + serverAddress: 'g.' + 'a'.repeat(1000), + serverSecret: randomBytes(32) + }) + + // Base server ILP address combined with payment tag exceeds maximum ILP address space + expect(() => + server.generateCredentials({ + paymentTag: '0123456789'.repeat(10) + }) + ).toThrowError( + 'Failed to generate credentials: too much data to encode within an ILP address' + ) + }) + }) +}) + +describe('handling packets', () => { + const serverAddress = 'g.receiver' + const serverSecret = randomBytes(32) + const server = new StreamServer({ + serverAddress, + serverSecret + }) + + it('rejects if packet is not addressed to receiver', () => { + const connectionOrReject = server.createReply({ + amount: '0', + destination: 'g.not_receiver', + executionCondition: randomBytes(32), + expiresAt: new Date(), + data: Buffer.alloc(0) + }) as IlpReject + + expect(isIlpReply(connectionOrReject)) + expect(connectionOrReject.code).toBe(IlpError.F02_UNREACHABLE) + expect(connectionOrReject.data.byteLength).toBe(0) + expect(connectionOrReject.triggeredBy).toBe(serverAddress) + }) + + it('rejects if token fails decryption', () => { + const connectionOrReject = server.createReply({ + amount: '0', + destination: 'g.receiver.INVALID', + executionCondition: randomBytes(32), + expiresAt: new Date(), + data: Buffer.alloc(0) + }) as IlpReject + + expect(isIlpReply(connectionOrReject)) + expect(connectionOrReject.code).toBe(IlpError.F06_UNEXPECTED_PAYMENT) + expect(connectionOrReject.data.byteLength).toBe(0) + expect(connectionOrReject.triggeredBy).toBe(serverAddress) + }) + + it('rejects if token is structurally invalid', () => { + const token = base64url( + encrypt( + hmac(serverSecret, Buffer.from('ilp_stream_connection_token')), + Buffer.alloc(0) + ) + ) + + const connectionOrReject = server.createReply({ + amount: '0', + destination: `g.receiver.${token}`, + executionCondition: randomBytes(32), + expiresAt: new Date(), + data: Buffer.alloc(0) + }) as IlpReject + + expect(isIlpReply(connectionOrReject)) + expect(connectionOrReject.code).toBe(IlpError.F06_UNEXPECTED_PAYMENT) + expect(connectionOrReject.data.byteLength).toBe(0) + expect(connectionOrReject.triggeredBy).toBe(serverAddress) + }) + + it('supports payment tag', () => { + const paymentTag = 'Hello world!' + const { ilpAddress: address1 } = server.generateCredentials({ + paymentTag + }) + expect(server.decodePaymentTag(address1)).toBe(paymentTag) + + const { ilpAddress: address2 } = server.generateCredentials() + expect(server.decodePaymentTag(address2)).toBeUndefined() + }) + + it('rejects if STREAM packet fails decryption', () => { + const { ilpAddress } = server.generateCredentials() + const prepare: IlpPrepare = { + destination: ilpAddress, + executionCondition: randomBytes(32), + amount: '10', + expiresAt: new Date(), + data: randomBytes(100) + } + + const reply = server.createReply(prepare) as IlpReject + + expect(isIlpReply(reply)) + expect(reply.code).toBe(IlpError.F06_UNEXPECTED_PAYMENT) + expect(reply.data.byteLength).toBe(0) + expect(reply.triggeredBy).toBe(serverAddress) + }) + + it('rejects if STREAM packet is structurally invalid', () => { + const { sharedSecret, ilpAddress } = server.generateCredentials() + const data = encrypt( + hmac(sharedSecret, Buffer.from('ilp_stream_encryption')), + Buffer.alloc(0) + ) + + const prepare: IlpPrepare = { + amount: '0', + destination: ilpAddress, + executionCondition: randomBytes(32), + expiresAt: new Date(), + data + } + + const reply = server.createReply(prepare) as IlpReject + + expect(isIlpReply(reply)) + expect(reply.code).toBe(IlpError.F06_UNEXPECTED_PAYMENT) + expect(reply.data.byteLength).toBe(0) + expect(reply.triggeredBy).toBe(serverAddress) + }) + + it('rejects if STREAM packet type is not Prepare', async () => { + const { sharedSecret, ilpAddress } = server.generateCredentials() + + const key = hmac(sharedSecret, Buffer.from('ilp_stream_encryption')) + const data = await new Packet( + 1, + IlpPacketType.Fulfill, + 0 + ).serializeAndEncrypt(key) + + const prepare: IlpPrepare = { + amount: '0', + destination: ilpAddress, + executionCondition: Buffer.alloc(32), + expiresAt: new Date(), + data + } + + const reply = server.createReply(prepare) as IlpReject + + expect(isIlpReply(reply)) + expect(reply.code).toBe(IlpError.F00_BAD_REQUEST) + expect(reply.data.byteLength).toBe(0) + expect(reply.triggeredBy).toBe(serverAddress) + }) + + it('handles connection close frames', async () => { + const { sharedSecret, ilpAddress } = server.generateCredentials() + + const key = hmac(sharedSecret, Buffer.from('ilp_stream_encryption')) + const data = await new Packet(1, IlpPacketType.Prepare, 0, [ + new ConnectionCloseFrame(ErrorCode.NoError, '') + ]).serializeAndEncrypt(key) + + const prepare: IlpPrepare = { + amount: '0', + destination: ilpAddress, + executionCondition: Buffer.alloc(32), + expiresAt: new Date(), + data + } + + const reply = server.createReply(prepare) as IlpReject + + expect(isIlpReply(reply)) + expect(reply.code).toBe(IlpError.F99_APPLICATION_ERROR) + expect(reply.triggeredBy).toBe(serverAddress) + + // Includes reply packet with echoed ConnectionClose frame + const replyPacket = await Packet.decryptAndDeserialize(key, reply.data) + expect(replyPacket.frames.some((f) => f.type === FrameType.ConnectionClose)) + expect(replyPacket.sequence).toEqual(Long.UONE) + expect(replyPacket.prepareAmount).toEqual(Long.UZERO) + }) + + it('replies with asset details', async () => { + const { sharedSecret, ilpAddress } = server.generateCredentials({ + asset: { + code: 'USD', + scale: 4 + } + }) + + const key = hmac(sharedSecret, Buffer.from('ilp_stream_encryption')) + const data = await new Packet(0, IlpPacketType.Prepare, 1, [ + new ConnectionNewAddressFrame('g.sender') + ]).serializeAndEncrypt(key) + + const prepare: IlpPrepare = { + amount: '0', + destination: ilpAddress, + executionCondition: Buffer.alloc(32), + expiresAt: new Date(), + data + } + + const reply = server.createReply(prepare) as IlpReject + + expect(isIlpReply(reply)) + expect(reply.code).toBe(IlpError.F99_APPLICATION_ERROR) + expect(reply.triggeredBy).toBe(serverAddress) + + // Includes reply packet with echoed ConnectionClose frame + const replyPacket = await Packet.decryptAndDeserialize(key, reply.data) + expect( + replyPacket.frames.some( + (f) => + f.type === FrameType.ConnectionAssetDetails && + f.sourceAssetCode === 'USD' && + f.sourceAssetScale === 4 + ) + ) + expect(replyPacket.sequence).toEqual(Long.UZERO) + expect(replyPacket.prepareAmount).toEqual(Long.UZERO) + }) + + it('rejects if exchange rate is insufficient', async () => { + const { sharedSecret, ilpAddress } = server.generateCredentials() + + const key = hmac(sharedSecret, Buffer.from('ilp_stream_encryption')) + const data = await new Packet( + 1, + IlpPacketType.Prepare, + 2 + ).serializeAndEncrypt(key) + + const prepare: IlpPrepare = { + amount: '1', // Received 1 unit, but minimum is 2 + destination: ilpAddress, + executionCondition: Buffer.alloc(32), + expiresAt: new Date(), + data + } + + const reply = server.createReply(prepare) as IlpReject + + expect(isIlpReply(reply)) + expect(reply.code).toBe(IlpError.F99_APPLICATION_ERROR) + expect(reply.triggeredBy).toBe(serverAddress) + + const replyPacket = await Packet.decryptAndDeserialize(key, reply.data) + expect(replyPacket.sequence).toEqual(Long.UONE) + expect(replyPacket.prepareAmount).toEqual(Long.UONE) + }) + + it('rejects if packet is unfulfillable', async () => { + const { sharedSecret, ilpAddress } = server.generateCredentials() + + const key = hmac(sharedSecret, Buffer.from('ilp_stream_encryption')) + const data = await new Packet( + 23, + IlpPacketType.Prepare, + 0 + ).serializeAndEncrypt(key) + + const prepare: IlpPrepare = { + amount: '1', + destination: ilpAddress, + executionCondition: randomBytes(32), + expiresAt: new Date(), + data + } + + const reply = server.createReply(prepare) as IlpReject + + expect(isIlpReply(reply)) + expect(reply.code).toBe(IlpError.F99_APPLICATION_ERROR) + expect(reply.triggeredBy).toBe(serverAddress) + + const replyPacket = await Packet.decryptAndDeserialize(key, reply.data) + expect(+replyPacket.sequence).toBe(23) + expect(+replyPacket.prepareAmount).toBe(1) + }) + + it('generates receipts using total received', async () => { + const receiptNonce = randomBytes(16) + const receiptSecret = randomBytes(32) + const { sharedSecret, ilpAddress } = server.generateCredentials({ + receiptSetup: { + nonce: receiptNonce, + secret: receiptSecret + } + }) + + const key = hmac(sharedSecret, Buffer.from('ilp_stream_encryption')) + const data = await new Packet( + 1, + IlpPacketType.Prepare, + 1 + ).serializeAndEncrypt(key) + + const fulfillmentKey = hmac( + sharedSecret, + Buffer.from('ilp_stream_fulfillment') + ) + const fulfillment = hmac(fulfillmentKey, data) + const executionCondition = sha256(fulfillment) + + const prepare: IlpPrepare = { + amount: '1', // This also tests packets with received amount = minimum + destination: ilpAddress, + executionCondition, + expiresAt: new Date(), + data + } + + const money = server.createReply(prepare) as IncomingMoney + money.setTotalReceived(9) + const reply = money.temporaryDecline() + + expect(isIlpReply(reply)) + expect(reply.code).toBe(IlpError.T00_INTERNAL_ERROR) + expect(reply.triggeredBy).toBe(serverAddress) + + const replyPacket = await Packet.decryptAndDeserialize(key, reply.data) + expect(+replyPacket.sequence).toBe(1) + expect(+replyPacket.prepareAmount).toBe(1) + + const receiptFrame = replyPacket.frames.find( + (f) => f.type === FrameType.StreamReceipt + ) as StreamReceiptFrame + + expect(receiptFrame).toBeDefined() + expect(+receiptFrame.streamId).toBe(1) + + const receipt = verifyReceipt(receiptFrame.receipt, receiptSecret) + expect(receipt.nonce).toEqual(receiptNonce) + expect(+receipt.streamId).toBe(1) + expect(+receipt.totalReceived).toBe(9) + expect(receipt.version).toBe(1) + }) + + it('creates Fulfill if accepted', async () => { + const { sharedSecret, ilpAddress } = server.generateCredentials() + + const key = hmac(sharedSecret, Buffer.from('ilp_stream_encryption')) + const data = await new Packet( + 100, + IlpPacketType.Prepare, + 50 + ).serializeAndEncrypt(key) + + const fulfillmentKey = hmac( + sharedSecret, + Buffer.from('ilp_stream_fulfillment') + ) + const fulfillment = hmac(fulfillmentKey, data) + const executionCondition = sha256(fulfillment) + + const prepare: IlpPrepare = { + amount: '1000', + destination: ilpAddress + '.extra.segments', + executionCondition, + expiresAt: new Date(), + data + } + + const money = server.createReply(prepare) as IncomingMoney + money.setTotalReceived(1000) // Should do nothing since there was no receipt setup parameters + const reply = money.accept() + + expect(reply.fulfillment).toEqual(fulfillment) + + const replyPacket = await Packet.decryptAndDeserialize(key, reply.data) + expect(+replyPacket.sequence).toBe(100) + expect(+replyPacket.prepareAmount).toBe(1000) + expect(replyPacket.frames.length).toBe(0) // No `StreamReceipt` frame + }) + + it('creates Reject if soft declined', async () => { + const { sharedSecret, ilpAddress } = server.generateCredentials() + + const key = hmac(sharedSecret, Buffer.from('ilp_stream_encryption')) + const data = await new Packet( + 1, + IlpPacketType.Prepare, + 2 + ).serializeAndEncrypt(key) + + const fulfillmentKey = hmac( + sharedSecret, + Buffer.from('ilp_stream_fulfillment') + ) + const fulfillment = hmac(fulfillmentKey, data) + const executionCondition = sha256(fulfillment) + + const prepare: IlpPrepare = { + amount: '3', + destination: ilpAddress + '.extra.segments', + executionCondition, + expiresAt: new Date(), + data + } + + const money = server.createReply(prepare) as IncomingMoney + const reply = money.temporaryDecline() + + expect(reply.code).toBe(IlpError.T00_INTERNAL_ERROR) + expect(reply.triggeredBy).toBe(serverAddress) + expect(reply.message).toBe('') + + const replyPacket = await Packet.decryptAndDeserialize(key, reply.data) + expect(+replyPacket.sequence).toBe(1) + expect(+replyPacket.prepareAmount).toBe(3) + expect(replyPacket.frames.length).toBe(0) + }) + + it('creates Reject if hard declined', async () => { + const { sharedSecret, ilpAddress } = server.generateCredentials() + + const key = hmac(sharedSecret, Buffer.from('ilp_stream_encryption')) + const data = await new Packet( + 1, + IlpPacketType.Prepare, + 2 + ).serializeAndEncrypt(key) + + const fulfillmentKey = hmac( + sharedSecret, + Buffer.from('ilp_stream_fulfillment') + ) + const fulfillment = hmac(fulfillmentKey, data) + const executionCondition = sha256(fulfillment) + + const prepare: IlpPrepare = { + amount: '3', + destination: ilpAddress, + executionCondition, + expiresAt: new Date(), + data + } + + const money = server.createReply(prepare) as IncomingMoney + const reply = money.finalDecline() + + expect(reply.code).toBe(IlpError.F99_APPLICATION_ERROR) + expect(reply.triggeredBy).toBe(serverAddress) + expect(reply.message).toBe('') + + const replyPacket = await Packet.decryptAndDeserialize(key, reply.data) + expect(+replyPacket.sequence).toBe(1) + expect(+replyPacket.prepareAmount).toBe(3) + expect(replyPacket.frames.some((f) => f.type === FrameType.ConnectionClose)) + }) + + it('exposes unique connection ids', async () => { + const generateConnectionId = async (): Promise => { + const { ilpAddress, sharedSecret } = server.generateCredentials() + + const key = hmac(sharedSecret, Buffer.from('ilp_stream_encryption')) + const data = await new Packet( + 1, + IlpPacketType.Prepare, + 2 + ).serializeAndEncrypt(key) + + const fulfillmentKey = hmac( + sharedSecret, + Buffer.from('ilp_stream_fulfillment') + ) + const fulfillment = hmac(fulfillmentKey, data) + const executionCondition = sha256(fulfillment) + + const { connectionId } = server.createReply({ + amount: '3', + destination: ilpAddress, + executionCondition, + expiresAt: new Date(), + data + }) as IncomingMoney + + return connectionId + } + + const id1 = await generateConnectionId() + const id2 = await generateConnectionId() + expect(id2).not.toBe(id1) + }) +}) + +describe('ilp-protocol-stream integration', () => { + it('accepts incoming payments', async () => { + const receiptNonce = randomBytes(16) + const receiptSecret = randomBytes(32) + const server = new StreamServer({ + serverAddress: 'test.wallet', + serverSecret: randomBytes(32) + }) + + const { sharedSecret, ilpAddress } = server.generateCredentials({ + paymentTag: 'some random information.', + receiptSetup: { + nonce: receiptNonce, + secret: receiptSecret + }, + asset: { + code: 'JPY', + scale: 6 + } + }) + + let replyToIldcp = true + const plugin: Plugin = { + async sendData(data: Buffer) { + // First, handle the initial IL-DCP request when the connection is created + if (replyToIldcp) { + replyToIldcp = false + return serializeIldcpResponse({ + clientAddress: 'test.wallet', + assetCode: 'USD', + assetScale: 4 + }) + } + // Otherwise, handle the packet using the STREAM server + else { + const prepare = deserializeIlpPrepare(data) + const moneyOrReject = server.createReply(prepare) + if (isIlpReply(moneyOrReject)) { + return serializeIlpReply(moneyOrReject) + } + + moneyOrReject.setTotalReceived(prepare.amount) + return serializeIlpFulfill(moneyOrReject.accept()) + } + }, + async connect() {}, + async disconnect() {}, + isConnected() { + return true + }, + registerDataHandler() {}, + deregisterDataHandler() {} + } + + const connection = await createConnection({ + plugin, + sharedSecret, + destinationAccount: ilpAddress + }) + + const stream1 = connection.createStream() + const stream2 = connection.createStream() + + await Promise.all([stream1.sendTotal(2003), stream2.sendTotal(1000)]) + + stream1.end() + stream2.end() + + await connection.end() + + expect(+connection.totalDelivered).toBe(3003) + expect(stream1.receipt).toBeDefined() + expect(connection.destinationAssetCode).toBe('JPY') + expect(connection.destinationAssetScale).toBe(6) + }) +}) diff --git a/packages/stream-receiver/tsconfig.build.json b/packages/stream-receiver/tsconfig.build.json new file mode 100644 index 0000000000..7027618be4 --- /dev/null +++ b/packages/stream-receiver/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "composite": true, + "baseUrl": ".", + "rootDir": ".", + "outDir": "dist", + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4bd2d98a7..2d824471e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,8 @@ overrides: path-to-regexp@>=6.3.0: ^6.3.0 next: ^15.2.3 form-data: ^4.0.4 + '@interledger/stream-receiver': workspace:* + '@interledger/pay': workspace:* importers: @@ -341,11 +343,11 @@ importers: specifier: 2.0.2 version: 2.0.2 '@interledger/pay': - specifier: 0.4.0-alpha.9 - version: 0.4.0-alpha.9 + specifier: workspace:* + version: link:../pay '@interledger/stream-receiver': - specifier: ^0.3.3-alpha.3 - version: 0.3.3-alpha.3 + specifier: workspace:* + version: link:../stream-receiver '@koa/cors': specifier: ^5.0.0 version: 5.0.0 @@ -722,6 +724,85 @@ importers: specifier: ^9.0.8 version: 9.0.8 + packages/pay: + dependencies: + abort-controller: + specifier: ^3.0.0 + version: 3.0.0 + ilp-logger: + specifier: ^1.4.5-alpha.2 + version: 1.4.5-alpha.2 + ilp-packet: + specifier: ^3.1.4-alpha.2 + version: 3.1.4-alpha.2 + ilp-protocol-stream: + specifier: ^2.7.2-alpha.2 + version: 2.7.2-alpha.2 + long: + specifier: ^4.0.0 + version: 4.0.0 + node-fetch: + specifier: ^2.6.6 + version: 2.7.0 + oer-utils: + specifier: ^5.1.3-alpha.2 + version: 5.1.3-alpha.2 + devDependencies: + '@interledger/stream-receiver': + specifier: workspace:* + version: link:../stream-receiver + '@types/long': + specifier: 4.0.2 + version: 4.0.2 + '@types/node-fetch': + specifier: 2.6.4 + version: 2.6.4 + axios: + specifier: 1.4.0 + version: 1.4.0 + get-port: + specifier: 5.1.1 + version: 5.1.1 + ilp-connector: + specifier: 23.0.2 + version: 23.0.2 + ilp-plugin-http: + specifier: 1.6.1 + version: 1.6.1 + nock: + specifier: 13.3.1 + version: 13.3.1 + reduct: + specifier: 3.3.1 + version: 3.3.1 + testcontainers: + specifier: 9.8.0 + version: 9.8.0 + + packages/stream-receiver: + dependencies: + '@types/long': + specifier: ^4.0.2 + version: 4.0.2 + ilp-logger: + specifier: ^1.4.5-alpha.2 + version: 1.4.5-alpha.2 + ilp-packet: + specifier: ^3.1.4-alpha.2 + version: 3.1.4-alpha.2 + ilp-protocol-ildcp: + specifier: 2.2.4-alpha.2 + version: 2.2.4-alpha.2 + ilp-protocol-stream: + specifier: ^2.7.2-alpha.2 + version: 2.7.2-alpha.2 + long: + specifier: ^4.0.0 + version: 4.0.0 + oer-utils: + specifier: ^5.1.3-alpha.2 + version: 5.1.3-alpha.2 + packages/token-introspection: dependencies: '@interledger/openapi': @@ -1274,7 +1355,7 @@ packages: engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} dependencies: ci-info: 4.2.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@7.2.0) dlv: 1.1.3 dset: 3.1.4 is-docker: 3.0.0 @@ -1330,7 +1411,7 @@ packages: '@babel/traverse': 7.25.9 '@babel/types': 7.26.0 convert-source-map: 2.0.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@7.2.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -1352,7 +1433,7 @@ packages: '@babel/traverse': 7.26.7 '@babel/types': 7.27.0 convert-source-map: 2.0.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0(supports-color@7.2.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -1375,7 +1456,7 @@ packages: '@babel/traverse': 7.26.9 '@babel/types': 7.26.9 convert-source-map: 2.0.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0(supports-color@7.2.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -1398,7 +1479,7 @@ packages: '@babel/traverse': 7.27.4 '@babel/types': 7.27.3 convert-source-map: 2.0.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@7.2.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -1557,7 +1638,7 @@ packages: '@babel/core': 7.26.9 '@babel/helper-compilation-targets': 7.26.5 '@babel/helper-plugin-utils': 7.26.5 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@7.2.0) lodash.debounce: 4.0.8 resolve: 1.22.8 transitivePeerDependencies: @@ -3009,7 +3090,7 @@ packages: '@babel/parser': 7.26.7 '@babel/template': 7.25.9 '@babel/types': 7.26.7 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@7.2.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -3023,7 +3104,7 @@ packages: '@babel/parser': 7.27.0 '@babel/template': 7.25.9 '@babel/types': 7.27.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@7.2.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -3037,7 +3118,7 @@ packages: '@babel/parser': 7.26.9 '@babel/template': 7.26.9 '@babel/types': 7.26.9 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@7.2.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -3052,7 +3133,7 @@ packages: '@babel/parser': 7.27.5 '@babel/template': 7.27.2 '@babel/types': 7.27.3 - debug: 4.4.1 + debug: 4.4.1(supports-color@7.2.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -3924,7 +4005,7 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: ajv: 6.12.6 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@7.2.0) espree: 9.6.1 globals: 13.19.0 ignore: 5.2.4 @@ -4632,7 +4713,7 @@ packages: '@types/json-stable-stringify': 1.0.34 '@whatwg-node/fetch': 0.9.8 chalk: 4.1.2 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0(supports-color@7.2.0) dotenv: 16.4.7 graphql: 16.11.0 graphql-request: 6.1.0(graphql@16.11.0) @@ -4831,7 +4912,7 @@ packages: deprecated: Use @eslint/config-array instead dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@7.2.0) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -4863,7 +4944,7 @@ packages: '@antfu/install-pkg': 0.4.1 '@antfu/utils': 0.7.10 '@iconify/types': 2.0.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@7.2.0) kolorist: 1.8.0 local-pkg: 0.5.0 mlly: 1.7.3 @@ -5316,33 +5397,6 @@ packages: transitivePeerDependencies: - supports-color - /@interledger/pay@0.4.0-alpha.9: - resolution: {integrity: sha512-ScT+hsAFBjpSy68VncSa6wW+VidgviKQE9W9lyiOBCrXfnrwrTdycEXWOG9ShoAYXpA3/FG/dYO9eImAPO5Pzg==} - dependencies: - abort-controller: 3.0.0 - ilp-logger: 1.4.5-alpha.2 - ilp-packet: 3.1.4-alpha.2 - ilp-protocol-stream: 2.7.2-alpha.2 - long: 4.0.0 - node-fetch: 2.6.7 - transitivePeerDependencies: - - encoding - - supports-color - dev: false - - /@interledger/stream-receiver@0.3.3-alpha.3: - resolution: {integrity: sha512-4h3zIY9OGT+4BgzIsidt+8u89/+8fXzkptz/cKp4Bs5QBNsM0zecktJ8irp0audPNxvUYVjK6r/IGclvuejn4Q==} - dependencies: - '@types/long': 4.0.2 - ilp-logger: 1.4.5-alpha.2 - ilp-packet: 3.1.4-alpha.2 - ilp-protocol-stream: 2.7.2-alpha.2 - long: 4.0.0 - oer-utils: 5.1.3-alpha.2 - transitivePeerDependencies: - - supports-color - dev: false - /@ioredis/commands@1.2.0: resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} dev: false @@ -7569,6 +7623,12 @@ packages: dependencies: '@types/estree': 1.0.7 + /@types/archiver@5.3.4: + resolution: {integrity: sha512-Lj7fLBIMwYFgViVVZHEdExZC3lVYsl+QL0VmdNdIzGZH544jHveYWij6qdnBgJQDnR7pMKliN9z2cPZFEbhyPw==} + dependencies: + '@types/readdir-glob': 1.1.5 + dev: true + /@types/aria-query@4.2.2: resolution: {integrity: sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==} dev: true @@ -8045,6 +8105,10 @@ packages: resolution: {integrity: sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==} dev: true + /@types/long@4.0.1: + resolution: {integrity: sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==} + dev: true + /@types/long@4.0.2: resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} @@ -8102,9 +8166,15 @@ packages: form-data: 4.0.4 dev: false + /@types/node-fetch@2.6.4: + resolution: {integrity: sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==} + dependencies: + '@types/node': 20.14.15 + form-data: 4.0.4 + dev: true + /@types/node@10.17.60: resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==} - dev: false /@types/node@17.0.45: resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==} @@ -8173,6 +8243,12 @@ packages: '@types/prop-types': 15.7.5 csstype: 3.1.0 + /@types/readdir-glob@1.1.5: + resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} + dependencies: + '@types/node': 20.14.15 + dev: true + /@types/request@2.48.8: resolution: {integrity: sha512-whjk1EDJPcAR2kYHRbFl/lKeeKYTi05A15K9bnLInCVroNDCtXce57xKdI0/rQaA3K+6q0eFyUBPmqfSndUZdQ==} dependencies: @@ -8312,7 +8388,7 @@ packages: '@typescript-eslint/scope-manager': 5.60.1 '@typescript-eslint/type-utils': 5.60.1(eslint@8.57.1)(typescript@5.8.3) '@typescript-eslint/utils': 5.60.1(eslint@8.57.1)(typescript@5.8.3) - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@7.2.0) eslint: 8.57.1 grapheme-splitter: 1.0.4 ignore: 5.2.4 @@ -8366,7 +8442,7 @@ packages: '@typescript-eslint/scope-manager': 5.60.1 '@typescript-eslint/types': 5.60.1 '@typescript-eslint/typescript-estree': 5.60.1(typescript@5.8.3) - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@7.2.0) eslint: 8.57.1 typescript: 5.8.3 transitivePeerDependencies: @@ -8430,7 +8506,7 @@ packages: dependencies: '@typescript-eslint/typescript-estree': 5.60.1(typescript@5.8.3) '@typescript-eslint/utils': 5.60.1(eslint@8.57.1)(typescript@5.8.3) - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@7.2.0) eslint: 8.57.1 tsutils: 3.21.0(typescript@5.8.3) typescript: 5.8.3 @@ -8450,7 +8526,7 @@ packages: dependencies: '@typescript-eslint/typescript-estree': 7.5.0(typescript@5.4.3) '@typescript-eslint/utils': 7.5.0(eslint@8.57.1)(typescript@5.4.3) - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@7.2.0) eslint: 8.57.1 ts-api-utils: 1.0.1(typescript@5.4.3) typescript: 5.4.3 @@ -8484,7 +8560,7 @@ packages: dependencies: '@typescript-eslint/types': 5.60.1 '@typescript-eslint/visitor-keys': 5.60.1 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@7.2.0) globby: 11.1.0 is-glob: 4.0.3 semver: 7.6.3 @@ -8505,7 +8581,7 @@ packages: dependencies: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@7.2.0) globby: 11.1.0 is-glob: 4.0.3 semver: 7.6.3 @@ -8526,7 +8602,7 @@ packages: dependencies: '@typescript-eslint/types': 7.5.0 '@typescript-eslint/visitor-keys': 7.5.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@7.2.0) globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 @@ -9014,7 +9090,7 @@ packages: resolution: {integrity: sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==} engines: {node: '>= 14'} dependencies: - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@7.2.0) transitivePeerDependencies: - supports-color dev: true @@ -9031,17 +9107,6 @@ packages: resolution: {integrity: sha512-hCOfMzbFx5IDutmWLAt6MZwOUjIfSM9G9FyVxytmE4Rs/5YDPWQrD/+IR1w+FweD9H2oOZEnv36TmkjhNURBVA==} dev: true - /ajv-formats@2.1.1(ajv@8.12.0): - resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - dependencies: - ajv: 8.12.0 - dev: true - /ajv-formats@2.1.1(ajv@8.17.1): resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -9085,6 +9150,7 @@ packages: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 uri-js: 4.4.1 + dev: false /ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} @@ -9130,6 +9196,13 @@ packages: engines: {node: '>=0.10.0'} dev: false + /ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + dependencies: + color-convert: 1.9.3 + dev: true + /ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -9164,6 +9237,38 @@ packages: normalize-path: 3.0.0 picomatch: 2.3.1 + /archiver-utils@2.1.0: + resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} + engines: {node: '>= 6'} + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 2.3.7 + dev: true + + /archiver-utils@3.0.4: + resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} + engines: {node: '>= 10'} + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 3.6.0 + dev: true + /archiver-utils@5.0.2: resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} engines: {node: '>= 14'} @@ -9177,6 +9282,19 @@ packages: readable-stream: 4.1.0 dev: true + /archiver@5.3.2: + resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} + engines: {node: '>= 10'} + dependencies: + archiver-utils: 2.1.0 + async: 3.2.6 + buffer-crc32: 0.2.13 + readable-stream: 3.6.0 + readdir-glob: 1.1.2 + tar-stream: 2.2.0 + zip-stream: 4.1.1 + dev: true + /archiver@7.0.1: resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} engines: {node: '>= 14'} @@ -9400,7 +9518,7 @@ packages: common-ancestor-path: 1.0.1 cookie: 1.0.2 cssesc: 3.0.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@7.2.0) deterministic-object-hash: 2.0.2 devalue: 5.1.1 diff: 5.2.0 @@ -9504,7 +9622,6 @@ packages: /async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - dev: false /asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -9555,6 +9672,16 @@ packages: engines: {node: '>=4'} dev: true + /axios@1.4.0: + resolution: {integrity: sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==} + dependencies: + follow-redirects: 1.15.9(debug@4.3.2) + form-data: 4.0.4 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + dev: true + /axios@1.8.2(debug@4.3.2): resolution: {integrity: sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==} dependencies: @@ -9823,6 +9950,14 @@ packages: tweetnacl: 0.14.5 dev: true + /bignumber.js@5.0.0: + resolution: {integrity: sha512-KWTu6ZMVk9sxlDJQh2YH1UOnfDP8O8TpxUxgQG/vKASoSnEjK9aVuOueFaPcQEYQ5fyNXNTOYwYw3099RYebWg==} + dev: true + + /bignumber.js@7.2.1: + resolution: {integrity: sha512-S4XzBk5sMB+Rcb/LNcpzXr57VRTxgAvaAEDAl1AwRx27j00hT84O6OkteE7u8UB3NuaaygCRrEpqox4uDOrbdQ==} + dev: true + /binary-extensions@2.2.0: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} @@ -9834,6 +9969,10 @@ packages: editions: 6.21.0 dev: false + /bintrees@1.0.2: + resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + dev: true + /bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} dependencies: @@ -9959,11 +10098,19 @@ packages: node-int64: 0.4.0 dev: true + /buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + dev: true + /buffer-crc32@1.0.0: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} engines: {node: '>=8.0.0'} dev: true + /buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + dev: true + /buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -10083,6 +10230,13 @@ packages: engines: {node: '>=6'} dev: true + /camel-case@3.0.0: + resolution: {integrity: sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w==} + dependencies: + no-case: 2.3.2 + upper-case: 1.1.3 + dev: true + /camel-case@4.1.2: resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} dependencies: @@ -10148,6 +10302,15 @@ packages: supports-color: 2.0.0 dev: false + /chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + dev: true + /chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -10175,6 +10338,29 @@ packages: upper-case-first: 2.0.2 dev: true + /change-case@3.1.0: + resolution: {integrity: sha512-2AZp7uJZbYEzRPsFoa+ijKdvp9zsrnnt6+yFokfwEpeJm0xuJDVoxiRCAaTzyJND8GJkofo2IcKWaUZ/OECVzw==} + dependencies: + camel-case: 3.0.0 + constant-case: 2.0.0 + dot-case: 2.1.1 + header-case: 1.0.1 + is-lower-case: 1.1.3 + is-upper-case: 1.1.2 + lower-case: 1.1.4 + lower-case-first: 1.0.2 + no-case: 2.3.2 + param-case: 2.1.1 + pascal-case: 2.0.1 + path-case: 2.1.1 + sentence-case: 2.1.1 + snake-case: 2.1.0 + swap-case: 1.1.2 + title-case: 2.1.1 + upper-case: 1.1.3 + upper-case-first: 1.1.2 + dev: true + /change-case@4.1.2: resolution: {integrity: sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==} dependencies: @@ -10435,12 +10621,22 @@ packages: resolution: {integrity: sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==} dev: true + /color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + dependencies: + color-name: 1.1.3 + dev: true + /color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} dependencies: color-name: 1.1.4 + /color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + dev: true + /color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} requiresBuild: true @@ -10532,6 +10728,16 @@ packages: engines: {node: '>=4.0.0'} dev: true + /compress-commons@4.1.2: + resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} + engines: {node: '>= 10'} + dependencies: + buffer-crc32: 0.2.13 + crc32-stream: 4.0.3 + normalize-path: 3.0.0 + readable-stream: 3.6.0 + dev: true + /compress-commons@6.0.2: resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} engines: {node: '>= 14'} @@ -10602,6 +10808,13 @@ packages: - supports-color dev: false + /constant-case@2.0.0: + resolution: {integrity: sha512-eS0N9WwmjTqrOmR3o83F5vW8Z+9R1HnVz3xmzT2PMFug9ly+Au/fxRWlEBSb6LcZwspSsEn9Xs1uw9YgzAg1EQ==} + dependencies: + snake-case: 2.1.0 + upper-case: 1.1.3 + dev: true + /constant-case@3.0.4: resolution: {integrity: sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==} dependencies: @@ -10727,6 +10940,14 @@ packages: hasBin: true dev: true + /crc32-stream@4.0.3: + resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} + engines: {node: '>= 10'} + dependencies: + crc-32: 1.2.2 + readable-stream: 3.6.0 + dev: true + /crc32-stream@6.0.0: resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} engines: {node: '>= 14'} @@ -11282,8 +11503,8 @@ packages: ms: 2.1.3 supports-color: 7.2.0 - /debug@4.4.0(supports-color@9.4.0): - resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + /debug@4.4.1(supports-color@7.2.0): + resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} peerDependencies: supports-color: '*' @@ -11292,9 +11513,9 @@ packages: optional: true dependencies: ms: 2.1.3 - supports-color: 9.4.0 + supports-color: 7.2.0 - /debug@4.4.1: + /debug@4.4.1(supports-color@9.4.0): resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} peerDependencies: @@ -11304,6 +11525,8 @@ packages: optional: true dependencies: ms: 2.1.3 + supports-color: 9.4.0 + dev: false /decamelize@1.2.0: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} @@ -11501,6 +11724,13 @@ packages: /dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + /docker-compose@0.23.19: + resolution: {integrity: sha512-v5vNLIdUqwj4my80wxFDkNH+4S85zsRuH29SO7dCWVWPCMt/ohZBsGN6g6KXWifT0pzQ7uOxqEKCYCDPJ8Vz4g==} + engines: {node: '>= 6.0.0'} + dependencies: + yaml: 1.10.2 + dev: true + /docker-compose@0.24.8: resolution: {integrity: sha512-plizRs/Vf15H+GCVxq2EUvyPK7ei9b/cVesHvjnX4xaXjM9spHe2Ytq0BitndFgvTJ3E3NljPNUEl7BAN43iZw==} engines: {node: '>= 6.0.0'} @@ -11512,7 +11742,7 @@ packages: resolution: {integrity: sha512-h0Ow21gclbYsZ3mkHDfsYNDqtRhXS8fXr51bU0qr1dxgTMJj0XufbzX+jhNOvA8KuEEzn6JbvLVhXyv+fny9Uw==} engines: {node: '>= 8.0'} dependencies: - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@7.2.0) readable-stream: 3.6.0 split-ca: 1.0.1 ssh2: 1.11.0 @@ -11582,6 +11812,12 @@ packages: domhandler: 5.0.3 dev: false + /dot-case@2.1.1: + resolution: {integrity: sha512-HnM6ZlFqcajLsyudHq7LeeLDr2rFAVYtDv/hV5qchQEidSck8j9OPUsXY9KwJv/lHMtYlX4DjRQqwFYa+0r8Ug==} + dependencies: + no-case: 2.3.2 + dev: true + /dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} dependencies: @@ -11625,6 +11861,12 @@ packages: /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + /ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + dependencies: + safe-buffer: 5.2.1 + dev: true + /editions@6.21.0: resolution: {integrity: sha512-ofkXJtn7z0urokN62DI3SBo/5xAtF0rR7tn+S/bSYV79Ka8pTajIIl+fFQ1q88DQEImymmo97M4azY3WX/nUdg==} engines: {node: '>=4'} @@ -11736,7 +11978,7 @@ packages: arraybuffer.prototype.slice: 1.0.1 available-typed-arrays: 1.0.5 call-bind: 1.0.7 - es-set-tostringtag: 2.0.3 + es-set-tostringtag: 2.1.0 es-to-primitive: 1.2.1 function.prototype.name: 1.1.5 get-intrinsic: 1.2.4 @@ -12149,7 +12391,7 @@ packages: eslint: '*' eslint-plugin-import: '*' dependencies: - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@7.2.0) enhanced-resolve: 5.13.0 eslint: 8.57.1 eslint-module-utils: 2.7.4(@typescript-eslint/parser@5.60.1)(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.5.5)(eslint@8.57.1) @@ -12712,7 +12954,6 @@ packages: /extensible-error@1.0.2: resolution: {integrity: sha512-kXU1FiTsGT8PyMKtFM074RK/VBpzwuQJicAHqBpsPDeTXBQiSALPjkjKXlyKdG/GP6lR7bBaEkq8qdoO2geu9g==} - dev: false /external-editor@3.1.0: resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} @@ -13757,6 +13998,11 @@ packages: resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} dev: true + /has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + dev: true + /has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -14058,6 +14304,13 @@ packages: hasBin: true dev: false + /header-case@1.0.1: + resolution: {integrity: sha512-i0q9mkOeSuhXw6bGgiQCCBgY/jlZuV/7dZXyZ9c6LcBrqwvT8eT719E9uxE5LiZftdl+z81Ugbg/VvXV4OJOeQ==} + dependencies: + no-case: 2.3.2 + upper-case: 1.1.3 + dev: true + /header-case@2.0.4: resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==} dependencies: @@ -14207,7 +14460,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@7.2.0) transitivePeerDependencies: - supports-color dev: true @@ -14236,7 +14489,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@7.2.0) transitivePeerDependencies: - supports-color dev: true @@ -14281,6 +14534,46 @@ packages: resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} engines: {node: '>= 4'} + /ilp-compat-plugin@2.0.3: + resolution: {integrity: sha512-enMMJJ4T6Q0+GWbyov2Oe/14ikEgmy6feMWGOrXpUy1NtFLskDPGL+GKFG7kJNq5OlqHUz1SUqU224odOE65Cg==} + dependencies: + debug: 3.2.7 + ilp-packet: 2.2.0 + oer-utils: 1.3.4 + uuid: 3.4.0 + transitivePeerDependencies: + - supports-color + dev: true + + /ilp-connector@23.0.2: + resolution: {integrity: sha512-5tP2N3Xq/Dg0FSxZEHW9ZouR3ekF5JJGbjdG5snBrodch5XF1VN0iRApED7WbEOOeH2nhpl2RmPhnepm7F3NwA==} + engines: {node: '>=6.6.0'} + hasBin: true + dependencies: + ajv: 6.12.6 + bignumber.js: 7.2.1 + change-case: 3.1.0 + debug: 3.2.7 + extensible-error: 1.0.2 + ilp-compat-plugin: 2.0.3 + ilp-packet: 3.1.3 + ilp-protocol-ccp: 1.2.3 + ilp-protocol-ildcp: 2.2.3 + lodash: 4.17.21 + long: 4.0.0 + node-fetch: 2.7.0 + oer-utils: 4.0.0 + prom-client: 11.5.3 + reduct: 3.3.1 + riverpig: 1.1.4 + sax: 1.2.4 + source-map-support: 0.5.21 + through2: 2.0.5 + transitivePeerDependencies: + - encoding + - supports-color + dev: true + /ilp-logger@1.4.4: resolution: {integrity: sha512-R7F+SH6Aiipuqoq63gtzy6/HVIfcCK1rEmq8bE8NLSufXJPRoXszNs6RpypQi9HJcZvTcIUPFE15bS/HI+T+/A==} dependencies: @@ -14291,16 +14584,54 @@ packages: /ilp-logger@1.4.5-alpha.2: resolution: {integrity: sha512-WtbscdjUUPVseRkDpRlfb/YUpsq4zfoOz6PlJSkx+aqJot1P5N+YGd4YKW1g9wm6O8muo5e/xBotyJqCQs0g+Q==} dependencies: - '@types/debug': 4.1.7 - debug: 4.4.0(supports-color@9.4.0) + '@types/debug': 4.1.12 + debug: 4.4.1(supports-color@9.4.0) supports-color: 9.4.0 dev: false + /ilp-packet@2.2.0: + resolution: {integrity: sha512-QEGqY0HzGrue4r+4GWWe7lB7Xvjij4cyc2XeOTHYmwkO0BjgwzJW85mZJzR9q5HmK8zdFkN6C0CfedAaYiUv9w==} + dependencies: + bignumber.js: 5.0.0 + extensible-error: 1.0.2 + long: 3.2.0 + oer-utils: 1.3.4 + dev: true + + /ilp-packet@3.1.3: + resolution: {integrity: sha512-FBsiPQbHPdLPI6jdA+sQO+4fFBuMc212yCdNXMqoGJdic2GFHF/E8P9bTorIVRZRVExhWDE5givqCMguupW8VA==} + dependencies: + extensible-error: 1.0.2 + oer-utils: 5.1.2 + dev: true + /ilp-packet@3.1.4-alpha.2: resolution: {integrity: sha512-0a75sI7o/1NG9qgJlObg9M264pnezcPEse1+UQV8gkgQAuKesj5b6qyPw7WbkvjIDXAtIQ61Qg48wRpkaddbQw==} dependencies: oer-utils: 5.1.3-alpha.2 + /ilp-plugin-http@1.6.1: + resolution: {integrity: sha512-q02kkUkEr7IDPq5xiz9DTq5QuxOOBlXp571pUklPGMmRMMNVAE3kcZ6k0WV9LxuYMU+GtNsBev2QYuddlhYiwA==} + dependencies: + '@types/node': 10.17.60 + ilp-packet: 3.1.3 + ilp-protocol-ildcp: 2.2.3 + jsonwebtoken: 8.5.1 + koa: 2.16.0 + node-fetch: 2.7.0 + raw-body: 2.5.2 + transitivePeerDependencies: + - encoding + - supports-color + dev: true + + /ilp-protocol-ccp@1.2.3: + resolution: {integrity: sha512-dYYAaaOxx7/Ghc/DiWNzP0qbqBp5d3fruVqpbGqhG9fpdztbLAfpmj0RIgFQ6BR2fnNokGtPyw0EwjBzJvHbZw==} + dependencies: + ilp-packet: 3.1.3 + oer-utils: 5.1.2 + dev: true + /ilp-protocol-ccp@1.2.4-alpha.2: resolution: {integrity: sha512-y/86eHTILmLc0tDpaQ998lmNKJOpFdFv8gfjdvZorBTgqAJ2mkEpCDpoQZnDCGshS4NFtu/56wbV8DT2Fs66gQ==} dependencies: @@ -14308,10 +14639,20 @@ packages: oer-utils: 5.1.3-alpha.2 dev: false + /ilp-protocol-ildcp@2.2.3: + resolution: {integrity: sha512-cz1q5dAZ4vYYlNyGy++waUfSa1W5/xyUtu7YYtcNk2klbLXqzBcflDuQj3/MmmTNhDS8WTxdRUydFMeGEpL13w==} + dependencies: + debug: 4.4.1(supports-color@7.2.0) + ilp-packet: 3.1.3 + oer-utils: 5.1.2 + transitivePeerDependencies: + - supports-color + dev: true + /ilp-protocol-ildcp@2.2.4-alpha.2: resolution: {integrity: sha512-pMBHAXwTnOA1E9TzJAXxbVxrCpqqcYEPJ5w+9kj/gTr3Lmu8M5U/h0W7bGx/pgfGQ2jHXKdO8IJurojDjfoURA==} dependencies: - debug: 4.3.4 + debug: 4.4.1(supports-color@7.2.0) ilp-packet: 3.1.4-alpha.2 oer-utils: 5.1.3-alpha.2 transitivePeerDependencies: @@ -14722,6 +15063,12 @@ packages: engines: {node: '>=8'} dev: true + /is-lower-case@1.1.3: + resolution: {integrity: sha512-+5A1e/WJpLLXZEDlgz4G//WYSHyQBD32qa4Jd3Lw06qQlv3fJHnp3YIHjTQSGzHMgzmVKz2ZP3rBxTHkPw/lxA==} + dependencies: + lower-case: 1.1.4 + dev: true + /is-lower-case@2.0.2: resolution: {integrity: sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ==} dependencies: @@ -14912,6 +15259,12 @@ packages: engines: {node: '>=10'} dev: true + /is-upper-case@1.1.2: + resolution: {integrity: sha512-GQYSJMgfeAmVwh9ixyk888l7OIhNAGKtY6QA+IrWlu9MDTCaXmeozOZ2S9Knj7bQwBO/H6J2kb+pbyTUiMNbsw==} + dependencies: + upper-case: 1.1.3 + dev: true + /is-upper-case@2.0.2: resolution: {integrity: sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ==} dependencies: @@ -15018,7 +15371,7 @@ packages: '@babel/parser': 7.26.7 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.0 - semver: 7.6.3 + semver: 7.7.2 transitivePeerDependencies: - supports-color dev: true @@ -15036,7 +15389,7 @@ packages: resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} engines: {node: '>=10'} dependencies: - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@7.2.0) istanbul-lib-coverage: 3.2.0 source-map: 0.6.1 transitivePeerDependencies: @@ -15658,6 +16011,22 @@ packages: resolution: {integrity: sha512-trvBk1ki43VZptdBI5rIlG4YOzyeH/WefQt5rj1grasPn4iiZWKet8nkgc4GlsAylaztn0qZfUYOiTsASJFdNA==} dev: true + /jsonwebtoken@8.5.1: + resolution: {integrity: sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==} + engines: {node: '>=4', npm: '>=1.4.28'} + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 5.7.2 + dev: true + /jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -15668,6 +16037,21 @@ packages: object.values: 1.1.7 dev: true + /jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + dev: true + + /jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + dev: true + /katex@0.16.21: resolution: {integrity: sha512-XvqR7FgOHtWupfMiigNzmh+MgUVmDGU2kXZm899ZkPfcuoPuFxyHmXsgATDpFZDAXCI8tvinaVcDo8PIIJSo4A==} hasBin: true @@ -15789,7 +16173,7 @@ packages: content-disposition: 0.5.4 content-type: 1.0.5 cookies: 0.9.1 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0(supports-color@7.2.0) delegates: 1.0.0 depd: 2.0.0 destroy: 1.2.0 @@ -15820,7 +16204,7 @@ packages: content-disposition: 0.5.4 content-type: 1.0.5 cookies: 0.9.1 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@7.2.0) delegates: 1.0.0 depd: 2.0.0 destroy: 1.2.0 @@ -16015,27 +16399,66 @@ packages: /lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} - dev: false + + /lodash.difference@4.5.0: + resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + dev: true + + /lodash.flatten@4.4.0: + resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + dev: true /lodash.get@4.4.2: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. dev: false + /lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + dev: true + /lodash.isarguments@3.1.0: resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} dev: false + /lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + dev: true + /lodash.isfinite@3.3.2: resolution: {integrity: sha512-7FGG40uhC8Mm633uKW1r58aElFlBlxCrg9JfSi3P6aYiWmfiWF0PgMd86ZUsxE5GwWPdHoS2+48bwTh2VPkIQA==} dev: false + /lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + dev: true + + /lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + dev: true + + /lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + dev: true + + /lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + dev: true + /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + /lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + dev: true + /lodash.sortby@4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + /lodash.union@4.6.0: + resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} + dev: true + /lodash.unset@4.5.2: resolution: {integrity: sha512-bwKX88k2JhCV9D1vtE8+naDKlLiGrSmf8zi/Y9ivFHwbmRfA8RxS/aVJ+sIht2XOwqoNr4xUPUkGZpc1sHFEKg==} dev: false @@ -16066,6 +16489,11 @@ packages: engines: {node: '>= 0.6.0'} dev: false + /long@3.2.0: + resolution: {integrity: sha512-ZYvPPOMqUwPoDsbJaR10iQJYnMuZhRTvHYl62ErLIEX7RgFlziSBUUvrt3OVfc47QlHHpzPZYP17g3Fv7oeJkg==} + engines: {node: '>=0.6'} + dev: true + /long@4.0.0: resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==} @@ -16082,12 +16510,22 @@ packages: dependencies: js-tokens: 4.0.0 + /lower-case-first@1.0.2: + resolution: {integrity: sha512-UuxaYakO7XeONbKrZf5FEgkantPf5DUqDayzP5VXZrtRPdH86s4kN47I8B3TW10S4QKiE3ziHNf3kRN//okHjA==} + dependencies: + lower-case: 1.1.4 + dev: true + /lower-case-first@2.0.2: resolution: {integrity: sha512-EVm/rR94FJTZi3zefZ82fLWab+GX14LJN4HrWBcuo6Evmsl9hEfnqxgcHCKb9q+mNf6EVdsjx/qucYFIIB84pg==} dependencies: tslib: 2.8.1 dev: true + /lower-case@1.1.4: + resolution: {integrity: sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==} + dev: true + /lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} dependencies: @@ -17266,7 +17704,7 @@ packages: resolution: {integrity: sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==} dependencies: '@types/debug': 4.1.12 - debug: 4.4.1 + debug: 4.4.1(supports-color@7.2.0) decode-named-character-reference: 1.0.2 micromark-core-commonmark: 1.1.0 micromark-factory-space: 1.1.0 @@ -17290,7 +17728,7 @@ packages: resolution: {integrity: sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==} dependencies: '@types/debug': 4.1.12 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@7.2.0) decode-named-character-reference: 1.0.2 devlop: 1.1.0 micromark-core-commonmark: 2.0.1 @@ -17601,12 +18039,30 @@ packages: '@types/nlcst': 2.0.3 dev: false + /no-case@2.3.2: + resolution: {integrity: sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==} + dependencies: + lower-case: 1.1.4 + dev: true + /no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} dependencies: lower-case: 2.0.2 tslib: 2.8.1 + /nock@13.3.1: + resolution: {integrity: sha512-vHnopocZuI93p2ccivFyGuUfzjq2fxNyNurp7816mlT5V5HF4SzXu8lvLrVzBbNqzs+ODooZ6OksuSUNM7Njkw==} + engines: {node: '>= 10.13'} + dependencies: + debug: 4.4.1(supports-color@7.2.0) + json-stringify-safe: 5.0.1 + lodash: 4.17.21 + propagate: 2.0.1 + transitivePeerDependencies: + - supports-color + dev: true + /nock@13.5.6: resolution: {integrity: sha512-o2zOYiCpzRqSzPj0Zt/dQ/DqZeYoaQ7TUonc/xUPjCGl9WeHpNbxgVvOquXYAaJzI0M9BXV3HTzG0p8IUAbBTQ==} engines: {node: '>= 10.13'} @@ -17646,18 +18102,6 @@ packages: resolution: {integrity: sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==} dev: false - /node-fetch@2.6.7: - resolution: {integrity: sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==} - engines: {node: 4.x || >=6.0.0} - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - dependencies: - whatwg-url: 5.0.0 - dev: false - /node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -17960,6 +18404,23 @@ packages: knex: 3.1.0(pg@8.11.3) dev: false + /oer-utils@1.3.4: + resolution: {integrity: sha512-JTRqe1iQuB0weu1Mppu0YUApL6CU0CxtmB8pJIhTyTm4X7rmps6p18GVRzwHRfvSP7YUGakzgA+xPqZseF1FOA==} + dev: true + + /oer-utils@4.0.0: + resolution: {integrity: sha512-WxX0gNGSS0Lzig4IdliHlIQ03PVTpIuLFbOAag5lJrx1hWNG3f/yhx3QOmORpoXe2e53HZbeet8qoOAsiWz5BQ==} + dependencies: + bignumber.js: 7.2.1 + dev: true + + /oer-utils@5.1.2: + resolution: {integrity: sha512-VhkvT3bthHrbnwBOG9vGpDFB8XHrIitpZY2nC+3scZI2Tf17g8YmeDK6wsA7HpdjGXMsbf14fRgltBXwhzrWOw==} + dependencies: + '@types/long': 4.0.1 + long: 4.0.0 + dev: true + /oer-utils@5.1.3-alpha.2: resolution: {integrity: sha512-BX/8xcb+TXGTRtu97a/ZvcrWSyMd8wt8GSQMD7MvKxPEFqN4lX9w+Pk/Wm4HxwfHQQO9EhTYryR5hosTgWxP+A==} dependencies: @@ -18067,15 +18528,15 @@ packages: /openapi-response-validator@9.3.1: resolution: {integrity: sha512-2AOzHAbrwdj5DNL3u+BadhfmL3mlc3mmCv6cSAsEjoMncpOOVd95JyMf0j0XUyJigJ8/ILxnhETfg35vt1pGSQ==} dependencies: - ajv: 8.12.0 + ajv: 8.17.1 openapi-types: 9.3.1 dev: true /openapi-schema-validator@9.3.1: resolution: {integrity: sha512-5wpFKMoEbUcjiqo16jIen3Cb2+oApSnYZpWn8WQdRO2q/dNQZZl8Pz6ESwCriiyU5AK4i5ZI6+7O3bHQr6+6+g==} dependencies: - ajv: 8.12.0 - ajv-formats: 2.1.1(ajv@8.12.0) + ajv: 8.17.1 + ajv-formats: 2.1.1(ajv@8.17.1) lodash.merge: 4.6.2 openapi-types: 9.3.1 dev: true @@ -18279,6 +18740,12 @@ packages: /pako@0.2.9: resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + /param-case@2.1.1: + resolution: {integrity: sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w==} + dependencies: + no-case: 2.3.2 + dev: true + /param-case@3.0.4: resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} dependencies: @@ -18364,12 +18831,25 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + /pascal-case@2.0.1: + resolution: {integrity: sha512-qjS4s8rBOJa2Xm0jmxXiyh1+OFf6ekCWOvUaRgAQSktzlTbMotS0nmG9gyYAybCWBcuP4fsBeRCKNwGBnMe2OQ==} + dependencies: + camel-case: 3.0.0 + upper-case-first: 1.1.2 + dev: true + /pascal-case@3.1.2: resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} dependencies: no-case: 3.0.4 tslib: 2.8.1 + /path-case@2.1.1: + resolution: {integrity: sha512-Ou0N05MioItesaLr9q8TtHVWmJ6fxWdqKB2RohFmNWVyJ+2zeKIeDNWAN6B/Pe7wpzWChhZX6nONYmOnMeJQ/Q==} + dependencies: + no-case: 2.3.2 + dev: true + /path-case@3.0.4: resolution: {integrity: sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==} dependencies: @@ -18901,6 +19381,13 @@ packages: /process-warning@3.0.0: resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==} + /prom-client@11.5.3: + resolution: {integrity: sha512-iz22FmTbtkyL2vt0MdDFY+kWof+S9UB/NACxSn2aJcewtw+EERsen0urSkZ2WrHseNdydsvcxCTAnPcSMZZv4Q==} + engines: {node: '>=6.1'} + dependencies: + tdigest: 0.1.2 + dev: true + /promise-inflight@1.0.1: resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==} peerDependencies: @@ -19100,6 +19587,7 @@ packages: /raw-body@1.1.7: resolution: {integrity: sha512-WmJJU2e9Y6M5UzTOkHaM7xJGAPQD8PNzx3bAd2+uhZAim6wDk6dAZxPVYLF67XhbR4hmKGh33Lpmh4XWrCH5Mg==} engines: {node: '>= 0.8.0'} + deprecated: No longer maintained. Please upgrade to a stable version. dependencies: bytes: 1.0.0 string_decoder: 0.10.31 @@ -19290,6 +19778,10 @@ packages: redis-errors: 1.2.0 dev: false + /reduct@3.3.1: + resolution: {integrity: sha512-1OaOZqNczCfns093pnxtvOqe/1fr758JIIHlQkBritMMm3O1aPiDA3Wmj5gDsQjsBqMKB7lYU0BaUHIqLbS5sA==} + dev: true + /reflect.getprototypeof@1.0.8: resolution: {integrity: sha512-B5dj6usc5dkk8uFliwjwDHM8To5/QwdKz9JcBZ8Ic4G1f0YmeeJTtE/ZTdgRFPAfxZFiUaPhZ1Jcs4qeagItGQ==} engines: {node: '>= 0.4'} @@ -19675,7 +20167,7 @@ packages: resolution: {integrity: sha512-X34iHADNbNDfr6OTStIAHWSAvvKQRYgLO6duASaVf7J2VA3lvmNYboAHOuLC2huav1IwgZJtyEcJCKVzFxOSMQ==} engines: {node: '>=8.6.0'} dependencies: - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0(supports-color@7.2.0) module-details-from-path: 1.0.3 resolve: 1.22.8 transitivePeerDependencies: @@ -19841,6 +20333,16 @@ packages: glob: 10.3.10 dev: true + /riverpig@1.1.4: + resolution: {integrity: sha512-V2hBp+E8a6MpV7hXGlX0gelkC1gTHTa6a+JQ8t2+IjnhTesDYIpBwI7IG+qtb7aTj4A2R0SnOcUDLjLSBc3A6A==} + engines: {node: '>=6.0.0'} + dependencies: + chalk: 2.4.2 + debug: 3.2.7 + transitivePeerDependencies: + - supports-color + dev: true + /robust-predicates@3.0.2: resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} dev: false @@ -20011,7 +20513,6 @@ packages: /sax@1.2.4: resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==} - dev: false /scheduler@0.23.0: resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} @@ -20048,6 +20549,11 @@ packages: /secure-json-parse@2.5.0: resolution: {integrity: sha512-ZQruFgZnIWH+WyO9t5rWt4ZEGqCKPwhiw+YbzTwpmT9elgLrLcfuyUiSnwwjUiVy9r4VM3urtbNF1xmEh9IL2w==} + /semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + dev: true + /semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -20098,6 +20604,13 @@ packages: transitivePeerDependencies: - supports-color + /sentence-case@2.1.1: + resolution: {integrity: sha512-ENl7cYHaK/Ktwk5OTD+aDbQ3uC8IByu/6Bkg+HDv8Mm+XnBnppVNalcfJTNsp1ibstKh030/JKQQWglDvtKwEQ==} + dependencies: + no-case: 2.3.2 + upper-case-first: 1.1.2 + dev: true + /sentence-case@3.0.4: resolution: {integrity: sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==} dependencies: @@ -20439,6 +20952,12 @@ packages: engines: {node: '>= 18'} dev: false + /snake-case@2.1.0: + resolution: {integrity: sha512-FMR5YoPFwOLuh4rRz92dywJjyKYZNLpMn1R5ujVpIYkbA9p01fq8RMg0FkO4M+Yobt4MjHeLTJVm5xFFBHSV2Q==} + dependencies: + no-case: 2.3.2 + dev: true + /snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} dependencies: @@ -20980,6 +21499,13 @@ packages: engines: {node: '>=0.8.0'} dev: false + /supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + dependencies: + has-flag: 3.0.0 + dev: true + /supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -21001,6 +21527,13 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + /swap-case@1.1.2: + resolution: {integrity: sha512-BAmWG6/bx8syfc6qXPprof3Mn5vQgf5dwdUNJhsNqU9WdPt5P+ES/wQ5bxfijy8zwZgZZHslC3iAsxsuQMCzJQ==} + dependencies: + lower-case: 1.1.4 + upper-case: 1.1.3 + dev: true + /swap-case@2.0.2: resolution: {integrity: sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw==} dependencies: @@ -21143,6 +21676,12 @@ packages: engines: {node: '>=8.0.0'} dev: false + /tdigest@0.1.2: + resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} + dependencies: + bintrees: 1.0.2 + dev: true + /terser-webpack-plugin@5.3.11(@swc/core@1.11.29)(webpack@5.97.1): resolution: {integrity: sha512-RVCsMfuD0+cTt3EwX8hSl2Ks56EbFHWmhluwcqoPKtBnfjiT6olaq7PRIRfhyU8nnC2MrnDrBLfrD/RGE+cVXQ==} engines: {node: '>= 10.13.0'} @@ -21210,6 +21749,29 @@ packages: - supports-color dev: true + /testcontainers@9.8.0: + resolution: {integrity: sha512-61IlJeVrUbS5JlAgM/N0koFnRxsID+vDap7CUmgaHXSGxmFofCiokB7kD96c1BtDWGOznrd7lTAPGSkd3RVkPA==} + engines: {node: '>= 10.16'} + dependencies: + '@balena/dockerignore': 1.0.2 + '@types/archiver': 5.3.4 + '@types/dockerode': 3.3.32 + archiver: 5.3.2 + async-lock: 1.4.1 + byline: 5.0.0 + debug: 4.4.1(supports-color@7.2.0) + docker-compose: 0.23.19 + dockerode: 3.3.5 + get-port: 5.1.1 + node-fetch: 2.7.0 + properties-reader: 2.3.0 + ssh-remote-port-forward: 1.0.4 + tar-fs: 2.1.1 + transitivePeerDependencies: + - encoding + - supports-color + dev: true + /text-decoder@1.2.1: resolution: {integrity: sha512-x9v3H/lTKIJKQQe7RPQkLfKAnc9lUTkWDypIQgTzPJAq+5/GCDHonmshfvlsNSj58yyshbIJJDLmU15qNERrXQ==} requiresBuild: true @@ -21322,6 +21884,13 @@ packages: picomatch: 4.0.2 dev: false + /title-case@2.1.1: + resolution: {integrity: sha512-EkJoZ2O3zdCz3zJsYCsxyq2OC5hrxR9mfdd5I+w8h/tmFfeOxJ+vvkxsKxdmN0WtS9zLdHEgfgVOiMVgv+Po4Q==} + dependencies: + no-case: 2.3.2 + upper-case: 1.1.3 + dev: true + /title-case@3.0.3: resolution: {integrity: sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==} dependencies: @@ -22088,11 +22657,21 @@ packages: picocolors: 1.1.1 dev: true + /upper-case-first@1.1.2: + resolution: {integrity: sha512-wINKYvI3Db8dtjikdAqoBbZoP6Q+PZUyfMR7pmwHzjC2quzSkUq5DmPrTtPEqHaz8AGtmsB4TqwapMTM1QAQOQ==} + dependencies: + upper-case: 1.1.3 + dev: true + /upper-case-first@2.0.2: resolution: {integrity: sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==} dependencies: tslib: 2.8.1 + /upper-case@1.1.3: + resolution: {integrity: sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==} + dev: true + /upper-case@2.0.2: resolution: {integrity: sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==} dependencies: @@ -22138,6 +22717,12 @@ packages: hasBin: true dev: false + /uuid@3.4.0: + resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} + deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. + hasBin: true + dev: true + /uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true @@ -22288,7 +22873,7 @@ packages: hasBin: true dependencies: cac: 6.7.14 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@7.2.0) mlly: 1.7.3 pathe: 1.1.2 picocolors: 1.1.1 @@ -22312,7 +22897,7 @@ packages: hasBin: true dependencies: cac: 6.7.14 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@7.2.0) mlly: 1.7.3 pathe: 1.1.2 picocolors: 1.1.1 @@ -22336,7 +22921,7 @@ packages: hasBin: true dependencies: cac: 6.7.14 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0(supports-color@7.2.0) es-module-lexer: 1.6.0 pathe: 1.1.2 vite: 6.2.5(@types/node@18.11.9)(yaml@2.7.0) @@ -22361,7 +22946,7 @@ packages: hasBin: true dependencies: cac: 6.7.14 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0(supports-color@7.2.0) es-module-lexer: 1.6.0 pathe: 1.1.2 vite: 6.2.5(@types/node@20.12.7)(yaml@2.7.0) @@ -23033,6 +23618,11 @@ packages: resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} dev: true + /yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + dev: true + /yaml@2.7.0: resolution: {integrity: sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==} engines: {node: '>= 14'} @@ -23133,6 +23723,15 @@ packages: /zen-observable@0.8.15: resolution: {integrity: sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==} + /zip-stream@4.1.1: + resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} + engines: {node: '>= 10'} + dependencies: + archiver-utils: 3.0.4 + compress-commons: 4.1.2 + readable-stream: 3.6.0 + dev: true + /zip-stream@6.0.1: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'}