diff --git a/docs/account-service-api.md b/docs/account-service-api.md index 4836bfe5fb..0f38a4427d 100644 --- a/docs/account-service-api.md +++ b/docs/account-service-api.md @@ -5,6 +5,7 @@ See [Update Account Services API Docs](https://github.com/interledger/rafiki/issues/550) - [Rafiki Account Service API](#rafiki-account-service-api) +- [OUTDATED Needs to be updated](#outdated-needs-to-be-updated) - [Parties](#parties) - [**Permissioning**](#permissioning) - [Encoding](#encoding) @@ -14,12 +15,12 @@ See [Update Account Services API Docs](https://github.com/interledger/rafiki/iss - [Server behavior](#server-behavior) - [Errors](#errors) - [Interledger accounts](#interledger-accounts) - - [Permissioning](#permissioning) + - [Permissioning](#permissioning-1) - [InterledgerAccount resource](#interledgeraccount-resource) - [Fetch all Interledger accounts](#fetch-all-interledger-accounts) - [Get Interledger account](#get-interledger-account) - [InterledgerBalance resource](#interledgerbalance-resource) - - [Get Interledger balance](#get-interledger-balance) + - [Get Interledger balance](#get-interledger-balance) - [Create Interledger account](#create-interledger-account) - [Update Interledger account](#update-interledger-account) - [Delete Interledger account](#delete-interledger-account) @@ -44,16 +45,16 @@ See [Update Account Services API Docs](https://github.com/interledger/rafiki/iss - [Deposits](#deposits) - [Deposit resource](#deposit-resource) - [Execute deposit](#execute-deposit) - - [Parameters](#parameters-5) + - [Parameters](#parameters-1) - [Lookup deposit](#lookup-deposit) - [Withdrawals](#withdrawals) - [Crash recovery](#crash-recovery) - [Withdrawal resource](#withdrawal-resource) - [Request withdrawal](#request-withdrawal) - [`POST /liquidity-accounts/{accountId}/withdrawals`](#post-liquidity-accountsaccountidwithdrawals) - - [Parameters](#parameters-6) - - [Finalize pending withdrawal](#finalize-pending-withdrawal) - - [Rollback pending withdrawal](#rollback-pending-withdrawal) + - [Parameters](#parameters-2) + - [Post pending withdrawal](#post-pending-withdrawal) + - [Void pending withdrawal](#void-pending-withdrawal) - [Lookup withdrawal](#lookup-withdrawal) # Parties @@ -255,7 +256,7 @@ TODO: Then, does this also need an API to apply the Fulfill? | originAmount | No | String | UInt64 | TODO | | destinationAccountId | No | String | V4 UUID | | | destinationAmount | Yes | String | UInt64 | If omitted, defaults to the origin amount (but requires both accounts to be the same asset & denomination, or else fails). | -| autoCommit | Yes | Boolean | | Defaults to two-phase commit? If true, transfer is irrevocable | +| autoPost | Yes | Boolean | | Defaults to two-phase transfer? If true, transfer is irrevocable | **Response** @@ -473,7 +474,7 @@ To safely integrate this functionality: 1. Provider applies a balance reduction in its ledger corresponding to funds the counterparty sent to them, initiating the deposit. 2. Provider executes the deposit within Rafiki, performing the request with the same idempotency key until it gets an acknowledgement Rafiki credited the balance. -3. Provider finalizes or rolls back the deposit within its own system, depending upon if Rafiki successfully applied the deposit. +3. Provider posts or voids the deposit within its own system, depending upon if Rafiki successfully applied the deposit. #### Deposit resource @@ -525,30 +526,30 @@ To safely integrate withdrawals: 2. Provider requests a withdrawal from Rafiki, which reduces the balance and places a hold on the funds, or fails if there's insufficient funds. - If the account has insufficient funds, the provider cancels the withdrawal within their system. 3. Provider credits the balance in their external system or performs a (potentially irrevocable) settlement. - - If this fails, the provider may rollback the withdrawal with Rafiki. -4. After the settlement is applied, the provider finalizes the withdrawal within Rafiki, commiting the balance reduction. -5. Provider finalizes the withdrawal in its system. + - If this fails, the provider may void the withdrawal with Rafiki. +4. After the settlement is applied, the provider posts the withdrawal within Rafiki, posting the balance reduction. +5. Provider posts the withdrawal in its system. ### Crash recovery If the provider's withdrawal system crashes between steps 1 and 3, it may not know whether it already initiated the withdrawal and reserved funds within Rafiki. At this stage, it may choose to either: 1. **Retry.** Safely retry initiating the withdrawal in Rafiki. Since this is an idempotent request, it will only debit funds if they have not already been reserved for that withdrawal. -2. **Rollback.** Safely rollback the withdrawal within Rafiki. Since this is also an idempotent request, this only lifts the hold on funds if the same withdrawal was already initiated. - - If the provider encounters a technical issue preventing them from settling or crediting the balance within their own system, they may decide to rollback the withdrawal. - - Rollbacks are not performed automatically, and only the provider's system initiates rollbacks. For example, if the provider's system credited the withdrawal within its own system, then crashed, Rafiki might rollback the withdrawal before the operator's system recovered. This dangerous behavior might overdraw users, or enable them to steal money from the operator. - - Operators may implement their own functionality to rollback withdrawals after a period of time. +2. **Void.** Safely void the withdrawal within Rafiki. Since this is also an idempotent request, this only lifts the hold on funds if the same withdrawal was already initiated. + - If the provider encounters a technical issue preventing them from settling or crediting the balance within their own system, they may decide to void the withdrawal. + - Voids are not performed automatically, and only the provider's system initiates voids. For example, if the provider's system credited the withdrawal within its own system, then crashed, Rafiki might void the withdrawal before the operator's system recovered. This dangerous behavior might overdraw users, or enable them to steal money from the operator. + - Operators may implement their own functionality to void withdrawals after a period of time. -If the provider's withdrawal system crashes between steps 3 and 5, the system knows Rafiki reserved the withdrawal amount, but it may not have finalized the withdrawal. So, the provider may safely retry finalizing the withdrawal within Rafiki as an idempotent request after they perform the settlement. +If the provider's withdrawal system crashes between steps 3 and 5, the system knows Rafiki reserved the withdrawal amount, but it may not have posted the withdrawal. So, the provider may safely retry posting the withdrawal within Rafiki as an idempotent request after they perform the settlement. #### Withdrawal resource -| Name | Optional | JSON type | Type | Description | -| :------------ | :------- | :-------- | :------ | :------------------------------------------------------------------------------------------------------------------------------------------------------- | -| id | No | String | V4 UUID | Unique ID for this withdrawal, randomly generated by Rafiki. | -| amount | No | String | UInt64 | Amount debited from the corresponding account, or amount on hold if the withdrawal is not yet finalized. | -| createdTime | No | String | UInt64 | UNIX nanosecond timestamp when the withdrawal is initiated and funds were reserved, assigned by TigerBeetle as a sequence number. | -| finalizedTime | Yes | String | UInt64 | UNIX nanosecond timestamp of the finalized transfer, assigned by TigerBeetle as a sequence number. Excluded until the provider finalizes the withdrawal. | +| Name | Optional | JSON type | Type | Description | +| :---------- | :------- | :-------- | :------ | :------------------------------------------------------------------------------------------------------------------------------------------------ | +| id | No | String | V4 UUID | Unique ID for this withdrawal, randomly generated by Rafiki. | +| amount | No | String | UInt64 | Amount debited from the corresponding account, or amount on hold if the withdrawal is not yet posted. | +| createdTime | No | String | UInt64 | UNIX nanosecond timestamp when the withdrawal is initiated and funds were reserved, assigned by TigerBeetle as a sequence number. | +| postedTime | Yes | String | UInt64 | UNIX nanosecond timestamp of the posted transfer, assigned by TigerBeetle as a sequence number. Excluded until the provider posts the withdrawal. | ### Request withdrawal @@ -574,31 +575,31 @@ A successful response indicates the provider may safely and irrevocably credit t #### Parameters -| Name | Optional | JSON type | Type | Description | -| :----- | :------- | :-------- | :----- | :--------------------------------------------------------------------------------------------------------- | -| amount | No | String | UInt64 | Amount to debit from the corresponding account, if available, as a hold until the withdrawal is finalized. | +| Name | Optional | JSON type | Type | Description | +| :----- | :------- | :-------- | :----- | :------------------------------------------------------------------------------------------------------ | +| amount | No | String | UInt64 | Amount to debit from the corresponding account, if available, as a hold until the withdrawal is posted. | **Response** If successful, returns `201 Created` with the new `**Withdrawal**` resource. If the account balance is insufficient to perform the withdrawal, returns a `400 Bad Request` error. -### Finalize pending withdrawal +### Post pending withdrawal -Commits the transfer debiting funds the given account, so funds may no longer be rolled back, marking the withdrawal as complete. +Posts the transfer debiting funds the given account, so funds may no longer be voided, marking the withdrawal as complete. -The finalization step exists so clients of Rafiki may differentiate pending withdrawals—which may be reverted—from withdrawals fully credited within the provider's external settlement system. +The posting step exists so clients of Rafiki may differentiate pending withdrawals—which may be reverted—from withdrawals fully credited within the provider's external settlement system. **Request** -`POST /ilp-accounts/{accountId}/withdrawals/{withdrawalId}/finalize` +`POST /ilp-accounts/{accountId}/withdrawals/{withdrawalId}/post` -`POST /liquidity-accounts/{accountId}/withdrawals/{withdrawalId}/finalize` +`POST /liquidity-accounts/{accountId}/withdrawals/{withdrawalId}/post` **Response** If successful, returns a `204 No Content` response. -### Rollback pending withdrawal +### Void pending withdrawal Deletes the withdrawal resource and releases the hold on the account's funds. @@ -610,7 +611,7 @@ Deletes the withdrawal resource and releases the hold on the account's funds. **Response** -If successful, returns a `204 No Content` response. If already rolled back, may return a `404 Not Found` error. +If successful, returns a `204 No Content` response. If the pending withdrawal was already voided, may return a `404 Not Found` error. ### Lookup withdrawal diff --git a/infrastructure/terraform/rafiki-test/.terraform.lock.hcl b/infrastructure/terraform/rafiki-test/.terraform.lock.hcl index 04feff8700..d18605ceca 100644 --- a/infrastructure/terraform/rafiki-test/.terraform.lock.hcl +++ b/infrastructure/terraform/rafiki-test/.terraform.lock.hcl @@ -2,19 +2,19 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/google" { - version = "4.49.0" - constraints = "~> 4.49.0" + version = "4.50.0" + constraints = "~> 4.50.0" hashes = [ - "h1:39Tdupc+nIX2UOD7mT/2CPdaYv8qs9QO1dmc3e/Z/Vg=", - "h1:DOYre+TiAErDprRnWy3HB1skET0rNtTqVT6HxNtY11M=", - "h1:GVal0WKtU0GOaWlIgr9XxA9fmCDodEvHG4J5JqoWsdU=", - "h1:HXq22ONrih8/1ZR+8ws4r+Tvyrr4pbaghl36b1j6360=", - "h1:IuC4bklR05V/XpKAXYUpwHr5choEUAzT8BwsN985ahI=", - "h1:TIpO8BINlHe/qcsXs+NWl7I665B37o3GHg8i87QU0JI=", - "h1:VwkL9Fad+rukROzUBsY2sEojy/6+quKJewXfmac3tI8=", - "h1:XFuRaDeIxzlBWs8c4tseZR2D2sbFUQUY732ljVa61aM=", - "h1:mFexwDoGXWqYo2Znlf59YkF2ObsES+RC7oVtoCE6aSM=", - "h1:rsvTgtAYaWrRmlPDNhqWRWegiqNDnZ26sLJ6fxtV4+A=", - "h1:zR7p8BGrW9VC6TG1t143wDsFFp5tUrReIq7cDmGlkf8=", + "h1:/EkM+8sBOMR0GoYwlZhfVA8LsQ+WKHXIhOf9A7nlBbA=", + "h1:5LW6KCPAu5WZH6UxzVm/UrQ0/WyeKKyB6dNFWZ2julw=", + "h1:7iwDAwCchZC1D46TvlTYcCEY7wUztgVypOMzuPVWdGE=", + "h1:IEgdWy6HHxu7Dnfzm0PeJVTTMJXSdmhYp+snARJWfkk=", + "h1:Qyg0jc9JupG+n2lR/e+nfgmM/brmqLGdYxi58sOOMc4=", + "h1:XVfQipuA4yY4RI5+Lm5wmbjGvWFZU/NAzapy0DdRqoM=", + "h1:Z+00W9q2VR6FOUaVU+mAjRaM9WhIav9nNKRzpdfjKsI=", + "h1:Z+xvDC/xU7EIXYPnaCSCdGAK1XvOQU2myM5UY1QzIkU=", + "h1:hzdokGRnsma3EaHgSsMaElozy8an6JTJumD9VlBX150=", + "h1:ipQbwu2j/Tg/tlHqHBWQy9aRT9z8F46npyfI+4ZzMZs=", + "h1:qLnqxZpYf/t8UROCmRDTkqsfrq2R9SftzgHX1j5w9Vc=", ] } diff --git a/infrastructure/terraform/rafiki-test/backend.tf b/infrastructure/terraform/rafiki-test/backend.tf index 8191d1f7c5..7b4c57d1e1 100644 --- a/infrastructure/terraform/rafiki-test/backend.tf +++ b/infrastructure/terraform/rafiki-test/backend.tf @@ -3,7 +3,7 @@ terraform { required_providers { google = { source = "hashicorp/google" - version = "~> 4.49.0" + version = "~> 4.50.0" } } backend "gcs" { diff --git a/packages/backend/src/accounting/errors.ts b/packages/backend/src/accounting/errors.ts index 7871e24ff8..a4c2a1dcc9 100644 --- a/packages/backend/src/accounting/errors.ts +++ b/packages/backend/src/accounting/errors.ts @@ -19,8 +19,8 @@ export class CreateTransferError extends Error { } export enum TransferError { - AlreadyCommitted = 'AlreadyCommitted', - AlreadyRolledBack = 'AlreadyRolledBack', + AlreadyPosted = 'AlreadyPosted', + AlreadyVoided = 'AlreadyVoided', DifferentAssets = 'DifferentAssets', InsufficientBalance = 'InsufficientBalance', InsufficientDebitBalance = 'InsufficientDebitBalance', diff --git a/packages/backend/src/accounting/service.test.ts b/packages/backend/src/accounting/service.test.ts index 7b069d3f29..573e4a36cb 100644 --- a/packages/backend/src/accounting/service.test.ts +++ b/packages/backend/src/accounting/service.test.ts @@ -207,7 +207,7 @@ describe('Accounting Service', (): void => { timeout: 0n }) assert.ok(!isTransferError(transfer)) - await transfer.commit() + await transfer.post() }) ) await expect( @@ -301,10 +301,10 @@ describe('Accounting Service', (): void => { ${BigInt(2)} | ${BigInt(1)} | ${'destination < source'} `('$description', ({ sourceAmount, destinationAmount }): void => { test.each` - commit | description - ${true} | ${'commit'} - ${false} | ${'rollback'} - `('$description', async ({ commit }): Promise => { + post | description + ${true} | ${'post'} + ${false} | ${'void'} + `('$description', async ({ post }): Promise => { const trxOrError = await accountingService.createTransfer({ sourceAccount, destinationAccount, @@ -341,37 +341,35 @@ describe('Accounting Service', (): void => { accountingService.getBalance(destinationAccount.id) ).resolves.toEqual(BigInt(0)) - if (commit) { - await expect(trxOrError.commit()).resolves.toBeUndefined() + if (post) { + await expect(trxOrError.post()).resolves.toBeUndefined() } else { - await expect(trxOrError.rollback()).resolves.toBeUndefined() + await expect(trxOrError.void()).resolves.toBeUndefined() } await expect( accountingService.getBalance(sourceAccount.id) ).resolves.toEqual( - commit - ? startingSourceBalance - sourceAmount - : startingSourceBalance + post ? startingSourceBalance - sourceAmount : startingSourceBalance ) if (sameAsset) { await expect( accountingService.getBalance(sourceAccount.asset.id) ).resolves.toEqual( - commit + post ? startingDestinationLiquidity - amountDiff : startingDestinationLiquidity ) } else { await expect( accountingService.getBalance(sourceAccount.asset.id) - ).resolves.toEqual(commit ? sourceAmount : BigInt(0)) + ).resolves.toEqual(post ? sourceAmount : BigInt(0)) await expect( accountingService.getBalance(destinationAccount.asset.id) ).resolves.toEqual( - commit + post ? startingDestinationLiquidity - destinationAmount : startingDestinationLiquidity ) @@ -379,17 +377,13 @@ describe('Accounting Service', (): void => { await expect( accountingService.getBalance(destinationAccount.id) - ).resolves.toEqual(commit ? destinationAmount : BigInt(0)) + ).resolves.toEqual(post ? destinationAmount : BigInt(0)) - await expect(trxOrError.commit()).resolves.toEqual( - commit - ? TransferError.AlreadyCommitted - : TransferError.AlreadyRolledBack + await expect(trxOrError.post()).resolves.toEqual( + post ? TransferError.AlreadyPosted : TransferError.AlreadyVoided ) - await expect(trxOrError.rollback()).resolves.toEqual( - commit - ? TransferError.AlreadyCommitted - : TransferError.AlreadyRolledBack + await expect(trxOrError.void()).resolves.toEqual( + post ? TransferError.AlreadyPosted : TransferError.AlreadyVoided ) }) }) @@ -654,16 +648,16 @@ describe('Accounting Service', (): void => { }) }) - describe('Commit', (): void => { + describe('Post', (): void => { beforeEach(async (): Promise => { await expect( accountingService.createWithdrawal(withdrawal) ).resolves.toBeUndefined() }) - test('A withdrawal can be committed', async (): Promise => { + test('A withdrawal can be posted', async (): Promise => { await expect( - accountingService.commitWithdrawal(withdrawal.id) + accountingService.postWithdrawal(withdrawal.id) ).resolves.toBeUndefined() await expect( accountingService.getBalance(withdrawal.account.id) @@ -675,37 +669,37 @@ describe('Accounting Service', (): void => { ).resolves.toEqual(startingBalance - withdrawal.amount) }) - test('Cannot commit unknown withdrawal', async (): Promise => { - await expect( - accountingService.commitWithdrawal(uuid()) - ).resolves.toEqual(TransferError.UnknownTransfer) + test('Cannot post unknown withdrawal', async (): Promise => { + await expect(accountingService.postWithdrawal(uuid())).resolves.toEqual( + TransferError.UnknownTransfer + ) }) - test('Cannot commit invalid withdrawal id', async (): Promise => { + test('Cannot post invalid withdrawal id', async (): Promise => { await expect( - accountingService.commitWithdrawal('not a uuid') + accountingService.postWithdrawal('not a uuid') ).resolves.toEqual(TransferError.InvalidId) }) - test('Cannot commit committed withdrawal', async (): Promise => { + test('Cannot post posted withdrawal', async (): Promise => { await expect( - accountingService.commitWithdrawal(withdrawal.id) + accountingService.postWithdrawal(withdrawal.id) ).resolves.toBeUndefined() await expect( - accountingService.commitWithdrawal(withdrawal.id) - ).resolves.toEqual(TransferError.AlreadyCommitted) + accountingService.postWithdrawal(withdrawal.id) + ).resolves.toEqual(TransferError.AlreadyPosted) }) - test('Cannot commit rolled back withdrawal', async (): Promise => { + test('Cannot post voided withdrawal', async (): Promise => { await expect( - accountingService.rollbackWithdrawal(withdrawal.id) + accountingService.voidWithdrawal(withdrawal.id) ).resolves.toBeUndefined() await expect( - accountingService.commitWithdrawal(withdrawal.id) - ).resolves.toEqual(TransferError.AlreadyRolledBack) + accountingService.postWithdrawal(withdrawal.id) + ).resolves.toEqual(TransferError.AlreadyVoided) }) - test('Cannot commit expired withdrawal', async (): Promise => { + test('Cannot post expired withdrawal', async (): Promise => { const expiringWithdrawal = { ...withdrawal, id: uuid(), @@ -716,21 +710,21 @@ describe('Accounting Service', (): void => { ).resolves.toBeUndefined() await new Promise((resolve) => setImmediate(resolve)) await expect( - accountingService.commitWithdrawal(expiringWithdrawal.id) + accountingService.postWithdrawal(expiringWithdrawal.id) ).resolves.toEqual(TransferError.TransferExpired) }) }) - describe('Rollback', (): void => { + describe('Void', (): void => { beforeEach(async (): Promise => { await expect( accountingService.createWithdrawal(withdrawal) ).resolves.toBeUndefined() }) - test('A withdrawal can be rolled back', async (): Promise => { + test('A withdrawal can be voided', async (): Promise => { await expect( - accountingService.rollbackWithdrawal(withdrawal.id) + accountingService.voidWithdrawal(withdrawal.id) ).resolves.toBeUndefined() await expect( accountingService.getBalance(withdrawal.account.id) @@ -742,37 +736,37 @@ describe('Accounting Service', (): void => { ).resolves.toEqual(startingBalance) }) - test('Cannot rollback unknown withdrawal', async (): Promise => { - await expect( - accountingService.rollbackWithdrawal(uuid()) - ).resolves.toEqual(TransferError.UnknownTransfer) + test('Cannot void unknown withdrawal', async (): Promise => { + await expect(accountingService.voidWithdrawal(uuid())).resolves.toEqual( + TransferError.UnknownTransfer + ) }) - test('Cannot commit invalid withdrawal id', async (): Promise => { + test('Cannot post invalid withdrawal id', async (): Promise => { await expect( - accountingService.rollbackWithdrawal('not a uuid') + accountingService.voidWithdrawal('not a uuid') ).resolves.toEqual(TransferError.InvalidId) }) - test('Cannot rollback committed withdrawal', async (): Promise => { + test('Cannot void posted withdrawal', async (): Promise => { await expect( - accountingService.commitWithdrawal(withdrawal.id) + accountingService.postWithdrawal(withdrawal.id) ).resolves.toBeUndefined() await expect( - accountingService.rollbackWithdrawal(withdrawal.id) - ).resolves.toEqual(TransferError.AlreadyCommitted) + accountingService.voidWithdrawal(withdrawal.id) + ).resolves.toEqual(TransferError.AlreadyPosted) }) - test('Cannot rollback rolled back withdrawal', async (): Promise => { + test('Cannot void voided withdrawal', async (): Promise => { await expect( - accountingService.rollbackWithdrawal(withdrawal.id) + accountingService.voidWithdrawal(withdrawal.id) ).resolves.toBeUndefined() await expect( - accountingService.rollbackWithdrawal(withdrawal.id) - ).resolves.toEqual(TransferError.AlreadyRolledBack) + accountingService.voidWithdrawal(withdrawal.id) + ).resolves.toEqual(TransferError.AlreadyVoided) }) - test('Cannot rollback expired withdrawal', async (): Promise => { + test('Cannot void expired withdrawal', async (): Promise => { const expiringWithdrawal = { ...withdrawal, id: uuid(), @@ -783,7 +777,7 @@ describe('Accounting Service', (): void => { ).resolves.toBeUndefined() await new Promise((resolve) => setImmediate(resolve)) await expect( - accountingService.rollbackWithdrawal(expiringWithdrawal.id) + accountingService.voidWithdrawal(expiringWithdrawal.id) ).resolves.toEqual(TransferError.TransferExpired) }) }) diff --git a/packages/backend/src/accounting/service.ts b/packages/backend/src/accounting/service.ts index a5b3a52aac..e52f3ce1fc 100644 --- a/packages/backend/src/accounting/service.ts +++ b/packages/backend/src/accounting/service.ts @@ -73,8 +73,8 @@ export interface TransferOptions { } export interface Transaction { - commit: () => Promise - rollback: () => Promise + post: () => Promise + void: () => Promise } export interface AccountingService { @@ -92,8 +92,8 @@ export interface AccountingService { createTransfer(options: TransferOptions): Promise createDeposit(deposit: Deposit): Promise createWithdrawal(withdrawal: Withdrawal): Promise - commitWithdrawal(id: string): Promise - rollbackWithdrawal(id: string): Promise + postWithdrawal(id: string): Promise + voidWithdrawal(id: string): Promise } export interface ServiceDependencies extends BaseService { @@ -121,8 +121,8 @@ export function createAccountingService( createTransfer: (options) => createTransfer(deps, options), createDeposit: (transfer) => createAccountDeposit(deps, transfer), createWithdrawal: (transfer) => createAccountWithdrawal(deps, transfer), - commitWithdrawal: (options) => commitAccountWithdrawal(deps, options), - rollbackWithdrawal: (options) => rollbackAccountWithdrawal(deps, options) + postWithdrawal: (options) => postAccountWithdrawal(deps, options), + voidWithdrawal: (options) => voidAccountWithdrawal(deps, options) } } @@ -358,7 +358,7 @@ export async function createTransfer( } const trx: Transaction = { - commit: async (): Promise => { + post: async (): Promise => { const error = await createTransfers( deps, transfers.map((transfer) => { @@ -369,7 +369,7 @@ export async function createTransfer( pendingId: transfer.id } }), - true // <- commit + true // <- post ) if (error) { return error.error @@ -390,7 +390,7 @@ export async function createTransfer( }) } }, - rollback: async (): Promise => { + void: async (): Promise => { const error = await createTransfers( deps, transfers.map((transfer) => { @@ -400,7 +400,7 @@ export async function createTransfer( pendingId: transfer.id } }), - false // <- rollback + false // <- void ) if (error) { return error.error @@ -453,7 +453,7 @@ async function createAccountWithdrawal( } } -async function rollbackAccountWithdrawal( +async function voidAccountWithdrawal( deps: ServiceDependencies, withdrawalId: string ): Promise { @@ -485,7 +485,7 @@ async function rollbackAccountWithdrawal( } } -async function commitAccountWithdrawal( +async function postAccountWithdrawal( deps: ServiceDependencies, withdrawalId: string ): Promise { diff --git a/packages/backend/src/accounting/transfers.ts b/packages/backend/src/accounting/transfers.ts index 49eba996ad..6af26904d6 100644 --- a/packages/backend/src/accounting/transfers.ts +++ b/packages/backend/src/accounting/transfers.ts @@ -29,7 +29,7 @@ export interface CreateTransferOptions { export async function createTransfers( deps: ServiceDependencies, transfers: CreateTransferOptions[], - commit?: boolean + post?: boolean ): Promise { const tbTransfers: TbTransfer[] = [] for (let i = 0; i < transfers.length; i++) { @@ -41,10 +41,10 @@ export async function createTransfers( if (transfer.timeout) { flags |= TransferFlags.pending } - if (transfer.pendingId && commit === true) { + if (transfer.pendingId && post === true) { flags |= TransferFlags.post_pending_transfer transfer.id = uuid() - } else if (transfer.pendingId && commit === false) { + } else if (transfer.pendingId && post === false) { flags |= TransferFlags.void_pending_transfer transfer.id = uuid() } else { @@ -118,14 +118,14 @@ export async function createTransfers( case CreateTransferErrorCode.pending_transfer_not_pending: return { index, - error: commit - ? TransferError.AlreadyCommitted - : TransferError.AlreadyRolledBack + error: post + ? TransferError.AlreadyPosted + : TransferError.AlreadyVoided } case CreateTransferErrorCode.pending_transfer_already_posted: - return { index, error: TransferError.AlreadyCommitted } + return { index, error: TransferError.AlreadyPosted } case CreateTransferErrorCode.pending_transfer_already_voided: - return { index, error: TransferError.AlreadyRolledBack } + return { index, error: TransferError.AlreadyVoided } default: // TODO @jason: This needs to be removed: =========> switch (code) { @@ -134,14 +134,14 @@ export async function createTransfers( case 40: //pending_transfer_not_pending return { index, - error: commit - ? TransferError.AlreadyCommitted - : TransferError.AlreadyRolledBack + error: post + ? TransferError.AlreadyPosted + : TransferError.AlreadyVoided } case 47: //pending_transfer_already_posted, - return { index, error: TransferError.AlreadyCommitted } + return { index, error: TransferError.AlreadyPosted } case 48: //pending_transfer_already_voided, - return { index, error: TransferError.AlreadyRolledBack } + return { index, error: TransferError.AlreadyVoided } case 49: //pending_transfer_expired, return { index, error: TransferError.TransferExpired } } diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index f241ec2b39..824d27b26b 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -47,6 +47,8 @@ import { IlpPlugin, IlpPluginOptions } from './shared/ilp_plugin' import { createValidatorMiddleware, HttpMethod, isHttpMethod } from 'openapi' import { PaymentPointerKeyService } from './open_payments/payment_pointer/key/service' import { AccessAction, AccessType, AuthenticatedClient } from 'open-payments' +import { RemoteIncomingPaymentService } from './open_payments/payment/incoming_remote/service' +import { ReceiverService } from './open_payments/receiver/service' import { Client as TokenIntrospectionClient } from 'token-introspection' export interface AppContextData { @@ -160,6 +162,8 @@ export interface AppServices { paymentPointerKeyRoutes: Promise paymentPointerRoutes: Promise incomingPaymentService: Promise + remoteIncomingPaymentService: Promise + receiverService: Promise streamServer: Promise webhookService: Promise quoteService: Promise diff --git a/packages/backend/src/connector/core/middleware/balance.ts b/packages/backend/src/connector/core/middleware/balance.ts index b3f7ccc085..2e2ee6b2ac 100644 --- a/packages/backend/src/connector/core/middleware/balance.ts +++ b/packages/backend/src/connector/core/middleware/balance.ts @@ -84,9 +84,9 @@ export function createBalanceMiddleware(): ILPMiddleware { if (trx) { if (response.fulfill) { - await trx.commit() + await trx.post() } else { - await trx.rollback() + await trx.void() } } } diff --git a/packages/backend/src/connector/core/test/mocks/accounting-service.ts b/packages/backend/src/connector/core/test/mocks/accounting-service.ts index 7dfc55dacb..d05b33ccd1 100644 --- a/packages/backend/src/connector/core/test/mocks/accounting-service.ts +++ b/packages/backend/src/connector/core/test/mocks/accounting-service.ts @@ -107,10 +107,10 @@ export class MockAccountingService implements AccountingService { } sourceAccount.balance -= sourceAmount return { - commit: async () => { + post: async () => { destinationAccount.balance += destinationAmount }, - rollback: async () => { + void: async () => { sourceAccount.balance += sourceAmount } } diff --git a/packages/backend/src/graphql/generated/graphql.schema.json b/packages/backend/src/graphql/generated/graphql.schema.json index e5af82c5d0..c51ca61695 100644 --- a/packages/backend/src/graphql/generated/graphql.schema.json +++ b/packages/backend/src/graphql/generated/graphql.schema.json @@ -1415,6 +1415,148 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "CreateReceiverInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "description", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expiresAt", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalRef", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "incomingAmount", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "AmountInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentPointerUrl", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CreateReceiverResponse", + "description": null, + "fields": [ + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "message", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "receiver", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Receiver", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "success", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "ENUM", "name": "Crv", @@ -2267,13 +2409,13 @@ "interfaces": null, "enumValues": [ { - "name": "AlreadyCommitted", + "name": "AlreadyPosted", "description": null, "isDeprecated": false, "deprecationReason": null }, { - "name": "AlreadyRolledBack", + "name": "AlreadyVoided", "description": null, "isDeprecated": false, "deprecationReason": null @@ -2870,18 +3012,18 @@ "deprecationReason": null }, { - "name": "deletePeer", - "description": "Delete peer", + "name": "createReceiver", + "description": null, "args": [ { - "name": "id", + "name": "input", "description": null, "type": { "kind": "NON_NULL", "name": null, "ofType": { - "kind": "SCALAR", - "name": "String", + "kind": "INPUT_OBJECT", + "name": "CreateReceiverInput", "ofType": null } }, @@ -2895,7 +3037,7 @@ "name": null, "ofType": { "kind": "OBJECT", - "name": "DeletePeerMutationResponse", + "name": "CreateReceiverResponse", "ofType": null } }, @@ -2903,11 +3045,11 @@ "deprecationReason": null }, { - "name": "depositEventLiquidity", - "description": "Deposit webhook event liquidity", + "name": "deletePeer", + "description": "Delete peer", "args": [ { - "name": "eventId", + "name": "id", "description": null, "type": { "kind": "NON_NULL", @@ -2924,20 +3066,24 @@ } ], "type": { - "kind": "OBJECT", - "name": "LiquidityMutationResponse", - "ofType": null + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DeletePeerMutationResponse", + "ofType": null + } }, "isDeprecated": false, "deprecationReason": null }, { - "name": "finalizeLiquidityWithdrawal", - "description": "Finalize liquidity withdrawal", + "name": "depositEventLiquidity", + "description": "Deposit webhook event liquidity", "args": [ { - "name": "withdrawalId", - "description": "The id of the liquidity withdrawal to finalize.", + "name": "eventId", + "description": null, "type": { "kind": "NON_NULL", "name": null, @@ -2961,12 +3107,12 @@ "deprecationReason": null }, { - "name": "revokePaymentPointerKey", - "description": null, + "name": "postLiquidityWithdrawal", + "description": "Posts liquidity withdrawal", "args": [ { - "name": "id", - "description": null, + "name": "withdrawalId", + "description": "The id of the liquidity withdrawal to post.", "type": { "kind": "NON_NULL", "name": null, @@ -2983,19 +3129,19 @@ ], "type": { "kind": "OBJECT", - "name": "RevokePaymentPointerKeyMutationResponse", + "name": "LiquidityMutationResponse", "ofType": null }, "isDeprecated": false, "deprecationReason": null }, { - "name": "rollbackLiquidityWithdrawal", - "description": "Rollback liquidity withdrawal", + "name": "revokePaymentPointerKey", + "description": null, "args": [ { - "name": "withdrawalId", - "description": "The id of the liquidity withdrawal to rollback.", + "name": "id", + "description": null, "type": { "kind": "NON_NULL", "name": null, @@ -3012,7 +3158,7 @@ ], "type": { "kind": "OBJECT", - "name": "LiquidityMutationResponse", + "name": "RevokePaymentPointerKeyMutationResponse", "ofType": null }, "isDeprecated": false, @@ -3117,6 +3263,35 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "voidLiquidityWithdrawal", + "description": "Void liquidity withdrawal", + "args": [ + { + "name": "withdrawalId", + "description": "The id of the liquidity withdrawal to void.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "LiquidityMutationResponse", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "withdrawEventLiquidity", "description": "Withdraw webhook event liquidity", @@ -5109,6 +5284,161 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "Receiver", + "description": null, + "fields": [ + { + "name": "completed", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expiresAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalRef", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "incomingAmount", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Amount", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentPointerUrl", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "receivedAmount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Amount", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "RevokePaymentPointerKeyMutationResponse", diff --git a/packages/backend/src/graphql/generated/graphql.ts b/packages/backend/src/graphql/generated/graphql.ts index 2bf94d0d47..47e02d77af 100644 --- a/packages/backend/src/graphql/generated/graphql.ts +++ b/packages/backend/src/graphql/generated/graphql.ts @@ -180,6 +180,22 @@ export type CreateQuoteInput = { sendAmount?: InputMaybe; }; +export type CreateReceiverInput = { + description?: InputMaybe; + expiresAt?: InputMaybe; + externalRef?: InputMaybe; + incomingAmount?: InputMaybe; + paymentPointerUrl: Scalars['String']; +}; + +export type CreateReceiverResponse = { + __typename?: 'CreateReceiverResponse'; + code: Scalars['String']; + message?: Maybe; + receiver?: Maybe; + success: Scalars['Boolean']; +}; + export enum Crv { Ed25519 = 'Ed25519' } @@ -282,8 +298,8 @@ export enum Kty { } export enum LiquidityError { - AlreadyCommitted = 'AlreadyCommitted', - AlreadyRolledBack = 'AlreadyRolledBack', + AlreadyPosted = 'AlreadyPosted', + AlreadyVoided = 'AlreadyVoided', AmountZero = 'AmountZero', InsufficientBalance = 'InsufficientBalance', InvalidId = 'InvalidId', @@ -331,20 +347,21 @@ export type Mutation = { /** Create liquidity withdrawal from peer */ createPeerLiquidityWithdrawal?: Maybe; createQuote: QuoteResponse; + createReceiver: CreateReceiverResponse; /** Delete peer */ deletePeer: DeletePeerMutationResponse; /** Deposit webhook event liquidity */ depositEventLiquidity?: Maybe; - /** Finalize liquidity withdrawal */ - finalizeLiquidityWithdrawal?: Maybe; + /** Posts liquidity withdrawal */ + postLiquidityWithdrawal?: Maybe; revokePaymentPointerKey?: Maybe; - /** Rollback liquidity withdrawal */ - rollbackLiquidityWithdrawal?: Maybe; triggerPaymentPointerEvents: TriggerPaymentPointerEventsMutationResponse; /** Update asset withdrawal threshold */ updateAssetWithdrawalThreshold: AssetMutationResponse; /** Update peer */ updatePeer: UpdatePeerMutationResponse; + /** Void liquidity withdrawal */ + voidLiquidityWithdrawal?: Maybe; /** Withdraw webhook event liquidity */ withdrawEventLiquidity?: Maybe; }; @@ -410,6 +427,11 @@ export type MutationCreateQuoteArgs = { }; +export type MutationCreateReceiverArgs = { + input: CreateReceiverInput; +}; + + export type MutationDeletePeerArgs = { id: Scalars['String']; }; @@ -420,7 +442,7 @@ export type MutationDepositEventLiquidityArgs = { }; -export type MutationFinalizeLiquidityWithdrawalArgs = { +export type MutationPostLiquidityWithdrawalArgs = { withdrawalId: Scalars['String']; }; @@ -430,11 +452,6 @@ export type MutationRevokePaymentPointerKeyArgs = { }; -export type MutationRollbackLiquidityWithdrawalArgs = { - withdrawalId: Scalars['String']; -}; - - export type MutationTriggerPaymentPointerEventsArgs = { limit: Scalars['Int']; }; @@ -450,6 +467,11 @@ export type MutationUpdatePeerArgs = { }; +export type MutationVoidLiquidityWithdrawalArgs = { + withdrawalId: Scalars['String']; +}; + + export type MutationWithdrawEventLiquidityArgs = { eventId: Scalars['String']; }; @@ -693,6 +715,20 @@ export type QuoteResponse = { success: Scalars['Boolean']; }; +export type Receiver = { + __typename?: 'Receiver'; + completed: Scalars['Boolean']; + createdAt: Scalars['String']; + description?: Maybe; + expiresAt?: Maybe; + externalRef?: Maybe; + id: Scalars['String']; + incomingAmount?: Maybe; + paymentPointerUrl: Scalars['String']; + receivedAmount: Amount; + updatedAt: Scalars['String']; +}; + export type RevokePaymentPointerKeyMutationResponse = MutationResponse & { __typename?: 'RevokePaymentPointerKeyMutationResponse'; code: Scalars['String']; @@ -830,6 +866,8 @@ export type ResolversTypes = { CreatePeerLiquidityWithdrawalInput: ResolverTypeWrapper>; CreatePeerMutationResponse: ResolverTypeWrapper>; CreateQuoteInput: ResolverTypeWrapper>; + CreateReceiverInput: ResolverTypeWrapper>; + CreateReceiverResponse: ResolverTypeWrapper>; Crv: ResolverTypeWrapper>; DeletePeerMutationResponse: ResolverTypeWrapper>; Float: ResolverTypeWrapper>; @@ -871,6 +909,7 @@ export type ResolversTypes = { QuoteConnection: ResolverTypeWrapper>; QuoteEdge: ResolverTypeWrapper>; QuoteResponse: ResolverTypeWrapper>; + Receiver: ResolverTypeWrapper>; RevokePaymentPointerKeyMutationResponse: ResolverTypeWrapper>; String: ResolverTypeWrapper>; TransferMutationResponse: ResolverTypeWrapper>; @@ -906,6 +945,8 @@ export type ResolversParentTypes = { CreatePeerLiquidityWithdrawalInput: Partial; CreatePeerMutationResponse: Partial; CreateQuoteInput: Partial; + CreateReceiverInput: Partial; + CreateReceiverResponse: Partial; DeletePeerMutationResponse: Partial; Float: Partial; Http: Partial; @@ -942,6 +983,7 @@ export type ResolversParentTypes = { QuoteConnection: Partial; QuoteEdge: Partial; QuoteResponse: Partial; + Receiver: Partial; RevokePaymentPointerKeyMutationResponse: Partial; String: Partial; TransferMutationResponse: Partial; @@ -1012,6 +1054,14 @@ export type CreatePeerMutationResponseResolvers; }; +export type CreateReceiverResponseResolvers = { + code?: Resolver; + message?: Resolver, ParentType, ContextType>; + receiver?: Resolver, ParentType, ContextType>; + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type DeletePeerMutationResponseResolvers = { code?: Resolver; message?: Resolver; @@ -1099,14 +1149,15 @@ export type MutationResolvers>; createPeerLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; createQuote?: Resolver>; + createReceiver?: Resolver>; deletePeer?: Resolver>; depositEventLiquidity?: Resolver, ParentType, ContextType, RequireFields>; - finalizeLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; + postLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; revokePaymentPointerKey?: Resolver, ParentType, ContextType, RequireFields>; - rollbackLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; triggerPaymentPointerEvents?: Resolver>; updateAssetWithdrawalThreshold?: Resolver>; updatePeer?: Resolver>; + voidLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; withdrawEventLiquidity?: Resolver, ParentType, ContextType, RequireFields>; }; @@ -1267,6 +1318,20 @@ export type QuoteResponseResolvers; }; +export type ReceiverResolvers = { + completed?: Resolver; + createdAt?: Resolver; + description?: Resolver, ParentType, ContextType>; + expiresAt?: Resolver, ParentType, ContextType>; + externalRef?: Resolver, ParentType, ContextType>; + id?: Resolver; + incomingAmount?: Resolver, ParentType, ContextType>; + paymentPointerUrl?: Resolver; + receivedAmount?: Resolver; + updatedAt?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type RevokePaymentPointerKeyMutationResponseResolvers = { code?: Resolver; message?: Resolver; @@ -1311,6 +1376,7 @@ export type Resolvers = { CreatePaymentPointerKeyMutationResponse?: CreatePaymentPointerKeyMutationResponseResolvers; CreatePaymentPointerMutationResponse?: CreatePaymentPointerMutationResponseResolvers; CreatePeerMutationResponse?: CreatePeerMutationResponseResolvers; + CreateReceiverResponse?: CreateReceiverResponseResolvers; DeletePeerMutationResponse?: DeletePeerMutationResponseResolvers; Http?: HttpResolvers; HttpOutgoing?: HttpOutgoingResolvers; @@ -1340,6 +1406,7 @@ export type Resolvers = { QuoteConnection?: QuoteConnectionResolvers; QuoteEdge?: QuoteEdgeResolvers; QuoteResponse?: QuoteResponseResolvers; + Receiver?: ReceiverResolvers; RevokePaymentPointerKeyMutationResponse?: RevokePaymentPointerKeyMutationResponseResolvers; TransferMutationResponse?: TransferMutationResponseResolvers; TriggerPaymentPointerEventsMutationResponse?: TriggerPaymentPointerEventsMutationResponseResolvers; diff --git a/packages/backend/src/graphql/resolvers/index.ts b/packages/backend/src/graphql/resolvers/index.ts index 697c395616..8cd6e6d7b7 100644 --- a/packages/backend/src/graphql/resolvers/index.ts +++ b/packages/backend/src/graphql/resolvers/index.ts @@ -27,8 +27,8 @@ import { createAssetLiquidityWithdrawal, createPeerLiquidityWithdrawal, createPaymentPointerWithdrawal, - finalizeLiquidityWithdrawal, - rollbackLiquidityWithdrawal, + postLiquidityWithdrawal, + voidLiquidityWithdrawal, depositEventLiquidity, withdrawEventLiquidity } from './liquidity' @@ -37,6 +37,7 @@ import { createPaymentPointerKey, revokePaymentPointerKey } from './paymentPointerKey' +import { createReceiver } from './receiver' export const resolvers: Resolvers = { UInt64: GraphQLBigInt, @@ -64,6 +65,7 @@ export const resolvers: Resolvers = { createQuote, createOutgoingPayment, createIncomingPayment, + createReceiver, createPeer: createPeer, updatePeer: updatePeer, deletePeer: deletePeer, @@ -72,8 +74,8 @@ export const resolvers: Resolvers = { createAssetLiquidityWithdrawal: createAssetLiquidityWithdrawal, createPeerLiquidityWithdrawal: createPeerLiquidityWithdrawal, createPaymentPointerWithdrawal, - finalizeLiquidityWithdrawal: finalizeLiquidityWithdrawal, - rollbackLiquidityWithdrawal: rollbackLiquidityWithdrawal, + postLiquidityWithdrawal: postLiquidityWithdrawal, + voidLiquidityWithdrawal: voidLiquidityWithdrawal, depositEventLiquidity, withdrawEventLiquidity } diff --git a/packages/backend/src/graphql/resolvers/liquidity.test.ts b/packages/backend/src/graphql/resolvers/liquidity.test.ts index 08a09c950c..a044522b16 100644 --- a/packages/backend/src/graphql/resolvers/liquidity.test.ts +++ b/packages/backend/src/graphql/resolvers/liquidity.test.ts @@ -1115,7 +1115,7 @@ describe('Liquidity Resolvers', (): void => { }) describe.each(['peer', 'asset'])( - 'Finalize %s liquidity withdrawal', + 'Post %s liquidity withdrawal', (type): void => { let withdrawalId: string @@ -1140,12 +1140,12 @@ describe('Liquidity Resolvers', (): void => { ).resolves.toBeUndefined() }) - test(`Can finalize a(n) ${type} liquidity withdrawal`, async (): Promise => { + test(`Can post a(n) ${type} liquidity withdrawal`, async (): Promise => { const response = await appContainer.apolloClient .mutate({ mutation: gql` - mutation FinalizeLiquidityWithdrawal($withdrawalId: String!) { - finalizeLiquidityWithdrawal(withdrawalId: $withdrawalId) { + mutation PostLiquidityWithdrawal($withdrawalId: String!) { + postLiquidityWithdrawal(withdrawalId: $withdrawalId) { code success message @@ -1159,7 +1159,7 @@ describe('Liquidity Resolvers', (): void => { }) .then((query): LiquidityMutationResponse => { if (query.data) { - return query.data.finalizeLiquidityWithdrawal + return query.data.postLiquidityWithdrawal } else { throw new Error('Data was empty') } @@ -1170,12 +1170,12 @@ describe('Liquidity Resolvers', (): void => { expect(response.error).toBeNull() }) - test("Can't finalize non-existent withdrawal", async (): Promise => { + test("Can't post non-existent withdrawal", async (): Promise => { const response = await appContainer.apolloClient .mutate({ mutation: gql` - mutation FinalizeLiquidityWithdrawal($withdrawalId: String!) { - finalizeLiquidityWithdrawal(withdrawalId: $withdrawalId) { + mutation PostLiquidityWithdrawal($withdrawalId: String!) { + postLiquidityWithdrawal(withdrawalId: $withdrawalId) { code success message @@ -1189,7 +1189,7 @@ describe('Liquidity Resolvers', (): void => { }) .then((query): LiquidityMutationResponse => { if (query.data) { - return query.data.finalizeLiquidityWithdrawal + return query.data.postLiquidityWithdrawal } else { throw new Error('Data was empty') } @@ -1201,12 +1201,12 @@ describe('Liquidity Resolvers', (): void => { expect(response.error).toEqual(LiquidityError.UnknownTransfer) }) - test("Can't finalize invalid withdrawal id", async (): Promise => { + test("Can't post invalid withdrawal id", async (): Promise => { const response = await appContainer.apolloClient .mutate({ mutation: gql` - mutation FinalizeLiquidityWithdrawal($withdrawalId: String!) { - finalizeLiquidityWithdrawal(withdrawalId: $withdrawalId) { + mutation PostLiquidityWithdrawal($withdrawalId: String!) { + postLiquidityWithdrawal(withdrawalId: $withdrawalId) { code success message @@ -1220,7 +1220,7 @@ describe('Liquidity Resolvers', (): void => { }) .then((query): LiquidityMutationResponse => { if (query.data) { - return query.data.finalizeLiquidityWithdrawal + return query.data.postLiquidityWithdrawal } else { throw new Error('Data was empty') } @@ -1232,15 +1232,15 @@ describe('Liquidity Resolvers', (): void => { expect(response.error).toEqual(LiquidityError.InvalidId) }) - test("Can't finalize finalized withdrawal", async (): Promise => { + test("Can't post posted withdrawal", async (): Promise => { await expect( - accountingService.commitWithdrawal(withdrawalId) + accountingService.postWithdrawal(withdrawalId) ).resolves.toBeUndefined() const response = await appContainer.apolloClient .mutate({ mutation: gql` - mutation FinalizeLiquidityWithdrawal($withdrawalId: String!) { - finalizeLiquidityWithdrawal(withdrawalId: $withdrawalId) { + mutation postLiquidityWithdrawal($withdrawalId: String!) { + postLiquidityWithdrawal(withdrawalId: $withdrawalId) { code success message @@ -1254,7 +1254,7 @@ describe('Liquidity Resolvers', (): void => { }) .then((query): LiquidityMutationResponse => { if (query.data) { - return query.data.finalizeLiquidityWithdrawal + return query.data.postLiquidityWithdrawal } else { throw new Error('Data was empty') } @@ -1262,19 +1262,19 @@ describe('Liquidity Resolvers', (): void => { expect(response.success).toBe(false) expect(response.code).toEqual('409') - expect(response.message).toEqual('Withdrawal already finalized') - expect(response.error).toEqual(LiquidityError.AlreadyCommitted) + expect(response.message).toEqual('Withdrawal already posted') + expect(response.error).toEqual(LiquidityError.AlreadyPosted) }) - test("Can't finalize rolled back withdrawal", async (): Promise => { + test("Can't post voided withdrawal", async (): Promise => { await expect( - accountingService.rollbackWithdrawal(withdrawalId) + accountingService.voidWithdrawal(withdrawalId) ).resolves.toBeUndefined() const response = await appContainer.apolloClient .mutate({ mutation: gql` - mutation FinalizeLiquidityWithdrawal($withdrawalId: String!) { - finalizeLiquidityWithdrawal(withdrawalId: $withdrawalId) { + mutation postLiquidityWithdrawal($withdrawalId: String!) { + postLiquidityWithdrawal(withdrawalId: $withdrawalId) { code success message @@ -1288,7 +1288,7 @@ describe('Liquidity Resolvers', (): void => { }) .then((query): LiquidityMutationResponse => { if (query.data) { - return query.data.finalizeLiquidityWithdrawal + return query.data.postLiquidityWithdrawal } else { throw new Error('Data was empty') } @@ -1296,8 +1296,8 @@ describe('Liquidity Resolvers', (): void => { expect(response.success).toBe(false) expect(response.code).toEqual('409') - expect(response.message).toEqual('Withdrawal already rolled back') - expect(response.error).toEqual(LiquidityError.AlreadyRolledBack) + expect(response.message).toEqual('Withdrawal already voided') + expect(response.error).toEqual(LiquidityError.AlreadyVoided) }) } ) @@ -1328,12 +1328,12 @@ describe('Liquidity Resolvers', (): void => { ).resolves.toBeUndefined() }) - test(`Can rollback a(n) ${type} liquidity withdrawal`, async (): Promise => { + test(`Can void a(n) ${type} liquidity withdrawal`, async (): Promise => { const response = await appContainer.apolloClient .mutate({ mutation: gql` - mutation RollbackLiquidityWithdrawal($withdrawalId: String!) { - rollbackLiquidityWithdrawal(withdrawalId: $withdrawalId) { + mutation VoidLiquidityWithdrawal($withdrawalId: String!) { + voidLiquidityWithdrawal(withdrawalId: $withdrawalId) { code success message @@ -1347,7 +1347,7 @@ describe('Liquidity Resolvers', (): void => { }) .then((query): LiquidityMutationResponse => { if (query.data) { - return query.data.rollbackLiquidityWithdrawal + return query.data.voidLiquidityWithdrawal } else { throw new Error('Data was empty') } @@ -1358,12 +1358,12 @@ describe('Liquidity Resolvers', (): void => { expect(response.error).toBeNull() }) - test("Can't rollback non-existent withdrawal", async (): Promise => { + test("Can't void non-existent withdrawal", async (): Promise => { const response = await appContainer.apolloClient .mutate({ mutation: gql` - mutation RollbackLiquidityWithdrawal($withdrawalId: String!) { - rollbackLiquidityWithdrawal(withdrawalId: $withdrawalId) { + mutation VoidLiquidityWithdrawal($withdrawalId: String!) { + voidLiquidityWithdrawal(withdrawalId: $withdrawalId) { code success message @@ -1377,7 +1377,7 @@ describe('Liquidity Resolvers', (): void => { }) .then((query): LiquidityMutationResponse => { if (query.data) { - return query.data.rollbackLiquidityWithdrawal + return query.data.voidLiquidityWithdrawal } else { throw new Error('Data was empty') } @@ -1389,12 +1389,12 @@ describe('Liquidity Resolvers', (): void => { expect(response.error).toEqual(LiquidityError.UnknownTransfer) }) - test("Can't rollback invalid withdrawal id", async (): Promise => { + test("Can't void invalid withdrawal id", async (): Promise => { const response = await appContainer.apolloClient .mutate({ mutation: gql` - mutation RollbackLiquidityWithdrawal($withdrawalId: String!) { - rollbackLiquidityWithdrawal(withdrawalId: $withdrawalId) { + mutation VoidLiquidityWithdrawal($withdrawalId: String!) { + voidLiquidityWithdrawal(withdrawalId: $withdrawalId) { code success message @@ -1408,7 +1408,7 @@ describe('Liquidity Resolvers', (): void => { }) .then((query): LiquidityMutationResponse => { if (query.data) { - return query.data.rollbackLiquidityWithdrawal + return query.data.voidLiquidityWithdrawal } else { throw new Error('Data was empty') } @@ -1420,15 +1420,15 @@ describe('Liquidity Resolvers', (): void => { expect(response.error).toEqual(LiquidityError.InvalidId) }) - test("Can't rollback finalized withdrawal", async (): Promise => { + test("Can't void posted withdrawal", async (): Promise => { await expect( - accountingService.commitWithdrawal(withdrawalId) + accountingService.postWithdrawal(withdrawalId) ).resolves.toBeUndefined() const response = await appContainer.apolloClient .mutate({ mutation: gql` - mutation RollbackLiquidityWithdrawal($withdrawalId: String!) { - rollbackLiquidityWithdrawal(withdrawalId: $withdrawalId) { + mutation VoidLiquidityWithdrawal($withdrawalId: String!) { + voidLiquidityWithdrawal(withdrawalId: $withdrawalId) { code success message @@ -1442,7 +1442,7 @@ describe('Liquidity Resolvers', (): void => { }) .then((query): LiquidityMutationResponse => { if (query.data) { - return query.data.rollbackLiquidityWithdrawal + return query.data.voidLiquidityWithdrawal } else { throw new Error('Data was empty') } @@ -1450,19 +1450,19 @@ describe('Liquidity Resolvers', (): void => { expect(response.success).toBe(false) expect(response.code).toEqual('409') - expect(response.message).toEqual('Withdrawal already finalized') - expect(response.error).toEqual(LiquidityError.AlreadyCommitted) + expect(response.message).toEqual('Withdrawal already posted') + expect(response.error).toEqual(LiquidityError.AlreadyPosted) }) - test("Can't rollback rolled back withdrawal", async (): Promise => { + test("Can't void voided withdrawal", async (): Promise => { await expect( - accountingService.rollbackWithdrawal(withdrawalId) + accountingService.voidWithdrawal(withdrawalId) ).resolves.toBeUndefined() const response = await appContainer.apolloClient .mutate({ mutation: gql` - mutation RollbackLiquidityWithdrawal($withdrawalId: String!) { - rollbackLiquidityWithdrawal(withdrawalId: $withdrawalId) { + mutation voidLiquidityWithdrawal($withdrawalId: String!) { + voidLiquidityWithdrawal(withdrawalId: $withdrawalId) { code success message @@ -1476,7 +1476,7 @@ describe('Liquidity Resolvers', (): void => { }) .then((query): LiquidityMutationResponse => { if (query.data) { - return query.data.rollbackLiquidityWithdrawal + return query.data.voidLiquidityWithdrawal } else { throw new Error('Data was empty') } @@ -1484,8 +1484,8 @@ describe('Liquidity Resolvers', (): void => { expect(response.success).toBe(false) expect(response.code).toEqual('409') - expect(response.message).toEqual('Withdrawal already rolled back') - expect(response.error).toEqual(LiquidityError.AlreadyRolledBack) + expect(response.message).toEqual('Withdrawal already voided') + expect(response.error).toEqual(LiquidityError.AlreadyVoided) }) } ) diff --git a/packages/backend/src/graphql/resolvers/liquidity.ts b/packages/backend/src/graphql/resolvers/liquidity.ts index 07dc80047f..155cc7218e 100644 --- a/packages/backend/src/graphql/resolvers/liquidity.ts +++ b/packages/backend/src/graphql/resolvers/liquidity.ts @@ -264,39 +264,39 @@ export const createPaymentPointerWithdrawal: MutationResolvers['c } } -export const finalizeLiquidityWithdrawal: MutationResolvers['finalizeLiquidityWithdrawal'] = +export const postLiquidityWithdrawal: MutationResolvers['postLiquidityWithdrawal'] = async ( parent, args, ctx ): Promise => { const accountingService = await ctx.container.use('accountingService') - const error = await accountingService.commitWithdrawal(args.withdrawalId) + const error = await accountingService.postWithdrawal(args.withdrawalId) if (error) { return errorToResponse(error) } return { code: '200', success: true, - message: 'Finalized Withdrawal' + message: 'Posted Withdrawal' } } -export const rollbackLiquidityWithdrawal: MutationResolvers['rollbackLiquidityWithdrawal'] = +export const voidLiquidityWithdrawal: MutationResolvers['voidLiquidityWithdrawal'] = async ( parent, args, ctx ): Promise => { const accountingService = await ctx.container.use('accountingService') - const error = await accountingService.rollbackWithdrawal(args.withdrawalId) + const error = await accountingService.voidWithdrawal(args.withdrawalId) if (error) { return errorToResponse(error) } return { code: '200', success: true, - message: 'Rolled Back Withdrawal' + message: 'Voided Withdrawal' } } @@ -419,17 +419,17 @@ const errorToResponse = (error: FundingError): LiquidityMutationResponse => { const responses: { [key in LiquidityError]: LiquidityMutationResponse } = { - [LiquidityError.AlreadyCommitted]: { + [LiquidityError.AlreadyPosted]: { code: '409', - message: 'Withdrawal already finalized', + message: 'Withdrawal already posted', success: false, - error: LiquidityError.AlreadyCommitted + error: LiquidityError.AlreadyPosted }, - [LiquidityError.AlreadyRolledBack]: { + [LiquidityError.AlreadyVoided]: { code: '409', - message: 'Withdrawal already rolled back', + message: 'Withdrawal already voided', success: false, - error: LiquidityError.AlreadyRolledBack + error: LiquidityError.AlreadyVoided }, [LiquidityError.AmountZero]: { code: '400', diff --git a/packages/backend/src/graphql/resolvers/receiver.test.ts b/packages/backend/src/graphql/resolvers/receiver.test.ts new file mode 100644 index 0000000000..86355f2486 --- /dev/null +++ b/packages/backend/src/graphql/resolvers/receiver.test.ts @@ -0,0 +1,254 @@ +import { gql } from 'apollo-server-koa' +import { v4 as uuid } from 'uuid' +import { createTestApp, TestContainer } from '../../tests/app' +import { IocContract } from '@adonisjs/fold' +import { AppServices } from '../../app' +import { initIocContainer } from '../..' +import { Config } from '../../config/app' +import { Amount, serializeAmount } from '../../open_payments/amount' +import { mockIncomingPayment, mockPaymentPointer } from 'open-payments' +import { CreateReceiverResponse } from '../generated/graphql' +import { ReceiverService } from '../../open_payments/receiver/service' +import { Receiver } from '../../open_payments/receiver/model' +import { ReceiverError } from '../../open_payments/receiver/errors' + +describe('Receiver Resolver', (): void => { + let deps: IocContract + let appContainer: TestContainer + let receiverService: ReceiverService + + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + receiverService = await deps.use('receiverService') + }) + + afterAll(async (): Promise => { + appContainer.apolloClient.stop() + await appContainer.shutdown() + }) + + describe('Mutation.createReceiver', (): void => { + const amount: Amount = { + value: BigInt(123), + assetCode: 'USD', + assetScale: 2 + } + const paymentPointer = mockPaymentPointer() + + test.each` + incomingAmount | expiresAt | description | externalRef + ${undefined} | ${undefined} | ${undefined} | ${undefined} + ${amount} | ${new Date(Date.now() + 30_000)} | ${'Test incoming payment'} | ${'#123'} + `( + 'creates receiver ($#)', + async ({ + description, + externalRef, + expiresAt, + incomingAmount + }): Promise => { + const receiver = Receiver.fromIncomingPayment( + mockIncomingPayment({ + id: `${paymentPointer.id}/incoming-payments/${uuid()}`, + paymentPointer: paymentPointer.id, + description, + externalRef, + expiresAt: expiresAt?.toISOString(), + incomingAmount: incomingAmount + ? serializeAmount(incomingAmount) + : undefined + }) + ) + + const createSpy = jest + .spyOn(receiverService, 'create') + .mockResolvedValueOnce(receiver) + + const input = { + paymentPointerUrl: paymentPointer.id, + incomingAmount, + expiresAt, + description, + externalRef + } + + const query = await appContainer.apolloClient + .query({ + query: gql` + mutation CreateReceiver($input: CreateReceiverInput!) { + createReceiver(input: $input) { + code + success + message + receiver { + id + paymentPointerUrl + completed + expiresAt + incomingAmount { + value + assetCode + assetScale + } + receivedAmount { + value + assetCode + assetScale + } + description + externalRef + createdAt + updatedAt + } + } + } + `, + variables: { input } + }) + .then((query): CreateReceiverResponse => query.data?.createReceiver) + + expect(createSpy).toHaveBeenCalledWith(input) + expect(query).toEqual({ + __typename: 'CreateReceiverResponse', + code: '200', + success: true, + message: null, + receiver: { + __typename: 'Receiver', + id: receiver.incomingPayment?.id, + paymentPointerUrl: receiver.incomingPayment?.paymentPointer, + completed: false, + expiresAt: + receiver.incomingPayment?.expiresAt?.toISOString() || null, + incomingAmount: + receiver.incomingPayment?.incomingAmount === undefined + ? null + : { + __typename: 'Amount', + ...serializeAmount(receiver.incomingPayment?.incomingAmount) + }, + receivedAmount: receiver.incomingPayment && { + __typename: 'Amount', + ...serializeAmount(receiver.incomingPayment.receivedAmount) + }, + description: receiver.incomingPayment?.description || null, + externalRef: receiver.incomingPayment?.externalRef || null, + createdAt: receiver.incomingPayment?.createdAt.toISOString(), + updatedAt: receiver.incomingPayment?.updatedAt.toISOString() + } + }) + } + ) + + test('returns error if error returned when creating receiver', async (): Promise => { + const createSpy = jest + .spyOn(receiverService, 'create') + .mockResolvedValueOnce(ReceiverError.UnknownPaymentPointer) + + const input = { + paymentPointerUrl: paymentPointer.id + } + + const query = await appContainer.apolloClient + .query({ + query: gql` + mutation CreateReceiver($input: CreateReceiverInput!) { + createReceiver(input: $input) { + code + success + message + receiver { + id + paymentPointerUrl + completed + expiresAt + incomingAmount { + value + assetCode + assetScale + } + receivedAmount { + value + assetCode + assetScale + } + description + externalRef + createdAt + updatedAt + } + } + } + `, + variables: { input } + }) + .then((query): CreateReceiverResponse => query.data?.createReceiver) + + expect(createSpy).toHaveBeenCalledWith(input) + expect(query).toEqual({ + __typename: 'CreateReceiverResponse', + code: '404', + success: false, + message: 'unknown payment pointer', + receiver: null + }) + }) + + test('returns error if error thrown when creating receiver', async (): Promise => { + const createSpy = jest + .spyOn(receiverService, 'create') + .mockImplementationOnce((_) => { + throw new Error('Cannot create receiver') + }) + + const input = { + paymentPointerUrl: paymentPointer.id + } + + const query = await appContainer.apolloClient + .query({ + query: gql` + mutation CreateReceiver($input: CreateReceiverInput!) { + createReceiver(input: $input) { + code + success + message + receiver { + id + paymentPointerUrl + completed + expiresAt + incomingAmount { + value + assetCode + assetScale + } + receivedAmount { + value + assetCode + assetScale + } + description + externalRef + createdAt + updatedAt + } + } + } + `, + variables: { input } + }) + .then((query): CreateReceiverResponse => query.data?.createReceiver) + + expect(createSpy).toHaveBeenCalledWith(input) + expect(query).toEqual({ + __typename: 'CreateReceiverResponse', + code: '500', + success: false, + message: 'Error trying to create receiver', + receiver: null + }) + }) + }) +}) diff --git a/packages/backend/src/graphql/resolvers/receiver.ts b/packages/backend/src/graphql/resolvers/receiver.ts new file mode 100644 index 0000000000..6ff086875b --- /dev/null +++ b/packages/backend/src/graphql/resolvers/receiver.ts @@ -0,0 +1,71 @@ +import { + ResolversTypes, + MutationResolvers, + Receiver as SchemaReceiver +} from '../generated/graphql' +import { ApolloContext } from '../../app' +import { Receiver } from '../../open_payments/receiver/model' +import { + isReceiverError, + errorToCode as receiverErrorToCode, + errorToMessage as receiverErrorToMessage +} from '../../open_payments/receiver/errors' + +export const createReceiver: MutationResolvers['createReceiver'] = + async (_, args, ctx): Promise => { + const receiverService = await ctx.container.use('receiverService') + + try { + const receiverOrError = await receiverService.create({ + paymentPointerUrl: args.input.paymentPointerUrl, + expiresAt: args.input.expiresAt + ? new Date(args.input.expiresAt) + : undefined, + description: args.input.description, + incomingAmount: args.input.incomingAmount, + externalRef: args.input.externalRef + }) + + if (isReceiverError(receiverOrError)) { + return { + code: receiverErrorToCode(receiverOrError).toString(), + success: false, + message: receiverErrorToMessage(receiverOrError) + } + } + + return { + code: '200', + success: true, + receiver: receiverToGraphql(receiverOrError) + } + } catch (error) { + const errorMessage = 'Error trying to create receiver' + ctx.logger.error({ error, args }, errorMessage) + + return { + code: '500', + success: false, + message: errorMessage + } + } + } + +export function receiverToGraphql(receiver: Receiver): SchemaReceiver { + if (!receiver.incomingPayment) { + throw new Error('Missing incoming payment for receiver') + } + + return { + id: receiver.incomingPayment.id, + paymentPointerUrl: receiver.incomingPayment.paymentPointer, + expiresAt: receiver.incomingPayment.expiresAt?.toISOString(), + description: receiver.incomingPayment.description, + incomingAmount: receiver.incomingPayment.incomingAmount, + receivedAmount: receiver.incomingPayment.receivedAmount, + externalRef: receiver.incomingPayment.externalRef, + completed: receiver.incomingPayment.completed, + createdAt: receiver.incomingPayment.createdAt.toISOString(), + updatedAt: receiver.incomingPayment.updatedAt.toISOString() + } +} diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index 086ad9321a..f3d80aa553 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -80,15 +80,15 @@ type Mutation { input: CreatePaymentPointerWithdrawalInput! ): PaymentPointerWithdrawalMutationResponse - "Finalize liquidity withdrawal" - finalizeLiquidityWithdrawal( - "The id of the liquidity withdrawal to finalize." + "Posts liquidity withdrawal" + postLiquidityWithdrawal( + "The id of the liquidity withdrawal to post." withdrawalId: String! ): LiquidityMutationResponse - "Rollback liquidity withdrawal" - rollbackLiquidityWithdrawal( - "The id of the liquidity withdrawal to rollback." + "Void liquidity withdrawal" + voidLiquidityWithdrawal( + "The id of the liquidity withdrawal to void." withdrawalId: String! ): LiquidityMutationResponse @@ -102,6 +102,8 @@ type Mutation { input: CreateIncomingPaymentInput! ): IncomingPaymentResponse! + createReceiver(input: CreateReceiverInput!): CreateReceiverResponse! + "Deposit webhook event liquidity" depositEventLiquidity(eventId: String!): LiquidityMutationResponse @@ -258,8 +260,8 @@ type Asset implements Model { } enum LiquidityError { - AlreadyCommitted - AlreadyRolledBack + AlreadyPosted + AlreadyVoided AmountZero InsufficientBalance InvalidId @@ -351,6 +353,19 @@ type IncomingPayment implements Model { createdAt: String! } +type Receiver { + id: String! + paymentPointerUrl: String! + completed: Boolean! + incomingAmount: Amount + receivedAmount: Amount! + expiresAt: String + description: String + externalRef: String + createdAt: String! + updatedAt: String! +} + enum IncomingPaymentState { "The payment has a state of PENDING when it is initially created." PENDING @@ -464,6 +479,14 @@ input CreateIncomingPaymentInput { externalRef: String } +input CreateReceiverInput { + paymentPointerUrl: String! + expiresAt: String + description: String + incomingAmount: AmountInput + externalRef: String +} + type OutgoingPaymentResponse { code: String! success: Boolean! @@ -478,6 +501,13 @@ type IncomingPaymentResponse { payment: IncomingPayment } +type CreateReceiverResponse { + code: String! + success: Boolean! + message: String + receiver: Receiver +} + input CreatePaymentPointerInput { assetId: String! url: String! diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index b114935a89..bb01d6988e 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -42,6 +42,7 @@ import { createConnectionService } from './open_payments/connection/service' import { createConnectionRoutes } from './open_payments/connection/routes' import { createPaymentPointerKeyService } from './open_payments/payment_pointer/key/service' import { createReceiverService } from './open_payments/receiver/service' +import { createRemoteIncomingPaymentService } from './open_payments/payment/incoming_remote/service' BigInt.prototype.toJSON = function () { return this.toString() @@ -218,6 +219,15 @@ export function initIocContainer( paymentPointerService: await deps.use('paymentPointerService') }) }) + container.singleton('remoteIncomingPaymentService', async (deps) => { + return await createRemoteIncomingPaymentService({ + logger: await deps.use('logger'), + knex: await deps.use('knex'), + grantService: await deps.use('grantService'), + openPaymentsUrl: config.openPaymentsUrl, + openPaymentsClient: await deps.use('openPaymentsClient') + }) + }) container.singleton('incomingPaymentRoutes', async (deps) => { return createIncomingPaymentRoutes({ config: await deps.use('config'), @@ -263,7 +273,10 @@ export function initIocContainer( incomingPaymentService: await deps.use('incomingPaymentService'), openPaymentsUrl: config.openPaymentsUrl, paymentPointerService: await deps.use('paymentPointerService'), - openPaymentsClient: await deps.use('openPaymentsClient') + openPaymentsClient: await deps.use('openPaymentsClient'), + remoteIncomingPaymentService: await deps.use( + 'remoteIncomingPaymentService' + ) }) }) diff --git a/packages/backend/src/open_payments/payment/incoming/model.ts b/packages/backend/src/open_payments/payment/incoming/model.ts index 73c6ace03c..b75f4c5c04 100644 --- a/packages/backend/src/open_payments/payment/incoming/model.ts +++ b/packages/backend/src/open_payments/payment/incoming/model.ts @@ -245,7 +245,7 @@ export class IncomingPayment ilpStreamConnection: Connection }): OpenPaymentsIncomingPayment { return { - id: this.id, + id: this.url, paymentPointer: this.paymentPointer.url, incomingAmount: this.incomingAmount ? serializeAmount(this.incomingAmount) diff --git a/packages/backend/src/open_payments/payment/incoming_remote/errors.ts b/packages/backend/src/open_payments/payment/incoming_remote/errors.ts new file mode 100644 index 0000000000..235e4f332f --- /dev/null +++ b/packages/backend/src/open_payments/payment/incoming_remote/errors.ts @@ -0,0 +1,33 @@ +export enum RemoteIncomingPaymentError { + UnknownPaymentPointer = 'UnknownPaymentPointer', + InvalidRequest = 'InvalidRequest', + InvalidGrant = 'InvalidGrant', + ExpiredGrant = 'ExpiredGrant' +} + +export const isRemoteIncomingPaymentError = ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + o: any +): o is RemoteIncomingPaymentError => + Object.values(RemoteIncomingPaymentError).includes(o) + +export const errorToCode: { + [key in RemoteIncomingPaymentError]: number +} = { + [RemoteIncomingPaymentError.UnknownPaymentPointer]: 404, + [RemoteIncomingPaymentError.InvalidRequest]: 500, + [RemoteIncomingPaymentError.InvalidGrant]: 500, + [RemoteIncomingPaymentError.ExpiredGrant]: 500 +} + +export const errorToMessage: { + [key in RemoteIncomingPaymentError]: string +} = { + [RemoteIncomingPaymentError.UnknownPaymentPointer]: 'unknown payment pointer', + [RemoteIncomingPaymentError.InvalidRequest]: + 'invalid remote incoming payment request', + [RemoteIncomingPaymentError.InvalidGrant]: + 'invalid grant for remote incoming payment', + [RemoteIncomingPaymentError.ExpiredGrant]: + 'expired grant for remote incoming payment' +} diff --git a/packages/backend/src/open_payments/payment/incoming_remote/service.test.ts b/packages/backend/src/open_payments/payment/incoming_remote/service.test.ts new file mode 100644 index 0000000000..67f1635484 --- /dev/null +++ b/packages/backend/src/open_payments/payment/incoming_remote/service.test.ts @@ -0,0 +1,254 @@ +import { Knex } from 'knex' +import { RemoteIncomingPaymentService } from './service' +import { createTestApp, TestContainer } from '../../../tests/app' +import { Config } from '../../../config/app' +import { IocContract } from '@adonisjs/fold' +import { initIocContainer } from '../../..' +import { AppServices } from '../../../app' +import { truncateTables } from '../../../tests/tableManager' +import { Amount, serializeAmount } from '../../amount' +import { + AuthenticatedClient as OpenPaymentsClient, + AccessAction, + AccessType, + mockIncomingPayment, + mockInteractiveGrant, + mockNonInteractiveGrant, + mockPaymentPointer +} from 'open-payments' +import { GrantService } from '../../grant/service' +import { RemoteIncomingPaymentError } from './errors' + +describe('Remote Incoming Payment Service', (): void => { + let deps: IocContract + let appContainer: TestContainer + let remoteIncomingPaymentService: RemoteIncomingPaymentService + let knex: Knex + let openPaymentsClient: OpenPaymentsClient + let grantService: GrantService + + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + openPaymentsClient = await deps.use('openPaymentsClient') + grantService = await deps.use('grantService') + knex = appContainer.knex + remoteIncomingPaymentService = await deps.use( + 'remoteIncomingPaymentService' + ) + }) + + afterEach(async (): Promise => { + jest.useRealTimers() + await truncateTables(knex) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('create', (): void => { + const amount: Amount = { + value: BigInt(123), + assetCode: 'USD', + assetScale: 2 + } + const paymentPointer = mockPaymentPointer() + const grantOptions = { + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.Create, AccessAction.ReadAll], + accessToken: 'OZB8CDFONP219RP1LT0OS9M2PMHKUR64TB8N6BW7', + authServer: paymentPointer.authServer + } + + test('throws if payment pointer not found', async () => { + const clientGetPaymentPointerSpy = jest + .spyOn(openPaymentsClient.paymentPointer, 'get') + .mockImplementationOnce(() => { + throw new Error('No payment pointer') + }) + + await expect( + remoteIncomingPaymentService.create({ + paymentPointerUrl: paymentPointer.id + }) + ).resolves.toEqual(RemoteIncomingPaymentError.UnknownPaymentPointer) + expect(clientGetPaymentPointerSpy).toHaveBeenCalledWith({ + url: paymentPointer.id + }) + }) + + describe('with existing grant', () => { + beforeAll(() => { + jest + .spyOn(openPaymentsClient.paymentPointer, 'get') + .mockResolvedValue(paymentPointer) + }) + + test.each` + incomingAmount | expiresAt | description | externalRef + ${undefined} | ${undefined} | ${undefined} | ${undefined} + ${amount} | ${new Date(Date.now() + 30_000)} | ${'Test incoming payment'} | ${'#123'} + `('creates remote incoming payment ($#)', async (args): Promise => { + const mockedIncomingPayment = mockIncomingPayment({ + ...args, + paymentPointerUrl: paymentPointer.id + }) + + const grant = await grantService.create(grantOptions) + + const clientCreateIncomingPaymentSpy = jest + .spyOn(openPaymentsClient.incomingPayment, 'create') + .mockResolvedValueOnce(mockedIncomingPayment) + + const incomingPayment = await remoteIncomingPaymentService.create({ + ...args, + paymentPointerUrl: paymentPointer.id + }) + + expect(incomingPayment).toStrictEqual(mockedIncomingPayment) + expect(clientCreateIncomingPaymentSpy).toHaveBeenCalledWith( + { + paymentPointer: paymentPointer.id, + accessToken: grant.accessToken + }, + { + ...args, + expiresAt: args.expiresAt + ? args.expiresAt.toISOString() + : undefined, + incomingAmount: args.incomingAmount + ? serializeAmount(args.incomingAmount) + : undefined + } + ) + }) + + test('returns error if grant expired', async () => { + await grantService.create({ + ...grantOptions, + expiresIn: -10 + }) + + await expect( + remoteIncomingPaymentService.create({ + paymentPointerUrl: paymentPointer.id + }) + ).resolves.toEqual(RemoteIncomingPaymentError.ExpiredGrant) + }) + + test('returns error if grant does not have accessToken', async () => { + await grantService.create({ + ...grantOptions, + accessToken: undefined + }) + + await expect( + remoteIncomingPaymentService.create({ + paymentPointerUrl: paymentPointer.id + }) + ).resolves.toEqual(RemoteIncomingPaymentError.InvalidGrant) + }) + + test('returns error if fails to create the incoming payment', async () => { + await grantService.create(grantOptions) + jest + .spyOn(openPaymentsClient.incomingPayment, 'create') + .mockImplementationOnce(() => { + throw new Error('Error in client') + }) + + await expect( + remoteIncomingPaymentService.create({ + paymentPointerUrl: paymentPointer.id + }) + ).resolves.toEqual(RemoteIncomingPaymentError.InvalidRequest) + }) + }) + + describe('with new grant', () => { + beforeAll(() => { + jest + .spyOn(openPaymentsClient.paymentPointer, 'get') + .mockResolvedValue(paymentPointer) + }) + + test.each` + incomingAmount | expiresAt | description | externalRef + ${undefined} | ${undefined} | ${undefined} | ${undefined} + ${amount} | ${new Date(Date.now() + 30_000)} | ${'Test incoming payment'} | ${'#123'} + `('creates remote incoming payment ($#)', async (args): Promise => { + const mockedIncomingPayment = mockIncomingPayment({ + ...args, + paymentPointerUrl: paymentPointer.id + }) + + const grant = mockNonInteractiveGrant() + + const clientCreateIncomingPaymentSpy = jest + .spyOn(openPaymentsClient.incomingPayment, 'create') + .mockResolvedValueOnce(mockedIncomingPayment) + + const clientRequestGrantSpy = jest + .spyOn(openPaymentsClient.grant, 'request') + .mockResolvedValueOnce(grant) + + const grantCreateSpy = jest.spyOn(grantService, 'create') + const incomingPayment = await remoteIncomingPaymentService.create({ + ...args, + paymentPointerUrl: paymentPointer.id + }) + + expect(incomingPayment).toStrictEqual(mockedIncomingPayment) + expect(clientRequestGrantSpy).toHaveBeenCalledWith( + { url: paymentPointer.authServer }, + { + access_token: { + access: [ + { + type: grantOptions.accessType, + actions: grantOptions.accessActions + } + ] + }, + interact: { + start: ['redirect'] + } + } + ) + expect(grantCreateSpy).toHaveBeenCalledWith({ + ...grantOptions, + accessToken: grant.access_token.value, + expiresIn: grant.access_token.expires_in + }) + expect(clientCreateIncomingPaymentSpy).toHaveBeenCalledWith( + { + paymentPointer: paymentPointer.id, + accessToken: grant.access_token.value + }, + { + ...args, + expiresAt: args.expiresAt + ? args.expiresAt.toISOString() + : undefined, + incomingAmount: args.incomingAmount + ? serializeAmount(args.incomingAmount) + : undefined + } + ) + }) + + test('returns error if created grant is interactive', async () => { + jest + .spyOn(openPaymentsClient.grant, 'request') + .mockResolvedValueOnce(mockInteractiveGrant()) + + await expect( + remoteIncomingPaymentService.create({ + paymentPointerUrl: paymentPointer.id + }) + ).resolves.toEqual(RemoteIncomingPaymentError.InvalidGrant) + }) + }) + }) +}) diff --git a/packages/backend/src/open_payments/payment/incoming_remote/service.ts b/packages/backend/src/open_payments/payment/incoming_remote/service.ts new file mode 100644 index 0000000000..9ccee2193c --- /dev/null +++ b/packages/backend/src/open_payments/payment/incoming_remote/service.ts @@ -0,0 +1,159 @@ +import { + AuthenticatedClient, + IncomingPayment as OpenPaymentsIncomingPayment, + isNonInteractiveGrant, + AccessAction, + PaymentPointer as OpenPaymentsPaymentPointer +} from 'open-payments' +import { Grant } from '../../grant/model' +import { GrantService } from '../../grant/service' +import { BaseService } from '../../../shared/baseService' +import { Amount, serializeAmount } from '../../amount' +import { + isRemoteIncomingPaymentError, + RemoteIncomingPaymentError +} from './errors' + +interface CreateRemoteIncomingPaymentArgs { + paymentPointerUrl: string + description?: string + expiresAt?: Date + incomingAmount?: Amount + externalRef?: string +} + +export interface RemoteIncomingPaymentService { + create( + args: CreateRemoteIncomingPaymentArgs + ): Promise +} + +interface ServiceDependencies extends BaseService { + grantService: GrantService + openPaymentsUrl: string + openPaymentsClient: AuthenticatedClient +} + +export async function createRemoteIncomingPaymentService( + deps_: ServiceDependencies +): Promise { + const log = deps_.logger.child({ + service: 'RemoteIncomingPaymentService' + }) + const deps: ServiceDependencies = { + ...deps_, + logger: log + } + + return { + create: (args) => create(deps, args) + } +} + +async function create( + deps: ServiceDependencies, + args: CreateRemoteIncomingPaymentArgs +): Promise { + const { paymentPointerUrl } = args + const grantOrError = await getGrant(deps, paymentPointerUrl, [ + AccessAction.Create, + AccessAction.ReadAll + ]) + + if (isRemoteIncomingPaymentError(grantOrError)) { + return grantOrError + } + + if (!grantOrError.accessToken) { + const errorMessage = + 'Grant for remote incoming payment is missing access token' + deps.logger.warn({ grant: grantOrError }, errorMessage) + return RemoteIncomingPaymentError.InvalidGrant + } + + try { + return await deps.openPaymentsClient.incomingPayment.create( + { + paymentPointer: paymentPointerUrl, + accessToken: grantOrError.accessToken + }, + { + incomingAmount: args.incomingAmount + ? serializeAmount(args.incomingAmount) + : undefined, + description: args.description, + expiresAt: args.expiresAt?.toISOString(), + externalRef: args.externalRef + } + ) + } catch (error) { + const errorMessage = 'Error creating remote incoming payment' + deps.logger.error({ error, paymentPointerUrl }, errorMessage) + return RemoteIncomingPaymentError.InvalidRequest + } +} + +async function getGrant( + deps: ServiceDependencies, + paymentPointerUrl: string, + accessActions: AccessAction[] +): Promise { + let paymentPointer: OpenPaymentsPaymentPointer + + try { + paymentPointer = await deps.openPaymentsClient.paymentPointer.get({ + url: paymentPointerUrl + }) + } catch (error) { + const errorMessage = 'Could not get payment pointer' + deps.logger.error({ paymentPointerUrl, error }, errorMessage) + return RemoteIncomingPaymentError.UnknownPaymentPointer + } + + const grantOptions = { + authServer: paymentPointer.authServer, + accessType: 'incoming-payment' as const, + accessActions + } + + const existingGrant = await deps.grantService.get(grantOptions) + + if (existingGrant) { + if (existingGrant.expired) { + // TODO https://github.com/interledger/rafiki/issues/795 + const errorMessage = 'Grant access token expired' + deps.logger.error({ grantOptions }, errorMessage) + return RemoteIncomingPaymentError.ExpiredGrant + } + return existingGrant + } + + const grant = await deps.openPaymentsClient.grant.request( + { url: paymentPointer.authServer }, + { + access_token: { + access: [ + { + type: grantOptions.accessType, + actions: grantOptions.accessActions + } + ] + }, + interact: { + start: ['redirect'] + } + } + ) + + if (isNonInteractiveGrant(grant)) { + return deps.grantService.create({ + ...grantOptions, + accessToken: grant.access_token.value, + expiresIn: grant.access_token.expires_in + }) + } + + const errorMessage = 'Grant request requires interaction' + deps.logger.warn({ grantOptions }, errorMessage) + return RemoteIncomingPaymentError.InvalidGrant +} diff --git a/packages/backend/src/open_payments/payment/outgoing/service.test.ts b/packages/backend/src/open_payments/payment/outgoing/service.test.ts index a8b9dbbc66..6fa087e86d 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.test.ts @@ -163,15 +163,15 @@ describe('OutgoingPaymentService', (): void => { options.sourceAccount.id === sourcePaymentPointerId ) { return { - commit: async (): Promise => { - const err = await trxOrError.commit() + post: async (): Promise => { + const err = await trxOrError.post() if (!err) { amtDelivered += options.destinationAmount || options.sourceAmount } return err }, - rollback: trxOrError.rollback + void: trxOrError.void } } return trxOrError diff --git a/packages/backend/src/open_payments/quote/service.ts b/packages/backend/src/open_payments/quote/service.ts index 23cdbbe694..34d3a0ea31 100644 --- a/packages/backend/src/open_payments/quote/service.ts +++ b/packages/backend/src/open_payments/quote/service.ts @@ -349,10 +349,11 @@ export async function finalizeQuote( } // Ensure a quotation's expiry date is not set past the expiry date of the receiver when the receiver is an incoming payment if ( - receiver.expiresAt && - receiver.expiresAt.getTime() < patchOptions.expiresAt.getTime() + receiver.incomingPayment?.expiresAt && + receiver.incomingPayment?.expiresAt.getTime() < + patchOptions.expiresAt.getTime() ) { - patchOptions.expiresAt = receiver.expiresAt + patchOptions.expiresAt = receiver.incomingPayment.expiresAt } await quote.$query(deps.knex).patch(patchOptions) diff --git a/packages/backend/src/open_payments/receiver/errors.ts b/packages/backend/src/open_payments/receiver/errors.ts new file mode 100644 index 0000000000..6f655764d1 --- /dev/null +++ b/packages/backend/src/open_payments/receiver/errors.ts @@ -0,0 +1,31 @@ +import { + errorToMessage as incomingPaymentErrorToMessage, + errorToCode as incomingPaymentErrorToCode, + isIncomingPaymentError, + IncomingPaymentError +} from '../payment/incoming/errors' +import { + errorToMessage as remoteIncomingPaymentErrorToMessage, + errorToCode as remoteIncomingPaymentErrorToCode, + RemoteIncomingPaymentError +} from '../payment/incoming_remote/errors' + +export type ReceiverError = IncomingPaymentError | RemoteIncomingPaymentError +export const ReceiverError = { + ...IncomingPaymentError, + ...RemoteIncomingPaymentError +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types +export const isReceiverError = (o: any): o is ReceiverError => + Object.values(ReceiverError).includes(o) + +export const errorToCode = (error: ReceiverError): number => + isIncomingPaymentError(error) + ? incomingPaymentErrorToCode[error] + : remoteIncomingPaymentErrorToCode[error] + +export const errorToMessage = (error: ReceiverError): string => + isIncomingPaymentError(error) + ? incomingPaymentErrorToMessage[error] + : remoteIncomingPaymentErrorToMessage[error] diff --git a/packages/backend/src/open_payments/receiver/model.test.ts b/packages/backend/src/open_payments/receiver/model.test.ts index 0f9b0094aa..d2cbcf70d0 100644 --- a/packages/backend/src/open_payments/receiver/model.test.ts +++ b/packages/backend/src/open_payments/receiver/model.test.ts @@ -10,6 +10,7 @@ import { ConnectionService } from '../connection/service' import { Receiver } from './model' import { IncomingPaymentState } from '../payment/incoming/model' import { Connection } from '../connection/model' +import assert from 'assert' describe('Receiver Model', (): void => { let deps: IocContract @@ -38,61 +39,89 @@ describe('Receiver Model', (): void => { paymentPointerId: paymentPointer.id }) + const connection = connectionService.get(incomingPayment) + + assert(connection instanceof Connection) + const receiver = Receiver.fromIncomingPayment( incomingPayment.toOpenPaymentsType({ - ilpStreamConnection: connectionService.get( - incomingPayment - ) as Connection + ilpStreamConnection: connection }) ) expect(receiver).toEqual({ assetCode: incomingPayment.asset.code, assetScale: incomingPayment.asset.scale, - incomingAmountValue: undefined, - receivedAmountValue: BigInt(0), ilpAddress: expect.any(String), sharedSecret: expect.any(Buffer), - expiresAt: incomingPayment.expiresAt + incomingPayment: { + id: incomingPayment.url, + paymentPointer: incomingPayment.paymentPointer.url, + updatedAt: incomingPayment.updatedAt, + createdAt: incomingPayment.createdAt, + completed: incomingPayment.completed, + receivedAmount: incomingPayment.receivedAmount, + incomingAmount: incomingPayment.incomingAmount, + expiresAt: incomingPayment.expiresAt + } }) }) - test('fails to create receiver if payment completed', async () => { + test('throws if incoming payment is completed', async () => { const paymentPointer = await createPaymentPointer(deps) const incomingPayment = await createIncomingPayment(deps, { paymentPointerId: paymentPointer.id }) incomingPayment.state = IncomingPaymentState.Completed - - const receiver = Receiver.fromIncomingPayment( - incomingPayment.toOpenPaymentsType({ - ilpStreamConnection: connectionService.get( - incomingPayment - ) as Connection - }) - ) - - expect(receiver).toBeUndefined() + const connection = connectionService.get(incomingPayment) + + assert(connection instanceof Connection) + expect(() => + Receiver.fromIncomingPayment( + incomingPayment.toOpenPaymentsType({ + ilpStreamConnection: connection + }) + ) + ).toThrow('Cannot create receiver from completed incoming payment') }) - test('fails to create receiver if payment expired', async () => { + test('throws if incoming payment is expired', async () => { const paymentPointer = await createPaymentPointer(deps) const incomingPayment = await createIncomingPayment(deps, { paymentPointerId: paymentPointer.id }) incomingPayment.expiresAt = new Date(Date.now() - 1) + const connection = connectionService.get(incomingPayment) + + assert(connection instanceof Connection) + expect(() => + Receiver.fromIncomingPayment( + incomingPayment.toOpenPaymentsType({ + ilpStreamConnection: connection + }) + ) + ).toThrow('Cannot create receiver from expired incoming payment') + }) - const receiver = Receiver.fromIncomingPayment( - incomingPayment.toOpenPaymentsType({ - ilpStreamConnection: connectionService.get( - incomingPayment - ) as Connection - }) - ) + test('throws if stream connection has invalid ILP address', async () => { + const paymentPointer = await createPaymentPointer(deps) + const incomingPayment = await createIncomingPayment(deps, { + paymentPointerId: paymentPointer.id + }) - expect(receiver).toBeUndefined() + const connection = connectionService.get(incomingPayment) + assert(connection instanceof Connection) + ;(connection.ilpAddress as string) = 'not base 64 encoded' + + expect(() => + Receiver.fromIncomingPayment( + incomingPayment.toOpenPaymentsType({ + ilpStreamConnection: connection + }) + ) + ).toThrow('Invalid ILP address on stream connection') }) }) @@ -103,11 +132,10 @@ describe('Receiver Model', (): void => { paymentPointerId: paymentPointer.id }) - const receiver = Receiver.fromConnection( - ( - connectionService.get(incomingPayment) as Connection - ).toOpenPaymentsType() - ) + const connection = connectionService.get(incomingPayment) + assert(connection instanceof Connection) + + const receiver = Receiver.fromConnection(connection.toOpenPaymentsType()) expect(receiver).toEqual({ assetCode: incomingPayment.asset.code, @@ -126,13 +154,16 @@ describe('Receiver Model', (): void => { paymentPointerId: paymentPointer.id }) - const connection = ( - connectionService.get(incomingPayment) as Connection - ).toOpenPaymentsType() + const connection = connectionService.get(incomingPayment) + assert(connection instanceof Connection) - connection.ilpAddress = 'not base 64 encoded' + const openPaymentsConnection = connection.toOpenPaymentsType() - expect(Receiver.fromConnection(connection)).toBeUndefined() + openPaymentsConnection.ilpAddress = 'not base 64 encoded' + + expect(() => Receiver.fromConnection(openPaymentsConnection)).toThrow( + 'Invalid ILP address on stream connection' + ) }) }) }) diff --git a/packages/backend/src/open_payments/receiver/model.ts b/packages/backend/src/open_payments/receiver/model.ts index 007040dee7..f183f0b0fd 100644 --- a/packages/backend/src/open_payments/receiver/model.ts +++ b/packages/backend/src/open_payments/receiver/model.ts @@ -15,18 +15,50 @@ interface OpenPaymentsConnectionWithIlpAddress ilpAddress: IlpAddress } +type ReceiverIncomingPayment = Readonly< + Omit< + OpenPaymentsIncomingPayment, + | 'ilpStreamConnection' + | 'expiresAt' + | 'receivedAmount' + | 'incomingAmount' + | 'createdAt' + | 'updatedAt' + > & { + expiresAt?: Date + createdAt: Date + updatedAt: Date + receivedAmount: Amount + incomingAmount?: Amount + } +> + export class Receiver extends ConnectionBase { static fromConnection( connection: OpenPaymentsConnection ): Receiver | undefined { - return this.fromOpenPaymentsConnection(connection) + if (!isValidIlpAddress(connection.ilpAddress)) { + throw new Error('Invalid ILP address on stream connection') + } + + return new this({ + id: connection.id, + assetCode: connection.assetCode, + assetScale: connection.assetScale, + sharedSecret: connection.sharedSecret, + ilpAddress: connection.ilpAddress + }) } static fromIncomingPayment( incomingPayment: OpenPaymentsIncomingPayment - ): Receiver | undefined { - if (!incomingPayment.ilpStreamConnection || incomingPayment.completed) { - return undefined + ): Receiver { + if (!incomingPayment.ilpStreamConnection) { + throw new Error('Missing stream connection on incoming payment') + } + + if (incomingPayment.completed) { + throw new Error('Cannot create receiver from completed incoming payment') } const expiresAt = incomingPayment.expiresAt @@ -34,7 +66,7 @@ export class Receiver extends ConnectionBase { : undefined if (expiresAt && expiresAt.getTime() <= Date.now()) { - return undefined + throw new Error('Cannot create receiver from expired incoming payment') } const incomingAmount = incomingPayment.incomingAmount @@ -42,45 +74,33 @@ export class Receiver extends ConnectionBase { : undefined const receivedAmount = parseAmount(incomingPayment.receivedAmount) - return this.fromOpenPaymentsConnection( - incomingPayment.ilpStreamConnection, - incomingAmount?.value, - receivedAmount.value, - expiresAt - ) - } - - private static fromOpenPaymentsConnection( - connection: OpenPaymentsConnection, - incomingAmountValue?: bigint, - receivedAmountValue?: bigint, - expiresAt?: Date - ): Receiver | undefined { - const ilpAddress = connection.ilpAddress - - if (!isValidIlpAddress(ilpAddress)) { - return undefined + if (!isValidIlpAddress(incomingPayment.ilpStreamConnection.ilpAddress)) { + throw new Error('Invalid ILP address on stream connection') } return new this( { - id: connection.id, - assetCode: connection.assetCode, - assetScale: connection.assetScale, - sharedSecret: connection.sharedSecret, - ilpAddress + ...incomingPayment.ilpStreamConnection, + ilpAddress: incomingPayment.ilpStreamConnection.ilpAddress }, - incomingAmountValue, - receivedAmountValue, - expiresAt + { + id: incomingPayment.id, + completed: incomingPayment.completed, + paymentPointer: incomingPayment.paymentPointer, + expiresAt, + receivedAmount, + incomingAmount, + description: incomingPayment.description, + externalRef: incomingPayment.externalRef, + createdAt: new Date(incomingPayment.createdAt), + updatedAt: new Date(incomingPayment.updatedAt) + } ) } private constructor( connection: OpenPaymentsConnectionWithIlpAddress, - private readonly incomingAmountValue?: bigint, - private readonly receivedAmountValue?: bigint, - public readonly expiresAt?: Date + public incomingPayment?: ReceiverIncomingPayment ) { super( connection.ilpAddress, @@ -98,9 +118,9 @@ export class Receiver extends ConnectionBase { } public get incomingAmount(): Amount | undefined { - if (this.incomingAmountValue) { + if (this.incomingPayment?.incomingAmount) { return { - value: this.incomingAmountValue, + value: this.incomingPayment.incomingAmount.value, assetCode: this.assetCode, assetScale: this.assetScale } @@ -109,9 +129,9 @@ export class Receiver extends ConnectionBase { } public get receivedAmount(): Amount | undefined { - if (this.receivedAmountValue !== undefined) { + if (this.incomingPayment?.receivedAmount) { return { - value: this.receivedAmountValue, + value: this.incomingPayment.receivedAmount.value, assetCode: this.assetCode, assetScale: this.assetScale } diff --git a/packages/backend/src/open_payments/receiver/service.test.ts b/packages/backend/src/open_payments/receiver/service.test.ts index 58c713bf63..dd2908890d 100644 --- a/packages/backend/src/open_payments/receiver/service.test.ts +++ b/packages/backend/src/open_payments/receiver/service.test.ts @@ -3,8 +3,14 @@ import { faker } from '@faker-js/faker' import { Knex } from 'knex' import { AuthenticatedClient, - GrantRequest, - NonInteractiveGrant + AccessType, + AccessAction, + IncomingPayment as OpenPaymentsIncomingPayment, + PaymentPointer as OpenPaymentsPaymentPointer, + mockIncomingPayment, + mockPaymentPointer, + NonInteractiveGrant, + GrantRequest } from 'open-payments' import { URL } from 'url' import { v4 as uuid } from 'uuid' @@ -15,34 +21,49 @@ import { Config } from '../../config/app' import { initIocContainer } from '../..' import { AppServices } from '../../app' import { createIncomingPayment } from '../../tests/incomingPayment' -import { createPaymentPointer } from '../../tests/paymentPointer' +import { + createPaymentPointer, + MockPaymentPointer +} from '../../tests/paymentPointer' import { truncateTables } from '../../tests/tableManager' import { ConnectionService } from '../connection/service' import { GrantService } from '../grant/service' -import { IncomingPayment } from '../payment/incoming/model' -import { PaymentPointer } from '../payment_pointer/model' import { PaymentPointerService } from '../payment_pointer/service' +import { Amount, parseAmount } from '../amount' +import { RemoteIncomingPaymentService } from '../payment/incoming_remote/service' import { Connection } from '../connection/model' -import { AccessType, AccessAction } from 'open-payments' +import { IncomingPaymentError } from '../payment/incoming/errors' +import { IncomingPaymentService } from '../payment/incoming/service' +import { createAsset } from '../../tests/asset' +import { ReceiverError } from './errors' +import { RemoteIncomingPaymentError } from '../payment/incoming_remote/errors' +import assert from 'assert' +import { Receiver } from './model' describe('Receiver Service', (): void => { let deps: IocContract let appContainer: TestContainer let receiverService: ReceiverService + let incomingPaymentService: IncomingPaymentService let openPaymentsClient: AuthenticatedClient let knex: Knex let connectionService: ConnectionService let paymentPointerService: PaymentPointerService let grantService: GrantService + let remoteIncomingPaymentService: RemoteIncomingPaymentService beforeAll(async (): Promise => { deps = initIocContainer(Config) - appContainer = await createTestApp(deps) + appContainer = await createTestApp(deps, { silentLogging: true }) receiverService = await deps.use('receiverService') + incomingPaymentService = await deps.use('incomingPaymentService') openPaymentsClient = await deps.use('openPaymentsClient') connectionService = await deps.use('connectionService') paymentPointerService = await deps.use('paymentPointerService') grantService = await deps.use('grantService') + remoteIncomingPaymentService = await deps.use( + 'remoteIncomingPaymentService' + ) knex = appContainer.knex }) @@ -96,13 +117,13 @@ describe('Receiver Service', (): void => { `${paymentPointer.url}/${CONNECTION_PATH}/${incomingPayment.connectionId}` ) + const connection = connectionService.get(incomingPayment) + + assert(connection instanceof Connection) + const clientGetConnectionSpy = jest .spyOn(openPaymentsClient.ilpStreamConnection, 'get') - .mockImplementationOnce(async () => - ( - connectionService.get(incomingPayment) as Connection - ).toOpenPaymentsType() - ) + .mockImplementationOnce(async () => connection.toOpenPaymentsType()) await expect(receiverService.get(remoteUrl.href)).resolves.toEqual({ assetCode: paymentPointer.asset.code, @@ -174,13 +195,22 @@ describe('Receiver Service', (): void => { await expect(receiverService.get(incomingPayment.url)).resolves.toEqual( { - assetCode: paymentPointer.asset.code, - assetScale: paymentPointer.asset.scale, - incomingAmountValue: incomingPayment.incomingAmount?.value, - receivedAmountValue: incomingPayment.receivedAmount.value, + assetCode: incomingPayment.receivedAmount.assetCode, + assetScale: incomingPayment.receivedAmount.assetScale, ilpAddress: expect.any(String), sharedSecret: expect.any(Buffer), - expiresAt: expect.any(Date) + incomingPayment: { + id: incomingPayment.url, + paymentPointer: incomingPayment.paymentPointer.url, + completed: incomingPayment.completed, + receivedAmount: incomingPayment.receivedAmount, + incomingAmount: incomingPayment.incomingAmount, + description: incomingPayment.description || undefined, + externalRef: incomingPayment.externalRef || undefined, + expiresAt: incomingPayment.expiresAt, + updatedAt: new Date(incomingPayment.updatedAt), + createdAt: new Date(incomingPayment.createdAt) + } } ) expect(clientGetIncomingPaymentSpy).not.toHaveBeenCalled() @@ -191,8 +221,8 @@ describe('Receiver Service', (): void => { ${false} | ${'no grant'} ${true} | ${'existing grant'} `('remote ($description)', ({ existingGrant }): void => { - let paymentPointer: PaymentPointer - let incomingPayment: IncomingPayment + let paymentPointer: OpenPaymentsPaymentPointer + let incomingPayment: OpenPaymentsIncomingPayment const authServer = faker.internet.url() const INCOMING_PAYMENT_PATH = 'incoming-payments' const grantOptions = { @@ -230,14 +260,12 @@ describe('Receiver Service', (): void => { } beforeEach(async (): Promise => { - paymentPointer = await createPaymentPointer(deps) - incomingPayment = await createIncomingPayment(deps, { - paymentPointerId: paymentPointer.id, - incomingAmount: { - value: BigInt(5), - assetCode: paymentPointer.asset.code, - assetScale: paymentPointer.asset.scale - } + paymentPointer = mockPaymentPointer({ + authServer + }) + incomingPayment = mockIncomingPayment({ + id: `${paymentPointer.id}/incoming-payments/${uuid()}`, + paymentPointer: paymentPointer.id }) if (existingGrant) { await expect( @@ -255,11 +283,7 @@ describe('Receiver Service', (): void => { test('resolves incoming payment', async () => { const clientGetPaymentPointerSpy = jest .spyOn(openPaymentsClient.paymentPointer, 'get') - .mockResolvedValueOnce( - paymentPointer.toOpenPaymentsType({ - authServer - }) - ) + .mockResolvedValueOnce(paymentPointer) const clientRequestGrantSpy = jest .spyOn(openPaymentsClient.grant, 'request') @@ -267,27 +291,32 @@ describe('Receiver Service', (): void => { const clientGetIncomingPaymentSpy = jest .spyOn(openPaymentsClient.incomingPayment, 'get') - .mockResolvedValueOnce( - incomingPayment.toOpenPaymentsType({ - ilpStreamConnection: connectionService.get( - incomingPayment - ) as Connection - }) - ) + .mockResolvedValueOnce(incomingPayment) await expect( - receiverService.get(incomingPayment.url) + receiverService.get(incomingPayment.id) ).resolves.toEqual({ - assetCode: paymentPointer.asset.code, - assetScale: paymentPointer.asset.scale, - incomingAmountValue: incomingPayment.incomingAmount?.value, - receivedAmountValue: incomingPayment.receivedAmount.value, + assetCode: incomingPayment.receivedAmount.assetCode, + assetScale: incomingPayment.receivedAmount.assetScale, ilpAddress: expect.any(String), sharedSecret: expect.any(Buffer), - expiresAt: expect.any(Date) + incomingPayment: { + id: incomingPayment.id, + paymentPointer: incomingPayment.paymentPointer, + updatedAt: new Date(incomingPayment.updatedAt), + createdAt: new Date(incomingPayment.createdAt), + completed: incomingPayment.completed, + receivedAmount: + incomingPayment.receivedAmount && + parseAmount(incomingPayment.receivedAmount), + incomingAmount: + incomingPayment.incomingAmount && + parseAmount(incomingPayment.incomingAmount), + expiresAt: incomingPayment.expiresAt + } }) expect(clientGetPaymentPointerSpy).toHaveBeenCalledWith({ - url: paymentPointer.url + url: paymentPointer.id }) if (!existingGrant) { expect(clientRequestGrantSpy).toHaveBeenCalledWith( @@ -296,7 +325,7 @@ describe('Receiver Service', (): void => { ) } expect(clientGetIncomingPaymentSpy).toHaveBeenCalledWith({ - url: incomingPayment.url, + url: incomingPayment.id, accessToken: grantOptions.accessToken }) }) @@ -308,11 +337,11 @@ describe('Receiver Service', (): void => { await expect( receiverService.get( - `${paymentPointer.url}/${INCOMING_PAYMENT_PATH}/${uuid()}` + `${paymentPointer.id}/${INCOMING_PAYMENT_PATH}/${uuid()}` ) ).resolves.toBeUndefined() expect(clientGetPaymentPointerSpy).toHaveBeenCalledWith({ - url: paymentPointer.url + url: paymentPointer.id }) }) @@ -325,18 +354,14 @@ describe('Receiver Service', (): void => { await grant?.$query(knex).patch({ expiresAt: new Date() }) jest .spyOn(openPaymentsClient.paymentPointer, 'get') - .mockResolvedValueOnce( - paymentPointer.toOpenPaymentsType({ - authServer - }) - ) + .mockResolvedValueOnce(paymentPointer) const clientRequestGrantSpy = jest.spyOn( openPaymentsClient.grant, 'request' ) await expect( - receiverService.get(incomingPayment.url) + receiverService.get(incomingPayment.id) ).resolves.toBeUndefined() expect(clientRequestGrantSpy).not.toHaveBeenCalled() }) @@ -344,17 +369,13 @@ describe('Receiver Service', (): void => { test('returns undefined for invalid grant', async (): Promise => { jest .spyOn(openPaymentsClient.paymentPointer, 'get') - .mockResolvedValueOnce( - paymentPointer.toOpenPaymentsType({ - authServer - }) - ) + .mockResolvedValueOnce(paymentPointer) const clientRequestGrantSpy = jest .spyOn(openPaymentsClient.grant, 'request') .mockRejectedValueOnce(new Error('Could not request grant')) await expect( - receiverService.get(incomingPayment.url) + receiverService.get(incomingPayment.id) ).resolves.toBeUndefined() expect(clientRequestGrantSpy).toHaveBeenCalledWith( { url: authServer }, @@ -365,11 +386,7 @@ describe('Receiver Service', (): void => { test('returns undefined for interactive grant', async (): Promise => { jest .spyOn(openPaymentsClient.paymentPointer, 'get') - .mockResolvedValueOnce( - paymentPointer.toOpenPaymentsType({ - authServer - }) - ) + .mockResolvedValueOnce(paymentPointer) const clientRequestGrantSpy = jest .spyOn(openPaymentsClient.grant, 'request') .mockResolvedValueOnce({ @@ -381,7 +398,7 @@ describe('Receiver Service', (): void => { }) await expect( - receiverService.get(incomingPayment.url) + receiverService.get(incomingPayment.id) ).resolves.toBeUndefined() expect(clientRequestGrantSpy).toHaveBeenCalledWith( { url: authServer }, @@ -393,11 +410,7 @@ describe('Receiver Service', (): void => { test('returns undefined when fetching remote incoming payment throws', async (): Promise => { jest .spyOn(openPaymentsClient.paymentPointer, 'get') - .mockResolvedValueOnce( - paymentPointer.toOpenPaymentsType({ - authServer - }) - ) + .mockResolvedValueOnce(paymentPointer) jest .spyOn(openPaymentsClient.grant, 'request') .mockResolvedValueOnce(grant) @@ -406,14 +419,213 @@ describe('Receiver Service', (): void => { .mockRejectedValueOnce(new Error('Could not get incoming payment')) await expect( - receiverService.get(incomingPayment.url) + receiverService.get(incomingPayment.id) ).resolves.toBeUndefined() expect(clientGetIncomingPaymentSpy).toHaveBeenCalledWith({ - url: incomingPayment.url, + url: incomingPayment.id, accessToken: expect.any(String) }) }) }) }) }) + + describe('create', () => { + describe('remote incoming payment', () => { + const paymentPointer = mockPaymentPointer({ + assetCode: 'USD', + assetScale: 2 + }) + + const amount: Amount = { + value: BigInt(123), + assetCode: 'USD', + assetScale: 2 + } + + test.each` + incomingAmount | expiresAt | description | externalRef + ${undefined} | ${undefined} | ${undefined} | ${undefined} + ${amount} | ${new Date(Date.now() + 30_000)} | ${'Test incoming payment'} | ${'#123'} + `( + 'creates receiver from remote incoming payment ($#)', + async ({ + description, + externalRef, + expiresAt, + incomingAmount + }): Promise => { + const incomingPayment = mockIncomingPayment({ + description, + externalRef, + expiresAt, + incomingAmount + }) + const remoteIncomingPaymentServiceSpy = jest + .spyOn(remoteIncomingPaymentService, 'create') + .mockResolvedValueOnce(incomingPayment) + + const localIncomingPaymentCreateSpy = jest.spyOn( + incomingPaymentService, + 'create' + ) + + const receiver = await receiverService.create({ + paymentPointerUrl: paymentPointer.id, + incomingAmount, + expiresAt, + description, + externalRef + }) + + expect(receiver).toEqual({ + assetCode: incomingPayment.receivedAmount.assetCode, + assetScale: incomingPayment.receivedAmount.assetScale, + ilpAddress: incomingPayment.ilpStreamConnection?.ilpAddress, + sharedSecret: expect.any(Buffer), + incomingPayment: { + id: incomingPayment.id, + paymentPointer: incomingPayment.paymentPointer, + completed: incomingPayment.completed, + receivedAmount: parseAmount(incomingPayment.receivedAmount), + incomingAmount: + incomingPayment.incomingAmount && + parseAmount(incomingPayment.incomingAmount), + description: incomingPayment.description || undefined, + externalRef: incomingPayment.externalRef || undefined, + updatedAt: new Date(incomingPayment.updatedAt), + createdAt: new Date(incomingPayment.createdAt), + expiresAt: + incomingPayment.expiresAt && new Date(incomingPayment.expiresAt) + } + }) + + expect(remoteIncomingPaymentServiceSpy).toHaveBeenCalledWith({ + paymentPointerUrl: paymentPointer.id, + incomingAmount, + expiresAt, + description, + externalRef + }) + expect(localIncomingPaymentCreateSpy).not.toHaveBeenCalled() + } + ) + + test('returns error if could not create remote incoming payment', async (): Promise => { + jest + .spyOn(remoteIncomingPaymentService, 'create') + .mockResolvedValueOnce( + RemoteIncomingPaymentError.UnknownPaymentPointer + ) + + await expect( + receiverService.create({ + paymentPointerUrl: paymentPointer.id + }) + ).resolves.toEqual(ReceiverError.UnknownPaymentPointer) + }) + }) + + describe('local incoming payment', () => { + let paymentPointer: MockPaymentPointer + const amount: Amount = { + value: BigInt(123), + assetCode: 'USD', + assetScale: 2 + } + + beforeEach(async () => { + const asset = await createAsset(deps, { + code: 'USD', + scale: 2 + }) + + paymentPointer = await createPaymentPointer(deps, { + mockServerPort: Config.openPaymentsPort, + assetId: asset.id + }) + }) + + test.each` + incomingAmount | expiresAt | description | externalRef + ${undefined} | ${undefined} | ${undefined} | ${undefined} + ${amount} | ${new Date(Date.now() + 30_000)} | ${'Test incoming payment'} | ${'#123'} + `( + 'creates receiver from local incoming payment ($#)', + async ({ + description, + externalRef, + expiresAt, + incomingAmount + }): Promise => { + const incomingPaymentCreateSpy = jest.spyOn( + incomingPaymentService, + 'create' + ) + const remoteIncomingPaymentCreateSpy = jest.spyOn( + remoteIncomingPaymentService, + 'create' + ) + const receiver = await receiverService.create({ + paymentPointerUrl: paymentPointer.url, + incomingAmount, + expiresAt, + description, + externalRef + }) + + assert(receiver instanceof Receiver) + expect(receiver).toEqual({ + assetCode: paymentPointer.asset.code, + assetScale: paymentPointer.asset.scale, + ilpAddress: receiver.ilpAddress, + sharedSecret: expect.any(Buffer), + incomingPayment: { + id: receiver.incomingPayment?.id, + paymentPointer: receiver.incomingPayment?.paymentPointer, + completed: receiver.incomingPayment?.completed, + receivedAmount: receiver.incomingPayment?.receivedAmount, + incomingAmount: receiver.incomingPayment?.incomingAmount, + description: receiver.incomingPayment?.description || undefined, + externalRef: receiver.incomingPayment?.externalRef || undefined, + updatedAt: receiver.incomingPayment?.updatedAt, + createdAt: receiver.incomingPayment?.createdAt, + expiresAt: receiver.incomingPayment?.expiresAt + } + }) + + expect(incomingPaymentCreateSpy).toHaveBeenCalledWith({ + paymentPointerId: paymentPointer.id, + incomingAmount, + expiresAt, + description, + externalRef + }) + expect(remoteIncomingPaymentCreateSpy).not.toHaveBeenCalled() + } + ) + + test('returns error if could not create local incoming payment', async (): Promise => { + jest + .spyOn(incomingPaymentService, 'create') + .mockResolvedValueOnce(IncomingPaymentError.InvalidAmount) + + await expect( + receiverService.create({ + paymentPointerUrl: paymentPointer.url + }) + ).resolves.toEqual(ReceiverError.InvalidAmount) + }) + + test('throws if error when getting connection for local incoming payment', async (): Promise => { + jest.spyOn(connectionService, 'get').mockReturnValueOnce(undefined) + + await expect( + receiverService.create({ + paymentPointerUrl: paymentPointer.url + }) + ).rejects.toThrow('Could not get connection for local incoming payment') + }) + }) + }) }) diff --git a/packages/backend/src/open_payments/receiver/service.ts b/packages/backend/src/open_payments/receiver/service.ts index f070924b40..3de999b5a1 100644 --- a/packages/backend/src/open_payments/receiver/service.ts +++ b/packages/backend/src/open_payments/receiver/service.ts @@ -2,9 +2,10 @@ import { AuthenticatedClient, IncomingPayment as OpenPaymentsIncomingPayment, ILPStreamConnection as OpenPaymentsConnection, - isNonInteractiveGrant + isNonInteractiveGrant, + AccessType, + AccessAction } from 'open-payments' - import { ConnectionService } from '../connection/service' import { Grant } from '../grant/model' import { GrantService } from '../grant/service' @@ -13,11 +14,27 @@ import { BaseService } from '../../shared/baseService' import { IncomingPaymentService } from '../payment/incoming/service' import { PaymentPointer } from '../payment_pointer/model' import { Receiver } from './model' -import { AccessType, AccessAction } from 'open-payments' +import { Amount } from '../amount' +import { RemoteIncomingPaymentService } from '../payment/incoming_remote/service' +import { isIncomingPaymentError } from '../payment/incoming/errors' +import { + isReceiverError, + ReceiverError, + errorToMessage as receiverErrorToMessage +} from './errors' + +interface CreateReceiverArgs { + paymentPointerUrl: string + description?: string + expiresAt?: Date + incomingAmount?: Amount + externalRef?: string +} // A receiver is resolved from an incoming payment or a connection export interface ReceiverService { get(url: string): Promise + create(args: CreateReceiverArgs): Promise } interface ServiceDependencies extends BaseService { @@ -27,6 +44,7 @@ interface ServiceDependencies extends BaseService { openPaymentsUrl: string paymentPointerService: PaymentPointerService openPaymentsClient: AuthenticatedClient + remoteIncomingPaymentService: RemoteIncomingPaymentService } const CONNECTION_URL_REGEX = /\/connections\/(.){36}$/ @@ -45,10 +63,79 @@ export async function createReceiverService( } return { - get: (url) => getReceiver(deps, url) + get: (url) => getReceiver(deps, url), + create: (url) => createReceiver(deps, url) + } +} + +async function createReceiver( + deps: ServiceDependencies, + args: CreateReceiverArgs +): Promise { + const localPaymentPointer = await deps.paymentPointerService.getByUrl( + args.paymentPointerUrl + ) + + const incomingPaymentOrError = localPaymentPointer + ? await createLocalIncomingPayment(deps, args, localPaymentPointer) + : await deps.remoteIncomingPaymentService.create(args) + + if (isReceiverError(incomingPaymentOrError)) { + return incomingPaymentOrError + } + + try { + return Receiver.fromIncomingPayment(incomingPaymentOrError) + } catch (error) { + const errorMessage = 'Could not create receiver from incoming payment' + deps.logger.error( + { error: error && error['message'] ? error['message'] : 'Unknown error' }, + errorMessage + ) + + throw new Error(errorMessage, { cause: error }) } } +async function createLocalIncomingPayment( + deps: ServiceDependencies, + args: CreateReceiverArgs, + paymentPointer: PaymentPointer +): Promise { + const { description, expiresAt, incomingAmount, externalRef } = args + + const incomingPaymentOrError = await deps.incomingPaymentService.create({ + paymentPointerId: paymentPointer.id, + description, + expiresAt, + incomingAmount, + externalRef + }) + + if (isIncomingPaymentError(incomingPaymentOrError)) { + const errorMessage = 'Could not create local incoming payment' + deps.logger.error( + { error: receiverErrorToMessage(incomingPaymentOrError) }, + errorMessage + ) + + return incomingPaymentOrError + } + + const connection = deps.connectionService.get(incomingPaymentOrError) + + if (!connection) { + const errorMessage = 'Could not get connection for local incoming payment' + deps.logger.error({ incomingPaymentOrError }, errorMessage) + + throw new Error(errorMessage) + } + + return incomingPaymentOrError.toOpenPaymentsType({ + ilpStreamConnection: connection + }) +} + async function getReceiver( deps: ServiceDependencies, url: string diff --git a/packages/backend/src/tests/app.ts b/packages/backend/src/tests/app.ts index 2efc088d81..be26de7248 100644 --- a/packages/backend/src/tests/app.ts +++ b/packages/backend/src/tests/app.ts @@ -29,7 +29,8 @@ export interface TestContainer { } export const createTestApp = async ( - container: IocContract + container: IocContract, + options?: { silentLogging?: boolean } ): Promise => { const config = await container.use('config') config.adminPort = 0 @@ -47,7 +48,7 @@ export const createTestApp = async ( ignore: 'pid,hostname' } }, - level: process.env.LOG_LEVEL || 'error', + level: options?.silentLogging ? 'silent' : process.env.LOG_LEVEL || 'error', name: 'test-logger' }) diff --git a/packages/mock-account-provider/generated/graphql.ts b/packages/mock-account-provider/generated/graphql.ts index 2bf94d0d47..47e02d77af 100644 --- a/packages/mock-account-provider/generated/graphql.ts +++ b/packages/mock-account-provider/generated/graphql.ts @@ -180,6 +180,22 @@ export type CreateQuoteInput = { sendAmount?: InputMaybe; }; +export type CreateReceiverInput = { + description?: InputMaybe; + expiresAt?: InputMaybe; + externalRef?: InputMaybe; + incomingAmount?: InputMaybe; + paymentPointerUrl: Scalars['String']; +}; + +export type CreateReceiverResponse = { + __typename?: 'CreateReceiverResponse'; + code: Scalars['String']; + message?: Maybe; + receiver?: Maybe; + success: Scalars['Boolean']; +}; + export enum Crv { Ed25519 = 'Ed25519' } @@ -282,8 +298,8 @@ export enum Kty { } export enum LiquidityError { - AlreadyCommitted = 'AlreadyCommitted', - AlreadyRolledBack = 'AlreadyRolledBack', + AlreadyPosted = 'AlreadyPosted', + AlreadyVoided = 'AlreadyVoided', AmountZero = 'AmountZero', InsufficientBalance = 'InsufficientBalance', InvalidId = 'InvalidId', @@ -331,20 +347,21 @@ export type Mutation = { /** Create liquidity withdrawal from peer */ createPeerLiquidityWithdrawal?: Maybe; createQuote: QuoteResponse; + createReceiver: CreateReceiverResponse; /** Delete peer */ deletePeer: DeletePeerMutationResponse; /** Deposit webhook event liquidity */ depositEventLiquidity?: Maybe; - /** Finalize liquidity withdrawal */ - finalizeLiquidityWithdrawal?: Maybe; + /** Posts liquidity withdrawal */ + postLiquidityWithdrawal?: Maybe; revokePaymentPointerKey?: Maybe; - /** Rollback liquidity withdrawal */ - rollbackLiquidityWithdrawal?: Maybe; triggerPaymentPointerEvents: TriggerPaymentPointerEventsMutationResponse; /** Update asset withdrawal threshold */ updateAssetWithdrawalThreshold: AssetMutationResponse; /** Update peer */ updatePeer: UpdatePeerMutationResponse; + /** Void liquidity withdrawal */ + voidLiquidityWithdrawal?: Maybe; /** Withdraw webhook event liquidity */ withdrawEventLiquidity?: Maybe; }; @@ -410,6 +427,11 @@ export type MutationCreateQuoteArgs = { }; +export type MutationCreateReceiverArgs = { + input: CreateReceiverInput; +}; + + export type MutationDeletePeerArgs = { id: Scalars['String']; }; @@ -420,7 +442,7 @@ export type MutationDepositEventLiquidityArgs = { }; -export type MutationFinalizeLiquidityWithdrawalArgs = { +export type MutationPostLiquidityWithdrawalArgs = { withdrawalId: Scalars['String']; }; @@ -430,11 +452,6 @@ export type MutationRevokePaymentPointerKeyArgs = { }; -export type MutationRollbackLiquidityWithdrawalArgs = { - withdrawalId: Scalars['String']; -}; - - export type MutationTriggerPaymentPointerEventsArgs = { limit: Scalars['Int']; }; @@ -450,6 +467,11 @@ export type MutationUpdatePeerArgs = { }; +export type MutationVoidLiquidityWithdrawalArgs = { + withdrawalId: Scalars['String']; +}; + + export type MutationWithdrawEventLiquidityArgs = { eventId: Scalars['String']; }; @@ -693,6 +715,20 @@ export type QuoteResponse = { success: Scalars['Boolean']; }; +export type Receiver = { + __typename?: 'Receiver'; + completed: Scalars['Boolean']; + createdAt: Scalars['String']; + description?: Maybe; + expiresAt?: Maybe; + externalRef?: Maybe; + id: Scalars['String']; + incomingAmount?: Maybe; + paymentPointerUrl: Scalars['String']; + receivedAmount: Amount; + updatedAt: Scalars['String']; +}; + export type RevokePaymentPointerKeyMutationResponse = MutationResponse & { __typename?: 'RevokePaymentPointerKeyMutationResponse'; code: Scalars['String']; @@ -830,6 +866,8 @@ export type ResolversTypes = { CreatePeerLiquidityWithdrawalInput: ResolverTypeWrapper>; CreatePeerMutationResponse: ResolverTypeWrapper>; CreateQuoteInput: ResolverTypeWrapper>; + CreateReceiverInput: ResolverTypeWrapper>; + CreateReceiverResponse: ResolverTypeWrapper>; Crv: ResolverTypeWrapper>; DeletePeerMutationResponse: ResolverTypeWrapper>; Float: ResolverTypeWrapper>; @@ -871,6 +909,7 @@ export type ResolversTypes = { QuoteConnection: ResolverTypeWrapper>; QuoteEdge: ResolverTypeWrapper>; QuoteResponse: ResolverTypeWrapper>; + Receiver: ResolverTypeWrapper>; RevokePaymentPointerKeyMutationResponse: ResolverTypeWrapper>; String: ResolverTypeWrapper>; TransferMutationResponse: ResolverTypeWrapper>; @@ -906,6 +945,8 @@ export type ResolversParentTypes = { CreatePeerLiquidityWithdrawalInput: Partial; CreatePeerMutationResponse: Partial; CreateQuoteInput: Partial; + CreateReceiverInput: Partial; + CreateReceiverResponse: Partial; DeletePeerMutationResponse: Partial; Float: Partial; Http: Partial; @@ -942,6 +983,7 @@ export type ResolversParentTypes = { QuoteConnection: Partial; QuoteEdge: Partial; QuoteResponse: Partial; + Receiver: Partial; RevokePaymentPointerKeyMutationResponse: Partial; String: Partial; TransferMutationResponse: Partial; @@ -1012,6 +1054,14 @@ export type CreatePeerMutationResponseResolvers; }; +export type CreateReceiverResponseResolvers = { + code?: Resolver; + message?: Resolver, ParentType, ContextType>; + receiver?: Resolver, ParentType, ContextType>; + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type DeletePeerMutationResponseResolvers = { code?: Resolver; message?: Resolver; @@ -1099,14 +1149,15 @@ export type MutationResolvers>; createPeerLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; createQuote?: Resolver>; + createReceiver?: Resolver>; deletePeer?: Resolver>; depositEventLiquidity?: Resolver, ParentType, ContextType, RequireFields>; - finalizeLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; + postLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; revokePaymentPointerKey?: Resolver, ParentType, ContextType, RequireFields>; - rollbackLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; triggerPaymentPointerEvents?: Resolver>; updateAssetWithdrawalThreshold?: Resolver>; updatePeer?: Resolver>; + voidLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; withdrawEventLiquidity?: Resolver, ParentType, ContextType, RequireFields>; }; @@ -1267,6 +1318,20 @@ export type QuoteResponseResolvers; }; +export type ReceiverResolvers = { + completed?: Resolver; + createdAt?: Resolver; + description?: Resolver, ParentType, ContextType>; + expiresAt?: Resolver, ParentType, ContextType>; + externalRef?: Resolver, ParentType, ContextType>; + id?: Resolver; + incomingAmount?: Resolver, ParentType, ContextType>; + paymentPointerUrl?: Resolver; + receivedAmount?: Resolver; + updatedAt?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type RevokePaymentPointerKeyMutationResponseResolvers = { code?: Resolver; message?: Resolver; @@ -1311,6 +1376,7 @@ export type Resolvers = { CreatePaymentPointerKeyMutationResponse?: CreatePaymentPointerKeyMutationResponseResolvers; CreatePaymentPointerMutationResponse?: CreatePaymentPointerMutationResponseResolvers; CreatePeerMutationResponse?: CreatePeerMutationResponseResolvers; + CreateReceiverResponse?: CreateReceiverResponseResolvers; DeletePeerMutationResponse?: DeletePeerMutationResponseResolvers; Http?: HttpResolvers; HttpOutgoing?: HttpOutgoingResolvers; @@ -1340,6 +1406,7 @@ export type Resolvers = { QuoteConnection?: QuoteConnectionResolvers; QuoteEdge?: QuoteEdgeResolvers; QuoteResponse?: QuoteResponseResolvers; + Receiver?: ReceiverResolvers; RevokePaymentPointerKeyMutationResponse?: RevokePaymentPointerKeyMutationResponseResolvers; TransferMutationResponse?: TransferMutationResponseResolvers; TriggerPaymentPointerEventsMutationResponse?: TriggerPaymentPointerEventsMutationResponseResolvers; diff --git a/packages/mock-account-provider/package.json b/packages/mock-account-provider/package.json index 3826f36521..0df568abbb 100644 --- a/packages/mock-account-provider/package.json +++ b/packages/mock-account-provider/package.json @@ -9,9 +9,9 @@ }, "dependencies": { "@apollo/client": "^3.7.4", - "@remix-run/node": "^1.11.0", - "@remix-run/react": "^1.11.0", - "@remix-run/serve": "^1.11.0", + "@remix-run/node": "^1.11.1", + "@remix-run/react": "^1.11.1", + "@remix-run/serve": "^1.11.1", "@types/node": "^18.7.12", "@types/uuid": "^9.0.0", "axios": "^1.2.3", @@ -23,8 +23,8 @@ "yaml": "^2.2.1" }, "devDependencies": { - "@remix-run/dev": "^1.11.0", - "@remix-run/eslint-config": "^1.11.0", + "@remix-run/dev": "^1.11.1", + "@remix-run/eslint-config": "^1.11.1", "@types/react": "^18.0.27", "@types/react-dom": "^18.0.10", "eslint": "^8.32.0", diff --git a/packages/open-payments/package.json b/packages/open-payments/package.json index e903a02b4a..4ff1993e2d 100644 --- a/packages/open-payments/package.json +++ b/packages/open-payments/package.json @@ -20,17 +20,18 @@ }, "devDependencies": { "@types/node": "^18.7.12", + "@types/uuid": "^9.0.0", "base64url": "^3.0.1", "nock": "^13.3.0", "openapi-typescript": "^4.5.0", - "typescript": "^4.9.4", - "uuid": "^9.0.0" + "typescript": "^4.9.4" }, "dependencies": { "axios": "^1.2.3", "http-message-signatures": "^0.1.2", "http-signature-utils": "workspace:../http-signature-utils", "openapi": "workspace:../openapi", - "pino": "^8.8.0" + "pino": "^8.8.0", + "uuid": "^9.0.0" } } diff --git a/packages/open-payments/src/index.ts b/packages/open-payments/src/index.ts index a24fbcc06a..e4aec97194 100644 --- a/packages/open-payments/src/index.ts +++ b/packages/open-payments/src/index.ts @@ -21,3 +21,19 @@ export { AuthenticatedClient, UnauthenticatedClient } from './client' + +export { + mockILPStreamConnection, + mockPaymentPointer, + mockIncomingPayment, + mockOutgoingPayment, + mockIncomingPaymentPaginationResult, + mockOutgoingPaymentPaginationResult, + mockQuote, + mockJwk, + mockAccessToken, + mockContinuationRequest, + mockGrantRequest, + mockInteractiveGrant, + mockNonInteractiveGrant +} from './test/helpers' diff --git a/packages/open-payments/src/test/helpers.ts b/packages/open-payments/src/test/helpers.ts index 29e7d01aed..3376fa4aa6 100644 --- a/packages/open-payments/src/test/helpers.ts +++ b/packages/open-payments/src/test/helpers.ts @@ -83,7 +83,7 @@ export const mockILPStreamConnection = ( ): ILPStreamConnection => ({ id: uuid(), sharedSecret: base64url('sharedSecret'), - ilpAddress: 'ilpAddress', + ilpAddress: 'test.ilpAddress', assetCode: 'USD', assetScale: 2, ...overrides @@ -92,7 +92,7 @@ export const mockILPStreamConnection = ( export const mockIncomingPayment = ( overrides?: Partial ): IncomingPayment => ({ - id: uuid(), + id: `https://example.com/.well-known/pay/incoming-payments/${uuid()}`, paymentPointer: 'paymentPointer', completed: false, incomingAmount: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63429914bb..123d74e827 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,10 +51,10 @@ importers: '@koa/router': ^12.0.0 '@types/jest': ^29.2.6 '@types/koa': 2.13.5 - '@types/koa-bodyparser': ^4.3.10 - '@types/koa-session': ^5.10.6 '@types/koa__cors': ^3.3.0 '@types/koa__router': ^12.0.0 + '@types/koa-bodyparser': ^4.3.10 + '@types/koa-session': ^5.10.6 '@types/uuid': ^9.0.0 ajv: ^8.12.0 axios: ^1.2.3 @@ -106,10 +106,10 @@ importers: '@faker-js/faker': 7.6.0 '@types/jest': 29.2.6 '@types/koa': 2.13.5 - '@types/koa-bodyparser': 4.3.10 - '@types/koa-session': 5.10.6 '@types/koa__cors': 3.3.0 '@types/koa__router': 12.0.0 + '@types/koa-bodyparser': 4.3.10 + '@types/koa-session': 5.10.6 '@types/uuid': 9.0.0 jest-openapi: 0.14.2 nock: 13.3.0 @@ -135,9 +135,9 @@ importers: '@poppinss/file-generator': ^1.0.2 '@types/jest': ^29.2.6 '@types/koa': 2.13.5 - '@types/koa-bodyparser': ^4.3.10 '@types/koa__cors': ^3.3.0 '@types/koa__router': ^12.0.0 + '@types/koa-bodyparser': ^4.3.10 '@types/luxon': ^3.2.0 '@types/react': ^18.0.27 '@types/rosie': ^0.0.40 @@ -239,9 +239,9 @@ importers: '@graphql-codegen/typescript-resolvers': 2.7.12_graphql@16.6.0 '@types/jest': 29.2.6 '@types/koa': 2.13.5 - '@types/koa-bodyparser': 4.3.10 '@types/koa__cors': 3.3.0 '@types/koa__router': 12.0.0 + '@types/koa-bodyparser': 4.3.10 '@types/luxon': 3.2.0 '@types/react': 18.0.27 '@types/rosie': 0.0.40 @@ -306,11 +306,11 @@ importers: packages/mock-account-provider: specifiers: '@apollo/client': ^3.7.4 - '@remix-run/dev': ^1.11.0 - '@remix-run/eslint-config': ^1.11.0 - '@remix-run/node': ^1.11.0 - '@remix-run/react': ^1.11.0 - '@remix-run/serve': ^1.11.0 + '@remix-run/dev': ^1.11.1 + '@remix-run/eslint-config': ^1.11.1 + '@remix-run/node': ^1.11.1 + '@remix-run/react': ^1.11.1 + '@remix-run/serve': ^1.11.1 '@types/node': ^18.7.12 '@types/react': ^18.0.27 '@types/react-dom': ^18.0.10 @@ -326,9 +326,9 @@ importers: yaml: ^2.2.1 dependencies: '@apollo/client': 3.7.4_gdcq4dv6opitr3wbfwyjmanyra - '@remix-run/node': 1.11.0_biqbaboplfbrettd7655fr4n2y - '@remix-run/react': 1.11.0_biqbaboplfbrettd7655fr4n2y - '@remix-run/serve': 1.11.0_biqbaboplfbrettd7655fr4n2y + '@remix-run/node': 1.11.1_biqbaboplfbrettd7655fr4n2y + '@remix-run/react': 1.11.1_biqbaboplfbrettd7655fr4n2y + '@remix-run/serve': 1.11.1_biqbaboplfbrettd7655fr4n2y '@types/node': 18.7.12 '@types/uuid': 9.0.0 axios: 1.2.3 @@ -339,8 +339,8 @@ importers: uuid: 9.0.0 yaml: 2.2.1 devDependencies: - '@remix-run/dev': 1.11.0_jexbcx4y5yg3hjc5erkuntyhsi - '@remix-run/eslint-config': 1.11.0_wvykjhgtukofhefoe5m2qcm46i + '@remix-run/dev': 1.11.1_kbsvfewv4jwrmiyw4esgzjg2te + '@remix-run/eslint-config': 1.11.1_wvykjhgtukofhefoe5m2qcm46i '@types/react': 18.0.27 '@types/react-dom': 18.0.10 eslint: 8.32.0 @@ -349,6 +349,7 @@ importers: packages/open-payments: specifiers: '@types/node': ^18.7.12 + '@types/uuid': ^9.0.0 axios: ^1.2.3 base64url: ^3.0.1 http-message-signatures: ^0.1.2 @@ -365,13 +366,14 @@ importers: http-signature-utils: link:../http-signature-utils openapi: link:../openapi pino: 8.8.0 + uuid: 9.0.0 devDependencies: '@types/node': 18.7.13 + '@types/uuid': 9.0.0 base64url: 3.0.1 nock: 13.3.0 openapi-typescript: 4.5.0 typescript: 4.9.4 - uuid: 9.0.0 packages/openapi: specifiers: @@ -393,7 +395,7 @@ importers: dependencies: '@apidevtools/json-schema-ref-parser': 9.1.0 ajv: 8.12.0 - ajv-formats: 2.1.1_ajv@8.12.0 + ajv-formats: 2.1.1 openapi-default-setter: 12.1.0 openapi-request-coercer: 12.1.0 openapi-request-validator: 12.1.0 @@ -3670,12 +3672,12 @@ packages: resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} dev: false - /@remix-run/dev/1.11.0_jexbcx4y5yg3hjc5erkuntyhsi: - resolution: {integrity: sha512-3Rj7dNC/Efb6OuDpIqXHDEG/kw5afFz59P+x0Bd6wS6XEiHUwNFocKyzqVWT8Z2CLGKfng1tonA8El9QPtYB+g==} + /@remix-run/dev/1.11.1_kbsvfewv4jwrmiyw4esgzjg2te: + resolution: {integrity: sha512-8N7TRbJtNXKhU4pQwDGdEMDlOIJ1SqQdG1zZoIzU7Q7YfkdAI8/AOtXT7i2X6y5urjijtFUZEY6x8IVNhGAjbg==} engines: {node: '>=14'} hasBin: true peerDependencies: - '@remix-run/serve': ^1.11.0 + '@remix-run/serve': ^1.11.1 peerDependenciesMeta: '@remix-run/serve': optional: true @@ -3691,8 +3693,8 @@ packages: '@babel/types': 7.20.5 '@esbuild-plugins/node-modules-polyfill': 0.1.4_esbuild@0.16.3 '@npmcli/package-json': 2.0.0 - '@remix-run/serve': 1.11.0_biqbaboplfbrettd7655fr4n2y - '@remix-run/server-runtime': 1.11.0_biqbaboplfbrettd7655fr4n2y + '@remix-run/serve': 1.11.1_biqbaboplfbrettd7655fr4n2y + '@remix-run/server-runtime': 1.11.1_biqbaboplfbrettd7655fr4n2y '@vanilla-extract/integration': 6.0.1 arg: 5.0.2 cacache: 15.3.0 @@ -3710,7 +3712,7 @@ packages: inquirer: 8.2.4 jscodeshift: 0.13.1_@babel+preset-env@7.18.10 jsesc: 3.0.2 - json5: 2.2.1 + json5: 2.2.3 lodash: 4.17.21 lodash.debounce: 4.0.8 lru-cache: 7.14.1 @@ -3742,8 +3744,8 @@ packages: - utf-8-validate dev: true - /@remix-run/eslint-config/1.11.0_wvykjhgtukofhefoe5m2qcm46i: - resolution: {integrity: sha512-A9LV+2/bgIY4MnBYwTi9Hb046F7FPRBopFN1T9dufAY+jXxmRXbLOCzZkRNsO6lTAApdSmDxzY00+JvJ5da2ug==} + /@remix-run/eslint-config/1.11.1_wvykjhgtukofhefoe5m2qcm46i: + resolution: {integrity: sha512-mpisTkWBKYklmuIuDhwdy8BAUV5+EJ3Q3cxlSGBXZ+SaddK8WgBZu5C44o/0YZvktFm53rWBH3Afeq0A03yUkQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^8.0.0 @@ -3762,7 +3764,7 @@ packages: eslint: 8.32.0 eslint-import-resolver-node: 0.3.6 eslint-import-resolver-typescript: 3.5.2_kornzrmzxylufiu5zotgc4cisa - eslint-plugin-import: 2.26.0_2l6piu6guil2f63lj3qmhzbnn4 + eslint-plugin-import: 2.26.0_anoc6aiadrrjoh5wuzips7vvmq eslint-plugin-jest: 26.9.0_rjpbdoflvuixsozdbd4eqx6o5m eslint-plugin-jest-dom: 4.0.2_eslint@8.32.0 eslint-plugin-jsx-a11y: 6.6.1_eslint@8.32.0 @@ -3778,23 +3780,23 @@ packages: - supports-color dev: true - /@remix-run/express/1.11.0_nawawpklwqjvxe6axdd7matlce: - resolution: {integrity: sha512-cOXu8Vx2vAUW7aEgOLQay5P/rDc0OqMzvuzJU6YEicS+qEzweqBdUBDjyhAEAqj06fNQHC8YWlvcsKDoO/cQ3w==} + /@remix-run/express/1.11.1_nawawpklwqjvxe6axdd7matlce: + resolution: {integrity: sha512-riT5ooTXWeX52bSTaL5ovCApKRU/W6NjdowfESH4Ur3MSM3FP1/z9K0HBV+mVsAW7zYSOk/vaPX+kJnVZsuhCw==} engines: {node: '>=14'} peerDependencies: express: ^4.17.1 dependencies: - '@remix-run/node': 1.11.0_biqbaboplfbrettd7655fr4n2y + '@remix-run/node': 1.11.1_biqbaboplfbrettd7655fr4n2y express: 4.18.1 transitivePeerDependencies: - react - react-dom - /@remix-run/node/1.11.0_biqbaboplfbrettd7655fr4n2y: - resolution: {integrity: sha512-9tryyo7kBPvKpdThq/uYNERm9meFkP2eDkMW4QOR5sRSlUaMeL6CJwrur7+2ECKC812OhFWV3FJZbnyencOGVg==} + /@remix-run/node/1.11.1_biqbaboplfbrettd7655fr4n2y: + resolution: {integrity: sha512-Lc2mI7Qm26kIB0cyjPC56XTZskfufX1uTTpHsAAYvBooyahhKoBHDLa0skl56cT20mz7dSy+ocYoPel6Eavr2A==} engines: {node: '>=14'} dependencies: - '@remix-run/server-runtime': 1.11.0_biqbaboplfbrettd7655fr4n2y + '@remix-run/server-runtime': 1.11.1_biqbaboplfbrettd7655fr4n2y '@remix-run/web-fetch': 4.3.2 '@remix-run/web-file': 3.0.2 '@remix-run/web-stream': 1.0.3 @@ -3807,8 +3809,8 @@ packages: - react - react-dom - /@remix-run/react/1.11.0_biqbaboplfbrettd7655fr4n2y: - resolution: {integrity: sha512-0aneldZAXwpJ6h5Mil6BxwCm8mRIA4adf+eO+bFPz9njS5JQJ/o7+jx+TgwyAVzmiFdGf0b1km6PrVLq02jQKg==} + /@remix-run/react/1.11.1_biqbaboplfbrettd7655fr4n2y: + resolution: {integrity: sha512-8D+55ygREmGLWuCfqU7DntMgD8EN/5Lk8feO0oF09eFR9tZ5frjjiB3xTiH+4wkyY3vz+cdFxVI/8p9Iyrwd6Q==} engines: {node: '>=14'} peerDependencies: react: '>=16.8' @@ -3825,12 +3827,12 @@ packages: resolution: {integrity: sha512-nwQoYb3m4DDpHTeOwpJEuDt8lWVcujhYYSFGLluC+9es2PyLjm+jjq3IeRBQbwBtPLJE/lkuHuGHr8uQLgmJRA==} engines: {node: '>=14'} - /@remix-run/serve/1.11.0_biqbaboplfbrettd7655fr4n2y: - resolution: {integrity: sha512-fabCgs9UYOx4A3i3erY/ZZy42Zm5F70hc9bgRM6queo7/D+0b9pemQ1GIEDarasLMm564uGbdUNskyByfU7ZyA==} + /@remix-run/serve/1.11.1_biqbaboplfbrettd7655fr4n2y: + resolution: {integrity: sha512-a39vG0DhKIQfjlK9yHX17VOjBw+MyPYtHG7/tJtsE204s93pahZZMM7nBjsUrdx6zjwht8v6uoF4lZTEzzrSwg==} engines: {node: '>=14'} hasBin: true dependencies: - '@remix-run/express': 1.11.0_nawawpklwqjvxe6axdd7matlce + '@remix-run/express': 1.11.1_nawawpklwqjvxe6axdd7matlce compression: 1.7.4 express: 4.18.1 morgan: 1.10.0 @@ -3839,8 +3841,8 @@ packages: - react-dom - supports-color - /@remix-run/server-runtime/1.11.0_biqbaboplfbrettd7655fr4n2y: - resolution: {integrity: sha512-6yP6d5KXRSwMgydKkwXi+B2sqDkCVn0/DgflHz8F0jRxnBn2ZWW1T9QTQdaXO3yL+wI6h4MN++TUbK/yiz40IA==} + /@remix-run/server-runtime/1.11.1_biqbaboplfbrettd7655fr4n2y: + resolution: {integrity: sha512-D0jTiaUSMK0q29CTVL7Bn4SlE6fd4bnp18vxObjSXlA6VYrzDAK3pQ/1baDawPdxBDIQPqImXOQ6g4aAYImfCA==} engines: {node: '>=14'} peerDependencies: react: '>=16.8' @@ -4910,10 +4912,8 @@ packages: resolution: {integrity: sha512-hCOfMzbFx5IDutmWLAt6MZwOUjIfSM9G9FyVxytmE4Rs/5YDPWQrD/+IR1w+FweD9H2oOZEnv36TmkjhNURBVA==} dev: true - /ajv-formats/2.1.1_ajv@8.12.0: + /ajv-formats/2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} - peerDependencies: - ajv: ^8.0.0 peerDependenciesMeta: ajv: optional: true @@ -5059,9 +5059,9 @@ packages: '@koa/cors': 3.4.3 '@types/accepts': 1.3.5 '@types/koa': 2.13.5 + '@types/koa__cors': 3.3.0 '@types/koa-bodyparser': 4.3.10 '@types/koa-compose': 3.2.5 - '@types/koa__cors': 3.3.0 accepts: 1.3.8 apollo-server-core: 3.11.1_graphql@16.6.0 apollo-server-types: 3.7.1_graphql@16.6.0 @@ -6181,8 +6181,8 @@ packages: engines: {node: '>=10'} hasBin: true dependencies: - JSONStream: 1.3.5 is-text-path: 1.0.1 + JSONStream: 1.3.5 lodash: 4.17.21 meow: 8.1.2 split2: 3.2.2 @@ -6277,7 +6277,7 @@ packages: dependencies: '@types/node': 18.11.9 cosmiconfig: 8.0.0 - ts-node: 10.9.1_xurywthbbrjt4i6rf23onsialm + ts-node: 10.9.1_3v2cms72gsblw7jmali7btb3pu typescript: 4.9.4 dev: true @@ -6924,7 +6924,7 @@ packages: debug: 4.3.4 enhanced-resolve: 5.12.0 eslint: 8.32.0 - eslint-plugin-import: 2.26.0_2l6piu6guil2f63lj3qmhzbnn4 + eslint-plugin-import: 2.26.0_anoc6aiadrrjoh5wuzips7vvmq get-tsconfig: 4.2.0 globby: 13.1.3 is-core-module: 2.10.0 @@ -6934,7 +6934,7 @@ packages: - supports-color dev: true - /eslint-module-utils/2.7.4_whphnv3xbr2fd5qrl3vf5jwhzq: + /eslint-module-utils/2.7.4_cq7sa6n5nd5vslxucnjee62qoy: resolution: {integrity: sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA==} engines: {node: '>=4'} peerDependencies: @@ -6959,6 +6959,7 @@ packages: debug: 3.2.7 eslint: 8.32.0 eslint-import-resolver-node: 0.3.6 + eslint-import-resolver-typescript: 3.5.2_kornzrmzxylufiu5zotgc4cisa transitivePeerDependencies: - supports-color dev: true @@ -6974,7 +6975,7 @@ packages: regexpp: 3.2.0 dev: true - /eslint-plugin-import/2.26.0_2l6piu6guil2f63lj3qmhzbnn4: + /eslint-plugin-import/2.26.0_anoc6aiadrrjoh5wuzips7vvmq: resolution: {integrity: sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==} engines: {node: '>=4'} peerDependencies: @@ -6991,7 +6992,7 @@ packages: doctrine: 2.1.0 eslint: 8.32.0 eslint-import-resolver-node: 0.3.6 - eslint-module-utils: 2.7.4_whphnv3xbr2fd5qrl3vf5jwhzq + eslint-module-utils: 2.7.4_cq7sa6n5nd5vslxucnjee62qoy has: 1.0.3 is-core-module: 2.10.0 is-glob: 4.0.3 @@ -9631,12 +9632,6 @@ packages: minimist: 1.2.6 dev: true - /json5/2.2.1: - resolution: {integrity: sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==} - engines: {node: '>=6'} - hasBin: true - dev: true - /json5/2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -11171,7 +11166,7 @@ packages: resolution: {integrity: sha512-WbcX/mrRGq0GBOqf/Rv4X0vY2WUO1ZmOjrWsnRByEIIJ/6LDWVUqlgEnpVEzpDN8Z2/XoK+YPcFnqIalFmn3sA==} dependencies: ajv: 8.12.0 - ajv-formats: 2.1.1_ajv@8.12.0 + ajv-formats: 2.1.1 content-type: 1.0.4 openapi-jsonschema-parameters: 12.1.0 openapi-types: 12.1.0 @@ -11196,7 +11191,7 @@ packages: resolution: {integrity: sha512-5wpFKMoEbUcjiqo16jIen3Cb2+oApSnYZpWn8WQdRO2q/dNQZZl8Pz6ESwCriiyU5AK4i5ZI6+7O3bHQr6+6+g==} dependencies: ajv: 8.12.0 - ajv-formats: 2.1.1_ajv@8.12.0 + ajv-formats: 2.1.1 lodash.merge: 4.6.2 openapi-types: 9.3.1 dev: true