From 589edc3aa0eac173d1c710f1f15f9cf9cfb983ec Mon Sep 17 00:00:00 2001 From: Diane Huxley Date: Tue, 13 Feb 2024 09:58:36 -0800 Subject: [PATCH] Add exchange state machine (#168) * Add exchange state machine * Fix tsdoc warnings * lint * Changeset * Additional tests * Fix --- .changeset/green-cheetahs-share.md | 5 + packages/protocol/src/exchange.ts | 124 ++++++++ packages/protocol/src/main.ts | 1 + packages/protocol/src/message-kinds/rfq.ts | 2 +- packages/protocol/tests/exchange.spec.ts | 340 +++++++++++++++++++++ 5 files changed, 471 insertions(+), 1 deletion(-) create mode 100644 .changeset/green-cheetahs-share.md create mode 100644 packages/protocol/src/exchange.ts create mode 100644 packages/protocol/tests/exchange.spec.ts diff --git a/.changeset/green-cheetahs-share.md b/.changeset/green-cheetahs-share.md new file mode 100644 index 00000000..f964e5b6 --- /dev/null +++ b/.changeset/green-cheetahs-share.md @@ -0,0 +1,5 @@ +--- +"@tbdex/protocol": patch +--- + +Add exchange state machine diff --git a/packages/protocol/src/exchange.ts b/packages/protocol/src/exchange.ts new file mode 100644 index 00000000..85080aff --- /dev/null +++ b/packages/protocol/src/exchange.ts @@ -0,0 +1,124 @@ +import { Close, Order, OrderStatus, Quote, Rfq } from './message-kinds/index.js' +import { Message } from './message.js' +import { MessageKind } from './types.js' + +/** + * State-machine for validating the order and metadata of Tbdex messages in an exchange. + * + * This state-machine does not validate the {@link Message.signature} or {@link Message.data} + * of messages in the exchange. + * + * Either add messages in order one at a time using {@link Exchange.addNextMessage}, + * or add a list of unsorted messages in an exchange using {@link Exchange.addMessages} + * + * @beta + */ +export class Exchange { + /** Message sent by Alice to PFI to request a quote */ + rfq: Rfq | undefined + /** Message sent by the PFI in response to an RFQ */ + quote: Quote | undefined + /** Message sent by Alice to the PFI to accept a quote*/ + order: Order | undefined + /** Message sent by the PFI to Alice to convet the current status of the order */ + orderstatus: OrderStatus | undefined + /** Message sent by either the PFI or Alice to terminate an exchange */ + close: Close | undefined + + constructor() {} + + /** + * Add a list of unsorted messages to an exchange. + * @param messages - An unsorted array of Tbdex messages in a given exchange + */ + addMessages(messages: Message[]): void { + // Sort with earliest dateCreated first + const sortedMessages = messages.sort((m1, m2) => { + const time1 = new Date(m1.metadata.createdAt).getTime() + const time2 = new Date(m2.metadata.createdAt).getTime() + return time1 - time2 + }) + + for (const message of sortedMessages) { + this.addNextMessage(message) + } + } + + /** + * Add the next message in the exchange + * @param message - The next allowed message in the exchange + * @throws if message is not a valid next message. See {@link Exchange.isValidNext} + */ + addNextMessage(message: Message): void { + if (!this.isValidNext(message.metadata.kind)) { + throw new Error( + `Could not add message (${message.metadata.id}) to exchange because ${message.metadata.kind} ` + + `is not a valid next message` + ) + } + + if (this.exchangeId !== undefined && message.metadata.exchangeId !== this.exchangeId) { + throw new Error( + `Could not add message with id ${message.metadata.id} to exchange because it does not have matching ` + + `exchange id ${this.exchangeId}` + ) + } + + if (message.isRfq()) { + this.rfq = message + } else if (message.isQuote()) { + this.quote = message + } else if (message.isClose()) { + this.close = message + } else if (message.isOrder()) { + this.order = message + } else if (message.isOrderStatus()) { + this.orderstatus = message + } else { + // Unreachable + throw new Error('Unrecognized message kind') + } + } + + /** + * Determines if the message kind is a valid next message in the current exchange + * @param messageKind - the kind of TBDex message + * @returns true if the next message in the exchange may have kind messageKind, false otherwise + */ + isValidNext(messageKind: MessageKind): boolean { + const validNext = this.latestMessage?.validNext ?? new Set(['rfq']) + return validNext.has(messageKind) + } + + /** + * Latest message in an exchange if there are any messages currently + */ + get latestMessage(): Message | undefined { + return this.close ?? + this.orderstatus ?? + this.order ?? + this.quote ?? + this.rfq + } + + /** + * The exchangeId of all messages in the Exchange + */ + get exchangeId(): string | undefined { + return this.rfq?.metadata?.exchangeId + } + + /** + * A sorted list of messages currently in the exchange. + */ + get messages(): Message[] { + const allPossibleMessages: (Message | undefined)[] = [ + this.rfq, + this.quote, + this.order, + this.orderstatus, + this.close + ] + return allPossibleMessages.filter((message): message is Message => message !== undefined) + } +} \ No newline at end of file diff --git a/packages/protocol/src/main.ts b/packages/protocol/src/main.ts index 5d62470e..c34c4b7c 100644 --- a/packages/protocol/src/main.ts +++ b/packages/protocol/src/main.ts @@ -13,6 +13,7 @@ import { Message } from './message.js' export * from './resource-kinds/index.js' export * from './message-kinds/index.js' +export * from './exchange.js' export * from './did-resolver.js' export * from './dev-tools.js' export * from './crypto.js' diff --git a/packages/protocol/src/message-kinds/rfq.ts b/packages/protocol/src/message-kinds/rfq.ts index d9b05309..c6b16479 100644 --- a/packages/protocol/src/message-kinds/rfq.ts +++ b/packages/protocol/src/message-kinds/rfq.ts @@ -17,7 +17,7 @@ export type CreateRfqOptions = { } /** - * Message sent by Alice to PFI to requesting for a quote (RFQ) + * Message sent by Alice to PFI to request a quote (RFQ) * @beta */ export class Rfq extends Message { diff --git a/packages/protocol/tests/exchange.spec.ts b/packages/protocol/tests/exchange.spec.ts new file mode 100644 index 00000000..e07a3d5a --- /dev/null +++ b/packages/protocol/tests/exchange.spec.ts @@ -0,0 +1,340 @@ +import { PortableDid } from '@web5/dids' +import { expect } from 'chai' +import { Close, DevTools, Exchange, Message, Order, OrderStatus, Quote, Rfq } from '../src/main.js' + +describe('Exchange', () => { + let aliceDid: PortableDid + let pfiDid: PortableDid + let rfq: Rfq + let quote: Quote + let closeByAlice: Close + let closeByPfi: Close + let order: Order + let orderStatus: OrderStatus + + beforeEach(async () => { + aliceDid = await DevTools.createDid() + pfiDid = await DevTools.createDid() + + rfq = Rfq.create({ + metadata: { + from : aliceDid.did, + to : pfiDid.did, + }, + data: await DevTools.createRfqData() + }) + await rfq.sign(aliceDid) + + closeByAlice = Close.create({ + metadata: { + from : aliceDid.did, + to : pfiDid.did, + exchangeId : rfq.metadata.exchangeId, + }, + data: { + reason: 'I dont like u anymore' + } + }) + await closeByAlice.sign(aliceDid) + + quote = Quote.create({ + metadata: { + from : pfiDid.did, + to : aliceDid.did, + exchangeId : rfq.metadata.exchangeId + }, + data: DevTools.createQuoteData() + }) + await quote.sign(pfiDid) + + closeByPfi = Close.create({ + metadata: { + from : pfiDid.did, + to : aliceDid.did, + exchangeId : rfq.metadata.exchangeId, + }, + data: { + reason: 'I dont like u anymore' + } + }) + await closeByPfi.sign(pfiDid) + + order = Order.create({ + metadata: { + from : aliceDid.did, + to : pfiDid.did, + exchangeId : rfq.metadata.exchangeId + }, + }) + await order.sign(aliceDid) + + orderStatus = OrderStatus.create({ + metadata: { + from : pfiDid.did, + to : aliceDid.did, + exchangeId : rfq.metadata.exchangeId, + }, + data: { + orderStatus: 'Done' + } + }) + }) + + describe('addMessages', () => { + it('adds an Rfq', async () => { + const exchange = new Exchange() + exchange.addMessages([rfq]) + + expect(exchange.rfq).to.deep.eq(rfq) + }) + + it('adds a list of messages in an exchange even if the list is out of order', async () => { + const exchange = new Exchange() + + // Messages are listed out of order + exchange.addMessages([order, quote, orderStatus, rfq]) + + expect(exchange.rfq).to.deep.eq(rfq) + expect(exchange.quote).to.deep.eq(quote) + expect(exchange.order).to.deep.eq(order) + expect(exchange.orderstatus).to.deep.eq(orderStatus) + }) + + it('throws if the messages listed do not form a valid exchange', async () => { + // scenario: We try to add messages RFQ and Order, without a Quote + + const exchange = new Exchange() + try { + exchange.addMessages([rfq, order]) + expect.fail() + } catch (e) { + expect(e.message).to.contain('is not a valid next message') + } + }) + + it('throws if the messages listed do not have matching exchange_id', async () => { + const quote = Quote.create({ + metadata: { + from : pfiDid.did, + to : aliceDid.did, + exchangeId : Message.generateId('rfq') + }, + data: DevTools.createQuoteData() + }) + await quote.sign(pfiDid) + + const exchange = new Exchange() + try { + exchange.addMessages([rfq, quote]) + expect.fail() + } catch (e) { + expect(e.message).to.contain('to exchange because it does not have matching exchange id') + } + }) + + it('throws if the messages listed have timestamp after Close', async () => { + const aliceDid = await DevTools.createDid() + const pfiDid = await DevTools.createDid() + + const close = Close.create({ + metadata: { + from : aliceDid.did, + to : pfiDid.did, + exchangeId : rfq.metadata.exchangeId, + }, + data: { + reason: 'I dont like u anymore' + } + }) + await close.sign(aliceDid) + + const quote = Quote.create({ + metadata: { + from : pfiDid.did, + to : aliceDid.did, + exchangeId : rfq.metadata.exchangeId + }, + data: DevTools.createQuoteData() + }) + await quote.sign(pfiDid) + + const exchange = new Exchange() + try { + exchange.addMessages([rfq, close, quote]) + expect.fail() + } catch (e) { + expect(e.message).to.contain('is not a valid next message') + } + }) + }) + + describe('addNextMessage', () => { + 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, orderStatus]) { + try { + exchange.addNextMessage(message) + expect.fail() + } catch (e) { + expect(e.message).to.contain('is not a valid next message') + } + } + + exchange.addNextMessage(rfq) + expect(exchange.rfq).to.deep.eq(rfq) + }) + + it('cannot add an Order, OrderStatus, or Rfq after Rfq', async () => { + const exchange = new Exchange() + exchange.addNextMessage(rfq) + + for (const message of [rfq, order, orderStatus]) { + try { + exchange.addNextMessage(message) + expect.fail() + } catch (e) { + expect(e.message).to.contain('is not a valid next message') + } + } + }) + + it('can add a Quote after Rfq', async () => { + const exchange = new Exchange() + exchange.addNextMessage(rfq) + + exchange.addNextMessage(quote) + expect(exchange.quote).to.deep.eq(quote) + }) + + it('can add a Close after Rfq', async () => { + const exchange = new Exchange() + exchange.addNextMessage(rfq) + + exchange.addNextMessage(closeByAlice) + expect(exchange.close).to.deep.eq(closeByAlice) + }) + + it('can add a Close after Quote', async () => { + const exchange = new Exchange() + exchange.addMessages([rfq, quote]) + + exchange.addNextMessage(closeByPfi) + expect(exchange.close).to.deep.eq(closeByPfi) + }) + + it('cannot add Rfq, Quote, Order, OrderStatus, or Close after Close', async () => { + const exchange = new Exchange() + exchange.addMessages([rfq, quote]) + exchange.addNextMessage(closeByAlice) + + for (const message of [rfq, quote, order, orderStatus, closeByAlice]) { + try { + exchange.addNextMessage(message) + expect.fail() + } catch (e) { + expect(e.message).to.contain('is not a valid next message') + } + } + }) + + it('can add an Order after Quote', async () => { + const exchange = new Exchange() + + exchange.addMessages([rfq, quote]) + + exchange.addNextMessage(order) + expect(exchange.order).to.deep.eq(order) + }) + + it('cannot add Rfq, Quote, or OrderStatus after Quote', async () => { + const exchange = new Exchange() + exchange.addMessages([rfq, quote]) + + for (const message of [rfq, quote, orderStatus]) { + try { + exchange.addNextMessage(message) + expect.fail() + } catch (e) { + expect(e.message).to.contain('is not a valid next message') + } + } + }) + + it('can add an OrderStatus after Order', async () => { + const exchange = new Exchange() + + exchange.addMessages([rfq, quote, order]) + + exchange.addNextMessage(orderStatus) + expect(exchange.orderstatus).to.deep.eq(orderStatus) + }) + + it('cannot add Rfq, Quote, Order, or Close after Order', async () => { + const exchange = new Exchange() + exchange.addMessages([rfq, quote, order]) + + for (const message of [rfq, quote, order, closeByAlice]) { + try { + exchange.addNextMessage(message) + expect.fail() + } catch (e) { + expect(e.message).to.contain('is not a valid next message') + } + } + }) + }) + }) + + describe('messages', () => { + it('returns the list of messages in the exchange', async () => { + const aliceDid = await DevTools.createDid() + const pfiDid = await DevTools.createDid() + + const rfq = Rfq.create({ + metadata: { + from : aliceDid.did, + to : pfiDid.did, + }, + data: await DevTools.createRfqData() + }) + await rfq.sign(aliceDid) + + const quote = Quote.create({ + metadata: { + from : pfiDid.did, + to : aliceDid.did, + exchangeId : rfq.metadata.exchangeId + }, + data: DevTools.createQuoteData() + }) + await quote.sign(pfiDid) + + const order = Order.create({ + metadata: { + from : aliceDid.did, + to : pfiDid.did, + exchangeId : rfq.metadata.exchangeId + }, + }) + await order.sign(aliceDid) + + const orderStatus = OrderStatus.create({ + metadata: { + from : pfiDid.did, + to : aliceDid.did, + exchangeId : rfq.metadata.exchangeId, + }, + data: { + orderStatus: 'Done' + } + }) + await orderStatus.sign(pfiDid) + + const exchange = new Exchange() + exchange.addMessages([rfq, quote, order, orderStatus]) + + expect(exchange.messages).to.deep.eq([rfq, quote, order, orderStatus]) + }) + }) +}) \ No newline at end of file