From ad3adeb98185e75c831d00f01598a5ab3ed80311 Mon Sep 17 00:00:00 2001 From: Brandon Bryant Date: Mon, 1 Apr 2024 12:07:47 -0400 Subject: [PATCH] simple ordinal send p2p protocol --- docs/paymailClient.md | 89 +++++++++++++++ docs/specs/ordinals/simpleP2PSend.md | 103 +++++++++++++++++ .../simpleP2pOrdinalDestinationsCapability.ts | 9 ++ .../simpleP2pOrdinalReceiveCapability.ts | 9 ++ src/paymailClient/paymailClient.ts | 61 ++++++++++ .../simple-p2p-ordinal-destinations.test.ts | 49 +++++++++ .../receiveOrdinalTransaction.ts | 104 ++++++++++++++++++ .../simpleP2pOrdinalDestinationsRoute.ts | 47 ++++++++ 8 files changed, 471 insertions(+) create mode 100644 docs/specs/ordinals/simpleP2PSend.md create mode 100644 src/capability/simpleP2pOrdinalDestinationsCapability.ts create mode 100644 src/capability/simpleP2pOrdinalReceiveCapability.ts create mode 100644 src/paymailRouter/__tests/simple-p2p-ordinal-destinations.test.ts create mode 100644 src/paymailRouter/paymailRoutes/receiveOrdinalTransaction.ts create mode 100644 src/paymailRouter/paymailRoutes/simpleP2pOrdinalDestinationsRoute.ts diff --git a/docs/paymailClient.md b/docs/paymailClient.md index 48c2081..124f5df 100644 --- a/docs/paymailClient.md +++ b/docs/paymailClient.md @@ -135,6 +135,22 @@ export default class PaymailClient { } return response; }; + public getP2pOrdinalDestinations = async (paymail: string, ordinals: number): Promise => { + const response = await this.request(paymail, SimpleP2pOrdinalDestinationsCapability, { + ordinals + }); + const schema = Joi.object({ + outputs: Joi.array().items(Joi.object({ + script: Joi.string().required() + }).required().min(1)), + reference: Joi.string().required() + }).options({ stripUnknown: true }); + const { error } = schema.validate(response); + if (error) { + throw new PaymailServerResponseError(`Validation error: ${error.message}`); + } + return response; + }; public sendTransactionP2P = async (paymail: string, hex: string, reference: string, metadata?: { sender: string; pubkey: string; @@ -156,6 +172,27 @@ export default class PaymailClient { } return value; }; + public sendOrdinalTransactionP2P = async (paymail: string, hex: string, reference: string, metadata?: { + sender: string; + pubkey: string; + signature: string; + note: string; + }) => { + const response = await this.request(paymail, SimpleP2pOrdinalReceiveCapability, { + hex, + reference, + metadata + }); + const schema = Joi.object({ + txid: Joi.string().required(), + note: Joi.string().optional().allow("") + }).options({ stripUnknown: true }); + const { error, value } = schema.validate(response); + if (error) { + throw new PaymailServerResponseError(`Validation error: ${error.message}`); + } + return value; + }; public createP2PSignature = (msg: string, privKey: PrivateKey): string => { const msgHash = new BigNumber(sha256(msg, "hex"), 16); const sig = ECDSA.sign(msgHash, privKey, true); @@ -252,6 +289,29 @@ public ensureCapabilityFor = async (aDomain, aCapability) => { } ``` +### Property getP2pOrdinalDestinations + +Requests a P2P ordinal destination for a given Paymail. + +```ts +public getP2pOrdinalDestinations = async (paymail: string, ordinals: number): Promise => { + const response = await this.request(paymail, SimpleP2pOrdinalDestinationsCapability, { + ordinals + }); + const schema = Joi.object({ + outputs: Joi.array().items(Joi.object({ + script: Joi.string().required() + }).required().min(1)), + reference: Joi.string().required() + }).options({ stripUnknown: true }); + const { error } = schema.validate(response); + if (error) { + throw new PaymailServerResponseError(`Validation error: ${error.message}`); + } + return response; +} +``` + ### Property getP2pPaymentDestination Requests a P2P payment destination for a given Paymail. @@ -364,6 +424,35 @@ public sendBeefTransactionP2P = async (paymail: string, beef: string, reference: } ``` +### Property sendOrdinalTransactionP2P + +Sends a transaction using the Pay-to-Peer (P2P) protocol. +This method is used to send a transaction to a Paymail address. + +```ts +public sendOrdinalTransactionP2P = async (paymail: string, hex: string, reference: string, metadata?: { + sender: string; + pubkey: string; + signature: string; + note: string; +}) => { + const response = await this.request(paymail, SimpleP2pOrdinalReceiveCapability, { + hex, + reference, + metadata + }); + const schema = Joi.object({ + txid: Joi.string().required(), + note: Joi.string().optional().allow("") + }).options({ stripUnknown: true }); + const { error, value } = schema.validate(response); + if (error) { + throw new PaymailServerResponseError(`Validation error: ${error.message}`); + } + return value; +} +``` + ### Property sendTransactionP2P Sends a transaction using the Pay-to-Peer (P2P) protocol. diff --git a/docs/specs/ordinals/simpleP2PSend.md b/docs/specs/ordinals/simpleP2PSend.md new file mode 100644 index 0000000..c662016 --- /dev/null +++ b/docs/specs/ordinals/simpleP2PSend.md @@ -0,0 +1,103 @@ +### Simple P2P Ordinal Transactions Specification + +--- + +#### 1. Simple P2P Ordinal Payment Destinations + + +This protocol provides a mechanism for requesting payment destinations specifically for ordinal transactions. It is similar to the P2P Payment Destination protocol but tailored for handling simple ordinal sends. + +#### Motivation +Ordinals introduce a specific need in the Bitcoin ecosystem for transaction types that are simpler and more focused. While existing P2P payment protocols provide a broad range of functionalities, there is a demand for a protocol that specifically caters to ordinal transactions. This need arises from the unique characteristics of ordinals, ordinal transactions can not be differentiated from regular bitcoin transactions so it is needed for a specific path where only ordinal transactions go though to prevent accidental burns of an ordinal + +#### Capability Discovery + +The `.well-known/bsvalias` document is updated to include a declaration of the endpoint for ordinal payment destinations: + +```json +{ + "bsvalias": "1.0", + "capabilities": { + "cc2154bfa6a2": "https://example.bsvalias.tld/api/ordinal-p2p-payment-destination/{alias}@{domain.tld}" + } +} +``` + +The capability `cc2154bfa6a2` returns a URI template where the client must perform a POST request with the number of ordinals to be sent. + + +#### Client Request + +The request body structure: + +```json +{ + "ordinals": 2 +} +``` + +#### Server Response + +```json +{ + "outputs": [ + { + "script": "hex-encoded-locking-script" + }, + { + "script": "another-or-the-same-hex-locking-script" + } + ], + "reference": "reference-id" +} +``` + +#### 2. Simple P2P Ordinal Receive Transaction +This protocol allows Paymail providers to receive simple ordinal transactions sent to their users. + +#### Motivation + +The growing usage of ordinals requires a dedicated protocol for receiving such transactions. The existing P2P transaction protocols handle a wide array of transaction types, but a more focused approach is needed for the simplicity and specificity of ordinal transactions. + +#### Capability Discovery + +The `.well-known/bsvalias` document includes a declaration for the ordinal transaction receiving endpoint: + +```json +{ + "bsvalias": "1.0", + "capabilities": { + "2cc00c7f93c3": "https://example.bsvalias.tld/api/receive-ordinal-tx/{alias}@{domain.tld}" + } +} +``` + +#### Client Request + +The request body structure: + +```json +{ + "hex": "hex-encoded-transaction", + "reference": "reference-id", + "metadata": { + "sender": "someone@example.tld", + "pubkey": "public-key", + "signature": "signature", + "note": "transaction-note" + } +} +``` + + +#### Server Response +```json +{ + "txid": "transaction-id", + "note": "optional-note" +} +``` + +### Conclusion + +These simple ordinal transaction capabilities are designed for straightforward and efficient handling of ordinal transactions. They complement the existing Paymail capabilities by focusing on a specific transaction type. As the ecosystem evolves, further protocols may be developed to accommodate more complex functions involving ordinals. \ No newline at end of file diff --git a/src/capability/simpleP2pOrdinalDestinationsCapability.ts b/src/capability/simpleP2pOrdinalDestinationsCapability.ts new file mode 100644 index 0000000..f297d9a --- /dev/null +++ b/src/capability/simpleP2pOrdinalDestinationsCapability.ts @@ -0,0 +1,9 @@ +import Capability from './capability.js' + +export default new Capability({ + title: 'Simple P2P Ordinal Destinations', + authors: ['Brandon Bryant (HandCash)'], + version: '1', + method: 'POST', + code: 'cc2154bfa6a2' +}) diff --git a/src/capability/simpleP2pOrdinalReceiveCapability.ts b/src/capability/simpleP2pOrdinalReceiveCapability.ts new file mode 100644 index 0000000..0006c0d --- /dev/null +++ b/src/capability/simpleP2pOrdinalReceiveCapability.ts @@ -0,0 +1,9 @@ +import Capability from './capability.js' + +export default new Capability({ + title: 'Simple P2P Ordinal Receive Transaction', + authors: ['Brandon Bryant (HandCash)'], + version: '1', + method: 'POST', + code: '2cc00c7f93c3' +}) diff --git a/src/paymailClient/paymailClient.ts b/src/paymailClient/paymailClient.ts index 9022b40..0c2039f 100644 --- a/src/paymailClient/paymailClient.ts +++ b/src/paymailClient/paymailClient.ts @@ -11,6 +11,8 @@ import P2pPaymentDestinationCapability from '../capability/p2pPaymentDestination import ReceiveTransactionCapability from '../capability/p2pReceiveTransactionCapability.js' import VerifyPublicKeyOwnerCapability from '../capability/verifyPublicKeyOwnerCapability.js' import ReceiveBeefTransactionCapability from '../capability/p2pReceiveBeefTransactionCapability.js' +import SimpleP2pOrdinalDestinationsCapability from '../capability/simpleP2pOrdinalDestinationsCapability.js' +import SimpleP2pOrdinalReceiveCapability from '../capability/simpleP2pOrdinalReceiveCapability.js' const { sha256 } = Hash /** @@ -188,6 +190,31 @@ export default class PaymailClient { return response } + /** + * Requests a P2P ordinal destination for a given Paymail. + * @param paymail - The Paymail address to request the payment destination for. + * @param ordinals - The amount of ordinals to be sent in transaction + * @returns An object containing the ordinal destination details. + */ + public getP2pOrdinalDestinations = async (paymail: string, ordinals: number): Promise => { + const response = await this.request(paymail, SimpleP2pOrdinalDestinationsCapability, { + ordinals + }) + + const schema = Joi.object({ + outputs: Joi.array().items( + Joi.object({ + script: Joi.string().required() + }).required().min(1)), + reference: Joi.string().required() + }).options({ stripUnknown: true }) + const { error } = schema.validate(response) + if (error) { + throw new PaymailServerResponseError(`Validation error: ${error.message}`) + } + return response + } + /** * Sends a transaction using the Pay-to-Peer (P2P) protocol. * This method is used to send a transaction to a Paymail address. @@ -222,6 +249,40 @@ export default class PaymailClient { return value } + /** + * Sends a transaction using the Pay-to-Peer (P2P) protocol. + * This method is used to send a transaction to a Paymail address. + * + * @param paymail - The Paymail address to send the transaction to. + * @param hex - The transaction in hexadecimal format. + * @param reference - A reference identifier for the transaction. + * @param metadata - Optional metadata for the transaction including sender, public key, signature, and note. + * @returns A Promise that resolves to an object containing the transaction ID and an optional note. + * @throws PaymailServerResponseError - Thrown if there is a validation error in the response. + */ + public sendOrdinalTransactionP2P = async (paymail: string, hex: string, reference: string, metadata?: { + sender: string + pubkey: string + signature: string + note: string + }) => { + const response = await this.request(paymail, SimpleP2pOrdinalReceiveCapability, { + hex, + reference, + metadata + }) + + const schema = Joi.object({ + txid: Joi.string().required(), + note: Joi.string().optional().allow('') + }).options({ stripUnknown: true }) + const { error, value } = schema.validate(response) + if (error) { + throw new PaymailServerResponseError(`Validation error: ${error.message}`) + } + return value + } + /** * Creates a digital signature for a P2P transaction using a given private key. * @param txid - The transaction ID to be signed. diff --git a/src/paymailRouter/__tests/simple-p2p-ordinal-destinations.test.ts b/src/paymailRouter/__tests/simple-p2p-ordinal-destinations.test.ts new file mode 100644 index 0000000..f6a0c72 --- /dev/null +++ b/src/paymailRouter/__tests/simple-p2p-ordinal-destinations.test.ts @@ -0,0 +1,49 @@ +import request from 'supertest' +import express from 'express' +import PaymailRouter from '../../../dist/cjs/src/paymailRouter/paymailRouter.js' +import OrdinalP2pPaymentDestinationRoute from '../../../dist/cjs/src/paymailRouter/paymailRoutes/simpleP2pOrdinalDestinationsRoute.js' +import PaymailClient from '../../../dist/cjs/src/paymailClient/paymailClient.js' + +describe('#Paymail Server - Simple Ordinal P2P Payment Destinations', () => { + let app + let client: PaymailClient + + beforeAll(() => { + app = express(); + const baseUrl = 'http://localhost:3000'; + client = new PaymailClient(undefined, undefined, undefined); + const routes = [ + new OrdinalP2pPaymentDestinationRoute({ + domainLogicHandler: (name, domain, body) => { + return { + outputs: [ + { + script: '76a914f32281faa74e2ac037493f7a3cd317e8f5e9673688ac', + } + ], + reference: 'someref' + }; + } + }) + ]; + const paymailRouter = new PaymailRouter({ baseUrl, routes }); + app.use(paymailRouter.getRouter()); + }); + + it('should get ordinal p2p destination', async () => { + const response = await request(app).post('/ordinal-p2p-payment-destination/satoshi@bsv.org').send({ + ordinals: 1, + }) + expect(response.statusCode).toBe(200) + expect(response.body.outputs[0].script).toEqual('76a914f32281faa74e2ac037493f7a3cd317e8f5e9673688ac') + expect(response.body.reference).toEqual('someref') + }) + + it('should return 400 if ordinals is not provided', async () => { + const response = await request(app).post('/ordinal-p2p-payment-destination/satoshi@bsv.org').send({ + BSV: 1 + }) + expect(response.statusCode).toBe(400) + expect(response.error.text).toEqual('Invalid body: "ordinals" is required') + }) +}) diff --git a/src/paymailRouter/paymailRoutes/receiveOrdinalTransaction.ts b/src/paymailRouter/paymailRoutes/receiveOrdinalTransaction.ts new file mode 100644 index 0000000..ae674c3 --- /dev/null +++ b/src/paymailRouter/paymailRoutes/receiveOrdinalTransaction.ts @@ -0,0 +1,104 @@ +import Joi from 'joi'; +import { PublicKey, Transaction, Signature } from '@bsv/sdk'; +import PaymailRoute, { DomainLogicHandler } from './paymailRoute.js'; +import simpleP2pOrdinalReceiveCapability from 'src/capability/simpleP2pOrdinalReceiveCapability.js'; +import { PaymailBadRequestError } from '../../errors/index.js'; +import PaymailClient from '../../paymailClient/paymailClient.js'; + +interface ReceiveTransactionResponse { + txid: string; + note?: string; +} + +interface ReceiveTransactionRouteConfig { + domainLogicHandler: DomainLogicHandler; + verifySignature?: boolean; + paymailClient: PaymailClient; + endpoint?: string; +} + +export default class SimpleP2pOrdinalReceiveRoute extends PaymailRoute { + private readonly verifySignature: boolean; + private readonly paymailClient: PaymailClient; + + constructor(config: ReceiveTransactionRouteConfig) { + super({ + capability: simpleP2pOrdinalReceiveCapability, + endpoint: config.endpoint || '/receive-transaction/:paymail', + domainLogicHandler: config.domainLogicHandler + }); + this.verifySignature = config.verifySignature ?? false; + this.paymailClient = config.paymailClient; + } + + protected async validateBody(body: any): Promise { + const schema = this.buildSchema(); + const { error, value } = schema.validate(body); + if (error) { + throw new PaymailBadRequestError(error.message); + } + await this.validateTransaction(value); + } + + private buildSchema() { + console.log(this.verifySignature) + const metadataSchema = Joi.object({ + sender: this.verifySignature ? Joi.string().required() : Joi.string().allow('').optional(), + pubkey: this.verifySignature ? Joi.string().required() : Joi.string().allow('').optional(), + signature: this.verifySignature ? Joi.string().required() : Joi.string().allow('').optional(), + note: Joi.string().allow('').optional(), + }).options({ stripUnknown: true }); + return Joi.object({ + hex: Joi.string().required(), + metadata: this.verifySignature ? metadataSchema.required() : metadataSchema, + reference: Joi.string().required(), + }).options({ stripUnknown: true }); + } + + private async validateTransaction(value: any) { + const tx = this.validateTransactionFormat(value.hex); + if (this.verifySignature) { + await this.validateSignature(tx, value.metadata); + } + } + + private validateTransactionFormat(hex: string): Transaction { + try { + return Transaction.fromHex(hex); + } catch (error) { + throw new PaymailBadRequestError('Invalid body: ' + error.message); + } + } + + private async validateSignature(tx: Transaction, metadata: { + sender: string; + pubkey: string; + signature: string; + }): Promise { + const { sender, pubkey, signature } = metadata; + const match = await this.verifySenderPublicKey(sender, pubkey); + if (!match) { + throw new PaymailBadRequestError('Invalid Public Key for sender'); + } + this.verifyTransactionSignature(tx.id('hex') as string, signature, pubkey); + } + + private async verifySenderPublicKey(sender: string, pubkey: string): Promise { + const { match } = await this.paymailClient.verifyPublicKey(sender, pubkey); + return match; + } + + private verifyTransactionSignature(message: string, signature: string, pubkey: string): void { + const sig = Signature.fromDER(signature, 'hex'); + if (!sig.verify(message, PublicKey.fromString(pubkey))) { + throw new PaymailBadRequestError('Invalid Signature'); + } + } + + protected serializeResponse(domainLogicResponse: ReceiveTransactionResponse): string { + return JSON.stringify({ + txid: domainLogicResponse.txid, + note: domainLogicResponse.note || '', + }); + } +} diff --git a/src/paymailRouter/paymailRoutes/simpleP2pOrdinalDestinationsRoute.ts b/src/paymailRouter/paymailRoutes/simpleP2pOrdinalDestinationsRoute.ts new file mode 100644 index 0000000..5d033c2 --- /dev/null +++ b/src/paymailRouter/paymailRoutes/simpleP2pOrdinalDestinationsRoute.ts @@ -0,0 +1,47 @@ +import PaymailRoute, { DomainLogicHandler } from './paymailRoute.js'; +import simpleP2pOrdinalDestinationsCapability from '../../capability/simpleP2pOrdinalDestinationsCapability.js'; +import { PaymailBadRequestError } from '../../errors/index.js'; +import joi from 'joi'; + +interface OrdinalP2pDestination { + script: string; +} + +interface OrdinalP2pDestinationsResponse { + outputs: OrdinalP2pDestination[]; + reference: string; +} + +interface OrdinalP2pPaymentDestinationRouteConfig { + domainLogicHandler: DomainLogicHandler; +} + +export default class OrdinalP2pPaymentDestinationRoute extends PaymailRoute { + constructor(config: OrdinalP2pPaymentDestinationRouteConfig) { + super({ + capability: simpleP2pOrdinalDestinationsCapability, + endpoint: '/ordinal-p2p-payment-destination/:paymail', + domainLogicHandler: config.domainLogicHandler + }); + } + + + protected async validateBody(body: any): Promise { + const schema = joi.object({ + ordinals: joi.number().required(), + }); + const { error } = schema.validate(body); + if (error) { + throw new PaymailBadRequestError('Invalid body: ' + error.message); + } + } + + protected serializeResponse(domainLogicResponse: OrdinalP2pDestinationsResponse): string { + return JSON.stringify({ + outputs: domainLogicResponse.outputs.map(output => ({ + script: output.script, + })), + reference: domainLogicResponse.reference + }); + } +}