diff --git a/localenv/cloud-nine-wallet/docker-compose.yml b/localenv/cloud-nine-wallet/docker-compose.yml index e0a912c175..d5c4913fe3 100644 --- a/localenv/cloud-nine-wallet/docker-compose.yml +++ b/localenv/cloud-nine-wallet/docker-compose.yml @@ -115,6 +115,7 @@ services: OPERATOR_TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787 CARD_SERVICE_URL: 'http://cloud-nine-wallet-card-service:3007' CARD_WEBHOOK_SERVICE_URL: 'http://cloud-nine-wallet-card-service:3007/webhook' + DB_ENCRYPTION_SECRET: 'zO9KogehJECHReHgQr+ZWGkmgOD4AYa4ksUxALSwgM8=' depends_on: shared-database: condition: service_healthy diff --git a/localenv/docs/encrypted-data-exchange.md b/localenv/docs/encrypted-data-exchange.md new file mode 100644 index 0000000000..a7c7a3d1dc --- /dev/null +++ b/localenv/docs/encrypted-data-exchange.md @@ -0,0 +1,24 @@ +# Run with Encrypted Data Exchange + +Start the env with the feature flag enabled on the receiver: + +```bash +pnpm localenv:compose:partial-payment up +``` + +# Manually Test the Encrypted Data Exchange Flow (Happy Path) + +## Steps + +- Run the **Examples > Admin API > Open Payments** or the **Examples > Open Payments > Peer-to-Peer Payment** requests in Bruno. + +## Verification + +In the `happy-life-bank-mock-ase` logs: + +- `incoming_payment.partial_payment_received` webhook was received, with `partialIncomingPaymentId` and the `dataFromSender` defined in the payload +- `confirmPartialIncomingPayment` was called + +In Bruno: + +- Confirm the Outgoing Payment is completed by calling the `Get Outgoing Payment` request, and verifying a positive `sentAmount`. diff --git a/localenv/happy-life-bank/docker-compose.yml b/localenv/happy-life-bank/docker-compose.yml index d5fb4eddfd..c02ee49250 100644 --- a/localenv/happy-life-bank/docker-compose.yml +++ b/localenv/happy-life-bank/docker-compose.yml @@ -52,6 +52,7 @@ services: SIGNATURE_VERSION: 1 SIGNATURE_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= IDP_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= + DB_ENCRYPTION_SECRET: 'zO9KogehJECHReHgQr+ZWGkmgOD4AYa4ksUxALSwgM8=' DISPLAY_NAME: Happy Life Bank DISPLAY_ICON: bank-icon.svg OPERATOR_TENANT_ID: cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d @@ -114,6 +115,7 @@ services: OPERATOR_TENANT_ID: cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d CARD_SERVICE_URL: 'http://happy-life-bank-card-service:4007' POS_WEBHOOK_SERVICE_URL: 'http://happy-life-bank-point-of-sale:4008/webhook' + DB_ENCRYPTION_SECRET: 'zO9KogehJECHReHgQr+ZWGkmgOD4AYa4ksUxALSwgM8=' WALLET_ADDRESS_NOT_FOUND_POLLING_ENABLED: true depends_on: - cloud-nine-backend 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 96f99ac5d5..bdd76e81bb 100644 --- a/localenv/mock-account-servicing-entity/app/lib/webhooks.server.ts +++ b/localenv/mock-account-servicing-entity/app/lib/webhooks.server.ts @@ -118,7 +118,8 @@ export async function handleOutgoingPaymentCreated( variables: { input: { outgoingPaymentId: payment.id, - idempotencyKey: uuid() + idempotencyKey: uuid(), + dataToTransmit: 'sample kyc data' } } }) @@ -186,6 +187,62 @@ export async function handleIncomingPaymentCompletedExpired( return } +export async function handleIncomingPartialPaymentReceived( + wh: Webhook, + options?: TenantOptions +) { + if (wh.type !== WebhookEventType.IncomingPaymentPartialPaymentReceived) { + throw new Error( + 'Invalid event type when handling incoming partial payment webhook' + ) + } + + const incomingPaymentId = wh.data['id'] as string | undefined + if (!incomingPaymentId) { + throw new Error('No incomingPaymentId found on webhook data') + } + + const partialIncomingPaymentId = wh.data['partialIncomingPaymentId'] as + | string + | undefined + if (!partialIncomingPaymentId) { + throw new Error('No partialIncomingPaymentId found on webhook data') + } + + const dataFromSender = wh.data['dataFromSender'] as string | undefined + if (!dataFromSender) { + throw new Error('No dataFromSender found on webhook data') + } + + await generateApolloClient(options) + .mutate({ + mutation: gql` + mutation ConfirmPartialIncomingPayment( + $input: ConfirmPartialIncomingPaymentInput! + ) { + confirmPartialIncomingPayment(input: $input) { + success + } + } + `, + variables: { + input: { + incomingPaymentId, + partialIncomingPaymentId + } + } + }) + .then((query): LiquidityMutationResponse => { + if (query.data) { + return query.data.confirmPartialIncomingPayment + } else { + throw new Error('Data was empty') + } + }) + + return +} + export async function handleWalletAddressWebMonetization( wh: Webhook, options?: TenantOptions diff --git a/localenv/mock-account-servicing-entity/app/routes/webhooks.ts b/localenv/mock-account-servicing-entity/app/routes/webhooks.ts index 2d180867d7..e05c4140e1 100644 --- a/localenv/mock-account-servicing-entity/app/routes/webhooks.ts +++ b/localenv/mock-account-servicing-entity/app/routes/webhooks.ts @@ -3,12 +3,11 @@ import { json } from '@remix-run/node' import { handleLowLiquidity, handleWalletAddressNotFound, - handleWalletAddressWebMonetization -} from '~/lib/webhooks.server' -import { + handleWalletAddressWebMonetization, handleOutgoingPaymentCreated, handleOutgoingPaymentCompletedFailed, - handleIncomingPaymentCompletedExpired + handleIncomingPaymentCompletedExpired, + handleIncomingPartialPaymentReceived } from '~/lib/webhooks.server' import { WebhookEventType, Webhook } from 'mock-account-service-lib' import { getTenantCredentials } from '~/lib/utils' @@ -36,6 +35,9 @@ export async function action({ request }: ActionFunctionArgs) { break case WebhookEventType.IncomingPaymentCreated: break + case WebhookEventType.IncomingPaymentPartialPaymentReceived: + await handleIncomingPartialPaymentReceived(wh, tenantOptions) + break case WebhookEventType.IncomingPaymentCompleted: case WebhookEventType.IncomingPaymentExpired: await handleIncomingPaymentCompletedExpired(wh, tenantOptions) diff --git a/localenv/mock-account-servicing-entity/generated/graphql.ts b/localenv/mock-account-servicing-entity/generated/graphql.ts index 1bf67d8422..79646ee839 100644 --- a/localenv/mock-account-servicing-entity/generated/graphql.ts +++ b/localenv/mock-account-servicing-entity/generated/graphql.ts @@ -216,6 +216,16 @@ export type CompleteReceiverResponse = { receiver?: Maybe; }; +export type ConfirmPartialIncomingPaymentInput = { + incomingPaymentId: Scalars['ID']['input']; + partialIncomingPaymentId: Scalars['ID']['input']; +}; + +export type ConfirmPartialIncomingPaymentResponse = { + __typename?: 'ConfirmPartialIncomingPaymentResponse'; + success: Scalars['Boolean']['output']; +}; + export type CreateAssetInput = { /** Should be an ISO 4217 currency code whenever possible, e.g. `USD`. For more information, refer to [assets](https://rafiki.dev/overview/concepts/accounting/#assets). */ code: Scalars['String']['input']; @@ -530,6 +540,8 @@ export type DepositEventLiquidityInput = { }; export type DepositOutgoingPaymentLiquidityInput = { + /** Data to be encrypted and sent to the receiver. */ + dataToTransmit?: InputMaybe; /** Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency). */ idempotencyKey: Scalars['String']['input']; /** Unique identifier of the outgoing payment to deposit liquidity into. */ @@ -785,6 +797,8 @@ export type Mutation = { cancelOutgoingPayment: OutgoingPaymentResponse; /** Complete an internal or external Open Payments incoming payment. The receiver has a wallet address on either this or another Open Payments resource server. */ completeReceiver: CompleteReceiverResponse; + /** Confirms a partial incoming payment. */ + confirmPartialIncomingPayment: ConfirmPartialIncomingPaymentResponse; /** Create a new asset. */ createAsset: AssetMutationResponse; /** Withdraw asset liquidity. */ @@ -837,6 +851,8 @@ export type Mutation = { depositPeerLiquidity?: Maybe; /** Post liquidity withdrawal. Withdrawals are two-phase commits and are committed via this mutation. */ postLiquidityWithdrawal?: Maybe; + /** Rejects a partial incoming payment. */ + rejectPartialIncomingPayment: RejectPartialIncomingPaymentResponse; /** Revoke a public key associated with a wallet address. Open Payment requests using this key for request signatures will be denied going forward. */ revokeWalletAddressKey?: Maybe; /** Set the fee structure on an asset. */ @@ -883,6 +899,11 @@ export type MutationCompleteReceiverArgs = { }; +export type MutationConfirmPartialIncomingPaymentArgs = { + input: ConfirmPartialIncomingPaymentInput; +}; + + export type MutationCreateAssetArgs = { input: CreateAssetInput; }; @@ -1008,6 +1029,11 @@ export type MutationPostLiquidityWithdrawalArgs = { }; +export type MutationRejectPartialIncomingPaymentArgs = { + input: RejectPartialIncomingPaymentInput; +}; + + export type MutationRevokeWalletAddressKeyArgs = { input: RevokeWalletAddressKeyInput; }; @@ -1506,6 +1532,18 @@ export type Receiver = { walletAddressUrl: Scalars['String']['output']; }; +export type RejectPartialIncomingPaymentInput = { + incomingPaymentId: Scalars['ID']['input']; + partialIncomingPaymentId: Scalars['ID']['input']; + /** Reason why this incoming payment has been canceled. This value will be sent to the sender. */ + reason?: InputMaybe; +}; + +export type RejectPartialIncomingPaymentResponse = { + __typename?: 'RejectPartialIncomingPaymentResponse'; + success: Scalars['Boolean']['output']; +}; + export type RevokeWalletAddressKeyInput = { /** Internal unique identifier of the key to revoke. */ id: Scalars['String']['input']; @@ -2007,6 +2045,8 @@ export type ResolversTypes = { CardPaymentFailureReason: ResolverTypeWrapper>; CompleteReceiverInput: ResolverTypeWrapper>; CompleteReceiverResponse: ResolverTypeWrapper>; + ConfirmPartialIncomingPaymentInput: ResolverTypeWrapper>; + ConfirmPartialIncomingPaymentResponse: ResolverTypeWrapper>; CreateAssetInput: ResolverTypeWrapper>; CreateAssetLiquidityWithdrawalInput: ResolverTypeWrapper>; CreateIncomingPaymentInput: ResolverTypeWrapper>; @@ -2090,6 +2130,8 @@ export type ResolversTypes = { QuoteEdge: ResolverTypeWrapper>; QuoteResponse: ResolverTypeWrapper>; Receiver: ResolverTypeWrapper>; + RejectPartialIncomingPaymentInput: ResolverTypeWrapper>; + RejectPartialIncomingPaymentResponse: ResolverTypeWrapper>; RevokeWalletAddressKeyInput: ResolverTypeWrapper>; RevokeWalletAddressKeyMutationResponse: ResolverTypeWrapper>; SetFeeInput: ResolverTypeWrapper>; @@ -2156,6 +2198,8 @@ export type ResolversParentTypes = { CardDetailsInput: Partial; CompleteReceiverInput: Partial; CompleteReceiverResponse: Partial; + ConfirmPartialIncomingPaymentInput: Partial; + ConfirmPartialIncomingPaymentResponse: Partial; CreateAssetInput: Partial; CreateAssetLiquidityWithdrawalInput: Partial; CreateIncomingPaymentInput: Partial; @@ -2232,6 +2276,8 @@ export type ResolversParentTypes = { QuoteEdge: Partial; QuoteResponse: Partial; Receiver: Partial; + RejectPartialIncomingPaymentInput: Partial; + RejectPartialIncomingPaymentResponse: Partial; RevokeWalletAddressKeyInput: Partial; RevokeWalletAddressKeyMutationResponse: Partial; SetFeeInput: Partial; @@ -2360,6 +2406,11 @@ export type CompleteReceiverResponseResolvers; }; +export type ConfirmPartialIncomingPaymentResponseResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type CreateOrUpdatePeerByUrlMutationResponseResolvers = { peer?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -2501,6 +2552,7 @@ export type MutationResolvers>; cancelOutgoingPayment?: Resolver>; completeReceiver?: Resolver>; + confirmPartialIncomingPayment?: Resolver>; createAsset?: Resolver>; createAssetLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; createIncomingPayment?: Resolver>; @@ -2526,6 +2578,7 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; depositPeerLiquidity?: Resolver, ParentType, ContextType, RequireFields>; postLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; + rejectPartialIncomingPayment?: Resolver>; revokeWalletAddressKey?: Resolver, ParentType, ContextType, RequireFields>; setFee?: Resolver>; triggerWalletAddressEvents?: Resolver>; @@ -2701,6 +2754,11 @@ export type ReceiverResolvers; }; +export type RejectPartialIncomingPaymentResponseResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type RevokeWalletAddressKeyMutationResponseResolvers = { walletAddressKey?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -2872,6 +2930,7 @@ export type Resolvers = { BasePayment?: BasePaymentResolvers; CancelIncomingPaymentResponse?: CancelIncomingPaymentResponseResolvers; CompleteReceiverResponse?: CompleteReceiverResponseResolvers; + ConfirmPartialIncomingPaymentResponse?: ConfirmPartialIncomingPaymentResponseResolvers; CreateOrUpdatePeerByUrlMutationResponse?: CreateOrUpdatePeerByUrlMutationResponseResolvers; CreatePeerMutationResponse?: CreatePeerMutationResponseResolvers; CreateReceiverResponse?: CreateReceiverResponseResolvers; @@ -2912,6 +2971,7 @@ export type Resolvers = { QuoteEdge?: QuoteEdgeResolvers; QuoteResponse?: QuoteResponseResolvers; Receiver?: ReceiverResolvers; + RejectPartialIncomingPaymentResponse?: RejectPartialIncomingPaymentResponseResolvers; RevokeWalletAddressKeyMutationResponse?: RevokeWalletAddressKeyMutationResponseResolvers; SetFeeResponse?: SetFeeResponseResolvers; Tenant?: TenantResolvers; diff --git a/localenv/partial-payment/docker-compose.yml b/localenv/partial-payment/docker-compose.yml new file mode 100644 index 0000000000..3f7482650c --- /dev/null +++ b/localenv/partial-payment/docker-compose.yml @@ -0,0 +1,5 @@ +services: + happy-life-backend: + environment: + ENABLE_PARTIAL_PAYMENT_DECISION: 'true' + PARTIAL_PAYMENT_DECISION_MAX_WAIT_MS: '30000' diff --git a/package.json b/package.json index 0efa634a55..f325c7705f 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "build": "tsc --build", "localenv:compose:psql": "docker compose -f ./localenv/cloud-nine-wallet/docker-compose.yml -f ./localenv/global-bank/docker-compose.yml -f ./localenv/happy-life-bank/docker-compose.yml -f ./localenv/merged/docker-compose.yml", "localenv:compose": "docker compose -f ./localenv/cloud-nine-wallet/docker-compose.yml -f ./localenv/happy-life-bank/docker-compose.yml -f ./localenv/merged/docker-compose.yml -f ./localenv/tigerbeetle/docker-compose.yml --env-file ./localenv/tigerbeetle/.env.tigerbeetle", + "localenv:compose:partial-payment": "docker compose -f ./localenv/cloud-nine-wallet/docker-compose.yml -f ./localenv/happy-life-bank/docker-compose.yml -f ./localenv/merged/docker-compose.yml -f ./localenv/tigerbeetle/docker-compose.yml -f ./localenv/partial-payment/docker-compose.yml --env-file ./localenv/tigerbeetle/.env.tigerbeetle", "localenv:compose:multihop": "docker compose -f ./localenv/cloud-nine-wallet/docker-compose.yml -f ./localenv/global-bank/docker-compose.yml -f ./localenv/happy-life-bank/docker-compose.yml -f ./localenv/multihop/docker-compose.yml -f ./localenv/merged/docker-compose.yml -f ./localenv/tigerbeetle/docker-compose.yml --env-file ./localenv/tigerbeetle/.env.tigerbeetle", "localenv:compose:multitenancy": "docker compose -f ./localenv/cloud-ten-wallet/docker-compose.yml -f ./localenv/cloud-nine-wallet/docker-compose.yml -f ./localenv/happy-life-bank/docker-compose.yml -f ./localenv/merged/docker-compose.yml -f ./localenv/tigerbeetle/docker-compose.yml --env-file ./localenv/tigerbeetle/.env.tigerbeetle", "localenv:compose:psql:telemetry": "docker compose -f ./localenv/cloud-nine-wallet/docker-compose.yml -f ./localenv/global-bank/docker-compose.yml -f ./localenv/happy-life-bank/docker-compose.yml -f ./localenv/merged/docker-compose.yml -f ./localenv/telemetry/docker-compose.yml", diff --git a/packages/backend/migrations/20251126191852_add_sender_data_to_outgoing_payment.js b/packages/backend/migrations/20251126191852_add_sender_data_to_outgoing_payment.js new file mode 100644 index 0000000000..f4c7a22b5f --- /dev/null +++ b/packages/backend/migrations/20251126191852_add_sender_data_to_outgoing_payment.js @@ -0,0 +1,31 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function (knex) { + const hasColumn = await knex.schema.hasColumn( + 'outgoingPayments', + 'dataToTransmit' + ) + if (!hasColumn) { + await knex.schema.alterTable('outgoingPayments', function (table) { + table.string('dataToTransmit').nullable() + }) + } +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function (knex) { + const hasColumn = await knex.schema.hasColumn( + 'outgoingPayments', + 'dataToTransmit' + ) + if (hasColumn) { + await knex.schema.alterTable('outgoingPayments', function (table) { + table.dropColumn('dataToTransmit') + }) + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 08bd776351..f92a38b3cd 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -32,7 +32,7 @@ "@types/tmp": "^0.2.6", "@types/uuid": "^9.0.8", "cross-fetch": "^4.1.0", - "ilp-protocol-stream": "^2.7.2-alpha.2", + "ilp-protocol-stream": "2.7.2-alpha.3", "jest-environment-node": "^29.7.0", "jest-openapi": "^0.14.2", "nock": "14.0.0-beta.19", @@ -57,8 +57,8 @@ "@interledger/http-signature-utils": "2.0.2", "@interledger/open-payments": "7.4.0", "@interledger/openapi": "2.0.2", - "@interledger/pay": "0.4.0-alpha.9", - "@interledger/stream-receiver": "^0.3.3-alpha.3", + "@interledger/pay": "0.4.0-alpha.12", + "@interledger/stream-receiver": "0.3.3-alpha.4", "@koa/cors": "^5.0.0", "@koa/router": "^12.0.2", "@opentelemetry/api": "^1.8.0", diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index c7f8cddd17..6a4b6e5218 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -210,6 +210,20 @@ export const Config = { 'SEND_TENANT_WEBHOOKS_TO_OPERATOR', false ), + /** Optional base64-encoded key for encrypting partial-payment payload fields in webhooks. */ + dbEncryptionSecret: process.env.DB_ENCRYPTION_SECRET, + enablePartialPaymentDecision: envBool( + 'ENABLE_PARTIAL_PAYMENT_DECISION', + false + ), + partialPaymentDecisionMaxWaitMs: envInt( + 'PARTIAL_PAYMENT_DECISION_MAX_WAIT_MS', + 1500 + ), + partialPaymentDecisionSafetyMarginMs: envInt( + 'PARTIAL_PAYMENT_DECISION_SAFETY_MARGIN_MS', + 100 + ), cardServiceUrl: optional(envString, 'CARD_SERVICE_URL'), posServiceUrl: optional(envString, 'POS_SERVICE_URL'), posWebhookServiceUrl: optional(envString, 'POS_WEBHOOK_SERVICE_URL'), diff --git a/packages/backend/src/graphql/generated/graphql.schema.json b/packages/backend/src/graphql/generated/graphql.schema.json index 964c0eae82..f09c8661cf 100644 --- a/packages/backend/src/graphql/generated/graphql.schema.json +++ b/packages/backend/src/graphql/generated/graphql.schema.json @@ -1236,6 +1236,78 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "ConfirmPartialIncomingPaymentInput", + "description": null, + "isOneOf": false, + "fields": null, + "inputFields": [ + { + "name": "incomingPaymentId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "partialIncomingPaymentId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ConfirmPartialIncomingPaymentResponse", + "description": null, + "isOneOf": null, + "fields": [ + { + "name": "success", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "CreateAssetInput", @@ -3129,6 +3201,18 @@ "isOneOf": false, "fields": null, "inputFields": [ + { + "name": "dataToTransmit", + "description": "Data to be encrypted and sent to the receiver.", + "type": { + "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. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency).", @@ -4753,6 +4837,39 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "confirmPartialIncomingPayment", + "description": "Confirms a partial incoming payment.", + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ConfirmPartialIncomingPaymentInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ConfirmPartialIncomingPaymentResponse", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "createAsset", "description": "Create a new asset.", @@ -5530,6 +5647,39 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "rejectPartialIncomingPayment", + "description": "Rejects a partial incoming payment.", + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "RejectPartialIncomingPaymentInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "RejectPartialIncomingPaymentResponse", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "revokeWalletAddressKey", "description": "Revoke a public key associated with a wallet address. Open Payment requests using this key for request signatures will be denied going forward.", @@ -8588,6 +8738,90 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "RejectPartialIncomingPaymentInput", + "description": null, + "isOneOf": false, + "fields": null, + "inputFields": [ + { + "name": "incomingPaymentId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "partialIncomingPaymentId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "reason", + "description": "Reason why this incoming payment has been canceled. This value will be sent to the sender.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "RejectPartialIncomingPaymentResponse", + "description": null, + "isOneOf": null, + "fields": [ + { + "name": "success", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "RevokeWalletAddressKeyInput", diff --git a/packages/backend/src/graphql/generated/graphql.ts b/packages/backend/src/graphql/generated/graphql.ts index 1bf67d8422..79646ee839 100644 --- a/packages/backend/src/graphql/generated/graphql.ts +++ b/packages/backend/src/graphql/generated/graphql.ts @@ -216,6 +216,16 @@ export type CompleteReceiverResponse = { receiver?: Maybe; }; +export type ConfirmPartialIncomingPaymentInput = { + incomingPaymentId: Scalars['ID']['input']; + partialIncomingPaymentId: Scalars['ID']['input']; +}; + +export type ConfirmPartialIncomingPaymentResponse = { + __typename?: 'ConfirmPartialIncomingPaymentResponse'; + success: Scalars['Boolean']['output']; +}; + export type CreateAssetInput = { /** Should be an ISO 4217 currency code whenever possible, e.g. `USD`. For more information, refer to [assets](https://rafiki.dev/overview/concepts/accounting/#assets). */ code: Scalars['String']['input']; @@ -530,6 +540,8 @@ export type DepositEventLiquidityInput = { }; export type DepositOutgoingPaymentLiquidityInput = { + /** Data to be encrypted and sent to the receiver. */ + dataToTransmit?: InputMaybe; /** Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency). */ idempotencyKey: Scalars['String']['input']; /** Unique identifier of the outgoing payment to deposit liquidity into. */ @@ -785,6 +797,8 @@ export type Mutation = { cancelOutgoingPayment: OutgoingPaymentResponse; /** Complete an internal or external Open Payments incoming payment. The receiver has a wallet address on either this or another Open Payments resource server. */ completeReceiver: CompleteReceiverResponse; + /** Confirms a partial incoming payment. */ + confirmPartialIncomingPayment: ConfirmPartialIncomingPaymentResponse; /** Create a new asset. */ createAsset: AssetMutationResponse; /** Withdraw asset liquidity. */ @@ -837,6 +851,8 @@ export type Mutation = { depositPeerLiquidity?: Maybe; /** Post liquidity withdrawal. Withdrawals are two-phase commits and are committed via this mutation. */ postLiquidityWithdrawal?: Maybe; + /** Rejects a partial incoming payment. */ + rejectPartialIncomingPayment: RejectPartialIncomingPaymentResponse; /** Revoke a public key associated with a wallet address. Open Payment requests using this key for request signatures will be denied going forward. */ revokeWalletAddressKey?: Maybe; /** Set the fee structure on an asset. */ @@ -883,6 +899,11 @@ export type MutationCompleteReceiverArgs = { }; +export type MutationConfirmPartialIncomingPaymentArgs = { + input: ConfirmPartialIncomingPaymentInput; +}; + + export type MutationCreateAssetArgs = { input: CreateAssetInput; }; @@ -1008,6 +1029,11 @@ export type MutationPostLiquidityWithdrawalArgs = { }; +export type MutationRejectPartialIncomingPaymentArgs = { + input: RejectPartialIncomingPaymentInput; +}; + + export type MutationRevokeWalletAddressKeyArgs = { input: RevokeWalletAddressKeyInput; }; @@ -1506,6 +1532,18 @@ export type Receiver = { walletAddressUrl: Scalars['String']['output']; }; +export type RejectPartialIncomingPaymentInput = { + incomingPaymentId: Scalars['ID']['input']; + partialIncomingPaymentId: Scalars['ID']['input']; + /** Reason why this incoming payment has been canceled. This value will be sent to the sender. */ + reason?: InputMaybe; +}; + +export type RejectPartialIncomingPaymentResponse = { + __typename?: 'RejectPartialIncomingPaymentResponse'; + success: Scalars['Boolean']['output']; +}; + export type RevokeWalletAddressKeyInput = { /** Internal unique identifier of the key to revoke. */ id: Scalars['String']['input']; @@ -2007,6 +2045,8 @@ export type ResolversTypes = { CardPaymentFailureReason: ResolverTypeWrapper>; CompleteReceiverInput: ResolverTypeWrapper>; CompleteReceiverResponse: ResolverTypeWrapper>; + ConfirmPartialIncomingPaymentInput: ResolverTypeWrapper>; + ConfirmPartialIncomingPaymentResponse: ResolverTypeWrapper>; CreateAssetInput: ResolverTypeWrapper>; CreateAssetLiquidityWithdrawalInput: ResolverTypeWrapper>; CreateIncomingPaymentInput: ResolverTypeWrapper>; @@ -2090,6 +2130,8 @@ export type ResolversTypes = { QuoteEdge: ResolverTypeWrapper>; QuoteResponse: ResolverTypeWrapper>; Receiver: ResolverTypeWrapper>; + RejectPartialIncomingPaymentInput: ResolverTypeWrapper>; + RejectPartialIncomingPaymentResponse: ResolverTypeWrapper>; RevokeWalletAddressKeyInput: ResolverTypeWrapper>; RevokeWalletAddressKeyMutationResponse: ResolverTypeWrapper>; SetFeeInput: ResolverTypeWrapper>; @@ -2156,6 +2198,8 @@ export type ResolversParentTypes = { CardDetailsInput: Partial; CompleteReceiverInput: Partial; CompleteReceiverResponse: Partial; + ConfirmPartialIncomingPaymentInput: Partial; + ConfirmPartialIncomingPaymentResponse: Partial; CreateAssetInput: Partial; CreateAssetLiquidityWithdrawalInput: Partial; CreateIncomingPaymentInput: Partial; @@ -2232,6 +2276,8 @@ export type ResolversParentTypes = { QuoteEdge: Partial; QuoteResponse: Partial; Receiver: Partial; + RejectPartialIncomingPaymentInput: Partial; + RejectPartialIncomingPaymentResponse: Partial; RevokeWalletAddressKeyInput: Partial; RevokeWalletAddressKeyMutationResponse: Partial; SetFeeInput: Partial; @@ -2360,6 +2406,11 @@ export type CompleteReceiverResponseResolvers; }; +export type ConfirmPartialIncomingPaymentResponseResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type CreateOrUpdatePeerByUrlMutationResponseResolvers = { peer?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -2501,6 +2552,7 @@ export type MutationResolvers>; cancelOutgoingPayment?: Resolver>; completeReceiver?: Resolver>; + confirmPartialIncomingPayment?: Resolver>; createAsset?: Resolver>; createAssetLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; createIncomingPayment?: Resolver>; @@ -2526,6 +2578,7 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; depositPeerLiquidity?: Resolver, ParentType, ContextType, RequireFields>; postLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; + rejectPartialIncomingPayment?: Resolver>; revokeWalletAddressKey?: Resolver, ParentType, ContextType, RequireFields>; setFee?: Resolver>; triggerWalletAddressEvents?: Resolver>; @@ -2701,6 +2754,11 @@ export type ReceiverResolvers; }; +export type RejectPartialIncomingPaymentResponseResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type RevokeWalletAddressKeyMutationResponseResolvers = { walletAddressKey?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -2872,6 +2930,7 @@ export type Resolvers = { BasePayment?: BasePaymentResolvers; CancelIncomingPaymentResponse?: CancelIncomingPaymentResponseResolvers; CompleteReceiverResponse?: CompleteReceiverResponseResolvers; + ConfirmPartialIncomingPaymentResponse?: ConfirmPartialIncomingPaymentResponseResolvers; CreateOrUpdatePeerByUrlMutationResponse?: CreateOrUpdatePeerByUrlMutationResponseResolvers; CreatePeerMutationResponse?: CreatePeerMutationResponseResolvers; CreateReceiverResponse?: CreateReceiverResponseResolvers; @@ -2912,6 +2971,7 @@ export type Resolvers = { QuoteEdge?: QuoteEdgeResolvers; QuoteResponse?: QuoteResponseResolvers; Receiver?: ReceiverResolvers; + RejectPartialIncomingPaymentResponse?: RejectPartialIncomingPaymentResponseResolvers; RevokeWalletAddressKeyMutationResponse?: RevokeWalletAddressKeyMutationResponseResolvers; SetFeeResponse?: SetFeeResponseResolvers; Tenant?: TenantResolvers; diff --git a/packages/backend/src/graphql/resolvers/incoming_payment.test.ts b/packages/backend/src/graphql/resolvers/incoming_payment.test.ts index 8b1c79d095..ac19b0c327 100644 --- a/packages/backend/src/graphql/resolvers/incoming_payment.test.ts +++ b/packages/backend/src/graphql/resolvers/incoming_payment.test.ts @@ -31,7 +31,9 @@ import { IncomingPayment, IncomingPaymentConnection, IncomingPaymentResponse, - IncomingPaymentState as SchemaPaymentState + IncomingPaymentState as SchemaPaymentState, + ConfirmPartialIncomingPaymentResponse, + RejectPartialIncomingPaymentResponse } from '../generated/graphql' import { IncomingPaymentError, @@ -42,6 +44,8 @@ import { Amount, serializeAmount } from '../../open_payments/amount' import { GraphQLErrorCode } from '../errors' import { createTenant } from '../../tests/tenant' import { faker } from '@faker-js/faker' +import { WalletAddress } from '../../open_payments/wallet_address/model' +import Redis from 'ioredis' describe('Incoming Payment Resolver', (): void => { let deps: IocContract @@ -62,6 +66,10 @@ describe('Incoming Payment Resolver', (): void => { tenantId = Config.operatorTenantId }) + afterEach(() => { + jest.restoreAllMocks() + }) + afterAll(async (): Promise => { await truncateTables(deps) await appContainer.apolloClient.stop() @@ -1058,4 +1066,259 @@ describe('Incoming Payment Resolver', (): void => { }) }) }) + + describe('Mutation.(confirm/reject)PartialIncomingPayment', () => { + const PARTIAL_PAYMENT_DECISION_PREFIX = 'partial_payment_decision' + const confirmPartialIncomingPaymentMutation = gql` + mutation ConfirmPartialIncomingPayment( + $input: ConfirmPartialIncomingPaymentInput! + ) { + confirmPartialIncomingPayment(input: $input) { + success + } + } + ` + const rejectPartialIncomingPaymentMutation = gql` + mutation RejectPartialIncomingPayment( + $input: RejectPartialIncomingPaymentInput! + ) { + rejectPartialIncomingPayment(input: $input) { + success + } + } + ` + let redis: Redis + let walletAddress: WalletAddress + + beforeEach(async (): Promise => { + redis = await deps.use('redis') + walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) + }) + + afterEach((): void => { + jest.restoreAllMocks() + }) + + test.each` + action | mutation + ${'confirm'} | ${confirmPartialIncomingPaymentMutation} + ${'reject'} | ${rejectPartialIncomingPaymentMutation} + `( + 'can $action a partial incoming payment', + async ({ action, mutation }): Promise => { + const incomingPayment = await createIncomingPayment(deps, { + walletAddressId: walletAddress.id, + tenantId: walletAddress.tenantId, + initiationReason: IncomingPaymentInitiationReason.Admin + }) + + const partialIncomingPaymentId = uuid() + + const result = await appContainer.apolloClient + .mutate({ + mutation, + variables: { + input: { + incomingPaymentId: incomingPayment.id, + partialIncomingPaymentId + } + } + }) + .then( + ( + mutation + ): + | ConfirmPartialIncomingPaymentResponse + | RejectPartialIncomingPaymentResponse => + action === 'confirm' + ? mutation.data?.confirmPartialIncomingPayment + : mutation.data?.rejectPartialIncomingPayment + ) + + expect(result.success).toBe(true) + } + ) + + test('uses rejection reason when rejecting partial incoming payment', async (): Promise => { + const incomingPayment = await createIncomingPayment(deps, { + walletAddressId: walletAddress.id, + tenantId: walletAddress.tenantId, + initiationReason: IncomingPaymentInitiationReason.Admin + }) + const partialIncomingPaymentId = uuid() + const reason = 'Incorrect data provided' + const redisSpy = jest.spyOn(redis, 'set') + + const result = await appContainer.apolloClient + .mutate({ + mutation: rejectPartialIncomingPaymentMutation, + variables: { + input: { + incomingPaymentId: incomingPayment.id, + partialIncomingPaymentId, + reason + } + } + }) + .then( + (mutation): RejectPartialIncomingPaymentResponse => + mutation.data?.rejectPartialIncomingPayment + ) + + expect(result.success).toBe(true) + expect(redisSpy).toHaveBeenCalledWith( + `${PARTIAL_PAYMENT_DECISION_PREFIX}:${incomingPayment.id}:${partialIncomingPaymentId}`, + JSON.stringify({ success: false, reason }) + ) + }) + + test.each` + action | mutation + ${'confirm'} | ${confirmPartialIncomingPaymentMutation} + ${'reject'} | ${rejectPartialIncomingPaymentMutation} + `( + 'cannot $action partial incoming payment that does not belong to tenant', + async ({ mutation }): Promise => { + const incomingPayment = await createIncomingPayment(deps, { + walletAddressId: walletAddress.id, + tenantId: walletAddress.tenantId, + initiationReason: IncomingPaymentInitiationReason.Admin + }) + const partialIncomingPaymentId = uuid() + + const otherTenant = await createTenant(deps) + const tenantApolloClient = await createApolloClient( + appContainer.container, + appContainer.app, + otherTenant.id + ) + + const incomingPaymentServiceSpy = jest.spyOn( + incomingPaymentService, + 'updatePartialPaymentDecision' + ) + expect.assertions(3) + try { + await tenantApolloClient.mutate({ + mutation, + variables: { + input: { + incomingPaymentId: incomingPayment.id, + partialIncomingPaymentId + } + } + }) + } catch (error) { + expect(incomingPaymentServiceSpy).not.toHaveBeenCalled() + expect(error).toBeInstanceOf(ApolloError) + expect((error as ApolloError).graphQLErrors).toContainEqual( + expect.objectContaining({ + message: errorToMessage[IncomingPaymentError.UnknownPayment], + extensions: expect.objectContaining({ + code: GraphQLErrorCode.NotFound + }) + }) + ) + } + } + ) + + test.each` + action | mutation | state + ${'confirm'} | ${confirmPartialIncomingPaymentMutation} | ${IncomingPaymentState.Completed} + ${'reject'} | ${rejectPartialIncomingPaymentMutation} | ${IncomingPaymentState.Completed} + ${'confirm'} | ${confirmPartialIncomingPaymentMutation} | ${IncomingPaymentState.Expired} + ${'reject'} | ${rejectPartialIncomingPaymentMutation} | ${IncomingPaymentState.Expired} + `( + 'cannot $action partial payment for incoming payment that is $state', + async ({ mutation, state }): Promise => { + const incomingPayment = await createIncomingPayment(deps, { + walletAddressId: walletAddress.id, + tenantId: walletAddress.tenantId, + initiationReason: IncomingPaymentInitiationReason.Admin + }) + const knex = await deps.use('knex') + await IncomingPaymentModel.query(knex).patchAndFetchById( + incomingPayment.id, + { state } + ) + + const incomingPaymentServiceSpy = jest.spyOn( + incomingPaymentService, + 'updatePartialPaymentDecision' + ) + expect.assertions(3) + try { + await appContainer.apolloClient.mutate({ + mutation, + variables: { + input: { + incomingPaymentId: incomingPayment.id, + partialIncomingPaymentId: uuid() + } + } + }) + } catch (error) { + expect(incomingPaymentServiceSpy).not.toHaveBeenCalled() + expect(error).toBeInstanceOf(ApolloError) + expect((error as ApolloError).graphQLErrors).toContainEqual( + expect.objectContaining({ + message: errorToMessage[IncomingPaymentError.InvalidState], + extensions: expect.objectContaining({ + code: errorToCode[IncomingPaymentError.InvalidState] + }) + }) + ) + } + } + ) + + test.each` + action | mutation + ${'confirm'} | ${confirmPartialIncomingPaymentMutation} + ${'reject'} | ${rejectPartialIncomingPaymentMutation} + `( + '$action errors if redis write fails', + async ({ action, mutation }): Promise => { + const redisSpy = jest.spyOn(redis, 'set').mockImplementationOnce(() => { + throw new Error('unknown redis error') + }) + + const incomingPayment = await createIncomingPayment(deps, { + walletAddressId: walletAddress.id, + tenantId: walletAddress.tenantId, + initiationReason: IncomingPaymentInitiationReason.Admin + }) + const partialIncomingPaymentId = uuid() + const result = await appContainer.apolloClient + .mutate({ + mutation, + variables: { + input: { + incomingPaymentId: incomingPayment.id, + partialIncomingPaymentId + } + } + }) + .then( + ( + mutation + ): + | ConfirmPartialIncomingPaymentResponse + | RejectPartialIncomingPaymentResponse => + action === 'confirm' + ? mutation.data?.confirmPartialIncomingPayment + : mutation.data?.rejectPartialIncomingPayment + ) + + expect(result.success).toBe(false) + expect(redisSpy).toHaveBeenCalledWith( + `${PARTIAL_PAYMENT_DECISION_PREFIX}:${incomingPayment.id}:${partialIncomingPaymentId}`, + JSON.stringify({ success: action === 'confirm' ? true : false }) + ) + } + ) + }) }) diff --git a/packages/backend/src/graphql/resolvers/incoming_payment.ts b/packages/backend/src/graphql/resolvers/incoming_payment.ts index 8a1f2c5bb9..702f0cd0e2 100644 --- a/packages/backend/src/graphql/resolvers/incoming_payment.ts +++ b/packages/backend/src/graphql/resolvers/incoming_payment.ts @@ -7,7 +7,10 @@ import { IncomingPaymentFilter, IncomingPaymentResolvers } from '../generated/graphql' -import { IncomingPayment } from '../../open_payments/payment/incoming/model' +import { + IncomingPayment, + IncomingPaymentState +} from '../../open_payments/payment/incoming/model' import { IncomingPaymentInitiationReason } from '../../open_payments/payment/incoming/types' import { isIncomingPaymentError, @@ -293,6 +296,90 @@ export const getIncomingPaymentTenant: IncomingPaymentResolvers['confirmPartialIncomingPayment'] = + async ( + parent, + args, + ctx + ): Promise => { + const { input } = args + await canHandlePartialIncomingPayment(ctx, input.incomingPaymentId) + const incomingPaymentService = await ctx.container.use( + 'incomingPaymentService' + ) + return { + success: await incomingPaymentService.updatePartialPaymentDecision({ + incomingPaymentId: input.incomingPaymentId, + partialPaymentId: input.partialIncomingPaymentId, + success: true + }) + } + } + +export const rejectPartialIncomingPayment: MutationResolvers['rejectPartialIncomingPayment'] = + async ( + parent, + args, + ctx + ): Promise => { + const { input } = args + await canHandlePartialIncomingPayment(ctx, input.incomingPaymentId) + const incomingPaymentService = await ctx.container.use( + 'incomingPaymentService' + ) + return { + success: await incomingPaymentService.updatePartialPaymentDecision({ + incomingPaymentId: input.incomingPaymentId, + partialPaymentId: input.partialIncomingPaymentId, + success: false, + reason: input.reason ?? undefined + }) + } + } + +async function canHandlePartialIncomingPayment( + ctx: TenantedApolloContext, + id: string +): Promise { + const incomingPaymentService = await ctx.container.use( + 'incomingPaymentService' + ) + let options: { + id: string + tenantId?: string + } + + if (!ctx.isOperator) { + options = { + id, + tenantId: ctx.tenant.id + } + } else options = { id } + + const incomingPayment = await incomingPaymentService.get(options) + if (!incomingPayment) + throw new GraphQLError( + errorToMessage[IncomingPaymentError.UnknownPayment], + { + extensions: { + code: errorToCode[IncomingPaymentError.UnknownPayment] + } + } + ) + + if ( + [IncomingPaymentState.Completed, IncomingPaymentState.Expired].includes( + incomingPayment.state + ) + ) + throw new GraphQLError(errorToMessage[IncomingPaymentError.InvalidState], { + extensions: { + code: errorToCode[IncomingPaymentError.InvalidState] + } + }) + return +} + export function paymentToGraphql( payment: IncomingPayment, config: IAppConfig diff --git a/packages/backend/src/graphql/resolvers/index.ts b/packages/backend/src/graphql/resolvers/index.ts index fe995dc8a5..1fa2ea782d 100644 --- a/packages/backend/src/graphql/resolvers/index.ts +++ b/packages/backend/src/graphql/resolvers/index.ts @@ -28,7 +28,9 @@ import { approveIncomingPayment, cancelIncomingPayment, getIncomingPayments, - getIncomingPaymentTenant + getIncomingPaymentTenant, + confirmPartialIncomingPayment, + rejectPartialIncomingPayment } from './incoming_payment' import { getQuote, createQuote, getWalletAddressQuotes } from './quote' import { @@ -174,6 +176,8 @@ export const resolvers: Resolvers = { createIncomingPayment, approveIncomingPayment, cancelIncomingPayment, + confirmPartialIncomingPayment, + rejectPartialIncomingPayment, createReceiver, completeReceiver, createPeer: createPeer, diff --git a/packages/backend/src/graphql/resolvers/liquidity.test.ts b/packages/backend/src/graphql/resolvers/liquidity.test.ts index 6a36bacaee..544bbf97b7 100644 --- a/packages/backend/src/graphql/resolvers/liquidity.test.ts +++ b/packages/backend/src/graphql/resolvers/liquidity.test.ts @@ -51,6 +51,7 @@ import { import { GraphQLErrorCode } from '../errors' import { Tenant } from '../../tenants/model' import { createTenant } from '../../tests/tenant' +import { faker } from '@faker-js/faker' describe('Liquidity Resolvers', (): void => { let deps: IocContract @@ -3493,6 +3494,9 @@ describe('Liquidity Resolvers', (): void => { test('Can deposit account liquidity', async (): Promise => { const depositSpy = jest.spyOn(accountingService, 'createDeposit') + const dataToTransmit = JSON.stringify({ + data: faker.internet.email() + }) const response = await appContainer.apolloClient .mutate({ mutation: gql` @@ -3507,7 +3511,8 @@ describe('Liquidity Resolvers', (): void => { variables: { input: { outgoingPaymentId: outgoingPayment.id, - idempotencyKey: uuid() + idempotencyKey: uuid(), + dataToTransmit } } }) @@ -3529,6 +3534,13 @@ describe('Liquidity Resolvers', (): void => { await expect( accountingService.getBalance(outgoingPayment.id) ).resolves.toEqual(outgoingPayment.debitAmount.value) + await expect( + OutgoingPayment.query(knex).findById(outgoingPayment.id) + ).resolves.toEqual( + expect.objectContaining({ + dataToTransmit + }) + ) }) test("Can't deposit for non-existent outgoing payment id", async (): Promise => { diff --git a/packages/backend/src/graphql/resolvers/liquidity.ts b/packages/backend/src/graphql/resolvers/liquidity.ts index 2151916261..fba80ec40d 100644 --- a/packages/backend/src/graphql/resolvers/liquidity.ts +++ b/packages/backend/src/graphql/resolvers/liquidity.ts @@ -550,7 +550,7 @@ export const depositOutgoingPaymentLiquidity: MutationResolvers['cr code: errorToCode[outgoingPaymentOrError] } }) - } else + } else { return { payment: paymentToGraphql(outgoingPaymentOrError) } + } } export const createOutgoingPaymentFromIncomingPayment: MutationResolvers['createOutgoingPaymentFromIncomingPayment'] = diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index 4ce4502f17..faf5ed4e94 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -372,6 +372,16 @@ type Mutation { "Delete a tenant." deleteTenant(id: String!): DeleteTenantMutationResponse! + + "Confirms a partial incoming payment." + confirmPartialIncomingPayment( + input: ConfirmPartialIncomingPaymentInput! + ): ConfirmPartialIncomingPaymentResponse! + + "Rejects a partial incoming payment." + rejectPartialIncomingPayment( + input: RejectPartialIncomingPaymentInput! + ): RejectPartialIncomingPaymentResponse! } type PageInfo { @@ -604,6 +614,8 @@ input DepositOutgoingPaymentLiquidityInput { outgoingPaymentId: String! "Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency)." idempotencyKey: String! + "Data to be encrypted and sent to the receiver." + dataToTransmit: String } input CreateIncomingPaymentWithdrawalInput { @@ -1741,6 +1753,26 @@ type DeleteTenantMutationResponse { success: Boolean! } +input ConfirmPartialIncomingPaymentInput { + incomingPaymentId: ID! + partialIncomingPaymentId: ID! +} + +type ConfirmPartialIncomingPaymentResponse { + success: Boolean! +} + +input RejectPartialIncomingPaymentInput { + incomingPaymentId: ID! + partialIncomingPaymentId: ID! + "Reason why this incoming payment has been canceled. This value will be sent to the sender." + reason: String +} + +type RejectPartialIncomingPaymentResponse { + success: Boolean! +} + """ The `UInt8` scalar type represents unsigned 8-bit whole numeric values, ranging from 0 to 255. """ diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 129c5e19e2..9f8654a747 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -382,7 +382,8 @@ export function initIocContainer( accountingService: await deps.use('accountingService'), walletAddressService: await deps.use('walletAddressService'), assetService: await deps.use('assetService'), - config: await deps.use('config') + config: await deps.use('config'), + redis: await deps.use('redis') }) }) container.singleton('remoteIncomingPaymentService', async (deps) => { diff --git a/packages/backend/src/open_payments/payment/incoming/model.ts b/packages/backend/src/open_payments/payment/incoming/model.ts index 5ab807e446..7478a03d7a 100644 --- a/packages/backend/src/open_payments/payment/incoming/model.ts +++ b/packages/backend/src/open_payments/payment/incoming/model.ts @@ -19,7 +19,8 @@ import { IncomingPaymentInitiationReason } from './types' export enum IncomingPaymentEventType { IncomingPaymentCreated = 'incoming_payment.created', IncomingPaymentExpired = 'incoming_payment.expired', - IncomingPaymentCompleted = 'incoming_payment.completed' + IncomingPaymentCompleted = 'incoming_payment.completed', + IncomingPaymentPartialPaymentReceived = 'incoming_payment.partial_payment_received' } export enum IncomingPaymentState { 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 94db3826e6..b186a85ca1 100644 --- a/packages/backend/src/open_payments/payment/incoming/service.test.ts +++ b/packages/backend/src/open_payments/payment/incoming/service.test.ts @@ -1,4 +1,5 @@ import assert from 'assert' +import { createDecipheriv, randomBytes } from 'node:crypto' import { faker } from '@faker-js/faker' import { Knex } from 'knex' import { v4 as uuid } from 'uuid' @@ -33,6 +34,7 @@ import { withConfigOverride } from '../../../tests/helpers' import { poll } from '../../../shared/utils' import { Pagination, SortOrder } from '../../../shared/baseModel' import { getPageTests } from '../../../shared/baseModel.test' +import Redis from 'ioredis' describe('Incoming Payment Service', (): void => { let deps: IocContract @@ -45,6 +47,7 @@ describe('Incoming Payment Service', (): void => { let asset: Asset let config: IAppConfig let tenantId: string + let redis: Redis beforeAll(async (): Promise => { deps = initIocContainer({ @@ -57,6 +60,7 @@ describe('Incoming Payment Service', (): void => { incomingPaymentService = await deps.use('incomingPaymentService') config = await deps.use('config') tenantId = Config.operatorTenantId + redis = await deps.use('redis') }) beforeEach(async (): Promise => { @@ -790,15 +794,19 @@ describe('Incoming Payment Service', (): void => { }) describe.each` - eventType | expiresAt | amountReceived - ${IncomingPaymentEventType.IncomingPaymentExpired} | ${new Date(Date.now() + 30_000)} | ${BigInt(1)} - ${IncomingPaymentEventType.IncomingPaymentCompleted} | ${undefined} | ${BigInt(123)} + eventType | expiresInMs | amountReceived + ${IncomingPaymentEventType.IncomingPaymentExpired} | ${30_000} | ${BigInt(1)} + ${IncomingPaymentEventType.IncomingPaymentCompleted} | ${undefined} | ${BigInt(123)} `( 'handleDeactivated ($eventType)', - ({ eventType, expiresAt, amountReceived }): void => { + ({ eventType, expiresInMs, amountReceived }): void => { let incomingPayment: IncomingPayment beforeEach(async (): Promise => { + const expiresAt = + expiresInMs !== undefined + ? new Date(Date.now() + expiresInMs) + : undefined incomingPayment = await createIncomingPayment(deps, { walletAddressId, client, @@ -1118,6 +1126,268 @@ describe('Incoming Payment Service', (): void => { }) }) + describe('processPartialPayment', (): void => { + let incomingPayment: IncomingPayment + + const dbEncryptionOverride: Partial = { + dbEncryptionSecret: randomBytes(32).toString('base64') + } + + beforeEach(async (): Promise => { + incomingPayment = await createIncomingPayment(deps, { + walletAddressId, + tenantId, + initiationReason: IncomingPaymentInitiationReason.Admin + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + test( + 'can process partial payment for incoming payment', + withConfigOverride( + () => config, + dbEncryptionOverride, + async (): Promise => { + const partialIncomingPaymentId = uuid() + const dataFromSender = JSON.stringify({ + data: faker.internet.email() + }) + await incomingPaymentService.processPartialPayment( + incomingPayment.id, + { + dataFromSender, + partialIncomingPaymentId, + expiresAt: new Date(Date.now() - 60_000) + } + ) + const webhookEvent = await IncomingPaymentEvent.query(knex) + .where({ + incomingPaymentId: incomingPayment.id, + type: IncomingPaymentEventType.IncomingPaymentPartialPaymentReceived + }) + .withGraphFetched('webhooks') + .first() + assert.ok(webhookEvent) + assert.ok(webhookEvent.data.dataFromSender) + expect(webhookEvent.data.partialIncomingPaymentId).toBe( + partialIncomingPaymentId + ) + + const webhookDataFromSender = JSON.parse( + webhookEvent.data.dataFromSender as string + ) + const decipher = createDecipheriv( + 'aes-256-gcm', + Uint8Array.from( + Buffer.from(config.dbEncryptionSecret as string, 'base64') + ), + webhookDataFromSender.iv + ) + decipher.setAuthTag( + Uint8Array.from(Buffer.from(webhookDataFromSender.tag, 'base64')) + ) + let decrypted = decipher.update( + webhookDataFromSender.cipherText, + 'base64', + 'utf8' + ) + decrypted += decipher.final('utf8') + + expect(decrypted).toEqual(dataFromSender) + expect(webhookEvent.webhooks).toHaveLength(1) + } + ) + ) + + test( + 'does not encrypt transmitted data without configured encryption secret', + withConfigOverride( + () => config, + { + ...dbEncryptionOverride, + dbEncryptionSecret: undefined + }, + async (): Promise => { + const partialIncomingPaymentId = uuid() + const dataFromSender = JSON.stringify({ + data: faker.internet.email() + }) + + await incomingPaymentService.processPartialPayment( + incomingPayment.id, + { + dataFromSender, + partialIncomingPaymentId, + expiresAt: new Date(Date.now() - 60_000) + } + ) + const webhookEvent = await IncomingPaymentEvent.query(knex) + .where({ + incomingPaymentId: incomingPayment.id, + type: IncomingPaymentEventType.IncomingPaymentPartialPaymentReceived + }) + .withGraphFetched('webhooks') + .first() + assert.ok(webhookEvent) + + expect(webhookEvent.data.dataFromSender).toEqual(dataFromSender) + expect(webhookEvent.data.partialIncomingPaymentId).toBe( + partialIncomingPaymentId + ) + expect(webhookEvent.webhooks).toHaveLength(1) + } + ) + ) + + test( + 'reads approval decision from redis JSON success flag', + withConfigOverride( + () => config, + { + partialPaymentDecisionMaxWaitMs: 1_000, + partialPaymentDecisionSafetyMarginMs: 0 + }, + async (): Promise => { + const partialIncomingPaymentId = uuid() + const expiresAt = new Date(Date.now() + 5_000) + const cacheKey = `partial_payment_decision:${incomingPayment.id}:${partialIncomingPaymentId}` + + const redisGetSpy = jest + .spyOn(redis, 'get') + .mockImplementation(async (key) => { + if (key === cacheKey) { + return JSON.stringify({ success: true }) + } + return null + }) + + const decision = await incomingPaymentService.processPartialPayment( + incomingPayment.id, + { + dataFromSender: '{}', + partialIncomingPaymentId, + expiresAt + } + ) + + expect(decision.reason).toBe('Additional data approved') + expect(decision.success).toBe(true) + expect(redisGetSpy).toHaveBeenCalledWith(cacheKey) + + const webhookEvent = await IncomingPaymentEvent.query(knex) + .where({ + incomingPaymentId: incomingPayment.id, + type: IncomingPaymentEventType.IncomingPaymentPartialPaymentReceived + }) + .orderBy('createdAt', 'desc') + .first() + assert.ok(webhookEvent) + expect(webhookEvent.data.partialIncomingPaymentId).toBe( + partialIncomingPaymentId + ) + } + ) + ) + + test( + 'reads rejection decision from redis JSON success flag', + withConfigOverride( + () => config, + { + partialPaymentDecisionMaxWaitMs: 1_000, + partialPaymentDecisionSafetyMarginMs: 0 + }, + async (): Promise => { + const partialIncomingPaymentId = uuid() + const expiresAt = new Date(Date.now() + 5_000) + const cacheKey = `partial_payment_decision:${incomingPayment.id}:${partialIncomingPaymentId}` + + const redisGetSpy = jest + .spyOn(redis, 'get') + .mockImplementation(async (key) => { + if (key === cacheKey) { + return JSON.stringify({ success: false }) + } + return null + }) + + const decision = await incomingPaymentService.processPartialPayment( + incomingPayment.id, + { + dataFromSender: '{}', + partialIncomingPaymentId, + expiresAt + } + ) + + expect(decision.reason).toBe('Additional data rejected') + expect(decision.success).toBe(false) + expect(redisGetSpy).toHaveBeenCalledWith(cacheKey) + } + ) + ) + + test( + 'rejects partial payment if request times out', + withConfigOverride( + () => config, + { + partialPaymentDecisionMaxWaitMs: 1, + partialPaymentDecisionSafetyMarginMs: 0 + }, + async (): Promise => { + const partialIncomingPaymentId = uuid() + const expiresAt = new Date(Date.now() + 5_000) + const cacheKey = `partial_payment_decision:${incomingPayment.id}:${partialIncomingPaymentId}` + + const redisGetSpy = jest.spyOn(redis, 'get').mockImplementation() + + const decision = await incomingPaymentService.processPartialPayment( + incomingPayment.id, + { + dataFromSender: '{}', + partialIncomingPaymentId, + expiresAt + } + ) + + expect(decision.reason).toBe('No response from receiving ASE') + expect(decision.success).toBe(false) + expect(redisGetSpy).toHaveBeenCalledWith(cacheKey) + } + ) + ) + }) + + describe('updatePartialPaymentDecision', (): void => { + afterEach(() => { + jest.restoreAllMocks() + }) + + test('sets partial payments decision in redis', async (): Promise => { + const partialPaymentId = uuid() + const incomingPaymentId = uuid() + const cacheKey = `partial_payment_decision:${incomingPaymentId}:${partialPaymentId}` + + const redisSetSpy = jest.spyOn(redis, 'set').mockImplementation() + + await incomingPaymentService.updatePartialPaymentDecision({ + incomingPaymentId, + partialPaymentId: partialPaymentId, + success: false, + reason: 'rejected' + }) + + expect(redisSetSpy).toHaveBeenCalledWith( + cacheKey, + JSON.stringify({ success: false, reason: 'rejected' }) + ) + }) + }) + describe('getPage', (): void => { let receiverWalletAddress: MockWalletAddress let assetId: string diff --git a/packages/backend/src/open_payments/payment/incoming/service.ts b/packages/backend/src/open_payments/payment/incoming/service.ts index 2d33a90093..3035a02423 100644 --- a/packages/backend/src/open_payments/payment/incoming/service.ts +++ b/packages/backend/src/open_payments/payment/incoming/service.ts @@ -17,11 +17,12 @@ import { import { Amount } from '../../amount' import { IncomingPaymentError } from './errors' import { IAppConfig } from '../../../config/app' -import { poll } from '../../../shared/utils' +import { encryptDbData, poll } from '../../../shared/utils' import { AssetService } from '../../../asset/service' import { finalizeWebhookRecipients } from '../../../webhook/service' import { Pagination, SortOrder } from '../../../shared/baseModel' import { IncomingPaymentFilter } from '../../../graphql/generated/graphql' +import { Redis } from 'ioredis' export const POSITIVE_SLIPPAGE = BigInt(1) // First retry waits 10 seconds @@ -78,6 +79,17 @@ export interface IncomingPaymentService update( options: UpdateOptions ): Promise + processPartialPayment( + id: string, + options: { + dataFromSender: string + partialIncomingPaymentId: string + expiresAt: Date + } + ): Promise + updatePartialPaymentDecision( + options: PartialPaymentDecisionOptions + ): Promise } export interface ServiceDependencies extends BaseService { @@ -86,6 +98,7 @@ export interface ServiceDependencies extends BaseService { walletAddressService: WalletAddressService assetService: AssetService config: IAppConfig + redis: Redis } export async function createIncomingPaymentService( @@ -107,7 +120,11 @@ export async function createIncomingPaymentService( getWalletAddressPage: (options) => getWalletAddressPage(deps, options), processNext: () => processNextIncomingPayment(deps), update: (options) => updateIncomingPayment(deps, options), - getPage: (options) => getPage(deps, options) + getPage: (options) => getPage(deps, options), + processPartialPayment: (id, options) => + processPartialPayment(deps, id, options), + updatePartialPaymentDecision: (options) => + updatePartialPaymentDecision(deps, options) } } @@ -549,6 +566,175 @@ async function addReceivedAmount( return payment } +async function processPartialPayment( + deps: ServiceDependencies, + id: string, + options: { + dataFromSender: string + partialIncomingPaymentId: string + expiresAt: Date + } +): Promise { + const { config, knex, redis } = deps + + const incomingPayment = await IncomingPayment.query(knex) + .findById(id) + .withGraphFetched('asset') + if (!incomingPayment) { + throw new Error('Unknown incoming payment') + } + + await IncomingPaymentEvent.query(knex).insertGraph({ + incomingPaymentId: incomingPayment.id, + type: IncomingPaymentEventType.IncomingPaymentPartialPaymentReceived, + data: { + ...incomingPayment.toData(0n), + partialIncomingPaymentId: options.partialIncomingPaymentId, + dataFromSender: + options.dataFromSender && config.dbEncryptionSecret + ? encryptDbData(options.dataFromSender, config.dbEncryptionSecret) + : options.dataFromSender + }, + tenantId: incomingPayment.tenantId, + webhooks: finalizeWebhookRecipients( + { + tenantIds: [incomingPayment.tenantId], + sendToPosService: + incomingPayment.initiatedBy === IncomingPaymentInitiationReason.Card + }, + deps.config, + deps.logger + ) + }) + + let decision: PartialPaymentDecision = { success: false } + const partialIncomingPaymentId = options.partialIncomingPaymentId + const cacheKey = getPartialPaymentDecisionCacheKey( + id, + partialIncomingPaymentId + ) + + // Bounded polling: wait for decision up to (packet expiry - safetyMs) or maxWaitMs + const safetyMs = Number.isFinite(config.partialPaymentDecisionSafetyMarginMs) + ? config.partialPaymentDecisionSafetyMarginMs + : 100 + const maxWaitMs = Number.isFinite(config.partialPaymentDecisionMaxWaitMs) + ? config.partialPaymentDecisionMaxWaitMs + : 1500 + + const now = Date.now() + const timeRemaining = Math.max( + 0, + options.expiresAt.getTime() - now - safetyMs + ) + const timeoutMs = Math.min(timeRemaining, maxWaitMs) + const pollingFrequencyMs = 50 + + try { + const polledDecision = await poll({ + request: async (): Promise => { + try { + const value = await redis.get(cacheKey) + if (!value) return null + + try { + const parsed = JSON.parse(value) as PartialPaymentDecision + return parsed + } catch (parseError) { + deps.logger.warn( + { parseError, incomingPaymentId: id, cacheKey }, + 'invalid partial payment decision format in cache' + ) + } + + return null + } catch (e) { + deps.logger.warn({ e, incomingPaymentId: id }, 'decision read failed') + return null + } + }, + stopWhen: (result: PartialPaymentDecision | null) => result !== null, + pollingFrequencyMs, + timeoutMs + }) + if (polledDecision) { + decision = { + ...decision, + ...polledDecision + } + + if (!decision.reason && typeof decision.success === 'boolean') { + decision.reason = decision.success + ? 'Additional data approved' + : 'Additional data rejected' + } + } + } catch (e) { + deps.logger.warn( + { e, incomingPaymentId: id }, + 'partial payment decision polling timed out or failed' + ) + } + + if (!decision.reason) { + decision.reason = 'No response from receiving ASE' + } + + return decision +} + +const PARTIAL_PAYMENT_DECISION_PREFIX = 'partial_payment_decision' + +function getPartialPaymentDecisionCacheKey( + incomingPaymentId: string, + partialIncomingPaymentId: string +): string { + return `${PARTIAL_PAYMENT_DECISION_PREFIX}:${incomingPaymentId}:${partialIncomingPaymentId}` +} + +interface PartialPaymentDecision { + success: boolean + reason?: string +} + +interface PartialPaymentDecisionOptions { + incomingPaymentId: string + partialPaymentId: string + success: boolean + reason?: string +} + +async function updatePartialPaymentDecision( + deps: ServiceDependencies, + options: PartialPaymentDecisionOptions +): Promise { + const { redis, logger } = deps + const cacheKey = getPartialPaymentDecisionCacheKey( + options.incomingPaymentId, + options.partialPaymentId + ) + const decisionPayload: PartialPaymentDecision = { + success: options.success + } + if (typeof options.reason === 'string') { + decisionPayload.reason = options.reason + } + try { + await redis.set(cacheKey, JSON.stringify(decisionPayload)) + return true + } catch (e) { + logger.error( + { + e, + incomingPaymentId: options.incomingPaymentId, + partialPaymentId: options.partialPaymentId + }, + 'failed to update partial payment decision' + ) + return false + } +} + async function getPage( deps: ServiceDependencies, options?: GetPageOptions diff --git a/packages/backend/src/open_payments/payment/outgoing/model.test.ts b/packages/backend/src/open_payments/payment/outgoing/model.test.ts index 5c3538c13d..c2673aec61 100644 --- a/packages/backend/src/open_payments/payment/outgoing/model.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/model.test.ts @@ -1,5 +1,8 @@ +import crypto from 'node:crypto' +import { v4 as uuid } from 'uuid' +import assert from 'assert' import { Knex } from 'knex' -import { Config } from '../../../config/app' +import { Config, IAppConfig } from '../../../config/app' import { createTestApp, TestContainer } from '../../../tests/app' import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '../../..' @@ -8,18 +11,30 @@ import { truncateTables } from '../../../tests/tableManager' import { OutgoingPaymentEventError, OutgoingPaymentEvent, - OutgoingPaymentEventType + OutgoingPaymentEventType, + OutgoingPayment } from './model' +import { createOutgoingPayment } from '../../../tests/outgoingPayment' +import { createWalletAddress } from '../../../tests/walletAddress' +import { IncomingPaymentInitiationReason } from '../incoming/types' +import { createIncomingPayment } from '../../../tests/incomingPayment' +import { OutgoingPaymentService } from './service' +import { faker } from '@faker-js/faker' +import { isFundingError, isOutgoingPaymentError } from './errors' +import { withConfigOverride } from '../../../tests/helpers' +import { WalletAddress } from '../../wallet_address/model' describe('Outgoing Payment Event Model', (): void => { let deps: IocContract let appContainer: TestContainer let knex: Knex + let config: IAppConfig beforeAll(async (): Promise => { deps = initIocContainer(Config) appContainer = await createTestApp(deps) knex = await deps.use('knex') + config = await deps.use('config') }) afterEach(async (): Promise => { @@ -47,4 +62,92 @@ describe('Outgoing Payment Event Model', (): void => { } ) }) + + describe('getDataToTransmit', (): void => { + let outgoingPaymentService: OutgoingPaymentService + let walletAddress: WalletAddress + let payment: OutgoingPayment + const dbEncryptionOverride: Partial = { + dbEncryptionSecret: crypto.randomBytes(32).toString('base64') + } + beforeAll(async (): Promise => { + outgoingPaymentService = await deps.use('outgoingPaymentService') + }) + + beforeEach(async (): Promise => { + walletAddress = await createWalletAddress(deps) + const incomingPayment = await createIncomingPayment(deps, { + walletAddressId: walletAddress.id, + tenantId: walletAddress.tenantId, + initiationReason: IncomingPaymentInitiationReason.Admin + }) + const receiver = incomingPayment.getUrl(config.openPaymentsUrl) + payment = await createOutgoingPayment(deps, { + tenantId: walletAddress.tenantId, + walletAddressId: walletAddress.id, + receiver, + method: 'ilp', + debitAmount: { + value: BigInt(123), + assetCode: walletAddress.asset.code, + assetScale: walletAddress.asset.scale + } + }) + }) + + test( + 'can decrypt data', + withConfigOverride( + () => config, + dbEncryptionOverride, + async (): Promise => { + const decipherSpy = jest.spyOn(crypto, 'createDecipheriv') + const dataToTransmit = { data: faker.internet.email() } + const paymentWithData = await outgoingPaymentService.fund({ + id: payment.id, + tenantId: walletAddress.tenantId, + amount: payment.debitAmount.value, + transferId: uuid(), + dataToTransmit: JSON.stringify(dataToTransmit) + }) + + assert.ok(!isOutgoingPaymentError(paymentWithData)) + assert.ok(!isFundingError(paymentWithData)) + expect( + paymentWithData.getDataToTransmit(config.dbEncryptionSecret) + ).toEqual(JSON.stringify(dataToTransmit)) + expect(decipherSpy).toHaveBeenCalled() + } + ) + ) + + test( + 'returns data as-is without configured key env variable', + withConfigOverride( + () => config, + { + ...dbEncryptionOverride, + dbEncryptionSecret: undefined + }, + async (): Promise => { + const decipherSpy = jest.spyOn(crypto, 'createDecipheriv') + const dataToTransmit = { data: faker.internet.email() } + const paymentWithData = await outgoingPaymentService.fund({ + id: payment.id, + tenantId: walletAddress.tenantId, + amount: payment.debitAmount.value, + transferId: uuid(), + dataToTransmit: JSON.stringify(dataToTransmit) + }) + + assert.ok(!isOutgoingPaymentError(paymentWithData)) + assert.ok(!isFundingError(paymentWithData)) + expect( + paymentWithData.getDataToTransmit(config.dbEncryptionSecret) + ).toEqual(JSON.stringify(dataToTransmit)) + expect(decipherSpy).not.toHaveBeenCalled() + } + ) + ) + }) }) diff --git a/packages/backend/src/open_payments/payment/outgoing/model.ts b/packages/backend/src/open_payments/payment/outgoing/model.ts index d652942611..2fd737c21c 100644 --- a/packages/backend/src/open_payments/payment/outgoing/model.ts +++ b/packages/backend/src/open_payments/payment/outgoing/model.ts @@ -1,5 +1,6 @@ import { Model, ModelOptions, QueryContext } from 'objection' import { DbErrors } from 'objection-db-errors' +import { createDecipheriv } from 'node:crypto' import { LiquidityAccount } from '../../../accounting/service' import { Asset } from '../../../asset/model' @@ -203,6 +204,24 @@ export class OutgoingPayment public tenantId!: string + public dataToTransmit?: string + public getDataToTransmit(key?: string): string | null { + if (!this.dataToTransmit) return null + if (!key) return this.dataToTransmit + const { tag, cipherText, iv } = JSON.parse(this.dataToTransmit) + + const decipher = createDecipheriv( + 'aes-256-gcm', + Uint8Array.from(Buffer.from(key, 'base64')), + iv + ) + decipher.setAuthTag(Uint8Array.from(Buffer.from(tag, 'base64'))) + let decryptedDataToTransmit = decipher.update(cipherText, 'base64', 'utf8') + decryptedDataToTransmit += decipher.final('utf8') + + return decryptedDataToTransmit + } + static get relationMappings() { return { ...super.relationMappings, 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 383458551a..2ece3667cd 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.test.ts @@ -2,6 +2,7 @@ import assert from 'assert' import { faker } from '@faker-js/faker' import { Knex } from 'knex' import { v4 as uuid } from 'uuid' +import { randomBytes } from 'node:crypto' import { FundingError, @@ -2879,6 +2880,65 @@ describe('OutgoingPaymentService', (): void => { expect(after?.state).toBe(startState) }) }) + + it( + 'can add encrypted data to be transmitted', + withConfigOverride( + () => config, + { + dbEncryptionSecret: randomBytes(32).toString('base64') + }, + async (): Promise => { + const encryptedData = JSON.stringify({ data: faker.internet.email() }) + const fundedPayment = await outgoingPaymentService.fund({ + id: payment.id, + tenantId, + amount: quoteAmount, + transferId: uuid(), + dataToTransmit: encryptedData + }) + + assert.ok(!isTransferError(fundedPayment)) + assert.ok(!isOutgoingPaymentError(fundedPayment)) + expect(JSON.parse(fundedPayment.dataToTransmit as string)).toEqual( + expect.objectContaining({ + cipherText: expect.any(String), + tag: expect.objectContaining({ + data: expect.any(Array), + type: 'Buffer' + }) + }) + ) + expect( + fundedPayment.getDataToTransmit(config.dbEncryptionSecret) + ).toEqual(encryptedData) + } + ) + ) + + it( + 'inserts data as-is without configured secret', + withConfigOverride( + () => config, + { + dbEncryptionSecret: undefined + }, + async (): Promise => { + const encryptedData = JSON.stringify({ data: faker.internet.email() }) + const fundedPayment = await outgoingPaymentService.fund({ + id: payment.id, + tenantId, + amount: quoteAmount, + transferId: uuid(), + dataToTransmit: encryptedData + }) + + assert.ok(!isTransferError(fundedPayment)) + assert.ok(!isOutgoingPaymentError(fundedPayment)) + expect(fundedPayment.dataToTransmit).toEqual(encryptedData) + } + ) + ) }) describe('getGrantSpentAmounts', (): void => { diff --git a/packages/backend/src/open_payments/payment/outgoing/service.ts b/packages/backend/src/open_payments/payment/outgoing/service.ts index 54a6f0becb..af3eb68714 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.ts @@ -49,6 +49,7 @@ import { FeeService } from '../../../fee/service' import { OutgoingPaymentGrantSpentAmounts } from './model' import { v4 as uuid } from 'uuid' import { OutgoingPaymentCardDetails } from './card/model' +import { encryptDbData } from '../../../shared/utils' const DEFAULT_GRANT_LOCK_TIMEOUT_MS = 5000 @@ -961,11 +962,18 @@ export interface FundOutgoingPaymentOptions { tenantId: string amount: bigint transferId: string + dataToTransmit?: string } async function fundPayment( deps: ServiceDependencies, - { id, tenantId, amount, transferId }: FundOutgoingPaymentOptions + { + id, + tenantId, + amount, + transferId, + dataToTransmit + }: FundOutgoingPaymentOptions ): Promise { return await deps.knex.transaction(async (trx) => { const payment = await OutgoingPayment.query(trx) @@ -1011,7 +1019,14 @@ async function fundPayment( if (error) { return error } - await payment.$query(trx).patch({ state: OutgoingPaymentState.Sending }) + + await payment.$query(trx).patch({ + state: OutgoingPaymentState.Sending, + dataToTransmit: + deps.config.dbEncryptionSecret && dataToTransmit + ? encryptDbData(dataToTransmit, deps.config.dbEncryptionSecret) + : dataToTransmit + }) if (payment.initiatedBy === OutgoingPaymentInitiationReason.Card) { await sendWebhookEvent( diff --git a/packages/backend/src/open_payments/payment/outgoing/worker.ts b/packages/backend/src/open_payments/payment/outgoing/worker.ts index 8c4a49b458..516e096890 100644 --- a/packages/backend/src/open_payments/payment/outgoing/worker.ts +++ b/packages/backend/src/open_payments/payment/outgoing/worker.ts @@ -116,7 +116,14 @@ async function onLifecycleError( payment: OutgoingPayment, err: Error | PaymentError ): Promise { - const error = typeof err === 'string' ? err : err.message + let error: string + if (err instanceof PaymentMethodHandlerError) { + error = err.description || err.message + } else if (typeof err === 'string') { + error = err + } else { + error = err.message + } const stateAttempts = payment.stateAttempts + 1 const errorDescription = diff --git a/packages/backend/src/open_payments/wallet_address/model.ts b/packages/backend/src/open_payments/wallet_address/model.ts index 299ba1c31f..ec5ad5484a 100644 --- a/packages/backend/src/open_payments/wallet_address/model.ts +++ b/packages/backend/src/open_payments/wallet_address/model.ts @@ -215,7 +215,7 @@ class SubresourceQueryBuilder< NumberQueryBuilderType!: SubresourceQueryBuilder PageQueryBuilderType!: SubresourceQueryBuilder> - get({ id, walletAddressId, client }: GetOptions) { + get({ id, walletAddressId, client, tenantId }: GetOptions) { if (walletAddressId) { this.where( `${this.modelClass().tableName}.walletAddressId`, @@ -225,6 +225,9 @@ class SubresourceQueryBuilder< if (client) { this.where({ client }) } + if (tenantId) { + this.where({ tenantId }) + } return this.findById(id) } list({ walletAddressId, client, pagination, sortOrder }: ListOptions) { diff --git a/packages/backend/src/openapi/specs/webhooks.yaml b/packages/backend/src/openapi/specs/webhooks.yaml index 6723e059f0..cf53b67439 100644 --- a/packages/backend/src/openapi/specs/webhooks.yaml +++ b/packages/backend/src/openapi/specs/webhooks.yaml @@ -41,6 +41,14 @@ webhooks: responses: '200': description: Data was received successfully + incomingPaymentPartialPaymentReceived: + post: + requestBody: + description: Notifies the account servicing entity that an incoming payment has received partial payment. May contain data that the ASE should handle before accepting the rest of the payment. + content: + application/json: + schema: + $ref: '#/components/schemas/incomingPaymentEvent' outgoingPaymentCreated: post: requestBody: @@ -158,6 +166,7 @@ components: - incoming_payment.created - incoming_payment.completed - incoming_payment.expired + - incoming_payment.partial_payment_received data: type: object required: @@ -192,6 +201,13 @@ components: expiresAt: type: string format: date-time + partialIncomingPaymentId: + type: string + format: uuid + description: Identifier for the partial payment associated with this webhook event. + dataFromSender: + type: string + description: Data transmitted by the sending Account Servicing Entity during this partial payment. additionalProperties: false outgoingPaymentEvent: required: diff --git a/packages/backend/src/payment-method/ilp/connector/core/middleware/index.ts b/packages/backend/src/payment-method/ilp/connector/core/middleware/index.ts index 3e84c260de..ec1da2a307 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/middleware/index.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/middleware/index.ts @@ -18,3 +18,4 @@ export * from './rate-limit' export * from './reduce-expiry' export * from './throughput' export * from './validate-fulfillment' +export * from './partial-payment-decision' diff --git a/packages/backend/src/payment-method/ilp/connector/core/middleware/partial-payment-decision.ts b/packages/backend/src/payment-method/ilp/connector/core/middleware/partial-payment-decision.ts new file mode 100644 index 0000000000..5f146eddb7 --- /dev/null +++ b/packages/backend/src/payment-method/ilp/connector/core/middleware/partial-payment-decision.ts @@ -0,0 +1,72 @@ +import { v4 as uuid } from 'uuid' +import { ILPContext, ILPMiddleware } from '../rafiki' +import { StreamState } from './stream-address' +import { isIlpReply } from 'ilp-packet' + +type PartialPaymentDecision = { + success: boolean + reason?: string +} + +export function createPartialPaymentDecisionMiddleware(): ILPMiddleware { + return async ( + ctx: ILPContext, + next: () => Promise + ): Promise => { + if (!ctx.services.config.enablePartialPaymentDecision) { + await next() + return + } + if (!ctx.state.streamDestination || !ctx.state.additionalData) { + await next() + return + } + const { prepare } = ctx.request + const incomingPaymentId = ctx.state.streamDestination + const additionalData = ctx.state.additionalData + const streamServer = ctx.state.streamServer + if (!streamServer) { + await next() + return + } + const replyOrMoney = streamServer.createReply(ctx.request.prepare) + if (isIlpReply(replyOrMoney)) { + ctx.response.reply = replyOrMoney + return + } + + let decision: PartialPaymentDecision + let reason: string | undefined + + try { + decision = await ctx.services.incomingPayments.processPartialPayment( + incomingPaymentId, + { + dataFromSender: additionalData, + partialIncomingPaymentId: uuid(), + expiresAt: prepare.expiresAt + } + ) + + if (decision.success) { + await next() + return + } + reason = decision?.reason + } catch (error) { + // We intentionally *decline* instead of throwing: throwing would be + // converted to an ILP Reject by `createIncomingErrorHandlerMiddleware`, + // losing the human-readable partial decision reason that we pass here. + ctx.services.logger.error( + { error, incomingPaymentId }, + 'failed to process partial payment' + ) + reason = 'Error processing partial payment' + } + const errorData = Buffer.from( + reason ?? 'Error processing partial payment', + 'utf8' + ) + ctx.response.reply = replyOrMoney.finalDecline(errorData) + } +} diff --git a/packages/backend/src/payment-method/ilp/connector/core/middleware/stream-address.ts b/packages/backend/src/payment-method/ilp/connector/core/middleware/stream-address.ts index df52b5de1d..854b599e98 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/middleware/stream-address.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/middleware/stream-address.ts @@ -1,11 +1,24 @@ -import { StreamServer } from '@interledger/stream-receiver' +import { StreamServer, IncomingMoney } from '@interledger/stream-receiver' import { ILPMiddleware, ILPContext } from '../rafiki' import { TenantSettingKeys } from '../../../../../tenants/settings/model' import { AuthState } from './auth' +import { isIlpReply } from 'ilp-packet' +const STREAM_DATA_STREAM_ID = 1 + +function getAdditionalDataFromReply(reply: IncomingMoney): string | undefined { + const frames = reply.dataFrames + if (!frames?.length) return undefined + const payload = + frames.find((f) => Number(f.streamId) === STREAM_DATA_STREAM_ID)?.data ?? + frames[0].data + return payload?.length ? payload.toString('utf8') : undefined +} export interface StreamState { streamDestination?: string streamServer?: StreamServer + additionalData?: string + connectionId?: string } export function createStreamAddressMiddleware(): ILPMiddleware { @@ -34,8 +47,23 @@ export function createStreamAddressMiddleware(): ILPMiddleware { ctx.request.prepare.destination ) || undefined - stopTimer() - await next() + // Decode frames to check for additional data + try { + if (ctx.state.streamServer && ctx.state.streamDestination) { + const replyOrMoney = ctx.state.streamServer.createReply( + ctx.request.prepare + ) + let payload: string | undefined + if (!isIlpReply(replyOrMoney)) { + payload = getAdditionalDataFromReply(replyOrMoney) + } + + ctx.state.additionalData = payload + } + } finally { + stopTimer() + await next() + } } } diff --git a/packages/backend/src/payment-method/ilp/connector/core/test/middleware/partial-payment-decision.test.ts b/packages/backend/src/payment-method/ilp/connector/core/test/middleware/partial-payment-decision.test.ts new file mode 100644 index 0000000000..ed25ee9607 --- /dev/null +++ b/packages/backend/src/payment-method/ilp/connector/core/test/middleware/partial-payment-decision.test.ts @@ -0,0 +1,332 @@ +import { ZeroCopyIlpPrepare } from '../..' +import { createILPContext } from '../../utils' +import { IlpPrepareFactory, RafikiServicesFactory } from '../../factories' +import { createPartialPaymentDecisionMiddleware } from '../../middleware/partial-payment-decision' +import { StreamState } from '../../middleware/stream-address' +import { StreamServer } from '@interledger/stream-receiver' +import { Config } from '../../../../../../config/app' + +describe('Partial Payment Decision Middleware', function () { + const middleware = createPartialPaymentDecisionMiddleware() + + function makeContext( + streamState?: Partial, + services = makeServices().services + ) { + const ctx = createILPContext({ + services, + state: { + streamDestination: 'test-payment-id', + additionalData: 'test-data', + streamServer: new StreamServer({ + serverAddress: services.config.ilpAddress, + serverSecret: services.config.streamSecret + }), + ...streamState + } + }) + return ctx + } + + function makeServices() { + const services = RafikiServicesFactory.build({ + config: { ...Config, enablePartialPaymentDecision: true } + }) + const mockProcessPartialPayment = jest.fn< + Promise, + [ + string, + { + dataFromSender: string + partialIncomingPaymentId: string + expiresAt: Date + } + ] + >() + Object.assign(services.incomingPayments, { + processPartialPayment: mockProcessPartialPayment + }) + return { services, mockProcessPartialPayment } + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + function mockIncomingMoneyReply(ctx: ReturnType) { + if (!ctx.state.streamServer) { + throw new Error('streamServer should be defined in this test') + } + jest.spyOn(ctx.state.streamServer, 'createReply').mockReturnValue({ + packet: Buffer.from('mock-packet') + } as unknown as ReturnType) + } + + function mockIncomingMoneyReplyWithDecline( + ctx: ReturnType + ) { + if (!ctx.state.streamServer) { + throw new Error('streamServer should be defined in this test') + } + + const finalDeclineMock = jest + .fn() + .mockImplementation( + (errorData?: Buffer) => errorData ?? Buffer.from('declined', 'utf8') + ) + + jest.spyOn(ctx.state.streamServer, 'createReply').mockReturnValue({ + packet: Buffer.from('mock-packet'), + finalDecline: finalDeclineMock + } as unknown as ReturnType) + return finalDeclineMock + } + + test('skips when streamDestination is not set', async () => { + const { services, mockProcessPartialPayment } = makeServices() + const ctx = makeContext({ streamDestination: undefined }, services) + const prepare = IlpPrepareFactory.build() + ctx.request.prepare = new ZeroCopyIlpPrepare(prepare) + const next = jest.fn() + + await expect(middleware(ctx, next)).resolves.toBeUndefined() + + expect(next).toHaveBeenCalledTimes(1) + expect(mockProcessPartialPayment).not.toHaveBeenCalled() + }) + + test('skips when additionalData is missing', async () => { + const { services, mockProcessPartialPayment } = makeServices() + const ctx = makeContext({ additionalData: undefined }, services) + const prepare = IlpPrepareFactory.build() + ctx.request.prepare = new ZeroCopyIlpPrepare(prepare) + const next = jest.fn() + + await expect(middleware(ctx, next)).resolves.toBeUndefined() + + expect(next).toHaveBeenCalledTimes(1) + expect(mockProcessPartialPayment).not.toHaveBeenCalled() + }) + + test('calls processPartialPayment with correct parameters', async () => { + const { services, mockProcessPartialPayment } = makeServices() + const incomingPaymentId = 'test-payment-id' + const ctx = makeContext( + { + streamDestination: incomingPaymentId + }, + services + ) + const prepare = IlpPrepareFactory.build() + const expiresAt = new Date(Date.now() + 30000) + prepare.expiresAt = expiresAt + ctx.request.prepare = new ZeroCopyIlpPrepare(prepare) + mockIncomingMoneyReply(ctx) + + mockProcessPartialPayment.mockResolvedValue({ + reason: 'Additional data approved', + success: true + }) + + const next = jest.fn() + + await expect(middleware(ctx, next)).resolves.toBeUndefined() + + expect(mockProcessPartialPayment).toHaveBeenCalledWith( + incomingPaymentId, + expect.objectContaining({ + dataFromSender: 'test-data', + expiresAt + }) + ) + expect(next).toHaveBeenCalledTimes(1) + }) + + test('extracts additional data from STREAM frames', async () => { + const { services, mockProcessPartialPayment } = makeServices() + const incomingPaymentId = 'test-payment-id' + const additionalData = 'test-data' + const ctx = makeContext( + { + streamDestination: incomingPaymentId, + additionalData + }, + services + ) + + const streamServer = ctx.state.streamServer + if (!streamServer) { + throw new Error('streamServer should be defined in this test') + } + const credentials = streamServer.generateCredentials({ + paymentTag: incomingPaymentId + }) + + const prepare = IlpPrepareFactory.build({ + destination: credentials.ilpAddress, + expiresAt: new Date(Date.now() + 30000) + }) + ctx.request.prepare = new ZeroCopyIlpPrepare(prepare) + + // Mock the streamServer.createReply to return incoming money + const mockReply = { packet: Buffer.from('mock-packet') } + if (!ctx.state.streamServer) { + throw new Error('streamServer should be defined in this test') + } + jest + .spyOn(ctx.state.streamServer, 'createReply') + .mockReturnValue( + mockReply as unknown as ReturnType + ) + + mockProcessPartialPayment.mockResolvedValue({ + reason: 'Additional data approved', + success: true + }) + + const next = jest.fn() + + await expect(middleware(ctx, next)).resolves.toBeUndefined() + + expect(mockProcessPartialPayment).toHaveBeenCalledWith( + incomingPaymentId, + expect.objectContaining({ + dataFromSender: additionalData, + expiresAt: prepare.expiresAt + }) + ) + }) + + test('allows when decision is "Additional data approved"', async () => { + const { services, mockProcessPartialPayment } = makeServices() + const ctx = makeContext(undefined, services) + const prepare = IlpPrepareFactory.build({ + expiresAt: new Date(Date.now() + 30000) + }) + ctx.request.prepare = new ZeroCopyIlpPrepare(prepare) + mockIncomingMoneyReply(ctx) + + mockProcessPartialPayment.mockResolvedValue({ + reason: 'Additional data approved', + success: true + }) + + const next = jest.fn() + + await expect(middleware(ctx, next)).resolves.toBeUndefined() + + expect(next).toHaveBeenCalledTimes(1) + }) + + test('declines payment when decision is not approved', async () => { + const { services, mockProcessPartialPayment } = makeServices() + const ctx = makeContext(undefined, services) + const prepare = IlpPrepareFactory.build({ + expiresAt: new Date(Date.now() + 30000) + }) + ctx.request.prepare = new ZeroCopyIlpPrepare(prepare) + mockIncomingMoneyReplyWithDecline(ctx) + + const rejectionReason = 'Data validation failed' + mockProcessPartialPayment.mockResolvedValue({ + reason: rejectionReason, + success: false + }) + + const next = jest.fn() + + await expect(middleware(ctx, next)).resolves.toBeUndefined() + expect(ctx.response.reply).toBeDefined() + expect(next).not.toHaveBeenCalled() + }) + + test('decline reply includes rejection reason when decision is not approved', async () => { + const { services, mockProcessPartialPayment } = makeServices() + const ctx = makeContext(undefined, services) + const prepare = IlpPrepareFactory.build({ + expiresAt: new Date(Date.now() + 30000) + }) + ctx.request.prepare = new ZeroCopyIlpPrepare(prepare) + const finalDeclineMock = mockIncomingMoneyReplyWithDecline(ctx) + + const rejectionReason = 'Data validation failed' + mockProcessPartialPayment.mockResolvedValue({ + reason: rejectionReason, + success: false + }) + + const next = jest.fn() + + await expect(middleware(ctx, next)).resolves.toBeUndefined() + expect(ctx.response.reply).toBeDefined() + expect(finalDeclineMock).toHaveBeenCalledWith(Buffer.from(rejectionReason)) + expect(ctx.response.reply).toBeDefined() + expect(next).not.toHaveBeenCalled() + }) + + test('handles errors from processPartialPayment by declining packet', async () => { + const { services, mockProcessPartialPayment } = makeServices() + const ctx = makeContext(undefined, services) + const prepare = IlpPrepareFactory.build({ + expiresAt: new Date(Date.now() + 30000) + }) + ctx.request.prepare = new ZeroCopyIlpPrepare(prepare) + mockIncomingMoneyReplyWithDecline(ctx) + + const error = new Error('Unknown incoming payment') + mockProcessPartialPayment.mockRejectedValue(error) + + const next = jest.fn() + + await expect(middleware(ctx, next)).resolves.toBeUndefined() + expect(services.logger.error).toHaveBeenCalledWith( + { error, incomingPaymentId: 'test-payment-id' }, + 'failed to process partial payment' + ) + expect(ctx.response.reply).toBeDefined() + expect(next).not.toHaveBeenCalled() + }) + + test('uses generic decline message on service error', async () => { + const { services, mockProcessPartialPayment } = makeServices() + const ctx = makeContext(undefined, services) + const prepare = IlpPrepareFactory.build({ + expiresAt: new Date(Date.now() + 30000) + }) + ctx.request.prepare = new ZeroCopyIlpPrepare(prepare) + const finalDeclineMock = mockIncomingMoneyReplyWithDecline(ctx) + + const error = new Error('Database error') + mockProcessPartialPayment.mockRejectedValue(error) + + const next = jest.fn() + + await expect(middleware(ctx, next)).resolves.toBeUndefined() + expect(finalDeclineMock).toHaveBeenCalledWith( + Buffer.from('Error processing partial payment') + ) + expect(ctx.response.reply).toBeDefined() + expect(next).not.toHaveBeenCalled() + }) + + test('handles missing streamServer gracefully when extracting data', async () => { + const { services, mockProcessPartialPayment } = makeServices() + const ctx = makeContext({ streamServer: undefined }, services) + const prepare = IlpPrepareFactory.build({ + expiresAt: new Date(Date.now() + 30000) + }) + ctx.request.prepare = new ZeroCopyIlpPrepare(prepare) + + mockProcessPartialPayment.mockResolvedValue({ + reason: 'Additional data approved', + success: true + }) + + const next = jest.fn() + + await expect(middleware(ctx, next)).resolves.toBeUndefined() + + expect(mockProcessPartialPayment).not.toHaveBeenCalled() + expect(next).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/backend/src/payment-method/ilp/connector/index.ts b/packages/backend/src/payment-method/ilp/connector/index.ts index 9114649b58..7aaec85024 100644 --- a/packages/backend/src/payment-method/ilp/connector/index.ts +++ b/packages/backend/src/payment-method/ilp/connector/index.ts @@ -24,7 +24,8 @@ import { createOutgoingThroughputMiddleware, createOutgoingValidateFulfillmentMiddleware, createStreamAddressMiddleware, - createStreamController + createStreamController, + createPartialPaymentDecisionMiddleware } from './core' import { TelemetryService } from '../../../telemetry/service' import { TenantSettingService } from '../../../tenants/settings/service' @@ -79,7 +80,6 @@ export async function createConnectorService({ // Incoming Rules createIncomingErrorHandlerMiddleware(ilpAddress), createStreamAddressMiddleware(), - createAccountMiddleware(), createIncomingMaxPacketAmountMiddleware(), createIncomingRateLimitMiddleware({}), @@ -89,6 +89,10 @@ export async function createConnectorService({ // Local pay createBalanceMiddleware(), + // Partial payment decision (publishes webhook + polls) should happen + // after `createBalanceMiddleware` so we don't start decision flow before checking liquidity + createPartialPaymentDecisionMiddleware(), + // Outgoing Rules createStreamController(), createOutgoingThroughputMiddleware(), diff --git a/packages/backend/src/payment-method/ilp/service.test.ts b/packages/backend/src/payment-method/ilp/service.test.ts index 26c446490e..b41f79933e 100644 --- a/packages/backend/src/payment-method/ilp/service.test.ts +++ b/packages/backend/src/payment-method/ilp/service.test.ts @@ -765,7 +765,8 @@ describe('IlpPaymentService', (): void => { describe('pay', (): void => { function mockIlpPay( overrideQuote: Partial, - error?: Pay.PaymentError + error?: Pay.PaymentError, + applicationData?: Buffer ): jest.SpyInstance< Promise, [options: Pay.PayOptions] @@ -778,6 +779,7 @@ describe('IlpPaymentService', (): void => { quote: { ...opts.quote, ...overrideQuote } }) if (error) res.error = error + if (applicationData) res.applicationData = applicationData return res }) } @@ -1081,6 +1083,115 @@ describe('IlpPaymentService', (): void => { expect((error as PaymentMethodHandlerError).retryable).toBe(false) } }) + + test('uses applicationData for partial payment application error description', async (): Promise => { + const { receiver, outgoingPayment } = + await createOutgoingPaymentWithReceiver(deps, { + sendingWalletAddress: walletAddressMap['USD'], + receivingWalletAddress: walletAddressMap['USD'], + method: 'ilp', + quoteOptions: { + tenantId, + debitAmount: { + value: 100n, + assetScale: walletAddressMap['USD'].asset.scale, + assetCode: walletAddressMap['USD'].asset.code + } + } + }) + + outgoingPayment.dataToTransmit = 'sample kyc data' + + const rejectReason = 'Rejected by mock ASE for partial payment' + mockIlpPay( + {}, + Pay.PaymentError.ApplicationError, + Buffer.from(rejectReason, 'utf8') + ) + + await expect( + ilpPaymentService.pay({ + receiver, + outgoingPayment, + finalDebitAmount: 50n, + finalReceiveAmount: 50n + }) + ).rejects.toMatchObject({ + message: 'Received error during ILP pay', + description: rejectReason, + retryable: false + }) + }) + + test('keeps generic application error without partial payment context', async (): Promise => { + const { receiver, outgoingPayment } = + await createOutgoingPaymentWithReceiver(deps, { + sendingWalletAddress: walletAddressMap['USD'], + receivingWalletAddress: walletAddressMap['USD'], + method: 'ilp', + quoteOptions: { + tenantId, + debitAmount: { + value: 100n, + assetScale: walletAddressMap['USD'].asset.scale, + assetCode: walletAddressMap['USD'].asset.code + } + } + }) + + mockIlpPay( + {}, + Pay.PaymentError.ApplicationError, + Buffer.from('Rejected by mock ASE', 'utf8') + ) + + await expect( + ilpPaymentService.pay({ + receiver, + outgoingPayment, + finalDebitAmount: 50n, + finalReceiveAmount: 50n + }) + ).rejects.toMatchObject({ + message: 'Received error during ILP pay', + description: Pay.PaymentError.ApplicationError, + retryable: false + }) + }) + + test('keeps generic application error when partial context has empty applicationData', async (): Promise => { + const { receiver, outgoingPayment } = + await createOutgoingPaymentWithReceiver(deps, { + sendingWalletAddress: walletAddressMap['USD'], + receivingWalletAddress: walletAddressMap['USD'], + method: 'ilp', + quoteOptions: { + tenantId, + debitAmount: { + value: 100n, + assetScale: walletAddressMap['USD'].asset.scale, + assetCode: walletAddressMap['USD'].asset.code + } + } + }) + + outgoingPayment.dataToTransmit = 'sample kyc data' + + mockIlpPay({}, Pay.PaymentError.ApplicationError, Buffer.from('', 'utf8')) + + await expect( + ilpPaymentService.pay({ + receiver, + outgoingPayment, + finalDebitAmount: 50n, + finalReceiveAmount: 50n + }) + ).rejects.toMatchObject({ + message: 'Received error during ILP pay', + description: Pay.PaymentError.ApplicationError, + retryable: false + }) + }) }) describe('calculateMinSendAmount', (): void => { diff --git a/packages/backend/src/payment-method/ilp/service.ts b/packages/backend/src/payment-method/ilp/service.ts index d1d37eff94..a5dc679359 100644 --- a/packages/backend/src/payment-method/ilp/service.ts +++ b/packages/backend/src/payment-method/ilp/service.ts @@ -346,10 +346,35 @@ async function pay( callName: 'Pay:pay' } ) + const errorMessage = 'Received error during ILP pay' try { - const receipt = await Pay.pay({ plugin, destination, quote }) + const dataToTransmit = outgoingPayment.getDataToTransmit( + deps.config.dbEncryptionSecret + ) + const hasAdditionalData = !!dataToTransmit + + const receipt = await Pay.pay({ + plugin, + destination, + quote, + appData: dataToTransmit ? Buffer.from(dataToTransmit, 'utf8') : undefined + }) if (receipt.error) { + if ( + hasAdditionalData && + receipt.error === Pay.PaymentError.ApplicationError + ) { + const finalDeclineReason = receipt.applicationData + ?.toString('utf8') + .trim() + if (finalDeclineReason) { + throw new PaymentMethodHandlerError(errorMessage, { + description: finalDeclineReason, + retryable: canRetryError(receipt.error) + }) + } + } throw receipt.error } @@ -366,14 +391,23 @@ async function pay( ) return receipt.amountDelivered } catch (err) { - const errorMessage = 'Received error during ILP pay' + let errorDescription = 'Unknown error' + if (err instanceof PaymentMethodHandlerError) { + errorDescription = err.description + } else if (Pay.isPaymentError(err)) { + errorDescription = err + } deps.logger.error( - { err, destination: destination.destinationAddress }, + { err, destination: destination.destinationAddress, errorDescription }, errorMessage ) + if (err instanceof PaymentMethodHandlerError) { + throw err + } + throw new PaymentMethodHandlerError(errorMessage, { - description: Pay.isPaymentError(err) ? err : 'Unknown error', + description: errorDescription, retryable: canRetryError(err as Error | Pay.PaymentError) }) } finally { @@ -455,5 +489,6 @@ export const retryableIlpErrors: { [Pay.PaymentError.InsufficientExchangeRate]: true, [Pay.PaymentError.RateProbeFailed]: true, [Pay.PaymentError.IdleTimeout]: true, - [Pay.PaymentError.ClosedByReceiver]: true + [Pay.PaymentError.ClosedByReceiver]: true, + [Pay.PaymentError.ApplicationError]: false } diff --git a/packages/backend/src/shared/utils.test.ts b/packages/backend/src/shared/utils.test.ts index 5b44db3aa4..e95b9f8810 100644 --- a/packages/backend/src/shared/utils.test.ts +++ b/packages/backend/src/shared/utils.test.ts @@ -1,4 +1,4 @@ -import crypto from 'node:crypto' +import crypto, { createDecipheriv } from 'node:crypto' import { IocContract } from '@adonisjs/fold' import { Redis } from 'ioredis' import { faker } from '@faker-js/faker' @@ -12,6 +12,7 @@ import { getTenantFromApiSignature, ensureTrailingSlash, loadRoutesFromDatabase, + encryptDbData, parseClientWalletAddress } from './utils' import { AppServices, AppContext } from '../app' @@ -568,6 +569,25 @@ describe('utils', (): void => { }) }) + test('can encrypt data with symmetric key', async (): Promise => { + const key = crypto.randomBytes(32).toString('base64') + + const plaintext = faker.internet.email() + + const encrypted = JSON.parse(encryptDbData(plaintext, key)) + + const decipher = createDecipheriv( + 'aes-256-gcm', + Uint8Array.from(Buffer.from(key, 'base64')), + encrypted.iv + ) + decipher.setAuthTag(Uint8Array.from(Buffer.from(encrypted.tag, 'base64'))) + let decipherText = decipher.update(encrypted.cipherText, 'base64', 'utf8') + decipherText += decipher.final('utf8') + + expect(decipherText).toEqual(plaintext) + }) + describe('parseClientWalletAddress', (): void => { test('returns walletaddress if client has it', () => { const walletAddress = faker.internet.url() diff --git a/packages/backend/src/shared/utils.ts b/packages/backend/src/shared/utils.ts index 3a97d43650..81258a2dc8 100644 --- a/packages/backend/src/shared/utils.ts +++ b/packages/backend/src/shared/utils.ts @@ -1,11 +1,11 @@ import { validate, version } from 'uuid' import { URL, type URL as URLType } from 'url' -import { createHmac } from 'crypto' +import { createCipheriv, createHmac, randomBytes } from 'crypto' import { canonicalize } from 'json-canonicalize' import { IAppConfig } from '../config/app' import { AppContext, AppServices } from '../app' import { Tenant } from '../tenants/model' - +import { Buffer } from 'node:buffer' import { IocContract } from '@adonisjs/fold' import { TokenInfoClient } from 'token-introspection' export function validateId(id: string): boolean { @@ -247,6 +247,21 @@ export function ensureTrailingSlash(str: string): string { return str } +export function encryptDbData(data: string, key: string): string { + const iv = randomBytes(32).toString('base64') + const cipher = createCipheriv( + 'aes-256-gcm', + Uint8Array.from(Buffer.from(key, 'base64')), + iv + ) + let cipherText = cipher.update(data, 'utf8', 'base64') + cipherText += cipher.final('base64') + + const tag = cipher.getAuthTag() + + return JSON.stringify({ cipherText, tag, iv }) +} + export const loadRoutesFromDatabase = async ( container: IocContract ): Promise => { diff --git a/packages/backend/src/webhook/service.ts b/packages/backend/src/webhook/service.ts index b1a54c2c8d..b300f6e0b6 100644 --- a/packages/backend/src/webhook/service.ts +++ b/packages/backend/src/webhook/service.ts @@ -1,5 +1,5 @@ import axios, { isAxiosError } from 'axios' -import { createHmac } from 'crypto' +import { createDecipheriv, createHmac } from 'crypto' import { canonicalize } from 'json-canonicalize' import { isWebhookWithEvent, Webhook, WebhookWithEvent } from './model' @@ -206,7 +206,11 @@ async function sendWebhook( const body = { id: webhook.event.id, type: webhook.event.type, - data: webhook.event.data + data: + webhook.event.type === 'incoming_payment.partial_payment_received' && + deps.config.dbEncryptionSecret + ? decryptPartialPaymentWebhookData(webhook, deps) + : webhook.event.data } if (deps.config.signatureSecret) { @@ -256,6 +260,47 @@ async function sendWebhook( } } +type EncryptedDbData = { + cipherText: string + tag: string + iv: string +} + +function decryptPartialPaymentWebhookData( + webhook: WebhookWithEvent, + deps: ServiceDependencies +): WebhookEvent['data'] { + if (!deps.config.dbEncryptionSecret) { + throw new Error('Missing dbEncryptionSecret for partial payment webhook') + } + + const rawDataFromSender = webhook.event.data['dataFromSender'] + if (typeof rawDataFromSender !== 'string') { + throw new Error('Missing dataFromSender on partial payment webhook event') + } + + try { + const { tag, cipherText, iv } = JSON.parse( + rawDataFromSender + ) as EncryptedDbData + const decipher = createDecipheriv( + 'aes-256-gcm', + Uint8Array.from(Buffer.from(deps.config.dbEncryptionSecret, 'base64')), + iv + ) + decipher.setAuthTag(Uint8Array.from(Buffer.from(tag, 'base64'))) + let decrypted = decipher.update(cipherText, 'base64', 'utf8') + decrypted += decipher.final('utf8') + + return { + ...webhook.event.data, + dataFromSender: decrypted + } + } catch { + throw new Error('dataFromSender is not valid encrypted payload') + } +} + export type EventPayload = Pick export function generateWebhookSignature( diff --git a/packages/card-service/src/graphql/generated/graphql.ts b/packages/card-service/src/graphql/generated/graphql.ts index a383331c62..46500183ff 100644 --- a/packages/card-service/src/graphql/generated/graphql.ts +++ b/packages/card-service/src/graphql/generated/graphql.ts @@ -216,6 +216,16 @@ export type CompleteReceiverResponse = { receiver?: Maybe; }; +export type ConfirmPartialIncomingPaymentInput = { + incomingPaymentId: Scalars['ID']['input']; + partialIncomingPaymentId: Scalars['ID']['input']; +}; + +export type ConfirmPartialIncomingPaymentResponse = { + __typename?: 'ConfirmPartialIncomingPaymentResponse'; + success: Scalars['Boolean']['output']; +}; + export type CreateAssetInput = { /** Should be an ISO 4217 currency code whenever possible, e.g. `USD`. For more information, refer to [assets](https://rafiki.dev/overview/concepts/accounting/#assets). */ code: Scalars['String']['input']; @@ -530,6 +540,8 @@ export type DepositEventLiquidityInput = { }; export type DepositOutgoingPaymentLiquidityInput = { + /** Data to be encrypted and sent to the receiver. */ + dataToTransmit?: InputMaybe; /** Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency). */ idempotencyKey: Scalars['String']['input']; /** Unique identifier of the outgoing payment to deposit liquidity into. */ @@ -785,6 +797,8 @@ export type Mutation = { cancelOutgoingPayment: OutgoingPaymentResponse; /** Complete an internal or external Open Payments incoming payment. The receiver has a wallet address on either this or another Open Payments resource server. */ completeReceiver: CompleteReceiverResponse; + /** Confirms a partial incoming payment. */ + confirmPartialIncomingPayment: ConfirmPartialIncomingPaymentResponse; /** Create a new asset. */ createAsset: AssetMutationResponse; /** Withdraw asset liquidity. */ @@ -837,6 +851,8 @@ export type Mutation = { depositPeerLiquidity?: Maybe; /** Post liquidity withdrawal. Withdrawals are two-phase commits and are committed via this mutation. */ postLiquidityWithdrawal?: Maybe; + /** Rejects a partial incoming payment. */ + rejectPartialIncomingPayment: RejectPartialIncomingPaymentResponse; /** Revoke a public key associated with a wallet address. Open Payment requests using this key for request signatures will be denied going forward. */ revokeWalletAddressKey?: Maybe; /** Set the fee structure on an asset. */ @@ -883,6 +899,11 @@ export type MutationCompleteReceiverArgs = { }; +export type MutationConfirmPartialIncomingPaymentArgs = { + input: ConfirmPartialIncomingPaymentInput; +}; + + export type MutationCreateAssetArgs = { input: CreateAssetInput; }; @@ -1008,6 +1029,11 @@ export type MutationPostLiquidityWithdrawalArgs = { }; +export type MutationRejectPartialIncomingPaymentArgs = { + input: RejectPartialIncomingPaymentInput; +}; + + export type MutationRevokeWalletAddressKeyArgs = { input: RevokeWalletAddressKeyInput; }; @@ -1506,6 +1532,18 @@ export type Receiver = { walletAddressUrl: Scalars['String']['output']; }; +export type RejectPartialIncomingPaymentInput = { + incomingPaymentId: Scalars['ID']['input']; + partialIncomingPaymentId: Scalars['ID']['input']; + /** Reason why this incoming payment has been canceled. This value will be sent to the sender. */ + reason?: InputMaybe; +}; + +export type RejectPartialIncomingPaymentResponse = { + __typename?: 'RejectPartialIncomingPaymentResponse'; + success: Scalars['Boolean']['output']; +}; + export type RevokeWalletAddressKeyInput = { /** Internal unique identifier of the key to revoke. */ id: Scalars['String']['input']; @@ -2007,6 +2045,8 @@ export type ResolversTypes = { CardPaymentFailureReason: ResolverTypeWrapper>; CompleteReceiverInput: ResolverTypeWrapper>; CompleteReceiverResponse: ResolverTypeWrapper>; + ConfirmPartialIncomingPaymentInput: ResolverTypeWrapper>; + ConfirmPartialIncomingPaymentResponse: ResolverTypeWrapper>; CreateAssetInput: ResolverTypeWrapper>; CreateAssetLiquidityWithdrawalInput: ResolverTypeWrapper>; CreateIncomingPaymentInput: ResolverTypeWrapper>; @@ -2090,6 +2130,8 @@ export type ResolversTypes = { QuoteEdge: ResolverTypeWrapper>; QuoteResponse: ResolverTypeWrapper>; Receiver: ResolverTypeWrapper>; + RejectPartialIncomingPaymentInput: ResolverTypeWrapper>; + RejectPartialIncomingPaymentResponse: ResolverTypeWrapper>; RevokeWalletAddressKeyInput: ResolverTypeWrapper>; RevokeWalletAddressKeyMutationResponse: ResolverTypeWrapper>; SetFeeInput: ResolverTypeWrapper>; @@ -2156,6 +2198,8 @@ export type ResolversParentTypes = { CardDetailsInput: Partial; CompleteReceiverInput: Partial; CompleteReceiverResponse: Partial; + ConfirmPartialIncomingPaymentInput: Partial; + ConfirmPartialIncomingPaymentResponse: Partial; CreateAssetInput: Partial; CreateAssetLiquidityWithdrawalInput: Partial; CreateIncomingPaymentInput: Partial; @@ -2232,6 +2276,8 @@ export type ResolversParentTypes = { QuoteEdge: Partial; QuoteResponse: Partial; Receiver: Partial; + RejectPartialIncomingPaymentInput: Partial; + RejectPartialIncomingPaymentResponse: Partial; RevokeWalletAddressKeyInput: Partial; RevokeWalletAddressKeyMutationResponse: Partial; SetFeeInput: Partial; @@ -2360,6 +2406,11 @@ export type CompleteReceiverResponseResolvers; }; +export type ConfirmPartialIncomingPaymentResponseResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type CreateOrUpdatePeerByUrlMutationResponseResolvers = { peer?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -2501,6 +2552,7 @@ export type MutationResolvers>; cancelOutgoingPayment?: Resolver>; completeReceiver?: Resolver>; + confirmPartialIncomingPayment?: Resolver>; createAsset?: Resolver>; createAssetLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; createIncomingPayment?: Resolver>; @@ -2526,6 +2578,7 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; depositPeerLiquidity?: Resolver, ParentType, ContextType, RequireFields>; postLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; + rejectPartialIncomingPayment?: Resolver>; revokeWalletAddressKey?: Resolver, ParentType, ContextType, RequireFields>; setFee?: Resolver>; triggerWalletAddressEvents?: Resolver>; @@ -2701,6 +2754,11 @@ export type ReceiverResolvers; }; +export type RejectPartialIncomingPaymentResponseResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type RevokeWalletAddressKeyMutationResponseResolvers = { walletAddressKey?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -2872,6 +2930,7 @@ export type Resolvers = { BasePayment?: BasePaymentResolvers; CancelIncomingPaymentResponse?: CancelIncomingPaymentResponseResolvers; CompleteReceiverResponse?: CompleteReceiverResponseResolvers; + ConfirmPartialIncomingPaymentResponse?: ConfirmPartialIncomingPaymentResponseResolvers; CreateOrUpdatePeerByUrlMutationResponse?: CreateOrUpdatePeerByUrlMutationResponseResolvers; CreatePeerMutationResponse?: CreatePeerMutationResponseResolvers; CreateReceiverResponse?: CreateReceiverResponseResolvers; @@ -2912,6 +2971,7 @@ export type Resolvers = { QuoteEdge?: QuoteEdgeResolvers; QuoteResponse?: QuoteResponseResolvers; Receiver?: ReceiverResolvers; + RejectPartialIncomingPaymentResponse?: RejectPartialIncomingPaymentResponseResolvers; RevokeWalletAddressKeyMutationResponse?: RevokeWalletAddressKeyMutationResponseResolvers; SetFeeResponse?: SetFeeResponseResolvers; Tenant?: TenantResolvers; diff --git a/packages/documentation/src/content/docs/partials/_backend-variables.mdx b/packages/documentation/src/content/docs/partials/_backend-variables.mdx index 7911d40d79..cf1497caef 100644 --- a/packages/documentation/src/content/docs/partials/_backend-variables.mdx +++ b/packages/documentation/src/content/docs/partials/_backend-variables.mdx @@ -51,6 +51,7 @@ import { LinkOut } from '@interledger/docs-design-system' | `ENABLE_SPSP_PAYMENT_POINTERS` | _undefined_ | `true` | When `true`, the SPSP route is enabled. | | `ENABLE_TELEMETRY` | `config.backend.telemetry.enabled` | `false` | Enables the telemetry service on Rafiki. | | `ENABLE_TELEMETRY_TRACES` | _undefined_ | `false` | N/A | +| `DB_ENCRYPTION_SECRET` | _undefined_ | _undefined_ | Base64-encoded 32-byte secret used to encrypt/decrypt transmitted payment data (`dataToTransmit`) stored on incoming/outgoing payment records and events. | | `EXCHANGE_RATES_LIFETIME` | _undefined_ | `15000` | The time, in milliseconds, the exchange rates you provide via the `EXCHANGE_RATES_URL` are valid. | | `GRAPHQL_IDEMPOTENCY_KEY_LOCK_MS` | _undefined_ | `2000` | The time to live (TTL), in milliseconds, for `idempotencyKey` concurrency lock on GraphQL mutations on the Backend Admin API. | | `GRAPHQL_IDEMPOTENCY_KEY_TTL_MS` | _undefined_ | `86400000` (24 hours) | The time to live (TTL), in milliseconds, for `idempotencyKey` on GraphQL mutations on the Backend Admin API. | diff --git a/packages/frontend/app/generated/graphql.ts b/packages/frontend/app/generated/graphql.ts index a83ab55f1f..dae607236c 100644 --- a/packages/frontend/app/generated/graphql.ts +++ b/packages/frontend/app/generated/graphql.ts @@ -216,6 +216,16 @@ export type CompleteReceiverResponse = { receiver?: Maybe; }; +export type ConfirmPartialIncomingPaymentInput = { + incomingPaymentId: Scalars['ID']['input']; + partialIncomingPaymentId: Scalars['ID']['input']; +}; + +export type ConfirmPartialIncomingPaymentResponse = { + __typename?: 'ConfirmPartialIncomingPaymentResponse'; + success: Scalars['Boolean']['output']; +}; + export type CreateAssetInput = { /** Should be an ISO 4217 currency code whenever possible, e.g. `USD`. For more information, refer to [assets](https://rafiki.dev/overview/concepts/accounting/#assets). */ code: Scalars['String']['input']; @@ -530,6 +540,8 @@ export type DepositEventLiquidityInput = { }; export type DepositOutgoingPaymentLiquidityInput = { + /** Data to be encrypted and sent to the receiver. */ + dataToTransmit?: InputMaybe; /** Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency). */ idempotencyKey: Scalars['String']['input']; /** Unique identifier of the outgoing payment to deposit liquidity into. */ @@ -785,6 +797,8 @@ export type Mutation = { cancelOutgoingPayment: OutgoingPaymentResponse; /** Complete an internal or external Open Payments incoming payment. The receiver has a wallet address on either this or another Open Payments resource server. */ completeReceiver: CompleteReceiverResponse; + /** Confirms a partial incoming payment. */ + confirmPartialIncomingPayment: ConfirmPartialIncomingPaymentResponse; /** Create a new asset. */ createAsset: AssetMutationResponse; /** Withdraw asset liquidity. */ @@ -837,6 +851,8 @@ export type Mutation = { depositPeerLiquidity?: Maybe; /** Post liquidity withdrawal. Withdrawals are two-phase commits and are committed via this mutation. */ postLiquidityWithdrawal?: Maybe; + /** Rejects a partial incoming payment. */ + rejectPartialIncomingPayment: RejectPartialIncomingPaymentResponse; /** Revoke a public key associated with a wallet address. Open Payment requests using this key for request signatures will be denied going forward. */ revokeWalletAddressKey?: Maybe; /** Set the fee structure on an asset. */ @@ -883,6 +899,11 @@ export type MutationCompleteReceiverArgs = { }; +export type MutationConfirmPartialIncomingPaymentArgs = { + input: ConfirmPartialIncomingPaymentInput; +}; + + export type MutationCreateAssetArgs = { input: CreateAssetInput; }; @@ -1008,6 +1029,11 @@ export type MutationPostLiquidityWithdrawalArgs = { }; +export type MutationRejectPartialIncomingPaymentArgs = { + input: RejectPartialIncomingPaymentInput; +}; + + export type MutationRevokeWalletAddressKeyArgs = { input: RevokeWalletAddressKeyInput; }; @@ -1506,6 +1532,18 @@ export type Receiver = { walletAddressUrl: Scalars['String']['output']; }; +export type RejectPartialIncomingPaymentInput = { + incomingPaymentId: Scalars['ID']['input']; + partialIncomingPaymentId: Scalars['ID']['input']; + /** Reason why this incoming payment has been canceled. This value will be sent to the sender. */ + reason?: InputMaybe; +}; + +export type RejectPartialIncomingPaymentResponse = { + __typename?: 'RejectPartialIncomingPaymentResponse'; + success: Scalars['Boolean']['output']; +}; + export type RevokeWalletAddressKeyInput = { /** Internal unique identifier of the key to revoke. */ id: Scalars['String']['input']; @@ -2007,6 +2045,8 @@ export type ResolversTypes = { CardPaymentFailureReason: ResolverTypeWrapper>; CompleteReceiverInput: ResolverTypeWrapper>; CompleteReceiverResponse: ResolverTypeWrapper>; + ConfirmPartialIncomingPaymentInput: ResolverTypeWrapper>; + ConfirmPartialIncomingPaymentResponse: ResolverTypeWrapper>; CreateAssetInput: ResolverTypeWrapper>; CreateAssetLiquidityWithdrawalInput: ResolverTypeWrapper>; CreateIncomingPaymentInput: ResolverTypeWrapper>; @@ -2090,6 +2130,8 @@ export type ResolversTypes = { QuoteEdge: ResolverTypeWrapper>; QuoteResponse: ResolverTypeWrapper>; Receiver: ResolverTypeWrapper>; + RejectPartialIncomingPaymentInput: ResolverTypeWrapper>; + RejectPartialIncomingPaymentResponse: ResolverTypeWrapper>; RevokeWalletAddressKeyInput: ResolverTypeWrapper>; RevokeWalletAddressKeyMutationResponse: ResolverTypeWrapper>; SetFeeInput: ResolverTypeWrapper>; @@ -2156,6 +2198,8 @@ export type ResolversParentTypes = { CardDetailsInput: Partial; CompleteReceiverInput: Partial; CompleteReceiverResponse: Partial; + ConfirmPartialIncomingPaymentInput: Partial; + ConfirmPartialIncomingPaymentResponse: Partial; CreateAssetInput: Partial; CreateAssetLiquidityWithdrawalInput: Partial; CreateIncomingPaymentInput: Partial; @@ -2232,6 +2276,8 @@ export type ResolversParentTypes = { QuoteEdge: Partial; QuoteResponse: Partial; Receiver: Partial; + RejectPartialIncomingPaymentInput: Partial; + RejectPartialIncomingPaymentResponse: Partial; RevokeWalletAddressKeyInput: Partial; RevokeWalletAddressKeyMutationResponse: Partial; SetFeeInput: Partial; @@ -2360,6 +2406,11 @@ export type CompleteReceiverResponseResolvers; }; +export type ConfirmPartialIncomingPaymentResponseResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type CreateOrUpdatePeerByUrlMutationResponseResolvers = { peer?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -2501,6 +2552,7 @@ export type MutationResolvers>; cancelOutgoingPayment?: Resolver>; completeReceiver?: Resolver>; + confirmPartialIncomingPayment?: Resolver>; createAsset?: Resolver>; createAssetLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; createIncomingPayment?: Resolver>; @@ -2526,6 +2578,7 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; depositPeerLiquidity?: Resolver, ParentType, ContextType, RequireFields>; postLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; + rejectPartialIncomingPayment?: Resolver>; revokeWalletAddressKey?: Resolver, ParentType, ContextType, RequireFields>; setFee?: Resolver>; triggerWalletAddressEvents?: Resolver>; @@ -2701,6 +2754,11 @@ export type ReceiverResolvers; }; +export type RejectPartialIncomingPaymentResponseResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type RevokeWalletAddressKeyMutationResponseResolvers = { walletAddressKey?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -2872,6 +2930,7 @@ export type Resolvers = { BasePayment?: BasePaymentResolvers; CancelIncomingPaymentResponse?: CancelIncomingPaymentResponseResolvers; CompleteReceiverResponse?: CompleteReceiverResponseResolvers; + ConfirmPartialIncomingPaymentResponse?: ConfirmPartialIncomingPaymentResponseResolvers; CreateOrUpdatePeerByUrlMutationResponse?: CreateOrUpdatePeerByUrlMutationResponseResolvers; CreatePeerMutationResponse?: CreatePeerMutationResponseResolvers; CreateReceiverResponse?: CreateReceiverResponseResolvers; @@ -2912,6 +2971,7 @@ export type Resolvers = { QuoteEdge?: QuoteEdgeResolvers; QuoteResponse?: QuoteResponseResolvers; Receiver?: ReceiverResolvers; + RejectPartialIncomingPaymentResponse?: RejectPartialIncomingPaymentResponseResolvers; RevokeWalletAddressKeyMutationResponse?: RevokeWalletAddressKeyMutationResponseResolvers; SetFeeResponse?: SetFeeResponseResolvers; Tenant?: TenantResolvers; diff --git a/packages/mock-account-service-lib/src/generated/graphql.ts b/packages/mock-account-service-lib/src/generated/graphql.ts index 1bf67d8422..79646ee839 100644 --- a/packages/mock-account-service-lib/src/generated/graphql.ts +++ b/packages/mock-account-service-lib/src/generated/graphql.ts @@ -216,6 +216,16 @@ export type CompleteReceiverResponse = { receiver?: Maybe; }; +export type ConfirmPartialIncomingPaymentInput = { + incomingPaymentId: Scalars['ID']['input']; + partialIncomingPaymentId: Scalars['ID']['input']; +}; + +export type ConfirmPartialIncomingPaymentResponse = { + __typename?: 'ConfirmPartialIncomingPaymentResponse'; + success: Scalars['Boolean']['output']; +}; + export type CreateAssetInput = { /** Should be an ISO 4217 currency code whenever possible, e.g. `USD`. For more information, refer to [assets](https://rafiki.dev/overview/concepts/accounting/#assets). */ code: Scalars['String']['input']; @@ -530,6 +540,8 @@ export type DepositEventLiquidityInput = { }; export type DepositOutgoingPaymentLiquidityInput = { + /** Data to be encrypted and sent to the receiver. */ + dataToTransmit?: InputMaybe; /** Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency). */ idempotencyKey: Scalars['String']['input']; /** Unique identifier of the outgoing payment to deposit liquidity into. */ @@ -785,6 +797,8 @@ export type Mutation = { cancelOutgoingPayment: OutgoingPaymentResponse; /** Complete an internal or external Open Payments incoming payment. The receiver has a wallet address on either this or another Open Payments resource server. */ completeReceiver: CompleteReceiverResponse; + /** Confirms a partial incoming payment. */ + confirmPartialIncomingPayment: ConfirmPartialIncomingPaymentResponse; /** Create a new asset. */ createAsset: AssetMutationResponse; /** Withdraw asset liquidity. */ @@ -837,6 +851,8 @@ export type Mutation = { depositPeerLiquidity?: Maybe; /** Post liquidity withdrawal. Withdrawals are two-phase commits and are committed via this mutation. */ postLiquidityWithdrawal?: Maybe; + /** Rejects a partial incoming payment. */ + rejectPartialIncomingPayment: RejectPartialIncomingPaymentResponse; /** Revoke a public key associated with a wallet address. Open Payment requests using this key for request signatures will be denied going forward. */ revokeWalletAddressKey?: Maybe; /** Set the fee structure on an asset. */ @@ -883,6 +899,11 @@ export type MutationCompleteReceiverArgs = { }; +export type MutationConfirmPartialIncomingPaymentArgs = { + input: ConfirmPartialIncomingPaymentInput; +}; + + export type MutationCreateAssetArgs = { input: CreateAssetInput; }; @@ -1008,6 +1029,11 @@ export type MutationPostLiquidityWithdrawalArgs = { }; +export type MutationRejectPartialIncomingPaymentArgs = { + input: RejectPartialIncomingPaymentInput; +}; + + export type MutationRevokeWalletAddressKeyArgs = { input: RevokeWalletAddressKeyInput; }; @@ -1506,6 +1532,18 @@ export type Receiver = { walletAddressUrl: Scalars['String']['output']; }; +export type RejectPartialIncomingPaymentInput = { + incomingPaymentId: Scalars['ID']['input']; + partialIncomingPaymentId: Scalars['ID']['input']; + /** Reason why this incoming payment has been canceled. This value will be sent to the sender. */ + reason?: InputMaybe; +}; + +export type RejectPartialIncomingPaymentResponse = { + __typename?: 'RejectPartialIncomingPaymentResponse'; + success: Scalars['Boolean']['output']; +}; + export type RevokeWalletAddressKeyInput = { /** Internal unique identifier of the key to revoke. */ id: Scalars['String']['input']; @@ -2007,6 +2045,8 @@ export type ResolversTypes = { CardPaymentFailureReason: ResolverTypeWrapper>; CompleteReceiverInput: ResolverTypeWrapper>; CompleteReceiverResponse: ResolverTypeWrapper>; + ConfirmPartialIncomingPaymentInput: ResolverTypeWrapper>; + ConfirmPartialIncomingPaymentResponse: ResolverTypeWrapper>; CreateAssetInput: ResolverTypeWrapper>; CreateAssetLiquidityWithdrawalInput: ResolverTypeWrapper>; CreateIncomingPaymentInput: ResolverTypeWrapper>; @@ -2090,6 +2130,8 @@ export type ResolversTypes = { QuoteEdge: ResolverTypeWrapper>; QuoteResponse: ResolverTypeWrapper>; Receiver: ResolverTypeWrapper>; + RejectPartialIncomingPaymentInput: ResolverTypeWrapper>; + RejectPartialIncomingPaymentResponse: ResolverTypeWrapper>; RevokeWalletAddressKeyInput: ResolverTypeWrapper>; RevokeWalletAddressKeyMutationResponse: ResolverTypeWrapper>; SetFeeInput: ResolverTypeWrapper>; @@ -2156,6 +2198,8 @@ export type ResolversParentTypes = { CardDetailsInput: Partial; CompleteReceiverInput: Partial; CompleteReceiverResponse: Partial; + ConfirmPartialIncomingPaymentInput: Partial; + ConfirmPartialIncomingPaymentResponse: Partial; CreateAssetInput: Partial; CreateAssetLiquidityWithdrawalInput: Partial; CreateIncomingPaymentInput: Partial; @@ -2232,6 +2276,8 @@ export type ResolversParentTypes = { QuoteEdge: Partial; QuoteResponse: Partial; Receiver: Partial; + RejectPartialIncomingPaymentInput: Partial; + RejectPartialIncomingPaymentResponse: Partial; RevokeWalletAddressKeyInput: Partial; RevokeWalletAddressKeyMutationResponse: Partial; SetFeeInput: Partial; @@ -2360,6 +2406,11 @@ export type CompleteReceiverResponseResolvers; }; +export type ConfirmPartialIncomingPaymentResponseResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type CreateOrUpdatePeerByUrlMutationResponseResolvers = { peer?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -2501,6 +2552,7 @@ export type MutationResolvers>; cancelOutgoingPayment?: Resolver>; completeReceiver?: Resolver>; + confirmPartialIncomingPayment?: Resolver>; createAsset?: Resolver>; createAssetLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; createIncomingPayment?: Resolver>; @@ -2526,6 +2578,7 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; depositPeerLiquidity?: Resolver, ParentType, ContextType, RequireFields>; postLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; + rejectPartialIncomingPayment?: Resolver>; revokeWalletAddressKey?: Resolver, ParentType, ContextType, RequireFields>; setFee?: Resolver>; triggerWalletAddressEvents?: Resolver>; @@ -2701,6 +2754,11 @@ export type ReceiverResolvers; }; +export type RejectPartialIncomingPaymentResponseResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type RevokeWalletAddressKeyMutationResponseResolvers = { walletAddressKey?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -2872,6 +2930,7 @@ export type Resolvers = { BasePayment?: BasePaymentResolvers; CancelIncomingPaymentResponse?: CancelIncomingPaymentResponseResolvers; CompleteReceiverResponse?: CompleteReceiverResponseResolvers; + ConfirmPartialIncomingPaymentResponse?: ConfirmPartialIncomingPaymentResponseResolvers; CreateOrUpdatePeerByUrlMutationResponse?: CreateOrUpdatePeerByUrlMutationResponseResolvers; CreatePeerMutationResponse?: CreatePeerMutationResponseResolvers; CreateReceiverResponse?: CreateReceiverResponseResolvers; @@ -2912,6 +2971,7 @@ export type Resolvers = { QuoteEdge?: QuoteEdgeResolvers; QuoteResponse?: QuoteResponseResolvers; Receiver?: ReceiverResolvers; + RejectPartialIncomingPaymentResponse?: RejectPartialIncomingPaymentResponseResolvers; RevokeWalletAddressKeyMutationResponse?: RevokeWalletAddressKeyMutationResponseResolvers; SetFeeResponse?: SetFeeResponseResolvers; Tenant?: TenantResolvers; diff --git a/packages/mock-account-service-lib/src/types.ts b/packages/mock-account-service-lib/src/types.ts index a81b20f327..4ef2d65c42 100644 --- a/packages/mock-account-service-lib/src/types.ts +++ b/packages/mock-account-service-lib/src/types.ts @@ -83,6 +83,7 @@ export enum WebhookEventType { IncomingPaymentCreated = 'incoming_payment.created', IncomingPaymentCompleted = 'incoming_payment.completed', IncomingPaymentExpired = 'incoming_payment.expired', + IncomingPaymentPartialPaymentReceived = 'incoming_payment.partial_payment_received', OutgoingPaymentCreated = 'outgoing_payment.created', OutgoingPaymentCompleted = 'outgoing_payment.completed', OutgoingPaymentFailed = 'outgoing_payment.failed', diff --git a/packages/point-of-sale/src/graphql/generated/graphql.ts b/packages/point-of-sale/src/graphql/generated/graphql.ts index 086835e7a1..82494d8412 100644 --- a/packages/point-of-sale/src/graphql/generated/graphql.ts +++ b/packages/point-of-sale/src/graphql/generated/graphql.ts @@ -216,6 +216,16 @@ export type CompleteReceiverResponse = { receiver?: Maybe; }; +export type ConfirmPartialIncomingPaymentInput = { + incomingPaymentId: Scalars['ID']['input']; + partialIncomingPaymentId: Scalars['ID']['input']; +}; + +export type ConfirmPartialIncomingPaymentResponse = { + __typename?: 'ConfirmPartialIncomingPaymentResponse'; + success: Scalars['Boolean']['output']; +}; + export type CreateAssetInput = { /** Should be an ISO 4217 currency code whenever possible, e.g. `USD`. For more information, refer to [assets](https://rafiki.dev/overview/concepts/accounting/#assets). */ code: Scalars['String']['input']; @@ -530,6 +540,8 @@ export type DepositEventLiquidityInput = { }; export type DepositOutgoingPaymentLiquidityInput = { + /** Data to be encrypted and sent to the receiver. */ + dataToTransmit?: InputMaybe; /** Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency). */ idempotencyKey: Scalars['String']['input']; /** Unique identifier of the outgoing payment to deposit liquidity into. */ @@ -785,6 +797,8 @@ export type Mutation = { cancelOutgoingPayment: OutgoingPaymentResponse; /** Complete an internal or external Open Payments incoming payment. The receiver has a wallet address on either this or another Open Payments resource server. */ completeReceiver: CompleteReceiverResponse; + /** Confirms a partial incoming payment. */ + confirmPartialIncomingPayment: ConfirmPartialIncomingPaymentResponse; /** Create a new asset. */ createAsset: AssetMutationResponse; /** Withdraw asset liquidity. */ @@ -837,6 +851,8 @@ export type Mutation = { depositPeerLiquidity?: Maybe; /** Post liquidity withdrawal. Withdrawals are two-phase commits and are committed via this mutation. */ postLiquidityWithdrawal?: Maybe; + /** Rejects a partial incoming payment. */ + rejectPartialIncomingPayment: RejectPartialIncomingPaymentResponse; /** Revoke a public key associated with a wallet address. Open Payment requests using this key for request signatures will be denied going forward. */ revokeWalletAddressKey?: Maybe; /** Set the fee structure on an asset. */ @@ -883,6 +899,11 @@ export type MutationCompleteReceiverArgs = { }; +export type MutationConfirmPartialIncomingPaymentArgs = { + input: ConfirmPartialIncomingPaymentInput; +}; + + export type MutationCreateAssetArgs = { input: CreateAssetInput; }; @@ -1008,6 +1029,11 @@ export type MutationPostLiquidityWithdrawalArgs = { }; +export type MutationRejectPartialIncomingPaymentArgs = { + input: RejectPartialIncomingPaymentInput; +}; + + export type MutationRevokeWalletAddressKeyArgs = { input: RevokeWalletAddressKeyInput; }; @@ -1506,6 +1532,18 @@ export type Receiver = { walletAddressUrl: Scalars['String']['output']; }; +export type RejectPartialIncomingPaymentInput = { + incomingPaymentId: Scalars['ID']['input']; + partialIncomingPaymentId: Scalars['ID']['input']; + /** Reason why this incoming payment has been canceled. This value will be sent to the sender. */ + reason?: InputMaybe; +}; + +export type RejectPartialIncomingPaymentResponse = { + __typename?: 'RejectPartialIncomingPaymentResponse'; + success: Scalars['Boolean']['output']; +}; + export type RevokeWalletAddressKeyInput = { /** Internal unique identifier of the key to revoke. */ id: Scalars['String']['input']; @@ -2007,6 +2045,8 @@ export type ResolversTypes = { CardPaymentFailureReason: ResolverTypeWrapper>; CompleteReceiverInput: ResolverTypeWrapper>; CompleteReceiverResponse: ResolverTypeWrapper>; + ConfirmPartialIncomingPaymentInput: ResolverTypeWrapper>; + ConfirmPartialIncomingPaymentResponse: ResolverTypeWrapper>; CreateAssetInput: ResolverTypeWrapper>; CreateAssetLiquidityWithdrawalInput: ResolverTypeWrapper>; CreateIncomingPaymentInput: ResolverTypeWrapper>; @@ -2090,6 +2130,8 @@ export type ResolversTypes = { QuoteEdge: ResolverTypeWrapper>; QuoteResponse: ResolverTypeWrapper>; Receiver: ResolverTypeWrapper>; + RejectPartialIncomingPaymentInput: ResolverTypeWrapper>; + RejectPartialIncomingPaymentResponse: ResolverTypeWrapper>; RevokeWalletAddressKeyInput: ResolverTypeWrapper>; RevokeWalletAddressKeyMutationResponse: ResolverTypeWrapper>; SetFeeInput: ResolverTypeWrapper>; @@ -2156,6 +2198,8 @@ export type ResolversParentTypes = { CardDetailsInput: Partial; CompleteReceiverInput: Partial; CompleteReceiverResponse: Partial; + ConfirmPartialIncomingPaymentInput: Partial; + ConfirmPartialIncomingPaymentResponse: Partial; CreateAssetInput: Partial; CreateAssetLiquidityWithdrawalInput: Partial; CreateIncomingPaymentInput: Partial; @@ -2232,6 +2276,8 @@ export type ResolversParentTypes = { QuoteEdge: Partial; QuoteResponse: Partial; Receiver: Partial; + RejectPartialIncomingPaymentInput: Partial; + RejectPartialIncomingPaymentResponse: Partial; RevokeWalletAddressKeyInput: Partial; RevokeWalletAddressKeyMutationResponse: Partial; SetFeeInput: Partial; @@ -2360,6 +2406,11 @@ export type CompleteReceiverResponseResolvers; }; +export type ConfirmPartialIncomingPaymentResponseResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type CreateOrUpdatePeerByUrlMutationResponseResolvers = { peer?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -2501,6 +2552,7 @@ export type MutationResolvers>; cancelOutgoingPayment?: Resolver>; completeReceiver?: Resolver>; + confirmPartialIncomingPayment?: Resolver>; createAsset?: Resolver>; createAssetLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; createIncomingPayment?: Resolver>; @@ -2526,6 +2578,7 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; depositPeerLiquidity?: Resolver, ParentType, ContextType, RequireFields>; postLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; + rejectPartialIncomingPayment?: Resolver>; revokeWalletAddressKey?: Resolver, ParentType, ContextType, RequireFields>; setFee?: Resolver>; triggerWalletAddressEvents?: Resolver>; @@ -2701,6 +2754,11 @@ export type ReceiverResolvers; }; +export type RejectPartialIncomingPaymentResponseResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type RevokeWalletAddressKeyMutationResponseResolvers = { walletAddressKey?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -2872,6 +2930,7 @@ export type Resolvers = { BasePayment?: BasePaymentResolvers; CancelIncomingPaymentResponse?: CancelIncomingPaymentResponseResolvers; CompleteReceiverResponse?: CompleteReceiverResponseResolvers; + ConfirmPartialIncomingPaymentResponse?: ConfirmPartialIncomingPaymentResponseResolvers; CreateOrUpdatePeerByUrlMutationResponse?: CreateOrUpdatePeerByUrlMutationResponseResolvers; CreatePeerMutationResponse?: CreatePeerMutationResponseResolvers; CreateReceiverResponse?: CreateReceiverResponseResolvers; @@ -2912,6 +2971,7 @@ export type Resolvers = { QuoteEdge?: QuoteEdgeResolvers; QuoteResponse?: QuoteResponseResolvers; Receiver?: ReceiverResolvers; + RejectPartialIncomingPaymentResponse?: RejectPartialIncomingPaymentResponseResolvers; RevokeWalletAddressKeyMutationResponse?: RevokeWalletAddressKeyMutationResponseResolvers; SetFeeResponse?: SetFeeResponseResolvers; Tenant?: TenantResolvers; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fe01566d2..97e219744f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -353,11 +353,11 @@ importers: specifier: 2.0.2 version: 2.0.2 '@interledger/pay': - specifier: 0.4.0-alpha.9 - version: 0.4.0-alpha.9 + specifier: 0.4.0-alpha.12 + version: 0.4.0-alpha.12 '@interledger/stream-receiver': - specifier: ^0.3.3-alpha.3 - version: 0.3.3-alpha.3 + specifier: 0.3.3-alpha.4 + version: 0.3.3-alpha.4 '@koa/cors': specifier: ^5.0.0 version: 5.0.0 @@ -528,8 +528,8 @@ importers: specifier: ^4.1.0 version: 4.1.0 ilp-protocol-stream: - specifier: ^2.7.2-alpha.2 - version: 2.7.2-alpha.2 + specifier: 2.7.2-alpha.3 + version: 2.7.2-alpha.3 jest-environment-node: specifier: ^29.7.0 version: 29.7.0 @@ -3137,11 +3137,11 @@ packages: '@interledger/openapi@2.0.4': resolution: {integrity: sha512-3V6xCk2dKnpBiS6ppBDtQsItZ4r1r/YO9T93Pag2yMxIIAe0NAwUfq5yWPYJtNTQ8j2br+z/AwlgWwenxKffWg==} - '@interledger/pay@0.4.0-alpha.9': - resolution: {integrity: sha512-ScT+hsAFBjpSy68VncSa6wW+VidgviKQE9W9lyiOBCrXfnrwrTdycEXWOG9ShoAYXpA3/FG/dYO9eImAPO5Pzg==} + '@interledger/pay@0.4.0-alpha.12': + resolution: {integrity: sha512-lINwWoQgrBln5hmPeC1IIkxi/nWwrIYiqkv90nXih9k+5zdZaiNTYE6KYuh71vsgeliHPOmwQLym3LvlsO7F1g==} - '@interledger/stream-receiver@0.3.3-alpha.3': - resolution: {integrity: sha512-4h3zIY9OGT+4BgzIsidt+8u89/+8fXzkptz/cKp4Bs5QBNsM0zecktJ8irp0audPNxvUYVjK6r/IGclvuejn4Q==} + '@interledger/stream-receiver@0.3.3-alpha.4': + resolution: {integrity: sha512-wg/agXfAaSe8AorvIKCA5nMQ8HT8JjODzX4WVfoqOjZ5w0wcwEzuy95YZF/sTPLP5ttDR+NN0MTer/CTZ0p1Jw==} '@ioredis/commands@1.2.0': resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} @@ -3289,6 +3289,7 @@ packages: '@koa/router@12.0.2': resolution: {integrity: sha512-sYcHglGKTxGF+hQ6x67xDfkE9o+NhVlRHBqq6gLywaMc6CojK/5vFZByphdonKinYlMLkEkacm+HEse9HzwgTA==} engines: {node: '>= 12'} + deprecated: Please upgrade to v15 or higher. All reported bugs in this version are fixed in newer releases, dependencies have been updated, and security has been improved. '@ljharb/through@2.3.13': resolution: {integrity: sha512-/gKJun8NNiWGZJkGzI/Ragc53cOdcLNdzjLaIa+GEjguQs0ulsurx8WN0jijdK9yPqDvziX995sMRLyLt1uZMQ==} @@ -4375,9 +4376,6 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} - '@types/debug@4.1.7': - resolution: {integrity: sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==} - '@types/docker-modem@3.0.6': resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==} @@ -4513,9 +4511,6 @@ packages: '@types/mime@3.0.4': resolution: {integrity: sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw==} - '@types/ms@0.7.31': - resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==} - '@types/ms@0.7.34': resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} @@ -6021,6 +6016,7 @@ packages: cuid@2.1.8: resolution: {integrity: sha512-xiEMER6E7TlTPnDxrM4eRiC6TRgjNX9xzEZ5U/Se2YJKr7Mq4pJn/2XEHjl3STcSh96GmkHPcBXLES8M29wyyg==} + deprecated: Cuid and other k-sortable and non-cryptographic ids (Ulid, ObjectId, KSUID, all UUIDs) are all insecure. Use @paralleldrive/cuid2 instead. cytoscape-cose-bilkent@4.1.0: resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} @@ -7343,7 +7339,7 @@ packages: glob@7.1.7: resolution: {integrity: sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} @@ -7787,9 +7783,6 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} - ilp-logger@1.4.4: - resolution: {integrity: sha512-R7F+SH6Aiipuqoq63gtzy6/HVIfcCK1rEmq8bE8NLSufXJPRoXszNs6RpypQi9HJcZvTcIUPFE15bS/HI+T+/A==} - ilp-logger@1.4.5-alpha.2: resolution: {integrity: sha512-WtbscdjUUPVseRkDpRlfb/YUpsq4zfoOz6PlJSkx+aqJot1P5N+YGd4YKW1g9wm6O8muo5e/xBotyJqCQs0g+Q==} @@ -7802,8 +7795,8 @@ packages: ilp-protocol-ildcp@2.2.4-alpha.2: resolution: {integrity: sha512-pMBHAXwTnOA1E9TzJAXxbVxrCpqqcYEPJ5w+9kj/gTr3Lmu8M5U/h0W7bGx/pgfGQ2jHXKdO8IJurojDjfoURA==} - ilp-protocol-stream@2.7.2-alpha.2: - resolution: {integrity: sha512-dr9TgdNpFn0yoU4X7M+l/yHkTH3/hPEtMaF0LhfTHr4m0sfnbGLEREazctmTTj6461WL2OuDneiAcboevmFw+w==} + ilp-protocol-stream@2.7.2-alpha.3: + resolution: {integrity: sha512-4HQFVL9Wfmv6nLB5tSp68LZYcd4KRg9kBMVVVHDiqRfqYVQoyPI7EcOAIh7cA01MVzmMrqhbDzIPu6T2SQV4TA==} immutable@3.8.3: resolution: {integrity: sha512-AUY/VyX0E5XlibOmWt10uabJzam1zlYjwiEgQSDc5+UIkFNaF9WM0JxXKaNMGf+F/ffUF+7kRKXM9A7C0xXqMg==} @@ -8811,6 +8804,7 @@ packages: mathjax-full@3.2.2: resolution: {integrity: sha512-+LfG9Fik+OuI8SLwsiR02IVdjcnRCy5MufYLi0C3TdMT56L/pjB0alMVGgoWJF8pN9Rc7FESycZB9BMNWIid5w==} + deprecated: Version 4 replaces this package with the scoped package @mathjax/src maxmin@3.0.0: resolution: {integrity: sha512-wcahMInmGtg/7c6a75fr21Ch/Ks1Tb+Jtoan5Ft4bAI0ZvJqyOw8kkM7e7p8hDSzY805vmxwHT50KcjGwKyJ0g==} @@ -9394,15 +9388,6 @@ packages: node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} - node-fetch@2.6.7: - resolution: {integrity: sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==} - engines: {node: 4.x || >=6.0.0} - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -14990,24 +14975,26 @@ snapshots: transitivePeerDependencies: - supports-color - '@interledger/pay@0.4.0-alpha.9': + '@interledger/pay@0.4.0-alpha.12': dependencies: abort-controller: 3.0.0 ilp-logger: 1.4.5-alpha.2 ilp-packet: 3.1.4-alpha.2 - ilp-protocol-stream: 2.7.2-alpha.2 + ilp-protocol-stream: 2.7.2-alpha.3 long: 4.0.0 - node-fetch: 2.6.7 + node-fetch: 2.7.0 + oer-utils: 5.1.3-alpha.2 transitivePeerDependencies: - encoding - supports-color - '@interledger/stream-receiver@0.3.3-alpha.3': + '@interledger/stream-receiver@0.3.3-alpha.4': dependencies: '@types/long': 4.0.2 ilp-logger: 1.4.5-alpha.2 ilp-packet: 3.1.4-alpha.2 - ilp-protocol-stream: 2.7.2-alpha.2 + ilp-protocol-ildcp: 2.2.4-alpha.2 + ilp-protocol-stream: 2.7.2-alpha.3 long: 4.0.0 oer-utils: 5.1.3-alpha.2 transitivePeerDependencies: @@ -16059,8 +16046,8 @@ snapshots: '@typescript-eslint/parser': 5.60.1(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.7 - eslint-import-resolver-typescript: 3.5.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.7)(eslint-plugin-import@2.27.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.27.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.7)(eslint-plugin-import@2.27.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.5.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.7)(eslint-plugin-import@2.27.5)(eslint@8.57.1) + eslint-plugin-import: 2.27.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.5.5)(eslint@8.57.1) eslint-plugin-jest: 26.9.0(@typescript-eslint/eslint-plugin@5.60.1(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(jest@29.7.0(@types/node@24.11.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@24.11.0)(typescript@5.4.3)))(typescript@5.8.3) eslint-plugin-jest-dom: 4.0.3(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) @@ -16631,10 +16618,6 @@ snapshots: dependencies: '@types/ms': 0.7.34 - '@types/debug@4.1.7': - dependencies: - '@types/ms': 0.7.31 - '@types/docker-modem@3.0.6': dependencies: '@types/node': 24.11.0 @@ -16793,8 +16776,6 @@ snapshots: '@types/mime@3.0.4': {} - '@types/ms@0.7.31': {} - '@types/ms@0.7.34': {} '@types/nlcst@2.0.3': @@ -18967,12 +18948,6 @@ snapshots: optionalDependencies: supports-color: 10.2.2 - debug@4.4.3(supports-color@7.2.0): - dependencies: - ms: 2.1.3 - optionalDependencies: - supports-color: 7.2.0 - debug@4.4.3(supports-color@9.4.0): dependencies: ms: 2.1.3 @@ -19547,13 +19522,13 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.7)(eslint-plugin-import@2.27.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1): + eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.7)(eslint-plugin-import@2.27.5)(eslint@8.57.1): dependencies: debug: 4.4.3(supports-color@10.2.2) enhanced-resolve: 5.18.0 eslint: 8.57.1 - eslint-module-utils: 2.7.4(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.7)(eslint-plugin-import@2.27.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.27.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.7)(eslint-plugin-import@2.27.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.7.4(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.5.5)(eslint@8.57.1) + eslint-plugin-import: 2.27.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.5.5)(eslint@8.57.1) get-tsconfig: 4.5.0 globby: 13.1.3 is-core-module: 2.13.0 @@ -19565,14 +19540,14 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.7.4(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.7)(eslint-plugin-import@2.27.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.7.4(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.5.5)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 5.60.1(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.7 - eslint-import-resolver-typescript: 3.5.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.7)(eslint-plugin-import@2.27.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.5.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.7)(eslint-plugin-import@2.27.5)(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -19596,7 +19571,7 @@ snapshots: eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-import@2.27.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.7)(eslint-plugin-import@2.27.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-plugin-import@2.27.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.5.5)(eslint@8.57.1): dependencies: array-includes: 3.1.8 array.prototype.flat: 1.3.1 @@ -19605,7 +19580,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.7 - eslint-module-utils: 2.7.4(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.7)(eslint-plugin-import@2.27.5(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.7.4(@typescript-eslint/parser@5.60.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.5.5)(eslint@8.57.1) has: 1.0.3 is-core-module: 2.13.0 is-glob: 4.0.3 @@ -21067,15 +21042,9 @@ snapshots: ignore@7.0.5: {} - ilp-logger@1.4.4: - dependencies: - '@types/debug': 4.1.7 - debug: 4.4.3(supports-color@7.2.0) - supports-color: 7.2.0 - ilp-logger@1.4.5-alpha.2: dependencies: - '@types/debug': 4.1.7 + '@types/debug': 4.1.12 debug: 4.4.3(supports-color@9.4.0) supports-color: 9.4.0 @@ -21096,9 +21065,9 @@ snapshots: transitivePeerDependencies: - supports-color - ilp-protocol-stream@2.7.2-alpha.2: + ilp-protocol-stream@2.7.2-alpha.3: dependencies: - ilp-logger: 1.4.4 + ilp-logger: 1.4.5-alpha.2 ilp-packet: 3.1.4-alpha.2 ilp-protocol-ildcp: 2.2.4-alpha.2 long: 4.0.0 @@ -23410,10 +23379,6 @@ snapshots: node-fetch-native@1.6.7: {} - node-fetch@2.6.7: - dependencies: - whatwg-url: 5.0.0 - node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 diff --git a/test/test-lib/src/generated/graphql.ts b/test/test-lib/src/generated/graphql.ts index 1bf67d8422..79646ee839 100644 --- a/test/test-lib/src/generated/graphql.ts +++ b/test/test-lib/src/generated/graphql.ts @@ -216,6 +216,16 @@ export type CompleteReceiverResponse = { receiver?: Maybe; }; +export type ConfirmPartialIncomingPaymentInput = { + incomingPaymentId: Scalars['ID']['input']; + partialIncomingPaymentId: Scalars['ID']['input']; +}; + +export type ConfirmPartialIncomingPaymentResponse = { + __typename?: 'ConfirmPartialIncomingPaymentResponse'; + success: Scalars['Boolean']['output']; +}; + export type CreateAssetInput = { /** Should be an ISO 4217 currency code whenever possible, e.g. `USD`. For more information, refer to [assets](https://rafiki.dev/overview/concepts/accounting/#assets). */ code: Scalars['String']['input']; @@ -530,6 +540,8 @@ export type DepositEventLiquidityInput = { }; export type DepositOutgoingPaymentLiquidityInput = { + /** Data to be encrypted and sent to the receiver. */ + dataToTransmit?: InputMaybe; /** Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency). */ idempotencyKey: Scalars['String']['input']; /** Unique identifier of the outgoing payment to deposit liquidity into. */ @@ -785,6 +797,8 @@ export type Mutation = { cancelOutgoingPayment: OutgoingPaymentResponse; /** Complete an internal or external Open Payments incoming payment. The receiver has a wallet address on either this or another Open Payments resource server. */ completeReceiver: CompleteReceiverResponse; + /** Confirms a partial incoming payment. */ + confirmPartialIncomingPayment: ConfirmPartialIncomingPaymentResponse; /** Create a new asset. */ createAsset: AssetMutationResponse; /** Withdraw asset liquidity. */ @@ -837,6 +851,8 @@ export type Mutation = { depositPeerLiquidity?: Maybe; /** Post liquidity withdrawal. Withdrawals are two-phase commits and are committed via this mutation. */ postLiquidityWithdrawal?: Maybe; + /** Rejects a partial incoming payment. */ + rejectPartialIncomingPayment: RejectPartialIncomingPaymentResponse; /** Revoke a public key associated with a wallet address. Open Payment requests using this key for request signatures will be denied going forward. */ revokeWalletAddressKey?: Maybe; /** Set the fee structure on an asset. */ @@ -883,6 +899,11 @@ export type MutationCompleteReceiverArgs = { }; +export type MutationConfirmPartialIncomingPaymentArgs = { + input: ConfirmPartialIncomingPaymentInput; +}; + + export type MutationCreateAssetArgs = { input: CreateAssetInput; }; @@ -1008,6 +1029,11 @@ export type MutationPostLiquidityWithdrawalArgs = { }; +export type MutationRejectPartialIncomingPaymentArgs = { + input: RejectPartialIncomingPaymentInput; +}; + + export type MutationRevokeWalletAddressKeyArgs = { input: RevokeWalletAddressKeyInput; }; @@ -1506,6 +1532,18 @@ export type Receiver = { walletAddressUrl: Scalars['String']['output']; }; +export type RejectPartialIncomingPaymentInput = { + incomingPaymentId: Scalars['ID']['input']; + partialIncomingPaymentId: Scalars['ID']['input']; + /** Reason why this incoming payment has been canceled. This value will be sent to the sender. */ + reason?: InputMaybe; +}; + +export type RejectPartialIncomingPaymentResponse = { + __typename?: 'RejectPartialIncomingPaymentResponse'; + success: Scalars['Boolean']['output']; +}; + export type RevokeWalletAddressKeyInput = { /** Internal unique identifier of the key to revoke. */ id: Scalars['String']['input']; @@ -2007,6 +2045,8 @@ export type ResolversTypes = { CardPaymentFailureReason: ResolverTypeWrapper>; CompleteReceiverInput: ResolverTypeWrapper>; CompleteReceiverResponse: ResolverTypeWrapper>; + ConfirmPartialIncomingPaymentInput: ResolverTypeWrapper>; + ConfirmPartialIncomingPaymentResponse: ResolverTypeWrapper>; CreateAssetInput: ResolverTypeWrapper>; CreateAssetLiquidityWithdrawalInput: ResolverTypeWrapper>; CreateIncomingPaymentInput: ResolverTypeWrapper>; @@ -2090,6 +2130,8 @@ export type ResolversTypes = { QuoteEdge: ResolverTypeWrapper>; QuoteResponse: ResolverTypeWrapper>; Receiver: ResolverTypeWrapper>; + RejectPartialIncomingPaymentInput: ResolverTypeWrapper>; + RejectPartialIncomingPaymentResponse: ResolverTypeWrapper>; RevokeWalletAddressKeyInput: ResolverTypeWrapper>; RevokeWalletAddressKeyMutationResponse: ResolverTypeWrapper>; SetFeeInput: ResolverTypeWrapper>; @@ -2156,6 +2198,8 @@ export type ResolversParentTypes = { CardDetailsInput: Partial; CompleteReceiverInput: Partial; CompleteReceiverResponse: Partial; + ConfirmPartialIncomingPaymentInput: Partial; + ConfirmPartialIncomingPaymentResponse: Partial; CreateAssetInput: Partial; CreateAssetLiquidityWithdrawalInput: Partial; CreateIncomingPaymentInput: Partial; @@ -2232,6 +2276,8 @@ export type ResolversParentTypes = { QuoteEdge: Partial; QuoteResponse: Partial; Receiver: Partial; + RejectPartialIncomingPaymentInput: Partial; + RejectPartialIncomingPaymentResponse: Partial; RevokeWalletAddressKeyInput: Partial; RevokeWalletAddressKeyMutationResponse: Partial; SetFeeInput: Partial; @@ -2360,6 +2406,11 @@ export type CompleteReceiverResponseResolvers; }; +export type ConfirmPartialIncomingPaymentResponseResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type CreateOrUpdatePeerByUrlMutationResponseResolvers = { peer?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -2501,6 +2552,7 @@ export type MutationResolvers>; cancelOutgoingPayment?: Resolver>; completeReceiver?: Resolver>; + confirmPartialIncomingPayment?: Resolver>; createAsset?: Resolver>; createAssetLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; createIncomingPayment?: Resolver>; @@ -2526,6 +2578,7 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; depositPeerLiquidity?: Resolver, ParentType, ContextType, RequireFields>; postLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; + rejectPartialIncomingPayment?: Resolver>; revokeWalletAddressKey?: Resolver, ParentType, ContextType, RequireFields>; setFee?: Resolver>; triggerWalletAddressEvents?: Resolver>; @@ -2701,6 +2754,11 @@ export type ReceiverResolvers; }; +export type RejectPartialIncomingPaymentResponseResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type RevokeWalletAddressKeyMutationResponseResolvers = { walletAddressKey?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -2872,6 +2930,7 @@ export type Resolvers = { BasePayment?: BasePaymentResolvers; CancelIncomingPaymentResponse?: CancelIncomingPaymentResponseResolvers; CompleteReceiverResponse?: CompleteReceiverResponseResolvers; + ConfirmPartialIncomingPaymentResponse?: ConfirmPartialIncomingPaymentResponseResolvers; CreateOrUpdatePeerByUrlMutationResponse?: CreateOrUpdatePeerByUrlMutationResponseResolvers; CreatePeerMutationResponse?: CreatePeerMutationResponseResolvers; CreateReceiverResponse?: CreateReceiverResponseResolvers; @@ -2912,6 +2971,7 @@ export type Resolvers = { QuoteEdge?: QuoteEdgeResolvers; QuoteResponse?: QuoteResponseResolvers; Receiver?: ReceiverResolvers; + RejectPartialIncomingPaymentResponse?: RejectPartialIncomingPaymentResponseResolvers; RevokeWalletAddressKeyMutationResponse?: RevokeWalletAddressKeyMutationResponseResolvers; SetFeeResponse?: SetFeeResponseResolvers; Tenant?: TenantResolvers;