From c3610edd78e99121601080e7855f4a4f820c6952 Mon Sep 17 00:00:00 2001 From: kirahsapong <102400653+kirahsapong@users.noreply.github.com> Date: Tue, 19 Dec 2023 12:07:09 -0800 Subject: [PATCH] Finish validating rfq against provided offering (#120) * Add test for offering id mismatch * validate rfq's quoteAmountSubunits against offering's quoteCurrency min/max * validate rfq's payinMethod.kind against offering's payinMethods * validate rfq's payinMethod.paymentDetails against offering's respective requiredPaymentDetails json schema * validate rfq's payoutMethod.paymentDetails against offering's respective requiredPaymentDetails json schema * refactor test mocks * fix tbdocs * Add changeset --- .changeset/soft-lizards-peel.md | 5 + packages/protocol/src/dev-tools.ts | 3 +- packages/protocol/src/message-kinds/rfq.ts | 54 ++++++++- packages/protocol/tests/rfq.spec.ts | 135 ++++++++++++++++++++- 4 files changed, 190 insertions(+), 7 deletions(-) create mode 100644 .changeset/soft-lizards-peel.md diff --git a/.changeset/soft-lizards-peel.md b/.changeset/soft-lizards-peel.md new file mode 100644 index 00000000..b564cb9c --- /dev/null +++ b/.changeset/soft-lizards-peel.md @@ -0,0 +1,5 @@ +--- +"@tbdex/protocol": patch +--- + +Adds more checks to validate an RFQ against a provided Offering diff --git a/packages/protocol/src/dev-tools.ts b/packages/protocol/src/dev-tools.ts index fa09007a..8c632d69 100644 --- a/packages/protocol/src/dev-tools.ts +++ b/packages/protocol/src/dev-tools.ts @@ -79,7 +79,8 @@ export class DevTools { const offeringData: OfferingData = { description : 'Selling BTC for USD', payinCurrency : { - currencyCode: 'USD' + currencyCode : 'USD', + maxSubunits : '99999999' }, payoutCurrency: { currencyCode : 'BTC', diff --git a/packages/protocol/src/message-kinds/rfq.ts b/packages/protocol/src/message-kinds/rfq.ts index bf54a0f2..bcc6bee4 100644 --- a/packages/protocol/src/message-kinds/rfq.ts +++ b/packages/protocol/src/message-kinds/rfq.ts @@ -3,6 +3,7 @@ import type { MessageKind, MessageKindModel, MessageMetadata, ResourceModel } fr import { Offering } from '../resource-kinds/index.js' import { VerifiableCredential, PresentationExchange } from '@web5/credentials' import { Message } from '../message.js' +import Ajv from 'ajv' /** * Options passed to {@link Rfq.create} @@ -51,19 +52,62 @@ export class Rfq extends Message<'rfq'> { * evaluates this rfq against the provided offering * @param offering - the offering to evaluate this rfq against * @throws if {@link Rfq.offeringId} doesn't match the provided offering's id + * @throws if {@link Rfq.payinSubunits} exceeds the provided offering's max subunits allowed + * @throws if {@link Rfq.payinMethod} property `kind` cannot be validated against the provided offering's payinMethod kinds + * @throws if {@link Rfq.payinMethod} property `paymentDetails` cannot be validated against the provided offering's payinMethod requiredPaymentDetails + * @throws if {@link Rfq.payoutMethod} property `kind` cannot be validated against the provided offering's payoutMethod kinds + * @throws if {@link Rfq.payoutMethod} property `paymentDetails` cannot be validated against the provided offering's payoutMethod requiredPaymentDetails */ async verifyOfferingRequirements(offering: Offering | ResourceModel<'offering'>) { if (offering.metadata.id !== this.offeringId) { throw new Error(`offering id mismatch. (rfq) ${this.offeringId} !== ${offering.metadata.id} (offering)`) } - // TODO: validate rfq's quoteAmountSubunits against offering's quoteCurrency min/max + if (this.payinSubunits > offering.data.payinCurrency.maxSubunits) { + throw new Error(`rfq payinSubunits exceeds offering's maxSubunits. (rfq) ${this.payinSubunits} > ${offering.data.payinCurrency.maxSubunits} (offering)`) + } + + const payinMethodMatches = offering.data.payinMethods.filter(payinMethod => payinMethod.kind === this.payinMethod.kind) + + if (!payinMethodMatches.length) { + throw new Error(`offering does not support rfq's payinMethod kind. (rfq) ${this.payinMethod.kind} was not found in: ${offering.data.payinMethods.map(payinMethod => payinMethod.kind).join()} (offering)`) + } + + const ajv = new Ajv.default() + const invalidPayinDetailsErrors = new Set() + + for (const payinMethodMatch of payinMethodMatches) { + const validate = ajv.compile(payinMethodMatch.requiredPaymentDetails) + const isValid = validate(this.payinMethod.paymentDetails) + if (isValid) { + break + } + invalidPayinDetailsErrors.add(validate.errors) + } - // TODO: validate rfq's payinMethod.kind against offering's payinMethods - // TODO: validate rfq's payinMethod.paymentDetails against offering's respective requiredPaymentDetails json schema + if (invalidPayinDetailsErrors.size > 0) { + throw new Error(`rfq payinMethod paymentDetails could not be validated against offering requiredPaymentDetails. Schema validation errors: ${Array.from(invalidPayinDetailsErrors).join()}`) + } + + const payoutMethodMatches = offering.data.payoutMethods.filter(payoutMethod => payoutMethod.kind === this.payoutMethod.kind) + + if (!payoutMethodMatches.length) { + throw new Error(`offering does not support rfq's payoutMethod kind. (rfq) ${this.payoutMethod.kind} was not found in: ${offering.data.payoutMethods.map(payoutMethod => payoutMethod.kind).join()} (offering)`) + } - // TODO: validate rfq's payoutMethod.kind against offering's payoutMethods - // TODO: validate rfq's payoutMethod.paymentDetails against offering's respective requiredPaymentDetails json schema + const invalidPayoutDetailsErrors = new Set() + + for (const payoutMethodMatch of payoutMethodMatches) { + const validate = ajv.compile(payoutMethodMatch.requiredPaymentDetails) + const isValid = validate(this.payoutMethod.paymentDetails) + if (isValid) { + break + } + invalidPayoutDetailsErrors.add(validate.errors) + } + if (invalidPayoutDetailsErrors.size > 0) { + throw new Error(`rfq payoutMethod paymentDetails could not be validated against offering requiredPaymentDetails. Schema validation errors: ${Array.from(invalidPayoutDetailsErrors).join()}`) + } await this.verifyClaims(offering) } diff --git a/packages/protocol/tests/rfq.spec.ts b/packages/protocol/tests/rfq.spec.ts index 0752f527..e98ba9a9 100644 --- a/packages/protocol/tests/rfq.spec.ts +++ b/packages/protocol/tests/rfq.spec.ts @@ -1,4 +1,4 @@ -import type { RfqData } from '../src/main.js' +import type { CreateRfqOptions, Offering, RfqData } from '../src/main.js' import { Rfq, DevTools } from '../src/main.js' import { Convert } from '@web5/common' @@ -158,6 +158,139 @@ describe('Rfq', () => { }) }) + describe('verifyOfferingRequirements', () => { + const offering: Offering = DevTools.createOffering() + const rfqOptions: CreateRfqOptions = { + metadata: { + from : '', + to : 'did:ex:pfi' + }, + data: { + ...rfqData, + offeringId: offering.id, + } + } + before(async () => { + const did = await DevTools.createDid() + const { signedCredential } = await DevTools.createCredential({ // this credential fulfills the offering's required claims + type : 'SanctionsCredential', + issuer : did, + subject : did.did, + data : { + 'beep': 'boop' + } + }) + rfqOptions.metadata.from = did.did + rfqOptions.data.claims = [signedCredential] + }) + it('throws an error if offeringId doesn\'t match the provided offering\'s id', async () => { + const rfq = Rfq.create({ + ...rfqOptions, + data: { + ...rfqOptions.data, + offeringId: 'ABC123456', + } + }) + try { + await rfq.verifyOfferingRequirements(offering) + expect.fail() + } catch(e) { + expect(e.message).to.include('offering id mismatch') + } + }) + it('throws an error if payinSubunits exceeds the provided offering\'s maxSubunits', async () => { + const rfq = Rfq.create({ + ...rfqOptions, + data: { + ...rfqOptions.data, + payinSubunits: '99999999999999999' + } + }) + try { + await rfq.verifyOfferingRequirements(offering) + expect.fail() + } catch(e) { + expect(e.message).to.include('rfq payinSubunits exceeds offering\'s maxSubunits') + } + }) + it('throws an error if payinMethod kind cannot be validated against the provided offering\'s payinMethod kinds', async () => { + const rfq = Rfq.create({ + ...rfqOptions, + data: { + ...rfqOptions.data, + payinMethod: { + ...rfqOptions.data.payinMethod, + kind: 'POKEMON' + } + } + }) + try { + await rfq.verifyOfferingRequirements(offering) + expect.fail() + } catch(e) { + expect(e.message).to.include('offering does not support rfq\'s payinMethod kind') + } + }) + it('throws an error if payinMethod paymentDetails cannot be validated against the provided offering\'s payinMethod requiredPaymentDetails', async () => { + const rfq = Rfq.create({ + ...rfqOptions, + data: { + ...rfqOptions.data, + payinMethod: { + ...rfqOptions.data.payinMethod, + paymentDetails: { + beep: 'boop' + } + } + } + }) + try { + await rfq.verifyOfferingRequirements(offering) + expect.fail() + } catch(e) { + expect(e.message).to.include('rfq payinMethod paymentDetails could not be validated against offering requiredPaymentDetails') + } + }) + it('throws an error if payoutMethod kind cannot be validated against the provided offering\'s payoutMethod kinds', async () => { + const rfq = Rfq.create({ + ...rfqOptions, + data: { + ...rfqOptions.data, + payoutMethod: { + ...rfqOptions.data.payoutMethod, + kind: 'POKEMON' + } + } + }) + try { + await rfq.verifyOfferingRequirements(offering) + expect.fail() + } catch(e) { + expect(e.message).to.include('offering does not support rfq\'s payoutMethod kind') + } + }) + it('throws an error if payoutMethod paymentDetails cannot be validated against the provided offering\'s payoutMethod requiredPaymentDetails', async () => { + const rfq = Rfq.create({ + ...rfqOptions, + data: { + ...rfqOptions.data, + payoutMethod: { + ...rfqOptions.data.payoutMethod, + paymentDetails: { + beep: 'boop' + } + } + } + }) + try { + await rfq.verifyOfferingRequirements(offering) + expect.fail() + } catch(e) { + expect(e.message).to.include('rfq payoutMethod paymentDetails could not be validated against offering requiredPaymentDetails') + } + }) + }) + describe('verifyClaims', () => { it(`does not throw an exception if an rfq's claims fulfill the provided offering's requirements`, async () => { const did = await DevTools.createDid()