diff --git a/localenv/cloud-nine-wallet/seed.yml b/localenv/cloud-nine-wallet/seed.yml index 7db6a9286e..dd22a8ee9e 100644 --- a/localenv/cloud-nine-wallet/seed.yml +++ b/localenv/cloud-nine-wallet/seed.yml @@ -7,16 +7,20 @@ assets: - code: USD scale: 2 liquidity: 1000000 + liquidityThreshold: 100000 - code: EUR scale: 2 liquidity: 1000000 + liquidityThreshold: 100000 - code: MXN scale: 2 liquidity: 1000000 + liquidityThreshold: 100000 peers: - initialLiquidity: '100000' peerUrl: http://happy-life-bank-backend:3002 peerIlpAddress: test.happy-life-bank + liquidityThreshold: 10000 accounts: - name: 'Grace Franklin' path: accounts/gfranklin diff --git a/localenv/happy-life-bank/seed.yml b/localenv/happy-life-bank/seed.yml index 013f26c0dc..1ea839c871 100644 --- a/localenv/happy-life-bank/seed.yml +++ b/localenv/happy-life-bank/seed.yml @@ -7,16 +7,20 @@ assets: - code: USD scale: 2 liquidity: 1000000 + liquidityThreshold: 100000 - code: EUR scale: 2 liquidity: 1000000 + liquidityThreshold: 100000 - code: MXN scale: 2 liquidity: 1000000 + liquidityThreshold: 100000 peers: - initialLiquidity: '1000000000000' peerUrl: http://cloud-nine-wallet-backend:3002 peerIlpAddress: test.cloud-nine-wallet + liquidityThreshold: 100000 accounts: - name: 'Philip Fry' path: accounts/pfry diff --git a/localenv/mock-account-servicing-entity/app/lib/parse_config.server.ts b/localenv/mock-account-servicing-entity/app/lib/parse_config.server.ts index be59e47e57..890d951a0e 100644 --- a/localenv/mock-account-servicing-entity/app/lib/parse_config.server.ts +++ b/localenv/mock-account-servicing-entity/app/lib/parse_config.server.ts @@ -14,6 +14,7 @@ export interface Asset { code: string scale: number liquidity: number + liquidityThreshold: number } export interface Fees { @@ -22,6 +23,7 @@ export interface Fees { } export interface Peering { + liquidityThreshold: number peerUrl: string peerIlpAddress: string initialLiquidity: string diff --git a/localenv/mock-account-servicing-entity/app/lib/requesters.ts b/localenv/mock-account-servicing-entity/app/lib/requesters.ts index c84c21d27b..1cf99a7757 100644 --- a/localenv/mock-account-servicing-entity/app/lib/requesters.ts +++ b/localenv/mock-account-servicing-entity/app/lib/requesters.ts @@ -27,7 +27,8 @@ export interface GraphqlResponseElement { export async function createAsset( code: string, - scale: number + scale: number, + liquidityThreshold: number ): Promise { const createAssetMutation = gql` mutation CreateAsset($input: CreateAssetInput!) { @@ -39,6 +40,7 @@ export async function createAsset( id code scale + liquidityThreshold } } } @@ -46,7 +48,8 @@ export async function createAsset( const createAssetInput = { input: { code, - scale + scale, + liquidityThreshold } } return apolloClient @@ -67,7 +70,8 @@ export async function createPeer( outgoingEndpoint: string, assetId: string, assetCode: string, - name: string + name: string, + liquidityThreshold: number ): Promise { const createPeerMutation = gql` mutation CreatePeer($input: CreatePeerInput!) { @@ -79,6 +83,7 @@ export async function createPeer( id staticIlpAddress name + liquidityThreshold } } } @@ -91,7 +96,8 @@ export async function createPeer( outgoing: { endpoint: outgoingEndpoint, authToken: `test-${assetCode}` } }, assetId, - name + name, + liquidityThreshold } } return apolloClient diff --git a/localenv/mock-account-servicing-entity/app/lib/run_seed.server.ts b/localenv/mock-account-servicing-entity/app/lib/run_seed.server.ts index c679c904ad..6a1d27bc26 100644 --- a/localenv/mock-account-servicing-entity/app/lib/run_seed.server.ts +++ b/localenv/mock-account-servicing-entity/app/lib/run_seed.server.ts @@ -19,8 +19,9 @@ import { Asset } from 'generated/graphql' export async function setupFromSeed(config: Config): Promise { const assets: Record = {} - for (const { code, scale, liquidity } of config.seed.assets) { - const { asset } = await createAsset(code, scale) + for (const { code, scale, liquidity, liquidityThreshold } of config.seed + .assets) { + const { asset } = await createAsset(code, scale, liquidityThreshold) if (!asset) { throw new Error('asset not defined') } @@ -39,7 +40,8 @@ export async function setupFromSeed(config: Config): Promise { peer.peerUrl, asset.id, asset.code, - peer.name + peer.name, + peer.liquidityThreshold ).then((response) => response.peer) if (!peerResponse) { throw new Error('peer response not defined') 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 21dc2ebb0f..d4d87f6b88 100644 --- a/localenv/mock-account-servicing-entity/app/lib/webhooks.server.ts +++ b/localenv/mock-account-servicing-entity/app/lib/webhooks.server.ts @@ -4,7 +4,11 @@ import type { Amount } from './transactions.server' import { mockAccounts } from './accounts.server' import { apolloClient } from './apolloClient' import { v4 as uuid } from 'uuid' -import { createPaymentPointer } from './requesters' +import { + addAssetLiquidity, + addPeerLiquidity, + createPaymentPointer +} from './requesters' import { CONFIG } from './parse_config.server' export enum EventType { @@ -14,7 +18,9 @@ export enum EventType { OutgoingPaymentCreated = 'outgoing_payment.created', OutgoingPaymentCompleted = 'outgoing_payment.completed', OutgoingPaymentFailed = 'outgoing_payment.failed', - PaymentPointerNotFound = 'payment_pointer.not_found' + PaymentPointerNotFound = 'payment_pointer.not_found', + LiquidityAsset = 'asset.liquidity_low', + LiquidityPeer = 'peer.liquidity_low' } export interface WebHook { @@ -194,3 +200,17 @@ export async function handlePaymentPointerNotFound(wh: WebHook) { paymentPointer.url ) } + +export async function handleLowLiquidity(wh: WebHook) { + const id = wh.data['id'] as string | undefined + + if (!id) { + throw new Error('id not found') + } + + if (wh.type == 'asset.liquidity_low') { + await addAssetLiquidity(id, 1000000, uuid()) + } else { + await addPeerLiquidity(id, '1000000', uuid()) + } +} diff --git a/localenv/mock-account-servicing-entity/app/routes/webhooks.ts b/localenv/mock-account-servicing-entity/app/routes/webhooks.ts index b9880dc1c0..e6b475c238 100644 --- a/localenv/mock-account-servicing-entity/app/routes/webhooks.ts +++ b/localenv/mock-account-servicing-entity/app/routes/webhooks.ts @@ -1,6 +1,10 @@ import type { ActionArgs } from '@remix-run/node' import { json } from '@remix-run/node' -import { handlePaymentPointerNotFound, WebHook } from '~/lib/webhooks.server' +import { + handleLowLiquidity, + handlePaymentPointerNotFound, + WebHook +} from '~/lib/webhooks.server' import { handleOutgoingPaymentCreated, handleOutgoingPaymentCompletedFailed, @@ -34,6 +38,10 @@ export async function action({ request }: ActionArgs) { case EventType.PaymentPointerNotFound: await handlePaymentPointerNotFound(wh) break + case EventType.LiquidityAsset: + case EventType.LiquidityPeer: + await handleLowLiquidity(wh) + break default: console.log(`unknown event type: ${wh.type}`) return json(undefined, { status: 400 }) diff --git a/localenv/mock-account-servicing-entity/generated/graphql.ts b/localenv/mock-account-servicing-entity/generated/graphql.ts index 83da044bf6..0c5bb65ceb 100644 --- a/localenv/mock-account-servicing-entity/generated/graphql.ts +++ b/localenv/mock-account-servicing-entity/generated/graphql.ts @@ -72,6 +72,8 @@ export type Asset = Model & { id: Scalars['ID']['output']; /** Available liquidity */ liquidity?: Maybe; + /** Account Servicing Entity will be notified via a webhook event if liquidity falls below this value */ + liquidityThreshold?: Maybe; /** The receiving fee structure for the asset */ receivingFee?: Maybe; /** Difference in orders of magnitude between the standard unit of an asset and a corresponding fractional unit */ @@ -114,6 +116,8 @@ export type CreateAssetInput = { code: Scalars['String']['input']; /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ idempotencyKey?: InputMaybe; + /** Account Servicing Entity will be notified via a webhook event if liquidity falls below this value */ + liquidityThreshold?: InputMaybe; /** Difference in orders of magnitude between the standard unit of an asset and a corresponding fractional unit */ scale: Scalars['UInt8']['input']; /** Minimum amount of liquidity that can be withdrawn from the asset */ @@ -206,6 +210,8 @@ export type CreatePeerInput = { http: HttpInput; /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ idempotencyKey?: InputMaybe; + /** Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this value */ + liquidityThreshold?: InputMaybe; /** Maximum packet amount that the peer accepts */ maxPacketAmount?: InputMaybe; /** Peer's internal name */ @@ -508,8 +514,8 @@ export type Mutation = { setFee: SetFeeResponse; /** If automatic withdrawal of funds received via Web Monetization by the payment pointer are disabled, this mutation can be used to trigger up to n withdrawal events. */ triggerPaymentPointerEvents: TriggerPaymentPointerEventsMutationResponse; - /** Update an asset's withdrawal threshold. The withdrawal threshold indicates the MINIMUM amount that can be withdrawn. */ - updateAssetWithdrawalThreshold: AssetMutationResponse; + /** Update an asset */ + updateAsset: AssetMutationResponse; /** Update a payment pointer */ updatePaymentPointer: UpdatePaymentPointerMutationResponse; /** Update a peer */ @@ -616,7 +622,7 @@ export type MutationTriggerPaymentPointerEventsArgs = { }; -export type MutationUpdateAssetWithdrawalThresholdArgs = { +export type MutationUpdateAssetArgs = { input: UpdateAssetInput; }; @@ -863,6 +869,8 @@ export type Peer = Model & { id: Scalars['ID']['output']; /** Available liquidity */ liquidity?: Maybe; + /** Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this value */ + liquidityThreshold?: Maybe; /** Maximum packet amount that the peer accepts */ maxPacketAmount?: Maybe; /** Peer's public name */ @@ -1118,6 +1126,8 @@ export type UpdateAssetInput = { id: Scalars['String']['input']; /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ idempotencyKey?: InputMaybe; + /** Account Servicing Entity will be notified via a webhook event if liquidity falls below this new value */ + liquidityThreshold?: InputMaybe; /** New minimum amount of liquidity that can be withdrawn from the asset */ withdrawalThreshold?: InputMaybe; }; @@ -1148,6 +1158,8 @@ export type UpdatePeerInput = { id: Scalars['String']['input']; /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ idempotencyKey?: InputMaybe; + /** Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this new value */ + liquidityThreshold?: InputMaybe; /** New maximum packet amount that the peer accepts */ maxPacketAmount?: InputMaybe; /** Peer's new public name */ @@ -1502,6 +1514,7 @@ export type AssetResolvers; id?: Resolver; liquidity?: Resolver, ParentType, ContextType>; + liquidityThreshold?: Resolver, ParentType, ContextType>; receivingFee?: Resolver, ParentType, ContextType>; scale?: Resolver; sendingFee?: Resolver, ParentType, ContextType>; @@ -1676,7 +1689,7 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; setFee?: Resolver>; triggerPaymentPointerEvents?: Resolver>; - updateAssetWithdrawalThreshold?: Resolver>; + updateAsset?: Resolver>; updatePaymentPointer?: Resolver>; updatePeer?: Resolver>; voidLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; @@ -1812,6 +1825,7 @@ export type PeerResolvers; id?: Resolver; liquidity?: Resolver, ParentType, ContextType>; + liquidityThreshold?: Resolver, ParentType, ContextType>; maxPacketAmount?: Resolver, ParentType, ContextType>; name?: Resolver, ParentType, ContextType>; staticIlpAddress?: Resolver; diff --git a/localenv/mock-account-servicing-entity/seed.example.yml b/localenv/mock-account-servicing-entity/seed.example.yml index 07d5eb3356..d47121fd43 100644 --- a/localenv/mock-account-servicing-entity/seed.example.yml +++ b/localenv/mock-account-servicing-entity/seed.example.yml @@ -2,12 +2,18 @@ self: graphqlUrl: http://localhost:3001/graphql hostname: 'backend' mapHostname: 'primary-mase' +assets: + - code: USD + scale: 2 + liquidity: 1000000 + liquidityThreshold: 100000 peers: - asset: USD scale: 2 initialLiquidity: '100000' peerUrl: http://peer-backend:3002 peerIlpAddress: test.peer + liquidityThreshold: 10000 accounts: - name: 'Grace Franklin' url: http://backend/accounts/gfranklin diff --git a/packages/backend/migrations/20230912091917_add_liquidityThreshold_assets.js b/packages/backend/migrations/20230912091917_add_liquidityThreshold_assets.js new file mode 100644 index 0000000000..b0e46cb618 --- /dev/null +++ b/packages/backend/migrations/20230912091917_add_liquidityThreshold_assets.js @@ -0,0 +1,19 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.alterTable('assets', (table) => { + table.bigInteger('liquidityThreshold').nullable() + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('assets', (table) => { + table.dropColumn('liquidityThreshold') + }) +} diff --git a/packages/backend/migrations/20230912091925_add_liquidityThreshold_peers.js b/packages/backend/migrations/20230912091925_add_liquidityThreshold_peers.js new file mode 100644 index 0000000000..62ea06bcfc --- /dev/null +++ b/packages/backend/migrations/20230912091925_add_liquidityThreshold_peers.js @@ -0,0 +1,19 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.alterTable('peers', (table) => { + table.bigInteger('liquidityThreshold').nullable() + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('peers', (table) => { + table.dropColumn('liquidityThreshold') + }) +} diff --git a/packages/backend/src/accounting/psql/service.ts b/packages/backend/src/accounting/psql/service.ts index dfd28af5cd..40f54c7c40 100644 --- a/packages/backend/src/accounting/psql/service.ts +++ b/packages/backend/src/accounting/psql/service.ts @@ -207,6 +207,8 @@ export async function createTransfer( postTransfers: async (transferRefs) => postTransfers(deps, transferRefs), getAccountReceived: async (accountRef) => getAccountTotalReceived(deps, accountRef), + getAccountBalance: async (accountRef) => + getLiquidityAccountBalance(deps, accountRef), createPendingTransfers: async (transfersToCreate) => { const [ sourceAccount, diff --git a/packages/backend/src/accounting/service.ts b/packages/backend/src/accounting/service.ts index 55ddfa12ce..3f50982963 100644 --- a/packages/backend/src/accounting/service.ts +++ b/packages/backend/src/accounting/service.ts @@ -14,8 +14,10 @@ export interface LiquidityAccount { asset: { id: string ledger: number + onDebit?: (options: OnDebitOptions) => Promise } onCredit?: (options: OnCreditOptions) => Promise + onDebit?: (options: OnDebitOptions) => Promise } export interface OnCreditOptions { @@ -23,6 +25,10 @@ export interface OnCreditOptions { withdrawalThrottleDelay?: number } +export interface OnDebitOptions { + balance: bigint +} + export interface Deposit { id: string account: LiquidityAccount @@ -81,6 +87,7 @@ interface CreateAccountToAccountTransferArgs { voidTransfers(transferIds: string[]): Promise postTransfers(transferIds: string[]): Promise getAccountReceived(accountRef: string): Promise + getAccountBalance(accountRef: string): Promise createPendingTransfers( transfers: TransferToCreate[] ): Promise @@ -95,6 +102,7 @@ export async function createAccountToAccountTransfer( postTransfers, createPendingTransfers, getAccountReceived, + getAccountBalance, withdrawalThrottleDelay, transferArgs } = args @@ -131,6 +139,22 @@ export async function createAccountToAccountTransfer( return pendingTransferIdsOrError } + const onDebit = async ( + account: LiquidityAccount | LiquidityAccount['asset'] + ) => { + if (account.onDebit) { + const balance = await getAccountBalance(account.id) + + if (balance === undefined) { + throw new Error() + } + + await account.onDebit({ + balance + }) + } + } + return { post: async (): Promise => { const error = await postTransfers(pendingTransferIdsOrError) @@ -139,6 +163,11 @@ export async function createAccountToAccountTransfer( return error } + await Promise.all([ + onDebit(sourceAccount), + onDebit(destinationAccount.asset) + ]) + if (destinationAccount.onCredit) { const totalReceived = await getAccountReceived(destinationAccount.id) diff --git a/packages/backend/src/accounting/tigerbeetle/service.ts b/packages/backend/src/accounting/tigerbeetle/service.ts index 7518f67551..92e3feb74e 100644 --- a/packages/backend/src/accounting/tigerbeetle/service.ts +++ b/packages/backend/src/accounting/tigerbeetle/service.ts @@ -244,6 +244,8 @@ export async function createTransfer( }, getAccountReceived: async (accountRef) => getAccountTotalReceived(deps, accountRef), + getAccountBalance: async (accountRef) => + getAccountBalance(deps, accountRef), createPendingTransfers: async (transfersToCreate) => { const tbTransfers: NewTransferOptions[] = transfersToCreate.map( (transfer) => ({ diff --git a/packages/backend/src/asset/model.test.ts b/packages/backend/src/asset/model.test.ts new file mode 100644 index 0000000000..2e1dcaeed5 --- /dev/null +++ b/packages/backend/src/asset/model.test.ts @@ -0,0 +1,82 @@ +import { Knex } from 'knex' + +import { AssetService } from './service' +import { Config } from '../config/app' +import { createTestApp, TestContainer } from '../tests/app' +import { IocContract } from '@adonisjs/fold' +import { initIocContainer } from '../' +import { AppServices } from '../app' +import { randomAsset } from '../tests/asset' +import { truncateTables } from '../tests/tableManager' +import { Asset } from './model' +import { isAssetError } from './errors' +import { WebhookEvent } from '../webhook/model' + +describe('Asset Model', (): void => { + let deps: IocContract + let appContainer: TestContainer + let assetService: AssetService + let knex: Knex + + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + assetService = await deps.use('assetService') + knex = await deps.use('knex') + }) + + afterEach(async (): Promise => { + await truncateTables(appContainer.knex) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('onDebit', (): void => { + let asset: Asset + beforeEach(async (): Promise => { + const options = { + ...randomAsset(), + liquidityThreshold: BigInt(100) + } + const assetOrError = await assetService.create(options) + if (!isAssetError(assetOrError)) { + asset = assetOrError + } + }) + test.each` + balance + ${BigInt(50)} + ${BigInt(99)} + ${BigInt(100)} + `( + 'creates webhook event if balance=$balance <= liquidityThreshold', + async ({ balance }): Promise => { + await asset.onDebit({ balance }) + const event = ( + await WebhookEvent.query(knex).where('type', 'asset.liquidity_low') + )[0] + expect(event).toMatchObject({ + type: 'asset.liquidity_low', + data: { + id: asset.id, + asset: { + id: asset.id, + code: asset.code, + scale: asset.scale + }, + liquidityThreshold: asset.liquidityThreshold?.toString(), + balance: balance.toString() + } + }) + } + ) + test('does not create webhook event if balance > liquidityThreshold', async (): Promise => { + await asset.onDebit({ balance: BigInt(110) }) + await expect( + WebhookEvent.query(knex).where('type', 'asset.liquidity_low') + ).resolves.toEqual([]) + }) + }) +}) diff --git a/packages/backend/src/asset/model.ts b/packages/backend/src/asset/model.ts index 403052e99f..d44de0f860 100644 --- a/packages/backend/src/asset/model.ts +++ b/packages/backend/src/asset/model.ts @@ -1,5 +1,6 @@ -import { LiquidityAccount } from '../accounting/service' +import { LiquidityAccount, OnDebitOptions } from '../accounting/service' import { BaseModel } from '../shared/baseModel' +import { WebhookEvent } from '../webhook/model' export class Asset extends BaseModel implements LiquidityAccount { public static get tableName(): string { @@ -14,10 +15,34 @@ export class Asset extends BaseModel implements LiquidityAccount { public readonly withdrawalThreshold!: bigint | null + public readonly liquidityThreshold!: bigint | null + public get asset(): LiquidityAccount['asset'] { return { id: this.id, - ledger: this.ledger + ledger: this.ledger, + onDebit: this.onDebit + } + } + + public async onDebit({ balance }: OnDebitOptions): Promise { + if (this.liquidityThreshold !== null) { + if (balance <= this.liquidityThreshold) { + await WebhookEvent.query().insert({ + type: 'asset.liquidity_low', + data: { + id: this.id, + asset: { + id: this.id, + code: this.code, + scale: this.scale + }, + liquidityThreshold: this.liquidityThreshold, + balance + } + }) + } } + return this } } diff --git a/packages/backend/src/asset/service.test.ts b/packages/backend/src/asset/service.test.ts index 3e92f381ad..f5c7a3e1db 100644 --- a/packages/backend/src/asset/service.test.ts +++ b/packages/backend/src/asset/service.test.ts @@ -36,15 +36,18 @@ describe('Asset Service', (): void => { describe('create', (): void => { test.each` - withdrawalThreshold - ${undefined} - ${BigInt(5)} + withdrawalThreshold | liquidityThreshold + ${undefined} | ${undefined} + ${BigInt(5)} | ${undefined} + ${undefined} | ${BigInt(5)} + ${BigInt(5)} | ${BigInt(5)} `( 'Asset can be created and fetched', - async ({ withdrawalThreshold }): Promise => { + async ({ withdrawalThreshold, liquidityThreshold }): Promise => { const options = { ...randomAsset(), - withdrawalThreshold + withdrawalThreshold, + liquidityThreshold } const asset = await assetService.create(options) assert.ok(!isAssetError(asset)) @@ -52,7 +55,8 @@ describe('Asset Service', (): void => { ...options, id: asset.id, ledger: asset.ledger, - withdrawalThreshold: withdrawalThreshold || null + withdrawalThreshold: withdrawalThreshold || null, + liquidityThreshold: liquidityThreshold || null }) await expect(assetService.get(asset.id)).resolves.toEqual(asset) } @@ -138,19 +142,24 @@ describe('Asset Service', (): void => { describe('update', (): void => { describe.each` - withdrawalThreshold - ${null} - ${BigInt(0)} - ${BigInt(5)} + withdrawalThreshold | liquidityThreshold + ${null} | ${null} + ${BigInt(0)} | ${null} + ${BigInt(5)} | ${null} + ${null} | ${BigInt(0)} + ${null} | ${BigInt(5)} + ${BigInt(0)} | ${BigInt(0)} + ${BigInt(5)} | ${BigInt(5)} `( - "Asset's withdrawal threshold can be updated from $withdrawalThreshold", - ({ withdrawalThreshold }): void => { + 'Asset threshold can be updated from withdrawalThreshold: $withdrawalThreshold, liquidityThreshold: $liquidityThreshold', + ({ withdrawalThreshold, liquidityThreshold }): void => { let assetId: string beforeEach(async (): Promise => { const asset = await assetService.create({ ...randomAsset(), - withdrawalThreshold + withdrawalThreshold, + liquidityThreshold }) assert.ok(!isAssetError(asset)) expect(asset.withdrawalThreshold).toEqual(withdrawalThreshold) @@ -158,19 +167,28 @@ describe('Asset Service', (): void => { }) test.each` - withdrawalThreshold - ${null} - ${BigInt(0)} - ${BigInt(5)} + withdrawalThreshold | liquidityThreshold + ${null} | ${null} + ${BigInt(0)} | ${null} + ${BigInt(5)} | ${null} + ${null} | ${BigInt(0)} + ${null} | ${BigInt(5)} + ${BigInt(0)} | ${BigInt(0)} + ${BigInt(5)} | ${BigInt(5)} `( - 'to $withdrawalThreshold', - async ({ withdrawalThreshold }): Promise => { + 'to withdrawalThreshold: $withdrawalThreshold, liquidityThreshold: $liquidityThreshold', + async ({ + withdrawalThreshold, + liquidityThreshold + }): Promise => { const asset = await assetService.update({ id: assetId, - withdrawalThreshold + withdrawalThreshold, + liquidityThreshold }) assert.ok(!isAssetError(asset)) expect(asset.withdrawalThreshold).toEqual(withdrawalThreshold) + expect(asset.liquidityThreshold).toEqual(liquidityThreshold) await expect(assetService.get(assetId)).resolves.toEqual(asset) } ) @@ -181,7 +199,8 @@ describe('Asset Service', (): void => { await expect( assetService.update({ id: uuid(), - withdrawalThreshold: BigInt(10) + withdrawalThreshold: BigInt(10), + liquidityThreshold: null }) ).resolves.toEqual(AssetError.UnknownAsset) }) diff --git a/packages/backend/src/asset/service.ts b/packages/backend/src/asset/service.ts index 092fa4c1e0..f2ce5a0361 100644 --- a/packages/backend/src/asset/service.ts +++ b/packages/backend/src/asset/service.ts @@ -13,11 +13,13 @@ export interface AssetOptions { export interface CreateOptions extends AssetOptions { withdrawalThreshold?: bigint + liquidityThreshold?: bigint } export interface UpdateOptions { id: string withdrawalThreshold: bigint | null + liquidityThreshold: bigint | null } export interface AssetService { @@ -56,7 +58,7 @@ export async function createAssetService({ async function createAsset( deps: ServiceDependencies, - { code, scale, withdrawalThreshold }: CreateOptions + { code, scale, withdrawalThreshold, liquidityThreshold }: CreateOptions ): Promise { try { // Asset rows include a smallserial 'ledger' column that would have sequence gaps @@ -69,7 +71,8 @@ async function createAsset( const asset = await Asset.query(trx).insertAndFetch({ code, scale, - withdrawalThreshold + withdrawalThreshold, + liquidityThreshold }) await deps.accountingService.createLiquidityAccount( asset, @@ -90,14 +93,14 @@ async function createAsset( async function updateAsset( deps: ServiceDependencies, - { id, withdrawalThreshold }: UpdateOptions + { id, withdrawalThreshold, liquidityThreshold }: UpdateOptions ): Promise { if (!deps.knex) { throw new Error('Knex undefined') } try { return await Asset.query(deps.knex) - .patchAndFetchById(id, { withdrawalThreshold }) + .patchAndFetchById(id, { withdrawalThreshold, liquidityThreshold }) .throwIfNotFound() } catch (err) { if (err instanceof NotFoundError) { diff --git a/packages/backend/src/graphql/generated/graphql.schema.json b/packages/backend/src/graphql/generated/graphql.schema.json index a7e0a799b5..2220fe95f6 100644 --- a/packages/backend/src/graphql/generated/graphql.schema.json +++ b/packages/backend/src/graphql/generated/graphql.schema.json @@ -358,6 +358,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "liquidityThreshold", + "description": "Account Servicing Entity will be notified via a webhook event if liquidity falls below this value", + "args": [], + "type": { + "kind": "SCALAR", + "name": "UInt64", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "receivingFee", "description": "The receiving fee structure for the asset", @@ -724,6 +736,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "liquidityThreshold", + "description": "Account Servicing Entity will be notified via a webhook event if liquidity falls below this value", + "type": { + "kind": "SCALAR", + "name": "UInt64", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "scale", "description": "Difference in orders of magnitude between the standard unit of an asset and a corresponding fractional unit", @@ -1359,6 +1383,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "liquidityThreshold", + "description": "Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this value", + "type": { + "kind": "SCALAR", + "name": "UInt64", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "maxPacketAmount", "description": "Maximum packet amount that the peer accepts", @@ -3765,8 +3801,8 @@ "deprecationReason": null }, { - "name": "updateAssetWithdrawalThreshold", - "description": "Update an asset's withdrawal threshold. The withdrawal threshold indicates the MINIMUM amount that can be withdrawn.", + "name": "updateAsset", + "description": "Update an asset", "args": [ { "name": "input", @@ -5518,6 +5554,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "liquidityThreshold", + "description": "Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this value", + "args": [], + "type": { + "kind": "SCALAR", + "name": "UInt64", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "maxPacketAmount", "description": "Maximum packet amount that the peer accepts", @@ -7241,6 +7289,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "liquidityThreshold", + "description": "Account Servicing Entity will be notified via a webhook event if liquidity falls below this new value", + "type": { + "kind": "SCALAR", + "name": "UInt64", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "withdrawalThreshold", "description": "New minimum amount of liquidity that can be withdrawn from the asset", @@ -7444,6 +7504,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "liquidityThreshold", + "description": "Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this new value", + "type": { + "kind": "SCALAR", + "name": "UInt64", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "maxPacketAmount", "description": "New maximum packet amount that the peer accepts", diff --git a/packages/backend/src/graphql/generated/graphql.ts b/packages/backend/src/graphql/generated/graphql.ts index 83da044bf6..0c5bb65ceb 100644 --- a/packages/backend/src/graphql/generated/graphql.ts +++ b/packages/backend/src/graphql/generated/graphql.ts @@ -72,6 +72,8 @@ export type Asset = Model & { id: Scalars['ID']['output']; /** Available liquidity */ liquidity?: Maybe; + /** Account Servicing Entity will be notified via a webhook event if liquidity falls below this value */ + liquidityThreshold?: Maybe; /** The receiving fee structure for the asset */ receivingFee?: Maybe; /** Difference in orders of magnitude between the standard unit of an asset and a corresponding fractional unit */ @@ -114,6 +116,8 @@ export type CreateAssetInput = { code: Scalars['String']['input']; /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ idempotencyKey?: InputMaybe; + /** Account Servicing Entity will be notified via a webhook event if liquidity falls below this value */ + liquidityThreshold?: InputMaybe; /** Difference in orders of magnitude between the standard unit of an asset and a corresponding fractional unit */ scale: Scalars['UInt8']['input']; /** Minimum amount of liquidity that can be withdrawn from the asset */ @@ -206,6 +210,8 @@ export type CreatePeerInput = { http: HttpInput; /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ idempotencyKey?: InputMaybe; + /** Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this value */ + liquidityThreshold?: InputMaybe; /** Maximum packet amount that the peer accepts */ maxPacketAmount?: InputMaybe; /** Peer's internal name */ @@ -508,8 +514,8 @@ export type Mutation = { setFee: SetFeeResponse; /** If automatic withdrawal of funds received via Web Monetization by the payment pointer are disabled, this mutation can be used to trigger up to n withdrawal events. */ triggerPaymentPointerEvents: TriggerPaymentPointerEventsMutationResponse; - /** Update an asset's withdrawal threshold. The withdrawal threshold indicates the MINIMUM amount that can be withdrawn. */ - updateAssetWithdrawalThreshold: AssetMutationResponse; + /** Update an asset */ + updateAsset: AssetMutationResponse; /** Update a payment pointer */ updatePaymentPointer: UpdatePaymentPointerMutationResponse; /** Update a peer */ @@ -616,7 +622,7 @@ export type MutationTriggerPaymentPointerEventsArgs = { }; -export type MutationUpdateAssetWithdrawalThresholdArgs = { +export type MutationUpdateAssetArgs = { input: UpdateAssetInput; }; @@ -863,6 +869,8 @@ export type Peer = Model & { id: Scalars['ID']['output']; /** Available liquidity */ liquidity?: Maybe; + /** Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this value */ + liquidityThreshold?: Maybe; /** Maximum packet amount that the peer accepts */ maxPacketAmount?: Maybe; /** Peer's public name */ @@ -1118,6 +1126,8 @@ export type UpdateAssetInput = { id: Scalars['String']['input']; /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ idempotencyKey?: InputMaybe; + /** Account Servicing Entity will be notified via a webhook event if liquidity falls below this new value */ + liquidityThreshold?: InputMaybe; /** New minimum amount of liquidity that can be withdrawn from the asset */ withdrawalThreshold?: InputMaybe; }; @@ -1148,6 +1158,8 @@ export type UpdatePeerInput = { id: Scalars['String']['input']; /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ idempotencyKey?: InputMaybe; + /** Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this new value */ + liquidityThreshold?: InputMaybe; /** New maximum packet amount that the peer accepts */ maxPacketAmount?: InputMaybe; /** Peer's new public name */ @@ -1502,6 +1514,7 @@ export type AssetResolvers; id?: Resolver; liquidity?: Resolver, ParentType, ContextType>; + liquidityThreshold?: Resolver, ParentType, ContextType>; receivingFee?: Resolver, ParentType, ContextType>; scale?: Resolver; sendingFee?: Resolver, ParentType, ContextType>; @@ -1676,7 +1689,7 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; setFee?: Resolver>; triggerPaymentPointerEvents?: Resolver>; - updateAssetWithdrawalThreshold?: Resolver>; + updateAsset?: Resolver>; updatePaymentPointer?: Resolver>; updatePeer?: Resolver>; voidLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; @@ -1812,6 +1825,7 @@ export type PeerResolvers; id?: Resolver; liquidity?: Resolver, ParentType, ContextType>; + liquidityThreshold?: Resolver, ParentType, ContextType>; maxPacketAmount?: Resolver, ParentType, ContextType>; name?: Resolver, ParentType, ContextType>; staticIlpAddress?: Resolver; diff --git a/packages/backend/src/graphql/resolvers/asset.test.ts b/packages/backend/src/graphql/resolvers/asset.test.ts index 80f8b12cd4..1f391c7739 100644 --- a/packages/backend/src/graphql/resolvers/asset.test.ts +++ b/packages/backend/src/graphql/resolvers/asset.test.ts @@ -51,19 +51,26 @@ describe('Asset Resolvers', (): void => { describe('Create Asset', (): void => { test.each` - withdrawalThreshold | expectedWithdrawalThreshold - ${undefined} | ${null} - ${BigInt(0)} | ${'0'} - ${BigInt(5)} | ${'5'} + withdrawalThreshold | expectedWithdrawalThreshold | liquidityThreshold | expectedLiquidityThreshold + ${undefined} | ${null} | ${undefined} | ${null} + ${BigInt(0)} | ${'0'} | ${undefined} | ${null} + ${BigInt(5)} | ${'5'} | ${undefined} | ${null} + ${undefined} | ${null} | ${BigInt(0)} | ${'0'} + ${undefined} | ${null} | ${BigInt(5)} | ${'5'} + ${BigInt(0)} | ${'0'} | ${BigInt(0)} | ${'0'} + ${BigInt(5)} | ${'5'} | ${BigInt(5)} | ${'5'} `( - 'Can create an asset (withdrawalThreshold: $withdrawalThreshold)', + 'Can create an asset (withdrawalThreshold: $withdrawalThreshold, liquidityThreshold: $liquidityThreshold)', async ({ withdrawalThreshold, - expectedWithdrawalThreshold + expectedWithdrawalThreshold, + liquidityThreshold, + expectedLiquidityThreshold }): Promise => { const input: CreateAssetInput = { ...randomAsset(), - withdrawalThreshold + withdrawalThreshold, + liquidityThreshold } const response = await appContainer.apolloClient @@ -80,6 +87,7 @@ describe('Asset Resolvers', (): void => { scale liquidity withdrawalThreshold + liquidityThreshold } } } @@ -105,13 +113,15 @@ describe('Asset Resolvers', (): void => { code: input.code, scale: input.scale, liquidity: '0', - withdrawalThreshold: expectedWithdrawalThreshold + withdrawalThreshold: expectedWithdrawalThreshold, + liquidityThreshold: expectedLiquidityThreshold }) await expect( assetService.get(response.asset.id) ).resolves.toMatchObject({ ...input, - withdrawalThreshold: withdrawalThreshold ?? null + withdrawalThreshold: withdrawalThreshold ?? null, + liquidityThreshold: liquidityThreshold ?? null }) } ) @@ -190,7 +200,8 @@ describe('Asset Resolvers', (): void => { test('Can get an asset', async (): Promise => { const asset = await assetService.create({ ...randomAsset(), - withdrawalThreshold: BigInt(10) + withdrawalThreshold: BigInt(10), + liquidityThreshold: BigInt(100) }) assert.ok(!isAssetError(asset)) assert.ok(asset.withdrawalThreshold) @@ -205,6 +216,7 @@ describe('Asset Resolvers', (): void => { scale liquidity withdrawalThreshold + liquidityThreshold createdAt } } @@ -228,6 +240,7 @@ describe('Asset Resolvers', (): void => { scale: asset.scale, liquidity: '0', withdrawalThreshold: asset.withdrawalThreshold.toString(), + liquidityThreshold: asset.liquidityThreshold?.toString(), createdAt: new Date(+asset.createdAt).toISOString() }) @@ -244,6 +257,7 @@ describe('Asset Resolvers', (): void => { scale: asset.scale, liquidity: '100', withdrawalThreshold: asset.withdrawalThreshold.toString(), + liquidityThreshold: asset.liquidityThreshold?.toString(), createdAt: new Date(+asset.createdAt).toISOString() }) }) @@ -362,7 +376,8 @@ describe('Asset Resolvers', (): void => { createModel: () => assetService.create({ ...randomAsset(), - withdrawalThreshold: BigInt(10) + withdrawalThreshold: BigInt(10), + liquidityThreshold: BigInt(100) }) as Promise, pagedQuery: 'assets' }) @@ -372,7 +387,8 @@ describe('Asset Resolvers', (): void => { for (let i = 0; i < 2; i++) { const asset = await assetService.create({ ...randomAsset(), - withdrawalThreshold: BigInt(10) + withdrawalThreshold: BigInt(10), + liquidityThreshold: BigInt(100) }) assert.ok(!isAssetError(asset)) assets.push(asset) @@ -388,6 +404,7 @@ describe('Asset Resolvers', (): void => { code scale withdrawalThreshold + liquidityThreshold } cursor } @@ -406,102 +423,121 @@ describe('Asset Resolvers', (): void => { query.edges.forEach((edge, idx) => { const asset = assets[idx] assert.ok(asset.withdrawalThreshold) + assert.ok(asset.liquidityThreshold) expect(edge.cursor).toEqual(asset.id) expect(edge.node).toEqual({ __typename: 'Asset', id: asset.id, code: asset.code, scale: asset.scale, - withdrawalThreshold: asset.withdrawalThreshold.toString() + withdrawalThreshold: asset.withdrawalThreshold.toString(), + liquidityThreshold: asset.liquidityThreshold.toString() }) }) }) }) - describe('updateAssetWithdrawalThreshold', (): void => { + describe('updateAsset', (): void => { describe.each` - withdrawalThreshold - ${null} - ${BigInt(0)} - ${BigInt(5)} - `('from $withdrawalThreshold', ({ withdrawalThreshold }): void => { - let asset: AssetModel + withdrawalThreshold | liquidityThreshold + ${null} | ${null} + ${BigInt(0)} | ${null} + ${BigInt(5)} | ${null} + ${null} | ${BigInt(0)} + ${null} | ${BigInt(5)} + ${BigInt(0)} | ${BigInt(0)} + ${BigInt(5)} | ${BigInt(5)} + `( + 'from withdrawalThreshold: $withdrawalThreshold and liquidityThreshold: $liquidityThreshold', + ({ withdrawalThreshold, liquidityThreshold }): void => { + let asset: AssetModel - beforeEach(async (): Promise => { - asset = (await assetService.create({ - ...randomAsset(), - withdrawalThreshold - })) as AssetModel - assert.ok(!isAssetError(asset)) - }) + beforeEach(async (): Promise => { + asset = (await assetService.create({ + ...randomAsset(), + withdrawalThreshold, + liquidityThreshold + })) as AssetModel + assert.ok(!isAssetError(asset)) + }) - test.each` - withdrawalThreshold - ${null} - ${BigInt(0)} - ${BigInt(5)} - `( - 'to $withdrawalThreshold', - async ({ withdrawalThreshold }): Promise => { - const response = await appContainer.apolloClient - .mutate({ - mutation: gql` - mutation UpdateAssetWithdrawalThreshold( - $input: UpdateAssetInput! - ) { - updateAssetWithdrawalThreshold(input: $input) { - code - success - message - asset { - id + test.each` + withdrawalThreshold | liquidityThreshold + ${null} | ${null} + ${BigInt(0)} | ${null} + ${BigInt(5)} | ${null} + ${null} | ${BigInt(0)} + ${null} | ${BigInt(5)} + ${BigInt(0)} | ${BigInt(0)} + ${BigInt(5)} | ${BigInt(5)} + `( + 'to withdrawalThreshold: $withdrawalThreshold and liquidityThreshold: $liquidityThreshold', + async ({ withdrawalThreshold }): Promise => { + const response = await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation updateAsset($input: UpdateAssetInput!) { + updateAsset(input: $input) { code - scale - withdrawalThreshold + success + message + asset { + id + code + scale + withdrawalThreshold + liquidityThreshold + } } } + `, + variables: { + input: { + id: asset.id, + withdrawalThreshold, + liquidityThreshold + } } - `, - variables: { - input: { - id: asset.id, - withdrawalThreshold + }) + .then((query): AssetMutationResponse => { + if (query.data) { + return query.data.updateAsset + } else { + throw new Error('Data was empty') } - } + }) + + expect(response.success).toBe(true) + expect(response.code).toEqual('200') + expect(response.asset).toEqual({ + __typename: 'Asset', + id: asset.id, + code: asset.code, + scale: asset.scale, + withdrawalThreshold: + withdrawalThreshold === null + ? null + : withdrawalThreshold.toString(), + liquidityThreshold: + liquidityThreshold === null + ? null + : liquidityThreshold.toString() }) - .then((query): AssetMutationResponse => { - if (query.data) { - return query.data.updateAssetWithdrawalThreshold - } else { - throw new Error('Data was empty') - } + await expect(assetService.get(asset.id)).resolves.toMatchObject({ + withdrawalThreshold, + liquidityThreshold }) - - expect(response.success).toBe(true) - expect(response.code).toEqual('200') - expect(response.asset).toEqual({ - __typename: 'Asset', - id: asset.id, - code: asset.code, - scale: asset.scale, - withdrawalThreshold: - withdrawalThreshold === null - ? null - : withdrawalThreshold.toString() - }) - await expect(assetService.get(asset.id)).resolves.toMatchObject({ - withdrawalThreshold - }) - } - ) - }) + } + ) + } + ) test('Returns error for unknown asset', async (): Promise => { const response = await appContainer.apolloClient .mutate({ mutation: gql` - mutation UpdateAssetWithdrawalThreshold($input: UpdateAssetInput!) { - updateAssetWithdrawalThreshold(input: $input) { + mutation updateAsset($input: UpdateAssetInput!) { + updateAsset(input: $input) { code success message @@ -514,13 +550,14 @@ describe('Asset Resolvers', (): void => { variables: { input: { id: uuid(), - withdrawalThreshold: BigInt(10) + withdrawalThreshold: BigInt(10), + liquidityThreshold: BigInt(100) } } }) .then((query): AssetMutationResponse => { if (query.data) { - return query.data.updateAssetWithdrawalThreshold + return query.data.updateAsset } else { throw new Error('Data was empty') } diff --git a/packages/backend/src/graphql/resolvers/asset.ts b/packages/backend/src/graphql/resolvers/asset.ts index a563a2a3d6..1c44bbf336 100644 --- a/packages/backend/src/graphql/resolvers/asset.ts +++ b/packages/backend/src/graphql/resolvers/asset.ts @@ -89,7 +89,7 @@ export const createAsset: MutationResolvers['createAsset'] = } } -export const updateAssetWithdrawalThreshold: MutationResolvers['updateAssetWithdrawalThreshold'] = +export const updateAsset: MutationResolvers['updateAsset'] = async ( parent, args, @@ -99,7 +99,8 @@ export const updateAssetWithdrawalThreshold: MutationResolvers['u const assetService = await ctx.container.use('assetService') const assetOrError = await assetService.update({ id: args.input.id, - withdrawalThreshold: args.input.withdrawalThreshold ?? null + withdrawalThreshold: args.input.withdrawalThreshold ?? null, + liquidityThreshold: args.input.liquidityThreshold ?? null }) if (isAssetError(assetOrError)) { switch (assetOrError) { @@ -116,7 +117,7 @@ export const updateAssetWithdrawalThreshold: MutationResolvers['u return { code: '200', success: true, - message: 'Updated Asset Withdrawal Threshold', + message: 'Updated Asset', asset: assetToGraphql(assetOrError) } } catch (error) { @@ -129,7 +130,7 @@ export const updateAssetWithdrawalThreshold: MutationResolvers['u ) return { code: '400', - message: 'Error trying to update asset withdrawal threshold', + message: 'Error trying to update asset', success: false } } @@ -164,5 +165,6 @@ export const assetToGraphql = (asset: Asset): SchemaAsset => ({ code: asset.code, scale: asset.scale, withdrawalThreshold: asset.withdrawalThreshold, + liquidityThreshold: asset.liquidityThreshold, createdAt: new Date(+asset.createdAt).toISOString() }) diff --git a/packages/backend/src/graphql/resolvers/index.ts b/packages/backend/src/graphql/resolvers/index.ts index 1519f5a98b..d2cf14be5a 100644 --- a/packages/backend/src/graphql/resolvers/index.ts +++ b/packages/backend/src/graphql/resolvers/index.ts @@ -10,7 +10,7 @@ import { getAsset, getAssets, createAsset, - updateAssetWithdrawalThreshold, + updateAsset, getAssetReceivingFee, getAssetSendingFee } from './asset' @@ -87,7 +87,7 @@ export const resolvers: Resolvers = { updatePaymentPointer, triggerPaymentPointerEvents, createAsset, - updateAssetWithdrawalThreshold, + updateAsset: updateAsset, createQuote, createOutgoingPayment, createIncomingPayment, diff --git a/packages/backend/src/graphql/resolvers/peer.test.ts b/packages/backend/src/graphql/resolvers/peer.test.ts index 0771121a63..7f6965e9d2 100644 --- a/packages/backend/src/graphql/resolvers/peer.test.ts +++ b/packages/backend/src/graphql/resolvers/peer.test.ts @@ -46,7 +46,8 @@ describe('Peer Resolvers', (): void => { }, maxPacketAmount: BigInt(100), staticIlpAddress: 'test.' + uuid(), - name: faker.person.fullName() + name: faker.person.fullName(), + liquidityThreshold: BigInt(100) }) beforeAll(async (): Promise => { @@ -96,6 +97,7 @@ describe('Peer Resolvers', (): void => { staticIlpAddress liquidity name + liquidityThreshold } } } @@ -133,7 +135,8 @@ describe('Peer Resolvers', (): void => { maxPacketAmount: peer.maxPacketAmount?.toString(), staticIlpAddress: peer.staticIlpAddress, liquidity: '0', - name: peer.name + name: peer.name, + liquidityThreshold: '100' }) delete peer.http.incoming await expect(peerService.get(response.peer.id)).resolves.toMatchObject({ @@ -247,6 +250,7 @@ describe('Peer Resolvers', (): void => { staticIlpAddress liquidity name + liquidityThreshold } } `, @@ -280,7 +284,8 @@ describe('Peer Resolvers', (): void => { staticIlpAddress: peer.staticIlpAddress, maxPacketAmount: peer.maxPacketAmount?.toString(), liquidity: '0', - name: peer.name + name: peer.name, + liquidityThreshold: '100' }) await accountingService.createDeposit({ @@ -307,7 +312,8 @@ describe('Peer Resolvers', (): void => { staticIlpAddress: peer.staticIlpAddress, maxPacketAmount: peer.maxPacketAmount?.toString(), liquidity: '100', - name: peer.name + name: peer.name, + liquidityThreshold: '100' }) }) @@ -374,6 +380,7 @@ describe('Peer Resolvers', (): void => { } staticIlpAddress name + liquidityThreshold } cursor } @@ -410,7 +417,8 @@ describe('Peer Resolvers', (): void => { }, staticIlpAddress: peer.staticIlpAddress, maxPacketAmount: peer.maxPacketAmount?.toString(), - name: peer.name + name: peer.name, + liquidityThreshold: '100' }) }) }) @@ -437,7 +445,8 @@ describe('Peer Resolvers', (): void => { } }, staticIlpAddress: 'g.rafiki.' + peer.id, - name: faker.person.fullName() + name: faker.person.fullName(), + liquidityThreshold: BigInt(200) } assert.ok(updateOptions.http) const response = await appContainer.apolloClient @@ -459,6 +468,7 @@ describe('Peer Resolvers', (): void => { } staticIlpAddress name + liquidityThreshold } } } @@ -488,7 +498,8 @@ describe('Peer Resolvers', (): void => { } }, staticIlpAddress: updateOptions.staticIlpAddress, - name: updateOptions.name + name: updateOptions.name, + liquidityThreshold: '200' }) await expect(peerService.get(peer.id)).resolves.toMatchObject({ asset: peer.asset, @@ -497,7 +508,8 @@ describe('Peer Resolvers', (): void => { }, maxPacketAmount: BigInt(updateOptions.maxPacketAmount), staticIlpAddress: updateOptions.staticIlpAddress, - name: updateOptions.name + name: updateOptions.name, + liquidityThreshold: BigInt(200) }) }) diff --git a/packages/backend/src/graphql/resolvers/peer.ts b/packages/backend/src/graphql/resolvers/peer.ts index d8c475cc1f..58c36ee2db 100644 --- a/packages/backend/src/graphql/resolvers/peer.ts +++ b/packages/backend/src/graphql/resolvers/peer.ts @@ -172,5 +172,6 @@ export const peerToGraphql = (peer: Peer): SchemaPeer => ({ asset: assetToGraphql(peer.asset), staticIlpAddress: peer.staticIlpAddress, name: peer.name, + liquidityThreshold: peer.liquidityThreshold, createdAt: new Date(+peer.createdAt).toISOString() }) diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index b42f8cc5c7..a408ca1509 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -86,10 +86,8 @@ type Mutation { "Create an asset" createAsset(input: CreateAssetInput!): AssetMutationResponse! - "Update an asset's withdrawal threshold. The withdrawal threshold indicates the MINIMUM amount that can be withdrawn." - updateAssetWithdrawalThreshold( - input: UpdateAssetInput! - ): AssetMutationResponse! + "Update an asset" + updateAsset(input: UpdateAssetInput!): AssetMutationResponse! "Add asset liquidity" addAssetLiquidity(input: AddAssetLiquidityInput!): LiquidityMutationResponse @@ -214,6 +212,8 @@ input CreateAssetInput { scale: UInt8! "Minimum amount of liquidity that can be withdrawn from the asset" withdrawalThreshold: UInt64 + "Account Servicing Entity will be notified via a webhook event if liquidity falls below this value" + liquidityThreshold: UInt64 "Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence)" idempotencyKey: String } @@ -223,6 +223,8 @@ input UpdateAssetInput { id: String! "New minimum amount of liquidity that can be withdrawn from the asset" withdrawalThreshold: UInt64 + "Account Servicing Entity will be notified via a webhook event if liquidity falls below this new value" + liquidityThreshold: UInt64 "Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence)" idempotencyKey: String } @@ -248,6 +250,8 @@ input CreatePeerInput { staticIlpAddress: String! "Peer's internal name" name: String + "Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this value" + liquidityThreshold: UInt64 "Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence)" idempotencyKey: String } @@ -263,6 +267,8 @@ input UpdatePeerInput { staticIlpAddress: String "Peer's new public name" name: String + "Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this new value" + liquidityThreshold: UInt64 "Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence)" idempotencyKey: String } @@ -406,6 +412,8 @@ type Asset implements Model { liquidity: UInt64 "Minimum amount of liquidity that can be withdrawn from the asset" withdrawalThreshold: UInt64 + "Account Servicing Entity will be notified via a webhook event if liquidity falls below this value" + liquidityThreshold: UInt64 "The receiving fee structure for the asset" receivingFee: Fee "The sending fee structure for the asset" @@ -442,6 +450,8 @@ type Peer implements Model { staticIlpAddress: String! "Peer's public name" name: String + "Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this value" + liquidityThreshold: UInt64 "Available liquidity" liquidity: UInt64 "Date-time of creation" diff --git a/packages/backend/src/openapi/webhooks.yaml b/packages/backend/src/openapi/webhooks.yaml index ca46f41936..7e72b668d0 100644 --- a/packages/backend/src/openapi/webhooks.yaml +++ b/packages/backend/src/openapi/webhooks.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Rafiki Webhooks - version: 1.0.0 + version: 1.1.0 description: 'Webhook Events fired by Rafiki' contact: email: tech@interledger.org @@ -83,6 +83,28 @@ webhooks: responses: '200': description: Return a 200 status to indicate that the data was received successfully + assetLiquidity: + post: + requestBody: + description: Notify account servicing entity that asset liquidity is low. + content: + application/json: + schema: + $ref: '#/components/schemas/liquidityEvent' + responses: + '200': + description: Return a 200 status to indicate that the data was received successfully + peerLiquidity: + post: + requestBody: + description: Notify account servicing entity that peer liquidity is low. + content: + application/json: + schema: + $ref: '#/components/schemas/liquidityEvent' + responses: + '200': + description: Return a 200 status to indicate that the data was received successfully components: schemas: @@ -266,3 +288,49 @@ components: type: string format: uint64 additionalProperties: false + liquidityEvent: + required: + - id + - type + - data + properties: + id: + type: string + format: uuid + type: + type: string + enum: + - asset.liquidity_low + - peer.liquidity_low + data: + type: object + required: + - id + - asset + - liquidityThreshold + - balance + properties: + id: + type: string + format: uuid + asset: + $ref: '#/components/schemas/asset' + liquidityThreshold: + type: string + balance: + type: string + additionalProperties: false + asset: + required: + - id + - code + - scale + properties: + id: + type: string + format: uuid + code: + type: string + scale: + type: number + additionalProperties: false diff --git a/packages/backend/src/peer/model.test.ts b/packages/backend/src/peer/model.test.ts new file mode 100644 index 0000000000..072cf32866 --- /dev/null +++ b/packages/backend/src/peer/model.test.ts @@ -0,0 +1,102 @@ +import { Knex } from 'knex' +import { faker } from '@faker-js/faker' +import { v4 as uuid } from 'uuid' + +import { PeerService } from './service' +import { Config } from '../config/app' +import { createTestApp, TestContainer } from '../tests/app' +import { IocContract } from '@adonisjs/fold' +import { initIocContainer } from '../' +import { AppServices } from '../app' +import { createAsset } from '../tests/asset' +import { truncateTables } from '../tests/tableManager' +import { Peer } from './model' +import { isPeerError } from './errors' +import { WebhookEvent } from '../webhook/model' +import { Asset } from '../asset/model' + +describe('Peer Model', (): void => { + let deps: IocContract + let appContainer: TestContainer + let peerService: PeerService + let knex: Knex + let asset: Asset + + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + peerService = await deps.use('peerService') + knex = await deps.use('knex') + }) + + beforeEach(async (): Promise => { + asset = await createAsset(deps) + }) + + afterEach(async (): Promise => { + await truncateTables(appContainer.knex) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('onDebit', (): void => { + let peer: Peer + beforeEach(async (): Promise => { + const options = { + assetId: asset.id, + http: { + incoming: { + authTokens: [faker.string.sample(32)] + }, + outgoing: { + authToken: faker.string.sample(32), + endpoint: faker.internet.url({ appendSlash: false }) + } + }, + maxPacketAmount: BigInt(100), + staticIlpAddress: 'test.' + uuid(), + name: faker.person.fullName(), + liquidityThreshold: BigInt(100) + } + const peerOrError = await peerService.create(options) + if (!isPeerError(peerOrError)) { + peer = peerOrError + } + }) + test.each` + balance + ${BigInt(50)} + ${BigInt(99)} + ${BigInt(100)} + `( + 'creates webhook event if balance=$balance <= liquidityThreshold', + async ({ balance }): Promise => { + await peer.onDebit({ balance }) + const event = ( + await WebhookEvent.query(knex).where('type', 'peer.liquidity_low') + )[0] + expect(event).toMatchObject({ + type: 'peer.liquidity_low', + data: { + id: peer.id, + asset: { + id: asset.id, + code: asset.code, + scale: asset.scale + }, + liquidityThreshold: peer.liquidityThreshold?.toString(), + balance: balance.toString() + } + }) + } + ) + test('does not create webhook event if balance > liquidityThreshold', async (): Promise => { + await peer.onDebit({ balance: BigInt(110) }) + await expect( + WebhookEvent.query(knex).where('type', 'peer.liquidity_low') + ).resolves.toEqual([]) + }) + }) +}) diff --git a/packages/backend/src/peer/model.ts b/packages/backend/src/peer/model.ts index 6ec2aaa94b..612e1014b3 100644 --- a/packages/backend/src/peer/model.ts +++ b/packages/backend/src/peer/model.ts @@ -1,9 +1,10 @@ import { Model, Pojo } from 'objection' -import { LiquidityAccount } from '../accounting/service' +import { LiquidityAccount, OnDebitOptions } from '../accounting/service' import { Asset } from '../asset/model' import { ConnectorAccount } from '../connector/core/rafiki' import { HttpToken } from '../httpToken/model' import { BaseModel } from '../shared/baseModel' +import { WebhookEvent } from '../webhook/model' export class Peer extends BaseModel @@ -32,6 +33,8 @@ export class Peer } } + public readonly liquidityThreshold!: bigint | null + public assetId!: string public asset!: Asset @@ -48,6 +51,27 @@ export class Peer public name?: string + public async onDebit({ balance }: OnDebitOptions): Promise { + if (this.liquidityThreshold !== null) { + if (balance <= this.liquidityThreshold) { + await WebhookEvent.query().insert({ + type: 'peer.liquidity_low', + data: { + id: this.id, + asset: { + id: this.asset.id, + code: this.asset.code, + scale: this.asset.scale + }, + liquidityThreshold: this.liquidityThreshold, + balance + } + }) + } + } + return this + } + $formatDatabaseJson(json: Pojo): Pojo { if (json.http?.outgoing) { json.outgoingToken = json.http.outgoing.authToken diff --git a/packages/backend/src/peer/service.test.ts b/packages/backend/src/peer/service.test.ts index c5ee11c9cd..5fb36706e0 100644 --- a/packages/backend/src/peer/service.test.ts +++ b/packages/backend/src/peer/service.test.ts @@ -35,7 +35,8 @@ describe('Peer Service', (): void => { }, maxPacketAmount: BigInt(100), staticIlpAddress: 'test.' + uuid(), - name: faker.person.fullName() + name: faker.person.fullName(), + liquidityThreshold: BigInt(10000) }) beforeAll(async (): Promise => { @@ -57,32 +58,41 @@ describe('Peer Service', (): void => { }) describe('Create/Get Peer', (): void => { - test('A peer can be created and fetched', async (): Promise => { - const options = { - assetId: asset.id, - http: { - outgoing: { - authToken: faker.string.sample(32), - endpoint: faker.internet.url({ appendSlash: false }) - } - }, - staticIlpAddress: 'test.' + uuid(), - name: faker.person.fullName() + test.each` + liquidityThreshold + ${undefined} + ${BigInt(1000)} + `( + 'A peer can be created and fetched, liquidityThreshold: $liquidityThreshold', + async ({ liquidityThreshold }): Promise => { + const options = { + assetId: asset.id, + http: { + outgoing: { + authToken: faker.string.sample(32), + endpoint: faker.internet.url({ appendSlash: false }) + } + }, + staticIlpAddress: 'test.' + uuid(), + name: faker.person.fullName(), + liquidityThreshold + } + const peer = await peerService.create(options) + assert.ok(!isPeerError(peer)) + expect(peer).toMatchObject({ + asset, + http: { + outgoing: options.http.outgoing + }, + staticIlpAddress: options.staticIlpAddress, + name: options.name, + liquidityThreshold: liquidityThreshold || null + }) + const retrievedPeer = await peerService.get(peer.id) + if (!retrievedPeer) throw new Error('peer not found') + expect(retrievedPeer).toEqual(peer) } - const peer = await peerService.create(options) - assert.ok(!isPeerError(peer)) - expect(peer).toMatchObject({ - asset, - http: { - outgoing: options.http.outgoing - }, - staticIlpAddress: options.staticIlpAddress, - name: options.name - }) - const retrievedPeer = await peerService.get(peer.id) - if (!retrievedPeer) throw new Error('peer not found') - expect(retrievedPeer).toEqual(peer) - }) + ) test('A peer can be created with all settings', async (): Promise => { const options = randomPeer() @@ -176,33 +186,42 @@ describe('Peer Service', (): void => { }) describe('Update Peer', (): void => { - test('Can update a peer', async (): Promise => { - const peer = await createPeer(deps) - const { http, maxPacketAmount, staticIlpAddress, name } = randomPeer() - const updateOptions: UpdateOptions = { - id: peer.id, - http, - maxPacketAmount, - staticIlpAddress, - name - } + test.each` + liquidityThreshold + ${null} + ${BigInt(2000)} + `( + 'Can update a peer, liquidityThreshold: $liquidityThreshold', + async ({ liquidityThreshold }): Promise => { + const peer = await createPeer(deps) + const { http, maxPacketAmount, staticIlpAddress, name } = randomPeer() + const updateOptions: UpdateOptions = { + id: peer.id, + http, + maxPacketAmount, + staticIlpAddress, + name, + liquidityThreshold + } - const peerOrError = await peerService.update(updateOptions) - assert.ok(!isPeerError(peerOrError)) - assert.ok(updateOptions.http) - delete updateOptions.http.incoming - const expectedPeer = { - asset: peer.asset, - http: { - outgoing: updateOptions.http.outgoing - }, - maxPacketAmount: updateOptions.maxPacketAmount, - staticIlpAddress: updateOptions.staticIlpAddress, - name: updateOptions.name + const peerOrError = await peerService.update(updateOptions) + assert.ok(!isPeerError(peerOrError)) + assert.ok(updateOptions.http) + delete updateOptions.http.incoming + const expectedPeer = { + asset: peer.asset, + http: { + outgoing: updateOptions.http.outgoing + }, + maxPacketAmount: updateOptions.maxPacketAmount, + staticIlpAddress: updateOptions.staticIlpAddress, + name: updateOptions.name, + liquidityThreshold: updateOptions.liquidityThreshold || null + } + expect(peerOrError).toMatchObject(expectedPeer) + await expect(peerService.get(peer.id)).resolves.toEqual(peerOrError) } - expect(peerOrError).toMatchObject(expectedPeer) - await expect(peerService.get(peer.id)).resolves.toEqual(peerOrError) - }) + ) test('Cannot update nonexistent peer', async (): Promise => { const updateOptions: UpdateOptions = { diff --git a/packages/backend/src/peer/service.ts b/packages/backend/src/peer/service.ts index c9955272b9..c401d1e6ed 100644 --- a/packages/backend/src/peer/service.ts +++ b/packages/backend/src/peer/service.ts @@ -32,6 +32,7 @@ export type Options = { maxPacketAmount?: bigint staticIlpAddress: string name?: string + liquidityThreshold?: bigint } export type CreateOptions = Options & { @@ -115,7 +116,8 @@ async function createPeer( http: options.http, maxPacketAmount: options.maxPacketAmount, staticIlpAddress: options.staticIlpAddress, - name: options.name + name: options.name, + liquidityThreshold: options.liquidityThreshold }) .withGraphFetched('asset') diff --git a/packages/backend/src/tests/peer.ts b/packages/backend/src/tests/peer.ts index 8e02673462..459c08e2d6 100644 --- a/packages/backend/src/tests/peer.ts +++ b/packages/backend/src/tests/peer.ts @@ -30,6 +30,9 @@ export async function createPeer( if (options.maxPacketAmount) { peerOptions.maxPacketAmount = options.maxPacketAmount } + if (options.liquidityThreshold) { + peerOptions.liquidityThreshold = options.liquidityThreshold + } const peerService = await deps.use('peerService') const peer = await peerService.create(peerOptions) if (isPeerError(peer)) { diff --git a/packages/backend/src/webhook/model.ts b/packages/backend/src/webhook/model.ts index 51b18e23e9..9cd2c87a5c 100644 --- a/packages/backend/src/webhook/model.ts +++ b/packages/backend/src/webhook/model.ts @@ -1,6 +1,5 @@ -import { Model, Pojo } from 'objection' +import { Pojo } from 'objection' -import { Asset } from '../asset/model' import { BaseModel } from '../shared/baseModel' const fieldPrefixes = ['withdrawal'] @@ -22,17 +21,6 @@ export class WebhookEvent extends BaseModel { amount: bigint } - static relationMappings = { - withdrawalAsset: { - relation: Model.HasOneRelation, - modelClass: Asset, - join: { - from: 'webhookEvents.withdrawalAssetId', - to: 'assets.id' - } - } - } - $formatDatabaseJson(json: Pojo): Pojo { for (const prefix of fieldPrefixes) { if (!json[prefix]) continue diff --git a/packages/documentation/astro.config.mjs b/packages/documentation/astro.config.mjs index af02d97888..4be24d6d0f 100644 --- a/packages/documentation/astro.config.mjs +++ b/packages/documentation/astro.config.mjs @@ -124,6 +124,10 @@ export default defineConfig({ { label: 'Management', link: 'integration/management' + }, + { + label: 'Event Handlers', + link: 'integration/event-handlers' } ] }, diff --git a/packages/documentation/src/content/docs/integration/event-handlers.md b/packages/documentation/src/content/docs/integration/event-handlers.md index d5d20a9994..15b03df393 100644 --- a/packages/documentation/src/content/docs/integration/event-handlers.md +++ b/packages/documentation/src/content/docs/integration/event-handlers.md @@ -135,3 +135,35 @@ participant R as Rafiki R->>ASE: webhook event: payment pointer not found,
payment pointer: https://example-wallet.com/carla_garcia ASE->>R: admin API call: CreatePaymentPointer
url: https://example-wallet.com/carla_garcia,
public name: Carla Eva Garcia ``` + +## `asset.liquidity_low` + +The `asset.liquidity_low` event indicates that the liquidity of an [asset](../reference/glossary#asset) has dropped below a predefined liquidity threshold. When receiving this event, the Account Servicing Entity should check if they have or can acquire additional liquidity of said asset and if so, deposit it in Rafiki. If the Account Servicing Entity cannot or does not increase the asset liquidity in Rafiki, cross-currency transfers will fail. + +Example: The asset liquidity for USD (scale: 2) drops below 100.00 USD. + +```mermaid +sequenceDiagram + +participant ASE as Account Servicing Entity +participant R as Rafiki + +R->>ASE: webhook event: liquidity (asset) low,
asset: USD (scale: 2, id: "abc") +ASE->>R: admin API call: AddAssetLiquidity +``` + +## `peer.liquidity_low` + +The `peer.liquidity_low` event indicates that the liquidity of a [peer](../reference/glossary#peer) has dropped below a predefined liquidity threshold. When receiving this event, the Account Servicing Entity need to decide if they can extend said peer's credit line or whether they need to settle first and then extend a new line of credit. If the Account Servicing Entity cannot or does not increase the peer liquidity in Rafiki, transfers to that peer will fail. + +Example: The peer liquidity for Happy Life Bank drops below 100.00 USD. + +```mermaid +sequenceDiagram + +participant ASE as Account Servicing Entity +participant R as Rafiki + +R->>ASE: webhook event: liquidity (peer) low,
peer: Happy Life Bank (asset: "USD", scale: 2, id: "abc") +ASE->>R: admin API call: AddPeerLiquidity +``` diff --git a/packages/documentation/src/content/docs/integration/getting-started.md b/packages/documentation/src/content/docs/integration/getting-started.md index daf72e7b79..e2281d5a9f 100644 --- a/packages/documentation/src/content/docs/integration/getting-started.md +++ b/packages/documentation/src/content/docs/integration/getting-started.md @@ -92,6 +92,8 @@ The endpoint accepts a `POST` request with | `outgoing_payment.failed` | Outgoing payment failed. | | `payment_pointer.not_found` | A requested payment pointer was not found | | `payment_pointer.web_monetization` | Web Monetization payments received via STREAM. | +| `asset.liquidity_low` | Asset liquidity has dropped below defined threshold. | +| `peer.liquidity_low` | Peer liquidity has dropped below defined threshold. | The Account Servicing Entity's expected behavior when observing these webhook events is detailed in the [Event Handlers](/integration/event-handlers) documentation. diff --git a/packages/documentation/src/schema-dumps/backend_raw_schema.md b/packages/documentation/src/schema-dumps/backend_raw_schema.md index 2a5bcd565d..5b0d285a85 100644 --- a/packages/documentation/src/schema-dumps/backend_raw_schema.md +++ b/packages/documentation/src/schema-dumps/backend_raw_schema.md @@ -485,11 +485,11 @@ Create an asset -updateAssetWithdrawalThreshold +updateAsset AssetMutationResponse! -Update an asset's withdrawal threshold. The withdrawal threshold indicates the MINIMUM amount that can be withdrawn. +Update an asset @@ -904,6 +904,15 @@ Available liquidity Minimum amount of liquidity that can be withdrawn from the asset + + + +liquidityThreshold +UInt64 + + +Account Servicing Entity will be notified via a webhook event if liquidity falls below this value + @@ -2414,6 +2423,15 @@ Peer's ILP address Peer's public name + + + +liquidityThreshold +UInt64 + + +Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this value + @@ -3267,6 +3285,15 @@ Difference in orders of magnitude between the standard unit of an asset and a co Minimum amount of liquidity that can be withdrawn from the asset + + + +liquidityThreshold +UInt64 + + +Account Servicing Entity will be notified via a webhook event if liquidity falls below this value + @@ -3622,6 +3649,15 @@ Peer's ILP address Peer's internal name + + + +liquidityThreshold +UInt64 + + +Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this value + @@ -4258,6 +4294,15 @@ Asset id New minimum amount of liquidity that can be withdrawn from the asset + + + +liquidityThreshold +UInt64 + + +Account Servicing Entity will be notified via a webhook event if liquidity falls below this new value + @@ -4376,6 +4421,15 @@ Peer's new ILP address Peer's new public name + + + +liquidityThreshold +UInt64 + + +Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this new value + diff --git a/packages/frontend/app/generated/graphql.ts b/packages/frontend/app/generated/graphql.ts index 5fbd4d818f..82f43207be 100644 --- a/packages/frontend/app/generated/graphql.ts +++ b/packages/frontend/app/generated/graphql.ts @@ -72,6 +72,8 @@ export type Asset = Model & { id: Scalars['ID']['output']; /** Available liquidity */ liquidity?: Maybe; + /** Account Servicing Entity will be notified via a webhook event if liquidity falls below this value */ + liquidityThreshold?: Maybe; /** The receiving fee structure for the asset */ receivingFee?: Maybe; /** Difference in orders of magnitude between the standard unit of an asset and a corresponding fractional unit */ @@ -114,6 +116,8 @@ export type CreateAssetInput = { code: Scalars['String']['input']; /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ idempotencyKey?: InputMaybe; + /** Account Servicing Entity will be notified via a webhook event if liquidity falls below this value */ + liquidityThreshold?: InputMaybe; /** Difference in orders of magnitude between the standard unit of an asset and a corresponding fractional unit */ scale: Scalars['UInt8']['input']; /** Minimum amount of liquidity that can be withdrawn from the asset */ @@ -206,6 +210,8 @@ export type CreatePeerInput = { http: HttpInput; /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ idempotencyKey?: InputMaybe; + /** Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this value */ + liquidityThreshold?: InputMaybe; /** Maximum packet amount that the peer accepts */ maxPacketAmount?: InputMaybe; /** Peer's internal name */ @@ -508,8 +514,8 @@ export type Mutation = { setFee: SetFeeResponse; /** If automatic withdrawal of funds received via Web Monetization by the payment pointer are disabled, this mutation can be used to trigger up to n withdrawal events. */ triggerPaymentPointerEvents: TriggerPaymentPointerEventsMutationResponse; - /** Update an asset's withdrawal threshold. The withdrawal threshold indicates the MINIMUM amount that can be withdrawn. */ - updateAssetWithdrawalThreshold: AssetMutationResponse; + /** Update an asset */ + updateAsset: AssetMutationResponse; /** Update a payment pointer */ updatePaymentPointer: UpdatePaymentPointerMutationResponse; /** Update a peer */ @@ -616,7 +622,7 @@ export type MutationTriggerPaymentPointerEventsArgs = { }; -export type MutationUpdateAssetWithdrawalThresholdArgs = { +export type MutationUpdateAssetArgs = { input: UpdateAssetInput; }; @@ -863,6 +869,8 @@ export type Peer = Model & { id: Scalars['ID']['output']; /** Available liquidity */ liquidity?: Maybe; + /** Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this value */ + liquidityThreshold?: Maybe; /** Maximum packet amount that the peer accepts */ maxPacketAmount?: Maybe; /** Peer's public name */ @@ -1118,6 +1126,8 @@ export type UpdateAssetInput = { id: Scalars['String']['input']; /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ idempotencyKey?: InputMaybe; + /** Account Servicing Entity will be notified via a webhook event if liquidity falls below this new value */ + liquidityThreshold?: InputMaybe; /** New minimum amount of liquidity that can be withdrawn from the asset */ withdrawalThreshold?: InputMaybe; }; @@ -1148,6 +1158,8 @@ export type UpdatePeerInput = { id: Scalars['String']['input']; /** Unique key to ensure duplicate or retried requests are processed only once. See [idempotence](https://en.wikipedia.org/wiki/Idempotence) */ idempotencyKey?: InputMaybe; + /** Account Servicing Entity will be notified via a webhook event if peer liquidity falls below this new value */ + liquidityThreshold?: InputMaybe; /** New maximum packet amount that the peer accepts */ maxPacketAmount?: InputMaybe; /** Peer's new public name */ @@ -1502,6 +1514,7 @@ export type AssetResolvers; id?: Resolver; liquidity?: Resolver, ParentType, ContextType>; + liquidityThreshold?: Resolver, ParentType, ContextType>; receivingFee?: Resolver, ParentType, ContextType>; scale?: Resolver; sendingFee?: Resolver, ParentType, ContextType>; @@ -1676,7 +1689,7 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; setFee?: Resolver>; triggerPaymentPointerEvents?: Resolver>; - updateAssetWithdrawalThreshold?: Resolver>; + updateAsset?: Resolver>; updatePaymentPointer?: Resolver>; updatePeer?: Resolver>; voidLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; @@ -1812,6 +1825,7 @@ export type PeerResolvers; id?: Resolver; liquidity?: Resolver, ParentType, ContextType>; + liquidityThreshold?: Resolver, ParentType, ContextType>; maxPacketAmount?: Resolver, ParentType, ContextType>; name?: Resolver, ParentType, ContextType>; staticIlpAddress?: Resolver; @@ -2058,7 +2072,7 @@ export type UpdateAssetMutationVariables = Exact<{ }>; -export type UpdateAssetMutation = { __typename?: 'Mutation', updateAssetWithdrawalThreshold: { __typename?: 'AssetMutationResponse', code: string, success: boolean, message: string } }; +export type UpdateAssetMutation = { __typename?: 'Mutation', updateAsset: { __typename?: 'AssetMutationResponse', code: string, success: boolean, message: string } }; export type AddAssetLiquidityMutationVariables = Exact<{ input: AddAssetLiquidityInput; diff --git a/packages/frontend/app/lib/api/asset.server.ts b/packages/frontend/app/lib/api/asset.server.ts index 3522eaa7e9..e5f0585506 100644 --- a/packages/frontend/app/lib/api/asset.server.ts +++ b/packages/frontend/app/lib/api/asset.server.ts @@ -112,7 +112,7 @@ export const updateAsset = async (args: UpdateAssetInput) => { >({ mutation: gql` mutation UpdateAssetMutation($input: UpdateAssetInput!) { - updateAssetWithdrawalThreshold(input: $input) { + updateAsset(input: $input) { code success message @@ -124,7 +124,7 @@ export const updateAsset = async (args: UpdateAssetInput) => { } }) - return response.data?.updateAssetWithdrawalThreshold + return response.data?.updateAsset } export const addAssetLiquidity = async (args: AddAssetLiquidityInput) => { diff --git a/packages/frontend/app/shared/enums.ts b/packages/frontend/app/shared/enums.ts index 6757ccb1ee..bbe12d6797 100644 --- a/packages/frontend/app/shared/enums.ts +++ b/packages/frontend/app/shared/enums.ts @@ -4,5 +4,7 @@ export enum WebhookEventType { IncomingPaymentExpired = 'incoming_payment.expired', OutgoingPaymentCreated = 'outgoing_payment.created', OutgoingPaymentCompleted = 'outgoing_payment.completed', - OutgoingPaymentFailed = 'outgoing_payment.failed' + OutgoingPaymentFailed = 'outgoing_payment.failed', + AssetLiquidityLow = 'asset.liquidity_low', + PeerLiquidityLow = 'peer.liquidity_low' } diff --git a/postman/collections/Interledger.json b/postman/collections/Interledger.json index 4b2b65d99d..1cf4f59ca4 100644 --- a/postman/collections/Interledger.json +++ b/postman/collections/Interledger.json @@ -35,8 +35,8 @@ "body": { "mode": "graphql", "graphql": { - "query": "mutation CreateAsset($input: CreateAssetInput!) {\n createAsset(input: $input) {\n asset {\n code\n createdAt\n id\n scale\n withdrawalThreshold\n }\n code\n message\n success\n }\n}", - "variables": "{\n \"input\": {\n \"code\": \"USD\",\n \"scale\": 6,\n \"withdrawalThreshold\": null\n }\n}" + "query": "mutation CreateAsset($input: CreateAssetInput!) {\n createAsset(input: $input) {\n asset {\n code\n createdAt\n id\n scale\n withdrawalThreshold\n liquidityThreshold\n }\n code\n message\n success\n }\n}", + "variables": "{\n \"input\": {\n \"code\": \"USD\",\n \"scale\": 6,\n \"withdrawalThreshold\": null,\n \"liquidityThreshold\": \"100000000\"\n }\n}" } }, "url": { @@ -52,11 +52,12 @@ "response": [] }, { - "name": "Update Asset Withdrawal Threshold", + "name": "Update Asset", "event": [ { "listen": "test", "script": { + "id": "e024410d-9eb8-41f5-9cab-691f76721bb6", "exec": [ "" ], @@ -74,8 +75,8 @@ "body": { "mode": "graphql", "graphql": { - "query": "mutation UpdateAssetWithdrawalThreshold($input: UpdateAssetInput!) {\n updateAssetWithdrawalThreshold(input: $input) {\n asset {\n code\n createdAt\n id\n scale\n withdrawalThreshold\n }\n code\n message\n success\n }\n}", - "variables": "{\n \"input\": {\n \"id\": \"{{assetId}}\",\n \"withdrawalThreshold\": 100\n }\n}" + "query": "mutation UpdateAsset($input: UpdateAssetInput!) {\n updateAsset(input: $input) {\n asset {\n code\n createdAt\n id\n scale\n withdrawalThreshold\n liquidityThreshold\n }\n code\n message\n success\n }\n}", + "variables": "{\n \"input\": {\n \"id\": \"{{assetId}}\",\n \"withdrawalThreshold\": 100,\n \"liquidityThreshold\": 100\n }\n}" } }, "url": { @@ -102,7 +103,7 @@ "body": { "mode": "graphql", "graphql": { - "query": "query GetAsset($id: String!) {\n asset(id: $id) {\n code\n createdAt\n id\n scale\n withdrawalThreshold\n sendingFee {\n id\n type\n basisPoints\n fixed\n }\n receivingFee {\n id\n type\n basisPoints\n fixed\n }\n }\n}", + "query": "query GetAsset($id: String!) {\n asset(id: $id) {\n code\n createdAt\n id\n scale\n withdrawalThreshold\n liquidityThreshold\n sendingFee {\n id\n type\n basisPoints\n fixed\n }\n receivingFee {\n id\n type\n basisPoints\n fixed\n }\n }\n}", "variables": "{\n \"id\": \"{{assetId}}\"\n}" } }, @@ -130,7 +131,7 @@ "body": { "mode": "graphql", "graphql": { - "query": "query GetAssets($after: String, $before: String, $first: Int, $last: Int) {\n assets(after: $after, before: $before, first:$first, last: $last) {\n edges {\n cursor\n node {\n code\n createdAt\n id\n scale\n withdrawalThreshold\n liquidity\n sendingFee {\n id\n type\n basisPoints\n fixed\n }\n receivingFee {\n id\n type\n basisPoints\n fixed\n }\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}", + "query": "query GetAssets($after: String, $before: String, $first: Int, $last: Int) {\n assets(after: $after, before: $before, first:$first, last: $last) {\n edges {\n cursor\n node {\n code\n createdAt\n id\n scale\n withdrawalThreshold\n liquidityThreshold\n liquidity\n sendingFee {\n id\n type\n basisPoints\n fixed\n }\n receivingFee {\n id\n type\n basisPoints\n fixed\n }\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}", "variables": "{\n \"after\": null,\n \"before\": null,\n \"first\": null,\n \"last\": null\n}" } }, @@ -248,6 +249,7 @@ { "listen": "test", "script": { + "id": "8b5efee8-1414-4786-af23-49ffa323cbd1", "exec": [ "const body = pm.response.json();", "", @@ -267,8 +269,8 @@ "body": { "mode": "graphql", "graphql": { - "query": "mutation CreatePeer ($input: CreatePeerInput!) {\n createPeer (input: $input) {\n code\n message\n success\n peer {\n id\n name\n asset{\n id\n scale\n code\n withdrawalThreshold\n }\n }\n }\n}", - "variables": "{\n \"input\": {\n \"name\": \"Wallet1\",\n \"staticIlpAddress\": \"test.peer\",\n \"http\": {\n \"incoming\": {\"authTokens\": [\"test123abc\"]},\n \"outgoing\": {\"endpoint\": \"http://peer-backend:3002\", \"authToken\": \"test123abc\"}\n },\n \"assetId\": \"{{assetId}}\",\n \"maxPacketAmount\": 1000\n }\n}" + "query": "mutation CreatePeer ($input: CreatePeerInput!) {\n createPeer (input: $input) {\n code\n message\n success\n peer {\n id\n name\n liquidity\n liquidityThreshold\n asset{\n id\n scale\n code\n withdrawalThreshold\n }\n }\n }\n}", + "variables": "{\n \"input\": {\n \"name\": \"Wallet1\",\n \"staticIlpAddress\": \"test.peer\",\n \"http\": {\n \"incoming\": {\"authTokens\": [\"test123abc\"]},\n \"outgoing\": {\"endpoint\": \"http://peer-backend:3002\", \"authToken\": \"test123abc\"}\n },\n \"assetId\": \"{{assetId}}\",\n \"maxPacketAmount\": 1000,\n \"liquidityThreshold\": 100000000\n }\n}" } }, "url": { @@ -295,8 +297,8 @@ "body": { "mode": "graphql", "graphql": { - "query": "mutation UpdatePeer ($input: UpdatePeerInput!){ \n updatePeer(input: $input) {\n code\n success\n message\n peer {\n id\n name\n http {\n outgoing {\n authToken\n endpoint\n }\n }\n }\n }\n}", - "variables": "{\n \"input\": {\n \"id\": \"{{peerId}}\",\n \"name\": \"Wall-y\",\n \"http\": {\n \"incoming\": {\"authTokens\": [\"test-123\"]},\n \"outgoing\": {\"endpoint\": \"http://peer-backend:3002\", \"authToken\": \"test\"}\n },\n \"maxPacketAmount\": 1000\n }\n}" + "query": "mutation UpdatePeer ($input: UpdatePeerInput!){ \n updatePeer(input: $input) {\n code\n success\n message\n peer {\n id\n name\n http {\n outgoing {\n authToken\n endpoint\n }\n }\n liquidity\n liquidityThreshold\n }\n }\n}", + "variables": "{\n \"input\": {\n \"id\": \"{{peerId}}\",\n \"name\": \"Wall-y\",\n \"http\": {\n \"incoming\": {\"authTokens\": [\"test-123\"]},\n \"outgoing\": {\"endpoint\": \"http://peer-backend:3002\", \"authToken\": \"test\"}\n },\n \"maxPacketAmount\": 1000,\n \"liquidityThreshold\": 100\n }\n}" } }, "url": { @@ -323,7 +325,7 @@ "body": { "mode": "graphql", "graphql": { - "query": "query GetPeer {\n peer (id: \"{{peerId}}\") {\n id\n name\n http {\n outgoing {\n authToken\n endpoint\n }\n }\n }\n}", + "query": "query GetPeer {\n peer (id: \"{{peerId}}\") {\n id\n name\n http {\n outgoing {\n authToken\n endpoint\n }\n }\n liquidity\n liquidityThreshold\n }\n}", "variables": "" } }, @@ -351,7 +353,7 @@ "body": { "mode": "graphql", "graphql": { - "query": "query GetPeers {\n peers {\n edges {\n cursor\n node {\n id\n name\n asset {\n code\n scale\n }\n liquidity\n staticIlpAddress\n http {\n outgoing {\n authToken\n endpoint\n }\n }\n }\n }\n }\n}", + "query": "query GetPeers {\n peers {\n edges {\n cursor\n node {\n id\n name\n asset {\n code\n scale\n }\n liquidity\n liquidityThreshold\n staticIlpAddress\n http {\n outgoing {\n authToken\n endpoint\n }\n }\n }\n }\n }\n}", "variables": "" } }, @@ -3564,221 +3566,221 @@ ], "variable": [ { - "id": "5ea3f014-b89b-4adf-ab9b-51d3e311663d", + "id": "4ee6dea3-c780-409d-af41-ec0c7344c83f", "key": "OpenPaymentsHost", "value": "http://localhost:3000" }, { - "id": "31b48aa1-d3ad-4a39-b07e-e5c6bb7b4b62", + "id": "346e5927-9aeb-4475-b6d3-7eeebcf0ef1c", "key": "PeerOpenPaymentsHost", "value": "http://localhost:4000" }, { - "id": "81cf178b-1ca3-4b0c-bee7-e71cb51b7b7d", + "id": "496e3b8e-12ed-44ac-b639-70e885797194", "key": "OpenPaymentsAuthHost", "value": "http://localhost:3006" }, { - "id": "36b88741-209a-4652-9a76-b17f7bd58acc", + "id": "89fe5e25-e9af-49bc-b7e9-c60863458f21", "key": "PeerOpenPaymentsAuthHost", "value": "http://localhost:4006" }, { - "id": "c66f6853-ccb9-414e-a823-695479c2cec1", + "id": "787a43bd-09ed-40e8-81a0-56a93eff3eaf", "key": "RafikiGraphqlHost", "value": "http://localhost:3001" }, { - "id": "ca34f9d5-eacb-495a-9f13-43b2b9516549", + "id": "f013914a-0126-4f29-ba38-20b00f50afc0", "key": "RafikiAuthGraphqlHost", "value": "http://localhost:3003", "type": "string" }, { - "id": "70defa54-9035-4f5f-8029-539a8bf86dac", + "id": "44a6339c-d2a5-43b3-baa5-3fac0293e62a", "key": "PeerGraphqlHost", "value": "http://localhost:4001" }, { - "id": "f84c4a0b-f5d7-4362-9c82-eba294e8c3f4", + "id": "fd5857c3-522e-482b-b2ab-868a8d3df2dd", "key": "PeerAuthGraphqlHost", "value": "http://localhost:4003", "type": "string" }, { - "id": "e171d6ae-2408-4ee3-89a3-ad5cfa720717", + "id": "d9e28ccc-4e81-46a6-a469-8543514cf5ba", "key": "SignatureHost", "value": "http://localhost:3040" }, { - "id": "985a4b69-55bb-423f-a493-8ca5c1887656", + "id": "bcf10d43-89be-4d8e-ae82-840a8dad5bad", "key": "PeerSignatureHost", "value": "http://localhost:3041" }, { - "id": "fcf12963-2d8e-4738-be4d-ad2d8cd4a8ce", + "id": "5c9bcd71-3b6b-46cb-b61b-94e9d275aaf2", "key": "gfranklinPaymentPointer", "value": "http://localhost:3000/accounts/gfranklin" }, { - "id": "a9095b83-41fa-4b5d-a91d-0a287524df89", + "id": "004fb735-6127-4cfe-971f-c5ba3b2db4c4", "key": "asmithPaymentPointer", "value": "http://localhost:4000/accounts/asmith", "type": "string" }, { - "id": "06b9e7e4-e0bc-49cd-9e1b-bf4d5025eda8", + "id": "e5037a18-1e62-41cd-b98b-18efec927470", "key": "gfranklinPaymentPointerId", "value": "6abd8671-72d0-41ee-8e0a-0b28eff522a5", "type": "string" }, { - "id": "f3defd6f-de85-4183-b15f-98e4c6da2715", + "id": "6459f543-714b-470c-b31a-216f4a135a67", "key": "pfryPaymentPointer", "value": "http://localhost:4000/accounts/pfry" }, { - "id": "2635c0d0-0d10-4518-bc5a-ca76ecd7bf6c", + "id": "7ae91d54-b4fd-48fe-8c7d-f314cb68e1f3", "key": "accessToken", "value": "example-access-token" }, { - "id": "dc7206ec-bc36-4efd-80ac-5ed3c7165b69", + "id": "e5aaf38c-b48a-440d-ace4-2e56d4875eef", "key": "paymentPointerId", "value": "bde8da93-9bff-4251-90ce-abb15e7d444c" }, { - "id": "5e0c81c5-c750-49af-81e0-fa7cc72ccda2", + "id": "683684e7-85b5-4e92-bc1a-8dcbb3056723", "key": "incomingPaymentId", "value": "dc7b71a5-d78e-450c-b4c5-7db299f2a133" }, { - "id": "2d7e0913-80cd-42f8-9ebd-4740f8ac486f", + "id": "ee9a05f9-acf7-4fad-8725-2ff86f53f0a1", "key": "connectionId", "value": "23de6081-b340-4334-b335-a2328bbb3334" }, { - "id": "9442ca3e-bdc1-49c4-827b-7ce62e84bdcc", + "id": "55d07b63-d5a9-499d-bf84-40f0132dcee8", "key": "outgoingPaymentId", "value": "empty" }, { - "id": "e629c10b-a8c9-4dbb-91ec-2dc095762419", + "id": "a39653ef-9502-4378-9705-a4729d7757fd", "key": "quoteId", "value": "http://backend/d982ab2f-4380-4d05-be3b-43d23a57d23f/quotes/8adaa7f9-fbd2-4cc2-89b5-6f354d68728e" }, { - "id": "ffca0789-cacf-4449-aa45-bfae4669c5b6", + "id": "319b27c4-765f-4138-adc4-9e15016ac28d", "key": "tokenId", "value": "f6cf615c-70e8-4b04-bd86-0d6ac41a4c85" }, { - "id": "6f002da2-bc94-42f6-875e-4140ebdc763e", + "id": "6a520e93-7fc1-4dc5-bbbc-aa56a28b13f3", "key": "tomorrow", "value": "" }, { - "id": "0708cdd5-7117-439f-bea9-c17e0cf83822", + "id": "3a5918fa-3822-4399-9dc7-cdec6464c46b", "key": "uniquePaymentPointer", "value": "" }, { - "id": "ffb02d57-b538-46b4-8620-0b7aa4a50dab", + "id": "aef2f78a-551c-45e2-981d-30fb35059693", "key": "quoteUrl", "value": "" }, { - "id": "949a9425-50e9-4800-bec8-2aab31e56d41", + "id": "e5460cae-87e6-4608-ae55-d7d897f7cb38", "key": "incomingPaymentUrl", "value": "" }, { - "id": "f71f85df-0710-4eaa-8db1-b8d45a8e61c4", + "id": "c79c3e36-ea19-4a26-ac04-87bb1476c462", "key": "continueToken", "value": "" }, { - "id": "1c0bc73a-b512-4bf0-8a31-67cbaecd8f99", + "id": "565e679c-ae68-428e-8b97-d936ebc83aaa", "key": "continueId", "value": "" }, { - "id": "96760b4a-cbac-43cb-8c1d-fa01b2560661", + "id": "ecc46a5b-91c0-4661-ab34-dc7ecd902d8e", "key": "paymentPointerUrl", "value": "" }, { - "id": "5d077921-e3bb-4fee-a15d-2b1d142651ba", + "id": "fbac2b60-3775-4a36-88e7-7f6b1900d35c", "key": "createPaymentPointerRequest", "value": "" }, { - "id": "891b5e2f-81bc-4ee7-904a-ca8ccfef6c54", + "id": "3ea240c0-b03d-449e-b341-a97550e5fbf9", "key": "secondPaymentPointerId", "value": "" }, { - "id": "8322df2c-c8d1-4c5a-9988-68d550e64bee", + "id": "b3a7868b-af13-4550-846c-36950086c90d", "key": "assetId", "value": "" }, { - "id": "dfdcca07-1cd3-4253-8b63-241f0c329ed4", + "id": "79893faa-4c7d-4555-b8a7-6440a9baa774", "key": "preRequestSignatures", "value": "const requestUrl = request.url\n .replace(/{{([A-Za-z]\\w+)}}/g, (_, key) => pm.collectionVariables.get(key))\n .replace(/localhost:([3,4])000/g, (_, key) =>\n key === '3' ? 'cloud-nine-wallet-backend' : 'happy-life-bank-backend'\n )\nconst requestBody =\n request.method === 'POST' && Object.keys(request.data).length !== 0\n ? request.data.replace(/{{([A-Za-z]\\w+)}}/g, (_, key) =>\n pm.collectionVariables.get(key)\n )\n : undefined\nconst requestHeaders = JSON.parse(\n JSON.stringify(request.headers).replace(/{{([A-Za-z]\\w+)}}/g, (_, key) =>\n pm.collectionVariables.get(key)\n )\n)\n// Request Signature Headers\npm.sendRequest(\n {\n url: pm.collectionVariables.get('signatureUrl'),\n method: 'POST',\n header: {\n 'content-type': 'application/json'\n },\n body: {\n mode: 'raw',\n raw: JSON.stringify({\n keyId: pm.collectionVariables.get('keyId'),\n request: {\n url: requestUrl,\n method: request.method,\n headers: requestHeaders,\n body: requestBody\n }\n })\n }\n },\n (_, res) => {\n const headers = res.json()\n for (let [key, value] of Object.entries(headers)) {\n pm.request.headers.add({ key, value })\n }\n }\n)\n" }, { - "id": "aba05b3a-5528-4869-8a83-2e9668b28319", + "id": "9b94ec03-326c-4890-949b-e88a5d5dd2d3", "key": "preRequestSignaturesGrantRequest", "value": "const url = require('url')\n\nconst body = JSON.parse(request.data)\nconst client = url.parse(body.client)\nconst jwkUrl = `http://localhost:${\n client.host === 'cloud-nine-wallet-backend' ? '3' : '4'\n}000${client.path}/jwks.json`\npm.collectionVariables.set(\n 'signatureUrl',\n pm.collectionVariables.get(\n client.host === 'cloud-nine-wallet-backend'\n ? 'SignatureHost'\n : 'PeerSignatureHost'\n )\n)\n\nconst requestUrl = request.url.replace(/{{([A-Za-z]\\w+)}}/g, (_, key) =>\n pm.collectionVariables.get(key)\n)\nconst requestBody = request.data.replace(/{{([A-Za-z]\\w+)}}/g, (_, key) =>\n pm.collectionVariables.get(key)\n)\nconst requestHeaders = JSON.parse(\n JSON.stringify(request.headers).replace(/{{([A-Za-z]\\w+)}}/g, (_, key) =>\n pm.collectionVariables.get(key)\n )\n)\n\n// Request Client JWK\npm.sendRequest(\n {\n url: jwkUrl,\n method: 'GET',\n header: {\n Host: client.host\n }\n },\n (err, res) => {\n const keys = res.json()\n pm.collectionVariables.set('keyId', keys.keys[0].kid)\n\n // Request Signature Headers\n pm.sendRequest(\n {\n url: pm.collectionVariables.get('signatureUrl'),\n method: 'POST',\n header: {\n 'content-type': 'application/json'\n },\n body: {\n mode: 'raw',\n raw: JSON.stringify({\n keyId: pm.collectionVariables.get('keyId'),\n request: {\n url: requestUrl,\n method: request.method,\n headers: requestHeaders,\n body: requestBody\n }\n })\n }\n },\n (_, res) => {\n const headers = res.json()\n for (let [key, value] of Object.entries(headers)) {\n pm.request.headers.add({ key, value })\n }\n }\n )\n }\n)\n" }, { - "id": "a14da3cd-a6db-4681-9973-16e8d1284e8b", + "id": "e3c9ec7a-4390-4060-8868-1d32e04aa2cf", "key": "signatureUrl", "value": "" }, { - "id": "df9bd98f-83bd-4751-b849-5c319025a9cf", + "id": "c228e981-36da-4033-8015-4eacfad854a3", "key": "keyId", "value": "" }, { - "id": "85a6000e-572d-4d57-9530-a7ff347ba6fe", + "id": "3ca5880b-b386-430b-9f27-eea5ebfbfb1e", "key": "peerId", "value": "" }, { - "id": "b5326003-aa0a-488b-8374-24e724cf8088", + "id": "2322ddf9-43f0-41a6-b515-2a9258825f5b", "key": "paymentPointerKeyId", "value": "" }, { - "id": "2fe86b77-7ef8-4fa6-b0a2-068ba6a6b1d5", + "id": "ec874c4d-cd4b-454a-9e1b-d9b5a76f113c", "key": "secureOpenPaymentsHost", "value": "" }, { - "id": "e39ab091-3cd9-4276-88f1-a19191e96613", + "id": "d4170010-6447-4fef-b08d-84ca0f9885bb", "key": "tokenManagementUrl", "value": "" }, { - "id": "4f1a7546-22a6-40f0-9846-d6c86abd6e87", + "id": "e5cbb52e-397c-40ae-8abb-69785df85df0", "key": "quoteSendAmountValue", "value": "" }, { - "id": "05604f7d-620c-4be8-a3d0-715c89741970", + "id": "67ca2f99-ac86-422e-9b68-624620213508", "key": "quoteReceiveAmountValue", "value": "" }, { - "id": "a0f4b62c-53f8-4db5-a7e4-88372ff765f5", + "id": "b3bc3f0c-da96-4ec8-a0eb-c822bbe7fd6c", "key": "idempotencyKey", "value": "" }, { - "id": "d8c534c9-a961-49a7-878b-a4b9def77643", + "id": "493478f0-6ea2-4f69-bc5e-a27cbb0122d5", "key": "receiverId", "value": "" }