Skip to content

Commit

Permalink
Finish validating rfq against provided offering (#120)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
kirahsapong authored Dec 19, 2023
1 parent e31fa7b commit c3610ed
Show file tree
Hide file tree
Showing 4 changed files with 190 additions and 7 deletions.
5 changes: 5 additions & 0 deletions .changeset/soft-lizards-peel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tbdex/protocol": patch
---

Adds more checks to validate an RFQ against a provided Offering
3 changes: 2 additions & 1 deletion packages/protocol/src/dev-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
54 changes: 49 additions & 5 deletions packages/protocol/src/message-kinds/rfq.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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)
}
Expand Down
135 changes: 134 additions & 1 deletion packages/protocol/tests/rfq.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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()
Expand Down

0 comments on commit c3610ed

Please sign in to comment.