From 3c044960e8d9edb29c251d7c009110cffb128fe6 Mon Sep 17 00:00:00 2001 From: Ethan Lee <125412902+ethan-tbd@users.noreply.github.com> Date: Wed, 24 Jul 2024 09:27:33 -0700 Subject: [PATCH] feat: add order instructions (#52) --- lib/src/http_client/tbdex_http_client.dart | 13 ---- lib/src/protocol/jcs.dart | 2 +- .../protocol/json_schemas/message_schema.dart | 2 +- .../orderinstructions_schema.dart | 35 ++++++++++ .../protocol/json_schemas/quote_schema.dart | 17 ----- lib/src/protocol/models/message.dart | 1 + lib/src/protocol/models/message_data.dart | 29 ++++++-- lib/src/protocol/models/order.dart | 3 +- .../protocol/models/order_instructions.dart | 70 +++++++++++++++++++ lib/src/protocol/models/order_status.dart | 2 +- lib/src/protocol/models/quote.dart | 3 +- lib/src/protocol/models/rfq.dart | 3 +- lib/src/protocol/parser.dart | 3 + lib/src/protocol/validator.dart | 13 ++++ tbdex | 2 +- test/helpers/test_data.dart | 35 ++++------ test/protocol/jcs_test.dart | 2 +- test/protocol/models/cancel_test.dart | 3 - .../models/order_instructions_test.dart | 52 ++++++++++++++ 19 files changed, 221 insertions(+), 69 deletions(-) create mode 100644 lib/src/protocol/json_schemas/orderinstructions_schema.dart create mode 100644 lib/src/protocol/models/order_instructions.dart create mode 100644 test/protocol/models/order_instructions_test.dart diff --git a/lib/src/http_client/tbdex_http_client.dart b/lib/src/http_client/tbdex_http_client.dart index 3cee6b2..42672b6 100644 --- a/lib/src/http_client/tbdex_http_client.dart +++ b/lib/src/http_client/tbdex_http_client.dart @@ -1,21 +1,8 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import 'package:tbdex/src/http_client/exceptions/http_exceptions.dart'; -import 'package:tbdex/src/http_client/exceptions/token_exceptions.dart'; -import 'package:tbdex/src/http_client/exceptions/validation_exceptions.dart'; -import 'package:tbdex/src/http_client/models/create_exchange_request.dart'; -import 'package:tbdex/src/http_client/models/exchange.dart'; -import 'package:tbdex/src/http_client/models/get_offerings_filter.dart'; import 'package:tbdex/src/http_client/models/submit_cancel_request.dart'; import 'package:tbdex/src/http_client/models/submit_close_request.dart'; import 'package:tbdex/src/http_client/models/submit_order_request.dart'; -import 'package:tbdex/src/protocol/models/balance.dart'; -import 'package:tbdex/src/protocol/models/close.dart'; -import 'package:tbdex/src/protocol/models/offering.dart'; -import 'package:tbdex/src/protocol/models/order.dart'; -import 'package:tbdex/src/protocol/models/rfq.dart'; -import 'package:tbdex/src/protocol/parser.dart'; -import 'package:tbdex/src/protocol/validator.dart'; import 'package:tbdex/tbdex.dart'; import 'package:typeid/typeid.dart'; import 'package:web5/web5.dart'; diff --git a/lib/src/protocol/jcs.dart b/lib/src/protocol/jcs.dart index 9155642..bbb7163 100644 --- a/lib/src/protocol/jcs.dart +++ b/lib/src/protocol/jcs.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'dart:typed_data'; -// TODO: turn into standalone lib +// TODO(ethan-tbd): turn into standalone lib /// Implements the JSON Canonicalization Scheme specified in /// [RFC8785](https://www.rfc-editor.org/rfc/rfc8785) diff --git a/lib/src/protocol/json_schemas/message_schema.dart b/lib/src/protocol/json_schemas/message_schema.dart index 0555361..5b34932 100644 --- a/lib/src/protocol/json_schemas/message_schema.dart +++ b/lib/src/protocol/json_schemas/message_schema.dart @@ -18,7 +18,7 @@ class MessageSchema { }, "kind": { "type": "string", - "enum": ["rfq", "quote", "order", "orderstatus", "close", "cancel"], + "enum": ["rfq", "quote", "order", "orderstatus", "close", "cancel", "orderinstructions"], "description": "The message kind (e.g. rfq, quote)" }, "id": { diff --git a/lib/src/protocol/json_schemas/orderinstructions_schema.dart b/lib/src/protocol/json_schemas/orderinstructions_schema.dart new file mode 100644 index 0000000..46252c1 --- /dev/null +++ b/lib/src/protocol/json_schemas/orderinstructions_schema.dart @@ -0,0 +1,35 @@ +class OrderinstructionsSchema { + static const String json = r''' + { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://tbdex.dev/orderinstructions.schema.json", + "type": "object", + "additionalProperties": false, + "properties": { + "payin": { + "$ref": "#/definitions/PaymentInstruction" + }, + "payout": { + "$ref": "#/definitions/PaymentInstruction" + } + }, + "definitions": { + "PaymentInstruction": { + "type": "object", + "additionalProperties": false, + "properties": { + "link": { + "type": "string", + "description": "Link to allow Alice to pay PFI, or be paid by the PFI" + }, + "instruction": { + "type": "string", + "description": "Instruction on how Alice can pay PFI, or how Alice can be paid by the PFI" + } + } + } + }, + "required": ["payin", "payout"] +} +'''; +} \ No newline at end of file diff --git a/lib/src/protocol/json_schemas/quote_schema.dart b/lib/src/protocol/json_schemas/quote_schema.dart index f334c71..6e9f62d 100644 --- a/lib/src/protocol/json_schemas/quote_schema.dart +++ b/lib/src/protocol/json_schemas/quote_schema.dart @@ -23,26 +23,9 @@ class QuoteSchema { "total": { "$ref": "definitions.json#/definitions/decimalString", "description": "The total amount of currency to be paid in or paid out. It is always a sum of subtotal and fee" - }, - "paymentInstruction": { - "$ref": "#/definitions/PaymentInstruction" } }, "required": ["currencyCode", "subtotal", "total"] - }, - "PaymentInstruction": { - "type": "object", - "additionalProperties": false, - "properties": { - "link": { - "type": "string", - "description": "Link to allow Alice to pay PFI, or be paid by the PFI" - }, - "instruction": { - "type": "string", - "description": "Instruction on how Alice can pay PFI, or how Alice can be paid by the PFI" - } - } } }, "type": "object", diff --git a/lib/src/protocol/models/message.dart b/lib/src/protocol/models/message.dart index 9faba1d..09c4fcd 100644 --- a/lib/src/protocol/models/message.dart +++ b/lib/src/protocol/models/message.dart @@ -16,6 +16,7 @@ enum MessageKind { cancel, order, orderstatus, + orderinstructions, } class MessageMetadata extends Metadata { diff --git a/lib/src/protocol/models/message_data.dart b/lib/src/protocol/models/message_data.dart index 1695761..568b941 100644 --- a/lib/src/protocol/models/message_data.dart +++ b/lib/src/protocol/models/message_data.dart @@ -209,14 +209,12 @@ class QuoteDetails { final String subtotal; final String total; final String? fee; - final PaymentInstruction? paymentInstruction; QuoteDetails({ required this.currencyCode, required this.subtotal, required this.total, this.fee, - this.paymentInstruction, }); factory QuoteDetails.fromJson(Map json) { @@ -225,9 +223,6 @@ class QuoteDetails { subtotal: json['subtotal'], total: json['total'], fee: json['fee'], - paymentInstruction: json['paymentInstruction'] != null - ? PaymentInstruction.fromJson(json['paymentInstruction']) - : null, ); } @@ -237,8 +232,6 @@ class QuoteDetails { 'subtotal': subtotal, 'total': total, if (fee != null) 'fee': fee, - if (paymentInstruction != null) - 'paymentInstruction': paymentInstruction?.toJson(), }; } } @@ -338,3 +331,25 @@ class OrderStatusData extends MessageData { }; } } + +class OrderInstructionsData extends MessageData { + final PaymentInstruction payin; + final PaymentInstruction payout; + + OrderInstructionsData({required this.payin, required this.payout}); + + factory OrderInstructionsData.fromJson(Map json) { + return OrderInstructionsData( + payin: PaymentInstruction.fromJson(json['payin']), + payout: PaymentInstruction.fromJson(json['payout']), + ); + } + + @override + Map toJson() { + return { + 'payin': payin.toJson(), + 'payout': payout.toJson(), + }; + } +} diff --git a/lib/src/protocol/models/order.dart b/lib/src/protocol/models/order.dart index caf2688..c17e5fd 100644 --- a/lib/src/protocol/models/order.dart +++ b/lib/src/protocol/models/order.dart @@ -9,7 +9,8 @@ class Order extends Message { final OrderData data; @override - Set get validNext => {MessageKind.orderstatus}; + Set get validNext => + {MessageKind.orderstatus, MessageKind.close, MessageKind.cancel}; Order._({ required this.metadata, diff --git a/lib/src/protocol/models/order_instructions.dart b/lib/src/protocol/models/order_instructions.dart new file mode 100644 index 0000000..237f4e3 --- /dev/null +++ b/lib/src/protocol/models/order_instructions.dart @@ -0,0 +1,70 @@ +import 'package:tbdex/src/protocol/models/message.dart'; +import 'package:tbdex/src/protocol/models/message_data.dart'; +import 'package:tbdex/src/protocol/parser.dart'; + +class OrderInstructions extends Message { + @override + final MessageMetadata metadata; + @override + final OrderInstructionsData data; + + @override + Set get validNext => + {MessageKind.orderstatus, MessageKind.close, MessageKind.cancel}; + + OrderInstructions._({ + required this.metadata, + required this.data, + String? signature, + }) : super() { + this.signature = signature; + } + + static OrderInstructions create( + String to, + String from, + String exchangeId, + OrderInstructionsData data, { + String? externalId, + String protocol = '1.0', + }) { + final now = DateTime.now().toUtc().toIso8601String(); + final metadata = MessageMetadata( + kind: MessageKind.orderinstructions, + to: to, + from: from, + id: Message.generateId(MessageKind.orderinstructions), + exchangeId: exchangeId, + createdAt: now, + protocol: protocol, + externalId: externalId, + ); + + return OrderInstructions._( + metadata: metadata, + data: data, + ); + } + + static Future parse(String rawMessage) async { + final orderStatus = Parser.parseMessage(rawMessage) as OrderInstructions; + await orderStatus.verify(); + return orderStatus; + } + + factory OrderInstructions.fromJson(Map json) { + return OrderInstructions._( + metadata: MessageMetadata.fromJson(json['metadata']), + data: OrderInstructionsData.fromJson(json['data']), + signature: json['signature'], + ); + } + + Map toJson() { + return { + 'metadata': metadata.toJson(), + 'data': data.toJson(), + 'signature': signature, + }; + } +} diff --git a/lib/src/protocol/models/order_status.dart b/lib/src/protocol/models/order_status.dart index ebc7e3e..683d41a 100644 --- a/lib/src/protocol/models/order_status.dart +++ b/lib/src/protocol/models/order_status.dart @@ -10,7 +10,7 @@ class OrderStatus extends Message { @override Set get validNext => - {MessageKind.orderstatus, MessageKind.close}; + {MessageKind.orderstatus, MessageKind.close, MessageKind.cancel}; OrderStatus._({ required this.metadata, diff --git a/lib/src/protocol/models/quote.dart b/lib/src/protocol/models/quote.dart index 13b2d5c..a101c7d 100644 --- a/lib/src/protocol/models/quote.dart +++ b/lib/src/protocol/models/quote.dart @@ -9,7 +9,8 @@ class Quote extends Message { final QuoteData data; @override - Set get validNext => {MessageKind.order, MessageKind.close}; + Set get validNext => + {MessageKind.order, MessageKind.close, MessageKind.cancel}; Quote._({ required this.metadata, diff --git a/lib/src/protocol/models/rfq.dart b/lib/src/protocol/models/rfq.dart index 209968a..b9f52f7 100644 --- a/lib/src/protocol/models/rfq.dart +++ b/lib/src/protocol/models/rfq.dart @@ -14,7 +14,8 @@ class Rfq extends Message { final RfqPrivateData? privateData; @override - Set get validNext => {MessageKind.quote, MessageKind.close}; + Set get validNext => + {MessageKind.quote, MessageKind.close, MessageKind.cancel}; Rfq._({ required this.metadata, diff --git a/lib/src/protocol/parser.dart b/lib/src/protocol/parser.dart index 006e676..b09a11c 100644 --- a/lib/src/protocol/parser.dart +++ b/lib/src/protocol/parser.dart @@ -8,6 +8,7 @@ import 'package:tbdex/src/protocol/models/close.dart'; import 'package:tbdex/src/protocol/models/message.dart'; import 'package:tbdex/src/protocol/models/offering.dart'; import 'package:tbdex/src/protocol/models/order.dart'; +import 'package:tbdex/src/protocol/models/order_instructions.dart'; import 'package:tbdex/src/protocol/models/order_status.dart'; import 'package:tbdex/src/protocol/models/quote.dart'; import 'package:tbdex/src/protocol/models/resource.dart'; @@ -147,6 +148,8 @@ abstract class Parser { return Order.fromJson(jsonObject); case MessageKind.orderstatus: return OrderStatus.fromJson(jsonObject); + case MessageKind.orderinstructions: + return OrderInstructions.fromJson(jsonObject); } } diff --git a/lib/src/protocol/validator.dart b/lib/src/protocol/validator.dart index ff39393..0fd1216 100644 --- a/lib/src/protocol/validator.dart +++ b/lib/src/protocol/validator.dart @@ -9,6 +9,7 @@ import 'package:tbdex/src/protocol/json_schemas/definitions_schema.dart'; import 'package:tbdex/src/protocol/json_schemas/message_schema.dart'; import 'package:tbdex/src/protocol/json_schemas/offering_schema.dart'; import 'package:tbdex/src/protocol/json_schemas/order_schema.dart'; +import 'package:tbdex/src/protocol/json_schemas/orderinstructions_schema.dart'; import 'package:tbdex/src/protocol/json_schemas/orderstatus_schema.dart'; import 'package:tbdex/src/protocol/json_schemas/quote_schema.dart'; import 'package:tbdex/src/protocol/json_schemas/resource_schema.dart'; @@ -19,6 +20,7 @@ import 'package:tbdex/src/protocol/models/close.dart'; import 'package:tbdex/src/protocol/models/message.dart'; import 'package:tbdex/src/protocol/models/offering.dart'; import 'package:tbdex/src/protocol/models/order.dart'; +import 'package:tbdex/src/protocol/models/order_instructions.dart'; import 'package:tbdex/src/protocol/models/order_status.dart'; import 'package:tbdex/src/protocol/models/quote.dart'; import 'package:tbdex/src/protocol/models/resource.dart'; @@ -103,6 +105,13 @@ class Validator { orderStatus.metadata.kind.name, ); break; + case MessageKind.orderinstructions: + final orderInstructions = message as OrderInstructions; + _instance._validate(orderInstructions.toJson(), 'message'); + _instance._validate( + orderInstructions.data.toJson(), + orderInstructions.metadata.kind.name, + ); } } @@ -152,6 +161,10 @@ class Validator { JsonSchema.create(MessageSchema.json, refProvider: refProvider); _schemaMap['order'] = JsonSchema.create(OrderSchema.json, refProvider: refProvider); + _schemaMap['orderinstructions'] = JsonSchema.create( + OrderinstructionsSchema.json, + refProvider: refProvider, + ); _schemaMap['orderstatus'] = JsonSchema.create(OrderstatusSchema.json, refProvider: refProvider); _schemaMap['quote'] = diff --git a/tbdex b/tbdex index 621f54f..7d2fdd0 160000 --- a/tbdex +++ b/tbdex @@ -1 +1 @@ -Subproject commit 621f54f078401c1552fc18d6b5f69bc1ba697221 +Subproject commit 7d2fdd03c9405b920b056ab7c7c776a858dc3591 diff --git a/test/helpers/test_data.dart b/test/helpers/test_data.dart index 5dd7f3a..523b875 100644 --- a/test/helpers/test_data.dart +++ b/test/helpers/test_data.dart @@ -1,21 +1,10 @@ import 'dart:convert'; import 'package:json_schema/json_schema.dart'; -import 'package:tbdex/src/http_client/models/create_exchange_request.dart'; import 'package:tbdex/src/http_client/models/submit_close_request.dart'; import 'package:tbdex/src/http_client/models/submit_order_request.dart'; -import 'package:tbdex/src/protocol/models/balance.dart'; -import 'package:tbdex/src/protocol/models/cancel.dart'; -import 'package:tbdex/src/protocol/models/close.dart'; -import 'package:tbdex/src/protocol/models/message.dart'; -import 'package:tbdex/src/protocol/models/message_data.dart'; -import 'package:tbdex/src/protocol/models/offering.dart'; -import 'package:tbdex/src/protocol/models/order.dart'; -import 'package:tbdex/src/protocol/models/order_status.dart'; -import 'package:tbdex/src/protocol/models/quote.dart'; -import 'package:tbdex/src/protocol/models/resource.dart'; -import 'package:tbdex/src/protocol/models/resource_data.dart'; -import 'package:tbdex/src/protocol/models/rfq.dart'; +import 'package:tbdex/src/protocol/models/order_instructions.dart'; +import 'package:tbdex/tbdex.dart'; import 'package:typeid/typeid.dart'; import 'package:web5/web5.dart'; @@ -156,20 +145,12 @@ class TestData { subtotal: '100', total: '100.01', fee: '0.01', - paymentInstruction: PaymentInstruction( - link: 'https://block.xyz', - instruction: 'payin instruction', - ), ), payout: QuoteDetails( currencyCode: 'BTC', subtotal: '0.10', total: '0.12', fee: '0.02', - paymentInstruction: PaymentInstruction( - link: 'https://block.xyz', - instruction: 'payout instruction', - ), ), ), ); @@ -192,6 +173,18 @@ class TestData { ); } + static OrderInstructions getOrderInstructions() { + return OrderInstructions.create( + aliceDid.uri, + pfiDid.uri, + TypeId.generate(MessageKind.rfq.name), + OrderInstructionsData( + payin: PaymentInstruction(instruction: 'payin'), + payout: PaymentInstruction(instruction: 'payout'), + ), + ); + } + static Close getClose({String? to}) { return Close.create( to ?? pfiDid.uri, diff --git a/test/protocol/jcs_test.dart b/test/protocol/jcs_test.dart index 09c0f3d..57ddac6 100644 --- a/test/protocol/jcs_test.dart +++ b/test/protocol/jcs_test.dart @@ -26,7 +26,7 @@ void main() { test('object in array', () { final result = JsonCanonicalizer.canonicalize([ - {'b': 123, 'a': 'string'} + {'b': 123, 'a': 'string'}, ]); expect(utf8.decode(result), equals('[{"a":"string","b":123}]')); }); diff --git a/test/protocol/models/cancel_test.dart b/test/protocol/models/cancel_test.dart index 33d8517..228500f 100644 --- a/test/protocol/models/cancel_test.dart +++ b/test/protocol/models/cancel_test.dart @@ -1,8 +1,5 @@ import 'dart:convert'; -import 'package:tbdex/src/protocol/models/cancel.dart'; -import 'package:tbdex/src/protocol/models/message.dart'; -import 'package:tbdex/src/protocol/models/message_data.dart'; import 'package:tbdex/tbdex.dart'; import 'package:test/test.dart'; import 'package:typeid/typeid.dart'; diff --git a/test/protocol/models/order_instructions_test.dart b/test/protocol/models/order_instructions_test.dart new file mode 100644 index 0000000..974456e --- /dev/null +++ b/test/protocol/models/order_instructions_test.dart @@ -0,0 +1,52 @@ +import 'dart:convert'; + +import 'package:tbdex/src/protocol/models/order_instructions.dart'; +import 'package:tbdex/tbdex.dart'; +import 'package:test/test.dart'; +import 'package:typeid/typeid.dart'; + +import '../../helpers/test_data.dart'; + +void main() async { + await TestData.initializeDids(); + + group('OrderInstructions', () { + test('can create a new order instruction', () { + final orderInstructions = OrderInstructions.create( + TestData.pfi, + TestData.alice, + TypeId.generate(MessageKind.rfq.name), + OrderInstructionsData( + payin: PaymentInstruction(instruction: 'just do it'), + payout: PaymentInstruction(instruction: 'just receive it'), + ), + ); + + expect( + orderInstructions.metadata.id, + startsWith(MessageKind.orderinstructions.name), + ); + expect( + orderInstructions.metadata.kind, + equals(MessageKind.orderinstructions), + ); + expect(orderInstructions.metadata.protocol, equals('1.0')); + expect(orderInstructions.data.payin.instruction, equals('just do it')); + expect( + orderInstructions.data.payout.instruction, + equals('just receive it'), + ); + }); + + test('can parse and verify order instructions from a json string', + () async { + final orderInstructions = TestData.getOrderInstructions(); + await orderInstructions.sign(TestData.pfiDid); + final json = jsonEncode(orderInstructions.toJson()); + final parsed = await OrderInstructions.parse(json); + + expect(parsed, isA()); + expect(parsed.toString(), equals(json)); + }); + }); +}