From 7edf7045a361915db9c4c2e2205989c5ce5209e1 Mon Sep 17 00:00:00 2001 From: kirahsapong <102400653+kirahsapong@users.noreply.github.com> Date: Fri, 29 Mar 2024 09:00:13 -0700 Subject: [PATCH 1/3] Add balance resource and todos (#212) * add balance resource and todos * update validtor to cjs * add client get balances method * resolve todos * add client and vector tests * bump tbdex submodule * add todos * add optional balances endpoint * update comments * update test vectors spec * add authz to balances endpoint * remove paymentMethodKind enum * update httpServer opts * bump spec, update syntax, remove xits * remove cjs --- packages/http-client/src/client.ts | 45 +++++++ packages/http-client/tests/client.spec.ts | 50 +++++++ packages/http-server/src/http-server.ts | 36 ++++- .../http-server/src/in-memory-balances-api.ts | 57 ++++++++ .../src/request-handlers/get-balances.ts | 44 +++++++ packages/http-server/src/types.ts | 19 ++- .../http-server/tests/get-balances.spec.ts | 107 +++++++++++++++ packages/protocol/build/compile-validators.js | 16 ++- packages/protocol/src/dev-tools.ts | 24 +++- packages/protocol/src/parser.ts | 11 +- .../protocol/src/resource-kinds/balance.ts | 69 ++++++++++ packages/protocol/src/resource-kinds/index.ts | 1 + packages/protocol/src/resource.ts | 7 +- packages/protocol/src/types.ts | 41 +++++- packages/protocol/tests/balance.spec.ts | 123 ++++++++++++++++++ .../protocol/tests/generate-test-vectors.ts | 14 ++ packages/protocol/tests/test-vectors.spec.ts | 15 ++- tbdex | 2 +- 18 files changed, 658 insertions(+), 23 deletions(-) create mode 100644 packages/http-server/src/in-memory-balances-api.ts create mode 100644 packages/http-server/src/request-handlers/get-balances.ts create mode 100644 packages/http-server/tests/get-balances.spec.ts create mode 100644 packages/protocol/src/resource-kinds/balance.ts create mode 100644 packages/protocol/tests/balance.spec.ts diff --git a/packages/http-client/src/client.ts b/packages/http-client/src/client.ts index e0315d7b..259913d3 100644 --- a/packages/http-client/src/client.ts +++ b/packages/http-client/src/client.ts @@ -2,6 +2,7 @@ import type { JwtPayload } from '@web5/crypto' import type { ErrorDetail } from './types.js' import type { DidDocument, BearerDid } from '@web5/dids' import { + Balance, Close, MessageModel, Order, @@ -164,6 +165,40 @@ export class TbdexHttpClient { return data } + /** + * gets balances from the pfi provided + * @param opts - options + * @beta + */ + static async getBalances(opts: GetBalancesOptions): Promise { + const { pfiDid, did } = opts + + const pfiServiceEndpoint = await TbdexHttpClient.getPfiServiceEndpoint(pfiDid) + const apiRoute = `${pfiServiceEndpoint}/balances` + const requestToken = await TbdexHttpClient.generateRequestToken({ requesterDid: did, pfiDid }) + + let response: Response + try { + response = await fetch(apiRoute, { + headers: { + authorization: `Bearer ${requestToken}` + } + }) + } catch (e) { + throw new RequestError({ message: `Failed to get balances from ${pfiDid}`, recipientDid: pfiDid, url: apiRoute, cause: e }) + } + + if (!response.ok) { + const errorDetails = await response.json() as ErrorDetail[] + throw new ResponseError({ statusCode: response.status, details: errorDetails, recipientDid: pfiDid, url: apiRoute }) + } + + const responseBody = await response.json() as { data: Balance[] } + const data: Balance[] = responseBody.data + + return data + } + /** * get a specific exchange from the pfi provided * @param opts - options @@ -362,6 +397,16 @@ export type GetOfferingsOptions = { pfiDid: string } +/** + * options passed to {@link TbdexHttpClient.getBalances} method + * @beta + */ +export type GetBalancesOptions = { + /** the DID of the PFI from whom you want to get balances */ + pfiDid: string + did: BearerDid +} + /** * options passed to {@link TbdexHttpClient.getExchange} method * @beta diff --git a/packages/http-client/tests/client.spec.ts b/packages/http-client/tests/client.spec.ts index 57985e41..42c509db 100644 --- a/packages/http-client/tests/client.spec.ts +++ b/packages/http-client/tests/client.spec.ts @@ -336,6 +336,56 @@ describe('client', () => { }) }) + describe('getBalances', () => { + it('throws RequestError if service endpoint url is garbage', async () => { + getPfiServiceEndpointStub.resolves('garbage') + fetchStub.rejects({message: 'Failed to fetch on URL'}) + + try { + await TbdexHttpClient.getBalances({ pfiDid: pfiDid.uri, did: aliceDid }) + expect.fail() + } catch(e) { + expect(e.name).to.equal('RequestError') + expect(e).to.be.instanceof(RequestError) + expect(e.message).to.include('Failed to get balances') + expect(e.cause).to.exist + expect(e.cause.message).to.include('URL') + } + }) + + it('throws ResponseError if response status is not ok', async () => { + fetchStub.resolves({ + ok : false, + status : 400, + json : () => Promise.resolve({ + detail: 'some error' + }) + } as Response) + + try { + await TbdexHttpClient.getBalances({ pfiDid: pfiDid.uri, did: aliceDid }) + expect.fail() + } catch(e) { + expect(e.name).to.equal('ResponseError') + expect(e).to.be.instanceof(ResponseError) + expect(e.statusCode).to.exist + expect(e.details).to.exist + expect(e.recipientDid).to.equal(pfiDid.uri) + expect(e.url).to.equal('https://localhost:9000/balances') + } + }) + + it('returns balances array if response is ok', async () => { + fetchStub.resolves({ + ok : true, + json : () => Promise.resolve({ data: [] }) + } as Response) + + const balances = await TbdexHttpClient.getBalances({ pfiDid: pfiDid.uri, did: aliceDid }) + expect(balances).to.have.length(0) + }) + }) + describe('getExchange', () => { it('throws RequestError if service endpoint url is garbage', async () => { getPfiServiceEndpointStub.resolves('garbage') diff --git a/packages/http-server/src/http-server.ts b/packages/http-server/src/http-server.ts index b6c43361..b6191812 100644 --- a/packages/http-server/src/http-server.ts +++ b/packages/http-server/src/http-server.ts @@ -6,7 +6,9 @@ import type { SubmitCloseCallback, GetExchangesCallback, GetOfferingsCallback, + GetBalancesCallback, GetExchangeCallback, + BalancesApi, } from './types.js' import type { Express, Request, Response } from 'express' @@ -20,6 +22,7 @@ import { InMemoryOfferingsApi } from './in-memory-offerings-api.js' import { InMemoryExchangesApi } from './in-memory-exchanges-api.js' import { submitMessage } from './request-handlers/submit-message.js' import { getExchange } from './request-handlers/get-exchange.js' +import { getBalances } from './request-handlers/get-balances.js' /** * Maps the requests to their respective callbacks handlers @@ -29,6 +32,7 @@ type CallbackMap = { getExchange?: GetExchangeCallback getExchanges?: GetExchangesCallback getOfferings?: GetOfferingsCallback + getBalances?: GetBalancesCallback createExchange?: CreateExchangeCallback submitOrder?: SubmitOrderCallback submitClose?: SubmitCloseCallback @@ -36,11 +40,16 @@ type CallbackMap = { /** * Options for creating a new HttpServer + * @param opts.offeringsApi Optionally accepts an {@link OfferingsApi}. Defaults to an {@link InMemoryOfferingsApi} which supports additional methods. + * @param opts.exchangesApi Optionally accepts an {@link ExchangesApi}. Defaults to an {@link InMemoryExchangesApi} which supports additional methods. + * @param opts.balancesApi Optionally accepts a {@link BalancesApi}. Example: {@link InMemoryBalancesApi} which supports additional methods. Else, leave `undefined` if not supporting the balances endpoint. + * @param opts.pfiDid Required if instantiating the HttpServer with options. Else, defaults to an arbitrary string for example purposes only. * @beta */ type NewHttpServerOptions = { offeringsApi?: OfferingsApi exchangesApi?: ExchangesApi, + balancesApi?: BalancesApi, pfiDid: string } @@ -69,6 +78,11 @@ export class TbdexHttpServer { */ offeringsApi: OfferingsApi + /** + * PFI Balances API + */ + balancesApi?: BalancesApi + /** * PFI DID */ @@ -79,6 +93,7 @@ export class TbdexHttpServer { this.exchangesApi = opts?.exchangesApi ?? new InMemoryExchangesApi() this.offeringsApi = opts?.offeringsApi ?? new InMemoryOfferingsApi() + this.balancesApi = opts?.balancesApi this.pfiDid = opts?.pfiDid ?? 'did:ex:pfi' // initialize api here so that consumers can attach custom endpoints @@ -145,13 +160,22 @@ export class TbdexHttpServer { this.callbacks.getOfferings = callback } + /** + * Set up a callback or overwrite the existing callback for the GetBalances endpoint + * @param callback - A callback to be invoked when a valid request is sent to the + * GetBalances endpoint. + */ + onGetBalances(callback: GetBalancesCallback): void { + this.callbacks.getBalances = callback + } + /** * Setup the PFI routes and start a express server to listen for incoming requests * @param port - server port number * @param callback - to be called when the server is ready */ listen(port: number | string, callback?: () => void) { - const { offeringsApi, exchangesApi, pfiDid } = this + const { offeringsApi, exchangesApi, balancesApi, pfiDid } = this this.api.post('/exchanges', (req: Request, res: Response) => createExchange(req, res, { @@ -192,6 +216,16 @@ export class TbdexHttpServer { }) ) + if (balancesApi) { + this.api.get('/balances', (req, res) => + getBalances(req, res, { + callback: this.callbacks['getBalances'], + balancesApi, + pfiDid, + }) + ) + } + // TODO: support hostname and backlog arguments return this.api.listen(port, callback) } diff --git a/packages/http-server/src/in-memory-balances-api.ts b/packages/http-server/src/in-memory-balances-api.ts new file mode 100644 index 00000000..7b77d71c --- /dev/null +++ b/packages/http-server/src/in-memory-balances-api.ts @@ -0,0 +1,57 @@ +import { Balance } from '@tbdex/protocol' +import { BalancesApi } from './types.js' + +/** + * An in-memory implementation of {@link BalancesApi} for example and default purposes. + * InMemoryBalancesApi has additional methods {@link InMemoryBalancesApi.addBalance} + * {@link InMemoryBalancesApi.clearRequesterBalances} + * and {@link InMemoryBalancesApi.clearBalances} + */ +export class InMemoryBalancesApi implements BalancesApi { + /** Map from requester DID to list of Balances */ + balancesMap: Map + + constructor() { + this.balancesMap = new Map() + } + + /** + * Add a single balance resource + * @param balance - Balance to be added to the {@link balancesMap} + */ + addBalance(opts: {requesterDid: string, balance: Balance}): void { + let requesterBalances = this.balancesMap.get(opts.requesterDid) ?? [] + requesterBalances.push(opts.balance) + this.balancesMap.set(opts.requesterDid, requesterBalances) + } + + /** + * Clear existing list of balances for a single requester + */ + clearRequesterBalances(opts: {requesterDid: string}): void { + this.balancesMap.delete(opts.requesterDid) + } + + /** + * Clear existing list of balances + */ + clearAllBalances(): void { + this.balancesMap.clear() + } + + /** + * + * @returns A list of balances + */ + async getBalances(opts?: { requesterDid: string }): Promise { + if (opts === undefined || opts.requesterDid === undefined) { + // In production, this should probably return an empty list. + // For example and testing purposes, we return all balances. + + return Array.from(this.balancesMap.values()).flatMap(balances => balances) + } + + return this.balancesMap.get(opts.requesterDid) ?? [] + } + +} \ No newline at end of file diff --git a/packages/http-server/src/request-handlers/get-balances.ts b/packages/http-server/src/request-handlers/get-balances.ts new file mode 100644 index 00000000..310a8bae --- /dev/null +++ b/packages/http-server/src/request-handlers/get-balances.ts @@ -0,0 +1,44 @@ +import { Request, Response } from 'express' +import type { GetBalancesCallback, BalancesApi } from '../types.js' +import { TbdexHttpClient } from '@tbdex/http-client' + +type GetBalancesOpts = { + callback?: GetBalancesCallback + balancesApi: BalancesApi, + pfiDid: string +} + +export async function getBalances(request: Request, response: Response, opts: GetBalancesOpts): Promise { + const { callback, balancesApi, pfiDid } = opts + + const authzHeader = request.headers['authorization'] + if (!authzHeader) { + response.status(401).json({ errors: [{ detail: 'Authorization header required' }] }) + return + } + + const [_, requestToken] = authzHeader.split('Bearer ') + + if (!requestToken) { + response.status(401).json({ errors: [{ detail: 'Malformed Authorization header. Expected: Bearer TOKEN_HERE' }] }) + return + } + + let requesterDid: string + try { + requesterDid = await TbdexHttpClient.verifyRequestToken({ requestToken: requestToken, pfiDid }) + } catch(e) { + response.status(401).json({ errors: [{ detail: `Malformed Authorization header: ${e}` }] }) + return + } + + const balances = await balancesApi.getBalances({ requesterDid }) + + if (callback) { + // TODO: figure out what to do with callback result. should we pass through the offerings we've fetched + // and allow the callback to modify what's returned? (issue #11) + await callback({ request, response }) + } + + response.status(200).json({ data: balances }) +} \ No newline at end of file diff --git a/packages/http-server/src/types.ts b/packages/http-server/src/types.ts index 97655e44..2ca66b6f 100644 --- a/packages/http-server/src/types.ts +++ b/packages/http-server/src/types.ts @@ -1,5 +1,5 @@ import type { Request, Response } from 'express' -import type { Close, Exchange, Offering, Order, OrderStatus, Quote, Rfq } from '@tbdex/protocol' +import type { Balance, Close, Exchange, Offering, Order, OrderStatus, Quote, Rfq } from '@tbdex/protocol' /** @@ -20,6 +20,12 @@ export type GetExchangesCallback = (ctx: RequestContext, filter: GetExchangesFil */ export type GetOfferingsCallback = (ctx: RequestContext) => any +/** + * Callback handler for GetBalances requests + * @beta + */ +export type GetBalancesCallback = (ctx: RequestContext) => any + /** * Callback handler for the SubmitRfq requests * @beta @@ -82,6 +88,17 @@ export interface OfferingsApi { getOfferings(): Promise } +/** + * PFI Balances API + * @beta + */ +export interface BalancesApi { + /** + * Retrieve a list of balances + */ + getBalances(opts?: { requesterDid: string }): Promise +} + /** * PFI Exchanges API * @beta diff --git a/packages/http-server/tests/get-balances.spec.ts b/packages/http-server/tests/get-balances.spec.ts new file mode 100644 index 00000000..64467a82 --- /dev/null +++ b/packages/http-server/tests/get-balances.spec.ts @@ -0,0 +1,107 @@ +import { DevTools, Balance } from '@tbdex/protocol' +import type { Server } from 'http' + +import { RequestContext, TbdexHttpServer } from '../src/main.js' +import { expect } from 'chai' +import { InMemoryBalancesApi } from '../src/in-memory-balances-api.js' +import Sinon from 'sinon' +import { DidJwk } from '@web5/dids' +import { ErrorDetail, TbdexHttpClient } from '@tbdex/http-client' + +describe('GET /balances', () => { + let api: TbdexHttpServer + let server: Server + + beforeEach(() => { + api = new TbdexHttpServer({ balancesApi: new InMemoryBalancesApi(), pfiDid: 'did:ex:pfi' }) + server = api.listen(8000) + }) + + afterEach(() => { + server.close() + server.closeAllConnections() + }) + + it('returns a 401 if no Authorization header is provided', async () => { + const resp = await fetch('http://localhost:8000/balances') + + expect(resp.ok).to.be.false + expect(resp.status).to.equal(401) + + const respBody = await resp.json() as { errors: ErrorDetail[] } + expect(respBody['errors']).to.exist + expect(respBody['errors'].length).to.equal(1) + expect(respBody['errors'][0]['detail']).to.include('Authorization') + }) + + it('returns 401 if bearer token is missing from the Authorization header', async () => { + const resp = await fetch('http://localhost:8000/balances', { + headers: { + 'Authorization': 'Not well formatted token' + } + }) + + const respBody = await resp.json() as { errors: ErrorDetail[] } + expect(respBody['errors']).to.exist + expect(respBody['errors'].length).to.equal(1) + expect(respBody['errors'][0]['detail']).to.include('Malformed Authorization header. Expected: Bearer TOKEN_HERE') + }) + + it('returns 401 if the bearer token is malformed in the Authorization header', async () => { + const resp = await fetch('http://localhost:8000/balances', { + headers: { + 'Authorization': 'Bearer MALFORMED' + } + }) + + const respBody = await resp.json() as { errors: ErrorDetail[] } + expect(respBody['errors']).to.exist + expect(respBody['errors'].length).to.equal(1) + expect(respBody['errors'][0]['detail']).to.include('Malformed Authorization header') + }) + + it('returns an array of balances', async () => { + const pfiDid = await DidJwk.create() + const aliceDid = await DidJwk.create() + const balance = DevTools.createBalance() + await balance.sign(pfiDid); + (api.balancesApi as InMemoryBalancesApi).addBalance({ requesterDid: aliceDid.uri, balance }) + + const requestToken = await TbdexHttpClient.generateRequestToken({ requesterDid: aliceDid, pfiDid: api.pfiDid }) + + const response = await fetch('http://localhost:8000/balances', { + headers: { + 'Authorization': `Bearer ${requestToken}` + } + }) + expect(response.status).to.equal(200) + + const responseBody = await response.json() as { data: Balance[] } + expect(responseBody.data).to.exist + expect(responseBody.data.length).to.equal(1) + expect(responseBody.data[0]).to.deep.eq(balance.toJSON()) + }) + + it('calls the callback if it is provided', async () => { + const pfiDid = await DidJwk.create() + const aliceDid = await DidJwk.create() + const balance = DevTools.createBalance() + await balance.sign(pfiDid); + (api.balancesApi as InMemoryBalancesApi).addBalance({ requesterDid: aliceDid.uri, balance }) + + const callbackSpy = Sinon.spy((_ctx: RequestContext) => Promise.resolve()) + api.onGetBalances(callbackSpy) + + const requestToken = await TbdexHttpClient.generateRequestToken({ requesterDid: aliceDid, pfiDid: api.pfiDid }) + + const response = await fetch('http://localhost:8000/balances', { + headers: { + 'Authorization': `Bearer ${requestToken}` + } + }) + expect(response.status).to.equal(200) + + expect(callbackSpy.callCount).to.eq(1) + // TODO: Check what arguments are passed to callback after we finalize its behavior + }) +}) \ No newline at end of file diff --git a/packages/protocol/build/compile-validators.js b/packages/protocol/build/compile-validators.js index f9b5b8b3..c794eac7 100644 --- a/packages/protocol/build/compile-validators.js +++ b/packages/protocol/build/compile-validators.js @@ -20,6 +20,7 @@ import { mkdirp } from 'mkdirp' 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' } +import BalanceSchema from '../../../tbdex/hosted/json-schemas/balance.schema.json' assert { type: 'json' } import MessageSchema from '../../../tbdex/hosted/json-schemas/message.schema.json' assert { type: 'json' } import OrderSchema from '../../../tbdex/hosted/json-schemas/order.schema.json' assert { type: 'json' } import OrderstatusSchema from '../../../tbdex/hosted/json-schemas/orderstatus.schema.json' assert { type: 'json' } @@ -31,6 +32,7 @@ const schemas = { close : CloseSchema, definitions : DefinitionsSchema, offering : OfferingSchema, + balance : BalanceSchema, message : MessageSchema, order : OrderSchema, orderstatus : OrderstatusSchema, @@ -51,15 +53,15 @@ const generatedCode = standaloneCode(validator) // ESM generation is broken in AJV standalone. // In particular, it will "require" files from AJVs runtime directory instead of "import"ing. function replaceRequireWithImport(inputString) { - const variableNameRegex = /\w+/; // Matches the variable name - const moduleNameRegex = /[^"']+/; // Matches the module name + const variableNameRegex = /\w+/ // Matches the variable name + const moduleNameRegex = /[^"']+/ // Matches the module name const regex = new RegExp( - `const\\s+(${variableNameRegex.source})\\s*=\\s*require\\s*\\(\\s*[\"'](${moduleNameRegex.source})[\"']\\s*\\)\\.default`, - 'g' - ); + `const\\s+(${variableNameRegex.source})\\s*=\\s*require\\s*\\(\\s*[\"'](${moduleNameRegex.source})[\"']\\s*\\)\\.default`, + 'g' + ) - const replacedString = inputString.replace(regex, 'import { default as $1 } from "$2.js"'); - return replacedString; + const replacedString = inputString.replace(regex, 'import { default as $1 } from "$2.js"') + return replacedString } const moduleCode = replaceRequireWithImport(generatedCode) diff --git a/packages/protocol/src/dev-tools.ts b/packages/protocol/src/dev-tools.ts index 8d061e40..cdbcf0ef 100644 --- a/packages/protocol/src/dev-tools.ts +++ b/packages/protocol/src/dev-tools.ts @@ -1,8 +1,8 @@ -import type { OfferingData, QuoteData, RfqData } from './types.js' +import type { BalanceData, OfferingData, QuoteData, RfqData } from './types.js' import type { BearerDid } from '@web5/dids' -import { Offering } from './resource-kinds/index.js' +import { Balance, Offering } from './resource-kinds/index.js' import { Rfq } from './message-kinds/index.js' import { Resource } from './resource.js' import { VerifiableCredential } from '@web5/credentials' @@ -120,6 +120,26 @@ export class DevTools { } } + /** + * creates and returns an example balance. Useful for testing purposes + */ + static createBalance(opts?: { from?: string, balanceData?: BalanceData }): Balance { + return Balance.create({ + metadata : { from: opts?.from ?? 'did:ex:pfi' }, + data : opts?.balanceData ?? DevTools.createBalanceData() + }) + } + + /** + * creates an example BalanceData. Useful for testing purposes + */ + static createBalanceData(): BalanceData { + return { + currencyCode : 'USD', + available : '400.00' + } + } + /** * creates an example QuoteData. Useful for testing purposes */ diff --git a/packages/protocol/src/parser.ts b/packages/protocol/src/parser.ts index 6e25d138..7553f7e9 100644 --- a/packages/protocol/src/parser.ts +++ b/packages/protocol/src/parser.ts @@ -1,11 +1,11 @@ -import type { MessageModel, ResourceModel, RfqMetadata, RfqData, QuoteData, QuoteMetadata, OrderData, OrderMetadata, OrderStatusMetadata, OrderStatusData, CloseMetadata, CloseData, OfferingMetadata, OfferingData } from './types.js' +import type { MessageModel, ResourceModel, RfqMetadata, RfqData, QuoteData, QuoteMetadata, OrderData, OrderMetadata, OrderStatusMetadata, OrderStatusData, CloseMetadata, CloseData, OfferingMetadata, OfferingData, BalanceMetadata, BalanceData } 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, OrderStatus, Close } from './message-kinds/index.js' -import { Offering } from './resource-kinds/index.js' +import { Balance, Offering } from './resource-kinds/index.js' /** * Utility functions for parsing Messages and Resources @@ -97,6 +97,13 @@ export class Parser { jsonResource.signature ) break + case 'balance': + resource = new Balance( + jsonResource.metadata as BalanceMetadata, + jsonResource.data as BalanceData, + jsonResource.signature + ) + break default: throw new Error(`Unrecognized resource kind (${jsonResource.metadata.kind})`) diff --git a/packages/protocol/src/resource-kinds/balance.ts b/packages/protocol/src/resource-kinds/balance.ts new file mode 100644 index 00000000..f12f3923 --- /dev/null +++ b/packages/protocol/src/resource-kinds/balance.ts @@ -0,0 +1,69 @@ +import type { BalanceData, BalanceMetadata, ResourceModel } from '../types.js' +import { Resource } from '../resource.js' +import { Parser } from '../parser.js' + +/** + * Options passed to {@link Balance.create} + * @beta + */ +export type CreateBalanceOptions = { + data: BalanceData + metadata: Omit & { protocol?: BalanceMetadata['protocol'] } +} + +/** + * A Balance is a protected resource used to communicate the amounts of each + * currency held by the PFI on behalf of its customer. + * @beta + */ +export class Balance extends Resource { + /** The resource kind (balance) */ + readonly kind = 'balance' + /** Metadata such as sender, date created, date updated, and ID */ + readonly metadata: BalanceMetadata + /** Balance's data such as currencies and available amounts */ + readonly data: BalanceData + + constructor(metadata: BalanceMetadata, data: BalanceData, signature?: string) { + super(metadata, data, signature) + this.metadata = metadata + this.data = data + } + + /** + * Parses a json resource into an Balance + * @param rawMessage - the Balance to parse + * @throws if the balance could not be parsed or is not a valid Balance + * @returns The parsed Balance + */ + static async parse(rawMessage: ResourceModel | string): Promise { + const jsonMessage = Parser.rawToResourceModel(rawMessage) + + const balance = new Balance( + jsonMessage.metadata as BalanceMetadata, + jsonMessage.data as BalanceData, + jsonMessage.signature + ) + + await balance.verify() + return balance + } + + /** + * Creates an Balance with the given options + * @param opts - options to create an balance + */ + static create(opts: CreateBalanceOptions) { + const metadata: BalanceMetadata = { + ...opts.metadata, + kind : 'balance', + id : Resource.generateId('balance'), + createdAt : new Date().toISOString(), + protocol : opts.metadata.protocol ?? '1.0' + } + + const balance = new Balance(metadata, opts.data) + balance.validateData() + return balance + } +} \ No newline at end of file diff --git a/packages/protocol/src/resource-kinds/index.ts b/packages/protocol/src/resource-kinds/index.ts index b104d39e..99b6ce76 100644 --- a/packages/protocol/src/resource-kinds/index.ts +++ b/packages/protocol/src/resource-kinds/index.ts @@ -1 +1,2 @@ export * from './offering.js' +export * from './balance.js' diff --git a/packages/protocol/src/resource.ts b/packages/protocol/src/resource.ts index 80bf84c1..6c1d2f4d 100644 --- a/packages/protocol/src/resource.ts +++ b/packages/protocol/src/resource.ts @@ -1,5 +1,5 @@ import type { ResourceModel, ResourceMetadata, ResourceKind, ResourceData } from './types.js' -import type { Offering } from './resource-kinds/index.js' +import type { Balance, Offering } from './resource-kinds/index.js' import { typeid } from 'typeid-js' import { Crypto } from './crypto.js' @@ -161,4 +161,9 @@ export abstract class Resource { isOffering(): this is Offering { return this.metadata.kind === 'offering' } + + /** balance type guard */ + isBalance(): this is Balance { + return this.metadata.kind === 'balance' + } } diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index c823d806..5e3a6ee1 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -36,22 +36,22 @@ export type ResourceMetadata = { } /** - * Offering's metadata + * Type alias to represent a set of resource kind string keys * @beta */ -export type OfferingMetadata = ResourceMetadata & { kind: 'offering' } +export type ResourceKind = 'offering' | 'balance' /** - * Type alias to represent a set of resource kind string keys + * Resource's data * @beta */ -export type ResourceKind = 'offering' +export type ResourceData = OfferingData | BalanceData /** - * Resource's data + * Offering's metadata * @beta */ -export type ResourceData = OfferingData +export type OfferingMetadata = ResourceMetadata & { kind: 'offering' } /** * An Offering is used by the PFI to describe a currency pair they have to offer @@ -153,6 +153,24 @@ export type PayoutMethod = { /** */ } +/** + * Balance's metadata + * @beta + */ +export type BalanceMetadata = ResourceMetadata & { kind: 'balance' } + +/** + * A Balance is a protected resource used to communicate the amounts of each + * currency held by the PFI on behalf of its customer. + * @beta + */ +export type BalanceData = { + /** ISO 3166 currency code string */ + currencyCode: string + /** The amount available to be transacted with */ + available: string +} + /** * Represents the full message object: metadata + message kind data + signature * @beta @@ -269,7 +287,16 @@ export type SelectedPayinMethod = { */ export type SelectedPayoutMethod = { /** Type of payment method e.g. BTC_ADDRESS, DEBIT_CARD, MOMO_MPESA */ - kind: string + /** + * Some payment methods should be consistent across PFIs and therefore have reserved kind values. + * PFIs may provide, as a feature, stored balances, which are effectively the PFI custodying assets + * or funds onbehalf of their customer. Customers can top up this balance and their transactions + * can draw against this balance. + * + * If a PFI offers STORED_BALANCE as a payout kind, they MUST necessarily have a respective + * offering with STORED_BALANCE as a payin kind. + */ + kind: 'STORED_BALANCE' | string /** * An object containing the properties defined in the respective Offering's requiredPaymentDetails json schema. * Omitted from the signature payload. diff --git a/packages/protocol/tests/balance.spec.ts b/packages/protocol/tests/balance.spec.ts new file mode 100644 index 00000000..88fab0d2 --- /dev/null +++ b/packages/protocol/tests/balance.spec.ts @@ -0,0 +1,123 @@ +import { Balance, Parser } from '../src/main.js' +import { DevTools } from '../src/dev-tools.js' +import { Convert } from '@web5/common' +import { expect } from 'chai' +import { DidDht } from '@web5/dids' + +describe('Balance', () => { + describe('create', () => { + it('creates a Balance', async () => { + const data = DevTools.createBalanceData() + + const balance = Balance.create({ + metadata: { from: 'did:ex:pfi' }, + data, + }) + + expect(balance.isBalance()).to.be.true + expect(balance.metadata.kind).to.eq('balance') + expect(balance.id).to.exist + expect(balance.id).to.include('balance_') + expect(balance.metadata.createdAt).to.exist + expect(balance.data).to.eq(data) + }) + + it('throws if the data is not valid', async () => { + const data = DevTools.createBalanceData() + delete (data as any).currencyCode + + expect(() => { + Balance.create({ + metadata: { from: 'did:ex:pfi' }, + data, + }) + }).to.throw + }) + }) + + describe('sign', () => { + it('sets signature property', async () => { + const pfi = await DidDht.create() + const balance = Balance.create({ + metadata : { from: pfi.uri }, + data : DevTools.createBalanceData() + }) + + + await balance.sign(pfi) + + expect(balance.signature).to.not.be.undefined + expect(typeof balance.signature).to.equal('string') + }) + + it('includes alg and kid in jws header', async () => { + const pfi = await DidDht.create() + const balance = Balance.create({ + metadata : { from: pfi.uri }, + data : DevTools.createBalanceData() + }) + + await balance.sign(pfi) + + const [base64UrlEncodedJwsHeader] = balance.signature!.split('.') + const jwsHeader: { kid?: string, alg?: string} = Convert.base64Url(base64UrlEncodedJwsHeader).toObject() + + expect(jwsHeader.kid).to.equal(pfi.document.verificationMethod![0].id) + expect(jwsHeader.alg).to.exist + }) + }) + + describe('verify', () => { + it('does not throw an exception if resource integrity is intact', async () => { + const pfi = await DidDht.create() + const balance = Balance.create({ + metadata : { from: pfi.uri }, + data : DevTools.createBalanceData() + }) + + await balance.sign(pfi) + await balance.verify() + }) + + it('throws an error if no signature is present on the resource provided', async () => { + const pfi = await DidDht.create() + const balance = Balance.create({ + metadata : { from: pfi.uri }, + data : DevTools.createBalanceData() + }) + + try { + await balance.verify() + expect.fail() + } catch(e) { + expect(e.message).to.include(`must have required property 'signature'`) + } + }) + }) + + describe('parse', () => { + it('throws an error if payload is not valid JSON', async () => { + try { + await Parser.parseResource(';;;)_') + expect.fail() + } catch(e) { + expect(e.message).to.include('Failed to parse resource') + } + }) + + it('returns a Resource instance if parsing is successful', async () => { + const pfi = await DidDht.create() + const balance = Balance.create({ + metadata : { from: pfi.uri }, + data : DevTools.createBalanceData() + }) + + await balance.sign(pfi) + + const jsonResource = JSON.stringify(balance) + const parsedResource = await Parser.parseResource(jsonResource) + + expect(jsonResource).to.equal(JSON.stringify(parsedResource)) + }) + }) +}) \ No newline at end of file diff --git a/packages/protocol/tests/generate-test-vectors.ts b/packages/protocol/tests/generate-test-vectors.ts index b61c793c..831bef6b 100644 --- a/packages/protocol/tests/generate-test-vectors.ts +++ b/packages/protocol/tests/generate-test-vectors.ts @@ -37,6 +37,19 @@ const generateParseOfferingVector = async () => { } } +const generateParseBalanceVector = async () => { + const balance = DevTools.createBalance({ from: pfiDid.uri }) + + await balance.sign(pfiDid) + + return { + description : 'Balance parses from string', + input : JSON.stringify(balance.toJSON()), + output : balance.toJSON(), + error : false, + } +} + const generateParseQuoteVector = async () => { const quote = Quote.create({ @@ -163,6 +176,7 @@ const overWriteTestVectors = async () => { // Add more test vector generators as you need them. This is not a complete list. const vectorFilePair: { filename: string, vector: TestVector }[] = [ { filename: 'parse-offering.json', vector: await generateParseOfferingVector() }, + { filename: 'parse-balance.json', vector: await generateParseBalanceVector() }, { filename: 'parse-quote.json', vector: await generateParseQuoteVector() }, { filename: 'parse-close.json', vector: await generateParseCloseVector() }, { filename: 'parse-rfq.json', vector: await generateParseRfqVector() }, diff --git a/packages/protocol/tests/test-vectors.spec.ts b/packages/protocol/tests/test-vectors.spec.ts index ad6087c1..75ff67e5 100644 --- a/packages/protocol/tests/test-vectors.spec.ts +++ b/packages/protocol/tests/test-vectors.spec.ts @@ -5,7 +5,8 @@ import ParseOrder from '../../../tbdex/hosted/test-vectors/protocol/vectors/pars import ParseOrderStatus from '../../../tbdex/hosted/test-vectors/protocol/vectors/parse-orderstatus.json' assert { type: 'json' } import ParseQuote from '../../../tbdex/hosted/test-vectors/protocol/vectors/parse-quote.json' assert { type: 'json' } import ParseRfq from '../../../tbdex/hosted/test-vectors/protocol/vectors/parse-rfq.json' assert { type: 'json' } -import { Close, Offering, Order, OrderStatus, Quote, Rfq } from '../src/main.js' +import ParseBalance from '../../../tbdex/hosted/test-vectors/protocol/vectors/parse-balance.json' assert { type: 'json' } +import { Balance, Close, Offering, Order, OrderStatus, Quote, Rfq } from '../src/main.js' import { Parser } from '../src/parser.js' describe('TbdexTestVectorsProtocol', function () { @@ -81,4 +82,16 @@ describe('TbdexTestVectorsProtocol', function () { expect(rfq.isRfq()).to.be.true expect(rfq.toJSON()).to.deep.eq(ParseRfq.output) }) + + it('parse_balance', async () => { + // Parse with parseResource() + const resource = await Parser.parseResource(ParseBalance.input) + expect(resource.isBalance()).to.be.true + expect(resource.toJSON()).to.deep.eq(ParseBalance.output) + + // Parse with Balance.parse() + const balance = await Balance.parse(ParseBalance.input) + expect(balance.isBalance()).to.be.true + expect(balance.toJSON()).to.deep.eq(ParseBalance.output) + }) }) diff --git a/tbdex b/tbdex index 1855eb5b..6af20c9d 160000 --- a/tbdex +++ b/tbdex @@ -1 +1 @@ -Subproject commit 1855eb5b81778b7ecf105c79bc31d942b8405122 +Subproject commit 6af20c9d52a1497bef72ee88f712496844edd372 From 91edf55afe3a4483a135f1fba414fc63a28519c3 Mon Sep 17 00:00:00 2001 From: Diane Huxley Date: Fri, 29 Mar 2024 16:58:38 -0700 Subject: [PATCH 2/3] updating parse close vector with success field (#220) * updating parse close vector with success field * update submodule to main after merge * adding change made to test vector --------- Co-authored-by: Jiyoon Koo --- packages/protocol/tests/generate-test-vectors.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/protocol/tests/generate-test-vectors.ts b/packages/protocol/tests/generate-test-vectors.ts index 831bef6b..901bb53b 100644 --- a/packages/protocol/tests/generate-test-vectors.ts +++ b/packages/protocol/tests/generate-test-vectors.ts @@ -136,7 +136,8 @@ const generateParseCloseVector = async () => { const close = Close.create({ metadata : { from: pfiDid.uri, to: aliceDid.uri, exchangeId: Message.generateId('rfq'), protocol: '1.0' }, data : { - reason: 'The reason for closing the exchange' + reason : 'The reason for closing the exchange', + success : true } }) From 300f346da0ccfea8faeb4d3491a7b94e912bd0e0 Mon Sep 17 00:00:00 2001 From: Diane Huxley Date: Fri, 29 Mar 2024 17:11:14 -0700 Subject: [PATCH 3/3] Refresh offerings test vector (#218) --- packages/protocol/tests/test-vectors.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/protocol/tests/test-vectors.spec.ts b/packages/protocol/tests/test-vectors.spec.ts index 75ff67e5..6a1e8a83 100644 --- a/packages/protocol/tests/test-vectors.spec.ts +++ b/packages/protocol/tests/test-vectors.spec.ts @@ -23,7 +23,7 @@ describe('TbdexTestVectorsProtocol', function () { expect(close.toJSON()).to.deep.eq(ParseClose.output) }) - it.skip('parse_offering', async() => { + it('parse_offering', async() => { // Parse with parseResource() const resource = await Parser.parseResource(ParseOffering.input) expect(resource.isOffering()).to.be.true