diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 59ed452166..ada3a1a0c9 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -46,7 +46,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -58,7 +58,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # ℹī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -72,4 +72,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 diff --git a/infrastructure/helm/rafiki/Chart.lock b/infrastructure/helm/rafiki/Chart.lock index c7ab7191ff..643e3c6725 100644 --- a/infrastructure/helm/rafiki/Chart.lock +++ b/infrastructure/helm/rafiki/Chart.lock @@ -1,10 +1,10 @@ dependencies: - name: redis repository: https://charts.bitnami.com/bitnami - version: 18.5.0 + version: 18.6.3 - name: postgresql repository: https://charts.bitnami.com/bitnami - version: 13.2.27 + version: 13.2.29 - name: rafiki-auth repository: https://interledger.github.io/helm-charts version: 0.4.0 @@ -14,5 +14,5 @@ dependencies: - name: rafiki-frontend repository: https://interledger.github.io/helm-charts version: 0.4.0 -digest: sha256:82620e588dfa7ea959868a59016b665a42631312cfed2398511c070a128bf589 -generated: "2023-12-31T15:27:19.592448505Z" +digest: sha256:285deeb65d1e6c18459a15037bf716df325bb1e5149daeb80ebe9899b33ed989 +generated: "2024-01-11T00:15:47.075431532Z" diff --git a/infrastructure/helm/rafiki/Chart.yaml b/infrastructure/helm/rafiki/Chart.yaml index 3bc983cf40..f31982fa4f 100644 --- a/infrastructure/helm/rafiki/Chart.yaml +++ b/infrastructure/helm/rafiki/Chart.yaml @@ -25,10 +25,10 @@ appVersion: 'v1.0.0-alpha.1' dependencies: - name: redis - version: "18.5.0" + version: "18.6.3" repository: "https://charts.bitnami.com/bitnami" - name: postgresql - version: '13.2.27' + version: '13.2.29' repository: 'https://charts.bitnami.com/bitnami' - name: rafiki-auth version: '0.4.0' diff --git a/infrastructure/terraform/rafiki-test/.terraform.lock.hcl b/infrastructure/terraform/rafiki-test/.terraform.lock.hcl index 903406e2ec..7ea25c87da 100644 --- a/infrastructure/terraform/rafiki-test/.terraform.lock.hcl +++ b/infrastructure/terraform/rafiki-test/.terraform.lock.hcl @@ -2,31 +2,31 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/google" { - version = "5.9.0" - constraints = "~> 5.9.0" + version = "5.11.0" + constraints = "~> 5.11.0" hashes = [ - "h1:+ZgEduXHzK0mmlOwu/3LfxsizdzB7+HIpm7oPUCFYGM=", - "h1:0yN1anKU3Gl5j9J1btUS79Xb+kV0vfFOWkN08K0Nppk=", - "h1:9Xs5x03PD0CbTqCAUlLh7DoHl9xN4UPP9bmdj+U0xAM=", - "h1:FeK6r+kWbwknaukvBW0/tpYVd5sptW+LS5rtSNhSYW4=", - "h1:MMg/VGi8FZ2OtnenEgoFaEXRxb7PD0h4mqcE/MRb2Kg=", - "h1:MRlR31CZOOdL90X0zsSu+HUc4UXt/xMf51AtxSd5pPA=", - "h1:evxfNy7BXlzQuEHqajUbI3bb8zowsqPQH0snIFym7Dc=", - "h1:eydN7F/AJbTcH0ZixOPElwDLr23S0vLjNfb3v+Li69Y=", - "h1:jG9wcaMKIuI8JSf8T+SjAcw5vhEpW/fnFfPjSXMbuEY=", - "h1:wHvXetVyTbigvNMZGVOalZN+41CNg7KDzzZAQXnNA3M=", - "h1:wsIaG0E/QatJ7w+OuGoaqb1h14V9sNNbXIX81/13hfc=", - "zh:19c618c257b2d9e30a0978b1282b1e418748323ae74d9c1ad63a858cb159cd86", - "zh:2c1f18b6714062fe8eab633918b41c622423693f2a4fd747dc516f3578b8e738", - "zh:440b31f85e2d823919639c4d248a058cd90020724a2fa543546e36611ca18df4", - "zh:453edfad0fbd30e6d694f1b38cc9d5f0b8ec356bbce3f2919f1c4622518c46ca", - "zh:47965b68bf9afd2f6a7412792083911d22b6a1a17f0052c9a8329b5ade47bbe9", - "zh:5621990ad07b8cd9af6862f7a66b593b19bbdf20986d7c8cfd8948302810de51", - "zh:74e2380a9acceb552d067697c38b4679e950fc2ba4bf47025d8917910b08df3d", - "zh:a588be4fa16331c406a15e784d419a04e995741ed09eb2e14ec58b53f3ecd8cc", - "zh:a60af7611f69b76ff727ee569b1ebefee82a5e5e1f1809d2df04286ee2c0aa4e", - "zh:c15d781c9a198d343201eb1a4bc17c616ca8cb38bb33739c3e138db06022a171", - "zh:d5c15eeb3be0e01b17ed67ab9b52137480139816edd7e90e93643be57564d2d0", + "h1:+b7zhAsyfgJ+xIBZ+BHzK3oHYJl7hZnnJqOSC/1M9Z0=", + "h1:2t2M7w1yLULtKjvbbciHvBgrIpajMkaESnPEw44tmuQ=", + "h1:4b8NIybn+VToq8g7NLE5lJnKuDtTjNZduwH2EZLkpfw=", + "h1:CCUe8VSZd/xr8QMvoZUWoXuP7Wp5tBBOr+3UuEueUo0=", + "h1:Ezg3fsY84CB/2P00ZwQEECuIfJd6UUYs5tIptN2kzsE=", + "h1:FV7t+G3+rJD3aN5Yr+FY8/cDG+FKhFCt8XvLJkqCcY8=", + "h1:HCBDx61bxHTo3br0WmzdC3+VlnOyhh9X0u5OboGHjYA=", + "h1:RBxlZNZH96B07pEwugus9gmrJ34WHIdBBRoqJWkoJrU=", + "h1:TppGVYFyU3eisUCy4Y9ZemHNu/h0hrBgTUrwKePUoxg=", + "h1:Y+eSGZVn4TuUOU8Xa0UMYUxESGPIZqzVJ20uhhO//ZM=", + "h1:Zc3PGbyxjTPO2WhrNTtdiFkSUc7E9qpv1D1w+jLEBio=", + "zh:444815a900947de3cb4e3aac48bf8cd98009130c110e3cee1e72698536046fee", + "zh:45ca22a2f44fe67f9ff71528dcd93493281e34bff7791f5eb24c86e76f32956d", + "zh:53e2e33824743e9e620454438de803de10572bd79ce16034abfc91ab1877be7a", + "zh:5eb699830a07320f896a3da7cdee169ab5fa356a6d38858b8b9337f1e4e30904", + "zh:6837cd8d9d63503e138ec3ebf52f850ca786824a3b0d5b9dfecec303f1656ca6", + "zh:7adde1fe2fc8966812bcbfeb24580cbb53f2f5301bd793eaa70ad753ba6b2d3c", + "zh:92052fd7ec776cd221f19db4624ae4ed1550c95c2984c9f3b6c54cea8896812b", + "zh:b0305aab81220b7d5711225224f5baad8fc6f5dd3a8199073966af8a151e2932", + "zh:e7b5aa624d89664803dd545f261261806b7f6607c19f6ceaf61f9011b0e02e63", "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:fbc04244e1f666ce0320b4eb0efb9cae460a5d688fc039637c8fe745665c19e5", + "zh:ff3553298929629ae2ad77000b3e050394e2f00c04e90a24268e3dfe6a6342c4", ] } diff --git a/infrastructure/terraform/rafiki-test/backend.tf b/infrastructure/terraform/rafiki-test/backend.tf index 7c5755187b..d166e3fe1f 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 = "~> 5.9.0" + version = "~> 5.11.0" } } backend "gcs" { diff --git a/localenv/cloud-nine-wallet/seed.yml b/localenv/cloud-nine-wallet/seed.yml index c30eeb475f..763398e96c 100644 --- a/localenv/cloud-nine-wallet/seed.yml +++ b/localenv/cloud-nine-wallet/seed.yml @@ -19,6 +19,7 @@ assets: scale: 0 liquidity: 1000000 liquidityThreshold: 100000 +peeringAsset: 'USD' peers: - initialLiquidity: '10000000' peerUrl: http://happy-life-bank-backend:3002 diff --git a/localenv/docs/peer-setup.md b/localenv/docs/peer-setup.md index 26d2bde2b7..70d34460df 100644 --- a/localenv/docs/peer-setup.md +++ b/localenv/docs/peer-setup.md @@ -140,13 +140,13 @@ Example Successful Response } ``` -Next, run the following query to add liquidity for the secondary instance +Next, run the following query to deposit liquidity for the secondary instance Query: ``` -mutation AddPeerLiquidity ($input: AddPeerLiquidityInput!) { - addPeerLiquidity(input: $input) { +mutation DepositPeerLiquidity ($input: DepositPeerLiquidityInput!) { + depositPeerLiquidity(input: $input) { code success message @@ -172,10 +172,10 @@ Example successful response: ``` { "data": { - "addPeerLiquidity": { + "depositPeerLiquidity": { "code": "200", "success": true, - "message": "Added peer liquidity", + "message": "Deposited peer liquidity", "error": null } } @@ -296,13 +296,13 @@ Example successful response: } ``` -Next, run the following query to add liquidity for the primary instance +Next, run the following query to deposit liquidity for the primary instance Query: ``` -mutation AddPeerLiquidity ($input: AddPeerLiquidityInput!) { - addPeerLiquidity(input: $input) { +mutation DepositPeerLiquidity ($input: DepositPeerLiquidityInput!) { + depositPeerLiquidity(input: $input) { code success message @@ -328,10 +328,10 @@ Example successful response: ``` { "data": { - "addPeerLiquidity": { + "depositPeerLiquidity": { "code": "200", "success": true, - "message": "Added peer liquidity", + "message": "Deposited peer liquidity", "error": null } } diff --git a/localenv/happy-life-bank/seed.yml b/localenv/happy-life-bank/seed.yml index c3e147f2d1..1289d7cac8 100644 --- a/localenv/happy-life-bank/seed.yml +++ b/localenv/happy-life-bank/seed.yml @@ -19,6 +19,7 @@ assets: scale: 0 liquidity: 1000000000 liquidityThreshold: 1000000 +peeringAsset: 'USD' peers: - initialLiquidity: '1000000000000' peerUrl: http://cloud-nine-wallet-backend:3002 diff --git a/localenv/mock-account-servicing-entity/app/lib/parse_config.server.ts b/localenv/mock-account-servicing-entity/app/lib/parse_config.server.ts index c8d64d8e8f..ab1fdab20a 100644 --- a/localenv/mock-account-servicing-entity/app/lib/parse_config.server.ts +++ b/localenv/mock-account-servicing-entity/app/lib/parse_config.server.ts @@ -45,6 +45,7 @@ export interface Fee { export interface SeedInstance { self: Self assets: Array + peeringAsset: string peers: Array accounts: Array fees: Array diff --git a/localenv/mock-account-servicing-entity/app/lib/requesters.ts b/localenv/mock-account-servicing-entity/app/lib/requesters.ts index 36b9e422c6..3ebde490af 100644 --- a/localenv/mock-account-servicing-entity/app/lib/requesters.ts +++ b/localenv/mock-account-servicing-entity/app/lib/requesters.ts @@ -142,12 +142,12 @@ export async function createAutoPeer( } ` - const addedLiquidity = '10000' as unknown as bigint + const liquidityToDeposit = '10000' as unknown as bigint const createPeerInput: { input: CreateOrUpdatePeerByUrlInput } = { input: { peerUrl, assetId, - addedLiquidity + liquidityToDeposit } } return apolloClient @@ -164,14 +164,14 @@ export async function createAutoPeer( }) } -export async function addPeerLiquidity( +export async function depositPeerLiquidity( peerId: string, amount: string, transferUid: string ): Promise { - const addPeerLiquidityMutation = gql` - mutation AddPeerLiquidity($input: AddPeerLiquidityInput!) { - addPeerLiquidity(input: $input) { + const depositPeerLiquidityMutation = gql` + mutation DepositPeerLiquidity($input: DepositPeerLiquidityInput!) { + depositPeerLiquidity(input: $input) { code success message @@ -179,7 +179,7 @@ export async function addPeerLiquidity( } } ` - const addPeerLiquidityInput = { + const depositPeerLiquidityInput = { input: { peerId: peerId, amount: amount, @@ -189,26 +189,26 @@ export async function addPeerLiquidity( } return apolloClient .mutate({ - mutation: addPeerLiquidityMutation, - variables: addPeerLiquidityInput + mutation: depositPeerLiquidityMutation, + variables: depositPeerLiquidityInput }) .then(({ data }): LiquidityMutationResponse => { console.log(data) - if (!data.addPeerLiquidity.success) { + if (!data.depositPeerLiquidity.success) { throw new Error('Data was empty') } - return data.addPeerLiquidity + return data.depositPeerLiquidity }) } -export async function addAssetLiquidity( +export async function depositAssetLiquidity( assetId: string, amount: number, transferId: string ): Promise { - const addAssetLiquidityMutation = gql` - mutation AddAssetLiquidity($input: AddAssetLiquidityInput!) { - addAssetLiquidity(input: $input) { + const depositAssetLiquidityMutation = gql` + mutation DepositAssetLiquidity($input: DepositAssetLiquidityInput!) { + depositAssetLiquidity(input: $input) { code success message @@ -216,7 +216,7 @@ export async function addAssetLiquidity( } } ` - const addAssetLiquidityInput = { + const depositAssetLiquidityInput = { input: { assetId, amount, @@ -226,15 +226,15 @@ export async function addAssetLiquidity( } return apolloClient .mutate({ - mutation: addAssetLiquidityMutation, - variables: addAssetLiquidityInput + mutation: depositAssetLiquidityMutation, + variables: depositAssetLiquidityInput }) .then(({ data }): LiquidityMutationResponse => { console.log(data) - if (!data.addAssetLiquidity.success) { + if (!data.depositAssetLiquidity.success) { throw new Error('Data was empty') } - return data.addAssetLiquidity + return data.depositAssetLiquidity }) } diff --git a/localenv/mock-account-servicing-entity/app/lib/run_seed.server.ts b/localenv/mock-account-servicing-entity/app/lib/run_seed.server.ts index f37df37d0e..c98d8107ef 100644 --- a/localenv/mock-account-servicing-entity/app/lib/run_seed.server.ts +++ b/localenv/mock-account-servicing-entity/app/lib/run_seed.server.ts @@ -7,11 +7,11 @@ import { import { createAsset, createPeer, - addPeerLiquidity, + depositPeerLiquidity, createWalletAddress, createWalletAddressKey, setFee, - addAssetLiquidity, + depositAssetLiquidity, createAutoPeer } from './requesters' import { v4 } from 'uuid' @@ -28,10 +28,14 @@ export async function setupFromSeed(config: Config): Promise { throw new Error('asset not defined') } - const addedLiquidity = await addAssetLiquidity(asset.id, liquidity, v4()) + const initialLiquidity = await depositAssetLiquidity( + asset.id, + liquidity, + v4() + ) assets[code] = asset - console.log(JSON.stringify({ asset, addedLiquidity }, null, 2)) + console.log(JSON.stringify({ asset, initialLiquidity }, null, 2)) const { fees } = config.seed const fee = fees.find((fee) => fee.asset === code && fee.scale == scale) @@ -40,43 +44,43 @@ export async function setupFromSeed(config: Config): Promise { } } - for (const asset of Object.values(assets)) { - const peerResponses = await Promise.all( - config.seed.peers.map(async (peer: Peering) => { - const peerResponse = await createPeer( - peer.peerIlpAddress, - peer.peerUrl, - asset.id, - asset.code, - peer.name, - peer.liquidityThreshold - ).then((response) => response.peer) - if (!peerResponse) { - throw new Error('peer response not defined') - } - const transferUid = v4() - const liquidity = await addPeerLiquidity( - peerResponse.id, - peer.initialLiquidity, - transferUid - ) - return [peerResponse, liquidity] - }) - ) + const peeringAsset = config.seed.peeringAsset - console.log(JSON.stringify(peerResponses, null, 2)) + const peerResponses = await Promise.all( + config.seed.peers.map(async (peer: Peering) => { + const peerResponse = await createPeer( + peer.peerIlpAddress, + peer.peerUrl, + assets[peeringAsset].id, + assets[peeringAsset].code, + peer.name, + peer.liquidityThreshold + ).then((response) => response.peer) + if (!peerResponse) { + throw new Error('peer response not defined') + } + const transferUid = v4() + const liquidity = await depositPeerLiquidity( + peerResponse.id, + peer.initialLiquidity, + transferUid + ) + return [peerResponse, liquidity] + }) + ) - if (CONFIG.testnetAutoPeerUrl) { - console.log('autopeering url: ', CONFIG.testnetAutoPeerUrl) - const autoPeerResponse = await createAutoPeer( - CONFIG.testnetAutoPeerUrl, - asset.id - ).catch((e) => { - console.log('error on autopeering: ', e) - return - }) - console.log(JSON.stringify(autoPeerResponse, null, 2)) - } + console.log(JSON.stringify(peerResponses, null, 2)) + + if (CONFIG.testnetAutoPeerUrl) { + console.log('autopeering url: ', CONFIG.testnetAutoPeerUrl) + const autoPeerResponse = await createAutoPeer( + CONFIG.testnetAutoPeerUrl, + assets[peeringAsset].id + ).catch((e) => { + console.log('error on autopeering: ', e) + return + }) + console.log(JSON.stringify(autoPeerResponse, null, 2)) } // Clear the accounts before seeding. diff --git a/localenv/mock-account-servicing-entity/app/lib/webhooks.server.ts b/localenv/mock-account-servicing-entity/app/lib/webhooks.server.ts index 2d436d90bc..7caee31618 100644 --- a/localenv/mock-account-servicing-entity/app/lib/webhooks.server.ts +++ b/localenv/mock-account-servicing-entity/app/lib/webhooks.server.ts @@ -5,8 +5,8 @@ import { mockAccounts } from './accounts.server' import { apolloClient } from './apolloClient' import { v4 as uuid } from 'uuid' import { - addAssetLiquidity, - addPeerLiquidity, + depositAssetLiquidity, + depositPeerLiquidity, createWalletAddress } from './requesters' @@ -93,8 +93,10 @@ export async function handleOutgoingPaymentCreated(wh: WebHook) { await apolloClient .mutate({ mutation: gql` - mutation DepositEventLiquidity($input: DepositEventLiquidityInput!) { - depositEventLiquidity(input: $input) { + mutation DepositOutgoingPaymentLiquidity( + $input: DepositOutgoingPaymentLiquidityInput! + ) { + depositOutgoingPaymentLiquidity(input: $input) { code success message @@ -104,14 +106,14 @@ export async function handleOutgoingPaymentCreated(wh: WebHook) { `, variables: { input: { - eventId: wh.id, + outgoingPaymentId: payment.id, idempotencyKey: uuid() } } }) .then((query): LiquidityMutationResponse => { if (query.data) { - return query.data.depositEventLiquidity + return query.data.depositOutgoingPaymentLiquidity } else { throw new Error('Data was empty') } @@ -143,8 +145,10 @@ export async function handleIncomingPaymentCompletedExpired(wh: WebHook) { await apolloClient .mutate({ mutation: gql` - mutation WithdrawEventLiquidity($input: WithdrawEventLiquidityInput!) { - withdrawEventLiquidity(input: $input) { + mutation WithdrawIncomingPaymentLiquidity( + $input: WithdrawIncomingPaymentLiquidityInput! + ) { + withdrawIncomingPaymentLiquidity(input: $input) { code success message @@ -154,14 +158,14 @@ export async function handleIncomingPaymentCompletedExpired(wh: WebHook) { `, variables: { input: { - eventId: wh.id, + incomingPaymentId: payment.id, idempotencyKey: uuid() } } }) .then((query): LiquidityMutationResponse => { if (query.data) { - return query.data.withdrawEventLiquidity + return query.data.withdrawIncomingPaymentLiquidity } else { throw new Error('Data was empty') } @@ -206,8 +210,8 @@ export async function handleLowLiquidity(wh: WebHook) { } if (wh.type == 'asset.liquidity_low') { - await addAssetLiquidity(id, 1000000, uuid()) + await depositAssetLiquidity(id, 1000000, uuid()) } else { - await addPeerLiquidity(id, '1000000', uuid()) + await depositPeerLiquidity(id, '1000000', uuid()) } } diff --git a/localenv/mock-account-servicing-entity/generated/graphql.ts b/localenv/mock-account-servicing-entity/generated/graphql.ts index d8e42e4538..f5f4003911 100644 --- a/localenv/mock-account-servicing-entity/generated/graphql.ts +++ b/localenv/mock-account-servicing-entity/generated/graphql.ts @@ -19,28 +19,6 @@ export type Scalars = { UInt64: { input: bigint; output: bigint; } }; -export type AddAssetLiquidityInput = { - /** Amount of liquidity to add. */ - amount: Scalars['UInt64']['input']; - /** The id of the asset to add liquidity. */ - assetId: Scalars['String']['input']; - /** The id of the transfer. */ - id: Scalars['String']['input']; - /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ - idempotencyKey: Scalars['String']['input']; -}; - -export type AddPeerLiquidityInput = { - /** Amount of liquidity to add. */ - amount: Scalars['UInt64']['input']; - /** The id of the transfer. */ - id: Scalars['String']['input']; - /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ - idempotencyKey: Scalars['String']['input']; - /** The id of the peer to add liquidity. */ - peerId: Scalars['String']['input']; -}; - export enum Alg { EdDsa = 'EdDSA' } @@ -160,14 +138,14 @@ export type CreateIncomingPaymentInput = { }; export type CreateOrUpdatePeerByUrlInput = { - /** Initial amount of liquidity to add for peer */ - addedLiquidity?: InputMaybe; /** Asset id of peering relationship */ assetId: Scalars['String']['input']; /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ idempotencyKey?: InputMaybe; /** Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this value */ liquidityThreshold?: InputMaybe; + /** Amount of liquidity to deposit for peer */ + liquidityToDeposit?: InputMaybe; /** Maximum packet amount that the peer accepts */ maxPacketAmount?: InputMaybe; /** Peer's internal name for overriding auto-peer's default naming */ @@ -202,7 +180,7 @@ export type CreatePeerInput = { http: HttpInput; /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ idempotencyKey?: InputMaybe; - /** Initial amount of liquidity to add for peer */ + /** Initial amount of liquidity to deposit for peer */ initialLiquidity?: InputMaybe; /** Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this value */ liquidityThreshold?: InputMaybe; @@ -328,6 +306,17 @@ export type DeletePeerMutationResponse = MutationResponse & { success: Scalars['Boolean']['output']; }; +export type DepositAssetLiquidityInput = { + /** Amount of liquidity to deposit. */ + amount: Scalars['UInt64']['input']; + /** The id of the asset to deposit liquidity. */ + assetId: Scalars['String']['input']; + /** The id of the transfer. */ + id: Scalars['String']['input']; + /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ + idempotencyKey: Scalars['String']['input']; +}; + export type DepositEventLiquidityInput = { /** The id of the event to deposit into. */ eventId: Scalars['String']['input']; @@ -335,6 +324,24 @@ export type DepositEventLiquidityInput = { idempotencyKey: Scalars['String']['input']; }; +export type DepositOutgoingPaymentLiquidityInput = { + /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ + idempotencyKey: Scalars['String']['input']; + /** The id of the outgoing payment to deposit into. */ + outgoingPaymentId: Scalars['String']['input']; +}; + +export type DepositPeerLiquidityInput = { + /** Amount of liquidity to deposit. */ + amount: Scalars['UInt64']['input']; + /** The id of the transfer. */ + id: Scalars['String']['input']; + /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ + idempotencyKey: Scalars['String']['input']; + /** The id of the peer to deposit liquidity. */ + peerId: Scalars['String']['input']; +}; + export type Fee = Model & { __typename?: 'Fee'; /** Asset id associated with the fee */ @@ -424,6 +431,8 @@ export type IncomingPayment = BasePayment & Model & { id: Scalars['ID']['output']; /** The maximum amount that should be paid into the wallet address under this incoming payment. */ incomingAmount?: Maybe; + /** Available liquidity */ + liquidity?: Maybe; /** Additional metadata associated with the incoming payment. */ metadata?: Maybe; /** The total amount that has been paid into the wallet address under this incoming payment. */ @@ -526,10 +535,6 @@ export type Model = { export type Mutation = { __typename?: 'Mutation'; - /** Add asset liquidity */ - addAssetLiquidity?: Maybe; - /** Add peer liquidity */ - addPeerLiquidity?: Maybe; /** Create an asset */ createAsset: AssetMutationResponse; /** Withdraw asset liquidity */ @@ -556,8 +561,17 @@ export type Mutation = { createWalletAddressWithdrawal?: Maybe; /** Delete a peer */ deletePeer: DeletePeerMutationResponse; - /** Deposit webhook event liquidity */ + /** Deposit asset liquidity */ + depositAssetLiquidity?: Maybe; + /** + * Deposit webhook event liquidity + * @deprecated Use `depositOutgoingPaymentLiquidity` + */ depositEventLiquidity?: Maybe; + /** Deposit outgoing payment liquidity */ + depositOutgoingPaymentLiquidity?: Maybe; + /** Deposit peer liquidity */ + depositPeerLiquidity?: Maybe; /** Post liquidity withdrawal. Withdrawals are two-phase commits and are committed via this mutation. */ postLiquidityWithdrawal?: Maybe; /** Revoke a public key associated with a wallet address. Open Payment requests using this key for request signatures will be denied going forward. */ @@ -574,18 +588,15 @@ export type Mutation = { updateWalletAddress: UpdateWalletAddressMutationResponse; /** Void liquidity withdrawal. Withdrawals are two-phase commits and are rolled back via this mutation. */ voidLiquidityWithdrawal?: Maybe; - /** Withdraw webhook event liquidity */ + /** + * Withdraw webhook event liquidity + * @deprecated Use `withdrawOutgoingPaymentLiquidity, withdrawIncomingPaymentLiquidity, or createWalletAddressWithdrawal` + */ withdrawEventLiquidity?: Maybe; -}; - - -export type MutationAddAssetLiquidityArgs = { - input: AddAssetLiquidityInput; -}; - - -export type MutationAddPeerLiquidityArgs = { - input: AddPeerLiquidityInput; + /** Withdraw incoming payment liquidity */ + withdrawIncomingPaymentLiquidity?: Maybe; + /** Withdraw outgoing payment liquidity */ + withdrawOutgoingPaymentLiquidity?: Maybe; }; @@ -654,11 +665,26 @@ export type MutationDeletePeerArgs = { }; +export type MutationDepositAssetLiquidityArgs = { + input: DepositAssetLiquidityInput; +}; + + export type MutationDepositEventLiquidityArgs = { input: DepositEventLiquidityInput; }; +export type MutationDepositOutgoingPaymentLiquidityArgs = { + input: DepositOutgoingPaymentLiquidityInput; +}; + + +export type MutationDepositPeerLiquidityArgs = { + input: DepositPeerLiquidityInput; +}; + + export type MutationPostLiquidityWithdrawalArgs = { input: PostLiquidityWithdrawalInput; }; @@ -703,6 +729,16 @@ export type MutationWithdrawEventLiquidityArgs = { input: WithdrawEventLiquidityInput; }; + +export type MutationWithdrawIncomingPaymentLiquidityArgs = { + input: WithdrawIncomingPaymentLiquidityInput; +}; + + +export type MutationWithdrawOutgoingPaymentLiquidityArgs = { + input: WithdrawOutgoingPaymentLiquidityInput; +}; + export type MutationResponse = { code: Scalars['String']['output']; message: Scalars['String']['output']; @@ -718,6 +754,8 @@ export type OutgoingPayment = BasePayment & Model & { error?: Maybe; /** Outgoing payment id */ id: Scalars['ID']['output']; + /** Available liquidity */ + liquidity?: Maybe; /** Additional metadata associated with the outgoing payment. */ metadata?: Maybe; /** Quote for this outgoing payment */ @@ -784,6 +822,8 @@ export type Payment = BasePayment & Model & { createdAt: Scalars['String']['output']; /** Payment id */ id: Scalars['ID']['output']; + /** Available liquidity */ + liquidity?: Maybe; /** Additional metadata associated with the payment. */ metadata?: Maybe; /** Either the IncomingPaymentState or OutgoingPaymentState according to type */ @@ -1171,6 +1211,8 @@ export type WalletAddress = Model & { id: Scalars['ID']['output']; /** List of incoming payments received by this wallet address */ incomingPayments?: Maybe; + /** Available liquidity */ + liquidity?: Maybe; /** List of outgoing payments sent from this wallet address */ outgoingPayments?: Maybe; /** Public name associated with the wallet address */ @@ -1297,6 +1339,20 @@ export type WithdrawEventLiquidityInput = { idempotencyKey: Scalars['String']['input']; }; +export type WithdrawIncomingPaymentLiquidityInput = { + /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ + idempotencyKey: Scalars['String']['input']; + /** The id of the incoming payment to withdraw from. */ + incomingPaymentId: Scalars['String']['input']; +}; + +export type WithdrawOutgoingPaymentLiquidityInput = { + /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ + idempotencyKey: Scalars['String']['input']; + /** The id of the outgoing payment to withdraw from. */ + outgoingPaymentId: Scalars['String']['input']; +}; + export type ResolverTypeWrapper = Promise | T; @@ -1374,8 +1430,6 @@ export type ResolversInterfaceTypes> = { /** Mapping between all available schema types and the resolvers types */ export type ResolversTypes = { - AddAssetLiquidityInput: ResolverTypeWrapper>; - AddPeerLiquidityInput: ResolverTypeWrapper>; Alg: ResolverTypeWrapper>; Amount: ResolverTypeWrapper>; AmountInput: ResolverTypeWrapper>; @@ -1405,7 +1459,10 @@ export type ResolversTypes = { Crv: ResolverTypeWrapper>; DeletePeerInput: ResolverTypeWrapper>; DeletePeerMutationResponse: ResolverTypeWrapper>; + DepositAssetLiquidityInput: ResolverTypeWrapper>; DepositEventLiquidityInput: ResolverTypeWrapper>; + DepositOutgoingPaymentLiquidityInput: ResolverTypeWrapper>; + DepositPeerLiquidityInput: ResolverTypeWrapper>; Fee: ResolverTypeWrapper>; FeeDetails: ResolverTypeWrapper>; FeeEdge: ResolverTypeWrapper>; @@ -1484,12 +1541,12 @@ export type ResolversTypes = { WebhookEventsConnection: ResolverTypeWrapper>; WebhookEventsEdge: ResolverTypeWrapper>; WithdrawEventLiquidityInput: ResolverTypeWrapper>; + WithdrawIncomingPaymentLiquidityInput: ResolverTypeWrapper>; + WithdrawOutgoingPaymentLiquidityInput: ResolverTypeWrapper>; }; /** Mapping between all available schema types and the resolvers parents */ export type ResolversParentTypes = { - AddAssetLiquidityInput: Partial; - AddPeerLiquidityInput: Partial; Amount: Partial; AmountInput: Partial; Asset: Partial; @@ -1517,7 +1574,10 @@ export type ResolversParentTypes = { CreateWalletAddressWithdrawalInput: Partial; DeletePeerInput: Partial; DeletePeerMutationResponse: Partial; + DepositAssetLiquidityInput: Partial; DepositEventLiquidityInput: Partial; + DepositOutgoingPaymentLiquidityInput: Partial; + DepositPeerLiquidityInput: Partial; Fee: Partial; FeeDetails: Partial; FeeEdge: Partial; @@ -1588,6 +1648,8 @@ export type ResolversParentTypes = { WebhookEventsConnection: Partial; WebhookEventsEdge: Partial; WithdrawEventLiquidityInput: Partial; + WithdrawIncomingPaymentLiquidityInput: Partial; + WithdrawOutgoingPaymentLiquidityInput: Partial; }; export type AmountResolvers = { @@ -1724,6 +1786,7 @@ export type IncomingPaymentResolvers; id?: Resolver; incomingAmount?: Resolver, ParentType, ContextType>; + liquidity?: Resolver, ParentType, ContextType>; metadata?: Resolver, ParentType, ContextType>; receivedAmount?: Resolver; state?: Resolver; @@ -1779,8 +1842,6 @@ export type ModelResolvers = { - addAssetLiquidity?: Resolver, ParentType, ContextType, RequireFields>; - addPeerLiquidity?: Resolver, ParentType, ContextType, RequireFields>; createAsset?: Resolver>; createAssetLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; createIncomingPayment?: Resolver>; @@ -1794,7 +1855,10 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; createWalletAddressWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; deletePeer?: Resolver>; + depositAssetLiquidity?: Resolver, ParentType, ContextType, RequireFields>; depositEventLiquidity?: Resolver, ParentType, ContextType, RequireFields>; + depositOutgoingPaymentLiquidity?: Resolver, ParentType, ContextType, RequireFields>; + depositPeerLiquidity?: Resolver, ParentType, ContextType, RequireFields>; postLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; revokeWalletAddressKey?: Resolver, ParentType, ContextType, RequireFields>; setFee?: Resolver>; @@ -1804,6 +1868,8 @@ export type MutationResolvers>; voidLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; withdrawEventLiquidity?: Resolver, ParentType, ContextType, RequireFields>; + withdrawIncomingPaymentLiquidity?: Resolver, ParentType, ContextType, RequireFields>; + withdrawOutgoingPaymentLiquidity?: Resolver, ParentType, ContextType, RequireFields>; }; export type MutationResponseResolvers = { @@ -1818,6 +1884,7 @@ export type OutgoingPaymentResolvers; error?: Resolver, ParentType, ContextType>; id?: Resolver; + liquidity?: Resolver, ParentType, ContextType>; metadata?: Resolver, ParentType, ContextType>; quote?: Resolver, ParentType, ContextType>; receiveAmount?: Resolver; @@ -1860,6 +1927,7 @@ export type PageInfoResolvers = { createdAt?: Resolver; id?: Resolver; + liquidity?: Resolver, ParentType, ContextType>; metadata?: Resolver, ParentType, ContextType>; state?: Resolver; type?: Resolver; @@ -2027,6 +2095,7 @@ export type WalletAddressResolvers; id?: Resolver; incomingPayments?: Resolver, ParentType, ContextType, Partial>; + liquidity?: Resolver, ParentType, ContextType>; outgoingPayments?: Resolver, ParentType, ContextType, Partial>; publicName?: Resolver, ParentType, ContextType>; quotes?: Resolver, ParentType, ContextType, Partial>; diff --git a/localenv/mock-account-servicing-entity/package.json b/localenv/mock-account-servicing-entity/package.json index eadfa79a36..b249454e1f 100644 --- a/localenv/mock-account-servicing-entity/package.json +++ b/localenv/mock-account-servicing-entity/package.json @@ -7,7 +7,7 @@ "start": "remix-serve build" }, "dependencies": { - "@apollo/client": "^3.8.8", + "@apollo/client": "^3.8.9", "@interledger/http-signature-utils": "2.0.0", "@remix-run/node": "^1.19.3", "@remix-run/react": "^1.19.3", diff --git a/localenv/mock-account-servicing-entity/seed.example.yml b/localenv/mock-account-servicing-entity/seed.example.yml index 936c511c6d..e0a89cee1d 100644 --- a/localenv/mock-account-servicing-entity/seed.example.yml +++ b/localenv/mock-account-servicing-entity/seed.example.yml @@ -11,6 +11,7 @@ assets: scale: 2 liquidity: 10000 liquidityThreshold: 1000 +peeringAsset: 'USD' peers: - initialLiquidity: '100000' peerUrl: http://peer-backend:3002 diff --git a/package.json b/package.json index ee777b0c3f..ebb1b0314f 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,10 @@ "license": "Apache-2.0", "repository": "https://github.com/interledger/rafiki", "engines": { - "pnpm": "^8.14.0", + "pnpm": "^8.14.1", "node": "18" }, - "packageManager": "pnpm@8.14.0", + "packageManager": "pnpm@8.14.1", "scripts": { "preinstall": "npx only-allow pnpm", "lint": "eslint --max-warnings=0 --fix .", @@ -35,10 +35,10 @@ "@jest/types": "^29.6.3", "@swc/jest": "^0.2.29", "@types/jest": "^29.5.11", - "@typescript-eslint/eslint-plugin": "^6.14.0", - "@typescript-eslint/parser": "^6.14.0", + "@typescript-eslint/eslint-plugin": "^6.18.1", + "@typescript-eslint/parser": "^6.18.1", "dotenv": "^16.3.1", - "eslint": "^8.55.0", + "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "jest": "^29.7.0", "npm-run-all": "^4.1.5", diff --git a/packages/auth/package.json b/packages/auth/package.json index cd0374e0a8..fc1e8376cc 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -35,7 +35,7 @@ "dotenv": "^16.3.1", "graphql": "^16.8.1", "knex": "^3.1.0", - "koa": "^2.14.2", + "koa": "^2.15.0", "koa-bodyparser": "^4.4.1", "koa-session": "^6.4.0", "objection": "^3.1.3", @@ -46,16 +46,16 @@ "uuid": "^9.0.1" }, "devDependencies": { - "@apollo/client": "^3.8.8", + "@apollo/client": "^3.8.9", "@faker-js/faker": "^8.3.1", "@graphql-codegen/cli": "5.0.0", "@graphql-codegen/introspection": "4.0.0", "@graphql-codegen/typescript": "4.0.1", "@graphql-codegen/typescript-resolvers": "4.0.1", - "@types/koa": "2.13.12", + "@types/koa": "2.14.0", "@types/koa-bodyparser": "^4.3.12", "@types/koa-session": "^6.4.5", - "@types/koa__cors": "^4.0.3", + "@types/koa__cors": "^5.0.0", "@types/koa__router": "^12.0.4", "@types/uuid": "^9.0.7", "cross-fetch": "^4.0.0", @@ -63,8 +63,8 @@ "nock": "^13.4.0", "node-mocks-http": "^1.14.1", "openapi-types": "^12.1.3", - "pino-pretty": "^10.2.3", - "testcontainers": "^10.3.2", + "pino-pretty": "^10.3.1", + "testcontainers": "^10.5.0", "typescript": "^4.9.5" } } diff --git a/packages/auth/src/grant/service.test.ts b/packages/auth/src/grant/service.test.ts index 714d3ea210..9e64437ae2 100644 --- a/packages/auth/src/grant/service.test.ts +++ b/packages/auth/src/grant/service.test.ts @@ -22,6 +22,8 @@ import { AccessType, AccessAction } from '@interledger/open-payments' import { createGrant } from '../tests/grant' import { AccessToken } from '../accessToken/model' import { Interaction, InteractionState } from '../interaction/model' +import { Pagination, SortOrder } from '../shared/baseModel' +import { getPageTests } from '../shared/baseModel.test' describe('Grant Service', (): void => { let deps: IocContract @@ -44,6 +46,14 @@ describe('Grant Service', (): void => { await appContainer.shutdown() }) + describe('getPage', (): void => { + getPageTests({ + createModel: () => createGrant(deps), + getPage: (pagination?: Pagination, sortOrder?: SortOrder) => + grantService.getPage(pagination, undefined, sortOrder) + }) + }) + describe('grant flow', (): void => { let grant: Grant @@ -412,37 +422,59 @@ describe('Grant Service', (): void => { const page = await grantService.getPage( { first: 1, - after: grants?.[0].id + after: grants?.[1].id }, filter ) - expect(page[0].id).toBe(grants?.[1].id) + expect(page[0].id).toBe(grants?.[0].id) expect(page.length).toBe(1) }) + describe('SortOrder', () => { + test('ASC', async () => { + const fetchedGrants = await grantService.getPage( + undefined, + undefined, + SortOrder.Asc + ) + + expect(fetchedGrants[0].id).toBe(grants[0].id) + }) + + test('DESC', async () => { + const fetchedGrants = await grantService.getPage( + undefined, + undefined, + SortOrder.Desc + ) + + expect(fetchedGrants[0].id).toBe(grants[grants.length - 1].id) + }) + }) + describe('GrantFilter', () => { describe('identifier', () => { test('in', async () => { - const grants = await grantService.getPage(undefined, { + const fetchedGrants = await grantService.getPage(undefined, { identifier: { in: [walletAddress] } }) - expect(grants.length).toBe(2) + expect(fetchedGrants.length).toBe(2) }) }) describe('state', () => { test('in', async () => { - const grants = await grantService.getPage(undefined, { + const fetchedGrants = await grantService.getPage(undefined, { state: { in: [GrantState.Finalized] } }) - expect(grants.length).toBe(1) + expect(fetchedGrants.length).toBe(1) }) test('notIn', async () => { const fetchedGrants = await grantService.getPage(undefined, { @@ -457,22 +489,22 @@ describe('Grant Service', (): void => { describe('finalizationReason', () => { test('in', async () => { - const grants = await grantService.getPage(undefined, { + const fetchedGrants = await grantService.getPage(undefined, { finalizationReason: { in: [GrantFinalization.Revoked] } }) - expect(grants.length).toBe(1) + expect(fetchedGrants.length).toBe(1) }) test('notIn', async () => { - const grants = await grantService.getPage(undefined, { + const fetchedGrants = await grantService.getPage(undefined, { finalizationReason: { notIn: [GrantFinalization.Revoked] } }) - expect(grants.length).toBe(2) + expect(fetchedGrants.length).toBe(2) }) }) }) diff --git a/packages/auth/src/grant/service.ts b/packages/auth/src/grant/service.ts index 85e500f028..6b98f5d834 100644 --- a/packages/auth/src/grant/service.ts +++ b/packages/auth/src/grant/service.ts @@ -12,7 +12,7 @@ import { } from './model' import { AccessRequest } from '../access/types' import { AccessService } from '../access/service' -import { Pagination } from '../shared/baseModel' +import { Pagination, SortOrder } from '../shared/baseModel' import { FilterString } from '../shared/filters' import { AccessTokenService } from '../accessToken/service' import { canSkipInteraction } from './utils' @@ -34,7 +34,11 @@ export interface GrantService { options?: GetByContinueOpts ): Promise revokeGrant(grantId: string): Promise - getPage(pagination?: Pagination, filter?: GrantFilter): Promise + getPage( + pagination?: Pagination, + filter?: GrantFilter, + sortOrder?: SortOrder + ): Promise lock(grantId: string, trx: Transaction, timeoutMs?: number): Promise } @@ -122,7 +126,8 @@ export async function createGrantService({ opts: GetByContinueOpts ) => getByContinue(continueId, continueToken, opts), revokeGrant: (grantId) => revokeGrant(deps, grantId), - getPage: (pagination?, filter?) => getGrantsPage(deps, pagination, filter), + getPage: (pagination?, filter?, sortOrder?) => + getGrantsPage(deps, pagination, filter, sortOrder), lock: (grantId: string, trx: Transaction, timeoutMs?: number) => lock(deps, grantId, trx, timeoutMs) } @@ -279,7 +284,8 @@ async function getByContinue( async function getGrantsPage( deps: ServiceDependencies, pagination?: Pagination, - filter?: GrantFilter + filter?: GrantFilter, + sortOrder?: SortOrder ): Promise { const query = Grant.query(deps.knex).withGraphJoined('access') const { identifier, state, finalizationReason } = filter ?? {} @@ -306,7 +312,7 @@ async function getGrantsPage( .orWhereNotIn('finalizationReason', finalizationReason.notIn) } - return query.getPage(pagination) + return query.getPage(pagination, sortOrder) } async function lock( diff --git a/packages/auth/src/graphql/generated/graphql.schema.json b/packages/auth/src/graphql/generated/graphql.schema.json index 702320921f..ba054e9fc6 100644 --- a/packages/auth/src/graphql/generated/graphql.schema.json +++ b/packages/auth/src/graphql/generated/graphql.schema.json @@ -1051,6 +1051,18 @@ "defaultValue": null, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "sortOrder", + "description": "Ascending or descending order of creation.", + "type": { + "kind": "ENUM", + "name": "SortOrder", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null } ], "type": { @@ -1163,6 +1175,29 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "ENUM", + "name": "SortOrder", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "ASC", + "description": "Choose ascending order for results.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "DESC", + "description": "Choose descending order for results.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, { "kind": "SCALAR", "name": "String", diff --git a/packages/auth/src/graphql/generated/graphql.ts b/packages/auth/src/graphql/generated/graphql.ts index 8fd6f5c082..ad12079321 100644 --- a/packages/auth/src/graphql/generated/graphql.ts +++ b/packages/auth/src/graphql/generated/graphql.ts @@ -177,6 +177,7 @@ export type QueryGrantsArgs = { filter?: InputMaybe; first?: InputMaybe; last?: InputMaybe; + sortOrder?: InputMaybe; }; export type RevokeGrantInput = { @@ -190,6 +191,13 @@ export type RevokeGrantMutationResponse = MutationResponse & { success: Scalars['Boolean']['output']; }; +export enum SortOrder { + /** Choose ascending order for results. */ + Asc = 'ASC', + /** Choose descending order for results. */ + Desc = 'DESC' +} + export type ResolverTypeWrapper = Promise | T; @@ -288,6 +296,7 @@ export type ResolversTypes = { Query: ResolverTypeWrapper<{}>; RevokeGrantInput: ResolverTypeWrapper>; RevokeGrantMutationResponse: ResolverTypeWrapper>; + SortOrder: ResolverTypeWrapper>; String: ResolverTypeWrapper>; UInt8: ResolverTypeWrapper>; UInt64: ResolverTypeWrapper>; diff --git a/packages/auth/src/graphql/resolvers/grant.test.ts b/packages/auth/src/graphql/resolvers/grant.test.ts index 3e79026ff0..8a0c3bc9d9 100644 --- a/packages/auth/src/graphql/resolvers/grant.test.ts +++ b/packages/auth/src/graphql/resolvers/grant.test.ts @@ -1,4 +1,4 @@ -import { ApolloError, gql } from '@apollo/client' +import { ApolloError, ApolloQueryResult, gql } from '@apollo/client' import { v4 as uuid } from 'uuid' import { createTestApp, TestContainer } from '../../tests/app' @@ -11,6 +11,7 @@ import { GrantFinalization, GrantsConnection, GrantState, + Query, RevokeGrantInput, RevokeGrantMutationResponse } from '../generated/graphql' @@ -18,6 +19,14 @@ import { Grant, Grant as GrantModel } from '../../grant/model' import { getPageTests } from './page.test' import { createGrant } from '../../tests/grant' +const responseHandler = (query: ApolloQueryResult): GrantsConnection => { + if (query.data) { + return query.data.grants + } else { + throw new Error('Data was empty') + } +} + describe('Grant Resolvers', (): void => { let deps: IocContract let appContainer: TestContainer @@ -47,8 +56,7 @@ describe('Grant Resolvers', (): void => { const grants: GrantModel[] = [] for (let i = 0; i < 2; i++) { - const grant = await createGrant(deps) - grants.push(grant) + grants[1 - i] = await createGrant(deps) } const query = await appContainer.apolloClient @@ -86,6 +94,70 @@ describe('Grant Resolvers', (): void => { }) }) + describe('Can order grants', (): void => { + let grants: GrantModel[] = [] + beforeEach(async () => { + const identifier = 'https://example.com/test' + const grantData = [ + { identifier }, + { identifier }, + { identifier: 'https://abc.com/xyz' } + ] + for (const { identifier } of grantData) { + const grant = await createGrant(deps, { identifier }) + grants.push(grant) + } + }) + + afterEach(() => { + grants = [] + }) + + test('ASC', async (): Promise => { + const query = await appContainer.apolloClient + .query({ + query: gql` + query grants($sortOrder: SortOrder) { + grants(sortOrder: $sortOrder) { + edges { + node { + id + state + } + cursor + } + } + } + `, + variables: { sortOrder: 'ASC' } + }) + .then(responseHandler) + expect(query.edges[0].node.id).toBe(grants[0].id) + }) + + test('DESC', async (): Promise => { + const query = await appContainer.apolloClient + .query({ + query: gql` + query grants($sortOrder: SortOrder) { + grants(sortOrder: $sortOrder) { + edges { + node { + id + state + } + cursor + } + } + } + `, + variables: { sortOrder: 'DESC' } + }) + .then(responseHandler) + expect(query.edges[0].node.id).toBe(grants[grants.length - 1].id) + }) + }) + describe('Can filter grants', (): void => { test('identifier', async (): Promise => { const grants: GrantModel[] = [] @@ -100,6 +172,8 @@ describe('Grant Resolvers', (): void => { grants.push(grant) } + const filteredGrants = grants.slice(0, 2).reverse() + const filter = { identifier: { in: [identifier] @@ -132,7 +206,7 @@ describe('Grant Resolvers', (): void => { }) expect(query.edges).toHaveLength(2) query.edges.forEach((edge, idx) => { - const grant = grants[idx] + const grant = filteredGrants[idx] expect(edge.cursor).toEqual(grant.id) expect(edge.node).toEqual({ __typename: 'Grant', diff --git a/packages/auth/src/graphql/resolvers/grant.ts b/packages/auth/src/graphql/resolvers/grant.ts index 1ecd50219a..45806f48cb 100644 --- a/packages/auth/src/graphql/resolvers/grant.ts +++ b/packages/auth/src/graphql/resolvers/grant.ts @@ -17,10 +17,11 @@ export const getGrants: QueryResolvers['grants'] = async ( ctx ): Promise => { const grantService = await ctx.container.use('grantService') - const { filter, ...pagination } = args - const grants = await grantService.getPage(pagination, filter) + const { filter, sortOrder, ...pagination } = args + const grants = await grantService.getPage(pagination, filter, sortOrder) const pageInfo = await getPageInfo( - (pagination: Pagination) => grantService.getPage(pagination, filter), + (pagination: Pagination) => + grantService.getPage(pagination, filter, sortOrder), grants ) diff --git a/packages/auth/src/graphql/resolvers/page.test.ts b/packages/auth/src/graphql/resolvers/page.test.ts index c20f984241..74cb7a93de 100644 --- a/packages/auth/src/graphql/resolvers/page.test.ts +++ b/packages/auth/src/graphql/resolvers/page.test.ts @@ -81,7 +81,7 @@ export const getPageTests = ({ async function createModels(): Promise { const models: M[] = [] for (let i = 0; i < 50; i++) { - models.push(await createModel()) + models[49 - i] = await createModel() } return models } diff --git a/packages/auth/src/graphql/schema.graphql b/packages/auth/src/graphql/schema.graphql index 3948654690..738c5710d2 100644 --- a/packages/auth/src/graphql/schema.graphql +++ b/packages/auth/src/graphql/schema.graphql @@ -11,6 +11,8 @@ type Query { last: Int "Filter grants based on specific criteria." filter: GrantFilter + "Ascending or descending order of creation." + sortOrder: SortOrder ): GrantsConnection! "Fetch a grant" @@ -153,5 +155,12 @@ enum GrantFinalization { REJECTED } +enum SortOrder { + "Choose ascending order for results." + ASC + "Choose descending order for results." + DESC +} + scalar UInt8 scalar UInt64 diff --git a/packages/auth/src/shared/baseModel.test.ts b/packages/auth/src/shared/baseModel.test.ts index 2c806ce9d6..8f980a4c74 100644 --- a/packages/auth/src/shared/baseModel.test.ts +++ b/packages/auth/src/shared/baseModel.test.ts @@ -1,8 +1,9 @@ -import { BaseModel, Pagination } from './baseModel' +import { BaseModel, Pagination, SortOrder } from './baseModel' +import { getPageInfo } from './pagination' interface PageTestsOptions { createModel: () => Promise - getPage: (pagination?: Pagination) => Promise + getPage: (pagination?: Pagination, sortOrder?: SortOrder) => Promise } export const getPageTests = ({ @@ -10,12 +11,16 @@ export const getPageTests = ({ getPage }: PageTestsOptions): void => { describe('Common BaseModel pagination', (): void => { - let modelsCreated: Type[] + let modelsCreatedAsc: Type[] + let modelsCreatedDesc: Type[] beforeEach(async (): Promise => { - modelsCreated = [] + modelsCreatedAsc = [] + modelsCreatedDesc = [] for (let i = 0; i < 22; i++) { - modelsCreated.push(await createModel()) + const model = await createModel() + modelsCreatedAsc.push(model) + modelsCreatedDesc[21 - i] = model } }) @@ -30,16 +35,16 @@ export const getPageTests = ({ ${{ after: 0, before: 19 }} | ${{ length: 20, first: 1, last: 20 }} | ${'Providing before and after results in forward pagination'} `('$description', async ({ pagination, expected }): Promise => { if (pagination?.after !== undefined) { - pagination.after = modelsCreated[pagination.after].id + pagination.after = modelsCreatedDesc[pagination.after].id } if (pagination?.before !== undefined) { - pagination.before = modelsCreated[pagination.before].id + pagination.before = modelsCreatedDesc[pagination.before].id } const models = await getPage(pagination) expect(models).toHaveLength(expected.length) - expect(models[0].id).toEqual(modelsCreated[expected.first].id) + expect(models[0].id).toEqual(modelsCreatedDesc[expected.first].id) expect(models[expected.length - 1].id).toEqual( - modelsCreated[expected.last].id + modelsCreatedDesc[expected.last].id ) }) @@ -52,20 +57,72 @@ export const getPageTests = ({ await expect(getPage(pagination)).rejects.toThrow(expectedError) }) - test('Backwards/Forwards pagination results in same order.', async (): Promise => { + test.each` + order | description + ${SortOrder.Asc} | ${'Backwards/Forwards pagination results in same order for ASC.'} + ${SortOrder.Desc} | ${'Backwards/Forwards pagination results in same order for DESC.'} + `('$description', async ({ order }): Promise => { + const models = + order === SortOrder.Asc ? modelsCreatedAsc : modelsCreatedDesc + const paginationForwards = { first: 10 } - const modelsForwards = await getPage(paginationForwards) + const modelsForwards = await getPage(paginationForwards, order) const paginationBackwards = { last: 10, - before: modelsCreated[10].id + before: models[10].id } - const modelsBackwards = await getPage(paginationBackwards) + const modelsBackwards = await getPage(paginationBackwards, order) expect(modelsForwards).toHaveLength(10) expect(modelsBackwards).toHaveLength(10) expect(modelsForwards).toEqual(modelsBackwards) }) + + test.each` + pagination | cursor | start | end | hasNextPage | hasPreviousPage | sortOrder + ${null} | ${null} | ${0} | ${19} | ${true} | ${false} | ${SortOrder.Desc} + ${{ first: 5 }} | ${null} | ${0} | ${4} | ${true} | ${false} | ${SortOrder.Desc} + ${{ first: 22 }} | ${null} | ${0} | ${21} | ${false} | ${false} | ${SortOrder.Desc} + ${{ first: 3 }} | ${3} | ${4} | ${6} | ${true} | ${true} | ${SortOrder.Desc} + ${{ last: 5 }} | ${9} | ${4} | ${8} | ${true} | ${true} | ${SortOrder.Desc} + ${null} | ${null} | ${0} | ${19} | ${true} | ${false} | ${SortOrder.Asc} + ${{ first: 5 }} | ${null} | ${0} | ${4} | ${true} | ${false} | ${SortOrder.Asc} + ${{ first: 22 }} | ${null} | ${0} | ${21} | ${false} | ${false} | ${SortOrder.Asc} + ${{ first: 3 }} | ${3} | ${4} | ${6} | ${true} | ${true} | ${SortOrder.Asc} + ${{ last: 5 }} | ${9} | ${4} | ${8} | ${true} | ${true} | ${SortOrder.Asc} + `( + 'pagination $pagination with cursor $cursor in $sortOrder order', + async ({ + pagination, + cursor, + start, + end, + hasNextPage, + hasPreviousPage, + sortOrder + }): Promise => { + const models = + sortOrder === SortOrder.Asc ? modelsCreatedAsc : modelsCreatedDesc + if (cursor) { + if (pagination.last) pagination.before = models[cursor].id + else pagination.after = models[cursor].id + } + + const page = await getPage(pagination, sortOrder) + const pageInfo = await getPageInfo( + (pagination, sortOrder) => getPage(pagination, sortOrder), + page, + sortOrder + ) + expect(pageInfo).toEqual({ + startCursor: models[start].id, + endCursor: models[end].id, + hasNextPage, + hasPreviousPage + }) + } + ) }) } diff --git a/packages/auth/src/shared/baseModel.ts b/packages/auth/src/shared/baseModel.ts index 910dc50a64..a24dd696e2 100644 --- a/packages/auth/src/shared/baseModel.ts +++ b/packages/auth/src/shared/baseModel.ts @@ -23,6 +23,11 @@ export interface PageInfo { hasPreviousPage: boolean } +export enum SortOrder { + Asc = 'ASC', + Desc = 'DESC' +} + class PaginationQueryBuilder extends QueryBuilder< M, R @@ -43,9 +48,13 @@ class PaginationQueryBuilder extends QueryBuilder< * Please read the spec before changing things: * https://relay.dev/graphql/connections.htm * @param pagination Pagination - cursors and limits. + * @param sortOrder order direction based on createdAt field * @returns Model[] An array of Models that form a page. */ - getPage(pagination?: Pagination): this { + getPage( + pagination?: Pagination, + sortOrder: SortOrder = SortOrder.Desc + ): this { const tableName = this.modelClass().tableName if ( typeof pagination?.before === 'undefined' && @@ -57,33 +66,34 @@ class PaginationQueryBuilder extends QueryBuilder< if (first < 0 || first > 100) throw new Error('Pagination index error') const last = pagination?.last || 20 if (last < 0 || last > 100) throw new Error('Pagination index error') - /** * Forward pagination */ if (typeof pagination?.after === 'string') { + const comparisonOperator = sortOrder === SortOrder.Asc ? '>' : '<' return this.whereRaw( - `("${tableName}"."createdAt", "${tableName}"."id") > (select "${tableName}"."createdAt" :: TIMESTAMP, "${tableName}"."id" from ?? where "${tableName}"."id" = ?)`, + `("${tableName}"."createdAt", "${tableName}"."id") ${comparisonOperator} (select "${tableName}"."createdAt" :: TIMESTAMP, "${tableName}"."id" from ?? where "${tableName}"."id" = ?)`, [this.modelClass().tableName, pagination.after] ) .orderBy([ - { column: 'createdAt', order: 'asc' }, - { column: 'id', order: 'asc' } + { column: 'createdAt', order: sortOrder }, + { column: 'id', order: sortOrder } ]) .limit(first) } - /** * Backward pagination */ if (typeof pagination?.before === 'string') { + const comparisonOperator = sortOrder === SortOrder.Asc ? '<' : '>' + const order = sortOrder === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc return this.whereRaw( - `("${tableName}"."createdAt", "${tableName}"."id") < (select "${tableName}"."createdAt" :: TIMESTAMP, "${tableName}"."id" from ?? where "${tableName}"."id" = ?)`, + `("${tableName}"."createdAt", "${tableName}"."id") ${comparisonOperator} (select "${tableName}"."createdAt" :: TIMESTAMP, "${tableName}"."id" from ?? where "${tableName}"."id" = ?)`, [this.modelClass().tableName, pagination.before] ) .orderBy([ - { column: 'createdAt', order: 'desc' }, - { column: 'id', order: 'desc' } + { column: 'createdAt', order }, + { column: 'id', order } ]) .limit(last) .runAfter((models) => { @@ -94,8 +104,8 @@ class PaginationQueryBuilder extends QueryBuilder< } return this.orderBy([ - { column: 'createdAt', order: 'asc' }, - { column: 'id', order: 'asc' } + { column: 'createdAt', order: sortOrder }, + { column: 'id', order: sortOrder } ]).limit(first) } } diff --git a/packages/auth/src/shared/pagination.ts b/packages/auth/src/shared/pagination.ts index f7c66ec617..1c502c2ad4 100644 --- a/packages/auth/src/shared/pagination.ts +++ b/packages/auth/src/shared/pagination.ts @@ -1,8 +1,9 @@ -import { BaseModel, PageInfo, Pagination } from './baseModel' +import { BaseModel, PageInfo, Pagination, SortOrder } from './baseModel' export async function getPageInfo( - getPage: (pagination: Pagination) => Promise, - page: T[] + getPage: (pagination: Pagination, sortOrder?: SortOrder) => Promise, + page: T[], + sortOrder?: SortOrder ): Promise { if (page.length == 0) return { @@ -15,18 +16,24 @@ export async function getPageInfo( let hasNextPage, hasPreviousPage try { - hasNextPage = await getPage({ - after: lastId, - first: 1 - }) + hasNextPage = await getPage( + { + after: lastId, + first: 1 + }, + sortOrder + ) } catch (e) { hasNextPage = [] } try { - hasPreviousPage = await getPage({ - before: firstId, - last: 1 - }) + hasPreviousPage = await getPage( + { + before: firstId, + last: 1 + }, + sortOrder + ) } catch (e) { hasPreviousPage = [] } diff --git a/packages/backend/migrations/20231121184537_add_resource_fkeys_webhook_event.js b/packages/backend/migrations/20231121184537_add_resource_fkeys_webhook_event.js new file mode 100644 index 0000000000..26f557871a --- /dev/null +++ b/packages/backend/migrations/20231121184537_add_resource_fkeys_webhook_event.js @@ -0,0 +1,96 @@ +exports.up = function (knex) { + return knex.schema + .table('webhookEvents', function (table) { + table + .uuid('outgoingPaymentId') + .nullable() + .references('outgoingPayments.id') + .index() + .onDelete('CASCADE') + table + .uuid('incomingPaymentId') + .nullable() + .references('incomingPayments.id') + .index() + .onDelete('CASCADE') + table + .uuid('walletAddressId') + .nullable() + .references('walletAddresses.id') + .index() + .onDelete('CASCADE') + table + .uuid('peerId') + .nullable() + .references('peers.id') + .index() + .onDelete('CASCADE') + table + .uuid('assetId') + .nullable() + .references('assets.id') + .index() + .onDelete('CASCADE') + }) + .then(() => { + return knex('webhookEvents').update({ + incomingPaymentId: knex.raw( + "CASE WHEN type LIKE 'incoming_payment.%' THEN (data->>'id')::uuid END" + ), + outgoingPaymentId: knex.raw( + "CASE WHEN type LIKE 'outgoing_payment.%' THEN (data->>'id')::uuid END" + ), + walletAddressId: knex.raw( + "CASE WHEN type = 'wallet_address.web_monetization' THEN (data->'walletAddress'->>'id')::uuid END" + ), + peerId: knex.raw( + "CASE WHEN type LIKE 'peer.%' THEN (data->>'id')::uuid END" + ), + assetId: knex.raw( + "CASE WHEN type LIKE 'asset.%' THEN (data->>'id')::uuid END" + ) + }) + }) + .then(() => { + return knex.schema.table('webhookEvents', function (table) { + table.check( + ` (CASE WHEN type != 'wallet_address.not_found' THEN + ( + ("outgoingPaymentId" IS NOT NULL)::int + + ("incomingPaymentId" IS NOT NULL)::int + + ("walletAddressId" IS NOT NULL)::int + + ("peerId" IS NOT NULL)::int + + ("assetId" IS NOT NULL)::int + ) = 1 + ELSE + ( + ("outgoingPaymentId" IS NOT NULL)::int + + ("incomingPaymentId" IS NOT NULL)::int + + ("walletAddressId" IS NOT NULL)::int + + ("peerId" IS NOT NULL)::int + + ("assetId" IS NOT NULL)::int + ) = 0 + END) + `, + null, + 'webhookevents_related_resource_constraint' + ) + }) + }) +} + +exports.down = function (knex) { + return knex.schema + .raw( + 'ALTER TABLE "webhookEvents" DROP CONSTRAINT IF EXISTS webhookevents_related_resource_constraint' + ) + .then(() => { + return knex.schema.table('webhookEvents', function (table) { + table.dropColumn('incomingPaymentId') + table.dropColumn('outgoingPaymentId') + table.dropColumn('walletAddressId') + table.dropColumn('peerId') + table.dropColumn('assetId') + }) + }) +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 6ce58fb390..afe0c40d60 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -15,13 +15,13 @@ "prepack": "pnpm build" }, "devDependencies": { - "@apollo/client": "^3.8.8", + "@apollo/client": "^3.8.9", "@graphql-codegen/cli": "5.0.0", "@graphql-codegen/introspection": "4.0.0", "@graphql-codegen/typescript": "4.0.1", "@graphql-codegen/typescript-operations": "^4.0.1", "@graphql-codegen/typescript-resolvers": "4.0.1", - "@types/koa": "2.13.12", + "@types/koa": "2.14.0", "@types/koa-bodyparser": "^4.3.12", "@types/koa__router": "^12.0.4", "@types/lodash": "^4.14.202", @@ -36,10 +36,10 @@ "nock": "^13.4.0", "node-mocks-http": "^1.14.1", "openapi-types": "^12.1.3", - "pino-pretty": "^10.2.3", + "pino-pretty": "^10.3.1", "react": "~18.2.0", "rosie": "^2.1.1", - "testcontainers": "^10.3.2", + "testcontainers": "^10.5.0", "tmp": "^0.2.1", "ts-node": "^10.9.2", "typescript": "^4.9.5" @@ -76,7 +76,7 @@ "ioredis": "^5.3.2", "json-canonicalize": "^1.0.6", "knex": "^3.1.0", - "koa": "^2.14.2", + "koa": "^2.15.0", "koa-bodyparser": "^4.4.1", "lodash": "^4.17.21", "luxon": "^3.4.4", diff --git a/packages/backend/src/asset/model.test.ts b/packages/backend/src/asset/model.test.ts index 2e1dcaeed5..461fb043e5 100644 --- a/packages/backend/src/asset/model.test.ts +++ b/packages/backend/src/asset/model.test.ts @@ -8,11 +8,10 @@ import { initIocContainer } from '../' import { AppServices } from '../app' import { randomAsset } from '../tests/asset' import { truncateTables } from '../tests/tableManager' -import { Asset } from './model' +import { Asset, AssetEvent, AssetEventError, AssetEventType } from './model' import { isAssetError } from './errors' -import { WebhookEvent } from '../webhook/model' -describe('Asset Model', (): void => { +describe('Models', (): void => { let deps: IocContract let appContainer: TestContainer let assetService: AssetService @@ -33,50 +32,72 @@ describe('Asset Model', (): void => { await appContainer.shutdown() }) - describe('onDebit', (): void => { - let asset: Asset - beforeEach(async (): Promise => { - const options = { - ...randomAsset(), - liquidityThreshold: BigInt(100) - } - const assetOrError = await assetService.create(options) - if (!isAssetError(assetOrError)) { - asset = assetOrError - } - }) - test.each` - balance - ${BigInt(50)} - ${BigInt(99)} - ${BigInt(100)} - `( - 'creates webhook event if balance=$balance <= liquidityThreshold', - async ({ balance }): Promise => { - await asset.onDebit({ balance }) - const event = ( - await WebhookEvent.query(knex).where('type', 'asset.liquidity_low') - )[0] - expect(event).toMatchObject({ - type: 'asset.liquidity_low', - data: { - id: asset.id, - asset: { + describe('Asset Model', (): void => { + describe('onDebit', (): void => { + let asset: Asset + beforeEach(async (): Promise => { + const options = { + ...randomAsset(), + liquidityThreshold: BigInt(100) + } + const assetOrError = await assetService.create(options) + if (!isAssetError(assetOrError)) { + asset = assetOrError + } + }) + test.each` + balance + ${BigInt(50)} + ${BigInt(99)} + ${BigInt(100)} + `( + 'creates webhook event if balance=$balance <= liquidityThreshold', + async ({ balance }): Promise => { + await asset.onDebit({ balance }) + const event = ( + await AssetEvent.query(knex).where( + 'type', + AssetEventType.LiquidityLow + ) + )[0] + expect(event).toMatchObject({ + type: AssetEventType.LiquidityLow, + data: { id: asset.id, - code: asset.code, - scale: asset.scale - }, - liquidityThreshold: asset.liquidityThreshold?.toString(), - balance: balance.toString() - } - }) - } - ) - test('does not create webhook event if balance > liquidityThreshold', async (): Promise => { - await asset.onDebit({ balance: BigInt(110) }) - await expect( - WebhookEvent.query(knex).where('type', 'asset.liquidity_low') - ).resolves.toEqual([]) + asset: { + id: asset.id, + code: asset.code, + scale: asset.scale + }, + liquidityThreshold: asset.liquidityThreshold?.toString(), + balance: balance.toString() + } + }) + } + ) + test('does not create webhook event if balance > liquidityThreshold', async (): Promise => { + await asset.onDebit({ balance: BigInt(110) }) + await expect( + AssetEvent.query(knex).where('type', AssetEventType.LiquidityLow) + ).resolves.toEqual([]) + }) + }) + }) + + describe('Asset Event Model', (): void => { + describe('beforeInsert', (): void => { + test.each( + Object.values(AssetEventType).map((type) => ({ + type, + error: AssetEventError.AssetIdRequired + })) + )('Asset Id is required', async ({ type, error }): Promise => { + expect( + AssetEvent.query().insert({ + type + }) + ).rejects.toThrow(error) + }) }) }) }) diff --git a/packages/backend/src/asset/model.ts b/packages/backend/src/asset/model.ts index d44de0f860..e4ac1da2b8 100644 --- a/packages/backend/src/asset/model.ts +++ b/packages/backend/src/asset/model.ts @@ -1,3 +1,4 @@ +import { QueryContext } from 'objection' import { LiquidityAccount, OnDebitOptions } from '../accounting/service' import { BaseModel } from '../shared/baseModel' import { WebhookEvent } from '../webhook/model' @@ -28,8 +29,9 @@ export class Asset extends BaseModel implements LiquidityAccount { public async onDebit({ balance }: OnDebitOptions): Promise { if (this.liquidityThreshold !== null) { if (balance <= this.liquidityThreshold) { - await WebhookEvent.query().insert({ - type: 'asset.liquidity_low', + await AssetEvent.query().insert({ + assetId: this.id, + type: AssetEventType.LiquidityLow, data: { id: this.id, asset: { @@ -46,3 +48,35 @@ export class Asset extends BaseModel implements LiquidityAccount { return this } } + +export enum AssetEventType { + LiquidityLow = 'asset.liquidity_low' +} + +export type AssetEventData = { + id: string + asset: { + id: string + code: string + scale: number + } + liquidityThreshold: bigint | null + balance: bigint +} + +export enum AssetEventError { + AssetIdRequired = 'Asset ID is required for asset events' +} + +export class AssetEvent extends WebhookEvent { + public type!: AssetEventType + public data!: AssetEventData + + public $beforeInsert(context: QueryContext): void { + super.$beforeInsert(context) + + if (!this.assetId) { + throw new Error(AssetEventError.AssetIdRequired) + } + } +} diff --git a/packages/backend/src/graphql/generated/graphql.schema.json b/packages/backend/src/graphql/generated/graphql.schema.json index f769181db2..5a90120ba1 100644 --- a/packages/backend/src/graphql/generated/graphql.schema.json +++ b/packages/backend/src/graphql/generated/graphql.schema.json @@ -8,156 +8,6 @@ }, "subscriptionType": null, "types": [ - { - "kind": "INPUT_OBJECT", - "name": "AddAssetLiquidityInput", - "description": null, - "fields": null, - "inputFields": [ - { - "name": "amount", - "description": "Amount of liquidity to add.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "UInt64", - "ofType": null - } - }, - "defaultValue": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "assetId", - "description": "The id of the asset to add liquidity.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "id", - "description": "The id of the transfer.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "idempotencyKey", - "description": "Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence)", - "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": "INPUT_OBJECT", - "name": "AddPeerLiquidityInput", - "description": null, - "fields": null, - "inputFields": [ - { - "name": "amount", - "description": "Amount of liquidity to add.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "UInt64", - "ofType": null - } - }, - "defaultValue": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "id", - "description": "The id of the transfer.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "idempotencyKey", - "description": "Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence)", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "peerId", - "description": "The id of the peer to add liquidity.", - "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": "ENUM", "name": "Alg", @@ -1010,18 +860,6 @@ "description": null, "fields": null, "inputFields": [ - { - "name": "addedLiquidity", - "description": "Initial amount of liquidity to add for peer", - "type": { - "kind": "SCALAR", - "name": "UInt64", - "ofType": null - }, - "defaultValue": null, - "isDeprecated": false, - "deprecationReason": null - }, { "name": "assetId", "description": "Asset id of peering relationship", @@ -1062,6 +900,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "liquidityToDeposit", + "description": "Amount of liquidity to deposit for peer", + "type": { + "kind": "SCALAR", + "name": "UInt64", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "maxPacketAmount", "description": "Maximum packet amount that the peer accepts", @@ -1303,7 +1153,7 @@ }, { "name": "initialLiquidity", - "description": "Initial amount of liquidity to add for peer", + "description": "Initial amount of liquidity to deposit for peer", "type": { "kind": "SCALAR", "name": "UInt64", @@ -2199,6 +2049,81 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "DepositAssetLiquidityInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "amount", + "description": "Amount of liquidity to deposit.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "UInt64", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "assetId", + "description": "The id of the asset to deposit liquidity.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "The id of the transfer.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "idempotencyKey", + "description": "Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence)", + "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": "INPUT_OBJECT", "name": "DepositEventLiquidityInput", @@ -2242,6 +2167,124 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "DepositOutgoingPaymentLiquidityInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "idempotencyKey", + "description": "Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence)", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "outgoingPaymentId", + "description": "The id of the outgoing payment to deposit into.", + "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": "INPUT_OBJECT", + "name": "DepositPeerLiquidityInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "amount", + "description": "Amount of liquidity to deposit.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "UInt64", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "The id of the transfer.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "idempotencyKey", + "description": "Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence)", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "peerId", + "description": "The id of the peer to deposit liquidity.", + "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": "Fee", @@ -2822,6 +2865,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "liquidity", + "description": "Available liquidity", + "args": [], + "type": { + "kind": "SCALAR", + "name": "UInt64", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "metadata", "description": "Additional metadata associated with the incoming payment.", @@ -3553,79 +3608,21 @@ }, { "kind": "OBJECT", - "name": "WalletAddressKey", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "WebhookEvent", - "ofType": null - } - ] - }, - { - "kind": "OBJECT", - "name": "Mutation", - "description": null, - "fields": [ - { - "name": "addAssetLiquidity", - "description": "Add asset liquidity", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "AddAssetLiquidityInput", - "ofType": null - } - }, - "defaultValue": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "type": { - "kind": "OBJECT", - "name": "LiquidityMutationResponse", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "addPeerLiquidity", - "description": "Add peer liquidity", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "AddPeerLiquidityInput", - "ofType": null - } - }, - "defaultValue": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "type": { - "kind": "OBJECT", - "name": "LiquidityMutationResponse", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null + "name": "WalletAddressKey", + "ofType": null }, + { + "kind": "OBJECT", + "name": "WebhookEvent", + "ofType": null + } + ] + }, + { + "kind": "OBJECT", + "name": "Mutation", + "description": null, + "fields": [ { "name": "createAsset", "description": "Create an asset", @@ -4039,6 +4036,35 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "depositAssetLiquidity", + "description": "Deposit asset liquidity", + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DepositAssetLiquidityInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "LiquidityMutationResponse", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "depositEventLiquidity", "description": "Deposit webhook event liquidity", @@ -4065,6 +4091,64 @@ "name": "LiquidityMutationResponse", "ofType": null }, + "isDeprecated": true, + "deprecationReason": "Use `depositOutgoingPaymentLiquidity`" + }, + { + "name": "depositOutgoingPaymentLiquidity", + "description": "Deposit outgoing payment liquidity", + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DepositOutgoingPaymentLiquidityInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "LiquidityMutationResponse", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "depositPeerLiquidity", + "description": "Deposit peer liquidity", + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DepositPeerLiquidityInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "LiquidityMutationResponse", + "ofType": null + }, "isDeprecated": false, "deprecationReason": null }, @@ -4346,6 +4430,64 @@ "name": "LiquidityMutationResponse", "ofType": null }, + "isDeprecated": true, + "deprecationReason": "Use `withdrawOutgoingPaymentLiquidity, withdrawIncomingPaymentLiquidity, or createWalletAddressWithdrawal`" + }, + { + "name": "withdrawIncomingPaymentLiquidity", + "description": "Withdraw incoming payment liquidity", + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "WithdrawIncomingPaymentLiquidityInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "LiquidityMutationResponse", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "withdrawOutgoingPaymentLiquidity", + "description": "Withdraw outgoing payment liquidity", + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "WithdrawOutgoingPaymentLiquidityInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "LiquidityMutationResponse", + "ofType": null + }, "isDeprecated": false, "deprecationReason": null } @@ -4550,6 +4692,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "liquidity", + "description": "Available liquidity", + "args": [], + "type": { + "kind": "SCALAR", + "name": "UInt64", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "metadata", "description": "Additional metadata associated with the outgoing payment.", @@ -4987,6 +5141,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "liquidity", + "description": "Available liquidity", + "args": [], + "type": { + "kind": "SCALAR", + "name": "UInt64", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "metadata", "description": "Additional metadata associated with the payment.", @@ -7658,6 +7824,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "liquidity", + "description": "Available liquidity", + "args": [], + "type": { + "kind": "SCALAR", + "name": "UInt64", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "outgoingPayments", "description": "List of outgoing payments sent from this wallet address", @@ -8463,6 +8641,92 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "WithdrawIncomingPaymentLiquidityInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "idempotencyKey", + "description": "Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence)", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "incomingPaymentId", + "description": "The id of the incoming payment to withdraw from.", + "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": "INPUT_OBJECT", + "name": "WithdrawOutgoingPaymentLiquidityInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "idempotencyKey", + "description": "Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence)", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "outgoingPaymentId", + "description": "The id of the outgoing payment to withdraw from.", + "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": "__Directive", diff --git a/packages/backend/src/graphql/generated/graphql.ts b/packages/backend/src/graphql/generated/graphql.ts index d8e42e4538..f5f4003911 100644 --- a/packages/backend/src/graphql/generated/graphql.ts +++ b/packages/backend/src/graphql/generated/graphql.ts @@ -19,28 +19,6 @@ export type Scalars = { UInt64: { input: bigint; output: bigint; } }; -export type AddAssetLiquidityInput = { - /** Amount of liquidity to add. */ - amount: Scalars['UInt64']['input']; - /** The id of the asset to add liquidity. */ - assetId: Scalars['String']['input']; - /** The id of the transfer. */ - id: Scalars['String']['input']; - /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ - idempotencyKey: Scalars['String']['input']; -}; - -export type AddPeerLiquidityInput = { - /** Amount of liquidity to add. */ - amount: Scalars['UInt64']['input']; - /** The id of the transfer. */ - id: Scalars['String']['input']; - /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ - idempotencyKey: Scalars['String']['input']; - /** The id of the peer to add liquidity. */ - peerId: Scalars['String']['input']; -}; - export enum Alg { EdDsa = 'EdDSA' } @@ -160,14 +138,14 @@ export type CreateIncomingPaymentInput = { }; export type CreateOrUpdatePeerByUrlInput = { - /** Initial amount of liquidity to add for peer */ - addedLiquidity?: InputMaybe; /** Asset id of peering relationship */ assetId: Scalars['String']['input']; /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ idempotencyKey?: InputMaybe; /** Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this value */ liquidityThreshold?: InputMaybe; + /** Amount of liquidity to deposit for peer */ + liquidityToDeposit?: InputMaybe; /** Maximum packet amount that the peer accepts */ maxPacketAmount?: InputMaybe; /** Peer's internal name for overriding auto-peer's default naming */ @@ -202,7 +180,7 @@ export type CreatePeerInput = { http: HttpInput; /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ idempotencyKey?: InputMaybe; - /** Initial amount of liquidity to add for peer */ + /** Initial amount of liquidity to deposit for peer */ initialLiquidity?: InputMaybe; /** Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this value */ liquidityThreshold?: InputMaybe; @@ -328,6 +306,17 @@ export type DeletePeerMutationResponse = MutationResponse & { success: Scalars['Boolean']['output']; }; +export type DepositAssetLiquidityInput = { + /** Amount of liquidity to deposit. */ + amount: Scalars['UInt64']['input']; + /** The id of the asset to deposit liquidity. */ + assetId: Scalars['String']['input']; + /** The id of the transfer. */ + id: Scalars['String']['input']; + /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ + idempotencyKey: Scalars['String']['input']; +}; + export type DepositEventLiquidityInput = { /** The id of the event to deposit into. */ eventId: Scalars['String']['input']; @@ -335,6 +324,24 @@ export type DepositEventLiquidityInput = { idempotencyKey: Scalars['String']['input']; }; +export type DepositOutgoingPaymentLiquidityInput = { + /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ + idempotencyKey: Scalars['String']['input']; + /** The id of the outgoing payment to deposit into. */ + outgoingPaymentId: Scalars['String']['input']; +}; + +export type DepositPeerLiquidityInput = { + /** Amount of liquidity to deposit. */ + amount: Scalars['UInt64']['input']; + /** The id of the transfer. */ + id: Scalars['String']['input']; + /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ + idempotencyKey: Scalars['String']['input']; + /** The id of the peer to deposit liquidity. */ + peerId: Scalars['String']['input']; +}; + export type Fee = Model & { __typename?: 'Fee'; /** Asset id associated with the fee */ @@ -424,6 +431,8 @@ export type IncomingPayment = BasePayment & Model & { id: Scalars['ID']['output']; /** The maximum amount that should be paid into the wallet address under this incoming payment. */ incomingAmount?: Maybe; + /** Available liquidity */ + liquidity?: Maybe; /** Additional metadata associated with the incoming payment. */ metadata?: Maybe; /** The total amount that has been paid into the wallet address under this incoming payment. */ @@ -526,10 +535,6 @@ export type Model = { export type Mutation = { __typename?: 'Mutation'; - /** Add asset liquidity */ - addAssetLiquidity?: Maybe; - /** Add peer liquidity */ - addPeerLiquidity?: Maybe; /** Create an asset */ createAsset: AssetMutationResponse; /** Withdraw asset liquidity */ @@ -556,8 +561,17 @@ export type Mutation = { createWalletAddressWithdrawal?: Maybe; /** Delete a peer */ deletePeer: DeletePeerMutationResponse; - /** Deposit webhook event liquidity */ + /** Deposit asset liquidity */ + depositAssetLiquidity?: Maybe; + /** + * Deposit webhook event liquidity + * @deprecated Use `depositOutgoingPaymentLiquidity` + */ depositEventLiquidity?: Maybe; + /** Deposit outgoing payment liquidity */ + depositOutgoingPaymentLiquidity?: Maybe; + /** Deposit peer liquidity */ + depositPeerLiquidity?: Maybe; /** Post liquidity withdrawal. Withdrawals are two-phase commits and are committed via this mutation. */ postLiquidityWithdrawal?: Maybe; /** Revoke a public key associated with a wallet address. Open Payment requests using this key for request signatures will be denied going forward. */ @@ -574,18 +588,15 @@ export type Mutation = { updateWalletAddress: UpdateWalletAddressMutationResponse; /** Void liquidity withdrawal. Withdrawals are two-phase commits and are rolled back via this mutation. */ voidLiquidityWithdrawal?: Maybe; - /** Withdraw webhook event liquidity */ + /** + * Withdraw webhook event liquidity + * @deprecated Use `withdrawOutgoingPaymentLiquidity, withdrawIncomingPaymentLiquidity, or createWalletAddressWithdrawal` + */ withdrawEventLiquidity?: Maybe; -}; - - -export type MutationAddAssetLiquidityArgs = { - input: AddAssetLiquidityInput; -}; - - -export type MutationAddPeerLiquidityArgs = { - input: AddPeerLiquidityInput; + /** Withdraw incoming payment liquidity */ + withdrawIncomingPaymentLiquidity?: Maybe; + /** Withdraw outgoing payment liquidity */ + withdrawOutgoingPaymentLiquidity?: Maybe; }; @@ -654,11 +665,26 @@ export type MutationDeletePeerArgs = { }; +export type MutationDepositAssetLiquidityArgs = { + input: DepositAssetLiquidityInput; +}; + + export type MutationDepositEventLiquidityArgs = { input: DepositEventLiquidityInput; }; +export type MutationDepositOutgoingPaymentLiquidityArgs = { + input: DepositOutgoingPaymentLiquidityInput; +}; + + +export type MutationDepositPeerLiquidityArgs = { + input: DepositPeerLiquidityInput; +}; + + export type MutationPostLiquidityWithdrawalArgs = { input: PostLiquidityWithdrawalInput; }; @@ -703,6 +729,16 @@ export type MutationWithdrawEventLiquidityArgs = { input: WithdrawEventLiquidityInput; }; + +export type MutationWithdrawIncomingPaymentLiquidityArgs = { + input: WithdrawIncomingPaymentLiquidityInput; +}; + + +export type MutationWithdrawOutgoingPaymentLiquidityArgs = { + input: WithdrawOutgoingPaymentLiquidityInput; +}; + export type MutationResponse = { code: Scalars['String']['output']; message: Scalars['String']['output']; @@ -718,6 +754,8 @@ export type OutgoingPayment = BasePayment & Model & { error?: Maybe; /** Outgoing payment id */ id: Scalars['ID']['output']; + /** Available liquidity */ + liquidity?: Maybe; /** Additional metadata associated with the outgoing payment. */ metadata?: Maybe; /** Quote for this outgoing payment */ @@ -784,6 +822,8 @@ export type Payment = BasePayment & Model & { createdAt: Scalars['String']['output']; /** Payment id */ id: Scalars['ID']['output']; + /** Available liquidity */ + liquidity?: Maybe; /** Additional metadata associated with the payment. */ metadata?: Maybe; /** Either the IncomingPaymentState or OutgoingPaymentState according to type */ @@ -1171,6 +1211,8 @@ export type WalletAddress = Model & { id: Scalars['ID']['output']; /** List of incoming payments received by this wallet address */ incomingPayments?: Maybe; + /** Available liquidity */ + liquidity?: Maybe; /** List of outgoing payments sent from this wallet address */ outgoingPayments?: Maybe; /** Public name associated with the wallet address */ @@ -1297,6 +1339,20 @@ export type WithdrawEventLiquidityInput = { idempotencyKey: Scalars['String']['input']; }; +export type WithdrawIncomingPaymentLiquidityInput = { + /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ + idempotencyKey: Scalars['String']['input']; + /** The id of the incoming payment to withdraw from. */ + incomingPaymentId: Scalars['String']['input']; +}; + +export type WithdrawOutgoingPaymentLiquidityInput = { + /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ + idempotencyKey: Scalars['String']['input']; + /** The id of the outgoing payment to withdraw from. */ + outgoingPaymentId: Scalars['String']['input']; +}; + export type ResolverTypeWrapper = Promise | T; @@ -1374,8 +1430,6 @@ export type ResolversInterfaceTypes> = { /** Mapping between all available schema types and the resolvers types */ export type ResolversTypes = { - AddAssetLiquidityInput: ResolverTypeWrapper>; - AddPeerLiquidityInput: ResolverTypeWrapper>; Alg: ResolverTypeWrapper>; Amount: ResolverTypeWrapper>; AmountInput: ResolverTypeWrapper>; @@ -1405,7 +1459,10 @@ export type ResolversTypes = { Crv: ResolverTypeWrapper>; DeletePeerInput: ResolverTypeWrapper>; DeletePeerMutationResponse: ResolverTypeWrapper>; + DepositAssetLiquidityInput: ResolverTypeWrapper>; DepositEventLiquidityInput: ResolverTypeWrapper>; + DepositOutgoingPaymentLiquidityInput: ResolverTypeWrapper>; + DepositPeerLiquidityInput: ResolverTypeWrapper>; Fee: ResolverTypeWrapper>; FeeDetails: ResolverTypeWrapper>; FeeEdge: ResolverTypeWrapper>; @@ -1484,12 +1541,12 @@ export type ResolversTypes = { WebhookEventsConnection: ResolverTypeWrapper>; WebhookEventsEdge: ResolverTypeWrapper>; WithdrawEventLiquidityInput: ResolverTypeWrapper>; + WithdrawIncomingPaymentLiquidityInput: ResolverTypeWrapper>; + WithdrawOutgoingPaymentLiquidityInput: ResolverTypeWrapper>; }; /** Mapping between all available schema types and the resolvers parents */ export type ResolversParentTypes = { - AddAssetLiquidityInput: Partial; - AddPeerLiquidityInput: Partial; Amount: Partial; AmountInput: Partial; Asset: Partial; @@ -1517,7 +1574,10 @@ export type ResolversParentTypes = { CreateWalletAddressWithdrawalInput: Partial; DeletePeerInput: Partial; DeletePeerMutationResponse: Partial; + DepositAssetLiquidityInput: Partial; DepositEventLiquidityInput: Partial; + DepositOutgoingPaymentLiquidityInput: Partial; + DepositPeerLiquidityInput: Partial; Fee: Partial; FeeDetails: Partial; FeeEdge: Partial; @@ -1588,6 +1648,8 @@ export type ResolversParentTypes = { WebhookEventsConnection: Partial; WebhookEventsEdge: Partial; WithdrawEventLiquidityInput: Partial; + WithdrawIncomingPaymentLiquidityInput: Partial; + WithdrawOutgoingPaymentLiquidityInput: Partial; }; export type AmountResolvers = { @@ -1724,6 +1786,7 @@ export type IncomingPaymentResolvers; id?: Resolver; incomingAmount?: Resolver, ParentType, ContextType>; + liquidity?: Resolver, ParentType, ContextType>; metadata?: Resolver, ParentType, ContextType>; receivedAmount?: Resolver; state?: Resolver; @@ -1779,8 +1842,6 @@ export type ModelResolvers = { - addAssetLiquidity?: Resolver, ParentType, ContextType, RequireFields>; - addPeerLiquidity?: Resolver, ParentType, ContextType, RequireFields>; createAsset?: Resolver>; createAssetLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; createIncomingPayment?: Resolver>; @@ -1794,7 +1855,10 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; createWalletAddressWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; deletePeer?: Resolver>; + depositAssetLiquidity?: Resolver, ParentType, ContextType, RequireFields>; depositEventLiquidity?: Resolver, ParentType, ContextType, RequireFields>; + depositOutgoingPaymentLiquidity?: Resolver, ParentType, ContextType, RequireFields>; + depositPeerLiquidity?: Resolver, ParentType, ContextType, RequireFields>; postLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; revokeWalletAddressKey?: Resolver, ParentType, ContextType, RequireFields>; setFee?: Resolver>; @@ -1804,6 +1868,8 @@ export type MutationResolvers>; voidLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; withdrawEventLiquidity?: Resolver, ParentType, ContextType, RequireFields>; + withdrawIncomingPaymentLiquidity?: Resolver, ParentType, ContextType, RequireFields>; + withdrawOutgoingPaymentLiquidity?: Resolver, ParentType, ContextType, RequireFields>; }; export type MutationResponseResolvers = { @@ -1818,6 +1884,7 @@ export type OutgoingPaymentResolvers; error?: Resolver, ParentType, ContextType>; id?: Resolver; + liquidity?: Resolver, ParentType, ContextType>; metadata?: Resolver, ParentType, ContextType>; quote?: Resolver, ParentType, ContextType>; receiveAmount?: Resolver; @@ -1860,6 +1927,7 @@ export type PageInfoResolvers = { createdAt?: Resolver; id?: Resolver; + liquidity?: Resolver, ParentType, ContextType>; metadata?: Resolver, ParentType, ContextType>; state?: Resolver; type?: Resolver; @@ -2027,6 +2095,7 @@ export type WalletAddressResolvers; id?: Resolver; incomingPayments?: Resolver, ParentType, ContextType, Partial>; + liquidity?: Resolver, ParentType, ContextType>; outgoingPayments?: Resolver, ParentType, ContextType, Partial>; publicName?: Resolver, ParentType, ContextType>; quotes?: Resolver, ParentType, ContextType, Partial>; diff --git a/packages/backend/src/graphql/resolvers/auto-peering.test.ts b/packages/backend/src/graphql/resolvers/auto-peering.test.ts index 09dc001318..64ba44dabe 100644 --- a/packages/backend/src/graphql/resolvers/auto-peering.test.ts +++ b/packages/backend/src/graphql/resolvers/auto-peering.test.ts @@ -106,7 +106,7 @@ describe('Auto Peering Resolvers', (): void => { describe('Create Peer By Url', (): void => { test('Can create a peer', async (): Promise => { const input = createOrUpdatePeerByUrlInput({ - addedLiquidity: 1000n + liquidityToDeposit: 1000n }) const peerDetails = { @@ -141,14 +141,14 @@ describe('Auto Peering Resolvers', (): void => { }, maxPacketAmount: input.maxPacketAmount?.toString(), staticIlpAddress: peerDetails.staticIlpAddress, - liquidity: input.addedLiquidity?.toString(), + liquidity: input.liquidityToDeposit?.toString(), name: input.name }) scope.done() }) test('Can update a peer', async (): Promise => { - const input = createOrUpdatePeerByUrlInput({ addedLiquidity: 1000n }) + const input = createOrUpdatePeerByUrlInput({ liquidityToDeposit: 1000n }) const peerDetails = { staticIlpAddress: 'test.peer2', @@ -191,7 +191,7 @@ describe('Auto Peering Resolvers', (): void => { }, maxPacketAmount: input.maxPacketAmount?.toString(), staticIlpAddress: peerDetails.staticIlpAddress, - liquidity: input.addedLiquidity?.toString(), + liquidity: input.liquidityToDeposit?.toString(), name: input.name }) @@ -199,7 +199,7 @@ describe('Auto Peering Resolvers', (): void => { ...input, name: 'Updated Name', maxPacketAmount: 1000n, - addedLiquidity: 2000n + liquidityToDeposit: 2000n }) const secondResponse = await callCreateOrUpdatePeerByUrl(secondInput) @@ -226,7 +226,7 @@ describe('Auto Peering Resolvers', (): void => { maxPacketAmount: secondInput.maxPacketAmount?.toString(), staticIlpAddress: peerDetails.staticIlpAddress, liquidity: ( - input.addedLiquidity! + secondInput.addedLiquidity! + input.liquidityToDeposit! + secondInput.liquidityToDeposit! ).toString(), name: secondInput.name }) diff --git a/packages/backend/src/graphql/resolvers/combined_payments.test.ts b/packages/backend/src/graphql/resolvers/combined_payments.test.ts index 5f9d5e852a..5d81f7cca7 100644 --- a/packages/backend/src/graphql/resolvers/combined_payments.test.ts +++ b/packages/backend/src/graphql/resolvers/combined_payments.test.ts @@ -82,6 +82,7 @@ describe('Payment', (): void => { id type walletAddressId + liquidity state metadata createdAt @@ -118,7 +119,8 @@ describe('Payment', (): void => { metadata: combinedOutgoingPayment.metadata, walletAddressId: combinedOutgoingPayment.walletAddressId, state: combinedOutgoingPayment.state, - createdAt: combinedOutgoingPayment.createdAt.toISOString() + createdAt: combinedOutgoingPayment.createdAt.toISOString(), + liquidity: '0' }) const combinedIncomingPayment = toCombinedPayment( @@ -131,7 +133,8 @@ describe('Payment', (): void => { metadata: combinedIncomingPayment.metadata, walletAddressId: combinedIncomingPayment.walletAddressId, state: combinedIncomingPayment.state, - createdAt: combinedIncomingPayment.createdAt.toISOString() + createdAt: combinedIncomingPayment.createdAt.toISOString(), + liquidity: '0' }) }) diff --git a/packages/backend/src/graphql/resolvers/incoming_payment.test.ts b/packages/backend/src/graphql/resolvers/incoming_payment.test.ts index c5ca2fb835..63154e3b3d 100644 --- a/packages/backend/src/graphql/resolvers/incoming_payment.test.ts +++ b/packages/backend/src/graphql/resolvers/incoming_payment.test.ts @@ -12,6 +12,7 @@ import { createWalletAddress } from '../../tests/walletAddress' import { truncateTables } from '../../tests/tableManager' import { v4 as uuid } from 'uuid' import { IncomingPaymentService } from '../../open_payments/payment/incoming/service' +import { AccountingService } from '../../accounting/service' import { IncomingPayment as IncomingPaymentModel, IncomingPaymentState @@ -32,12 +33,14 @@ describe('Incoming Payment Resolver', (): void => { let appContainer: TestContainer let walletAddressId: string let incomingPaymentService: IncomingPaymentService + let accountingService: AccountingService let asset: Asset beforeAll(async (): Promise => { deps = await initIocContainer(Config) appContainer = await createTestApp(deps) incomingPaymentService = await deps.use('incomingPaymentService') + accountingService = await deps.use('accountingService') asset = await createAsset(deps) }) @@ -317,6 +320,7 @@ describe('Incoming Payment Resolver', (): void => { walletAddressId state expiresAt + liquidity incomingAmount { value assetCode @@ -343,6 +347,7 @@ describe('Incoming Payment Resolver', (): void => { walletAddressId: payment.walletAddressId, state, expiresAt: expiresAt.toISOString(), + liquidity: '0', incomingAmount: { value: payment.incomingAmount?.value.toString(), assetCode: payment.incomingAmount?.assetCode, @@ -360,6 +365,36 @@ describe('Incoming Payment Resolver', (): void => { __typename: 'IncomingPayment' }) }) + + test('200 - with added liquidity', async (): Promise => { + await accountingService.createDeposit({ + id: uuid(), + account: payment, + amount: 100n + }) + + const query = await appContainer.apolloClient + .query({ + query: gql` + query IncomingPayment($paymentId: String!) { + incomingPayment(id: $paymentId) { + id + liquidity + } + } + `, + variables: { + paymentId: payment.id + } + }) + .then((query): IncomingPayment => query.data?.incomingPayment) + + expect(query).toEqual({ + id: payment.id, + liquidity: '100', + __typename: 'IncomingPayment' + }) + }) }) test('404', async (): Promise => { diff --git a/packages/backend/src/graphql/resolvers/index.ts b/packages/backend/src/graphql/resolvers/index.ts index dc161eb426..6f1f09abda 100644 --- a/packages/backend/src/graphql/resolvers/index.ts +++ b/packages/backend/src/graphql/resolvers/index.ts @@ -30,15 +30,22 @@ import { getPeer, getPeers, createPeer, updatePeer, deletePeer } from './peer' import { getAssetLiquidity, getPeerLiquidity, - addAssetLiquidity, - addPeerLiquidity, + getWalletAddressLiquidity, + getIncomingPaymentLiquidity, + getOutgoingPaymentLiquidity, + getPaymentLiquidity, + depositAssetLiquidity, + depositPeerLiquidity, createAssetLiquidityWithdrawal, createPeerLiquidityWithdrawal, createWalletAddressWithdrawal, postLiquidityWithdrawal, voidLiquidityWithdrawal, depositEventLiquidity, - withdrawEventLiquidity + withdrawEventLiquidity, + depositOutgoingPaymentLiquidity, + withdrawIncomingPaymentLiquidity, + withdrawOutgoingPaymentLiquidity } from './liquidity' import { GraphQLBigInt, GraphQLUInt8 } from '../scalars' import { @@ -80,10 +87,20 @@ export const resolvers: Resolvers = { receiver: getReceiver }, WalletAddress: { + liquidity: getWalletAddressLiquidity, incomingPayments: getWalletAddressIncomingPayments, outgoingPayments: getWalletAddressOutgoingPayments, quotes: getWalletAddressQuotes }, + IncomingPayment: { + liquidity: getIncomingPaymentLiquidity + }, + OutgoingPayment: { + liquidity: getOutgoingPaymentLiquidity + }, + Payment: { + liquidity: getPaymentLiquidity + }, Mutation: { createWalletAddressKey, revokeWalletAddressKey, @@ -100,8 +117,8 @@ export const resolvers: Resolvers = { createOrUpdatePeerByUrl: createOrUpdatePeerByUrl, updatePeer: updatePeer, deletePeer: deletePeer, - addAssetLiquidity: addAssetLiquidity, - addPeerLiquidity: addPeerLiquidity, + depositAssetLiquidity, + depositPeerLiquidity, createAssetLiquidityWithdrawal: createAssetLiquidityWithdrawal, createPeerLiquidityWithdrawal: createPeerLiquidityWithdrawal, createWalletAddressWithdrawal, @@ -109,6 +126,9 @@ export const resolvers: Resolvers = { voidLiquidityWithdrawal: voidLiquidityWithdrawal, depositEventLiquidity, withdrawEventLiquidity, + depositOutgoingPaymentLiquidity, + withdrawIncomingPaymentLiquidity, + withdrawOutgoingPaymentLiquidity, setFee } } diff --git a/packages/backend/src/graphql/resolvers/liquidity.test.ts b/packages/backend/src/graphql/resolvers/liquidity.test.ts index 3b6f3572e5..2879cfae17 100644 --- a/packages/backend/src/graphql/resolvers/liquidity.test.ts +++ b/packages/backend/src/graphql/resolvers/liquidity.test.ts @@ -26,9 +26,10 @@ import { } from '../../open_payments/payment/incoming/model' import { OutgoingPayment, - PaymentEvent, - PaymentWithdrawType, - isPaymentEventType + OutgoingPaymentEvent, + OutgoingPaymentEventType, + OutgoingPaymentWithdrawType, + isOutgoingPaymentEventType } from '../../open_payments/payment/outgoing/model' import { Peer } from '../../payment-method/ilp/peer/model' import { createAsset } from '../../tests/asset' @@ -64,19 +65,19 @@ describe('Liquidity Resolvers', (): void => { await appContainer.shutdown() }) - describe('Add peer liquidity', (): void => { + describe('Deposit peer liquidity', (): void => { let peer: Peer beforeEach(async (): Promise => { peer = await createPeer(deps) }) - test('Can add liquidity to peer', async (): Promise => { + test('Can deposit liquidity to peer', async (): Promise => { const response = await appContainer.apolloClient .mutate({ mutation: gql` - mutation AddPeerLiquidity($input: AddPeerLiquidityInput!) { - addPeerLiquidity(input: $input) { + mutation DepositPeerLiquidity($input: DepositPeerLiquidityInput!) { + depositPeerLiquidity(input: $input) { code success message @@ -95,7 +96,7 @@ describe('Liquidity Resolvers', (): void => { }) .then((query): LiquidityMutationResponse => { if (query.data) { - return query.data.addPeerLiquidity + return query.data.depositPeerLiquidity } else { throw new Error('Data was empty') } @@ -110,8 +111,8 @@ describe('Liquidity Resolvers', (): void => { const response = await appContainer.apolloClient .mutate({ mutation: gql` - mutation AddPeerLiquidity($input: AddPeerLiquidityInput!) { - addPeerLiquidity(input: $input) { + mutation DepositPeerLiquidity($input: DepositPeerLiquidityInput!) { + depositPeerLiquidity(input: $input) { code success message @@ -130,7 +131,7 @@ describe('Liquidity Resolvers', (): void => { }) .then((query): LiquidityMutationResponse => { if (query.data) { - return query.data.addPeerLiquidity + return query.data.depositPeerLiquidity } else { throw new Error('Data was empty') } @@ -146,8 +147,8 @@ describe('Liquidity Resolvers', (): void => { const response = await appContainer.apolloClient .mutate({ mutation: gql` - mutation AddPeerLiquidity($input: AddPeerLiquidityInput!) { - addPeerLiquidity(input: $input) { + mutation DepositPeerLiquidity($input: DepositPeerLiquidityInput!) { + depositPeerLiquidity(input: $input) { code success message @@ -166,7 +167,7 @@ describe('Liquidity Resolvers', (): void => { }) .then((query): LiquidityMutationResponse => { if (query.data) { - return query.data.addPeerLiquidity + return query.data.depositPeerLiquidity } else { throw new Error('Data was empty') } @@ -190,8 +191,8 @@ describe('Liquidity Resolvers', (): void => { const response = await appContainer.apolloClient .mutate({ mutation: gql` - mutation AddPeerLiquidity($input: AddPeerLiquidityInput!) { - addPeerLiquidity(input: $input) { + mutation DepositPeerLiquidity($input: DepositPeerLiquidityInput!) { + depositPeerLiquidity(input: $input) { code success message @@ -210,7 +211,7 @@ describe('Liquidity Resolvers', (): void => { }) .then((query): LiquidityMutationResponse => { if (query.data) { - return query.data.addPeerLiquidity + return query.data.depositPeerLiquidity } else { throw new Error('Data was empty') } @@ -226,8 +227,8 @@ describe('Liquidity Resolvers', (): void => { const response = await appContainer.apolloClient .mutate({ mutation: gql` - mutation AddPeerLiquidity($input: AddPeerLiquidityInput!) { - addPeerLiquidity(input: $input) { + mutation DepositPeerLiquidity($input: DepositPeerLiquidityInput!) { + depositPeerLiquidity(input: $input) { code success message @@ -246,7 +247,7 @@ describe('Liquidity Resolvers', (): void => { }) .then((query): LiquidityMutationResponse => { if (query.data) { - return query.data.addPeerLiquidity + return query.data.depositPeerLiquidity } else { throw new Error('Data was empty') } @@ -259,19 +260,21 @@ describe('Liquidity Resolvers', (): void => { }) }) - describe('Add asset liquidity', (): void => { + describe('Deposit asset liquidity', (): void => { let asset: Asset beforeEach(async (): Promise => { asset = await createAsset(deps) }) - test('Can add liquidity to asset', async (): Promise => { + test('Can deposit liquidity to asset', async (): Promise => { const response = await appContainer.apolloClient .mutate({ mutation: gql` - mutation AddAssetLiquidity($input: AddAssetLiquidityInput!) { - addAssetLiquidity(input: $input) { + mutation DepositAssetLiquidity( + $input: DepositAssetLiquidityInput! + ) { + depositAssetLiquidity(input: $input) { code success message @@ -290,7 +293,7 @@ describe('Liquidity Resolvers', (): void => { }) .then((query): LiquidityMutationResponse => { if (query.data) { - return query.data.addAssetLiquidity + return query.data.depositAssetLiquidity } else { throw new Error('Data was empty') } @@ -305,8 +308,10 @@ describe('Liquidity Resolvers', (): void => { const response = await appContainer.apolloClient .mutate({ mutation: gql` - mutation AddAssetLiquidity($input: AddAssetLiquidityInput!) { - addAssetLiquidity(input: $input) { + mutation DepositAssetLiquidity( + $input: DepositAssetLiquidityInput! + ) { + depositAssetLiquidity(input: $input) { code success message @@ -325,7 +330,7 @@ describe('Liquidity Resolvers', (): void => { }) .then((query): LiquidityMutationResponse => { if (query.data) { - return query.data.addAssetLiquidity + return query.data.depositAssetLiquidity } else { throw new Error('Data was empty') } @@ -341,8 +346,10 @@ describe('Liquidity Resolvers', (): void => { const response = await appContainer.apolloClient .mutate({ mutation: gql` - mutation AddAssetLiquidity($input: AddAssetLiquidityInput!) { - addAssetLiquidity(input: $input) { + mutation DepositAssetLiquidity( + $input: DepositAssetLiquidityInput! + ) { + depositAssetLiquidity(input: $input) { code success message @@ -361,7 +368,7 @@ describe('Liquidity Resolvers', (): void => { }) .then((query): LiquidityMutationResponse => { if (query.data) { - return query.data.addAssetLiquidity + return query.data.depositAssetLiquidity } else { throw new Error('Data was empty') } @@ -385,8 +392,10 @@ describe('Liquidity Resolvers', (): void => { const response = await appContainer.apolloClient .mutate({ mutation: gql` - mutation AddAssetLiquidity($input: AddAssetLiquidityInput!) { - addAssetLiquidity(input: $input) { + mutation DepositAssetLiquidity( + $input: DepositAssetLiquidityInput! + ) { + depositAssetLiquidity(input: $input) { code success message @@ -405,7 +414,7 @@ describe('Liquidity Resolvers', (): void => { }) .then((query): LiquidityMutationResponse => { if (query.data) { - return query.data.addAssetLiquidity + return query.data.depositAssetLiquidity } else { throw new Error('Data was empty') } @@ -421,8 +430,10 @@ describe('Liquidity Resolvers', (): void => { const response = await appContainer.apolloClient .mutate({ mutation: gql` - mutation AddAssetLiquidity($input: AddAssetLiquidityInput!) { - addAssetLiquidity(input: $input) { + mutation DepositAssetLiquidity( + $input: DepositAssetLiquidityInput! + ) { + depositAssetLiquidity(input: $input) { code success message @@ -441,7 +452,7 @@ describe('Liquidity Resolvers', (): void => { }) .then((query): LiquidityMutationResponse => { if (query.data) { - return query.data.addAssetLiquidity + return query.data.depositAssetLiquidity } else { throw new Error('Data was empty') } @@ -1566,7 +1577,7 @@ describe('Liquidity Resolvers', (): void => { } ) - { + describe('Event Liquidity', (): void => { let walletAddress: WalletAddress let incomingPayment: IncomingPayment let payment: OutgoingPayment @@ -1607,8 +1618,9 @@ describe('Liquidity Resolvers', (): void => { beforeEach(async (): Promise => { eventId = uuid() - await PaymentEvent.query(knex).insertAndFetch({ + await OutgoingPaymentEvent.query(knex).insertAndFetch({ id: eventId, + outgoingPaymentId: payment.id, type, data: payment.toData({ amountSent: BigInt(0), @@ -1747,12 +1759,26 @@ describe('Liquidity Resolvers', (): void => { const WithdrawEventType = { ...WalletAddressEventType, ...IncomingPaymentEventType, - ...PaymentWithdrawType + ...OutgoingPaymentWithdrawType } type WithdrawEventType = | WalletAddressEventType | IncomingPaymentEventType - | PaymentWithdrawType + | OutgoingPaymentWithdrawType + + interface WithdrawWebhookData { + id: string + type: WithdrawEventType + data: Record + withdrawal: { + accountId: string + assetId: string + amount: bigint + } + incomingPaymentId?: string + outgoingPaymentId?: string + walletAddressId?: string + } const isIncomingPaymentEventType = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types @@ -1772,15 +1798,22 @@ describe('Liquidity Resolvers', (): void => { const amount = BigInt(10) let liquidityAccount: LiquidityAccount let data: Record - if (isPaymentEventType(type)) { + let resourceId: + | 'outgoingPaymentId' + | 'incomingPaymentId' + | 'walletAddressId' + | null = null + if (isOutgoingPaymentEventType(type)) { liquidityAccount = payment data = payment.toData({ amountSent: BigInt(0), balance: amount }) + resourceId = 'outgoingPaymentId' } else if (isIncomingPaymentEventType(type)) { liquidityAccount = incomingPayment data = incomingPayment.toData(amount) + resourceId = 'incomingPaymentId' } else { liquidityAccount = walletAddress await accountingService.createLiquidityAccount( @@ -1788,8 +1821,11 @@ describe('Liquidity Resolvers', (): void => { LiquidityAccountType.WEB_MONETIZATION ) data = walletAddress.toData(amount) + if (type !== WalletAddressEventType.WalletAddressNotFound) { + resourceId = 'walletAddressId' + } } - await WebhookEvent.query(knex).insertAndFetch({ + const insertPayload: WithdrawWebhookData = { id: eventId, type, data, @@ -1798,7 +1834,13 @@ describe('Liquidity Resolvers', (): void => { assetId: liquidityAccount.asset.id, amount } - }) + } + + if (resourceId) { + insertPayload[resourceId] = liquidityAccount.id + } + + await WebhookEvent.query(knex).insertAndFetch(insertPayload) await expect( accountingService.createDeposit({ id: uuid(), @@ -1928,5 +1970,609 @@ describe('Liquidity Resolvers', (): void => { } ) }) - } + }) + + describe('Payment Liquidity', (): void => { + let walletAddress: WalletAddress + let incomingPayment: IncomingPayment + let outgoingPayment: OutgoingPayment + + beforeEach(async (): Promise => { + walletAddress = await createWalletAddress(deps) + const walletAddressId = walletAddress.id + incomingPayment = await createIncomingPayment(deps, { + walletAddressId, + incomingAmount: { + value: BigInt(56), + assetCode: walletAddress.asset.code, + assetScale: walletAddress.asset.scale + }, + expiresAt: new Date(Date.now() + 60 * 1000) + }) + outgoingPayment = await createOutgoingPayment(deps, { + walletAddressId, + method: 'ilp', + receiver: `${ + Config.openPaymentsUrl + }/${uuid()}/incoming-payments/${uuid()}`, + debitAmount: { + value: BigInt(456), + assetCode: walletAddress.asset.code, + assetScale: walletAddress.asset.scale + }, + validDestination: false + }) + await expect( + accountingService.getBalance(outgoingPayment.id) + ).resolves.toEqual(BigInt(0)) + }) + + describe('withdrawIncomingPaymentLiquidity', (): void => { + const amount = BigInt(10) + + beforeEach(async (): Promise => { + await expect( + accountingService.createDeposit({ + id: uuid(), + account: incomingPayment, + amount + }) + ).resolves.toBeUndefined() + await expect( + accountingService.getBalance(incomingPayment.id) + ).resolves.toEqual(amount) + }) + + describe('Can withdraw liquidity', () => { + test.each([ + IncomingPaymentEventType.IncomingPaymentCompleted, + IncomingPaymentEventType.IncomingPaymentExpired + ])('for incoming payment event %s', async (eventType) => { + const balance = await accountingService.getBalance(incomingPayment.id) + assert.ok(balance === amount) + + await WebhookEvent.query(knex).insert({ + id: uuid(), + incomingPaymentId: incomingPayment.id, + type: eventType, + data: {}, + withdrawal: { + accountId: incomingPayment.id, + assetId: incomingPayment.asset.id, + amount + } + }) + + const response = await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation WithdrawIncomingPaymentLiquidity( + $input: WithdrawIncomingPaymentLiquidityInput! + ) { + withdrawIncomingPaymentLiquidity(input: $input) { + code + success + message + error + } + } + `, + variables: { + input: { + incomingPaymentId: incomingPayment.id, + idempotencyKey: uuid() + } + } + }) + .then((query): LiquidityMutationResponse => { + if (query.data) { + return query.data.withdrawIncomingPaymentLiquidity + } else { + throw new Error('Data was empty') + } + }) + + expect(response.success).toBe(true) + expect(response.code).toEqual('200') + expect(response.error).toBeNull() + expect( + accountingService.getBalance(incomingPayment.id) + ).resolves.toEqual(balance - amount) + }) + }) + + describe('Cannot withdraw liquidity', () => { + test('Returns error for non-existent incoming payment id', async (): Promise => { + await WebhookEvent.query(knex).insert({ + id: uuid(), + incomingPaymentId: incomingPayment.id, + type: IncomingPaymentEventType.IncomingPaymentCompleted, + data: {}, + withdrawal: { + accountId: incomingPayment.id, + assetId: incomingPayment.asset.id, + amount + } + }) + const response = await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation WithdrawIncomingPaymentLiquidity( + $input: WithdrawIncomingPaymentLiquidityInput! + ) { + withdrawIncomingPaymentLiquidity(input: $input) { + code + success + message + error + } + } + `, + variables: { + input: { + incomingPaymentId: uuid(), + idempotencyKey: uuid() + } + } + }) + .then((query): LiquidityMutationResponse => { + if (query.data) { + return query.data.withdrawIncomingPaymentLiquidity + } else { + throw new Error('Data was empty') + } + }) + + expect(response.success).toBe(false) + expect(response.code).toEqual('400') + expect(response.message).toEqual('Invalid id') + expect(response.error).toEqual(LiquidityError.InvalidId) + }) + + test('Returns error when related webhook not found', async (): Promise => { + const response = await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation WithdrawIncomingPaymentLiquidity( + $input: WithdrawIncomingPaymentLiquidityInput! + ) { + withdrawIncomingPaymentLiquidity(input: $input) { + code + success + message + error + } + } + `, + variables: { + input: { + incomingPaymentId: incomingPayment.id, + idempotencyKey: uuid() + } + } + }) + .then((query): LiquidityMutationResponse => { + if (query.data) { + return query.data.withdrawIncomingPaymentLiquidity + } else { + throw new Error('Data was empty') + } + }) + + expect(response.success).toBe(false) + expect(response.code).toEqual('400') + expect(response.message).toEqual('Invalid id') + expect(response.error).toEqual(LiquidityError.InvalidId) + }) + + test('Returns error for already completed withdrawal', async (): Promise => { + const eventId = uuid() + await WebhookEvent.query(knex).insert({ + id: eventId, + incomingPaymentId: incomingPayment.id, + type: IncomingPaymentEventType.IncomingPaymentCompleted, + data: {}, + withdrawal: { + accountId: incomingPayment.id, + assetId: incomingPayment.asset.id, + amount + } + }) + await expect( + accountingService.createWithdrawal({ + id: eventId, + account: incomingPayment, + amount: amount + }) + ).resolves.toBeUndefined() + const response = await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation WithdrawIncomingPaymentLiquidity( + $input: WithdrawIncomingPaymentLiquidityInput! + ) { + withdrawIncomingPaymentLiquidity(input: $input) { + code + success + message + error + } + } + `, + variables: { + input: { + incomingPaymentId: incomingPayment.id, + idempotencyKey: uuid() + } + } + }) + .then((query): LiquidityMutationResponse => { + if (query.data) { + return query.data.withdrawIncomingPaymentLiquidity + } else { + throw new Error('Data was empty') + } + }) + + expect(response.success).toBe(false) + expect(response.code).toEqual('409') + expect(response.message).toEqual('Transfer exists') + expect(response.error).toEqual(LiquidityError.TransferExists) + }) + }) + }) + + describe('withdrawOutgoingPaymentLiquidity', (): void => { + const amount = BigInt(10) + + beforeEach(async (): Promise => { + await expect( + accountingService.createDeposit({ + id: uuid(), + account: outgoingPayment, + amount + }) + ).resolves.toBeUndefined() + await expect( + accountingService.getBalance(outgoingPayment.id) + ).resolves.toEqual(amount) + }) + + describe('Can withdraw liquidity', () => { + test.each([ + OutgoingPaymentEventType.PaymentCompleted, + OutgoingPaymentEventType.PaymentFailed + ])('for outgoing payment event %s', async (eventType) => { + const balance = await accountingService.getBalance(outgoingPayment.id) + assert.ok(balance === amount) + + await WebhookEvent.query(knex).insert({ + id: uuid(), + outgoingPaymentId: outgoingPayment.id, + type: eventType, + data: {}, + withdrawal: { + accountId: outgoingPayment.id, + assetId: outgoingPayment.asset.id, + amount + } + }) + + const response = await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation WithdrawOutgoingPaymentLiquidity( + $input: WithdrawOutgoingPaymentLiquidityInput! + ) { + withdrawOutgoingPaymentLiquidity(input: $input) { + code + success + message + error + } + } + `, + variables: { + input: { + outgoingPaymentId: outgoingPayment.id, + idempotencyKey: uuid() + } + } + }) + .then((query): LiquidityMutationResponse => { + if (query.data) { + return query.data.withdrawOutgoingPaymentLiquidity + } else { + throw new Error('Data was empty') + } + }) + + expect(response.success).toBe(true) + expect(response.code).toEqual('200') + expect(response.error).toBeNull() + expect( + accountingService.getBalance(outgoingPayment.id) + ).resolves.toEqual(balance - amount) + }) + }) + + describe('Cannot withdraw liquidity', () => { + test('Returns error for non-existent outgoing payment id', async (): Promise => { + const response = await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation WithdrawOutgoingPaymentLiquidity( + $input: WithdrawOutgoingPaymentLiquidityInput! + ) { + withdrawOutgoingPaymentLiquidity(input: $input) { + code + success + message + error + } + } + `, + variables: { + input: { + outgoingPaymentId: uuid(), + idempotencyKey: uuid() + } + } + }) + .then((query): LiquidityMutationResponse => { + if (query.data) { + return query.data.withdrawOutgoingPaymentLiquidity + } else { + throw new Error('Data was empty') + } + }) + expect(response.success).toBe(false) + expect(response.code).toEqual('400') + expect(response.message).toEqual('Invalid id') + expect(response.error).toEqual(LiquidityError.InvalidId) + }) + + test('Returns error when related webhook not found', async (): Promise => { + await expect( + accountingService.createWithdrawal({ + id: outgoingPayment.id, + account: outgoingPayment, + amount: amount + }) + ).resolves.toBeUndefined() + const response = await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation withdrawOutgoingPaymentLiquidity( + $input: WithdrawOutgoingPaymentLiquidityInput! + ) { + withdrawOutgoingPaymentLiquidity(input: $input) { + code + success + message + error + } + } + `, + variables: { + input: { + outgoingPaymentId: outgoingPayment.id, + idempotencyKey: uuid() + } + } + }) + .then((query): LiquidityMutationResponse => { + if (query.data) { + return query.data.withdrawOutgoingPaymentLiquidity + } else { + throw new Error('Data was empty') + } + }) + expect(response.success).toBe(false) + expect(response.code).toEqual('400') + expect(response.message).toEqual('Invalid id') + expect(response.error).toEqual(LiquidityError.InvalidId) + }) + + test('Returns error for already completed withdrawal', async (): Promise => { + await WebhookEvent.query(knex).insert({ + id: uuid(), + outgoingPaymentId: outgoingPayment.id, + type: OutgoingPaymentEventType.PaymentCompleted, + data: {}, + withdrawal: { + accountId: outgoingPayment.id, + assetId: outgoingPayment.asset.id, + amount + } + }) + await expect( + accountingService.createWithdrawal({ + id: outgoingPayment.id, + account: outgoingPayment, + amount: amount + }) + ).resolves.toBeUndefined() + const response = await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation withdrawOutgoingPaymentLiquidity( + $input: WithdrawOutgoingPaymentLiquidityInput! + ) { + withdrawOutgoingPaymentLiquidity(input: $input) { + code + success + message + error + } + } + `, + variables: { + input: { + outgoingPaymentId: outgoingPayment.id, + idempotencyKey: uuid() + } + } + }) + .then((query): LiquidityMutationResponse => { + if (query.data) { + return query.data.withdrawOutgoingPaymentLiquidity + } else { + throw new Error('Data was empty') + } + }) + expect(response.success).toBe(false) + expect(response.code).toEqual('403') + expect(response.message).toEqual('Insufficient balance') + expect(response.error).toEqual(LiquidityError.InsufficientBalance) + }) + }) + }) + + describe('depositOutgoingPaymentLiquidity', (): void => { + describe.each(Object.values(DepositEventType).map((type) => [type]))( + '%s', + (type): void => { + let eventId: string + + beforeEach(async (): Promise => { + eventId = uuid() + await OutgoingPaymentEvent.query(knex).insertAndFetch({ + id: eventId, + outgoingPaymentId: outgoingPayment.id, + type, + data: outgoingPayment.toData({ + amountSent: BigInt(0), + balance: BigInt(0) + }) + }) + }) + + test('Can deposit account liquidity', async (): Promise => { + const depositSpy = jest.spyOn(accountingService, 'createDeposit') + const response = await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation DepositLiquidity( + $input: DepositOutgoingPaymentLiquidityInput! + ) { + depositOutgoingPaymentLiquidity(input: $input) { + code + success + message + error + } + } + `, + variables: { + input: { + outgoingPaymentId: outgoingPayment.id, + idempotencyKey: uuid() + } + } + }) + .then((query): LiquidityMutationResponse => { + if (query.data) { + return query.data.depositOutgoingPaymentLiquidity + } else { + throw new Error('Data was empty') + } + }) + + expect(response.success).toBe(true) + expect(response.code).toEqual('200') + expect(response.error).toBeNull() + assert.ok(outgoingPayment.debitAmount) + await expect(depositSpy).toHaveBeenCalledWith({ + id: eventId, + account: expect.any(OutgoingPayment), + amount: outgoingPayment.debitAmount.value + }) + await expect( + accountingService.getBalance(outgoingPayment.id) + ).resolves.toEqual(outgoingPayment.debitAmount.value) + }) + + test("Can't deposit for non-existent outgoing payment id", async (): Promise => { + const response = await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation DepositLiquidity( + $input: DepositOutgoingPaymentLiquidityInput! + ) { + depositOutgoingPaymentLiquidity(input: $input) { + code + success + message + error + } + } + `, + variables: { + input: { + outgoingPaymentId: uuid(), + idempotencyKey: uuid() + } + } + }) + .then((query): LiquidityMutationResponse => { + if (query.data) { + return query.data.depositOutgoingPaymentLiquidity + } else { + throw new Error('Data was empty') + } + }) + + expect(response.success).toBe(false) + expect(response.code).toEqual('400') + expect(response.message).toEqual('Invalid id') + expect(response.error).toEqual(LiquidityError.InvalidId) + }) + + test('Returns an error for existing transfer', async (): Promise => { + await expect( + accountingService.createDeposit({ + id: eventId, + account: incomingPayment, + amount: BigInt(100) + }) + ).resolves.toBeUndefined() + const response = await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation DepositLiquidity( + $input: DepositOutgoingPaymentLiquidityInput! + ) { + depositOutgoingPaymentLiquidity(input: $input) { + code + success + message + error + } + } + `, + variables: { + input: { + outgoingPaymentId: outgoingPayment.id, + idempotencyKey: uuid() + } + } + }) + .then((query): LiquidityMutationResponse => { + if (query.data) { + return query.data.depositOutgoingPaymentLiquidity + } else { + throw new Error('Data was empty') + } + }) + + expect(response.success).toBe(false) + expect(response.code).toEqual('409') + expect(response.message).toEqual('Transfer exists') + expect(response.error).toEqual(LiquidityError.TransferExists) + }) + } + ) + }) + }) }) diff --git a/packages/backend/src/graphql/resolvers/liquidity.ts b/packages/backend/src/graphql/resolvers/liquidity.ts index 0c3d4b12d4..76b4075a24 100644 --- a/packages/backend/src/graphql/resolvers/liquidity.ts +++ b/packages/backend/src/graphql/resolvers/liquidity.ts @@ -6,7 +6,11 @@ import { LiquidityMutationResponse, WalletAddressWithdrawalMutationResponse, AssetResolvers, - PeerResolvers + PeerResolvers, + WalletAddressResolvers, + IncomingPaymentResolvers, + OutgoingPaymentResolvers, + PaymentResolvers } from '../generated/graphql' import { ApolloContext } from '../../app' import { @@ -14,34 +18,63 @@ import { isFundingError } from '../../open_payments/payment/outgoing/errors' import { - isPaymentEvent, - PaymentDepositType + isOutgoingPaymentEvent, + OutgoingPaymentDepositType, + OutgoingPaymentEventType } from '../../open_payments/payment/outgoing/model' import { PeerError } from '../../payment-method/ilp/peer/errors' +import { IncomingPaymentEventType } from '../../open_payments/payment/incoming/model' export const getAssetLiquidity: AssetResolvers['liquidity'] = async (parent, args, ctx): Promise => { - return await getLiquidity(ctx, parent.id as string) + return await getPeerOrAssetLiquidity(ctx, parent.id as string) } export const getPeerLiquidity: PeerResolvers['liquidity'] = async (parent, args, ctx): Promise => { - return await getLiquidity(ctx, parent.id as string) + return await getPeerOrAssetLiquidity(ctx, parent.id as string) + } + +export const getWalletAddressLiquidity: WalletAddressResolvers['liquidity'] = + async (parent, args, ctx): Promise => { + return (await getLiquidity(ctx, parent.id as string)) ?? null + } + +export const getIncomingPaymentLiquidity: IncomingPaymentResolvers['liquidity'] = + async (parent, args, ctx): Promise => { + return (await getLiquidity(ctx, parent.id as string)) ?? null + } + +export const getOutgoingPaymentLiquidity: OutgoingPaymentResolvers['liquidity'] = + async (parent, args, ctx): Promise => { + return (await getLiquidity(ctx, parent.id as string)) ?? null + } + +export const getPaymentLiquidity: PaymentResolvers['liquidity'] = + async (parent, args, ctx): Promise => { + return (await getLiquidity(ctx, parent.id as string)) ?? null } const getLiquidity = async ( ctx: ApolloContext, id: string -): Promise => { +): Promise => { const accountingService = await ctx.container.use('accountingService') - const liquidity = await accountingService.getBalance(id) + return await accountingService.getBalance(id) +} + +const getPeerOrAssetLiquidity = async ( + ctx: ApolloContext, + id: string +): Promise => { + const liquidity = await getLiquidity(ctx, id) if (liquidity === undefined) { throw new Error('No liquidity account found') } return liquidity } -export const addPeerLiquidity: MutationResolvers['addPeerLiquidity'] = +export const depositPeerLiquidity: MutationResolvers['depositPeerLiquidity'] = async ( parent, args, @@ -52,7 +85,7 @@ export const addPeerLiquidity: MutationResolvers['addPeerLiquidit return responses[LiquidityError.AmountZero] } const peerService = await ctx.container.use('peerService') - const peerOrError = await peerService.addLiquidity({ + const peerOrError = await peerService.depositLiquidity({ transferId: args.input.id, peerId: args.input.peerId, amount: args.input.amount @@ -79,13 +112,13 @@ export const addPeerLiquidity: MutationResolvers['addPeerLiquidit ) return { code: '400', - message: 'Error trying to add peer liquidity', + message: 'Error trying to deposit peer liquidity', success: false } } } -export const addAssetLiquidity: MutationResolvers['addAssetLiquidity'] = +export const depositAssetLiquidity: MutationResolvers['depositAssetLiquidity'] = async ( parent, args, @@ -124,7 +157,7 @@ export const addAssetLiquidity: MutationResolvers['addAssetLiquid ) return { code: '400', - message: 'Error trying to add asset liquidity', + message: 'Error trying to deposit asset liquidity', success: false } } @@ -328,8 +361,8 @@ export const voidLiquidityWithdrawal: MutationResolvers['voidLiqu } } -export const DepositEventType = PaymentDepositType -export type DepositEventType = PaymentDepositType +export const DepositEventType = OutgoingPaymentDepositType +export type DepositEventType = OutgoingPaymentDepositType // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types const isDepositEventType = (o: any): o is DepositEventType => @@ -344,7 +377,11 @@ export const depositEventLiquidity: MutationResolvers['depositEve try { const webhookService = await ctx.container.use('webhookService') const event = await webhookService.getEvent(args.input.eventId) - if (!event || !isPaymentEvent(event) || !isDepositEventType(event.type)) { + if ( + !event || + !isOutgoingPaymentEvent(event) || + !isDepositEventType(event.type) + ) { return responses[LiquidityError.InvalidId] } if (!event.data.debitAmount) { @@ -433,6 +470,183 @@ export const withdrawEventLiquidity: MutationResolvers['withdrawE } } +export const depositOutgoingPaymentLiquidity: MutationResolvers['depositOutgoingPaymentLiquidity'] = + async ( + parent, + args, + ctx + ): Promise => { + try { + const { outgoingPaymentId } = args.input + const webhookService = await ctx.container.use('webhookService') + const event = await webhookService.getLatestByResourceId({ + outgoingPaymentId, + types: [OutgoingPaymentDepositType.PaymentCreated] + }) + if (!event || !isOutgoingPaymentEvent(event)) { + return responses[LiquidityError.InvalidId] + } + + if (!event.data.debitAmount) { + throw new Error('No debit amount') + } + const outgoingPaymentService = await ctx.container.use( + 'outgoingPaymentService' + ) + const paymentOrErr = await outgoingPaymentService.fund({ + id: outgoingPaymentId, + amount: BigInt(event.data.debitAmount.value), + transferId: event.id + }) + if (isFundingError(paymentOrErr)) { + return errorToResponse(paymentOrErr) + } + return { + code: '200', + success: true, + message: 'Deposited liquidity' + } + } catch (err) { + ctx.logger.error( + { + outgoingPaymentId: args.input.outgoingPaymentId, + err + }, + 'error depositing liquidity' + ) + return { + code: '400', + message: 'Error trying to deposit liquidity', + success: false + } + } + } + +export const withdrawIncomingPaymentLiquidity: MutationResolvers['withdrawIncomingPaymentLiquidity'] = + async ( + parent, + args, + ctx + ): Promise => { + const { incomingPaymentId } = args.input + try { + const incomingPaymentService = await ctx.container.use( + 'incomingPaymentService' + ) + const incomingPayment = await incomingPaymentService.get({ + id: incomingPaymentId + }) + const webhookService = await ctx.container.use('webhookService') + const event = await webhookService.getLatestByResourceId({ + incomingPaymentId, + types: [ + IncomingPaymentEventType.IncomingPaymentCompleted, + IncomingPaymentEventType.IncomingPaymentExpired + ] + }) + if (!incomingPayment || !incomingPayment.receivedAmount || !event?.id) { + return responses[LiquidityError.InvalidId] + } + + const accountingService = await ctx.container.use('accountingService') + const error = await accountingService.createWithdrawal({ + id: event.id, + account: { + id: incomingPaymentId, + asset: incomingPayment.asset + }, + amount: incomingPayment.receivedAmount.value + }) + + if (error) { + return errorToResponse(error) + } + return { + code: '200', + success: true, + message: 'Withdrew liquidity' + } + } catch (error) { + ctx.logger.error( + { + incomingPaymentId, + error + }, + 'error withdrawing liquidity' + ) + return { + code: '400', + message: 'Error trying to withdraw liquidity', + success: false + } + } + } + +export const withdrawOutgoingPaymentLiquidity: MutationResolvers['withdrawOutgoingPaymentLiquidity'] = + async ( + parent, + args, + ctx + ): Promise => { + const { outgoingPaymentId } = args.input + try { + const outgoingPaymentService = await ctx.container.use( + 'outgoingPaymentService' + ) + const outgoingPayment = await outgoingPaymentService.get({ + id: outgoingPaymentId + }) + const webhookService = await ctx.container.use('webhookService') + const event = await webhookService.getLatestByResourceId({ + outgoingPaymentId, + types: [ + OutgoingPaymentEventType.PaymentCompleted, + OutgoingPaymentEventType.PaymentFailed + ] + }) + if (!outgoingPayment || !event?.id) { + return responses[LiquidityError.InvalidId] + } + + const accountingService = await ctx.container.use('accountingService') + const balance = await accountingService.getBalance(outgoingPayment.id) + if (!balance) { + return responses[LiquidityError.InsufficientBalance] + } + + const error = await accountingService.createWithdrawal({ + id: event.id, + account: { + id: outgoingPaymentId, + asset: outgoingPayment.asset + }, + amount: balance + }) + + if (error) { + return errorToResponse(error) + } + return { + code: '200', + success: true, + message: 'Withdrew liquidity' + } + } catch (error) { + ctx.logger.error( + { + outgoingPaymentId, + error + }, + 'error withdrawing liquidity' + ) + return { + code: '400', + message: 'Error trying to withdraw liquidity', + success: false + } + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types const isLiquidityError = (o: any): o is LiquidityError => Object.values(LiquidityError).includes(o) diff --git a/packages/backend/src/graphql/resolvers/outgoing_payment.test.ts b/packages/backend/src/graphql/resolvers/outgoing_payment.test.ts index 70fc8fe17b..9a29e682d9 100644 --- a/packages/backend/src/graphql/resolvers/outgoing_payment.test.ts +++ b/packages/backend/src/graphql/resolvers/outgoing_payment.test.ts @@ -127,6 +127,7 @@ describe('OutgoingPayment Resolvers', (): void => { error stateAttempts receiver + liquidity debitAmount { value assetCode @@ -169,6 +170,7 @@ describe('OutgoingPayment Resolvers', (): void => { error, stateAttempts: 0, receiver: payment.receiver, + liquidity: '0', debitAmount: { value: payment.debitAmount.value.toString(), assetCode: payment.debitAmount.assetCode, @@ -205,6 +207,36 @@ describe('OutgoingPayment Resolvers', (): void => { }) } ) + + test('200 - with added liquidity', async (): Promise => { + await accountingService.createDeposit({ + id: uuid(), + account: payment, + amount: 100n + }) + + const query = await appContainer.apolloClient + .query({ + query: gql` + query OutgoingPayment($paymentId: String!) { + outgoingPayment(id: $paymentId) { + id + liquidity + } + } + `, + variables: { + paymentId: payment.id + } + }) + .then((query): OutgoingPayment => query.data?.outgoingPayment) + + expect(query).toEqual({ + id: payment.id, + liquidity: '100', + __typename: 'OutgoingPayment' + }) + }) }) test('404', async (): Promise => { diff --git a/packages/backend/src/graphql/resolvers/wallet_address.test.ts b/packages/backend/src/graphql/resolvers/wallet_address.test.ts index 947cdc3426..7d27b77042 100644 --- a/packages/backend/src/graphql/resolvers/wallet_address.test.ts +++ b/packages/backend/src/graphql/resolvers/wallet_address.test.ts @@ -380,10 +380,11 @@ describe('Wallet Address Resolvers', (): void => { ${'Alice'} ${undefined} `( - 'Can get an wallet address (publicName: $publicName)', + 'Can get a wallet address (publicName: $publicName)', async ({ publicName }): Promise => { const walletAddress = await createWalletAddress(deps, { - publicName + publicName, + createLiquidityAccount: true }) const query = await appContainer.apolloClient .query({ @@ -391,6 +392,7 @@ describe('Wallet Address Resolvers', (): void => { query WalletAddress($walletAddressId: String!) { walletAddress(id: $walletAddressId) { id + liquidity asset { code scale @@ -415,6 +417,7 @@ describe('Wallet Address Resolvers', (): void => { expect(query).toEqual({ __typename: 'WalletAddress', id: walletAddress.id, + liquidity: '0', asset: { __typename: 'Asset', code: walletAddress.asset.code, diff --git a/packages/backend/src/graphql/resolvers/webhooks.test.ts b/packages/backend/src/graphql/resolvers/webhooks.test.ts index 34f0056fac..b5ecda5ccb 100644 --- a/packages/backend/src/graphql/resolvers/webhooks.test.ts +++ b/packages/backend/src/graphql/resolvers/webhooks.test.ts @@ -7,11 +7,7 @@ import { initIocContainer } from '../..' import { Config } from '../../config/app' import { truncateTables } from '../../tests/tableManager' import { WebhookEventsConnection } from '../generated/graphql' -import { - createWebhookEvent, - randomWebhookEvent, - webhookEventTypes -} from '../../tests/webhook' +import { createWebhookEvent, webhookEventTypes } from '../../tests/webhook' import { WebhookEvent } from '../../webhook/model' describe('Webhook Events Query', (): void => { @@ -45,10 +41,7 @@ describe('Webhook Events Query', (): void => { for (let i = 0; i < webhookEventTypes.length; i++) { for (let j = 0; j < numOfEachEventType; j++) { webhookEvents.push( - await createWebhookEvent( - deps, - randomWebhookEvent({ type: webhookEventTypes[i] }) - ) + await createWebhookEvent(deps, { type: webhookEventTypes[i] }) ) } } diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index 52ce37fb80..e350175ef0 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -102,8 +102,10 @@ type Mutation { "Update an asset" updateAsset(input: UpdateAssetInput!): AssetMutationResponse! - "Add asset liquidity" - addAssetLiquidity(input: AddAssetLiquidityInput!): LiquidityMutationResponse + "Deposit asset liquidity" + depositAssetLiquidity( + input: DepositAssetLiquidityInput! + ): LiquidityMutationResponse "Withdraw asset liquidity" createAssetLiquidityWithdrawal( @@ -124,8 +126,10 @@ type Mutation { "Delete a peer" deletePeer(input: DeletePeerInput!): DeletePeerMutationResponse! - "Add peer liquidity" - addPeerLiquidity(input: AddPeerLiquidityInput!): LiquidityMutationResponse + "Deposit peer liquidity" + depositPeerLiquidity( + input: DepositPeerLiquidityInput! + ): LiquidityMutationResponse "Withdraw peer liquidity" createPeerLiquidityWithdrawal( @@ -182,11 +186,30 @@ type Mutation { depositEventLiquidity( input: DepositEventLiquidityInput! ): LiquidityMutationResponse + @deprecated(reason: "Use `depositOutgoingPaymentLiquidity`") "Withdraw webhook event liquidity" withdrawEventLiquidity( input: WithdrawEventLiquidityInput! ): LiquidityMutationResponse + @deprecated( + reason: "Use `withdrawOutgoingPaymentLiquidity, withdrawIncomingPaymentLiquidity, or createWalletAddressWithdrawal`" + ) + + "Withdraw incoming payment liquidity" + withdrawIncomingPaymentLiquidity( + input: WithdrawIncomingPaymentLiquidityInput! + ): LiquidityMutationResponse + + "Withdraw outgoing payment liquidity" + withdrawOutgoingPaymentLiquidity( + input: WithdrawOutgoingPaymentLiquidityInput! + ): LiquidityMutationResponse + + "Deposit outgoing payment liquidity" + depositOutgoingPaymentLiquidity( + input: DepositOutgoingPaymentLiquidityInput! + ): LiquidityMutationResponse "Withdraw liquidity from a wallet address received via Web Monetization." createWalletAddressWithdrawal( @@ -270,7 +293,7 @@ input CreatePeerInput { name: String "Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this value" liquidityThreshold: UInt64 - "Initial amount of liquidity to add for peer" + "Initial amount of liquidity to deposit for peer" initialLiquidity: UInt64 "Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence)" idempotencyKey: String @@ -287,8 +310,8 @@ input CreateOrUpdatePeerByUrlInput { name: String "Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this value" liquidityThreshold: UInt64 - "Initial amount of liquidity to add for peer" - addedLiquidity: UInt64 + "Amount of liquidity to deposit for peer" + liquidityToDeposit: UInt64 "Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence)" idempotencyKey: String } @@ -329,10 +352,10 @@ input HttpOutgoingInput { endpoint: String! } -input AddPeerLiquidityInput { - "The id of the peer to add liquidity." +input DepositPeerLiquidityInput { + "The id of the peer to deposit liquidity." peerId: String! - "Amount of liquidity to add." + "Amount of liquidity to deposit." amount: UInt64! "The id of the transfer." id: String! @@ -340,10 +363,10 @@ input AddPeerLiquidityInput { idempotencyKey: String! } -input AddAssetLiquidityInput { - "The id of the asset to add liquidity." +input DepositAssetLiquidityInput { + "The id of the asset to deposit liquidity." assetId: String! - "Amount of liquidity to add." + "Amount of liquidity to deposit." amount: UInt64! "The id of the transfer." id: String! @@ -387,6 +410,27 @@ input VoidLiquidityWithdrawalInput { idempotencyKey: String! } +input DepositOutgoingPaymentLiquidityInput { + "The id of the outgoing payment to deposit into." + outgoingPaymentId: String! + "Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence)" + idempotencyKey: String! +} + +input WithdrawIncomingPaymentLiquidityInput { + "The id of the incoming payment to withdraw from." + incomingPaymentId: String! + "Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence)" + idempotencyKey: String! +} + +input WithdrawOutgoingPaymentLiquidityInput { + "The id of the outgoing payment to withdraw from." + outgoingPaymentId: String! + "Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence)" + idempotencyKey: String! +} + input DepositEventLiquidityInput { "The id of the event to deposit into." eventId: String! @@ -549,6 +593,8 @@ type WalletAddress implements Model { id: ID! "Asset of the wallet address" asset: Asset! + "Available liquidity" + liquidity: UInt64 "Wallet Address URL" url: String! "Public name associated with the wallet address" @@ -627,6 +673,8 @@ type IncomingPayment implements BasePayment & Model { id: ID! "Id of the wallet address under which this incoming payment was created" walletAddressId: ID! + "Available liquidity" + liquidity: UInt64 "Incoming payment state" state: IncomingPaymentState! "Date-time of expiry. After this time, the incoming payment will not accept further payments made to it." @@ -696,6 +744,8 @@ type OutgoingPayment implements BasePayment & Model { id: ID! "Id of the wallet address under which this outgoing payment was created" walletAddressId: ID! + "Available liquidity" + liquidity: UInt64 "Outgoing payment state" state: OutgoingPaymentState! error: String @@ -746,6 +796,8 @@ type Payment implements BasePayment & Model { walletAddressId: ID! "Either the IncomingPaymentState or OutgoingPaymentState according to type" state: String! + "Available liquidity" + liquidity: UInt64 "Additional metadata associated with the payment." metadata: JSONObject "Date-time of creation" diff --git a/packages/backend/src/open_payments/payment/incoming/model.test.ts b/packages/backend/src/open_payments/payment/incoming/model.test.ts index e5bfd3f8d3..e4577af3ad 100644 --- a/packages/backend/src/open_payments/payment/incoming/model.test.ts +++ b/packages/backend/src/open_payments/payment/incoming/model.test.ts @@ -9,30 +9,24 @@ import { truncateTables } from '../../../tests/tableManager' import { IlpStreamCredentials } from '../../../payment-method/ilp/stream-credentials/service' import { serializeAmount } from '../../amount' import { IlpAddress } from 'ilp-packet' -import { IncomingPayment, IncomingPaymentState } from './model' +import { + IncomingPayment, + IncomingPaymentEvent, + IncomingPaymentEventType, + IncomingPaymentState, + IncomingPaymentEventError +} from './model' import { WalletAddress } from '../../wallet_address/model' -describe('Incoming Payment Model', (): void => { +describe('Models', (): void => { let deps: IocContract let appContainer: TestContainer - let walletAddress: WalletAddress - let baseUrl: string - let incomingPayment: IncomingPayment beforeAll(async (): Promise => { deps = initIocContainer(Config) appContainer = await createTestApp(deps) }) - beforeEach(async (): Promise => { - walletAddress = await createWalletAddress(deps) - baseUrl = new URL(walletAddress.url).origin - incomingPayment = await createIncomingPayment(deps, { - walletAddressId: walletAddress.id, - metadata: { description: 'my payment' } - }) - }) - afterEach(async (): Promise => { jest.useRealTimers() await truncateTables(appContainer.knex) @@ -42,82 +36,40 @@ describe('Incoming Payment Model', (): void => { await appContainer.shutdown() }) - describe('toOpenPaymentsType', () => { - test('returns incoming payment', async () => { - expect(incomingPayment.toOpenPaymentsType(walletAddress)).toEqual({ - id: `${baseUrl}${IncomingPayment.urlPath}/${incomingPayment.id}`, - walletAddress: walletAddress.url, - completed: incomingPayment.completed, - receivedAmount: serializeAmount(incomingPayment.receivedAmount), - incomingAmount: incomingPayment.incomingAmount - ? serializeAmount(incomingPayment.incomingAmount) - : undefined, - expiresAt: incomingPayment.expiresAt.toISOString(), - metadata: incomingPayment.metadata ?? undefined, - updatedAt: incomingPayment.updatedAt.toISOString(), - createdAt: incomingPayment.createdAt.toISOString() - }) - }) - }) - - describe('toOpenPaymentsTypeWithMethods', () => { - test('returns incoming payment with payment methods', async () => { - const streamCredentials: IlpStreamCredentials = { - ilpAddress: 'test.ilp' as IlpAddress, - sharedSecret: Buffer.from('') - } + describe('Incoming Payment Model', (): void => { + let walletAddress: WalletAddress + let baseUrl: string + let incomingPayment: IncomingPayment - expect( - incomingPayment.toOpenPaymentsTypeWithMethods( - walletAddress, - streamCredentials - ) - ).toEqual({ - id: `${baseUrl}${IncomingPayment.urlPath}/${incomingPayment.id}`, - walletAddress: walletAddress.url, - completed: incomingPayment.completed, - receivedAmount: serializeAmount(incomingPayment.receivedAmount), - incomingAmount: incomingPayment.incomingAmount - ? serializeAmount(incomingPayment.incomingAmount) - : undefined, - expiresAt: incomingPayment.expiresAt.toISOString(), - metadata: incomingPayment.metadata ?? undefined, - updatedAt: incomingPayment.updatedAt.toISOString(), - createdAt: incomingPayment.createdAt.toISOString(), - methods: [ - { - type: 'ilp', - ilpAddress: 'test.ilp', - sharedSecret: expect.any(String) - } - ] + beforeEach(async (): Promise => { + walletAddress = await createWalletAddress(deps) + baseUrl = new URL(walletAddress.url).origin + incomingPayment = await createIncomingPayment(deps, { + walletAddressId: walletAddress.id, + metadata: { description: 'my payment' } }) }) - test('returns incoming payment with empty methods when stream credentials are undefined', async () => { - expect( - incomingPayment.toOpenPaymentsTypeWithMethods(walletAddress) - ).toEqual({ - id: `${baseUrl}${IncomingPayment.urlPath}/${incomingPayment.id}`, - walletAddress: walletAddress.url, - completed: incomingPayment.completed, - receivedAmount: serializeAmount(incomingPayment.receivedAmount), - incomingAmount: incomingPayment.incomingAmount - ? serializeAmount(incomingPayment.incomingAmount) - : undefined, - expiresAt: incomingPayment.expiresAt.toISOString(), - metadata: incomingPayment.metadata ?? undefined, - updatedAt: incomingPayment.updatedAt.toISOString(), - createdAt: incomingPayment.createdAt.toISOString(), - methods: [] + describe('toOpenPaymentsType', () => { + test('returns incoming payment', async () => { + expect(incomingPayment.toOpenPaymentsType(walletAddress)).toEqual({ + id: `${baseUrl}${IncomingPayment.urlPath}/${incomingPayment.id}`, + walletAddress: walletAddress.url, + completed: incomingPayment.completed, + receivedAmount: serializeAmount(incomingPayment.receivedAmount), + incomingAmount: incomingPayment.incomingAmount + ? serializeAmount(incomingPayment.incomingAmount) + : undefined, + expiresAt: incomingPayment.expiresAt.toISOString(), + metadata: incomingPayment.metadata ?? undefined, + updatedAt: incomingPayment.updatedAt.toISOString(), + createdAt: incomingPayment.createdAt.toISOString() + }) }) }) - test.each([IncomingPaymentState.Completed, IncomingPaymentState.Expired])( - 'returns incoming payment with empty methods if payment state is %s', - async (paymentState): Promise => { - incomingPayment.state = paymentState - + describe('toOpenPaymentsTypeWithMethods', () => { + test('returns incoming payment with payment methods', async () => { const streamCredentials: IlpStreamCredentials = { ilpAddress: 'test.ilp' as IlpAddress, sharedSecret: Buffer.from('') @@ -128,6 +80,31 @@ describe('Incoming Payment Model', (): void => { walletAddress, streamCredentials ) + ).toEqual({ + id: `${baseUrl}${IncomingPayment.urlPath}/${incomingPayment.id}`, + walletAddress: walletAddress.url, + completed: incomingPayment.completed, + receivedAmount: serializeAmount(incomingPayment.receivedAmount), + incomingAmount: incomingPayment.incomingAmount + ? serializeAmount(incomingPayment.incomingAmount) + : undefined, + expiresAt: incomingPayment.expiresAt.toISOString(), + metadata: incomingPayment.metadata ?? undefined, + updatedAt: incomingPayment.updatedAt.toISOString(), + createdAt: incomingPayment.createdAt.toISOString(), + methods: [ + { + type: 'ilp', + ilpAddress: 'test.ilp', + sharedSecret: expect.any(String) + } + ] + }) + }) + + test('returns incoming payment with empty methods when stream credentials are undefined', async () => { + expect( + incomingPayment.toOpenPaymentsTypeWithMethods(walletAddress) ).toEqual({ id: `${baseUrl}${IncomingPayment.urlPath}/${incomingPayment.id}`, walletAddress: walletAddress.url, @@ -142,7 +119,59 @@ describe('Incoming Payment Model', (): void => { createdAt: incomingPayment.createdAt.toISOString(), methods: [] }) - } - ) + }) + + test.each([IncomingPaymentState.Completed, IncomingPaymentState.Expired])( + 'returns incoming payment with empty methods if payment state is %s', + async (paymentState): Promise => { + incomingPayment.state = paymentState + + const streamCredentials: IlpStreamCredentials = { + ilpAddress: 'test.ilp' as IlpAddress, + sharedSecret: Buffer.from('') + } + + expect( + incomingPayment.toOpenPaymentsTypeWithMethods( + walletAddress, + streamCredentials + ) + ).toEqual({ + id: `${baseUrl}${IncomingPayment.urlPath}/${incomingPayment.id}`, + walletAddress: walletAddress.url, + completed: incomingPayment.completed, + receivedAmount: serializeAmount(incomingPayment.receivedAmount), + incomingAmount: incomingPayment.incomingAmount + ? serializeAmount(incomingPayment.incomingAmount) + : undefined, + expiresAt: incomingPayment.expiresAt.toISOString(), + metadata: incomingPayment.metadata ?? undefined, + updatedAt: incomingPayment.updatedAt.toISOString(), + createdAt: incomingPayment.createdAt.toISOString(), + methods: [] + }) + } + ) + }) + }) + + describe('Incoming Payment Event Model', (): void => { + describe('beforeInsert', (): void => { + test.each( + Object.values(IncomingPaymentEventType).map((type) => ({ + type, + error: IncomingPaymentEventError.IncomingPaymentIdRequired + })) + )( + 'Incoming Payment Id is required', + async ({ type, error }): Promise => { + expect( + IncomingPaymentEvent.query().insert({ + type + }) + ).rejects.toThrow(error) + } + ) + }) }) }) diff --git a/packages/backend/src/open_payments/payment/incoming/model.ts b/packages/backend/src/open_payments/payment/incoming/model.ts index 53de59b5e3..de6069ae2f 100644 --- a/packages/backend/src/open_payments/payment/incoming/model.ts +++ b/packages/backend/src/open_payments/payment/incoming/model.ts @@ -1,4 +1,4 @@ -import { Model } from 'objection' +import { Model, QueryContext } from 'objection' import { Amount, AmountJSON, serializeAmount } from '../../amount' import { IlpStreamCredentials } from '../../../payment-method/ilp/stream-credentials/service' @@ -50,9 +50,21 @@ export interface IncomingPaymentResponse { export type IncomingPaymentData = IncomingPaymentResponse & Record +export enum IncomingPaymentEventError { + IncomingPaymentIdRequired = 'Incoming Payment ID is required for incoming payment events' +} + export class IncomingPaymentEvent extends WebhookEvent { public type!: IncomingPaymentEventType public data!: IncomingPaymentData + + public $beforeInsert(context: QueryContext): void { + super.$beforeInsert(context) + + if (!this.incomingPaymentId) { + throw new Error(IncomingPaymentEventError.IncomingPaymentIdRequired) + } + } } export class IncomingPayment diff --git a/packages/backend/src/open_payments/payment/incoming/service.test.ts b/packages/backend/src/open_payments/payment/incoming/service.test.ts index 366f2a88aa..b99cf8b7d8 100644 --- a/packages/backend/src/open_payments/payment/incoming/service.test.ts +++ b/packages/backend/src/open_payments/payment/incoming/service.test.ts @@ -507,6 +507,7 @@ describe('Incoming Payment Service', (): void => { ) await expect( IncomingPaymentEvent.query(knex).where({ + incomingPaymentId: incomingPayment.id, type: eventType, withdrawalAccountId: incomingPayment.id, withdrawalAmount: amountReceived diff --git a/packages/backend/src/open_payments/payment/incoming/service.ts b/packages/backend/src/open_payments/payment/incoming/service.ts index 607de37a1a..533c687f04 100644 --- a/packages/backend/src/open_payments/payment/incoming/service.ts +++ b/packages/backend/src/open_payments/payment/incoming/service.ts @@ -130,6 +130,7 @@ async function createIncomingPayment( .withGraphFetched('[asset, walletAddress]') await IncomingPaymentEvent.query(trx || deps.knex).insert({ + incomingPaymentId: incomingPayment.id, type: IncomingPaymentEventType.IncomingPaymentCreated, data: incomingPayment.toData(BigInt(0)) }) @@ -228,6 +229,7 @@ async function handleDeactivated( deps.logger.trace({ type }, 'creating incoming payment webhook event') await IncomingPaymentEvent.query(deps.knex).insert({ + incomingPaymentId: incomingPayment.id, type, data: incomingPayment.toData(amountReceived), withdrawal: { diff --git a/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts b/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts index 6a09b7d311..a9d613f827 100644 --- a/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts +++ b/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts @@ -2,8 +2,8 @@ import { LifecycleError } from './errors' import { OutgoingPayment, OutgoingPaymentState, - PaymentEvent, - PaymentEventType + OutgoingPaymentEvent, + OutgoingPaymentEventType } from './model' import { ServiceDependencies } from './service' import { Receiver } from '../../receiver/model' @@ -131,7 +131,7 @@ export async function handleFailed( state: OutgoingPaymentState.Failed, error }) - await sendWebhookEvent(deps, payment, PaymentEventType.PaymentFailed) + await sendWebhookEvent(deps, payment, OutgoingPaymentEventType.PaymentFailed) } async function handleCompleted( @@ -141,13 +141,17 @@ async function handleCompleted( await payment.$query(deps.knex).patch({ state: OutgoingPaymentState.Completed }) - await sendWebhookEvent(deps, payment, PaymentEventType.PaymentCompleted) + await sendWebhookEvent( + deps, + payment, + OutgoingPaymentEventType.PaymentCompleted + ) } export async function sendWebhookEvent( deps: ServiceDependencies, payment: OutgoingPayment, - type: PaymentEventType + type: OutgoingPaymentEventType ): Promise { // TigerBeetle accounts are only created as the OutgoingPayment is funded. // So default the amountSent and balance to 0 for outgoing payments still in the funding state @@ -171,7 +175,9 @@ export async function sendWebhookEvent( amount: balance } : undefined - await PaymentEvent.query(deps.knex).insert({ + + await OutgoingPaymentEvent.query(deps.knex).insert({ + outgoingPaymentId: payment.id, type, data: payment.toData({ amountSent, balance }), withdrawal diff --git a/packages/backend/src/open_payments/payment/outgoing/model.test.ts b/packages/backend/src/open_payments/payment/outgoing/model.test.ts new file mode 100644 index 0000000000..f724192d6c --- /dev/null +++ b/packages/backend/src/open_payments/payment/outgoing/model.test.ts @@ -0,0 +1,50 @@ +import { Knex } from 'knex' +import { Config } from '../../../config/app' +import { createTestApp, TestContainer } from '../../../tests/app' +import { IocContract } from '@adonisjs/fold' +import { initIocContainer } from '../../..' +import { AppServices } from '../../../app' +import { truncateTables } from '../../../tests/tableManager' +import { + OutgoingPaymentEventError, + OutgoingPaymentEvent, + OutgoingPaymentEventType +} from './model' + +describe('Outgoing Payment Event Model', (): void => { + let deps: IocContract + let appContainer: TestContainer + let knex: Knex + + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + knex = await deps.use('knex') + }) + + afterEach(async (): Promise => { + await truncateTables(appContainer.knex) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('beforeInsert', (): void => { + test.each( + Object.values(OutgoingPaymentEventType).map((type) => ({ + type, + error: OutgoingPaymentEventError.OutgoingPaymentIdRequired + })) + )( + 'Outgoing Payment Id is required', + async ({ type, error }): Promise => { + expect( + OutgoingPaymentEvent.query(knex).insert({ + type + }) + ).rejects.toThrow(error) + } + ) + }) +}) diff --git a/packages/backend/src/open_payments/payment/outgoing/model.ts b/packages/backend/src/open_payments/payment/outgoing/model.ts index 2a25f6149b..aa4b9f367f 100644 --- a/packages/backend/src/open_payments/payment/outgoing/model.ts +++ b/packages/backend/src/open_payments/payment/outgoing/model.ts @@ -185,20 +185,22 @@ export enum OutgoingPaymentState { Completed = 'COMPLETED' } -export enum PaymentDepositType { +export enum OutgoingPaymentDepositType { PaymentCreated = 'outgoing_payment.created' } -export enum PaymentWithdrawType { +export enum OutgoingPaymentWithdrawType { PaymentFailed = 'outgoing_payment.failed', PaymentCompleted = 'outgoing_payment.completed' } -export const PaymentEventType = { - ...PaymentDepositType, - ...PaymentWithdrawType +export const OutgoingPaymentEventType = { + ...OutgoingPaymentDepositType, + ...OutgoingPaymentWithdrawType } -export type PaymentEventType = PaymentDepositType | PaymentWithdrawType +export type OutgoingPaymentEventType = + | OutgoingPaymentDepositType + | OutgoingPaymentWithdrawType export interface OutgoingPaymentResponse { id: string @@ -221,15 +223,29 @@ export type PaymentData = Omit & { peerId?: string } -// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types -export const isPaymentEventType = (o: any): o is PaymentEventType => - Object.values(PaymentEventType).includes(o) +export const isOutgoingPaymentEventType = ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types + o: any +): o is OutgoingPaymentEventType => + Object.values(OutgoingPaymentEventType).includes(o) // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types -export const isPaymentEvent = (o: any): o is PaymentEvent => - o instanceof WebhookEvent && isPaymentEventType(o.type) +export const isOutgoingPaymentEvent = (o: any): o is OutgoingPaymentEvent => + o instanceof WebhookEvent && isOutgoingPaymentEventType(o.type) + +export enum OutgoingPaymentEventError { + OutgoingPaymentIdRequired = 'Outgoing Payment ID is required for outgoing payment events' +} -export class PaymentEvent extends WebhookEvent { - public type!: PaymentEventType +export class OutgoingPaymentEvent extends WebhookEvent { + public type!: OutgoingPaymentEventType public data!: PaymentData + + public $beforeInsert(context: QueryContext): void { + super.$beforeInsert(context) + + if (!this.outgoingPaymentId) { + throw new Error(OutgoingPaymentEventError.OutgoingPaymentIdRequired) + } + } } 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 89a9c5f749..40f1a56505 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.test.ts @@ -32,8 +32,8 @@ import { OutgoingPaymentGrant, OutgoingPaymentState, PaymentData, - PaymentEvent, - PaymentEventType + OutgoingPaymentEvent, + OutgoingPaymentEventType } from './model' import { RETRY_BACKOFF_SECONDS } from './worker' import { IncomingPayment, IncomingPaymentState } from '../incoming/model' @@ -81,12 +81,12 @@ describe('OutgoingPaymentService', (): void => { const exchangeRate = 0.5 const webhookTypes: { - [key in OutgoingPaymentState]: PaymentEventType | undefined + [key in OutgoingPaymentState]: OutgoingPaymentEventType | undefined } = { - [OutgoingPaymentState.Funding]: PaymentEventType.PaymentCreated, + [OutgoingPaymentState.Funding]: OutgoingPaymentEventType.PaymentCreated, [OutgoingPaymentState.Sending]: undefined, - [OutgoingPaymentState.Failed]: PaymentEventType.PaymentFailed, - [OutgoingPaymentState.Completed]: PaymentEventType.PaymentCompleted + [OutgoingPaymentState.Failed]: OutgoingPaymentEventType.PaymentFailed, + [OutgoingPaymentState.Completed]: OutgoingPaymentEventType.PaymentCompleted } async function processNext( @@ -104,7 +104,7 @@ describe('OutgoingPaymentService', (): void => { const type = webhookTypes[payment.state] if (type) { await expect( - PaymentEvent.query(knex).where({ + OutgoingPaymentEvent.query(knex).where({ type }) ).resolves.not.toHaveLength(0) @@ -216,7 +216,7 @@ describe('OutgoingPaymentService', (): void => { } if (withdrawAmount !== undefined && withdrawAmount > 0) { await expect( - PaymentEvent.query(knex).where({ + OutgoingPaymentEvent.query(knex).where({ withdrawalAccountId: payment.id, withdrawalAmount: withdrawAmount }) @@ -379,12 +379,13 @@ describe('OutgoingPaymentService', (): void => { expectedPaymentData.peerId = peer.id } await expect( - PaymentEvent.query(knex).where({ - type: PaymentEventType.PaymentCreated + OutgoingPaymentEvent.query(knex).where({ + type: OutgoingPaymentEventType.PaymentCreated }) ).resolves.toMatchObject([ { - data: expectedPaymentData + data: expectedPaymentData, + outgoingPaymentId: payment.id } ]) } diff --git a/packages/backend/src/open_payments/payment/outgoing/service.ts b/packages/backend/src/open_payments/payment/outgoing/service.ts index 676290d77b..5449507e52 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.ts @@ -15,7 +15,7 @@ import { OutgoingPayment, OutgoingPaymentGrant, OutgoingPaymentState, - PaymentEventType + OutgoingPaymentEventType } from './model' import { Grant } from '../../auth/middleware' import { @@ -171,7 +171,7 @@ async function createOutgoingPayment( knex: trx }, payment, - PaymentEventType.PaymentCreated + OutgoingPaymentEventType.PaymentCreated ) return await addSentAmount(deps, payment, BigInt(0)) }) diff --git a/packages/backend/src/open_payments/wallet_address/model.test.ts b/packages/backend/src/open_payments/wallet_address/model.test.ts index b7145ef43a..f14164e652 100644 --- a/packages/backend/src/open_payments/wallet_address/model.test.ts +++ b/packages/backend/src/open_payments/wallet_address/model.test.ts @@ -7,7 +7,10 @@ import { WalletAddress, WalletAddressSubresource, GetOptions, - ListOptions + ListOptions, + WalletAddressEventError, + WalletAddressEventType, + WalletAddressEvent } from './model' import { Grant } from '../auth/middleware' import { @@ -27,6 +30,7 @@ import { Config } from '../../config/app' import { IocContract } from '@adonisjs/fold' import assert from 'assert' import { ReadContextWithAuthenticatedStatus } from '../payment/incoming/routes' +import { Knex } from 'knex' export interface SetupOptions { reqOpts: httpMocks.RequestOptions @@ -351,12 +355,14 @@ export const getRouteTests = ({ } } -describe('Wallet Address Model', (): void => { +describe('Models', (): void => { let deps: IocContract let appContainer: TestContainer + let knex: Knex beforeAll(async (): Promise => { deps = initIocContainer(Config) + knex = await deps.use('knex') appContainer = await createTestApp(deps) }) @@ -368,43 +374,82 @@ describe('Wallet Address Model', (): void => { await appContainer.shutdown() }) - describe('deactivatedAt', () => { - const getDateRelativeToToday = (daysFromToday: number) => { - const d = new Date() - d.setDate(d.getDate() + daysFromToday) - return d - } - - const deactivatedAtCases = [ - { - value: null, - expectedIsActive: true, - description: 'No deactivatedAt is active' - }, - { - value: getDateRelativeToToday(1), - expectedIsActive: true, - description: 'Future deactivatedAt is inactive' - }, - { - value: getDateRelativeToToday(-1), - expectedIsActive: false, - description: 'Past deactivatedAt is inactive' + describe('Wallet Address Model', (): void => { + describe('deactivatedAt', () => { + const getDateRelativeToToday = (daysFromToday: number) => { + const d = new Date() + d.setDate(d.getDate() + daysFromToday) + return d } - ] - test.each(deactivatedAtCases)( - '$description', - async ({ value, expectedIsActive }) => { - const walletAddress = await createWalletAddress(deps) - if (value) { - await walletAddress - .$query(appContainer.knex) - .patch({ deactivatedAt: value }) - assert.ok(walletAddress.deactivatedAt === value) + const deactivatedAtCases = [ + { + value: null, + expectedIsActive: true, + description: 'No deactivatedAt is active' + }, + { + value: getDateRelativeToToday(1), + expectedIsActive: true, + description: 'Future deactivatedAt is inactive' + }, + { + value: getDateRelativeToToday(-1), + expectedIsActive: false, + description: 'Past deactivatedAt is inactive' + } + ] + + test.each(deactivatedAtCases)( + '$description', + async ({ value, expectedIsActive }) => { + const walletAddress = await createWalletAddress(deps) + if (value) { + await walletAddress + .$query(appContainer.knex) + .patch({ deactivatedAt: value }) + assert.ok(walletAddress.deactivatedAt === value) + } + expect(walletAddress.isActive).toEqual(expectedIsActive) } - expect(walletAddress.isActive).toEqual(expectedIsActive) - } - ) + ) + }) + }) + + describe('Wallet Address Event Model', (): void => { + describe('beforeInsert', (): void => { + test.each([ + { + type: WalletAddressEventType.WalletAddressWebMonetization, + error: WalletAddressEventError.WalletAddressIdRequired + } + ])( + 'Wallet Address Id is required for event type: $type', + async ({ type, error }): Promise => { + expect( + WalletAddressEvent.query(knex).insert({ + type + }) + ).rejects.toThrow(error) + } + ) + + test.each([ + { + type: WalletAddressEventType.WalletAddressNotFound, + error: WalletAddressEventError.WalletAddressIdProhibited + } + ])( + 'Wallet Address Id is prohibited for event type: $type', + async ({ type, error }): Promise => { + expect( + WalletAddressEvent.query(knex).insert({ + walletAddressId: uuid(), + type + }) + ).rejects.toThrow(error) + } + ) + }) }) }) diff --git a/packages/backend/src/open_payments/wallet_address/model.ts b/packages/backend/src/open_payments/wallet_address/model.ts index e7bbd8f341..29bb83c35f 100644 --- a/packages/backend/src/open_payments/wallet_address/model.ts +++ b/packages/backend/src/open_payments/wallet_address/model.ts @@ -1,4 +1,4 @@ -import { Model, Page } from 'objection' +import { Model, Page, QueryContext } from 'objection' import { WalletAddress as OpenPaymentsWalletAddress } from '@interledger/open-payments' import { LiquidityAccount, OnCreditOptions } from '../../accounting/service' import { ConnectorAccount } from '../../payment-method/ilp/connector/core/rafiki' @@ -127,9 +127,30 @@ export type WalletAddressRequestedData = { walletAddressUrl: string } +export enum WalletAddressEventError { + WalletAddressIdRequired = 'Wallet Address ID is required for this wallet address event', + WalletAddressIdProhibited = 'Wallet Address ID is not allowed for this wallet address event' +} + export class WalletAddressEvent extends WebhookEvent { public type!: WalletAddressEventType public data!: WalletAddressData | WalletAddressRequestedData + + public $beforeInsert(context: QueryContext): void { + super.$beforeInsert(context) + + if ( + this.type === WalletAddressEventType.WalletAddressNotFound && + this.walletAddressId + ) { + throw new Error(WalletAddressEventError.WalletAddressIdProhibited) + } else if ( + this.type !== WalletAddressEventType.WalletAddressNotFound && + !this.walletAddressId + ) { + throw new Error(WalletAddressEventError.WalletAddressIdRequired) + } + } } export interface GetOptions { diff --git a/packages/backend/src/open_payments/wallet_address/service.ts b/packages/backend/src/open_payments/wallet_address/service.ts index 2aedaee0f4..e1907c3b97 100644 --- a/packages/backend/src/open_payments/wallet_address/service.ts +++ b/packages/backend/src/open_payments/wallet_address/service.ts @@ -316,6 +316,7 @@ async function createWithdrawalEvent( deps.logger.trace({ amount }, 'creating webhook withdrawal event') await WalletAddressEvent.query(deps.knex).insert({ + walletAddressId: walletAddress.id, type: WalletAddressEventType.WalletAddressWebMonetization, data: walletAddress.toData(amount), withdrawal: { diff --git a/packages/backend/src/payment-method/ilp/auto-peering/errors.ts b/packages/backend/src/payment-method/ilp/auto-peering/errors.ts index 126c8424eb..ddc5252b08 100644 --- a/packages/backend/src/payment-method/ilp/auto-peering/errors.ts +++ b/packages/backend/src/payment-method/ilp/auto-peering/errors.ts @@ -35,5 +35,5 @@ export const errorToMessage: { [AutoPeeringError.InvalidPeerUrl]: 'Peer URL is invalid or peer does not support auto-peering', [AutoPeeringError.InvalidPeeringRequest]: 'Invalid peering request', - [AutoPeeringError.LiquidityError]: 'Could not add liquidity to peer' + [AutoPeeringError.LiquidityError]: 'Could not deposit liquidity to peer' } diff --git a/packages/backend/src/payment-method/ilp/auto-peering/service.test.ts b/packages/backend/src/payment-method/ilp/auto-peering/service.test.ts index 32d6597234..8e739c4bcd 100644 --- a/packages/backend/src/payment-method/ilp/auto-peering/service.test.ts +++ b/packages/backend/src/payment-method/ilp/auto-peering/service.test.ts @@ -274,7 +274,7 @@ describe('Auto Peering Service', (): void => { assetId: asset.id, maxPacketAmount: 1000n, liquidityThreshold: 100n, - addedLiquidity: 10000n + liquidityToDeposit: 10000n } const peerDetails: PeeringDetails = { @@ -306,13 +306,13 @@ describe('Auto Peering Service', (): void => { assert(!isAutoPeeringError(peer)) await expect(accountingService.getBalance(peer.id)).resolves.toBe( - args.addedLiquidity + args.liquidityToDeposit ) scope.done() }) - test('returns error if could not add liquidity during peer creation', async (): Promise => { + test('returns error if could not deposit liquidity during peer creation', async (): Promise => { const asset = await createAsset(deps) const args: InitiatePeeringRequestArgs = { @@ -320,7 +320,7 @@ describe('Auto Peering Service', (): void => { assetId: asset.id, maxPacketAmount: 1000n, liquidityThreshold: 100n, - addedLiquidity: -10000n + liquidityToDeposit: -10000n } const peerDetails: PeeringDetails = { @@ -573,7 +573,7 @@ describe('Auto Peering Service', (): void => { const args: InitiatePeeringRequestArgs = { peerUrl: 'http://peer.rafiki.money', assetId: asset.id, - addedLiquidity: 1000n + liquidityToDeposit: 1000n } const peerDetails: PeeringDetails = { @@ -590,7 +590,7 @@ describe('Auto Peering Service', (): void => { const newArgs: InitiatePeeringRequestArgs = { ...args, - addedLiquidity: 2000n + liquidityToDeposit: 2000n } const updatedPeer = @@ -600,18 +600,18 @@ describe('Auto Peering Service', (): void => { expect(createdPeer.id).toBe(updatedPeer.id) await expect(accountingService.getBalance(createdPeer.id)).resolves.toBe( - args.addedLiquidity! + newArgs.addedLiquidity! + args.liquidityToDeposit! + newArgs.liquidityToDeposit! ) scope.done() }) - test('returns error if could not add liquidity during peer update', async (): Promise => { + test('returns error if could not deposit liquidity during peer update', async (): Promise => { const asset = await createAsset(deps) const args: InitiatePeeringRequestArgs = { peerUrl: 'http://peer.rafiki.money', assetId: asset.id, - addedLiquidity: 1000n + liquidityToDeposit: 1000n } const peerDetails: PeeringDetails = { @@ -628,7 +628,7 @@ describe('Auto Peering Service', (): void => { const newArgs: InitiatePeeringRequestArgs = { ...args, - addedLiquidity: -2000n + liquidityToDeposit: -2000n } await expect( diff --git a/packages/backend/src/payment-method/ilp/auto-peering/service.ts b/packages/backend/src/payment-method/ilp/auto-peering/service.ts index f5387c1df9..ea9f77ba6b 100644 --- a/packages/backend/src/payment-method/ilp/auto-peering/service.ts +++ b/packages/backend/src/payment-method/ilp/auto-peering/service.ts @@ -21,7 +21,7 @@ export interface InitiatePeeringRequestArgs { assetId: string name?: string maxPacketAmount?: bigint - addedLiquidity?: bigint + liquidityToDeposit?: bigint liquidityThreshold?: bigint } @@ -44,7 +44,7 @@ interface UpdatePeerArgs { name?: string } -interface AddLiquidityArgs { +interface DepositLiquidityArgs { peer: Peer amount: bigint } @@ -147,19 +147,19 @@ async function initiatePeeringRequest( return handlePeerError(deps, peerOrError, 'Could not create or update peer') } - return args.addedLiquidity - ? await addLiquidity(deps, { + return args.liquidityToDeposit + ? await depositLiquidity(deps, { peer: peerOrError, - amount: args.addedLiquidity + amount: args.liquidityToDeposit }) : peerOrError } -async function addLiquidity( +async function depositLiquidity( deps: ServiceDependencies, - args: AddLiquidityArgs + args: DepositLiquidityArgs ): Promise { - const transferOrPeerError = await deps.peerService.addLiquidity({ + const transferOrPeerError = await deps.peerService.depositLiquidity({ peerId: args.peer.id, amount: args.amount }) @@ -170,7 +170,7 @@ async function addLiquidity( ) { deps.logger.error( { err: transferOrPeerError, args, peerId: args.peer.id }, - 'Could not add liquidity to peer' + 'Could not deposit liquidity to peer' ) return AutoPeeringError.LiquidityError diff --git a/packages/backend/src/payment-method/ilp/peer-http-token/model.ts b/packages/backend/src/payment-method/ilp/peer-http-token/model.ts index 1f7a2238e5..8b207f9f6d 100644 --- a/packages/backend/src/payment-method/ilp/peer-http-token/model.ts +++ b/packages/backend/src/payment-method/ilp/peer-http-token/model.ts @@ -1,7 +1,6 @@ import { Model } from 'objection' import { Peer } from '../peer/model' import { BaseModel } from '../../../shared/baseModel' -import { join } from 'path' export class HttpToken extends BaseModel { public static get tableName(): string { @@ -11,7 +10,7 @@ export class HttpToken extends BaseModel { static relationMappings = { peer: { relation: Model.HasOneRelation, - modelClass: join(__dirname, '../peer/model'), + modelClass: Peer, join: { from: 'httpTokens.peerId', to: 'peers.id' diff --git a/packages/backend/src/payment-method/ilp/peer/model.test.ts b/packages/backend/src/payment-method/ilp/peer/model.test.ts index 1190295f40..a664509eb1 100644 --- a/packages/backend/src/payment-method/ilp/peer/model.test.ts +++ b/packages/backend/src/payment-method/ilp/peer/model.test.ts @@ -10,12 +10,11 @@ import { initIocContainer } from '../../..' import { AppServices } from '../../../app' import { createAsset } from '../../../tests/asset' import { truncateTables } from '../../../tests/tableManager' -import { Peer } from './model' +import { Peer, PeerEvent, PeerEventError, PeerEventType } from './model' import { isPeerError } from './errors' -import { WebhookEvent } from '../../../webhook/model' import { Asset } from '../../../asset/model' -describe('Peer Model', (): void => { +describe('Models', (): void => { let deps: IocContract let appContainer: TestContainer let peerService: PeerService @@ -41,62 +40,84 @@ describe('Peer Model', (): void => { await appContainer.shutdown() }) - describe('onDebit', (): void => { - let peer: Peer - beforeEach(async (): Promise => { - const options = { - assetId: asset.id, - http: { - incoming: { - authTokens: [faker.string.sample(32)] + describe('Peer Model', (): void => { + describe('onDebit', (): void => { + let peer: Peer + beforeEach(async (): Promise => { + const options = { + assetId: asset.id, + http: { + incoming: { + authTokens: [faker.string.sample(32)] + }, + outgoing: { + authToken: faker.string.sample(32), + endpoint: faker.internet.url({ appendSlash: false }) + } }, - outgoing: { - authToken: faker.string.sample(32), - endpoint: faker.internet.url({ appendSlash: false }) - } - }, - maxPacketAmount: BigInt(100), - staticIlpAddress: 'test.' + uuid(), - name: faker.person.fullName(), - liquidityThreshold: BigInt(100) - } - const peerOrError = await peerService.create(options) - if (!isPeerError(peerOrError)) { - peer = peerOrError - } + maxPacketAmount: BigInt(100), + staticIlpAddress: 'test.' + uuid(), + name: faker.person.fullName(), + liquidityThreshold: BigInt(100) + } + const peerOrError = await peerService.create(options) + if (!isPeerError(peerOrError)) { + peer = peerOrError + } + }) + test.each` + balance + ${BigInt(50)} + ${BigInt(99)} + ${BigInt(100)} + `( + 'creates webhook event if balance=$balance <= liquidityThreshold', + async ({ balance }): Promise => { + await peer.onDebit({ balance }) + const event = ( + await PeerEvent.query(knex).where( + 'type', + PeerEventType.LiquidityLow + ) + )[0] + expect(event).toMatchObject({ + type: PeerEventType.LiquidityLow, + data: { + id: peer.id, + asset: { + id: asset.id, + code: asset.code, + scale: asset.scale + }, + liquidityThreshold: peer.liquidityThreshold?.toString(), + balance: balance.toString() + } + }) + } + ) + test('does not create webhook event if balance > liquidityThreshold', async (): Promise => { + await peer.onDebit({ balance: BigInt(110) }) + await expect( + PeerEvent.query(knex).where('type', PeerEventType.LiquidityLow) + ).resolves.toEqual([]) + }) }) - test.each` - balance - ${BigInt(50)} - ${BigInt(99)} - ${BigInt(100)} - `( - 'creates webhook event if balance=$balance <= liquidityThreshold', - async ({ balance }): Promise => { - await peer.onDebit({ balance }) - const event = ( - await WebhookEvent.query(knex).where('type', 'peer.liquidity_low') - )[0] - expect(event).toMatchObject({ - type: 'peer.liquidity_low', - data: { - id: peer.id, - asset: { - id: asset.id, - code: asset.code, - scale: asset.scale - }, - liquidityThreshold: peer.liquidityThreshold?.toString(), - balance: balance.toString() - } - }) - } - ) - test('does not create webhook event if balance > liquidityThreshold', async (): Promise => { - await peer.onDebit({ balance: BigInt(110) }) - await expect( - WebhookEvent.query(knex).where('type', 'peer.liquidity_low') - ).resolves.toEqual([]) + }) + + describe('Peer Event Model', (): void => { + describe('beforeInsert', (): void => { + test.each( + Object.values(PeerEventType).map((type) => ({ + type, + error: PeerEventError.PeerIdRequired + })) + )('Peer Id is required', async ({ type, error }): Promise => { + expect( + PeerEvent.query().insert({ + type + }) + ).rejects.toThrow(error) + }) }) }) }) diff --git a/packages/backend/src/payment-method/ilp/peer/model.ts b/packages/backend/src/payment-method/ilp/peer/model.ts index 9a2a5c2763..e22619e867 100644 --- a/packages/backend/src/payment-method/ilp/peer/model.ts +++ b/packages/backend/src/payment-method/ilp/peer/model.ts @@ -1,10 +1,11 @@ -import { Model, Pojo } from 'objection' +import { Model, Pojo, QueryContext } from 'objection' import { LiquidityAccount, OnDebitOptions } from '../../../accounting/service' import { Asset } from '../../../asset/model' import { ConnectorAccount } from '../connector/core/rafiki' import { HttpToken } from '../peer-http-token/model' import { BaseModel } from '../../../shared/baseModel' import { WebhookEvent } from '../../../webhook/model' +import { join } from 'path' export class Peer extends BaseModel @@ -25,7 +26,7 @@ export class Peer }, incomingTokens: { relation: Model.HasManyRelation, - modelClass: HttpToken, + modelClass: join(__dirname, '../peer-http-token/model'), join: { from: 'peers.id', to: 'httpTokens.peerId' @@ -55,8 +56,9 @@ export class Peer public async onDebit({ balance }: OnDebitOptions): Promise { if (this.liquidityThreshold !== null) { if (balance <= this.liquidityThreshold) { - await WebhookEvent.query().insert({ - type: 'peer.liquidity_low', + await PeerEvent.query().insert({ + peerId: this.id, + type: PeerEventType.LiquidityLow, data: { id: this.id, asset: { @@ -97,3 +99,35 @@ export class Peer return formattedJson } } + +export enum PeerEventType { + LiquidityLow = 'peer.liquidity_low' +} + +export type PeerEventData = { + id: string + asset: { + id: string + code: string + scale: number + } + liquidityThreshold: bigint | null + balance: bigint +} + +export enum PeerEventError { + PeerIdRequired = 'Peer ID is required for peer events' +} + +export class PeerEvent extends WebhookEvent { + public type!: PeerEventType + public data!: PeerEventData + + public $beforeInsert(context: QueryContext): void { + super.$beforeInsert(context) + + if (!this.peerId) { + throw new Error(PeerEventError.PeerIdRequired) + } + } +} diff --git a/packages/backend/src/payment-method/ilp/peer/service.test.ts b/packages/backend/src/payment-method/ilp/peer/service.test.ts index 592c7d2625..9d0a7ae430 100644 --- a/packages/backend/src/payment-method/ilp/peer/service.test.ts +++ b/packages/backend/src/payment-method/ilp/peer/service.test.ts @@ -451,14 +451,14 @@ describe('Peer Service', (): void => { }) }) - describe('Add Liquidity', (): void => { - test('Can add liquidity to peer', async (): Promise => { + describe('Deposit Liquidity', (): void => { + test('Can deposit liquidity to peer', async (): Promise => { const peer = await createPeer(deps) const liquidity = 100n await expect( - peerService.addLiquidity({ peerId: peer.id, amount: liquidity }) + peerService.depositLiquidity({ peerId: peer.id, amount: liquidity }) ).resolves.toBeUndefined() await expect(accountingService.getBalance(peer.id)).resolves.toBe( @@ -470,7 +470,7 @@ describe('Peer Service', (): void => { const peer = await createPeer(deps) await expect( - peerService.addLiquidity({ + peerService.depositLiquidity({ peerId: peer.id, amount: 100n, transferId: '' @@ -482,7 +482,7 @@ describe('Peer Service', (): void => { test('Returns error if cannot find peer', async (): Promise => { await expect( - peerService.addLiquidity({ + peerService.depositLiquidity({ peerId: uuid(), amount: 100n }) diff --git a/packages/backend/src/payment-method/ilp/peer/service.ts b/packages/backend/src/payment-method/ilp/peer/service.ts index fa157e6846..be64dcc520 100644 --- a/packages/backend/src/payment-method/ilp/peer/service.ts +++ b/packages/backend/src/payment-method/ilp/peer/service.ts @@ -50,7 +50,7 @@ export type UpdateOptions = Partial & { id: string } -interface AddPeerLiquidityArgs { +interface DepositPeerLiquidityArgs { amount: bigint transferId?: string peerId: string @@ -66,8 +66,8 @@ export interface PeerService { ): Promise getByIncomingToken(token: string): Promise getPage(pagination?: Pagination, sortOrder?: SortOrder): Promise - addLiquidity( - args: AddPeerLiquidityArgs + depositLiquidity( + args: DepositPeerLiquidityArgs ): Promise delete(id: string): Promise } @@ -105,7 +105,7 @@ export async function createPeerService({ getByIncomingToken: (token) => getPeerByIncomingToken(deps, token), getPage: (pagination?, sortOrder?) => getPeersPage(deps, pagination, sortOrder), - addLiquidity: (args) => addLiquidityById(deps, args), + depositLiquidity: (args) => depositLiquidityById(deps, args), delete: (id) => deletePeer(deps, id) } } @@ -164,7 +164,7 @@ async function createPeer( ) if (options.initialLiquidity) { - const transferError = await addLiquidity( + const transferError = await depositLiquidity( deps, { peer, amount: options.initialLiquidity }, trx @@ -173,7 +173,7 @@ async function createPeer( if (transferError) { deps.logger.error( { err: transferError }, - 'error trying to add initial liquidity' + 'error trying to deposit initial liquidity' ) throw PeerError.InvalidInitialLiquidity @@ -247,9 +247,9 @@ async function updatePeer( } } -async function addLiquidityById( +async function depositLiquidityById( deps: ServiceDependencies, - args: AddPeerLiquidityArgs + args: DepositPeerLiquidityArgs ): Promise { const { peerId, amount, transferId } = args @@ -258,10 +258,10 @@ async function addLiquidityById( return PeerError.UnknownPeer } - return addLiquidity(deps, { peer, amount, transferId }) + return depositLiquidity(deps, { peer, amount, transferId }) } -async function addLiquidity( +async function depositLiquidity( deps: ServiceDependencies, args: { peer: Peer; amount: bigint; transferId?: string }, trx?: TransactionOrKnex diff --git a/packages/backend/src/tests/webhook.ts b/packages/backend/src/tests/webhook.ts index 0b12710b94..87dddc8437 100644 --- a/packages/backend/src/tests/webhook.ts +++ b/packages/backend/src/tests/webhook.ts @@ -5,24 +5,23 @@ import { AppServices } from '../app' import { WebhookEvent } from '../webhook/model' import { sample } from 'lodash' import { EventPayload } from '../webhook/service' +import { createAsset } from './asset' export const webhookEventTypes = ['event1', 'event2', 'event3'] as const +type WebhookEventPayload = EventPayload & { assetId: string } -export function randomWebhookEvent( - overrides?: Partial -): EventPayload { - return { +export async function createWebhookEvent( + deps: IocContract, + overrides?: Partial +): Promise { + const knex = await deps.use('knex') + const asset = await createAsset(deps) + const newEvent = { id: uuid(), + assetId: asset.id, type: sample(webhookEventTypes) as string, data: { field1: faker.string.sample() }, ...overrides } -} - -export async function createWebhookEvent( - deps: IocContract, - newEvent?: EventPayload -): Promise { - const knex = await deps.use('knex') - return await WebhookEvent.query(knex).insert(newEvent || randomWebhookEvent()) + return await WebhookEvent.query(knex).insert(newEvent) } diff --git a/packages/backend/src/webhook/model.ts b/packages/backend/src/webhook/model.ts index 9cd2c87a5c..9235787025 100644 --- a/packages/backend/src/webhook/model.ts +++ b/packages/backend/src/webhook/model.ts @@ -1,6 +1,12 @@ import { Pojo } from 'objection' import { BaseModel } from '../shared/baseModel' +import { join } from 'path' +import { OutgoingPayment } from '../open_payments/payment/outgoing/model' +import { IncomingPayment } from '../open_payments/payment/incoming/model' +import { WalletAddress } from '../open_payments/wallet_address/model' +import { Asset } from '../asset/model' +import { Peer } from '../payment-method/ilp/peer/model' const fieldPrefixes = ['withdrawal'] @@ -9,11 +15,67 @@ export class WebhookEvent extends BaseModel { return 'webhookEvents' } + static relationMappings = () => ({ + outgoingPayment: { + relation: BaseModel.BelongsToOneRelation, + modelClass: join(__dirname, '../open_payments/payment/outgoing/model'), + join: { + from: 'webhookEvents.outgoingPaymentId', + to: 'outgoingPayments.id' + } + }, + incomingPayment: { + relation: BaseModel.BelongsToOneRelation, + modelClass: join(__dirname, '../open_payments/payment/incoming/model'), + join: { + from: 'webhookEvents.incomingPaymentId', + to: 'incomingPayments.id' + } + }, + walletAddress: { + relation: BaseModel.BelongsToOneRelation, + modelClass: join(__dirname, '../open_payments/wallet_address/model'), + join: { + from: 'webhookEvents.walletAddressId', + to: 'walletAddresses.id' + } + }, + asset: { + relation: BaseModel.BelongsToOneRelation, + modelClass: join(__dirname), + join: { + from: 'webhookEvents.assetId', + to: 'assets.id' + } + }, + peer: { + relation: BaseModel.BelongsToOneRelation, + modelClass: join(__dirname, '../payment-method/ilp/peer/model'), + join: { + from: 'webhookEvents.peerId', + to: 'peer.id' + } + } + }) + public type!: string public data!: Record public attempts!: number public statusCode?: number public processAt!: Date | null + public depositAccountId?: string + + public readonly outgoingPaymentId?: string + public readonly incomingPaymentId?: string + public readonly walletAddressId?: string + public readonly assetId?: string + public readonly peerId?: string + + public outgoingPayment?: OutgoingPayment + public incomingPayment?: IncomingPayment + public walletAddress?: WalletAddress + public asset?: Asset + public peer?: Peer public withdrawal?: { accountId: string @@ -22,6 +84,7 @@ export class WebhookEvent extends BaseModel { } $formatDatabaseJson(json: Pojo): Pojo { + // transforms WebhookEvent.withdrawal to db fields. eg. withdrawal.accountId => withdrawalAccountId for (const prefix of fieldPrefixes) { if (!json[prefix]) continue for (const key in json[prefix]) { @@ -34,6 +97,7 @@ export class WebhookEvent extends BaseModel { } $parseDatabaseJson(json: Pojo): Pojo { + // transforms withdrawal db fields to WebhookEvent.withdrawal. eg. withdrawalAccountId => withdrawal.accountId json = super.$parseDatabaseJson(json) for (const key in json) { const prefix = fieldPrefixes.find((prefix) => key.startsWith(prefix)) diff --git a/packages/backend/src/webhook/service.test.ts b/packages/backend/src/webhook/service.test.ts index 0107646ee5..bd1b562fcf 100644 --- a/packages/backend/src/webhook/service.test.ts +++ b/packages/backend/src/webhook/service.test.ts @@ -21,11 +21,16 @@ import { initIocContainer } from '../' import { AppServices } from '../app' import { getPageTests } from '../shared/baseModel.test' import { Pagination, SortOrder } from '../shared/baseModel' +import { createWebhookEvent, webhookEventTypes } from '../tests/webhook' +import { IncomingPaymentEventType } from '../open_payments/payment/incoming/model' +import { OutgoingPaymentEventType } from '../open_payments/payment/outgoing/model' +import { createIncomingPayment } from '../tests/incomingPayment' +import { createWalletAddress } from '../tests/walletAddress' import { - createWebhookEvent, - randomWebhookEvent, - webhookEventTypes -} from '../tests/webhook' + WalletAddress, + WalletAddressEventType +} from '../open_payments/wallet_address/model' +import { createOutgoingPayment } from '../tests/outgoingPayment' describe('Webhook Service', (): void => { let deps: IocContract @@ -76,7 +81,7 @@ describe('Webhook Service', (): void => { beforeEach(async (): Promise => { event = await WebhookEvent.query(knex).insertAndFetch({ id: uuid(), - type: 'account.test_event', + type: WalletAddressEventType.WalletAddressNotFound, data: { account: { id: uuid() @@ -100,6 +105,127 @@ describe('Webhook Service', (): void => { }) }) + describe('Get Webhook Event by account id and types', (): void => { + let walletAddressIn: WalletAddress + let walletAddressOut: WalletAddress + let incomingPaymentIds: string[] + let outgoingPaymentIds: string[] + let events: WebhookEvent[] = [] + + beforeEach(async (): Promise => { + walletAddressIn = await createWalletAddress(deps) + walletAddressOut = await createWalletAddress(deps) + incomingPaymentIds = [ + ( + await createIncomingPayment(deps, { + walletAddressId: walletAddressIn.id + }) + ).id, + ( + await createIncomingPayment(deps, { + walletAddressId: walletAddressIn.id + }) + ).id + ] + outgoingPaymentIds = [ + ( + await createOutgoingPayment(deps, { + method: 'ilp', + walletAddressId: walletAddressOut.id, + receiver: '', + validDestination: false + }) + ).id, + ( + await createOutgoingPayment(deps, { + method: 'ilp', + walletAddressId: walletAddressOut.id, + receiver: '', + validDestination: false + }) + ).id + ] + + events = [ + await WebhookEvent.query(knex).insertAndFetch({ + id: uuid(), + type: IncomingPaymentEventType.IncomingPaymentCompleted, + data: { id: uuid() }, + incomingPaymentId: incomingPaymentIds[0] + }), + await WebhookEvent.query(knex).insertAndFetch({ + id: uuid(), + type: IncomingPaymentEventType.IncomingPaymentExpired, + data: { id: uuid() }, + incomingPaymentId: incomingPaymentIds[0] + }), + await WebhookEvent.query(knex).insertAndFetch({ + id: uuid(), + type: IncomingPaymentEventType.IncomingPaymentCompleted, + data: { id: uuid() }, + incomingPaymentId: incomingPaymentIds[1] + }), + await WebhookEvent.query(knex).insertAndFetch({ + id: uuid(), + type: OutgoingPaymentEventType.PaymentCreated, + data: { id: uuid() }, + outgoingPaymentId: outgoingPaymentIds[0] + }) + ] + }) + + test('Gets latest event matching account id and type', async (): Promise => { + await expect( + webhookService.getLatestByResourceId({ + incomingPaymentId: incomingPaymentIds[0], + types: [ + IncomingPaymentEventType.IncomingPaymentCompleted, + IncomingPaymentEventType.IncomingPaymentExpired + ] + }) + ).resolves.toEqual(events[1]) + await expect( + webhookService.getLatestByResourceId({ + outgoingPaymentId: outgoingPaymentIds[0], + types: [OutgoingPaymentEventType.PaymentCreated] + }) + ).resolves.toEqual(events[3]) + }) + + test('Gets latest of any type when type not provided', async (): Promise => { + const newLatestEvent = await WebhookEvent.query(knex).insertAndFetch({ + id: uuid(), + type: 'some_new_type', + data: { id: uuid() }, + incomingPaymentId: incomingPaymentIds[0] + }) + await expect( + webhookService.getLatestByResourceId({ + incomingPaymentId: incomingPaymentIds[0] + }) + ).resolves.toEqual(newLatestEvent) + }) + + describe('Returns undefined if no match', (): void => { + test('Good account id, bad event type', async (): Promise => { + await expect( + webhookService.getLatestByResourceId({ + incomingPaymentId: incomingPaymentIds[0], + types: ['nonexistant.event'] + }) + ).resolves.toBeUndefined() + }) + test('Bad account id, good event type', async (): Promise => { + await expect( + webhookService.getLatestByResourceId({ + incomingPaymentId: uuid(), + types: [IncomingPaymentEventType.IncomingPaymentCompleted] + }) + ).resolves.toBeUndefined() + }) + }) + }) + describe('getPage', (): void => { getPageTests({ createModel: () => createWebhookEvent(deps), @@ -121,9 +247,7 @@ describe('Webhook Service', (): void => { beforeEach(async (): Promise => { for (const eventOverride of eventOverrides) { - webhookEvents.push( - await createWebhookEvent(deps, randomWebhookEvent(eventOverride)) - ) + webhookEvents.push(await createWebhookEvent(deps, eventOverride)) } }) afterEach(async (): Promise => { diff --git a/packages/backend/src/webhook/service.ts b/packages/backend/src/webhook/service.ts index 9e8363594f..698e2cb986 100644 --- a/packages/backend/src/webhook/service.ts +++ b/packages/backend/src/webhook/service.ts @@ -25,6 +25,9 @@ interface GetPageOptions { export interface WebhookService { getEvent(id: string): Promise + getLatestByResourceId( + options: WebhookByResourceIdOptions + ): Promise processNext(): Promise getPage(options?: GetPageOptions): Promise } @@ -42,6 +45,8 @@ export async function createWebhookService( const deps = { ...deps_, logger } return { getEvent: (id) => getWebhookEvent(deps, id), + getLatestByResourceId: (options) => + getLatestWebhookEventByResourceId(deps, options), processNext: () => processNextWebhookEvent(deps), getPage: (options) => getWebhookEventsPage(deps, options) } @@ -54,6 +59,60 @@ async function getWebhookEvent( return WebhookEvent.query(deps.knex).findById(id) } +interface WebhookEventOptions { + types?: string[] +} +interface OutgoingPaymentOptions extends WebhookEventOptions { + outgoingPaymentId: string +} +interface IncomingPaymentOptions extends WebhookEventOptions { + incomingPaymentId: string +} +interface WalletAddressOptions extends WebhookEventOptions { + walletAddressId: string +} +interface PeerOptions extends WebhookEventOptions { + peerId: string +} +interface AssetOptions extends WebhookEventOptions { + assetId: string +} +type WebhookByResourceIdOptions = + | OutgoingPaymentOptions + | IncomingPaymentOptions + | WalletAddressOptions + | PeerOptions + | AssetOptions + +async function getLatestWebhookEventByResourceId( + deps: ServiceDependencies, + options: WebhookByResourceIdOptions +): Promise { + const { types } = options + + const query = WebhookEvent.query(deps.knex) + .orderBy('createdAt', 'DESC') + .limit(1) + + if (types && types.length) { + query.whereIn('type', types) + } + + if ('outgoingPaymentId' in options) { + query.where({ outgoingPaymentId: options.outgoingPaymentId }) + } else if ('incomingPaymentId' in options) { + query.where({ incomingPaymentId: options.incomingPaymentId }) + } else if ('walletAddressId' in options) { + query.where({ incomingPaymentId: options.walletAddressId }) + } else if ('peerId' in options) { + query.where({ incomingPaymentId: options.peerId }) + } else { + query.where({ assetId: options.assetId }) + } + + return await query.first() +} + // Fetch (and lock) a webhook event for work. // Returns the id of the processed event (if any). async function processNextWebhookEvent( diff --git a/packages/documentation/astro.config.mjs b/packages/documentation/astro.config.mjs index e5d559529f..84ab7bc79c 100644 --- a/packages/documentation/astro.config.mjs +++ b/packages/documentation/astro.config.mjs @@ -176,6 +176,10 @@ export default defineConfig({ { label: 'Admin APIs', items: [ + { + label: 'Idempotency', + link: 'apis/idempotency' + }, { label: 'Backend Admin API', collapsed: true, diff --git a/packages/documentation/package.json b/packages/documentation/package.json index a482f04306..6d4e36cef7 100644 --- a/packages/documentation/package.json +++ b/packages/documentation/package.json @@ -15,7 +15,7 @@ "astro-graphql-plugin": "^0.1.0", "graphql": "^16.8.1", "mermaid": "^10.6.1", - "rehype-mathjax": "^5.0.0", + "rehype-mathjax": "^6.0.0", "remark-math": "^6.0.0" } } diff --git a/packages/documentation/src/content/docs/apis/idempotency.md b/packages/documentation/src/content/docs/apis/idempotency.md new file mode 100644 index 0000000000..918ab8f708 --- /dev/null +++ b/packages/documentation/src/content/docs/apis/idempotency.md @@ -0,0 +1,11 @@ +--- +title: Idempotency +--- + +Several mutations utilize an idempotency key to allow safely retrying requests without performing operations multiple times. This should be a unique key (typically, a V4 UUID). + +For Rafiki's GraphQL API, whenever a mutation with an `idempotencyKey` is called, the request payload and the request response are saved under that key. Any subsequent requests made with the same idempotency key will return the original response & status of the request (regardless whether the request was successful or not). Keys are cached for a default of 24 hours, and can be configured via the `GRAPHQL_IDEMPOTENCY_KEY_TTL_MS` `backend` service's environment flag. + +Additionally, in the possible chance that a request is made while still concurrently processing the first one under the same `idempotencyKey`, the API would return an error. This further safeguards from potential errors in the system. The timing to prevent processing concurrent requests can be configured via the `GRAPHQL_IDEMPOTENCY_KEY_LOCK_MS` flag (which is 2 seconds by default). + +For more information on idempotency, [see more](https://en.wikipedia.org/wiki/Idempotence). diff --git a/packages/documentation/src/content/docs/concepts/accounting/liquidity.md b/packages/documentation/src/content/docs/concepts/accounting/liquidity.md index 462ba9d56f..8471b69480 100644 --- a/packages/documentation/src/content/docs/concepts/accounting/liquidity.md +++ b/packages/documentation/src/content/docs/concepts/accounting/liquidity.md @@ -28,19 +28,23 @@ Peer Liquidity defines the line of credit, denominated in the asset of the peeri A configured peer _Cloud Nine Wallet_ within Rafiki has a peer liquidity of 100 USD. Rafiki can send packets up to 100 USD to wallet addresses issued by _Cloud Nine Wallet_. Once that liquidity is used up, we should settle with _Cloud Nine Wallet_ and then reset their liquidity to 100 USD. -### Event Liquidity +### Payment Liquidity -When Open Payments incoming or outgoing payments are created, a liquidity account is created within the accounting database. Liquidity needs to be added to an outgoing payment before the payment can be processed. The Account Servicing Entity is notified to add liquidity via the `outgoing_payment.created` event, hence the name _Event Liquidity_. Similarly, packets that are received for an incoming payment increase its liquidity. The Account Servicing Entity is notified to withdraw that liquidity via the `incoming_payment.completed` event. +When Open Payments incoming or outgoing payments are created, a liquidity account is created within the accounting database. Liquidity needs to be deposited to an outgoing payment before the payment can be processed. The Account Servicing Entity is notified to deposit liquidity via the `outgoing_payment.created` event. Similarly, packets that are received for an incoming payment increase its liquidity. The Account Servicing Entity is notified to withdraw that liquidity via the `incoming_payment.completed` event. -## Adding and Withdrawing Liquidity +## Depositing and Withdrawing Liquidity + +> **Note:** The `idempotencyKey` must be provided whenever calling mutations dealing with liquidity. +> This key allows safely retrying requests, without performing the operation multiple times. +> This should be a unique key (typically, a V4 UUID). For more information on Rafiki's idempotency, [see more](/apis/idempotency). ### Asset Liquidity -Add and withdraw asset liquidity via the Admin API (or UI): +Deposit and withdraw asset liquidity via the Admin API (or UI): ```graphql -mutation AddAssetLiquidity($input: AddAssetLiquidityInput!) { - addAssetLiquidity(input: $input) { +mutation DepositAssetLiquidity($input: DepositAssetLiquidityInput!) { + depositAssetLiquidity(input: $input) { code success message @@ -92,11 +96,11 @@ where ### Peer Liquidity -Add and withdraw peer liquidity via the Admin API (or UI): +Deposit and withdraw peer liquidity via the Admin API (or UI): ```graphql -mutation AddPeerLiquidity($input: AddPeerLiquidityInput!) { - addPeerLiquidity(input: $input) { +mutation DepositPeerLiquidity($input: DepositPeerLiquidityInput!) { + depositPeerLiquidity(input: $input) { code success message @@ -145,13 +149,17 @@ where } ``` -### Event Liquidity +### Payment Liquidity + +#### Outgoing payment -Add and withdraw event liquidity via the Admin API only: +Deposit and withdraw outgoing payment liquidity via the Admin API only: ```graphql -mutation DepositEventLiquidity($input: DepositEventLiquidityInput!) { - depositEventLiquidity(input: $input) { +mutation DepositOutgoingPaymentLiquidity( + $input: DepositOutgoingPaymentLiquidityInput! +) { + depositOutgoingPaymentLiquidity(input: $input) { code error message @@ -165,7 +173,7 @@ where ```json { "input": { - "eventId": "b4f85d5c-652d-472d-873c-4ba2a5e39052", + "outgoingPaymentId": "b4f85d5c-652d-472d-873c-4ba2a5e39052", "idempotencyKey": "a09b730d-8610-4fda-98fa-ec7acb19c775" } } @@ -174,8 +182,38 @@ where and ```graphql -mutation WithdrawEventLiquidity($input: WithdrawEventLiquidityInput!) { - withdrawEventLiquidity(input: $input) { +mutation WithdrawOutgoingPaymentLiquidity( + $input: WithdrawOutgoingPaymentLiquidityInput! +) { + withdrawOutgoingPaymentLiquidity(input: $input) { + code + error + message + success + } +} +``` + +where + +```json +{ + "input": { + "outgoingPaymentId": "b4f85d5c-652d-472d-873c-4ba2a5e39052", + "idempotencyKey": "a09b730d-8610-4fda-98fa-ec7acb19c775" + } +} +``` + +#### Incoming payment + +Withdraw incoming payment liquidity via the Admin API only: + +```graphql +mutation WithdrawIncomingPaymentLiquidity( + $input: WithdrawIncomingPaymentLiquidityInput! +) { + withdrawIncomingPaymentLiquidity(input: $input) { code error message @@ -189,7 +227,7 @@ where ```json { "input": { - "eventId": "b4f85d5c-652d-472d-873c-4ba2a5e39052", + "incomingPaymentId": "b4f85d5c-652d-472d-873c-4ba2a5e39052", "idempotencyKey": "a09b730d-8610-4fda-98fa-ec7acb19c775" } } diff --git a/packages/documentation/src/content/docs/concepts/interledger-protocol/peering.md b/packages/documentation/src/content/docs/concepts/interledger-protocol/peering.md index 8828b42a4c..43fe14afd1 100644 --- a/packages/documentation/src/content/docs/concepts/interledger-protocol/peering.md +++ b/packages/documentation/src/content/docs/concepts/interledger-protocol/peering.md @@ -125,7 +125,7 @@ Query Variables (substitute the asset ID from the "create asset" response for `I "outgoing": {"endpoint": "ilp.othergreatwallet.com", "authToken": "theirtoken"} }, "assetId": "INSERT_ASSET_ID", - "initialLiquidity: + "initialLiquidity": } } ``` @@ -159,13 +159,13 @@ Example Successful Response } ``` -### Add Peer Liquidity +### Deposit Peer Liquidity Query: ```graphql -mutation AddPeerLiquidity($input: AddPeerLiquidityInput!) { - addPeerLiquidity(input: $input) { +mutation DepositPeerLiquidity($input: DepositPeerLiquidityInput!) { + depositPeerLiquidity(input: $input) { code success message @@ -193,10 +193,10 @@ Example successful response: ```json { "data": { - "addPeerLiquidity": { + "depositPeerLiquidity": { "code": "200", "success": true, - "message": "Added peer liquidity", + "message": "Deposited peer liquidity", "error": null } } @@ -355,9 +355,9 @@ with the input being: ```json { "input": { - "peerUrl: "PEER_URL", + "peerUrl": "PEER_URL", "assetId": "INSERT_ASSET_ID", - "initialLiquidity: + "liquidityToDeposit": } } ``` diff --git a/packages/documentation/src/content/docs/integration/event-handlers.mdx b/packages/documentation/src/content/docs/integration/event-handlers.mdx index c7443abd88..d1ae35a822 100644 --- a/packages/documentation/src/content/docs/integration/event-handlers.mdx +++ b/packages/documentation/src/content/docs/integration/event-handlers.mdx @@ -33,7 +33,7 @@ Example: An incoming payment was completed and received $10. participant R as Rafiki R->>ASE: webhook event: incoming payment completed,
receivedAmount: $10 - ASE->>R: admin API call: WithdrawEventLiquidity + ASE->>R: admin API call: WithdrawIncomingPaymentLiquidity ASE->>ASE: credit receiver's account with $10 `} @@ -51,7 +51,7 @@ Example: An incoming payment has expired and received $2.55. participant R as Rafiki R->>ASE: webhook event: incoming payment expired,
receivedAmount: $2.55 - ASE->>R: admin API call: WithdrawEventLiquidity + ASE->>R: admin API call: WithdrawIncomingPaymentLiquidity ASE->>ASE: credit receiver's account with $2.55 `} @@ -70,14 +70,14 @@ Example: An outgoing payment for $12 has been created. R->>ASE: webhook event: outgoing payment created,
debitAmount: $12 ASE->>ASE: put hold of $12 on sender's account - ASE->>R: admin API call: DepositEventLiquidity + ASE->>R: admin API call: DepositOutgoingPaymentLiquidity `} /> ## `outgoing_payment.completed` -The `outgoing_payment.completed` event indicates that an outgoing payment has successfully sent as many funds as possible to the receiver. The Account Servicing Entity should withdraw any access liquidity from that outgoing payment in Rafiki and use it as they see fit. One option would be to return it to the sender. Another option is that the access liquidity is considered a fee and retained by the Account Servicing Entity. Furthermore, the Account Servicing Entity should remove the hold on the sender's account and debit it. +The `outgoing_payment.completed` event indicates that an outgoing payment has successfully sent as many funds as possible to the receiver. The Account Servicing Entity should withdraw any excess liquidity from that outgoing payment in Rafiki and use it as they see fit. One option would be to return it to the sender. Another option is that the excess liquidity is considered a fee and retained by the Account Servicing Entity. Furthermore, the Account Servicing Entity should remove the hold on the sender's account and debit it. Example: An outgoing payment for $12 has been completed. $11.50 were sent. The Account Servicing Entity keeps $0.50 as fees. @@ -87,7 +87,7 @@ Example: An outgoing payment for $12 has been completed. $11.50 were sent. The A participant R as Rafiki R->>ASE: webhook event: outgoing completed,
debitAmount: $12, sentAmount:$11.50 - ASE->>R: admin API call: WithdrawEventLiquidity + ASE->>R: admin API call: WithdrawOutgoingPaymentLiquidity ASE->>ASE: remove the hold and deduct $12 from the sender's account,
credit ASE's account with $0.50 `} @@ -105,7 +105,7 @@ Example: An outgoing payment for $12 has failed. $8 were sent. participant R as Rafiki R->>ASE: webhook event: outgoing failed,
debitAmount: $12, sentAmount:$8 - ASE->>R: admin API call: WithdrawEventLiquidity + ASE->>R: admin API call: WithdrawOutgoingPaymentLiquidity ASE->>ASE: remove the hold and deduct $8 from the sender's account `} @@ -123,7 +123,7 @@ Example: A wallet address received $0.33 participant R as Rafiki R->>ASE: webhook event: wallet address web monetization,
receivedAmount: $0.33 - ASE->>R: admin API call: WithdrawEventLiquidity + ASE->>R: admin API call: CreateWalletAddressWithdrawal ASE->>ASE: credit receiver's account with $0.33 `} @@ -158,7 +158,7 @@ Example: The asset liquidity for USD (scale: 2) drops below 100.00 USD. participant R as Rafiki R->>ASE: webhook event: liquidity (asset) low,
asset: USD (scale: 2, id: "abc") - ASE->>R: admin API call: AddAssetLiquidity + ASE->>R: admin API call: DepositAssetLiquidity `} /> @@ -175,7 +175,7 @@ Example: The peer liquidity for Happy Life Bank drops below 100.00 USD. participant R as Rafiki R->>ASE: webhook event: liquidity (peer) low,
peer: Happy Life Bank (asset: "USD", scale: 2, id: "abc") - ASE->>R: admin API call: AddPeerLiquidity + ASE->>R: admin API call: DepositPeerLiquidity `} /> diff --git a/packages/frontend/README.md b/packages/frontend/README.md index a8eb19ef90..a61ccb5325 100644 --- a/packages/frontend/README.md +++ b/packages/frontend/README.md @@ -22,6 +22,8 @@ Now you can access the application on [http://localhost:3005](http://localhost:3 > NOTE: When running Rafiki Admin in the development environment, it will connect to Cloud Nine Wallet Admin GraphQL - [http://localhost:3001/graphql](http://localhost:3001/graphql). +To add a new typed apollo request, you will need to add an untyped request and regenerate the graphql types. This will generate new types tailored to the specific request being made. The generated type will reflect the request's query or mutation name, variables used, and requested fields. + ## Structure ``` diff --git a/packages/frontend/app/components/Badge.tsx b/packages/frontend/app/components/Badge.tsx index c147f1ca7f..fbaccfb3a5 100644 --- a/packages/frontend/app/components/Badge.tsx +++ b/packages/frontend/app/components/Badge.tsx @@ -1,21 +1,27 @@ import { cx } from 'class-variance-authority' -import type { WalletAddressStatus } from '~/generated/graphql' + +export enum BadgeColor { + Green = 'bg-green-200 text-green-800', + Red = 'bg-red-200 text-red-800', + Yellow = 'bg-yellow-200 text-yellow-800', + Gray = 'bg-gray-200 text-gray-800' +} type BadgeProps = { - status: WalletAddressStatus + children: React.ReactNode + color: BadgeColor } -export const Badge = ({ status }: BadgeProps) => { +export const Badge = ({ children, color }: BadgeProps) => { + color = color || BadgeColor.Gray return ( - {status} + {children} ) } diff --git a/packages/frontend/app/components/LiquidityConfirmDialog.tsx b/packages/frontend/app/components/LiquidityConfirmDialog.tsx new file mode 100644 index 0000000000..9a9167faa6 --- /dev/null +++ b/packages/frontend/app/components/LiquidityConfirmDialog.tsx @@ -0,0 +1,72 @@ +import { Dialog } from '@headlessui/react' +import { Form } from '@remix-run/react' +import { XIcon } from '~/components/icons' +import { Button } from '~/components/ui' + +type LiquidityConfirmDialogProps = { + title: string + onClose: () => void + type: 'Deposit' | 'Withdraw' + displayAmount: string +} + +export const LiquidityConfirmDialog = ({ + title, + onClose, + type, + displayAmount +}: LiquidityConfirmDialogProps) => { + return ( + +
+
+
+ +
+ +
+
+ + {title} + +
+

+ Are you sure you want to {type.toLowerCase()} {displayAmount}? +

+
+ {/* no input needed - form submit is confirmation */} +
+ + +
+
+
+
+
+
+
+
+ ) +} diff --git a/packages/frontend/app/components/LiquidityDialog.tsx b/packages/frontend/app/components/LiquidityDialog.tsx index 1bcf55fbfb..dc8e43c1a5 100644 --- a/packages/frontend/app/components/LiquidityDialog.tsx +++ b/packages/frontend/app/components/LiquidityDialog.tsx @@ -6,7 +6,7 @@ import { Button, Input } from '~/components/ui' type LiquidityDialogProps = { title: string onClose: () => void - type: 'Add' | 'Withdraw' + type: 'Deposit' | 'Withdraw' } export const LiquidityDialog = ({ diff --git a/packages/frontend/app/components/Sidebar.tsx b/packages/frontend/app/components/Sidebar.tsx index 9f0ff1b1a4..091a228b54 100644 --- a/packages/frontend/app/components/Sidebar.tsx +++ b/packages/frontend/app/components/Sidebar.tsx @@ -24,6 +24,10 @@ const navigation = [ { name: 'Webhooks', href: '/webhooks' + }, + { + name: 'Payments', + href: '/payments' } ] diff --git a/packages/frontend/app/generated/graphql.ts b/packages/frontend/app/generated/graphql.ts index 30a4bc0cfb..c9781e0ea2 100644 --- a/packages/frontend/app/generated/graphql.ts +++ b/packages/frontend/app/generated/graphql.ts @@ -19,28 +19,6 @@ export type Scalars = { UInt64: { input: bigint; output: bigint; } }; -export type AddAssetLiquidityInput = { - /** Amount of liquidity to add. */ - amount: Scalars['UInt64']['input']; - /** The id of the asset to add liquidity. */ - assetId: Scalars['String']['input']; - /** The id of the transfer. */ - id: Scalars['String']['input']; - /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ - idempotencyKey: Scalars['String']['input']; -}; - -export type AddPeerLiquidityInput = { - /** Amount of liquidity to add. */ - amount: Scalars['UInt64']['input']; - /** The id of the transfer. */ - id: Scalars['String']['input']; - /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ - idempotencyKey: Scalars['String']['input']; - /** The id of the peer to add liquidity. */ - peerId: Scalars['String']['input']; -}; - export enum Alg { EdDsa = 'EdDSA' } @@ -160,14 +138,14 @@ export type CreateIncomingPaymentInput = { }; export type CreateOrUpdatePeerByUrlInput = { - /** Initial amount of liquidity to add for peer */ - addedLiquidity?: InputMaybe; /** Asset id of peering relationship */ assetId: Scalars['String']['input']; /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ idempotencyKey?: InputMaybe; /** Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this value */ liquidityThreshold?: InputMaybe; + /** Amount of liquidity to deposit for peer */ + liquidityToDeposit?: InputMaybe; /** Maximum packet amount that the peer accepts */ maxPacketAmount?: InputMaybe; /** Peer's internal name for overriding auto-peer's default naming */ @@ -202,7 +180,7 @@ export type CreatePeerInput = { http: HttpInput; /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ idempotencyKey?: InputMaybe; - /** Initial amount of liquidity to add for peer */ + /** Initial amount of liquidity to deposit for peer */ initialLiquidity?: InputMaybe; /** Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this value */ liquidityThreshold?: InputMaybe; @@ -328,6 +306,17 @@ export type DeletePeerMutationResponse = MutationResponse & { success: Scalars['Boolean']['output']; }; +export type DepositAssetLiquidityInput = { + /** Amount of liquidity to deposit. */ + amount: Scalars['UInt64']['input']; + /** The id of the asset to deposit liquidity. */ + assetId: Scalars['String']['input']; + /** The id of the transfer. */ + id: Scalars['String']['input']; + /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ + idempotencyKey: Scalars['String']['input']; +}; + export type DepositEventLiquidityInput = { /** The id of the event to deposit into. */ eventId: Scalars['String']['input']; @@ -335,6 +324,24 @@ export type DepositEventLiquidityInput = { idempotencyKey: Scalars['String']['input']; }; +export type DepositOutgoingPaymentLiquidityInput = { + /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ + idempotencyKey: Scalars['String']['input']; + /** The id of the outgoing payment to deposit into. */ + outgoingPaymentId: Scalars['String']['input']; +}; + +export type DepositPeerLiquidityInput = { + /** Amount of liquidity to deposit. */ + amount: Scalars['UInt64']['input']; + /** The id of the transfer. */ + id: Scalars['String']['input']; + /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ + idempotencyKey: Scalars['String']['input']; + /** The id of the peer to deposit liquidity. */ + peerId: Scalars['String']['input']; +}; + export type Fee = Model & { __typename?: 'Fee'; /** Asset id associated with the fee */ @@ -424,6 +431,8 @@ export type IncomingPayment = BasePayment & Model & { id: Scalars['ID']['output']; /** The maximum amount that should be paid into the wallet address under this incoming payment. */ incomingAmount?: Maybe; + /** Available liquidity */ + liquidity?: Maybe; /** Additional metadata associated with the incoming payment. */ metadata?: Maybe; /** The total amount that has been paid into the wallet address under this incoming payment. */ @@ -526,10 +535,6 @@ export type Model = { export type Mutation = { __typename?: 'Mutation'; - /** Add asset liquidity */ - addAssetLiquidity?: Maybe; - /** Add peer liquidity */ - addPeerLiquidity?: Maybe; /** Create an asset */ createAsset: AssetMutationResponse; /** Withdraw asset liquidity */ @@ -556,8 +561,17 @@ export type Mutation = { createWalletAddressWithdrawal?: Maybe; /** Delete a peer */ deletePeer: DeletePeerMutationResponse; - /** Deposit webhook event liquidity */ + /** Deposit asset liquidity */ + depositAssetLiquidity?: Maybe; + /** + * Deposit webhook event liquidity + * @deprecated Use `depositOutgoingPaymentLiquidity` + */ depositEventLiquidity?: Maybe; + /** Deposit outgoing payment liquidity */ + depositOutgoingPaymentLiquidity?: Maybe; + /** Deposit peer liquidity */ + depositPeerLiquidity?: Maybe; /** Post liquidity withdrawal. Withdrawals are two-phase commits and are committed via this mutation. */ postLiquidityWithdrawal?: Maybe; /** Revoke a public key associated with a wallet address. Open Payment requests using this key for request signatures will be denied going forward. */ @@ -574,18 +588,15 @@ export type Mutation = { updateWalletAddress: UpdateWalletAddressMutationResponse; /** Void liquidity withdrawal. Withdrawals are two-phase commits and are rolled back via this mutation. */ voidLiquidityWithdrawal?: Maybe; - /** Withdraw webhook event liquidity */ + /** + * Withdraw webhook event liquidity + * @deprecated Use `withdrawOutgoingPaymentLiquidity, withdrawIncomingPaymentLiquidity, or createWalletAddressWithdrawal` + */ withdrawEventLiquidity?: Maybe; -}; - - -export type MutationAddAssetLiquidityArgs = { - input: AddAssetLiquidityInput; -}; - - -export type MutationAddPeerLiquidityArgs = { - input: AddPeerLiquidityInput; + /** Withdraw incoming payment liquidity */ + withdrawIncomingPaymentLiquidity?: Maybe; + /** Withdraw outgoing payment liquidity */ + withdrawOutgoingPaymentLiquidity?: Maybe; }; @@ -654,11 +665,26 @@ export type MutationDeletePeerArgs = { }; +export type MutationDepositAssetLiquidityArgs = { + input: DepositAssetLiquidityInput; +}; + + export type MutationDepositEventLiquidityArgs = { input: DepositEventLiquidityInput; }; +export type MutationDepositOutgoingPaymentLiquidityArgs = { + input: DepositOutgoingPaymentLiquidityInput; +}; + + +export type MutationDepositPeerLiquidityArgs = { + input: DepositPeerLiquidityInput; +}; + + export type MutationPostLiquidityWithdrawalArgs = { input: PostLiquidityWithdrawalInput; }; @@ -703,6 +729,16 @@ export type MutationWithdrawEventLiquidityArgs = { input: WithdrawEventLiquidityInput; }; + +export type MutationWithdrawIncomingPaymentLiquidityArgs = { + input: WithdrawIncomingPaymentLiquidityInput; +}; + + +export type MutationWithdrawOutgoingPaymentLiquidityArgs = { + input: WithdrawOutgoingPaymentLiquidityInput; +}; + export type MutationResponse = { code: Scalars['String']['output']; message: Scalars['String']['output']; @@ -718,6 +754,8 @@ export type OutgoingPayment = BasePayment & Model & { error?: Maybe; /** Outgoing payment id */ id: Scalars['ID']['output']; + /** Available liquidity */ + liquidity?: Maybe; /** Additional metadata associated with the outgoing payment. */ metadata?: Maybe; /** Quote for this outgoing payment */ @@ -784,6 +822,8 @@ export type Payment = BasePayment & Model & { createdAt: Scalars['String']['output']; /** Payment id */ id: Scalars['ID']['output']; + /** Available liquidity */ + liquidity?: Maybe; /** Additional metadata associated with the payment. */ metadata?: Maybe; /** Either the IncomingPaymentState or OutgoingPaymentState according to type */ @@ -1171,6 +1211,8 @@ export type WalletAddress = Model & { id: Scalars['ID']['output']; /** List of incoming payments received by this wallet address */ incomingPayments?: Maybe; + /** Available liquidity */ + liquidity?: Maybe; /** List of outgoing payments sent from this wallet address */ outgoingPayments?: Maybe; /** Public name associated with the wallet address */ @@ -1297,6 +1339,20 @@ export type WithdrawEventLiquidityInput = { idempotencyKey: Scalars['String']['input']; }; +export type WithdrawIncomingPaymentLiquidityInput = { + /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ + idempotencyKey: Scalars['String']['input']; + /** The id of the incoming payment to withdraw from. */ + incomingPaymentId: Scalars['String']['input']; +}; + +export type WithdrawOutgoingPaymentLiquidityInput = { + /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ + idempotencyKey: Scalars['String']['input']; + /** The id of the outgoing payment to withdraw from. */ + outgoingPaymentId: Scalars['String']['input']; +}; + export type ResolverTypeWrapper = Promise | T; @@ -1374,8 +1430,6 @@ export type ResolversInterfaceTypes> = { /** Mapping between all available schema types and the resolvers types */ export type ResolversTypes = { - AddAssetLiquidityInput: ResolverTypeWrapper>; - AddPeerLiquidityInput: ResolverTypeWrapper>; Alg: ResolverTypeWrapper>; Amount: ResolverTypeWrapper>; AmountInput: ResolverTypeWrapper>; @@ -1405,7 +1459,10 @@ export type ResolversTypes = { Crv: ResolverTypeWrapper>; DeletePeerInput: ResolverTypeWrapper>; DeletePeerMutationResponse: ResolverTypeWrapper>; + DepositAssetLiquidityInput: ResolverTypeWrapper>; DepositEventLiquidityInput: ResolverTypeWrapper>; + DepositOutgoingPaymentLiquidityInput: ResolverTypeWrapper>; + DepositPeerLiquidityInput: ResolverTypeWrapper>; Fee: ResolverTypeWrapper>; FeeDetails: ResolverTypeWrapper>; FeeEdge: ResolverTypeWrapper>; @@ -1484,12 +1541,12 @@ export type ResolversTypes = { WebhookEventsConnection: ResolverTypeWrapper>; WebhookEventsEdge: ResolverTypeWrapper>; WithdrawEventLiquidityInput: ResolverTypeWrapper>; + WithdrawIncomingPaymentLiquidityInput: ResolverTypeWrapper>; + WithdrawOutgoingPaymentLiquidityInput: ResolverTypeWrapper>; }; /** Mapping between all available schema types and the resolvers parents */ export type ResolversParentTypes = { - AddAssetLiquidityInput: Partial; - AddPeerLiquidityInput: Partial; Amount: Partial; AmountInput: Partial; Asset: Partial; @@ -1517,7 +1574,10 @@ export type ResolversParentTypes = { CreateWalletAddressWithdrawalInput: Partial; DeletePeerInput: Partial; DeletePeerMutationResponse: Partial; + DepositAssetLiquidityInput: Partial; DepositEventLiquidityInput: Partial; + DepositOutgoingPaymentLiquidityInput: Partial; + DepositPeerLiquidityInput: Partial; Fee: Partial; FeeDetails: Partial; FeeEdge: Partial; @@ -1588,6 +1648,8 @@ export type ResolversParentTypes = { WebhookEventsConnection: Partial; WebhookEventsEdge: Partial; WithdrawEventLiquidityInput: Partial; + WithdrawIncomingPaymentLiquidityInput: Partial; + WithdrawOutgoingPaymentLiquidityInput: Partial; }; export type AmountResolvers = { @@ -1724,6 +1786,7 @@ export type IncomingPaymentResolvers; id?: Resolver; incomingAmount?: Resolver, ParentType, ContextType>; + liquidity?: Resolver, ParentType, ContextType>; metadata?: Resolver, ParentType, ContextType>; receivedAmount?: Resolver; state?: Resolver; @@ -1779,8 +1842,6 @@ export type ModelResolvers = { - addAssetLiquidity?: Resolver, ParentType, ContextType, RequireFields>; - addPeerLiquidity?: Resolver, ParentType, ContextType, RequireFields>; createAsset?: Resolver>; createAssetLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; createIncomingPayment?: Resolver>; @@ -1794,7 +1855,10 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; createWalletAddressWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; deletePeer?: Resolver>; + depositAssetLiquidity?: Resolver, ParentType, ContextType, RequireFields>; depositEventLiquidity?: Resolver, ParentType, ContextType, RequireFields>; + depositOutgoingPaymentLiquidity?: Resolver, ParentType, ContextType, RequireFields>; + depositPeerLiquidity?: Resolver, ParentType, ContextType, RequireFields>; postLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; revokeWalletAddressKey?: Resolver, ParentType, ContextType, RequireFields>; setFee?: Resolver>; @@ -1804,6 +1868,8 @@ export type MutationResolvers>; voidLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; withdrawEventLiquidity?: Resolver, ParentType, ContextType, RequireFields>; + withdrawIncomingPaymentLiquidity?: Resolver, ParentType, ContextType, RequireFields>; + withdrawOutgoingPaymentLiquidity?: Resolver, ParentType, ContextType, RequireFields>; }; export type MutationResponseResolvers = { @@ -1818,6 +1884,7 @@ export type OutgoingPaymentResolvers; error?: Resolver, ParentType, ContextType>; id?: Resolver; + liquidity?: Resolver, ParentType, ContextType>; metadata?: Resolver, ParentType, ContextType>; quote?: Resolver, ParentType, ContextType>; receiveAmount?: Resolver; @@ -1860,6 +1927,7 @@ export type PageInfoResolvers = { createdAt?: Resolver; id?: Resolver; + liquidity?: Resolver, ParentType, ContextType>; metadata?: Resolver, ParentType, ContextType>; state?: Resolver; type?: Resolver; @@ -2027,6 +2095,7 @@ export type WalletAddressResolvers; id?: Resolver; incomingPayments?: Resolver, ParentType, ContextType, Partial>; + liquidity?: Resolver, ParentType, ContextType>; outgoingPayments?: Resolver, ParentType, ContextType, Partial>; publicName?: Resolver, ParentType, ContextType>; quotes?: Resolver, ParentType, ContextType, Partial>; @@ -2206,12 +2275,12 @@ export type SetFeeMutationVariables = Exact<{ export type SetFeeMutation = { __typename?: 'Mutation', setFee: { __typename?: 'SetFeeResponse', code: string, message: string, success: boolean, fee?: { __typename?: 'Fee', assetId: string, basisPoints: number, createdAt: string, fixed: bigint, id: string, type: FeeType } | null } }; -export type AddAssetLiquidityMutationVariables = Exact<{ - input: AddAssetLiquidityInput; +export type DepositAssetLiquidityMutationVariables = Exact<{ + input: DepositAssetLiquidityInput; }>; -export type AddAssetLiquidityMutation = { __typename?: 'Mutation', addAssetLiquidity?: { __typename?: 'LiquidityMutationResponse', code: string, success: boolean, message: string, error?: LiquidityError | null } | null }; +export type DepositAssetLiquidityMutation = { __typename?: 'Mutation', depositAssetLiquidity?: { __typename?: 'LiquidityMutationResponse', code: string, success: boolean, message: string, error?: LiquidityError | null } | null }; export type WithdrawAssetLiquidityVariables = Exact<{ input: CreateAssetLiquidityWithdrawalInput; @@ -2220,6 +2289,52 @@ export type WithdrawAssetLiquidityVariables = Exact<{ export type WithdrawAssetLiquidity = { __typename?: 'Mutation', createAssetLiquidityWithdrawal?: { __typename?: 'LiquidityMutationResponse', code: string, success: boolean, message: string, error?: LiquidityError | null } | null }; +export type GetIncomingPaymentVariables = Exact<{ + id: Scalars['String']['input']; +}>; + + +export type GetIncomingPayment = { __typename?: 'Query', incomingPayment?: { __typename?: 'IncomingPayment', id: string, walletAddressId: string, state: IncomingPaymentState, expiresAt: string, metadata?: any | null, createdAt: string, liquidity?: bigint | null, incomingAmount?: { __typename?: 'Amount', value: bigint, assetCode: string, assetScale: number } | null, receivedAmount: { __typename?: 'Amount', value: bigint, assetCode: string, assetScale: number } } | null }; + +export type GetOutgoingPaymentVariables = Exact<{ + id: Scalars['String']['input']; +}>; + + +export type GetOutgoingPayment = { __typename?: 'Query', outgoingPayment?: { __typename?: 'OutgoingPayment', id: string, createdAt: string, error?: string | null, receiver: string, walletAddressId: string, state: OutgoingPaymentState, metadata?: any | null, liquidity?: bigint | null, receiveAmount: { __typename?: 'Amount', assetCode: string, assetScale: number, value: bigint }, debitAmount: { __typename?: 'Amount', assetCode: string, assetScale: number, value: bigint }, sentAmount: { __typename?: 'Amount', assetCode: string, assetScale: number, value: bigint } } | null }; + +export type ListPaymentsQueryVariables = Exact<{ + after?: InputMaybe; + before?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + filter?: InputMaybe; +}>; + + +export type ListPaymentsQuery = { __typename?: 'Query', payments: { __typename?: 'PaymentConnection', edges: Array<{ __typename?: 'PaymentEdge', node: { __typename?: 'Payment', id: string, type: PaymentType, state: string, createdAt: string } }>, pageInfo: { __typename?: 'PageInfo', startCursor?: string | null, endCursor?: string | null, hasNextPage: boolean, hasPreviousPage: boolean } } }; + +export type DepositOutgoingPaymentLiquidityVariables = Exact<{ + input: DepositOutgoingPaymentLiquidityInput; +}>; + + +export type DepositOutgoingPaymentLiquidity = { __typename?: 'Mutation', depositOutgoingPaymentLiquidity?: { __typename?: 'LiquidityMutationResponse', success: boolean, message: string } | null }; + +export type WithdrawOutgoingPaymentLiquidityVariables = Exact<{ + input: WithdrawOutgoingPaymentLiquidityInput; +}>; + + +export type WithdrawOutgoingPaymentLiquidity = { __typename?: 'Mutation', withdrawOutgoingPaymentLiquidity?: { __typename?: 'LiquidityMutationResponse', success: boolean, message: string } | null }; + +export type WithdrawIncomingPaymentLiquidityVariables = Exact<{ + input: WithdrawIncomingPaymentLiquidityInput; +}>; + + +export type WithdrawIncomingPaymentLiquidity = { __typename?: 'Mutation', withdrawIncomingPaymentLiquidity?: { __typename?: 'LiquidityMutationResponse', success: boolean, message: string } | null }; + export type GetPeerQueryVariables = Exact<{ id: Scalars['String']['input']; }>; @@ -2258,12 +2373,12 @@ export type DeletePeerMutationVariables = Exact<{ export type DeletePeerMutation = { __typename?: 'Mutation', deletePeer: { __typename?: 'DeletePeerMutationResponse', code: string, success: boolean, message: string } }; -export type AddPeerLiquidityMutationVariables = Exact<{ - input: AddPeerLiquidityInput; +export type DepositPeerLiquidityMutationVariables = Exact<{ + input: DepositPeerLiquidityInput; }>; -export type AddPeerLiquidityMutation = { __typename?: 'Mutation', addPeerLiquidity?: { __typename?: 'LiquidityMutationResponse', code: string, success: boolean, message: string, error?: LiquidityError | null } | null }; +export type DepositPeerLiquidityMutation = { __typename?: 'Mutation', depositPeerLiquidity?: { __typename?: 'LiquidityMutationResponse', code: string, success: boolean, message: string, error?: LiquidityError | null } | null }; export type WithdrawPeerLiquidityVariables = Exact<{ input: CreatePeerLiquidityWithdrawalInput; @@ -2277,7 +2392,7 @@ export type GetWalletAddressQueryVariables = Exact<{ }>; -export type GetWalletAddressQuery = { __typename?: 'Query', walletAddress?: { __typename?: 'WalletAddress', id: string, url: string, publicName?: string | null, status: WalletAddressStatus, createdAt: string, asset: { __typename?: 'Asset', id: string, code: string, scale: number, withdrawalThreshold?: bigint | null } } | null }; +export type GetWalletAddressQuery = { __typename?: 'Query', walletAddress?: { __typename?: 'WalletAddress', id: string, url: string, publicName?: string | null, status: WalletAddressStatus, createdAt: string, liquidity?: bigint | null, asset: { __typename?: 'Asset', id: string, code: string, scale: number, withdrawalThreshold?: bigint | null } } | null }; export type ListWalletAddresssQueryVariables = Exact<{ after?: InputMaybe; @@ -2303,6 +2418,13 @@ export type CreateWalletAddressMutationVariables = Exact<{ export type CreateWalletAddressMutation = { __typename?: 'Mutation', createWalletAddress: { __typename?: 'CreateWalletAddressMutationResponse', code: string, success: boolean, message: string, walletAddress?: { __typename?: 'WalletAddress', id: string } | null } }; +export type CreateWalletAddressWithdrawalVariables = Exact<{ + input: CreateWalletAddressWithdrawalInput; +}>; + + +export type CreateWalletAddressWithdrawal = { __typename?: 'Mutation', createWalletAddressWithdrawal?: { __typename?: 'WalletAddressWithdrawalMutationResponse', success: boolean, message: string } | null }; + export type ListWebhookEventsVariables = Exact<{ after?: InputMaybe; before?: InputMaybe; @@ -2312,4 +2434,4 @@ export type ListWebhookEventsVariables = Exact<{ }>; -export type ListWebhookEvents = { __typename?: 'Query', webhookEvents: { __typename?: 'WebhookEventsConnection', edges: Array<{ __typename?: 'WebhookEventsEdge', cursor: string, node: { __typename?: 'WebhookEvent', id: string, data: any, type: string } }>, pageInfo: { __typename?: 'PageInfo', startCursor?: string | null, endCursor?: string | null, hasNextPage: boolean, hasPreviousPage: boolean } } }; +export type ListWebhookEvents = { __typename?: 'Query', webhookEvents: { __typename?: 'WebhookEventsConnection', edges: Array<{ __typename?: 'WebhookEventsEdge', cursor: string, node: { __typename?: 'WebhookEvent', id: string, data: any, type: string, createdAt: string } }>, pageInfo: { __typename?: 'PageInfo', startCursor?: string | null, endCursor?: string | null, hasNextPage: boolean, hasPreviousPage: boolean } } }; diff --git a/packages/frontend/app/lib/api/asset.server.ts b/packages/frontend/app/lib/api/asset.server.ts index 61f6fcba0f..5dd9a3399e 100644 --- a/packages/frontend/app/lib/api/asset.server.ts +++ b/packages/frontend/app/lib/api/asset.server.ts @@ -1,8 +1,8 @@ import { gql } from '@apollo/client' import type { - AddAssetLiquidityInput, - AddAssetLiquidityMutation, - AddAssetLiquidityMutationVariables, + DepositAssetLiquidityInput, + DepositAssetLiquidityMutation, + DepositAssetLiquidityMutationVariables, CreateAssetInput, CreateAssetLiquidityWithdrawalInput, CreateAssetMutation, @@ -208,14 +208,18 @@ export const setFee = async (args: SetFeeInput) => { return response.data?.setFee } -export const addAssetLiquidity = async (args: AddAssetLiquidityInput) => { +export const depositAssetLiquidity = async ( + args: DepositAssetLiquidityInput +) => { const response = await apolloClient.mutate< - AddAssetLiquidityMutation, - AddAssetLiquidityMutationVariables + DepositAssetLiquidityMutation, + DepositAssetLiquidityMutationVariables >({ mutation: gql` - mutation AddAssetLiquidityMutation($input: AddAssetLiquidityInput!) { - addAssetLiquidity(input: $input) { + mutation DepositAssetLiquidityMutation( + $input: DepositAssetLiquidityInput! + ) { + depositAssetLiquidity(input: $input) { code success message @@ -228,7 +232,7 @@ export const addAssetLiquidity = async (args: AddAssetLiquidityInput) => { } }) - return response.data?.addAssetLiquidity + return response.data?.depositAssetLiquidity } export const withdrawAssetLiquidity = async ( diff --git a/packages/frontend/app/lib/api/payments.server.ts b/packages/frontend/app/lib/api/payments.server.ts new file mode 100644 index 0000000000..a8300ee67d --- /dev/null +++ b/packages/frontend/app/lib/api/payments.server.ts @@ -0,0 +1,215 @@ +import { gql } from '@apollo/client' +import type { + ListPaymentsQuery, + ListPaymentsQueryVariables, + WithdrawIncomingPaymentLiquidity, + WithdrawIncomingPaymentLiquidityVariables, + WithdrawIncomingPaymentLiquidityInput, + WithdrawOutgoingPaymentLiquidity, + WithdrawOutgoingPaymentLiquidityVariables, + WithdrawOutgoingPaymentLiquidityInput, + DepositOutgoingPaymentLiquidityInput, + DepositOutgoingPaymentLiquidity, + DepositOutgoingPaymentLiquidityVariables +} from '~/generated/graphql' +import { + type QueryIncomingPaymentArgs, + type QueryPaymentsArgs, + type QueryOutgoingPaymentArgs, + type GetIncomingPayment, + type GetIncomingPaymentVariables, + type GetOutgoingPaymentVariables, + type GetOutgoingPayment +} from '~/generated/graphql' +import { apolloClient } from '../apollo.server' + +export const getIncomingPayment = async (args: QueryIncomingPaymentArgs) => { + await apolloClient.query + const response = await apolloClient.query< + GetIncomingPayment, + GetIncomingPaymentVariables + >({ + query: gql` + query GetIncomingPayment($id: String!) { + incomingPayment(id: $id) { + id + walletAddressId + state + expiresAt + incomingAmount { + value + assetCode + assetScale + } + receivedAmount { + value + assetCode + assetScale + } + metadata + createdAt + liquidity + } + } + `, + variables: args + }) + return response.data.incomingPayment +} + +export const getOutgoingPayment = async (args: QueryOutgoingPaymentArgs) => { + const response = await apolloClient.query< + GetOutgoingPayment, + GetOutgoingPaymentVariables + >({ + query: gql` + query GetOutgoingPayment($id: String!) { + outgoingPayment(id: $id) { + id + createdAt + error + receiver + walletAddressId + state + metadata + receiveAmount { + assetCode + assetScale + value + } + debitAmount { + assetCode + assetScale + value + } + sentAmount { + assetCode + assetScale + value + } + liquidity + } + } + `, + variables: args + }) + return response.data.outgoingPayment +} + +export const listPayments = async (args: QueryPaymentsArgs) => { + const response = await apolloClient.query< + ListPaymentsQuery, + ListPaymentsQueryVariables + >({ + query: gql` + query ListPaymentsQuery( + $after: String + $before: String + $first: Int + $last: Int + $filter: PaymentFilter + ) { + payments( + after: $after + before: $before + first: $first + last: $last + filter: $filter + ) { + edges { + node { + id + type + state + createdAt + } + } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + } + } + `, + variables: args + }) + + return response.data.payments +} + +export const depositOutgoingPaymentLiquidity = async ( + args: DepositOutgoingPaymentLiquidityInput +) => { + const response = await apolloClient.mutate< + DepositOutgoingPaymentLiquidity, + DepositOutgoingPaymentLiquidityVariables + >({ + mutation: gql` + mutation DepositOutgoingPaymentLiquidity( + $input: DepositOutgoingPaymentLiquidityInput! + ) { + depositOutgoingPaymentLiquidity(input: $input) { + success + message + } + } + `, + variables: { + input: args + } + }) + + return response.data?.depositOutgoingPaymentLiquidity +} + +export const withdrawOutgoingPaymentLiquidity = async ( + args: WithdrawOutgoingPaymentLiquidityInput +) => { + const response = await apolloClient.mutate< + WithdrawOutgoingPaymentLiquidity, + WithdrawOutgoingPaymentLiquidityVariables + >({ + mutation: gql` + mutation WithdrawOutgoingPaymentLiquidity( + $input: WithdrawOutgoingPaymentLiquidityInput! + ) { + withdrawOutgoingPaymentLiquidity(input: $input) { + success + message + } + } + `, + variables: { + input: args + } + }) + + return response.data?.withdrawOutgoingPaymentLiquidity +} + +export const withdrawIncomingPaymentLiquidity = async ( + args: WithdrawIncomingPaymentLiquidityInput +) => { + const response = await apolloClient.mutate< + WithdrawIncomingPaymentLiquidity, + WithdrawIncomingPaymentLiquidityVariables + >({ + mutation: gql` + mutation WithdrawIncomingPaymentLiquidity( + $input: WithdrawIncomingPaymentLiquidityInput! + ) { + withdrawIncomingPaymentLiquidity(input: $input) { + success + message + } + } + `, + variables: { + input: args + } + }) + + return response.data?.withdrawIncomingPaymentLiquidity +} diff --git a/packages/frontend/app/lib/api/peer.server.ts b/packages/frontend/app/lib/api/peer.server.ts index e54f7b3935..990b58290f 100644 --- a/packages/frontend/app/lib/api/peer.server.ts +++ b/packages/frontend/app/lib/api/peer.server.ts @@ -1,8 +1,8 @@ import { gql } from '@apollo/client' import type { - AddPeerLiquidityInput, - AddPeerLiquidityMutation, - AddPeerLiquidityMutationVariables, + DepositPeerLiquidityInput, + DepositPeerLiquidityMutation, + DepositPeerLiquidityMutationVariables, CreatePeerInput, CreatePeerLiquidityWithdrawalInput, CreatePeerMutation, @@ -170,14 +170,16 @@ export const deletePeer = async (args: MutationDeletePeerArgs) => { return response.data?.deletePeer } -export const addPeerLiquidity = async (args: AddPeerLiquidityInput) => { +export const depositPeerLiquidity = async (args: DepositPeerLiquidityInput) => { const response = await apolloClient.mutate< - AddPeerLiquidityMutation, - AddPeerLiquidityMutationVariables + DepositPeerLiquidityMutation, + DepositPeerLiquidityMutationVariables >({ mutation: gql` - mutation AddPeerLiquidityMutation($input: AddPeerLiquidityInput!) { - addPeerLiquidity(input: $input) { + mutation DepositPeerLiquidityMutation( + $input: DepositPeerLiquidityInput! + ) { + depositPeerLiquidity(input: $input) { code success message @@ -190,7 +192,7 @@ export const addPeerLiquidity = async (args: AddPeerLiquidityInput) => { } }) - return response.data?.addPeerLiquidity + return response.data?.depositPeerLiquidity } export const withdrawPeerLiquidity = async ( diff --git a/packages/frontend/app/lib/api/wallet-address.server.ts b/packages/frontend/app/lib/api/wallet-address.server.ts index 5caf76e7a9..50e6dca4a0 100644 --- a/packages/frontend/app/lib/api/wallet-address.server.ts +++ b/packages/frontend/app/lib/api/wallet-address.server.ts @@ -10,7 +10,10 @@ import type { QueryWalletAddressArgs, QueryWalletAddressesArgs, UpdateWalletAddressInput, - CreateWalletAddressMutationVariables + CreateWalletAddressMutationVariables, + CreateWalletAddressWithdrawalVariables, + CreateWalletAddressWithdrawal, + CreateWalletAddressWithdrawalInput } from '~/generated/graphql' export const getWalletAddress = async (args: QueryWalletAddressArgs) => { @@ -26,6 +29,7 @@ export const getWalletAddress = async (args: QueryWalletAddressArgs) => { publicName status createdAt + liquidity asset { id code @@ -126,3 +130,28 @@ export const createWalletAddress = async (args: CreateWalletAddressInput) => { return response.data?.createWalletAddress } + +export const createWalletAddressWithdrawal = async ( + args: CreateWalletAddressWithdrawalInput +) => { + const response = await apolloClient.mutate< + CreateWalletAddressWithdrawal, + CreateWalletAddressWithdrawalVariables + >({ + mutation: gql` + mutation CreateWalletAddressWithdrawal( + $input: CreateWalletAddressWithdrawalInput! + ) { + createWalletAddressWithdrawal(input: $input) { + success + message + } + } + `, + variables: { + input: args + } + }) + + return response.data?.createWalletAddressWithdrawal +} diff --git a/packages/frontend/app/lib/api/webhook.server.ts b/packages/frontend/app/lib/api/webhook.server.ts index e873d12d45..d88beec5ee 100644 --- a/packages/frontend/app/lib/api/webhook.server.ts +++ b/packages/frontend/app/lib/api/webhook.server.ts @@ -32,6 +32,7 @@ export const listWebhooks = async (args: QueryWebhookEventsArgs) => { id data type + createdAt } } pageInfo { diff --git a/packages/frontend/app/lib/validate.server.ts b/packages/frontend/app/lib/validate.server.ts index 54c5763836..2d173eae5c 100644 --- a/packages/frontend/app/lib/validate.server.ts +++ b/packages/frontend/app/lib/validate.server.ts @@ -1,7 +1,7 @@ import { z } from 'zod' import { isValidIlpAddress } from 'ilp-packet' import { WebhookEventType } from '~/shared/enums' -import { WalletAddressStatus } from '~/generated/graphql' +import { WalletAddressStatus, PaymentType } from '~/generated/graphql' export const uuidSchema = z.object({ id: z.string().uuid() @@ -22,6 +22,12 @@ export const webhooksSearchParams = paginationSchema.merge( }) ) +export const paymentsSearchParams = paginationSchema.merge( + z.object({ + type: z.array(z.nativeEnum(PaymentType)).default([]) + }) +) + export const peerGeneralInfoSchema = z .object({ name: z.string().optional(), diff --git a/packages/frontend/app/routes/assets.$assetId.add-liquidity.tsx b/packages/frontend/app/routes/assets.$assetId.deposit-liquidity.tsx similarity index 85% rename from packages/frontend/app/routes/assets.$assetId.add-liquidity.tsx rename to packages/frontend/app/routes/assets.$assetId.deposit-liquidity.tsx index 90eb222411..3b17f27378 100644 --- a/packages/frontend/app/routes/assets.$assetId.add-liquidity.tsx +++ b/packages/frontend/app/routes/assets.$assetId.deposit-liquidity.tsx @@ -2,19 +2,19 @@ import { type ActionArgs } from '@remix-run/node' import { useNavigate } from '@remix-run/react' import { v4 } from 'uuid' import { LiquidityDialog } from '~/components/LiquidityDialog' -import { addAssetLiquidity } from '~/lib/api/asset.server' +import { depositAssetLiquidity } from '~/lib/api/asset.server' import { messageStorage, setMessageAndRedirect } from '~/lib/message.server' import { amountSchema } from '~/lib/validate.server' -export default function AssetAddLiquidity() { +export default function AssetDepositLiquidity() { const navigate = useNavigate() const dismissDialog = () => navigate('..', { preventScrollReset: true }) return ( ) } @@ -48,7 +48,7 @@ export async function action({ request, params }: ActionArgs) { }) } - const response = await addAssetLiquidity({ + const response = await depositAssetLiquidity({ assetId, amount: result.data, id: v4(), @@ -61,7 +61,7 @@ export async function action({ request, params }: ActionArgs) { message: { content: response?.message ?? - 'Could not add asset liquidity. Please try again!', + 'Could not deposit asset liquidity. Please try again!', type: 'error' }, location: '.' diff --git a/packages/frontend/app/routes/assets.$assetId.tsx b/packages/frontend/app/routes/assets.$assetId.tsx index 9d563cfbff..43b7f3de11 100644 --- a/packages/frontend/app/routes/assets.$assetId.tsx +++ b/packages/frontend/app/routes/assets.$assetId.tsx @@ -32,20 +32,7 @@ export async function loader({ params }: LoaderArgs) { throw json(null, { status: 404, statusText: 'Asset not found.' }) } - return json({ - asset: { - ...asset, - createdAt: new Date(asset.createdAt).toLocaleString(), - ...(asset.sendingFee - ? { - sendingFee: { - ...asset.sendingFee, - createdAt: new Date(asset.sendingFee.createdAt).toLocaleString() - } - } - : {}) - } - }) + return json({ asset }) } export default function ViewAssetPage() { @@ -72,7 +59,9 @@ export default function ViewAssetPage() {

General Information

-

Created at {asset.createdAt}

+

+ Created at {new Date(asset.createdAt).toLocaleString()} +

@@ -123,11 +112,11 @@ export default function ViewAssetPage() {
diff --git a/packages/frontend/app/routes/assets.$assetId_.fee-history.tsx b/packages/frontend/app/routes/assets.$assetId_.fee-history.tsx index a3b3582126..903f9c4a43 100644 --- a/packages/frontend/app/routes/assets.$assetId_.fee-history.tsx +++ b/packages/frontend/app/routes/assets.$assetId_.fee-history.tsx @@ -28,31 +28,18 @@ export const loader = async ({ request, params }: LoaderArgs) => { throw json(null, { status: 404, statusText: 'Asset not found.' }) } - const fees = asset.fees - ? { - pageInfo: asset.fees.pageInfo, - edges: asset.fees.edges.map((edge) => ({ - cursor: edge.cursor, - node: { - ...edge.node, - createdAt: new Date(edge.node.createdAt).toLocaleString() - } - })) - } - : undefined - let previousPageUrl = '', nextPageUrl = '' - if (fees?.pageInfo.hasPreviousPage) { - previousPageUrl = `/assets/${assetId}/fee-history?before=${fees.pageInfo.startCursor}` + if (asset.fees?.pageInfo.hasPreviousPage) { + previousPageUrl = `/assets/${assetId}/fee-history?before=${asset.fees.pageInfo.startCursor}` } - if (fees?.pageInfo.hasNextPage) { - nextPageUrl = `/assets/${assetId}/fee-history?after=${fees.pageInfo.endCursor}` + if (asset.fees?.pageInfo.hasNextPage) { + nextPageUrl = `/assets/${assetId}/fee-history?after=${asset.fees.pageInfo.endCursor}` } - return json({ assetId, fees, previousPageUrl, nextPageUrl }) + return json({ assetId, fees: asset.fees, previousPageUrl, nextPageUrl }) } export default function AssetFeesPage() { @@ -90,7 +77,9 @@ export default function AssetFeesPage() { {fee.node.type} {fee.node.fixed} {fee.node.basisPoints} - {fee.node.createdAt} + + {new Date(fee.node.createdAt).toLocaleString()} + )) ) : ( diff --git a/packages/frontend/app/routes/payments._index.tsx b/packages/frontend/app/routes/payments._index.tsx new file mode 100644 index 0000000000..26feee2e56 --- /dev/null +++ b/packages/frontend/app/routes/payments._index.tsx @@ -0,0 +1,182 @@ +import { json, type LoaderArgs } from '@remix-run/node' +import { useLoaderData, useNavigate, useSearchParams } from '@remix-run/react' +import { Badge, PageHeader } from '~/components' +import { PopoverFilter } from '~/components/Filters' +import { Button, Table } from '~/components/ui' +import { listPayments } from '~/lib/api/payments.server' +import { paymentsSearchParams } from '~/lib/validate.server' +import { PaymentType } from '~/generated/graphql' +import type { CombinedPaymentState } from '~/shared/utils' +import { + capitalize, + badgeColorByPaymentState, + paymentSubpathByType +} from '~/shared/utils' + +export const loader = async ({ request }: LoaderArgs) => { + const url = new URL(request.url) + const searchParams = Object.fromEntries(url.searchParams.entries()) + + const result = paymentsSearchParams.safeParse( + searchParams.type + ? { ...searchParams, type: searchParams.type.split(',') } + : searchParams + ) + + if (!result.success) { + throw json(null, { status: 400, statusText: 'Invalid result.' }) + } + + const { type, ...pagination } = result.data + + const payments = await listPayments({ + ...pagination, + ...(type ? { filter: { type: { in: type } } } : {}) + }) + + let previousPageUrl = '', + nextPageUrl = '' + + if (payments.pageInfo.hasPreviousPage) { + previousPageUrl = `/payments?before=${payments.pageInfo.startCursor}` + } + + if (payments.pageInfo.hasNextPage) { + nextPageUrl = `/payments?after=${payments.pageInfo.endCursor}` + } + + return json({ + payments, + previousPageUrl, + nextPageUrl, + type + }) +} + +export default function PaymentsPage() { + const { payments, previousPageUrl, nextPageUrl, type } = + useLoaderData() + const setSearchParams = useSearchParams()[1] + + const navigate = useNavigate() + + function setTypeFilterParams(selectedType: PaymentType): void { + const selected = type + + if (!selected.includes(selectedType)) { + selected.push(selectedType) + setSearchParams(new URLSearchParams({ type: selected.join(',') })) + return + } + + setSearchParams(() => { + const newParams = selected.filter((t) => t !== selectedType).join(',') + if (newParams.length === 0) { + return '' + } + + return new URLSearchParams({ + type: newParams + }) + }) + } + + return ( +
+
+ +
+

Payments

+
+
+
+

Filters

+
+ 0 ? type : ['all']} + options={[ + { + name: 'All', + value: 'all', + action: () => { + navigate(``) + } + }, + ...Object.values(PaymentType).map((value) => ({ + name: capitalize(value), + value: value, + action: () => { + setTypeFilterParams(value) + } + })) + ]} + /> +
+
+ + + + {payments.edges.length ? ( + payments.edges.map((payment) => ( + { + const subpath = paymentSubpathByType[payment.node.type] + return navigate(`/payments/${subpath}/${payment.node.id}`) + }} + > + {payment.node.id} + {capitalize(payment.node.type)} + + { + + {payment.node.state} + + } + + + {new Date(payment.node.createdAt).toLocaleString()} + + + )) + ) : ( + + + No payments found. + + + )} + +
+
+ + +
+
+
+ ) +} diff --git a/packages/frontend/app/routes/payments.incoming.$incomingPaymentId.tsx b/packages/frontend/app/routes/payments.incoming.$incomingPaymentId.tsx new file mode 100644 index 0000000000..af5df6f95a --- /dev/null +++ b/packages/frontend/app/routes/payments.incoming.$incomingPaymentId.tsx @@ -0,0 +1,180 @@ +import type { LoaderArgs } from '@remix-run/node' +import { json } from '@remix-run/node' +import { Link, Outlet, useLoaderData } from '@remix-run/react' +import { z } from 'zod' +import { Badge, PageHeader } from '~/components' +import { Button } from '~/components/ui' +import { IncomingPaymentState } from '~/generated/graphql' +import { getIncomingPayment } from '~/lib/api/payments.server' +import { + badgeColorByPaymentState, + formatAmount, + prettify +} from '~/shared/utils' + +export async function loader({ params }: LoaderArgs) { + const incomingPaymentId = params.incomingPaymentId + + const result = z.string().uuid().safeParse(incomingPaymentId) + if (!result.success) { + throw json(null, { + status: 400, + statusText: 'Invalid incoming payment ID.' + }) + } + + const incomingPayment = await getIncomingPayment({ id: result.data }) + + if (!incomingPayment) { + throw json(null, { status: 400, statusText: 'Incoming payment not found.' }) + } + + return json({ incomingPayment }) +} + +export default function ViewIncomingPaymentPage() { + const { incomingPayment } = useLoaderData() + + const canWithdrawLiquidity = + BigInt(incomingPayment.liquidity ?? '0') && + [IncomingPaymentState.Expired, IncomingPaymentState.Completed].includes( + incomingPayment.state + ) + + const displayLiquidityAmount = `${formatAmount( + incomingPayment.liquidity ?? '0', + incomingPayment.receivedAmount.assetScale + )} + ${incomingPayment.receivedAmount.assetCode}` + + const expiresAtLocale = new Date(incomingPayment.expiresAt).toLocaleString() + + return ( +
+
+ {/* Incoming Payment General Info */} + + + +
+ {/* Incoming Payment General Info*/} +
+

General Information

+

+ Created at {new Date(incomingPayment.createdAt).toLocaleString()}{' '} +

+ {new Date(expiresAtLocale) > new Date() && ( +

Expires at {expiresAtLocale}

+ )} +
+
+
+
+

Incoming Payment ID

+

{incomingPayment.id}

+
+
+

Wallet Address ID

+ + {incomingPayment.walletAddressId} + +
+
+

State

+ + {incomingPayment.state} + +
+
+

Incoming Amount

+

+ {incomingPayment.incomingAmount ? ( + formatAmount( + incomingPayment.incomingAmount.value, + incomingPayment.incomingAmount.assetScale + ) + + ' ' + + incomingPayment.incomingAmount.assetCode + ) : ( + None + )} +

+
+
+

Received Amount

+

+ {formatAmount( + incomingPayment.receivedAmount.value, + incomingPayment.receivedAmount.assetScale + ) + + ' ' + + incomingPayment.receivedAmount.assetCode} +

+
+
+ {incomingPayment.metadata ? ( +
+ Metadata +
+                  
+ ) : ( +
+

Metadata

+

+ None +

+
+ )} +
+
+
+
+ {/* Incoming Payment General Info - END */} + {/* Incoming Payment Liquidity */} +
+
+

Liquidity Information

+
+
+
+
+

Amount

+

{displayLiquidityAmount}

+
+
+ {canWithdrawLiquidity ? ( + + ) : ( + + )} +
+
+
+
+ {/* Incoming Payment Liquidity - END */} +
+ +
+ ) +} diff --git a/packages/frontend/app/routes/payments.incoming.$incomingPaymentId.withdraw-liquidity.tsx b/packages/frontend/app/routes/payments.incoming.$incomingPaymentId.withdraw-liquidity.tsx new file mode 100644 index 0000000000..eb9cf941f0 --- /dev/null +++ b/packages/frontend/app/routes/payments.incoming.$incomingPaymentId.withdraw-liquidity.tsx @@ -0,0 +1,64 @@ +import { type ActionArgs } from '@remix-run/node' +import { useNavigate, useOutletContext } from '@remix-run/react' +import { v4 } from 'uuid' +import { LiquidityConfirmDialog } from '~/components/LiquidityConfirmDialog' +import { withdrawIncomingPaymentLiquidity } from '~/lib/api/payments.server' +import { messageStorage, setMessageAndRedirect } from '~/lib/message.server' + +export default function IncomingPaymentWithdrawLiquidity() { + const displayLiquidityAmount = useOutletContext() + const navigate = useNavigate() + const dismissDialog = () => navigate('..', { preventScrollReset: true }) + + return ( + + ) +} + +export async function action({ request, params }: ActionArgs) { + const session = await messageStorage.getSession(request.headers.get('cookie')) + const incomingPaymentId = params.incomingPaymentId + + if (!incomingPaymentId) { + return setMessageAndRedirect({ + session, + message: { + content: 'Missing incoming payment ID', + type: 'error' + }, + location: '.' + }) + } + + const response = await withdrawIncomingPaymentLiquidity({ + incomingPaymentId, + idempotencyKey: v4() + }) + + if (!response?.success) { + return setMessageAndRedirect({ + session, + message: { + content: + response?.message ?? + 'Could not withdraw incoming payment liquidity. Please try again!', + type: 'error' + }, + location: '.' + }) + } + + return setMessageAndRedirect({ + session, + message: { + content: response.message, + type: 'success' + }, + location: '..' + }) +} diff --git a/packages/frontend/app/routes/payments.outgoing.$outgoingPaymentId.deposit-liquidity.tsx b/packages/frontend/app/routes/payments.outgoing.$outgoingPaymentId.deposit-liquidity.tsx new file mode 100644 index 0000000000..b4085f240c --- /dev/null +++ b/packages/frontend/app/routes/payments.outgoing.$outgoingPaymentId.deposit-liquidity.tsx @@ -0,0 +1,66 @@ +import { type ActionArgs } from '@remix-run/node' +import { useNavigate, useOutletContext } from '@remix-run/react' +import { v4 } from 'uuid' +import { LiquidityConfirmDialog } from '~/components/LiquidityConfirmDialog' +import { depositOutgoingPaymentLiquidity } from '~/lib/api/payments.server' +import { messageStorage, setMessageAndRedirect } from '~/lib/message.server' +import type { LiquidityActionOutletContext } from './payments.outgoing.$outgoingPaymentId' + +export default function OutgoingPaymentDepositLiquidity() { + const { depositLiquidityDisplayAmount } = + useOutletContext()[0] + const navigate = useNavigate() + const dismissDialog = () => navigate('..', { preventScrollReset: true }) + + return ( + + ) +} + +export async function action({ request, params }: ActionArgs) { + const session = await messageStorage.getSession(request.headers.get('cookie')) + const outgoingPaymentId = params.outgoingPaymentId + + if (!outgoingPaymentId) { + return setMessageAndRedirect({ + session, + message: { + content: 'Missing outgoing payment ID', + type: 'error' + }, + location: '.' + }) + } + + const response = await depositOutgoingPaymentLiquidity({ + outgoingPaymentId, + idempotencyKey: v4() + }) + + if (!response?.success) { + return setMessageAndRedirect({ + session, + message: { + content: + response?.message ?? + 'Could not deposit outgoing payment liquidity. Please try again!', + type: 'error' + }, + location: '.' + }) + } + + return setMessageAndRedirect({ + session, + message: { + content: response.message, + type: 'success' + }, + location: '..' + }) +} diff --git a/packages/frontend/app/routes/payments.outgoing.$outgoingPaymentId.tsx b/packages/frontend/app/routes/payments.outgoing.$outgoingPaymentId.tsx new file mode 100644 index 0000000000..0a8d12324b --- /dev/null +++ b/packages/frontend/app/routes/payments.outgoing.$outgoingPaymentId.tsx @@ -0,0 +1,225 @@ +import type { LoaderArgs } from '@remix-run/node' +import { json } from '@remix-run/node' +import { Link, Outlet, useLoaderData } from '@remix-run/react' +import { z } from 'zod' +import { Badge, PageHeader } from '~/components' +import { Button } from '~/components/ui' +import { OutgoingPaymentState } from '~/generated/graphql' +import { getOutgoingPayment } from '~/lib/api/payments.server' +import { + badgeColorByPaymentState, + formatAmount, + prettify +} from '~/shared/utils' + +export type LiquidityActionOutletContext = { + withdrawLiquidityDisplayAmount: string + depositLiquidityDisplayAmount: string +}[] + +export async function loader({ params }: LoaderArgs) { + const outgoingPaymentId = params.outgoingPaymentId + + const result = z.string().uuid().safeParse(outgoingPaymentId) + if (!result.success) { + throw json(null, { + status: 400, + statusText: 'Outgoing payment ID is not valid.' + }) + } + + const outgoingPayment = await getOutgoingPayment({ id: result.data }) + + if (!outgoingPayment) { + throw json(null, { status: 400, statusText: 'Outgoing payment not found.' }) + } + + return json({ outgoingPayment }) +} + +export default function ViewOutgoingPaymentPage() { + const { outgoingPayment } = useLoaderData() + + const withdrawLiquidityDisplayAmount = `${formatAmount( + outgoingPayment.liquidity ?? '0', + outgoingPayment.sentAmount.assetScale + )} ${outgoingPayment.sentAmount.assetCode}` + + const outletContext: LiquidityActionOutletContext = [ + { + withdrawLiquidityDisplayAmount, + depositLiquidityDisplayAmount: `${formatAmount( + outgoingPayment.debitAmount.value, + outgoingPayment.debitAmount.assetScale + )} ${outgoingPayment.debitAmount.assetCode}` + } + ] + + return ( +
+
+ {/* Outgoing Payment General Info */} + + + +
+ {/* Outgoing Payment General Info*/} +
+

General Information

+

+ Created at {new Date(outgoingPayment.createdAt).toLocaleString()}{' '} +

+
+
+
+
+

Outgoing Payment ID

+

{outgoingPayment.id}

+
+
+

Wallet Address ID

+ + {outgoingPayment.walletAddressId} + +
+
+

State

+ + {outgoingPayment.state} + +
+
+

Receiver

+ + {outgoingPayment.receiver} + +
+
+

Receive Amount

+

+ {formatAmount( + outgoingPayment.receiveAmount.value, + outgoingPayment.receiveAmount.assetScale + ) + + ' ' + + outgoingPayment.receiveAmount.assetCode} +

+
+
+

Debit Amount

+

+ {formatAmount( + outgoingPayment.debitAmount.value, + outgoingPayment.debitAmount.assetScale + ) + + ' ' + + outgoingPayment.debitAmount.assetCode} +

+
+
+

Sent Amount

+

+ {formatAmount( + outgoingPayment.sentAmount.value, + outgoingPayment.sentAmount.assetScale + ) + + ' ' + + outgoingPayment.sentAmount.assetCode} +

+
+
+

Error

+ {outgoingPayment.error ? ( +

{outgoingPayment.error}

+ ) : ( + None + )} +
+
+ {outgoingPayment.metadata ? ( +
+ Metadata +
+                  
+ ) : ( +
+

Metadata

+

+ None +

+
+ )} +
+
+
+
+ {/* Outgoing Payment General Info - END */} + + {/* Outgoing Payment Liquidity */} +
+
+

Liquidity Information

+
+
+
+
+

Amount

+

{withdrawLiquidityDisplayAmount}

+
+
+ {BigInt(outgoingPayment.liquidity ?? '0') ? ( + + ) : ( + + )} + {outgoingPayment.state === OutgoingPaymentState.Funding ? ( + + ) : ( + + )} +
+
+
+
+ {/* Outgoing Payment Liquidity - END */} +
+ {/* */} + +
+ ) +} diff --git a/packages/frontend/app/routes/payments.outgoing.$outgoingPaymentId.withdraw-liquidity.tsx b/packages/frontend/app/routes/payments.outgoing.$outgoingPaymentId.withdraw-liquidity.tsx new file mode 100644 index 0000000000..79501d35fe --- /dev/null +++ b/packages/frontend/app/routes/payments.outgoing.$outgoingPaymentId.withdraw-liquidity.tsx @@ -0,0 +1,66 @@ +import { type ActionArgs } from '@remix-run/node' +import { useNavigate, useOutletContext } from '@remix-run/react' +import { v4 } from 'uuid' +import { LiquidityConfirmDialog } from '~/components/LiquidityConfirmDialog' +import { withdrawOutgoingPaymentLiquidity } from '~/lib/api/payments.server' +import { messageStorage, setMessageAndRedirect } from '~/lib/message.server' +import type { LiquidityActionOutletContext } from './payments.outgoing.$outgoingPaymentId' + +export default function OutgoingPaymentWithdrawLiquidity() { + const { withdrawLiquidityDisplayAmount } = + useOutletContext()[0] + const navigate = useNavigate() + const dismissDialog = () => navigate('..', { preventScrollReset: true }) + + return ( + + ) +} + +export async function action({ request, params }: ActionArgs) { + const session = await messageStorage.getSession(request.headers.get('cookie')) + const outgoingPaymentId = params.outgoingPaymentId + + if (!outgoingPaymentId) { + return setMessageAndRedirect({ + session, + message: { + content: 'Missing outgoing payment ID', + type: 'error' + }, + location: '.' + }) + } + + const response = await withdrawOutgoingPaymentLiquidity({ + outgoingPaymentId, + idempotencyKey: v4() + }) + + if (!response?.success) { + return setMessageAndRedirect({ + session, + message: { + content: + response?.message ?? + 'Could not withdraw outgoing payment liquidity. Please try again!', + type: 'error' + }, + location: '.' + }) + } + + return setMessageAndRedirect({ + session, + message: { + content: response.message, + type: 'success' + }, + location: '..' + }) +} diff --git a/packages/frontend/app/routes/peers.$peerId.add-liquidity.tsx b/packages/frontend/app/routes/peers.$peerId.deposit-liquidity.tsx similarity index 85% rename from packages/frontend/app/routes/peers.$peerId.add-liquidity.tsx rename to packages/frontend/app/routes/peers.$peerId.deposit-liquidity.tsx index ceb432f524..f4e71f80d6 100644 --- a/packages/frontend/app/routes/peers.$peerId.add-liquidity.tsx +++ b/packages/frontend/app/routes/peers.$peerId.deposit-liquidity.tsx @@ -2,19 +2,19 @@ import { type ActionArgs } from '@remix-run/node' import { useNavigate } from '@remix-run/react' import { v4 } from 'uuid' import { LiquidityDialog } from '~/components/LiquidityDialog' -import { addPeerLiquidity } from '~/lib/api/peer.server' +import { depositPeerLiquidity } from '~/lib/api/peer.server' import { messageStorage, setMessageAndRedirect } from '~/lib/message.server' import { amountSchema } from '~/lib/validate.server' -export default function PeerAddLiquidity() { +export default function PeerDepositLiquidity() { const navigate = useNavigate() const dismissDialog = () => navigate('..', { preventScrollReset: true }) return ( ) } @@ -48,7 +48,7 @@ export async function action({ request, params }: ActionArgs) { }) } - const response = await addPeerLiquidity({ + const response = await depositPeerLiquidity({ peerId, amount: result.data, id: v4(), @@ -61,7 +61,7 @@ export async function action({ request, params }: ActionArgs) { message: { content: response?.message ?? - 'Could not add peer liquidity. Please try again!', + 'Could not deposit peer liquidity. Please try again!', type: 'error' }, location: '.' diff --git a/packages/frontend/app/routes/peers.$peerId.tsx b/packages/frontend/app/routes/peers.$peerId.tsx index 8c46e0e422..8aca45e2b4 100644 --- a/packages/frontend/app/routes/peers.$peerId.tsx +++ b/packages/frontend/app/routes/peers.$peerId.tsx @@ -40,12 +40,7 @@ export async function loader({ params }: LoaderArgs) { throw json(null, { status: 400, statusText: 'Peer not found.' }) } - return json({ - peer: { - ...peer, - createdAt: new Date(peer.createdAt).toLocaleString() - } - }) + return json({ peer }) } export default function ViewPeerPage() { @@ -95,7 +90,9 @@ export default function ViewPeerPage() { {/* Peer General Info*/}

General Information

-

Created at {peer.createdAt}

+

+ Created at {new Date(peer.createdAt).toLocaleString()} +

@@ -252,12 +249,12 @@ export default function ViewPeerPage() {
+
+
+

Liquidity Information

+
+
+
+
+

Amount

+

{displayLiquidityAmount}

+
+
+ {BigInt(walletAddress.liquidity ?? '0') ? ( + + ) : ( + + )} +
+
+
+
- + ) } diff --git a/packages/frontend/app/routes/wallet-addresses.$walletAddressId.withdraw-liquidity.tsx b/packages/frontend/app/routes/wallet-addresses.$walletAddressId.withdraw-liquidity.tsx new file mode 100644 index 0000000000..476104460d --- /dev/null +++ b/packages/frontend/app/routes/wallet-addresses.$walletAddressId.withdraw-liquidity.tsx @@ -0,0 +1,65 @@ +import { type ActionArgs } from '@remix-run/node' +import { useNavigate, useOutletContext } from '@remix-run/react' +import { v4 } from 'uuid' +import { LiquidityConfirmDialog } from '~/components/LiquidityConfirmDialog' +import { createWalletAddressWithdrawal } from '~/lib/api/wallet-address.server' +import { messageStorage, setMessageAndRedirect } from '~/lib/message.server' + +export default function WalletAddressWithdrawLiquidity() { + const displayLiquidityAmount = useOutletContext() + const navigate = useNavigate() + const dismissDialog = () => navigate('..', { preventScrollReset: true }) + + return ( + + ) +} + +export async function action({ request, params }: ActionArgs) { + const session = await messageStorage.getSession(request.headers.get('cookie')) + const walletAddressId = params.walletAddressId + + if (!walletAddressId) { + return setMessageAndRedirect({ + session, + message: { + content: 'Missing wallet address ID', + type: 'error' + }, + location: '.' + }) + } + + const response = await createWalletAddressWithdrawal({ + id: v4(), + walletAddressId, + idempotencyKey: v4() + }) + + if (!response?.success) { + return setMessageAndRedirect({ + session, + message: { + content: + response?.message ?? + 'Could not withdraw wallet address liquidity. Please try again!', + type: 'error' + }, + location: '.' + }) + } + + return setMessageAndRedirect({ + session, + message: { + content: response.message, + type: 'success' + }, + location: '..' + }) +} diff --git a/packages/frontend/app/routes/wallet-addresses._index.tsx b/packages/frontend/app/routes/wallet-addresses._index.tsx index 4baca21fd8..861893eea5 100644 --- a/packages/frontend/app/routes/wallet-addresses._index.tsx +++ b/packages/frontend/app/routes/wallet-addresses._index.tsx @@ -4,6 +4,7 @@ import { Badge, PageHeader } from '~/components' import { Button, Table } from '~/components/ui' import { listWalletAddresses } from '~/lib/api/wallet-address.server' import { paginationSchema } from '~/lib/validate.server' +import { badgeColorByWalletAddressStatus } from '~/shared/utils' export const loader = async ({ request }: LoaderArgs) => { const url = new URL(request.url) @@ -77,7 +78,11 @@ export default function WalletAddressesPage() { - + + {pp.node.status} + )) diff --git a/packages/frontend/app/routes/webhooks.tsx b/packages/frontend/app/routes/webhooks.tsx index f366d754db..2b9ef09463 100644 --- a/packages/frontend/app/routes/webhooks.tsx +++ b/packages/frontend/app/routes/webhooks.tsx @@ -115,13 +115,16 @@ export default function WebhookEventsPage() { - + {webhooks.edges.length ? ( webhooks.edges.map((webhook) => ( {webhook.node.id} {webhook.node.type} + + {new Date(webhook.node.createdAt).toLocaleString()} +