diff --git a/.changeset/clean-horses-rule.md b/.changeset/clean-horses-rule.md new file mode 100644 index 00000000..0274cc9a --- /dev/null +++ b/.changeset/clean-horses-rule.md @@ -0,0 +1,5 @@ +--- +"@tbdex/protocol": minor +--- + +Added Cancel message support diff --git a/packages/protocol/build/compile-validators.js b/packages/protocol/build/compile-validators.js index 8591e7ec..e4932547 100644 --- a/packages/protocol/build/compile-validators.js +++ b/packages/protocol/build/compile-validators.js @@ -17,6 +17,7 @@ import standaloneCode from 'ajv/dist/standalone/index.js' import { mkdirp } from 'mkdirp' +import CancelSchema from '../../../tbdex/hosted/json-schemas/cancel.schema.json' assert { type: 'json' } import CloseSchema from '../../../tbdex/hosted/json-schemas/close.schema.json' assert { type: 'json' } import DefinitionsSchema from '../../../tbdex/hosted/json-schemas/definitions.json' assert { type: 'json' } import OfferingSchema from '../../../tbdex/hosted/json-schemas/offering.schema.json' assert { type: 'json' } @@ -31,6 +32,7 @@ import RfqPrivateSchema from '../../../tbdex/hosted/json-schemas/rfq-private.sch import RfqSchema from '../../../tbdex/hosted/json-schemas/rfq.schema.json' assert { type: 'json' } const schemas = { + cancel : CancelSchema, close : CloseSchema, definitions : DefinitionsSchema, offering : OfferingSchema, diff --git a/packages/protocol/src/exchange.ts b/packages/protocol/src/exchange.ts index 8e6cd1d9..8f5656c1 100644 --- a/packages/protocol/src/exchange.ts +++ b/packages/protocol/src/exchange.ts @@ -1,4 +1,4 @@ -import { Close, Order, OrderInstructions, OrderStatus, Quote, Rfq } from './message-kinds/index.js' +import { Cancel, Close, Order, OrderInstructions, OrderStatus, Quote, Rfq } from './message-kinds/index.js' import { Message } from './message.js' import { MessageKind } from './types.js' @@ -24,8 +24,10 @@ export class Exchange { orderInstructions: OrderInstructions | undefined /** Message sent by the PFI to Alice to convey the current status of the order */ orderstatus: OrderStatus[] - /** Message sent by either the PFI or Alice to terminate an exchange */ + /** Message sent by the PFI to terminate an exchange */ close: Close | undefined + /** Message sent by Alice to indicate that she does not wish to further propagate the exchange, and get a refund if applicable */ + cancel: Cancel | undefined constructor() { this.orderstatus = [] @@ -81,7 +83,9 @@ export class Exchange { this.rfq = message } else if (message.isQuote()) { this.quote = message - } else if (message.isClose()) { + } else if (message.isCancel()) { + this.cancel = message + } else if (message.isClose()) { this.close = message } else if (message.isOrder()) { this.order = message @@ -109,7 +113,8 @@ export class Exchange { * Latest message in an exchange if there are any messages currently */ get latestMessage(): Message | undefined { - return this.close ?? + return this.cancel ?? + this.close ?? this.orderstatus[this.orderstatus.length - 1] ?? this.orderInstructions ?? this.order ?? @@ -141,7 +146,8 @@ export class Exchange { this.order, this.orderInstructions, ...this.orderstatus, - this.close + this.close, + this.cancel ] return allPossibleMessages.filter((message): message is Message => message !== undefined) } diff --git a/packages/protocol/src/message-kinds/cancel.ts b/packages/protocol/src/message-kinds/cancel.ts new file mode 100644 index 00000000..a3b2d376 --- /dev/null +++ b/packages/protocol/src/message-kinds/cancel.ts @@ -0,0 +1,73 @@ +import type { MessageKind, MessageModel, CancelData, CancelMetadata } from '../types.js' +import { Message } from '../message.js' +import { Parser } from '../parser.js' + +/** + * Options passed to {@link Cancel.create} + * @beta + */ +export type CreateCancelOptions = { + data: CancelData + metadata: Omit & { protocol?: CancelMetadata['protocol'] } +} + +/** + * Sent by Alice to indicate that she does not wish to further propagate the exchange, and get a refund if applicable. + * @beta + */ +export class Cancel extends Message { + /** The message kind `cancel`. */ + readonly kind = 'cancel' + + /** A set of valid Message kinds that can come after a Cancel */ + readonly validNext = new Set(['orderstatus', 'close']) + + /** Metadata such as sender, recipient, date created, and ID */ + readonly metadata: CancelMetadata + + /** Cancel's data containing a reason why the exchange was canceled */ + readonly data: CancelData + + constructor(metadata: CancelMetadata, data: CancelData, signature?: string) { + super(metadata, data, signature) + this.metadata = metadata + this.data = data + } + + /** + * Parses a JSON message into a Cancel. + * @param rawMessage - The Cancel to parse. + * @throws Error if the Cancel could not be parsed or is not a valid Cancel. + * @returns The parsed Cancel. + */ + static async parse(rawMessage: MessageModel | string): Promise { + const jsonMessage = Parser.rawToMessageModel(rawMessage) + + const cancel = new Cancel( + jsonMessage.metadata as CancelMetadata, + jsonMessage.data as CancelData, + jsonMessage.signature + ) + + await cancel.verify() + return cancel + } + + /** + * Creates an Cancel with the given options. + * @param opts - Options to create an Cancel. + */ + static create(opts: CreateCancelOptions): Cancel { + const metadata: CancelMetadata = { + ...opts.metadata, + kind : 'cancel', + id : Message.generateId('cancel'), + createdAt : new Date().toISOString(), + protocol : opts.metadata.protocol ?? '1.0' + } + + const cancel = new Cancel(metadata, opts.data) + cancel.validateData() + return cancel + } +} \ No newline at end of file diff --git a/packages/protocol/src/message-kinds/close.ts b/packages/protocol/src/message-kinds/close.ts index ac0563dc..d5b27be7 100644 --- a/packages/protocol/src/message-kinds/close.ts +++ b/packages/protocol/src/message-kinds/close.ts @@ -12,17 +12,19 @@ export type CreateCloseOptions = { } /** - * A Close can be sent by Alice or the PFI as a reply to an RFQ or a Quote + * A Close can only be sent the PFI as a reply to an RFQ, Quote, Order, OrderInstructions, OrderStatus, or Cancel * @beta */ export class Close extends Message { - /** A set of valid Message kinds that can come after a close */ - readonly validNext = new Set([]) /** The message kind (close) */ readonly kind = 'close' + /** A set of valid Message kinds that can come after a close */ + readonly validNext = new Set([]) + /** Metadata such as sender, recipient, date created, and ID */ readonly metadata: CloseMetadata + /** Close's data containing a reason why the exchange was closed */ readonly data: CloseData @@ -68,9 +70,4 @@ export class Close extends Message { close.validateData() return close } - - /** an explanation of why the exchange is being closed */ - get reason() { - return this.data.reason - } } \ No newline at end of file diff --git a/packages/protocol/src/message-kinds/index.ts b/packages/protocol/src/message-kinds/index.ts index efb8af3f..8a1cdcc7 100644 --- a/packages/protocol/src/message-kinds/index.ts +++ b/packages/protocol/src/message-kinds/index.ts @@ -4,3 +4,4 @@ export * from './order.js' export * from './order-instructions.js' export * from './order-status.js' export * from './close.js' +export * from './cancel.js' diff --git a/packages/protocol/src/message-kinds/order-instructions.ts b/packages/protocol/src/message-kinds/order-instructions.ts index 08e0f0cf..0f090770 100644 --- a/packages/protocol/src/message-kinds/order-instructions.ts +++ b/packages/protocol/src/message-kinds/order-instructions.ts @@ -17,7 +17,7 @@ export type CreateOrderInstructionsOptions = { */ export class OrderInstructions extends Message { /** A set of valid Message kinds that can come after an Order Instructions */ - readonly validNext = new Set(['orderstatus', 'close']) + readonly validNext = new Set(['orderstatus', 'close', 'cancel']) /** The message kind `orderinstructions`. */ readonly kind = 'orderinstructions' diff --git a/packages/protocol/src/message-kinds/order-status.ts b/packages/protocol/src/message-kinds/order-status.ts index 5ec99893..d3b5d9de 100644 --- a/packages/protocol/src/message-kinds/order-status.ts +++ b/packages/protocol/src/message-kinds/order-status.ts @@ -18,7 +18,7 @@ export type CreateOrderStatusOptions = { */ export class OrderStatus extends Message { /** a set of valid Message kinds that can come after an order status */ - readonly validNext = new Set(['orderstatus', 'close']) + readonly validNext = new Set(['orderstatus', 'close', 'cancel']) /** The message kind (orderstatus) */ readonly kind = 'orderstatus' diff --git a/packages/protocol/src/message-kinds/order.ts b/packages/protocol/src/message-kinds/order.ts index 68660960..05bb0eae 100644 --- a/packages/protocol/src/message-kinds/order.ts +++ b/packages/protocol/src/message-kinds/order.ts @@ -16,7 +16,7 @@ export type CreateOrderOptions = { */ export class Order extends Message { /** a set of valid Message kinds that can come after an order */ - readonly validNext = new Set(['orderinstructions', 'close']) + readonly validNext = new Set(['orderinstructions', 'close', 'cancel']) /** The message kind (order) */ readonly kind = 'order' diff --git a/packages/protocol/src/message-kinds/quote.ts b/packages/protocol/src/message-kinds/quote.ts index fdab9522..a1c9514d 100644 --- a/packages/protocol/src/message-kinds/quote.ts +++ b/packages/protocol/src/message-kinds/quote.ts @@ -18,7 +18,7 @@ export type CreateQuoteOptions = { */ export class Quote extends Message { /** a set of valid Message kinds that can come after a quote */ - readonly validNext = new Set(['order', 'close']) + readonly validNext = new Set(['order', 'close', 'cancel']) /** The message kind (quote) */ readonly kind = 'quote' diff --git a/packages/protocol/src/message-kinds/rfq.ts b/packages/protocol/src/message-kinds/rfq.ts index 20cf15ba..c2a27a9d 100644 --- a/packages/protocol/src/message-kinds/rfq.ts +++ b/packages/protocol/src/message-kinds/rfq.ts @@ -39,7 +39,7 @@ export type ParseRfqOptions = { */ export class Rfq extends Message { /** a set of valid Message kinds that can come after an rfq */ - readonly validNext = new Set(['quote', 'close']) + readonly validNext = new Set(['quote', 'close', 'cancel']) /** The message kind (rfq) */ readonly kind = 'rfq' diff --git a/packages/protocol/src/message.ts b/packages/protocol/src/message.ts index d2a5f28f..13318334 100644 --- a/packages/protocol/src/message.ts +++ b/packages/protocol/src/message.ts @@ -1,5 +1,5 @@ import type { MessageKind, MessageModel, MessageMetadata, MessageData } from './types.js' -import { Rfq, Quote, Order, OrderInstructions, OrderStatus, Close } from './message-kinds/index.js' +import { Rfq, Quote, Order, OrderInstructions, OrderStatus, Close, Cancel } from './message-kinds/index.js' import { Crypto } from './crypto.js' import { typeid } from 'typeid-js' @@ -188,6 +188,11 @@ export abstract class Message { return this.metadata.kind === 'close' } + /** Cancel type guard */ + isCancel(): this is Cancel { + return this.metadata.kind === 'cancel' + } + /** * returns the message as a json object. Automatically used by `JSON.stringify` method. */ diff --git a/packages/protocol/src/parser.ts b/packages/protocol/src/parser.ts index e5860a15..03ee10e9 100644 --- a/packages/protocol/src/parser.ts +++ b/packages/protocol/src/parser.ts @@ -1,10 +1,10 @@ -import type { MessageModel, ResourceModel, RfqMetadata, RfqData, QuoteData, QuoteMetadata, OrderData, OrderMetadata, OrderStatusMetadata, OrderStatusData, CloseMetadata, CloseData, OfferingMetadata, OfferingData, BalanceMetadata, BalanceData, OrderInstructionsMetadata, OrderInstructionsData } from './types.js' +import type { MessageModel, ResourceModel, RfqMetadata, RfqData, QuoteData, QuoteMetadata, OrderData, OrderMetadata, OrderStatusMetadata, OrderStatusData, CancelData, CancelMetadata, CloseMetadata, CloseData, OfferingMetadata, OfferingData, BalanceMetadata, BalanceData, OrderInstructionsMetadata, OrderInstructionsData } from './types.js' // eslint-disable-next-line @typescript-eslint/no-unused-vars import type { Resource } from './resource.js' import type { Message } from './message.js' -import { Rfq, Quote, Order, OrderInstructions, OrderStatus, Close } from './message-kinds/index.js' +import { Rfq, Quote, Order, OrderInstructions, OrderStatus, Close, Cancel } from './message-kinds/index.js' import { Balance, Offering } from './resource-kinds/index.js' /** @@ -77,6 +77,14 @@ export class Parser { ) break + case 'cancel': + message = new Cancel( + jsonMessage.metadata as CancelMetadata, + jsonMessage.data as CancelData, + jsonMessage.signature + ) + break + default: throw new Error(`Unrecognized message kind (${jsonMessage.metadata.kind})`) } diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index bb7c16e0..2fc08872 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -266,17 +266,23 @@ export type OrderStatusMetadata = MessageMetadata & { kind: 'orderstatus' } */ export type CloseMetadata = MessageMetadata & { kind: 'close' } +/** + * Cancel's metadata + * @beta + */ +export type CancelMetadata = MessageMetadata & { kind: 'cancel' } + /** * Type alias to represent a set of message kind string keys * @beta */ -export type MessageKind = 'rfq' | 'quote' | 'order' | 'orderinstructions' | 'orderstatus' | 'close' +export type MessageKind = 'rfq' | 'quote' | 'order' | 'orderinstructions' | 'orderstatus' | 'close' | 'cancel' /** * Message's data * @beta */ -export type MessageData = RfqData | QuoteData | OrderData | OrderInstructionsData | OrderStatusData | CloseData +export type MessageData = RfqData | QuoteData | OrderData | OrderInstructionsData | OrderStatusData | CloseData | CancelData /** * Data contained in a RFQ message @@ -478,7 +484,7 @@ export enum OrderStatusEnum { } /** - * A Close can be sent by Alice or the PFI as a reply to an RFQ or a Quote + * A Close, which only the PFI can send, can be submitted at any point during the exchange to indicate that the transaction has reached a terminal state. * @beta */ export type CloseData = { @@ -487,3 +493,12 @@ export type CloseData = { /** indicates whether or not the exchange successfully completed */ success?: boolean } + +/** + * A Cancel can only be sent by Alice to indicate that she does not wish to further propagate the exchange, and get a refund if applicable. + * @beta + */ +export type CancelData = { + /** an explanation of why the exchange is being canceled */ + reason?: string +} diff --git a/packages/protocol/tests/exchange.spec.ts b/packages/protocol/tests/exchange.spec.ts index 434874b9..3062cf68 100644 --- a/packages/protocol/tests/exchange.spec.ts +++ b/packages/protocol/tests/exchange.spec.ts @@ -1,13 +1,13 @@ import { BearerDid, DidDht, DidJwk } from '@web5/dids' import { expect } from 'chai' -import { Close, DevTools, Exchange, Message, Order, OrderInstructions, OrderStatus, OrderStatusEnum, Quote, Rfq } from '../src/main.js' +import { Cancel, Close, DevTools, Exchange, Message, Order, OrderInstructions, OrderStatus, OrderStatusEnum, Quote, Rfq } from '../src/main.js' describe('Exchange', () => { let aliceDid: BearerDid let pfiDid: BearerDid let rfq: Rfq let quote: Quote - let closeByAlice: Close + let cancelByAlice: Cancel let closeByPfi: Close let order: Order let orderInstructions: OrderInstructions @@ -26,7 +26,7 @@ describe('Exchange', () => { }) await rfq.sign(aliceDid) - closeByAlice = Close.create({ + cancelByAlice = Cancel.create({ metadata: { from : aliceDid.uri, to : pfiDid.uri, @@ -36,7 +36,7 @@ describe('Exchange', () => { reason: 'I dont like u anymore' } }) - await closeByAlice.sign(aliceDid) + await cancelByAlice.sign(aliceDid) quote = Quote.create({ metadata: { @@ -184,7 +184,7 @@ describe('Exchange', () => { describe('message sequence', () => { it('can add an Rfq first but not other message kinds first', async () => { const exchange = new Exchange() - for (const message of [quote, closeByAlice, closeByPfi, order, orderInstructions, orderStatus]) { + for (const message of [quote, cancelByAlice, closeByPfi, order, orderInstructions, orderStatus]) { try { exchange.addNextMessage(message) expect.fail() @@ -223,8 +223,16 @@ describe('Exchange', () => { const exchange = new Exchange() exchange.addNextMessage(rfq) - exchange.addNextMessage(closeByAlice) - expect(exchange.close).to.deep.eq(closeByAlice) + exchange.addNextMessage(closeByPfi) + expect(exchange.close).to.deep.eq(closeByPfi) + }) + + it('can add a Cancel after Rfq', async () => { + const exchange = new Exchange() + exchange.addNextMessage(rfq) + + exchange.addNextMessage(cancelByAlice) + expect(exchange.cancel).to.deep.eq(cancelByAlice) }) it('can add a Close after Quote', async () => { @@ -235,12 +243,53 @@ describe('Exchange', () => { expect(exchange.close).to.deep.eq(closeByPfi) }) - it('cannot add Rfq, Quote, Order, OrderInstructions, OrderStatus, or Close after Close', async () => { + it('can add a Cancel after Quote', async () => { + const exchange = new Exchange() + exchange.addMessages([rfq, quote]) + + exchange.addNextMessage(cancelByAlice) + expect(exchange.cancel).to.deep.eq(cancelByAlice) + }) + + it('cannot add Rfq, Quote, Order, OrderInstructions, OrderStatus, Close, or Cancel after Close', async () => { + const exchange = new Exchange() + exchange.addMessages([rfq, quote]) + exchange.addNextMessage(closeByPfi) + + for (const message of [rfq, quote, order, orderInstructions, orderStatus, closeByPfi, cancelByAlice]) { + try { + exchange.addNextMessage(message) + expect.fail() + } catch (e) { + expect(e.message).to.contain('is not a valid next message') + } + } + }) + + it('can add a Close after Cancel', async () => { + const exchange = new Exchange() + exchange.addMessages([rfq, quote]) + exchange.addNextMessage(cancelByAlice) + + exchange.addNextMessage(closeByPfi) + expect(exchange.close).to.deep.eq(closeByPfi) + }) + + it('can add a OrderStatus after Cancel', async () => { + const exchange = new Exchange() + exchange.addMessages([rfq, quote]) + exchange.addNextMessage(cancelByAlice) + + exchange.addNextMessage(orderStatus) + expect(exchange.orderstatus).to.deep.eq([orderStatus]) + }) + + it('cannot add Rfq, Quote, Order, OrderInstructions, or Cancel after Cancel', async () => { const exchange = new Exchange() exchange.addMessages([rfq, quote]) - exchange.addNextMessage(closeByAlice) + exchange.addNextMessage(cancelByAlice) - for (const message of [rfq, quote, order, orderInstructions, orderStatus, closeByAlice]) { + for (const message of [rfq, quote, order, orderInstructions, cancelByAlice]) { try { exchange.addNextMessage(message) expect.fail() @@ -289,6 +338,14 @@ describe('Exchange', () => { expect(exchange.close).to.deep.eq(closeByPfi) }) + it('can add a Cancel after Order', async () => { + const exchange = new Exchange() + exchange.addMessages([rfq, quote, order]) + + exchange.addNextMessage(cancelByAlice) + expect(exchange.cancel).to.deep.eq(cancelByAlice) + }) + it('cannot add Rfq, Quote, Order, or OrderStatus after Order', async () => { const exchange = new Exchange() exchange.addMessages([rfq, quote, order]) @@ -312,6 +369,42 @@ describe('Exchange', () => { expect(exchange.orderstatus).to.deep.eq([orderStatus]) }) + it('can add a Close after OrderInstructions', async () => { + const exchange = new Exchange() + + exchange.addMessages([rfq, quote, order, orderInstructions]) + + exchange.addNextMessage(closeByPfi) + expect(exchange.close).to.deep.eq(closeByPfi) + }) + + it('can add a Cancel after OrderInstructions', async () => { + const exchange = new Exchange() + + exchange.addMessages([rfq, quote, order, orderInstructions]) + + exchange.addNextMessage(cancelByAlice) + expect(exchange.cancel).to.deep.eq(cancelByAlice) + }) + + it('can add a Close after OrderStatus', async () => { + const exchange = new Exchange() + + exchange.addMessages([rfq, quote, order, orderInstructions, orderStatus]) + + exchange.addNextMessage(closeByPfi) + expect(exchange.close).to.deep.eq(closeByPfi) + }) + + it('can add a Cancel after OrderStatus', async () => { + const exchange = new Exchange() + + exchange.addMessages([rfq, quote, order, orderInstructions, orderStatus]) + + exchange.addNextMessage(cancelByAlice) + expect(exchange.cancel).to.deep.eq(cancelByAlice) + }) + it('cannot add Rfq, Quote, Order, or OrderInstructions after OrderInstructions', async () => { const exchange = new Exchange() exchange.addMessages([rfq, quote, order, orderInstructions]) diff --git a/packages/protocol/tests/test-vectors.spec.ts b/packages/protocol/tests/test-vectors.spec.ts index 9df572b8..9563dfcc 100644 --- a/packages/protocol/tests/test-vectors.spec.ts +++ b/packages/protocol/tests/test-vectors.spec.ts @@ -1,4 +1,5 @@ import { expect } from 'chai' +import ParseCancel from '../../../tbdex/hosted/test-vectors/protocol/vectors/parse-cancel.json' assert { type: 'json' } import ParseClose from '../../../tbdex/hosted/test-vectors/protocol/vectors/parse-close.json' assert { type: 'json' } import ParseOffering from '../../../tbdex/hosted/test-vectors/protocol/vectors/parse-offering.json' assert { type: 'json' } import ParseOrder from '../../../tbdex/hosted/test-vectors/protocol/vectors/parse-order.json' assert { type: 'json' } @@ -8,7 +9,7 @@ import ParseQuote from '../../../tbdex/hosted/test-vectors/protocol/vectors/pars import ParseRfq from '../../../tbdex/hosted/test-vectors/protocol/vectors/parse-rfq.json' assert { type: 'json' } import ParseOmitPrivateData from '../../../tbdex/hosted/test-vectors/protocol/vectors/parse-rfq-omit-private-data.json' assert { type: 'json' } import ParseBalance from '../../../tbdex/hosted/test-vectors/protocol/vectors/parse-balance.json' assert { type: 'json' } -import { Balance, Close, Message, Offering, Order, OrderInstructions, OrderStatus, Quote, Resource, Rfq } from '../src/main.js' +import { Balance, Cancel, Close, Message, Offering, Order, OrderInstructions, OrderStatus, Quote, Resource, Rfq } from '../src/main.js' import { Parser } from '../src/parser.js' import Sinon from 'sinon' @@ -29,6 +30,21 @@ describe('TbdexTestVectorsProtocol', function () { resourceVerifyStub.restore() }) + it('parse_cancel', async () => { + // Parse with parseMessage() + const message = await Parser.parseMessage(ParseCancel.input) + expect(messageVerifyStub.calledOnce).to.be.true + expect(message.isCancel()).to.be.true + expect(message.toJSON()).to.deep.eq(ParseCancel.output) + messageVerifyStub.resetHistory() + + // Parse with Cancel.parse() + const cancel = await Cancel.parse(ParseCancel.input) + expect(messageVerifyStub.calledOnce).to.be.true + expect(cancel.isCancel()).to.be.true + expect(cancel.toJSON()).to.deep.eq(ParseCancel.output) + }) + it('parse_close', async () => { // Parse with parseMessage() const message = await Parser.parseMessage(ParseClose.input)