diff --git a/governance/repository-health/contract-baseline.json b/governance/repository-health/contract-baseline.json index d0c79e6b2..b9acf855d 100644 --- a/governance/repository-health/contract-baseline.json +++ b/governance/repository-health/contract-baseline.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "recordedAt": "2026-07-26", - "findingCount": 59, + "findingCount": 54, "findings": [ { "id": ".::missing-script::format:check", @@ -51,26 +51,6 @@ "id": "packages/messaging/message-box-client::missing-script::typecheck", "message": "Profile dual-runtime-library requires script typecheck" }, - { - "id": "packages/messaging/ts-paymail::missing-script::format:check", - "message": "Profile dual-runtime-library requires script format:check" - }, - { - "id": "packages/messaging/ts-paymail::missing-script::pack:check", - "message": "Profile dual-runtime-library requires script pack:check" - }, - { - "id": "packages/messaging/ts-paymail::missing-script::typecheck", - "message": "Profile dual-runtime-library requires script typecheck" - }, - { - "id": "packages/messaging/ts-paymail/docs/examples::missing-script::format:check", - "message": "Profile examples requires script format:check" - }, - { - "id": "packages/messaging/ts-paymail/docs/examples::missing-script::typecheck", - "message": "Profile examples requires script typecheck" - }, { "id": "packages/middleware/auth-express-middleware::missing-script::format:check", "message": "Profile node-library requires script format:check" diff --git a/packages/messaging/ts-paymail/AGENTS.md b/packages/messaging/ts-paymail/AGENTS.md index 56f4233cf..ba3636298 100644 --- a/packages/messaging/ts-paymail/AGENTS.md +++ b/packages/messaging/ts-paymail/AGENTS.md @@ -1,170 +1,99 @@ -# CLAUDE.md — @bsv/paymail +# AGENTS.md — `@bsv/paymail` ## Purpose -TypeScript SDK for BSV Paymail (BRC-121 capability discovery and routing). Provides both client-side capability discovery and server-side router with built-in support for PKI, P2P destinations, and public profiles. - -## Public API surface - -### Client (@bsv/paymail/client or default export) -- **PaymailClient** (class): `new PaymailClient(httpClient, validateDomain, validateEmail)` - - `getPublicProfile(paymail)` — retrieve name, avatar, avatar_hash for user@domain - - `getCapabilities(domain)` — discover all capabilities (PKI, P2P Destinations, etc.) for domain - - `getPki(paymail)` — get user's public key (BRC-121 PKI capability) - - `getP2pDestinations(paymail, amount, purpose)` — get payment output scripts (BRC-121 P2P capability) - - Internal: auto-discovers capabilities, validates paymail format, caches results - -### Server (@bsv/paymail/router) -- **PaymailRouter** (class): `new PaymailRouter({ baseUrl, routes })` - - Constructor takes Express baseUrl (e.g., 'https://myDomain.com') and array of `PaymailRoute` handlers - - `.getRouter()` — returns Express router configured for `/.well-known/bsvalias/*` endpoints - - Built-in routes: `PublicProfileRoute`, `PublicKeyInfrastructureRoute`, `ReceiveRawTransactionRoute` - -### Built-in Route Classes -- **PublicProfileRoute** — responds to `GET /.well-known/bsvalias/public-profile/{name}@{domain}` - - Requires `domainLogicHandler` callback for looking up user profile - - Returns: `{ name, domain, avatar?, avatar_hash? }` - -- **PublicKeyInfrastructureRoute** — responds to `GET /.well-known/bsvalias/pki/{name}@{domain}` - - Requires `domainLogicHandler` callback returning user's public key - - Returns: `{ bsvalias, handle, pubkey }` - -- **ReceiveRawTransactionRoute** — responds to `POST /.well-known/bsvalias/receive-transaction/{name}@{domain}` - - Requires `domainLogicHandler` callback for transaction handling - - Receives hex-encoded raw transaction, returns: `{ txid }` - -- **P2PDestinationsRoute** — responds to `GET /.well-known/bsvalias/p2p-payment-destination/{name}@{domain}?sats={amount}&purpose={purpose}` - - Returns payment derivation instructions per BRC-29 - - Returns: `{ outputs: [ { derivationSuffix, script, satoshis } ] }` - -## Real usage patterns - -From README: -```ts -import express from 'express' -import { PaymailRouter, PublicKeyInfrastructureRoute, PublicProfileRoute } from '@bsv/paymail' - -const app = express() -const baseUrl = 'https://myDomain.com' - -const publicProfileRoute = new PublicProfileRoute({ - domainLogicHandler: async (name, domain) => { - const user = await fetchUser(name, domain) - return { - name: user.getAlias(), - domain, - avatar: user.getAvatarUrl() - } - } -}) - -const pkiRoute = new PublicKeyInfrastructureRoute({ - domainLogicHandler: async (name, domain) => { - const user = await fetchUser(name, domain) - return { - bsvalias: '1.0', - handle: `${name}@${domain}`, - pubkey: user.getIdentityKey() - } - } -}) - -const routes = [publicProfileRoute, pkiRoute] -const paymailRouter = new PaymailRouter({ baseUrl, routes }) -app.use(paymailRouter.getRouter()) - -app.listen(3000, () => { - console.log(`Paymail server running on ${baseUrl}:3000`) -}) -``` -Client: -```ts -import { PaymailClient } from '@bsv/paymail' +`@bsv/paymail` is the TypeScript stack's Paymail client and Express routing +library. It implements capability discovery and typed flows for profiles, PKI, +P2P transaction delivery, transaction negotiation, and simple ordinals. -const client = new PaymailClient() +## Supported surfaces -const publicProfile = await client.getPublicProfile('satoshi@myDomain.com') -console.log(publicProfile.name, publicProfile.avatar) +- Root: `@bsv/paymail` +- Client: `@bsv/paymail/client` +- Capabilities: `@bsv/paymail/capability` +- Server router: `@bsv/paymail/router` +- Errors: `@bsv/paymail/errors` -const capabilities = await client.getCapabilities('myDomain.com') -console.log(capabilities) // lists all supported BRC-121 capabilities +The root browser condition intentionally exports the client, capabilities, and +errors without importing Express or server routes. Preserve every declared +subpath and its ESM/CommonJS declaration pairing when changing the build. -const pkiResult = await client.getPki('satoshi@myDomain.com') -console.log(pkiResult.pubkey) // user's identity key -``` +## Client contract -From tests: -```ts -const paymailClient = new PaymailClient(new HttpClient()) +Construct `PaymailClient` with optional `HttpClient`, `DNSResolverOptions`, and +localhost port arguments. `getCapabilities` is the public alias for +`getDomainCapabilities`; capabilities are cached per client instance. -const publicProfile = await paymailClient.getPublicProfile('brandonbryant@handcash.io') -expect(publicProfile).toHaveProperty('name') -expect(publicProfile).toHaveProperty('avatar') -``` +The default HTTP transport serializes JSON requests and enforces a 30-second +timeout. Unit tests must use injected HTTP and DNS fakes. Do not add tests that +depend on public DNS, Paymail providers, ARC, or other live services. -## Key concepts +P2P signatures use compact Base64 Bitcoin Signed Message encoding. The raw, +BEEF, and ordinal receive routes must accept the same encoding produced by +`PaymailClient.createP2PSignature`. Keep malformed length/header rejection and +transaction-ID signature checks covered. Verify the signature locally before +performing an outbound Paymail ownership lookup. -- **BRC-121 capabilities**: Discovery of Paymail services (PKI, P2P destinations, receive transaction, public profile) -- **Well-known endpoints**: All Paymail services exposed via `/.well-known/bsvalias/*` paths -- **Capability negotiation**: Client discovers what a domain supports before making requests -- **PKI (Public Key Infrastructure)**: BRC-121 PKI capability maps paymail to public key -- **P2P Destinations**: BRC-121 P2P capability returns payment output derivation per BRC-29 -- **Domain logic**: Routes delegate user lookup to your `domainLogicHandler` callback -- **Route extensibility**: Create custom `PaymailRoute` subclasses for custom capabilities -- **Paymail format**: `name@domain` validated per BRC-121 spec +## Router contract -## Dependencies +`domainLogicHandler` receives: -- `@bsv/sdk` ^2.0.14 — crypto, HTTP utilities -- `express` ^5.1.0 — web framework (server only) -- `joi` ^18.0.1 — schema validation -- `cross-fetch` or `node-fetch` ^3 — HTTP client -- Dev: jest, ts-jest, TypeScript, Oxlint +1. a route-parameter object containing `paymail` and any other path parameters; +2. the validated and unknown-field-stripped request body, when present; and +3. an optional public key for route implementations that supply one. -## Common pitfalls / gotchas +Use `PaymailRoute.getNameAndDomain(params)` to parse the handle. A router's +`baseUrl` is the externally reachable origin advertised by +`/.well-known/bsvalias`; `basePath` is optional. -1. **Domain logic is async** — all `domainLogicHandler` callbacks are async; implement accordingly -2. **Paymail format validation** — library validates `name@domain` format; invalid paymail raises error -3. **Capability caching** — client caches discovered capabilities; use options to control TTL -4. **BaseUrl must match DNS** — PaymailRouter's baseUrl should match the domain's actual DNS (e.g., 'https://myDomain.com') -5. **No trailing slashes** — baseUrl should not have trailing slash -6. **HTTP-only PKI discovery** — Paymail service discovery happens via standard HTTP DNS lookup + well-known paths -7. **Error handling** — 404 if user not found, 403 if user found but capability not offered +The package deliberately does not impose CORS or CSP. These endpoints are often +public services called by applications, WUI clients, mobile devices, webviews, +and unknown future origins. Host applications and edge infrastructure must keep +credential-free public access broadly available by default where required, +while allowing explicit origin allowlists or credentialed modes as operator +configuration. Never treat CORS as authorization or combine wildcard origins +with credentials. -## Spec conformance +## Build and publication -- **BRC-121** (Paymail Service Discovery): Full capability discovery, service routing, profile/PKI/P2P capability exposure -- **BRC-29** (Payment Derivation): P2P payment destination uses BRC-29 key derivation -- **BRC-100** (Wallet interface): Public key infrastructure integrates with wallet identity keys +`tsdown.config.ts` produces unbundled ESM and CommonJS files, module-specific +declarations, source maps, and a client-only browser root. Do not restore the +legacy project-reference build or publish source/test/example files. -## File map +The package is published only by the repository's coordinated release workflow. +Do not run `npm publish` or `pnpm publish` from a workstation. Preserve Open BSV +License Version 6 through the repository's license synchronization and packed +artifact controls. -``` -/Users/personal/git/ts-stack/packages/messaging/ts-paymail/ - src/ - index.ts — main exports - paymailClient/ - paymailClient.ts — client for capability discovery and profile retrieval - httpClient.ts — HTTP transport - resolver/ — DNS resolution for Paymail domains - paymailRouter/ - paymailRouter.ts — Express router configuration - routes/ - PublicProfileRoute.ts - PublicKeyInfrastructureRoute.ts - ReceiveRawTransactionRoute.ts - P2PDestinationsRoute.ts - capability/ — capability definitions and handlers - errors/ — PaymailError, ValidationError - tests/ - **/tests/ — unit & integration tests - docs/ - examples/ — example server & client code -``` +## Required verification + +Run from the repository root: -## Integration points +```sh +pnpm --filter @bsv/paymail format:check +pnpm --filter @bsv/paymail lint +pnpm --filter @bsv/paymail typecheck +pnpm --filter @bsv/paymail test:coverage +pnpm --filter @bsv/paymail pack:check +pnpm --filter @bsv/paymail test:browser +pnpm --filter example-paymail format:check +pnpm --filter example-paymail lint +pnpm --filter example-paymail test +pnpm --filter example-paymail build +``` -- **@bsv/sdk** — Wallet identity keys, public key operations, HTTP utilities -- **express** — Web framework for server-side router -- **auth-express-middleware** — Can be combined for authenticated Paymail routes (BRC-103 + BRC-121) -- **message-box-client** — Can use Paymail for peer discovery before messaging +Coverage thresholds are enforced in `jest.config.js`. `pack:check` validates +the exact dry-packed tarball in clean ESM and CommonJS consumers. `test:browser` +validates the same tarball with Vite and esbuild against +`browser-budget.json`. Coverage collection must continue to include every +production file under `src`, including modules that no test imports. + +## Examples and secrets + +`docs/examples` is a private compiled fixture, not a deployable or published +package. External examples accept only operator-supplied +`ARC_API_KEY`, `PAYMAIL_EXAMPLE_SATOSHI_XPRV`, +`PAYMAIL_EXAMPLE_HAL_XPRV`, and `PAYMAIL_EXAMPLE_JWT_SECRET` environment +variables. Never commit credentials, private keys, live-user data, or fallback +secret values. `DOMAIN`, `PORT`, and `PAYMAIL_BASE_URL` configure the manual +server's advertised endpoint. diff --git a/packages/messaging/ts-paymail/README.md b/packages/messaging/ts-paymail/README.md index 95f1c6845..c7399ff8f 100644 --- a/packages/messaging/ts-paymail/README.md +++ b/packages/messaging/ts-paymail/README.md @@ -1,127 +1,154 @@ # BSV Paymail -## Table of Contents +`@bsv/paymail` provides a typed Paymail client and an Express router for +capability discovery, public profiles, PKI, P2P transaction delivery, +transaction negotiation, and simple ordinal flows. -- [BSV Paymail](#bsv-paymail) - - [Table of Contents](#table-of-contents) - - [Objective](#objective) - - [Getting Started](#getting-started) - - [npm install](#npm-install) - - [Basic Usage](#basic-usage) - - [Paymail Server](#paymail-server) - - [Paymail Client](#paymail-client) - - [Documentation](#documentation) - - [Contribution Guidelines](#contribution-guidelines) - - [Support \& Contacts](#support--contacts) - - [License](#license) +## Requirements and installation -## Objective +The package supports Node.js 22 or newer. Install the package and its required +SDK peer: -The objective of this project is to develop a robust and versatile TypeScript library for Bitcoin SV (BSV) Paymail, aimed at enhancing the integration and functionality of Paymail in the BSV blockchain ecosystem. This library will focus on two primary components: - -1. **Server Development**: Building a comprehensive server setup that supports service discovery and a variety of capabilities. It will include default functionalities like P2P Destinations, Receive Raw Transaction, Public Profile, and the flexibility for developers to incorporate additional custom capabilities. +```sh +npm install @bsv/paymail @bsv/sdk +``` -2. **Client Implementation**: Creating client-side features to facilitate host and capability discovery in both Node.js and browser environments. This will involve developing methods for seamless interaction with the server's provided capabilities. +The package publishes native ESM and CommonJS entry points with +module-specific declarations. The root browser condition contains only the +client, capability, and error APIs; Express router code is excluded from +browser bundles. -The project aims to streamline the process of integrating BSV Paymail into various applications, promoting broader adoption and utility within the BSV ecosystem. +Supported public entry points are: +- `@bsv/paymail` +- `@bsv/paymail/client` +- `@bsv/paymail/capability` +- `@bsv/paymail/router` +- `@bsv/paymail/errors` -## Getting Started +## Client -### npm install +```ts +import { PaymailClient } from '@bsv/paymail' +const client = new PaymailClient() +const profile = await client.getPublicProfile('satoshi@example.com') +const capabilities = await client.getCapabilities('example.com') -```bash -npm install @bsv/paymail +console.log(profile.name, profile.avatar) +console.log(capabilities) ``` -### Basic Usage +The default HTTP client enforces a 30-second timeout. Capability documents are +cached per client instance. Supply a custom `HttpClient`, DNS resolver options, +or localhost port to the constructor when testing or integrating a different +transport. + +## Server router -#### Paymail Server -```typescript +Domain handlers receive the Express route-parameter object followed by the +validated request body. Use `getNameAndDomain` rather than parsing the handle +again: + +```ts import express from 'express' import { PaymailRouter, PublicKeyInfrastructureRoute, PublicProfileRoute } from '@bsv/paymail' const app = express() -const baseUrl = 'https://myDomain.com' -const publicProfileRoute = new PublicProfileRoute({ - domainLogicHandler: async (name, domain) => { +const profileRoute = new PublicProfileRoute({ + domainLogicHandler: async params => { + const { name, domain } = PublicProfileRoute.getNameAndDomain(params) const user = await fetchUser(name, domain) return { - name: user.getAlias(), - domain, - avatar: user.getAvatarUrl() + name: user.alias, + avatar: user.avatarUrl } } }) const pkiRoute = new PublicKeyInfrastructureRoute({ - domainLogicHandler: async (name, domain) => { + domainLogicHandler: async params => { + const { name, domain } = PublicKeyInfrastructureRoute.getNameAndDomain(params) const user = await fetchUser(name, domain) return { bsvalias: '1.0', handle: `${name}@${domain}`, - pubkey: user.getIdentityKey() + pubkey: user.identityKey } } }) -const routes = [publicProfileRoute, pkiRoute] -const paymailRouter = new PaymailRouter({ baseUrl, routes }) -app.use(paymailRouter.getRouter()) - -const PORT = 3000 -app.listen(PORT, async () => { - console.log(`Server is running on ${baseUrl}:${PORT}`) +const paymail = new PaymailRouter({ + baseUrl: 'https://paymail.example.com', + routes: [profileRoute, pkiRoute] }) -``` -#### Paymail Client - -```typescript -import { PaymailClient } from '@bsv/paymail' +app.use(paymail.getRouter()) +app.listen(3000) +``` -const client = new PaymailClient(); +`baseUrl` is the externally reachable origin advertised in the +`/.well-known/bsvalias` capability document. `basePath` can be supplied when +the router is mounted below the origin root. -(async () => { - const publicProfile = await client.getPublicProfile('satoshi@myDomain.com') - console.log(publicProfile) -})() +### Cross-origin deployment -``` +The library does not impose CORS or CSP policy. Paymail capability endpoints +are public protocol surfaces and commonly need to remain callable by deployed +applications across unrelated domains, webviews, and future clients. Configure +CORS at the host application or edge: -For a more detailed tutorial and advanced examples, check our [Documentation](#documentation). +- keep credential-free public APIs broadly accessible by default when the + service contract requires it; +- make exact-origin allowlists and credentialed origin handling explicit + operator choices; +- never combine wildcard origins with credentialed requests; and +- treat CSP as a document/UI policy, not as API authorization. +Authentication and authorization must be enforced by the protocol or route +logic rather than by assuming browser-origin headers are an access-control +boundary. -## Documentation +## Signing compatibility -The SDK is richly documented with code-level annotations. This should show up well within editors like VSCode. For complete API docs, check out [the docs folder](./docs) +`createP2PSignature` produces the compact Base64 Bitcoin Signed Message form +accepted by the raw, BEEF, and ordinal receive routes when signature +verification is enabled. The receiver verifies the transaction-ID signature +locally before performing the Paymail ownership lookup for the declared public +key, so malformed signatures cannot trigger outbound discovery work. -## Contribution Guidelines +## Development and verification -We're always looking for contributors to help us improve the project. Whether it's bug reports, feature requests, or pull requests - all contributions are welcome. +From the repository root: -1. **Fork & Clone**: Fork this repository and clone it to your local machine. -2. **Set Up**: Run `npm install` to install all dependencies. -3. **Make Changes**: Create a new branch and make your changes. -4. **Test**: Ensure all tests pass by running `npm test`. -5. **Commit**: Commit your changes and push to your fork. -6. **Pull Request**: Open a pull request from your fork to this repository. - -For more details, check the [contribution guidelines](./CONTRIBUTING.md). +```sh +pnpm --filter @bsv/paymail format:check +pnpm --filter @bsv/paymail lint +pnpm --filter @bsv/paymail typecheck +pnpm --filter @bsv/paymail test:coverage +pnpm --filter @bsv/paymail pack:check +pnpm --filter @bsv/paymail test:browser +pnpm --filter example-paymail test +``` -For information on past releases, check out the [changelog](./CHANGELOG.md). For future plans, check the [roadmap](./ROADMAP.md)! +Tests are deterministic and must not depend on public Paymail or DNS services. +The private examples under `docs/examples` are compiled fixtures for explicit +manual use; their external-service credentials and example private keys are +supplied through environment variables and must never be committed. -## Support & Contacts -Project Owners: Thomas Giacomo and Darren Kellenschwiler +`pack:check` installs the exact dry-packed tarball into clean ESM and CommonJS +consumers and validates declarations, export maps, publint, and payload +hygiene. `test:browser` installs that same tarball and measures Vite and +esbuild bundles against the checked-in browser budget. -Development Team Lead: Brandon Bryant +Package publishing is performed only by the repository release workflow. Do +not publish from a developer workstation. -For questions, bug reports, or feature requests, please open an issue on GitHub or contact us directly. +Additional API and protocol material is available in [`docs`](./docs). +Please report defects through the +[ts-stack issue tracker](https://github.com/bsv-blockchain/ts-stack/issues). ## License -The license for the code in this repository is the Open BSV License. Refer to [LICENSE.txt](./LICENSE.txt) for the license text. - -Thank you for being a part of the BSV Blockchain ecosystem. Let's build the future of BSV Blockchain together! +Open BSV License Version 6. See [`LICENSE.txt`](./LICENSE.txt). diff --git a/packages/messaging/ts-paymail/browser-budget.json b/packages/messaging/ts-paymail/browser-budget.json new file mode 100644 index 000000000..044cd3aad --- /dev/null +++ b/packages/messaging/ts-paymail/browser-budget.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": 1, + "profile": "browser", + "package": "@bsv/paymail", + "entry": ".", + "requiredExports": [ + "AbstractDnsResolver", + "Capability", + "DNSResolver", + "HttpClient", + "PaymailBadRequestError", + "PaymailClient", + "PaymailError", + "PaymailServerResponseError", + "PublicProfileCapability", + "SimpleP2pOrdinalDestinationsCapability", + "SimpleP2pOrdinalReceiveCapability" + ], + "prohibitedExports": [ + "PaymailRoute", + "PaymailRouter", + "ReceiveTransactionRoute", + "SimpleP2pOrdinalReceiveRoute" + ], + "maximumBytes": { + "vite": { + "raw": 530000, + "gzip": 136000, + "brotli": 114000 + }, + "esbuild": { + "raw": 365000, + "gzip": 126000, + "brotli": 107000 + } + } +} diff --git a/packages/messaging/ts-paymail/client.ts b/packages/messaging/ts-paymail/client.ts deleted file mode 100644 index e301a077d..000000000 --- a/packages/messaging/ts-paymail/client.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './src/paymailClient/index.js'; -export * from './src/errors/index.js'; -export * from './src/capability/index.js'; \ No newline at end of file diff --git a/packages/messaging/ts-paymail/docs/capability.md b/packages/messaging/ts-paymail/docs/capability.md index 3d03c0138..ea3ec5056 100644 --- a/packages/messaging/ts-paymail/docs/capability.md +++ b/packages/messaging/ts-paymail/docs/capability.md @@ -1,4 +1,3 @@ - Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes) # Interfaces @@ -7,36 +6,37 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes) ```ts export interface TransactionNegotiationBody { - thread_id: string; - fees: Array<{ - feeType: string; - satoshis: number; - bytes: number; - }>; - expanded_tx: { - tx: string; - ancestors: Array<{ - tx: string; - merkle_proofs?: any[]; - miner_responses?: any[]; - }>; - spent_outputs: Array<{ - value: number; - locking_script: string; - }>; - }; - expiry: number; - timestamp: number; - reply_to: { - handle: string; - peer_channel?: string; - }; + thread_id: string + fees?: Array<{ + feeType: string + satoshis: number + bytes: number + }> + expanded_tx: { + tx: string + ancestors?: Array<{ + tx: string + merkle_proofs?: unknown[] + miner_responses?: unknown[] + }> + spent_outputs?: Array<{ + value: number + locking_script: string + }> + } + expiry: number + timestamp: number + reply_to: { + handle: string + peer_channel?: string + } } ``` Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes) --- + # Classes ## Class: Capability @@ -46,16 +46,23 @@ A capability is essentially a feature or service offered by a Paymail provider. ```ts export default class Capability { - constructor({ code, title, authors, version, supersedes, method }: { - code?: string; - title: string; - authors?: string[]; - version?: string; - supersedes?: string[]; - method?: "GET" | "POST"; - }) - public getCode(): string - public getMethod(): "GET" | "POST" + constructor({ + code, + title, + authors, + version, + supersedes, + method + }: { + code?: string + title: string + authors?: string[] + version?: string + supersedes?: string[] + method?: 'GET' | 'POST' + }) + public getCode(): string + public getMethod(): 'GET' | 'POST' } ``` @@ -75,20 +82,20 @@ constructor({ code, title, authors, version, supersedes, method }: { version?: string; supersedes?: string[]; method?: "GET" | "POST"; -}) +}) ``` Argument Details -+ **params** - + The parameters for the capability. +- **params** + - The parameters for the capability. ### Method getCode Retrieves the code of the capability. ```ts -public getCode(): string +public getCode(): string ``` Returns @@ -100,7 +107,7 @@ The capability code or a generated code if not explicitly set. Retrieves the HTTP method of the capability. ```ts -public getMethod(): "GET" | "POST" +public getMethod(): "GET" | "POST" ``` Returns diff --git a/packages/messaging/ts-paymail/docs/examples/README.md b/packages/messaging/ts-paymail/docs/examples/README.md index b1ff7d208..2044d8246 100644 --- a/packages/messaging/ts-paymail/docs/examples/README.md +++ b/packages/messaging/ts-paymail/docs/examples/README.md @@ -11,8 +11,14 @@ pnpm --filter example-paymail server pnpm --filter example-paymail client ``` -The commands that contact a server require an operator-supplied local test -configuration. Do not commit credentials, production keys, or live-user data. -Examples must continue to compile against the workspace package and should be -converted to deterministic automated tests wherever an external service is not -fundamentally required. +The commands that contact external services require operator-supplied test +configuration. Set `ARC_API_KEY`, `PAYMAIL_EXAMPLE_SATOSHI_XPRV`, +`PAYMAIL_EXAMPLE_HAL_XPRV`, and `PAYMAIL_EXAMPLE_JWT_SECRET` only in the local +process environment when running the wallet/broadcast examples. Do not commit +credentials, production keys, or live-user data. Examples must continue to +compile against the workspace package and should be converted to deterministic +automated tests wherever an external service is not fundamentally required. + +The server defaults to `DOMAIN=localhost`, `PORT=3000`, and the advertised +origin `http://localhost:3000`. Set `PAYMAIL_BASE_URL` to the externally +reachable origin when a proxy, custom port, or deployed HTTPS endpoint is used. diff --git a/packages/messaging/ts-paymail/docs/examples/package.json b/packages/messaging/ts-paymail/docs/examples/package.json index 20cfc4a22..f9397197b 100644 --- a/packages/messaging/ts-paymail/docs/examples/package.json +++ b/packages/messaging/ts-paymail/docs/examples/package.json @@ -2,20 +2,21 @@ "name": "example-paymail", "version": "1.0.0", "private": true, - "description": "", - "main": "index.js", + "description": "Private, compiled examples for @bsv/paymail", "scripts": { - "test": "tsc -p tsconfig.json --noEmit --incremental false", + "test": "pnpm typecheck", "build": "tsc -p tsconfig.json", - "lint": "oxlint src", - "server": "npm run build && node dist/cjs/src/server.js", - "client": "npm run build && node dist/cjs/src/client.js", - "getProfile": "npm run build && node dist/cjs/src/client/getProfile.js", - "getPki": "npm run build && node dist/cjs/src/client/getPki.js", - "sendP2P": "npm run build && node dist/cjs/src/client/sendP2P.js", - "sendBeefP2P": "npm run build && node dist/cjs/src/client/sendP2PBeef.js" + "format:check": "pnpm --workspace-root exec prettier --check \"packages/messaging/ts-paymail/docs/examples/**/*.{json,ts}\"", + "lint": "oxlint src --deny-warnings", + "server": "pnpm build && node dist/cjs/src/server.js", + "client": "pnpm build && node dist/cjs/src/client.js", + "getProfile": "pnpm build && node dist/cjs/src/client/getProfile.js", + "getPki": "pnpm build && node dist/cjs/src/client/getPki.js", + "sendP2P": "pnpm build && node dist/cjs/src/client/sendP2P.js", + "sendBeefP2P": "pnpm build && node dist/cjs/src/client/sendP2PBeef.js", + "typecheck": "tsc -p tsconfig.json --noEmit --incremental false" }, - "author": "", + "author": "BSV Blockchain Association", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@bsv/paymail": "workspace:^", diff --git a/packages/messaging/ts-paymail/docs/examples/src/client/getPki.ts b/packages/messaging/ts-paymail/docs/examples/src/client/getPki.ts index 44ef7a0b8..bca10e2d3 100644 --- a/packages/messaging/ts-paymail/docs/examples/src/client/getPki.ts +++ b/packages/messaging/ts-paymail/docs/examples/src/client/getPki.ts @@ -1,9 +1,9 @@ import { PaymailClient } from '@bsv/paymail' import { mockUser1, mockUser2 } from '../mockUser' -const client = new PaymailClient(); +const client = new PaymailClient() -(async () => { +;(async () => { const user1Paymail = mockUser1.getPaymail() const user2Paymail = mockUser2.getPaymail() const pki1 = await client.getPki(user1Paymail) diff --git a/packages/messaging/ts-paymail/docs/examples/src/client/getProfile.ts b/packages/messaging/ts-paymail/docs/examples/src/client/getProfile.ts index 37de2ac70..1c13f1afd 100644 --- a/packages/messaging/ts-paymail/docs/examples/src/client/getProfile.ts +++ b/packages/messaging/ts-paymail/docs/examples/src/client/getProfile.ts @@ -1,9 +1,9 @@ import { PaymailClient } from '@bsv/paymail' import { mockUser1, mockUser2 } from '../mockUser' -const client = new PaymailClient(); +const client = new PaymailClient() -(async () => { +;(async () => { const user1Paymail = mockUser1.getPaymail() const user2Paymail = mockUser2.getPaymail() const publicProfile1 = await client.getPublicProfile(user1Paymail) diff --git a/packages/messaging/ts-paymail/docs/examples/src/client/sendP2P.ts b/packages/messaging/ts-paymail/docs/examples/src/client/sendP2P.ts index cc50661e0..9e472e4de 100644 --- a/packages/messaging/ts-paymail/docs/examples/src/client/sendP2P.ts +++ b/packages/messaging/ts-paymail/docs/examples/src/client/sendP2P.ts @@ -1,14 +1,13 @@ import { PaymailClient } from '@bsv/paymail' import { mockUser1, mockUser2 } from '../mockUser' +const client = new PaymailClient() -const client = new PaymailClient(); - -(async () => { +;(async () => { const sender = mockUser1 const receiver = mockUser2.getPaymail() await sender.initWallet() - const startingBalance = await sender.getSatoshiBalance() + const startingBalance = sender.getSatoshiBalance() console.log('sender starting balance', startingBalance) if (startingBalance < 3) { @@ -18,17 +17,19 @@ const client = new PaymailClient(); const p2pDestination = await client.getP2pPaymentDestination(receiver, startingBalance - 1) // example we are assuming only one output but in reality it can be many - const { tx, reference } = await sender.getSpendingTransactionToScript(p2pDestination.outputs[0].script, startingBalance - 1) + const { tx, reference } = await sender.getSpendingTransactionToScript( + p2pDestination.outputs[0].script, + startingBalance - 1 + ) - await client.sendTransactionP2P(receiver, tx.toHex(), p2pDestination.reference, - { - sender: sender.getPaymail(), - pubkey: sender.getIdentityKey(), - signature: client.createP2PSignature(tx.id('hex') as string, sender.getIdentityPrivateKey()), - note: 'hello world' - }) + await client.sendTransactionP2P(receiver, tx.toHex(), p2pDestination.reference, { + sender: sender.getPaymail(), + pubkey: sender.getIdentityKey(), + signature: client.createP2PSignature(tx.id('hex') as string, sender.getIdentityPrivateKey()), + note: 'hello world' + }) await sender.broadcastTransaction(tx) mockUser1.processTransaction(tx, reference) - console.log('sender updated balance', await sender.getSatoshiBalance()) + console.log('sender updated balance', sender.getSatoshiBalance()) await sender.closeWallet() })() diff --git a/packages/messaging/ts-paymail/docs/examples/src/client/sendP2PBeef.ts b/packages/messaging/ts-paymail/docs/examples/src/client/sendP2PBeef.ts index 54f54057a..e7c45a249 100644 --- a/packages/messaging/ts-paymail/docs/examples/src/client/sendP2PBeef.ts +++ b/packages/messaging/ts-paymail/docs/examples/src/client/sendP2PBeef.ts @@ -1,14 +1,14 @@ import { PaymailClient } from '@bsv/paymail' import { MerklePath } from '@bsv/sdk' -import { mockUser1, mockUser2 } from '../mockUser' +import { mockUser1, mockUser2 } from '../mockUser.js' -const client = new PaymailClient(); +const client = new PaymailClient() -(async () => { +;(async () => { const sender = mockUser1 const receiver = mockUser2.getPaymail() await sender.initWallet() - const startingBalance = await sender.getSatoshiBalance() + const startingBalance = sender.getSatoshiBalance() console.log('sender starting balance', startingBalance) if (startingBalance < 3) { @@ -18,19 +18,27 @@ const client = new PaymailClient(); const p2pDestination = await client.getP2pPaymentDestination(receiver, startingBalance - 1) // example we are assuming only one output but in reality it can be many - const { tx, reference } = await sender.getSpendingTransactionToScript(p2pDestination.outputs[0].script, startingBalance - 1) + const [destination] = p2pDestination.outputs + if (!destination) throw new Error('Paymail server returned no payment destination') + const { tx, reference } = await sender.getSpendingTransactionToScript( + destination.script, + startingBalance - 1 + ) - tx.inputs[0].sourceTransaction.merklePath = MerklePath.fromHex('fee4c80c000a02fd040200bbbae759df9fad847294792eeba4a16f65763f1a16b4ba534fd599b72ea110ccfd050202984a5dddf5d3432b356b8520eb931c0ae6ef84c7ef02a10cfdb285aca6ebf0d401fd030100a4b465a29be85a888fb8525f8479fe21bb3ac265672ec96c5a85e21509599f3f018000af7a024728ae41e682a10f9486c9b2ee75dd9b2cf600ee5caf75f628e64dc73b01410048c49627dfe08ca6f18f248b636519bc1c34979536fe6c0c129ed8f2962e4a6c0121000b08bbe613ee753de0a91b8d4b9a373e7a7219146f83ba750ecab6c3edbeb9610111007021971ef900c6ae86cb7a4ee352ca1bb3a1f1e627fb88d311d4e9cd09ec52b9010900bc3318f6bf5f28abe24bab8eed1affabef810f2029fc749f7d326096ae53d7d4010501010301010000277e90d691db5054c44ae3f11852497aaa9787e181554fccc91cc953650261a6') + const [firstInput] = tx.inputs + if (!firstInput?.sourceTransaction) throw new Error('Transaction input has no source transaction') + firstInput.sourceTransaction.merklePath = MerklePath.fromHex( + 'fee4c80c000a02fd040200bbbae759df9fad847294792eeba4a16f65763f1a16b4ba534fd599b72ea110ccfd050202984a5dddf5d3432b356b8520eb931c0ae6ef84c7ef02a10cfdb285aca6ebf0d401fd030100a4b465a29be85a888fb8525f8479fe21bb3ac265672ec96c5a85e21509599f3f018000af7a024728ae41e682a10f9486c9b2ee75dd9b2cf600ee5caf75f628e64dc73b01410048c49627dfe08ca6f18f248b636519bc1c34979536fe6c0c129ed8f2962e4a6c0121000b08bbe613ee753de0a91b8d4b9a373e7a7219146f83ba750ecab6c3edbeb9610111007021971ef900c6ae86cb7a4ee352ca1bb3a1f1e627fb88d311d4e9cd09ec52b9010900bc3318f6bf5f28abe24bab8eed1affabef810f2029fc749f7d326096ae53d7d4010501010301010000277e90d691db5054c44ae3f11852497aaa9787e181554fccc91cc953650261a6' + ) - await client.sendBeefTransactionP2P(receiver, tx.toHexBEEF(), p2pDestination.reference, - { - sender: sender.getPaymail(), - pubkey: sender.getIdentityKey(), - signature: client.createP2PSignature(tx.id('hex') as string, sender.getIdentityPrivateKey()), - note: 'hello world' - }) + await client.sendBeefTransactionP2P(receiver, tx.toHexBEEF(), p2pDestination.reference, { + sender: sender.getPaymail(), + pubkey: sender.getIdentityKey(), + signature: client.createP2PSignature(tx.id('hex') as string, sender.getIdentityPrivateKey()), + note: 'hello world' + }) await sender.broadcastTransaction(tx) mockUser1.processTransaction(tx, reference) - console.log('sender updated balance', await sender.getSatoshiBalance()) + console.log('sender updated balance', sender.getSatoshiBalance()) await sender.closeWallet() })() diff --git a/packages/messaging/ts-paymail/docs/examples/src/mockUser.ts b/packages/messaging/ts-paymail/docs/examples/src/mockUser.ts index b7a887312..850119aec 100644 --- a/packages/messaging/ts-paymail/docs/examples/src/mockUser.ts +++ b/packages/messaging/ts-paymail/docs/examples/src/mockUser.ts @@ -1,7 +1,35 @@ import jwt from 'jwt-simple' -import { HD, P2PKH, Transaction, ARC, PrivateKey, LockingScript } from '@bsv/sdk' +import { + ARC, + HD, + LockingScript, + P2PKH, + PrivateKey, + Transaction, + type TransactionInput, + type TransactionOutput +} from '@bsv/sdk' const DOMAIN = process.env.DOMAIN ?? 'localhost' +interface AvailableOutput { + reference: string + sourceTransactionId: string + sourceOutputIndex: number + unlockingScriptTemplate: NonNullable +} + +interface WocUtxo { + tx_hash: string + tx_pos: number + value: number +} + +function requiredEnvironment(name: string): string { + const value = process.env[name] + if (!value) throw new Error(`${name} is required to run the Paymail example`) + return value +} + class MockUser { private static readonly IDENTITY_KEY_PATH = 'm/0' private static readonly IDENTITY_KEY_INDEX = 0 @@ -14,12 +42,18 @@ class MockUser { private readonly avatarUrl: string private readonly extendedPrivateKey: string private readonly secret: string - private availableOutputs: any[] + private availableOutputs: AvailableOutput[] private readonly rawTransactionMap: Map = new Map() private p2pIndex = 0 private changeIndex = 0 - constructor (alias, domain, avatarUrl, extendedPrivateKey, jwtSecret = 'secret') { + constructor( + alias: string, + domain: string, + avatarUrl: string, + extendedPrivateKey: string, + jwtSecret: string + ) { this.alias = alias this.avatarUrl = avatarUrl this.extendedPrivateKey = extendedPrivateKey @@ -29,38 +63,45 @@ class MockUser { this.domain = domain } - getAlias () { + getAlias() { return this.alias } - getAvatarUrl () { + getAvatarUrl() { return this.avatarUrl } - getExtendedPrivateKey () { + getExtendedPrivateKey() { return new HD().fromString(this.extendedPrivateKey) } - getIdentityKey () { - return this.getExtendedPrivateKey().derive(MockUser.IDENTITY_KEY_PATH).deriveChild(MockUser.IDENTITY_KEY_INDEX).pubKey.toString() + getIdentityKey() { + return this.getExtendedPrivateKey() + .derive(MockUser.IDENTITY_KEY_PATH) + .deriveChild(MockUser.IDENTITY_KEY_INDEX) + .pubKey.toString() } - getIdentityPrivateKey (): PrivateKey { - return this.getExtendedPrivateKey().derive(MockUser.IDENTITY_KEY_PATH).deriveChild(MockUser.IDENTITY_KEY_INDEX).privKey + getIdentityPrivateKey(): PrivateKey { + return this.getExtendedPrivateKey() + .derive(MockUser.IDENTITY_KEY_PATH) + .deriveChild(MockUser.IDENTITY_KEY_INDEX).privKey } - getPaymailDestination () { + getPaymailDestination() { const nextP2PIndex = this.p2pIndex + 1 this.p2pIndex = nextP2PIndex - const reference = `p2p-${nextP2PIndex}`; - const destinationScript = this.getLockingScriptFromPrivateKey(this.getPrivateKeyFromReference(reference)) + const reference = `p2p-${nextP2PIndex}` + const destinationScript = this.getLockingScriptFromPrivateKey( + this.getPrivateKeyFromReference(reference) + ) return { destinationScript: destinationScript.toHex(), - reference: this.getReferenceToken(reference), + reference: this.getReferenceToken(reference) } - }; + } - processTransaction (tx: Transaction, reference: string) { + processTransaction(tx: Transaction, reference: string) { console.log('Processing transaction', tx.id('hex')) const privateKey = this.getPrivateKeyFromReference(this.getDecodedReferenceToken(reference)) const lockingScript = this.getLockingScriptFromPrivateKey(privateKey) @@ -78,23 +119,28 @@ class MockUser { console.log('Transaction processed', tx.id('hex')) } - getPrivateKeyFromReference (reference: string): PrivateKey { + getPrivateKeyFromReference(reference: string): PrivateKey { if (reference.startsWith('p2p-')) { const p2pIndex = Number.parseInt(reference.split('-')[1]) return this.getExtendedPrivateKey().derive(MockUser.P2P_PATH).deriveChild(p2pIndex).privKey } if (reference.startsWith('change-')) { const changeIndex = Number.parseInt(reference.split('-')[1]) - return this.getExtendedPrivateKey().derive(MockUser.CHANGE_PATH).deriveChild(changeIndex).privKey + return this.getExtendedPrivateKey().derive(MockUser.CHANGE_PATH).deriveChild(changeIndex) + .privKey } if (reference.startsWith('start-')) { const startIndex = Number.parseInt(reference.split('-')[1]) - return this.getExtendedPrivateKey().derive(MockUser.START_PATH).deriveChild(startIndex).privKey + return this.getExtendedPrivateKey().derive(MockUser.START_PATH).deriveChild(startIndex) + .privKey } throw new Error('Unknown reference type' + reference) - }; + } - async getSpendingTransactionToScript (lockingScript, amount) : Promise<{ tx: Transaction, reference: string }>{ + async getSpendingTransactionToScript( + lockingScript: string, + amount: number + ): Promise<{ tx: Transaction; reference: string }> { let targetAmount = amount const { changeOutput, changeIndex } = this.getChangeOutput() const tx = new Transaction() @@ -102,8 +148,8 @@ class MockUser { lockingScript: LockingScript.fromHex(lockingScript), satoshis: amount }) - tx.addOutput(changeOutput); - const usedOutputReferences = [] + tx.addOutput(changeOutput) + const usedOutputReferences: string[] = [] for (const output of this.availableOutputs) { if (targetAmount <= 0) { @@ -114,34 +160,44 @@ class MockUser { throw new Error('Source transaction not found') } const sourceOutput = sourceTx.outputs[output.sourceOutputIndex] + if (sourceOutput?.satoshis === undefined) { + throw new Error('Source transaction output is unavailable') + } tx.addInput({ sourceTransaction: sourceTx, sourceOutputIndex: output.sourceOutputIndex, unlockingScriptTemplate: output.unlockingScriptTemplate, - sequence: 0xFFFFFFFF + sequence: 0xffffffff }) usedOutputReferences.push(`${output.sourceTransactionId}:${output.sourceOutputIndex}`) targetAmount -= sourceOutput.satoshis } if (targetAmount > 0) { - throw new Error('Insufficient funds send money to wallet ' + this.getPrivateKeyFromReference('start-0').toAddress().toString()) + throw new Error( + 'Insufficient funds send money to wallet ' + + this.getPrivateKeyFromReference('start-0').toAddress().toString() + ) } await tx.fee() await tx.sign() this.availableOutputs = this.availableOutputs.filter(output => { - return !usedOutputReferences.includes(`${output.sourceTransactionId}:${output.sourceOutputIndex}`) + return !usedOutputReferences.includes( + `${output.sourceTransactionId}:${output.sourceOutputIndex}` + ) }) return { tx, reference: this.getReferenceToken('change-' + changeIndex) } - }; + } - getChangeOutput () { + getChangeOutput() { this.changeIndex += 1 - const changePrivateKey = this.getExtendedPrivateKey().derive(MockUser.CHANGE_PATH).deriveChild(this.changeIndex).privKey + const changePrivateKey = this.getExtendedPrivateKey() + .derive(MockUser.CHANGE_PATH) + .deriveChild(this.changeIndex).privKey const changeOutput = { lockingScript: this.getLockingScriptFromPrivateKey(changePrivateKey), change: true @@ -150,23 +206,28 @@ class MockUser { changeOutput, changeIndex: this.changeIndex } - }; + } - async broadcastTransaction (tx: Transaction) { - const result = await tx.broadcast(new ARC('https://api.taal.com/arc', 'mainnet_06770f425eb00298839a24a49cbdc02c')) - }; + async broadcastTransaction(tx: Transaction) { + const arcApiKey = process.env.ARC_API_KEY + if (!arcApiKey) throw new Error('ARC_API_KEY is required to broadcast the example transaction') + await tx.broadcast(new ARC('https://api.taal.com/arc', arcApiKey)) + } // Clean user wallet by consolidating outputs // For demonstration purposes only we will send to same path every time to make init easier - async consolidateOutputs () { + async consolidateOutputs() { const privateKey = this.getPrivateKeyFromReference('start-0') - const inputs = [] - const outputs = [{ - lockingScript: this.getLockingScriptFromPrivateKey(privateKey), - change: true - }] + const inputs: TransactionInput[] = [] + const outputs: TransactionOutput[] = [ + { + lockingScript: this.getLockingScriptFromPrivateKey(privateKey), + change: true + } + ] this.availableOutputs.forEach(output => { const sourceTx = this.rawTransactionMap.get(output.sourceTransactionId) + if (!sourceTx) throw new Error('Source transaction not found') const input = { sourceTransaction: sourceTx, sourceOutputIndex: output.sourceOutputIndex, @@ -180,66 +241,87 @@ class MockUser { await this.broadcastTransaction(tx) this.availableOutputs = [] this.processTransaction(tx, this.getReferenceToken('start-0')) - }; + } - getReferenceToken = (path) => jwt.encode(path, this.secret, 'HS512') + getReferenceToken = (path: string): string => jwt.encode(path, this.secret, 'HS512') - getDecodedReferenceToken = (jwtToken) => jwt.decode(jwtToken, this.secret) + getDecodedReferenceToken = (jwtToken: string): string => { + const decoded = jwt.decode(jwtToken, this.secret) + if (typeof decoded !== 'string') throw new Error('Invalid Paymail reference token') + return decoded + } - getAvailableOutputs () { + getAvailableOutputs() { return this.availableOutputs - }; + } - getSatoshiBalance () { - return this.availableOutputs.reduce((acc, output) => acc + this.rawTransactionMap.get(output.sourceTransactionId).outputs[output.sourceOutputIndex].satoshis, 0) - }; + getSatoshiBalance(): number { + return this.availableOutputs.reduce((acc, output) => { + const sourceTransaction = this.rawTransactionMap.get(output.sourceTransactionId) + const sourceOutput = sourceTransaction?.outputs[output.sourceOutputIndex] + if (sourceOutput?.satoshis === undefined) { + throw new Error('Source transaction output is unavailable') + } + return acc + sourceOutput.satoshis + }, 0) + } - getLockingScriptFromPrivateKey (privateKey: PrivateKey ) { + getLockingScriptFromPrivateKey(privateKey: PrivateKey) { return new P2PKH().lock(privateKey.toPublicKey().toHash()) - }; - - async initWallet () { - await Promise.all([this.syncReference('start-0'), - this.syncReference('change-0'), - this.syncReference('p2p-0'), - this.syncReference('p2p-1'), - this.syncReference('change-1')]); - }; - - async syncReference (reference) { - const privateKet = this.getPrivateKeyFromReference(reference) - const url = `https://api.whatsonchain.com/v1/bsv/main/address/${privateKet.toAddress().toString()}/unspent` + } + + async initWallet() { + await Promise.all([ + this.syncReference('start-0'), + this.syncReference('change-0'), + this.syncReference('p2p-0'), + this.syncReference('p2p-1'), + this.syncReference('change-1') + ]) + } + + async syncReference(reference: string): Promise { + const privateKey = this.getPrivateKeyFromReference(reference) + const url = `https://api.whatsonchain.com/v1/bsv/main/address/${privateKey.toAddress().toString()}/unspent` const response = await fetch(url) - const utxos = await response.json() - if(utxos.length === 0) return; + if (!response.ok) throw new Error(`WhatsOnChain UTXO request failed: ${response.status}`) + const utxos = (await response.json()) as WocUtxo[] + if (utxos.length === 0) return for (const utxo of utxos) { if (!this.rawTransactionMap.get(utxo.tx_hash)) { // convert to beef when WOC api allows it - const rawTxResponse = await fetch(`https://api.whatsonchain.com/v1/bsv/main/tx/${utxo.tx_hash}/hex`) + const rawTxResponse = await fetch( + `https://api.whatsonchain.com/v1/bsv/main/tx/${utxo.tx_hash}/hex` + ) + if (!rawTxResponse.ok) { + throw new Error(`WhatsOnChain transaction request failed: ${rawTxResponse.status}`) + } const rawTx = await rawTxResponse.text() const tx = Transaction.fromHex(rawTx) // call arc GET v1/tx/ // this will return 404 or get merkle proof this.rawTransactionMap.set(utxo.tx_hash, tx) tx.outputs.forEach((output, index) => { - if (output.lockingScript.toHex() === this.getLockingScriptFromPrivateKey(privateKet).toHex()) { + if ( + output.lockingScript.toHex() === this.getLockingScriptFromPrivateKey(privateKey).toHex() + ) { this.availableOutputs.push({ - reference: this.getReferenceToken('start-0'), + reference: this.getReferenceToken(reference), sourceTransactionId: utxo.tx_hash, sourceOutputIndex: index, - unlockingScriptTemplate: new P2PKH().unlock(privateKet) + unlockingScriptTemplate: new P2PKH().unlock(privateKey) }) } }) - }; + } } - }; + } - async closeWallet () { + async closeWallet() { await this.consolidateOutputs() } - getPaymail () { + getPaymail() { return this.alias + '@' + this.domain } } @@ -248,15 +330,20 @@ const mockUser1 = new MockUser( 'satoshi', DOMAIN, 'https://cdns-images.dzcdn.net/images/artist/0cd4444701460a1ccf94d150e37476d9/500x500.jpg', - 'xprv9s21ZrQH143K3JXfv6ia4roUARejG3VEEjbhNS1BbeHMFF2V1zBd949FrxUJZat3FSFLEA9wZxjUV2NdbbvD9uCiibdRfpaBVGysQaZxduX') + requiredEnvironment('PAYMAIL_EXAMPLE_SATOSHI_XPRV'), + requiredEnvironment('PAYMAIL_EXAMPLE_JWT_SECRET') +) const mockUser2 = new MockUser( - 'Hal Finney', + 'halfinney', DOMAIN, 'https://upload.wikimedia.org/wikipedia/en/5/52/Hal_Finney_%28computer_scientist%29.jpg', - 'xprv9s21ZrQH143K2KtoM9eftnf2f2AiwNTSfnwABjsZAnfF4ntLF9ExqSR533ic6q4hb9zzm5Ybmr5HQ7p8MHMfaWMG98CSzUCXDnjXsCnrryP') + requiredEnvironment('PAYMAIL_EXAMPLE_HAL_XPRV'), + requiredEnvironment('PAYMAIL_EXAMPLE_JWT_SECRET') +) -const fetchUser = async (name, domain) => { +const fetchUser = async (name: string, domain: string): Promise => { + if (domain !== DOMAIN) throw new Error(`Unsupported Paymail domain: ${domain}`) if (name === mockUser1.getAlias()) { return mockUser1 } diff --git a/packages/messaging/ts-paymail/docs/examples/src/server.ts b/packages/messaging/ts-paymail/docs/examples/src/server.ts index a749ea5b1..0ce76b65c 100644 --- a/packages/messaging/ts-paymail/docs/examples/src/server.ts +++ b/packages/messaging/ts-paymail/docs/examples/src/server.ts @@ -24,23 +24,33 @@ */ import express from 'express' -import p2pDestinationsRoute from './server/p2pDestinations'; -import receiveTransactionRoute from './server/receiveTransaction'; -import publicProfileRoute from './server/publicProfile'; -import pkiRoute from './server/pki'; -import { PaymailRouter } from '@bsv/paymail'; +import p2pDestinationsRoute from './server/p2pDestinations' +import receiveTransactionRoute from './server/receiveTransaction' +import receiveBeefTransactionRoute from './server/receiveBeefTransaction' +import publicProfileRoute from './server/publicProfile' +import pkiRoute from './server/pki' +import { PaymailRouter } from '@bsv/paymail' const app = express() app.disable('x-powered-by') const DOMAIN = process.env.DOMAIN ?? 'localhost' // Replace with your actual domain -const baseUrl = 'https://' + DOMAIN +const PORT = Number.parseInt(process.env.PORT ?? '3000', 10) +if (!Number.isInteger(PORT) || PORT < 1 || PORT > 65_535) { + throw new Error('PORT must be an integer from 1 through 65535') +} +const defaultBaseUrl = DOMAIN === 'localhost' ? `http://${DOMAIN}:${PORT}` : `https://${DOMAIN}` +const baseUrl = process.env.PAYMAIL_BASE_URL ?? defaultBaseUrl - -const routes = [publicProfileRoute, pkiRoute, p2pDestinationsRoute, receiveTransactionRoute] +const routes = [ + publicProfileRoute, + pkiRoute, + p2pDestinationsRoute, + receiveTransactionRoute, + receiveBeefTransactionRoute +] const paymailRouter = new PaymailRouter({ baseUrl, routes }) app.use(paymailRouter.getRouter()) -const PORT = 3000 -app.listen(PORT, async () => { - console.log(`Server is running on ${baseUrl}:${PORT}`) +app.listen(PORT, () => { + console.log(`Server is running on port ${PORT} and advertising ${baseUrl}`) }) diff --git a/packages/messaging/ts-paymail/docs/examples/src/server/p2pDestinations.ts b/packages/messaging/ts-paymail/docs/examples/src/server/p2pDestinations.ts index b1400e1ac..07226c0fd 100644 --- a/packages/messaging/ts-paymail/docs/examples/src/server/p2pDestinations.ts +++ b/packages/messaging/ts-paymail/docs/examples/src/server/p2pDestinations.ts @@ -1,4 +1,3 @@ - import { P2pPaymentDestinationRoute } from '@bsv/paymail' import { fetchUser } from '../mockUser.js' @@ -7,11 +6,12 @@ const p2pDestinationsRoute = new P2pPaymentDestinationRoute({ const { name, domain } = P2pPaymentDestinationRoute.getNameAndDomain(params) const user = await fetchUser(name, domain) const { destinationScript, reference } = user.getPaymailDestination() + const { satoshis } = body as { satoshis: number } return { outputs: [ { script: destinationScript, - satoshis: body.satoshis + satoshis } ], reference diff --git a/packages/messaging/ts-paymail/docs/examples/src/server/pki.ts b/packages/messaging/ts-paymail/docs/examples/src/server/pki.ts index 31a2c132d..306eba451 100644 --- a/packages/messaging/ts-paymail/docs/examples/src/server/pki.ts +++ b/packages/messaging/ts-paymail/docs/examples/src/server/pki.ts @@ -1,9 +1,8 @@ - import { PublicKeyInfrastructureRoute } from '@bsv/paymail' import { fetchUser } from '../mockUser.js' const pkiRoute = new PublicKeyInfrastructureRoute({ - domainLogicHandler: async (params) => { + domainLogicHandler: async params => { const { name, domain } = PublicKeyInfrastructureRoute.getNameAndDomain(params) const user = await fetchUser(name, domain) return { diff --git a/packages/messaging/ts-paymail/docs/examples/src/server/publicProfile.ts b/packages/messaging/ts-paymail/docs/examples/src/server/publicProfile.ts index 967e77961..5bd893458 100644 --- a/packages/messaging/ts-paymail/docs/examples/src/server/publicProfile.ts +++ b/packages/messaging/ts-paymail/docs/examples/src/server/publicProfile.ts @@ -2,7 +2,7 @@ import { PublicProfileRoute } from '@bsv/paymail' import { fetchUser } from '../mockUser.js' const publicProfileRoute = new PublicProfileRoute({ - domainLogicHandler: async (params) => { + domainLogicHandler: async params => { const { name, domain } = PublicProfileRoute.getNameAndDomain(params) const user = await fetchUser(name, domain) return { diff --git a/packages/messaging/ts-paymail/docs/examples/src/server/receiveBeefTransaction.ts b/packages/messaging/ts-paymail/docs/examples/src/server/receiveBeefTransaction.ts index 68fd88da6..3be66e0bf 100644 --- a/packages/messaging/ts-paymail/docs/examples/src/server/receiveBeefTransaction.ts +++ b/packages/messaging/ts-paymail/docs/examples/src/server/receiveBeefTransaction.ts @@ -4,12 +4,18 @@ import { fetchUser } from '../mockUser.js' const WocHeadersClient = { currentHeight: async () => { - const headers = await (await fetch('https://api.whatsonchain.com/v1/bsv/main/block/headers')).json() - return headers[0].height + const headers = (await ( + await fetch('https://api.whatsonchain.com/v1/bsv/main/block/headers') + ).json()) as Array<{ height: number }> + const [latest] = headers + if (!latest) throw new Error('WhatsOnChain returned no headers') + return latest.height }, - isValidRootForHeight: async (merkleRoot, height) => { + isValidRootForHeight: async (merkleRoot: string, height: number) => { try { - const { merkleroot } = await (await fetch(`https://api.whatsonchain.com/v1/bsv/main/block/height/${height}`)).json() + const { merkleroot } = (await ( + await fetch(`https://api.whatsonchain.com/v1/bsv/main/block/height/${height}`) + ).json()) as { merkleroot: string } return merkleroot === merkleRoot } catch (error) { console.error('error fetching merkleroot', error) @@ -22,10 +28,13 @@ const receiveBeefTransactionRoute = new ReceiveBeefTransactionRoute({ domainLogicHandler: async (params, body) => { const { name, domain } = ReceiveBeefTransactionRoute.getNameAndDomain(params) const user = await fetchUser(name, domain) - const tx = Transaction.fromHexBEEF(body.hex) - tx.verify(WocHeadersClient) + const { beef, reference } = body as { beef: string; reference: string } + const tx = Transaction.fromHexBEEF(beef) + if (!(await tx.verify(WocHeadersClient))) { + throw new Error('BEEF transaction verification failed') + } await user.broadcastTransaction(tx) - await user.processTransaction(tx, body.reference) + user.processTransaction(tx, reference) return { txid: tx.id('hex') } diff --git a/packages/messaging/ts-paymail/docs/examples/src/server/receiveTransaction.ts b/packages/messaging/ts-paymail/docs/examples/src/server/receiveTransaction.ts index cbff5223b..814d0b6d5 100644 --- a/packages/messaging/ts-paymail/docs/examples/src/server/receiveTransaction.ts +++ b/packages/messaging/ts-paymail/docs/examples/src/server/receiveTransaction.ts @@ -6,9 +6,10 @@ const receiveTransactionRoute = new ReceiveTransactionRoute({ domainLogicHandler: async (params, body) => { const { name, domain } = ReceiveTransactionRoute.getNameAndDomain(params) const user = await fetchUser(name, domain) - const tx = Transaction.fromHex(body.hex) + const { hex, reference } = body as { hex: string; reference: string } + const tx = Transaction.fromHex(hex) await user.broadcastTransaction(tx) - await user.processTransaction(tx, body.reference) + user.processTransaction(tx, reference) return { txid: tx.id('hex') } diff --git a/packages/messaging/ts-paymail/docs/examples/tsconfig.json b/packages/messaging/ts-paymail/docs/examples/tsconfig.json index 2ce48728f..d340eda59 100644 --- a/packages/messaging/ts-paymail/docs/examples/tsconfig.json +++ b/packages/messaging/ts-paymail/docs/examples/tsconfig.json @@ -6,9 +6,8 @@ "module": "commonjs", "rootDir": "./", "outDir": "./dist/cjs", - "strict": false, + "strict": true, "isolatedModules": true, - "strictNullChecks": false, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "incremental": true, diff --git a/packages/messaging/ts-paymail/docs/paymailClient.md b/packages/messaging/ts-paymail/docs/paymailClient.md index 18295226e..7acb161ba 100644 --- a/packages/messaging/ts-paymail/docs/paymailClient.md +++ b/packages/messaging/ts-paymail/docs/paymailClient.md @@ -1,14 +1,53 @@ - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes) +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) # Interfaces -| | -| --- | -| [DNSResolverOptions](#interface-dnsresolveroptions) | -| [DnsResponse](#interface-dnsresponse) | +| | +| ----------------------------------------------------------- | +| [DNSResolverOptions](#interface-dnsresolveroptions) | +| [DnsResolver](#interface-dnsresolver) | +| [DnsResponse](#interface-dnsresponse) | +| [P2PDestination](#interface-p2pdestination) | +| [P2POrdinalDestination](#interface-p2pordinaldestination) | +| [P2POrdinalDestinations](#interface-p2pordinaldestinations) | +| [P2PPaymentDestination](#interface-p2ppaymentdestination) | +| [P2PTransactionMetadata](#interface-p2ptransactionmetadata) | +| [P2PTransactionResponse](#interface-p2ptransactionresponse) | +| [PublicKeyInformation](#interface-publickeyinformation) | +| [PublicKeyVerification](#interface-publickeyverification) | +| [PublicProfile](#interface-publicprofile) | + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + +## Interface: DNSResolverOptions + +```ts +export interface DNSResolverOptions { + dns?: DnsResolver + dohServerBaseUrl?: string +} +``` + +See also: [DnsResolver](#interface-dnsresolver) -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes) +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + +## Interface: DnsResolver + +```ts +export interface DnsResolver { + resolveSrv( + domain: string, + callback: (error: DnsError | null, records?: SrvRecord[]) => void + ): void +} +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) --- @@ -16,34 +55,147 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes) ```ts export interface DnsResponse { - domain: string; - port: number; + domain: string + port: number } ``` -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes) +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) --- -## Interface: DNSResolverOptions + +## Interface: P2PDestination ```ts -export interface DNSResolverOptions { - dns?: any; - dohServerBaseUrl?: string; +export interface P2PDestination { + script: string + satoshis: number +} +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + +## Interface: P2POrdinalDestination + +```ts +export interface P2POrdinalDestination { + script: string +} +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + +## Interface: P2POrdinalDestinations + +```ts +export interface P2POrdinalDestinations { + outputs: P2POrdinalDestination[] + reference: string +} +``` + +See also: [P2POrdinalDestination](#interface-p2pordinaldestination) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + +## Interface: P2PPaymentDestination + +```ts +export interface P2PPaymentDestination { + outputs: P2PDestination[] + reference: string +} +``` + +See also: [P2PDestination](#interface-p2pdestination) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + +## Interface: P2PTransactionMetadata + +```ts +export interface P2PTransactionMetadata { + sender: string + pubkey: string + signature: string + note: string } ``` -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes) +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) --- + +## Interface: P2PTransactionResponse + +```ts +export interface P2PTransactionResponse { + txid: string + note?: string | null +} +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + +## Interface: PublicKeyInformation + +```ts +export interface PublicKeyInformation { + bsvalias?: string + handle: string + pubkey: string +} +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + +## Interface: PublicKeyVerification + +```ts +export interface PublicKeyVerification extends PublicKeyInformation { + match: boolean +} +``` + +See also: [PublicKeyInformation](#interface-publickeyinformation) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + +## Interface: PublicProfile + +```ts +export interface PublicProfile { + name: string + avatar: string +} +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + # Classes -| | -| --- | -| [HttpClient](#class-httpclient) | +| | +| ------------------------------------- | +| [HttpClient](#class-httpclient) | | [PaymailClient](#class-paymailclient) | -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes) +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) --- @@ -51,19 +203,17 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes) ```ts export default class HttpClient { - constructor(fetch: Fetch, defaultTimeout = 30000) - async request(url: string, options: (FetchOptions & { - method: "GET" | "POST"; - body?: any; - }) = { - method: "GET" - }): Promise + constructor(defaultTimeout = 30000) + async request(url: string, options: RequestOptions = defaultRequestOptions): Promise } ``` -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes) +See also: [RequestOptions](#type-requestoptions) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) --- + ## Class: PaymailClient PaymailClient provides functionality to interact with BSV Paymail services. @@ -71,197 +221,247 @@ It offers methods to retrieve public profiles, verify public keys, send transact ```ts export default class PaymailClient { - constructor(httpClient?: HttpClient, dnsOptions?: DNSResolverOptions, localhostPort?: number) - public ensureCapabilityFor = async (aDomain, aCapability) => { - const capabilities = await this.getDomainCapabilities(aDomain); - if (!capabilities[aCapability]) { - throw new PaymailServerResponseError(`Domain "${aDomain}" does not support capability "${aCapability}"`); - } - return capabilities[aCapability]; - }; - public request = async (aDomain: string, capability: Capability, body?: any) => { - const [name, domain] = aDomain.split("@"); - const url = await this.ensureCapabilityFor(domain, capability.getCode()); - const requestUrl = url.replace("{alias}", name).replace("{domain.tld}", domain); - const response = await this.httpClient.request(requestUrl, { - method: capability.getMethod(), - body - }); - const responseBody = await response.json(); - return responseBody; - }; - public getPublicProfile = async (paymail) => { - const response = await this.request(paymail, PublicProfileCapability); - const schema = Joi.object({ - name: Joi.string().required(), - avatar: Joi.string().uri().required() - }).options({ stripUnknown: true }); - const { error, value } = schema.validate(response); - if (error) { - throw new PaymailServerResponseError(`Validation error: ${error.message}`); - } - return value; - }; - public getPki = async (paymail) => { - const response = await this.request(paymail, PublicKeyInfrastructureCapability); - const schema = Joi.object({ - bsvalias: Joi.string().optional().allow("1.0"), - handle: Joi.string().required(), - pubkey: Joi.string().required() - }).options({ stripUnknown: true }); - const { error, value } = schema.validate(response); - if (error) { - throw new PaymailServerResponseError(`Validation error: ${error.message}`); - } - return value; - }; - public getP2pPaymentDestination = async (paymail: string, satoshis: number): Promise => { - const response = await this.request(paymail, P2pPaymentDestinationCapability, { - satoshis - }); - const schema = Joi.object({ - outputs: Joi.array().items(Joi.object({ - script: Joi.string().required(), - satoshis: Joi.number().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}`); - } - if (satoshis !== response.outputs.reduce((acc, output) => acc + output.satoshis, 0)) { - throw new PaymailServerResponseError("The server did not return the expected amount of satoshis"); - } - 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; - signature: string; - note: string; - }) => { - const response = await this.request(paymail, ReceiveTransactionCapability, { - hex, - reference, - metadata - }); - const schema = Joi.object({ - txid: Joi.string().required(), - note: Joi.string().optional().allow("", null) - }).options({ stripUnknown: true }); - const { error, value } = schema.validate(response); - if (error) { - throw new PaymailServerResponseError(`Validation error: ${error.message}`); - } - 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("", null) - }).options({ stripUnknown: true }); - const { error, value } = schema.validate(response); - if (error) { - throw new PaymailServerResponseError(`Validation error: ${error.message}`); - } - return value; - }; - public createP2PSignature = (txid: string, privKey: PrivateKey): string => { - const msg = Utils.toArray(txid, "utf8"); - const msgHash = BSM.magicHash(msg); - const sig = BSM.sign(msg, privKey); - const recovery = sig.CalculateRecoveryFactor(privKey.toPublicKey(), new BigNumber(msgHash)); - return sig.toCompact(recovery, true, "base64") as string; - }; - public verifyPublicKey = async (paymail, pubkey) => { - const [name, domain] = paymail.split("@"); - const url = await this.ensureCapabilityFor(domain, VerifyPublicKeyOwnerCapability.getCode()); - const requestUrl = url.replace("{alias}", name).replace("{domain.tld}", domain).replace("{pubkey}", pubkey); - const response = await this.httpClient.request(requestUrl); - const responseBody = await response.json(); - const schema = Joi.object({ - bsvalias: Joi.string().optional().allow("1.0"), - handle: Joi.string().required(), - pubkey: Joi.string().required(), - match: Joi.boolean().required() - }).options({ stripUnknown: true }); - const { error } = schema.validate(responseBody); - if (error) { - throw new PaymailServerResponseError(`Validation error: ${error.message}`); - } - return responseBody; - }; - public sendBeefTransactionP2P = async (paymail: string, beef: string, reference: string, metadata?: { - sender: string; - pubkey: string; - signature: string; - note: string; - }) => { - const response = await this.request(paymail, ReceiveBeefTransactionCapability, { - beef, - reference, - metadata - }); - const schema = Joi.object({ - txid: Joi.string().required(), - note: Joi.string().optional().allow("", null) - }).options({ stripUnknown: true }); - const { error, value } = schema.validate(response); - if (error) { - throw new PaymailServerResponseError(`Validation error: ${error.message}`); - } - return value; - }; - public getTransactionNegotiationCapabilities = async (paymail: string) => { - const response = await this.request(paymail, NegotiationCapability); - const schema = Joi.object({ - send_disabled: Joi.boolean().default(false), - auto_send_response: Joi.boolean().default(false), - receive: Joi.boolean().default(false), - three_step_exchange: Joi.boolean().default(false), - four_step_exchange: Joi.boolean().default(false), - auto_exchange_response: Joi.boolean().default(false) - }).options({ stripUnknown: true }); - const { error, value } = schema.validate(response); - if (error) { - throw new PaymailServerResponseError(`Validation error: ${error.message}`); - } - return value; - }; - public sendTransactionNegotiation = async (paymail: string, body: TransactionNegotiationBody) => { - const response = await this.request(paymail, TransactionNegotiationCapabilities, body); - return response; - }; + constructor(httpClient?: HttpClient, dnsOptions?: DNSResolverOptions, localhostPort?: number) + public readonly getDomainCapabilities = async (aDomain: string): Promise => { + const cached = this._domainCapabilityCache.get(aDomain) + if (cached !== undefined) return cached + const capabilities = await this.fetchWellKnown(aDomain) + this._domainCapabilityCache.set(aDomain, capabilities) + return capabilities + } + public readonly getCapabilities = this.getDomainCapabilities + public ensureCapabilityFor = async (aDomain: string, aCapability: string): Promise => { + const capabilities = await this.getDomainCapabilities(aDomain) + const endpoint = capabilities[aCapability] + if (typeof endpoint !== 'string' || endpoint.length === 0) { + throw new PaymailServerResponseError( + `Domain "${aDomain}" does not support capability "${aCapability}"` + ) + } + return endpoint + } + public request = async ( + aDomain: string, + capability: Capability, + body?: unknown + ): Promise => { + const parsed = parsePaymail(aDomain) + if (!parsed) { + throw new PaymailServerResponseError(`Invalid Paymail address: "${aDomain}"`) + } + const { name, domain } = parsed + const url = await this.ensureCapabilityFor(domain, capability.getCode()) + const requestUrl = url + .replaceAll('{alias}', encodeURIComponent(name)) + .replaceAll('{domain.tld}', encodeURIComponent(domain)) + const response = await this.httpClient.request(requestUrl, { + method: capability.getMethod(), + body + }) + const responseBody = await response.json() + return responseBody + } + public getPublicProfile = async (paymail: string): Promise => { + const response = await this.request(paymail, PublicProfileCapability) + const schema = Joi.object({ + name: Joi.string().required(), + avatar: Joi.string().uri().required() + }).options({ stripUnknown: true }) + const { error, value } = schema.validate(response) + if (error) { + throw new PaymailServerResponseError(`Validation error: ${error.message}`) + } + return value as PublicProfile + } + public getPki = async (paymail: string): Promise => { + const response = await this.request(paymail, PublicKeyInfrastructureCapability) + const schema = Joi.object({ + bsvalias: Joi.string().optional().allow('1.0'), + handle: Joi.string().required(), + pubkey: Joi.string().required() + }).options({ stripUnknown: true }) + const { error, value } = schema.validate(response) + if (error) { + throw new PaymailServerResponseError(`Validation error: ${error.message}`) + } + return value as PublicKeyInformation + } + public getP2pPaymentDestination = async ( + paymail: string, + satoshis: number + ): Promise => { + const response = await this.request(paymail, P2pPaymentDestinationCapability, { + satoshis + }) + const schema = Joi.object({ + outputs: Joi.array() + .items( + Joi.object({ + script: Joi.string().required(), + satoshis: Joi.number().required() + }).required() + ) + .min(1), + reference: Joi.string().required() + }).options({ stripUnknown: true }) + const { error, value } = schema.validate(response) + if (error) { + throw new PaymailServerResponseError(`Validation error: ${error.message}`) + } + const destination = value as P2PPaymentDestination + if (satoshis !== destination.outputs.reduce((acc, output) => acc + output.satoshis, 0)) { + throw new PaymailServerResponseError( + 'The server did not return the expected amount of satoshis' + ) + } + return destination + } + 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, value } = schema.validate(response) + if (error) { + throw new PaymailServerResponseError(`Validation error: ${error.message}`) + } + return value as P2POrdinalDestinations + } + public sendTransactionP2P = async ( + paymail: string, + hex: string, + reference: string, + metadata?: P2PTransactionMetadata + ): Promise => { + const response = await this.request(paymail, ReceiveTransactionCapability, { + hex, + reference, + metadata + }) + const schema = Joi.object({ + txid: Joi.string().required(), + note: Joi.string().optional().allow('', null) + }).options({ stripUnknown: true }) + const { error, value } = schema.validate(response) + if (error) { + throw new PaymailServerResponseError(`Validation error: ${error.message}`) + } + return value as P2PTransactionResponse + } + public sendOrdinalTransactionP2P = async ( + paymail: string, + hex: string, + reference: string, + metadata?: P2PTransactionMetadata + ): Promise => { + const response = await this.request(paymail, SimpleP2pOrdinalReceiveCapability, { + hex, + reference, + metadata + }) + const schema = Joi.object({ + txid: Joi.string().required(), + note: Joi.string().optional().allow('', null) + }).options({ stripUnknown: true }) + const { error, value } = schema.validate(response) + if (error) { + throw new PaymailServerResponseError(`Validation error: ${error.message}`) + } + return value as P2PTransactionResponse + } + public createP2PSignature = (txid: string, privKey: PrivateKey): string => + createP2PSignature(txid, privKey) + public verifyPublicKey = async ( + paymail: string, + pubkey: string + ): Promise => { + const parsed = parsePaymail(paymail) + if (!parsed) { + throw new PaymailServerResponseError(`Invalid Paymail address: "${paymail}"`) + } + const { name, domain } = parsed + const url = await this.ensureCapabilityFor(domain, VerifyPublicKeyOwnerCapability.getCode()) + const requestUrl = url + .replaceAll('{alias}', encodeURIComponent(name)) + .replaceAll('{domain.tld}', encodeURIComponent(domain)) + .replaceAll('{pubkey}', encodeURIComponent(pubkey)) + const response = await this.httpClient.request(requestUrl) + const responseBody = await response.json() + const schema = Joi.object({ + bsvalias: Joi.string().optional().allow('1.0'), + handle: Joi.string().required(), + pubkey: Joi.string().required(), + match: Joi.boolean().required() + }).options({ stripUnknown: true }) + const { error, value } = schema.validate(responseBody) + if (error) { + throw new PaymailServerResponseError(`Validation error: ${error.message}`) + } + return value as PublicKeyVerification + } + public sendBeefTransactionP2P = async ( + paymail: string, + beef: string, + reference: string, + metadata?: P2PTransactionMetadata + ): Promise => { + const response = await this.request(paymail, ReceiveBeefTransactionCapability, { + beef, + reference, + metadata + }) + const schema = Joi.object({ + txid: Joi.string().required(), + note: Joi.string().optional().allow('', null) + }).options({ stripUnknown: true }) + const { error, value } = schema.validate(response) + if (error) { + throw new PaymailServerResponseError(`Validation error: ${error.message}`) + } + return value as P2PTransactionResponse + } + public getTransactionNegotiationCapabilities = async ( + paymail: string + ): Promise> => { + const response = await this.request(paymail, NegotiationCapability) + const schema = Joi.object({ + send_disabled: Joi.boolean().default(false), + auto_send_response: Joi.boolean().default(false), + receive: Joi.boolean().default(false), + three_step_exchange: Joi.boolean().default(false), + four_step_exchange: Joi.boolean().default(false), + auto_exchange_response: Joi.boolean().default(false) + }).options({ stripUnknown: true }) + const { error, value } = schema.validate(response) + if (error) { + throw new PaymailServerResponseError(`Validation error: ${error.message}`) + } + return value as Record + } + public sendTransactionNegotiation = async ( + paymail: string, + body: TransactionNegotiationBody + ): Promise => { + const response = await this.request(paymail, TransactionNegotiationCapabilities, body) + return response + } } ``` +See also: [DNSResolverOptions](#interface-dnsresolveroptions), [DomainCapabilities](#type-domaincapabilities), [HttpClient](#class-httpclient), [P2POrdinalDestinations](#interface-p2pordinaldestinations), [P2PPaymentDestination](#interface-p2ppaymentdestination), [P2PTransactionMetadata](#interface-p2ptransactionmetadata), [P2PTransactionResponse](#interface-p2ptransactionresponse), [PublicKeyInformation](#interface-publickeyinformation), [PublicKeyVerification](#interface-publickeyverification), [PublicProfile](#interface-publicprofile) +
Class PaymailClient Details @@ -271,30 +471,26 @@ export default class PaymailClient { Constructs a new PaymailClient. ```ts -constructor(httpClient?: HttpClient, dnsOptions?: DNSResolverOptions, localhostPort?: number) +constructor(httpClient?: HttpClient, dnsOptions?: DNSResolverOptions, localhostPort?: number) ``` +See also: [DNSResolverOptions](#interface-dnsresolveroptions), [HttpClient](#class-httpclient) + Argument Details -+ **httpClient** - + HTTP client for making network requests. If not provided, a default HttpClient is used. -+ **dnsOptions** - + Configuration options for DNS resolution. -+ **localhostPort** - + The port number for localhost development. Defaults to 3000 if not specified. +- **httpClient** + - HTTP client for making network requests. If not provided, a default HttpClient is used. +- **dnsOptions** + - Configuration options for DNS resolution. +- **localhostPort** + - The port number for localhost development. Defaults to 3000 if not specified. ### Property createP2PSignature Creates a digital signature for a P2P transaction using a given private key. ```ts -public createP2PSignature = (txid: string, privKey: PrivateKey): string => { - const msg = Utils.toArray(txid, "utf8"); - const msgHash = BSM.magicHash(msg); - const sig = BSM.sign(msg, privKey); - const recovery = sig.CalculateRecoveryFactor(privKey.toPublicKey(), new BigNumber(msgHash)); - return sig.toCompact(recovery, true, "base64") as string; -} +public createP2PSignature = (txid: string, privKey: PrivateKey): string => createP2PSignature(txid, privKey) ``` ### Property ensureCapabilityFor @@ -302,12 +498,13 @@ public createP2PSignature = (txid: string, privKey: PrivateKey): string => { Ensures that a specified domain supports a given capability. ```ts -public ensureCapabilityFor = async (aDomain, aCapability) => { +public ensureCapabilityFor = async (aDomain: string, aCapability: string): Promise => { const capabilities = await this.getDomainCapabilities(aDomain); - if (!capabilities[aCapability]) { + const endpoint = capabilities[aCapability]; + if (typeof endpoint !== "string" || endpoint.length === 0) { throw new PaymailServerResponseError(`Domain "${aDomain}" does not support capability "${aCapability}"`); } - return capabilities[aCapability]; + return endpoint; } ``` @@ -316,57 +513,66 @@ public ensureCapabilityFor = async (aDomain, aCapability) => { Requests a P2P ordinal destination for a given Paymail. ```ts -public getP2pOrdinalDestinations = async (paymail: string, ordinals: number): Promise => { +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({ + outputs: Joi.array() + .items(Joi.object({ script: Joi.string().required() - }).required().min(1)), + }).required()) + .min(1), reference: Joi.string().required() }).options({ stripUnknown: true }); - const { error } = schema.validate(response); + const { error, value } = schema.validate(response); if (error) { throw new PaymailServerResponseError(`Validation error: ${error.message}`); } - return response; + return value as P2POrdinalDestinations; } ``` +See also: [P2POrdinalDestinations](#interface-p2pordinaldestinations) + ### Property getP2pPaymentDestination Requests a P2P payment destination for a given Paymail. ```ts -public getP2pPaymentDestination = async (paymail: string, satoshis: number): Promise => { +public getP2pPaymentDestination = async (paymail: string, satoshis: number): Promise => { const response = await this.request(paymail, P2pPaymentDestinationCapability, { satoshis }); const schema = Joi.object({ - outputs: Joi.array().items(Joi.object({ + outputs: Joi.array() + .items(Joi.object({ script: Joi.string().required(), satoshis: Joi.number().required() - }).required().min(1)), + }).required()) + .min(1), reference: Joi.string().required() }).options({ stripUnknown: true }); - const { error } = schema.validate(response); + const { error, value } = schema.validate(response); if (error) { throw new PaymailServerResponseError(`Validation error: ${error.message}`); } - if (satoshis !== response.outputs.reduce((acc, output) => acc + output.satoshis, 0)) { + const destination = value as P2PPaymentDestination; + if (satoshis !== destination.outputs.reduce((acc, output) => acc + output.satoshis, 0)) { throw new PaymailServerResponseError("The server did not return the expected amount of satoshis"); } - return response; + return destination; } ``` +See also: [P2PPaymentDestination](#interface-p2ppaymentdestination) + ### Property getPki Retrieves the public key infrastructure (PKI) data for a given Paymail address. ```ts -public getPki = async (paymail) => { +public getPki = async (paymail: string): Promise => { const response = await this.request(paymail, PublicKeyInfrastructureCapability); const schema = Joi.object({ bsvalias: Joi.string().optional().allow("1.0"), @@ -377,16 +583,18 @@ public getPki = async (paymail) => { if (error) { throw new PaymailServerResponseError(`Validation error: ${error.message}`); } - return value; + return value as PublicKeyInformation; } ``` +See also: [PublicKeyInformation](#interface-publickeyinformation) + ### Property getPublicProfile Retrieves the public profile associated with a Paymail address. ```ts -public getPublicProfile = async (paymail) => { +public getPublicProfile = async (paymail: string): Promise => { const response = await this.request(paymail, PublicProfileCapability); const schema = Joi.object({ name: Joi.string().required(), @@ -396,16 +604,18 @@ public getPublicProfile = async (paymail) => { if (error) { throw new PaymailServerResponseError(`Validation error: ${error.message}`); } - return value; + return value as PublicProfile; } ``` +See also: [PublicProfile](#interface-publicprofile) + ### Property getTransactionNegotiationCapabilities Retrieves the transaction negotiation capabilities for a given Paymail. ```ts -public getTransactionNegotiationCapabilities = async (paymail: string) => { +public getTransactionNegotiationCapabilities = async (paymail: string): Promise> => { const response = await this.request(paymail, NegotiationCapability); const schema = Joi.object({ send_disabled: Joi.boolean().default(false), @@ -419,7 +629,7 @@ public getTransactionNegotiationCapabilities = async (paymail: string) => { if (error) { throw new PaymailServerResponseError(`Validation error: ${error.message}`); } - return value; + return value as Record; } ``` @@ -428,10 +638,16 @@ public getTransactionNegotiationCapabilities = async (paymail: string) => { Makes a generic request to a Paymail service. ```ts -public request = async (aDomain: string, capability: Capability, body?: any) => { - const [name, domain] = aDomain.split("@"); +public request = async (aDomain: string, capability: Capability, body?: unknown): Promise => { + const parsed = parsePaymail(aDomain); + if (!parsed) { + throw new PaymailServerResponseError(`Invalid Paymail address: "${aDomain}"`); + } + const { name, domain } = parsed; const url = await this.ensureCapabilityFor(domain, capability.getCode()); - const requestUrl = url.replace("{alias}", name).replace("{domain.tld}", domain); + const requestUrl = url + .replaceAll("{alias}", encodeURIComponent(name)) + .replaceAll("{domain.tld}", encodeURIComponent(domain)); const response = await this.httpClient.request(requestUrl, { method: capability.getMethod(), body @@ -446,12 +662,7 @@ public request = async (aDomain: string, capability: Capability, body?: any) => Sends a beef transaction using the Pay-to-Peer (P2P) protocol. ```ts -public sendBeefTransactionP2P = async (paymail: string, beef: string, reference: string, metadata?: { - sender: string; - pubkey: string; - signature: string; - note: string; -}) => { +public sendBeefTransactionP2P = async (paymail: string, beef: string, reference: string, metadata?: P2PTransactionMetadata): Promise => { const response = await this.request(paymail, ReceiveBeefTransactionCapability, { beef, reference, @@ -465,22 +676,19 @@ public sendBeefTransactionP2P = async (paymail: string, beef: string, reference: if (error) { throw new PaymailServerResponseError(`Validation error: ${error.message}`); } - return value; + return value as P2PTransactionResponse; } ``` +See also: [P2PTransactionMetadata](#interface-p2ptransactionmetadata), [P2PTransactionResponse](#interface-p2ptransactionresponse) + ### 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; -}) => { +public sendOrdinalTransactionP2P = async (paymail: string, hex: string, reference: string, metadata?: P2PTransactionMetadata): Promise => { const response = await this.request(paymail, SimpleP2pOrdinalReceiveCapability, { hex, reference, @@ -494,16 +702,18 @@ public sendOrdinalTransactionP2P = async (paymail: string, hex: string, referenc if (error) { throw new PaymailServerResponseError(`Validation error: ${error.message}`); } - return value; + return value as P2PTransactionResponse; } ``` +See also: [P2PTransactionMetadata](#interface-p2ptransactionmetadata), [P2PTransactionResponse](#interface-p2ptransactionresponse) + ### Property sendTransactionNegotiation Sends a transaction negotiation request to a Paymail address. ```ts -public sendTransactionNegotiation = async (paymail: string, body: TransactionNegotiationBody) => { +public sendTransactionNegotiation = async (paymail: string, body: TransactionNegotiationBody): Promise => { const response = await this.request(paymail, TransactionNegotiationCapabilities, body); return response; } @@ -515,12 +725,7 @@ Sends a transaction using the Pay-to-Peer (P2P) protocol. This method is used to send a transaction to a Paymail address. ```ts -public sendTransactionP2P = async (paymail: string, hex: string, reference: string, metadata?: { - sender: string; - pubkey: string; - signature: string; - note: string; -}) => { +public sendTransactionP2P = async (paymail: string, hex: string, reference: string, metadata?: P2PTransactionMetadata): Promise => { const response = await this.request(paymail, ReceiveTransactionCapability, { hex, reference, @@ -534,19 +739,28 @@ public sendTransactionP2P = async (paymail: string, hex: string, reference: stri if (error) { throw new PaymailServerResponseError(`Validation error: ${error.message}`); } - return value; + return value as P2PTransactionResponse; } ``` +See also: [P2PTransactionMetadata](#interface-p2ptransactionmetadata), [P2PTransactionResponse](#interface-p2ptransactionresponse) + ### Property verifyPublicKey Verifies the ownership of a public key for a given Paymail address. ```ts -public verifyPublicKey = async (paymail, pubkey) => { - const [name, domain] = paymail.split("@"); +public verifyPublicKey = async (paymail: string, pubkey: string): Promise => { + const parsed = parsePaymail(paymail); + if (!parsed) { + throw new PaymailServerResponseError(`Invalid Paymail address: "${paymail}"`); + } + const { name, domain } = parsed; const url = await this.ensureCapabilityFor(domain, VerifyPublicKeyOwnerCapability.getCode()); - const requestUrl = url.replace("{alias}", name).replace("{domain.tld}", domain).replace("{pubkey}", pubkey); + const requestUrl = url + .replaceAll("{alias}", encodeURIComponent(name)) + .replaceAll("{domain.tld}", encodeURIComponent(domain)) + .replaceAll("{pubkey}", encodeURIComponent(pubkey)); const response = await this.httpClient.request(requestUrl); const responseBody = await response.json(); const schema = Joi.object({ @@ -555,16 +769,54 @@ public verifyPublicKey = async (paymail, pubkey) => { pubkey: Joi.string().required(), match: Joi.boolean().required() }).options({ stripUnknown: true }); - const { error } = schema.validate(responseBody); + const { error, value } = schema.validate(responseBody); if (error) { throw new PaymailServerResponseError(`Validation error: ${error.message}`); } - return responseBody; + return value as PublicKeyVerification; } ``` +See also: [PublicKeyVerification](#interface-publickeyverification) +
-Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes) +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + +# Functions + +# Types + +| | +| ---------------------------------------------- | +| [DomainCapabilities](#type-domaincapabilities) | +| [RequestOptions](#type-requestoptions) | + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + +## Type: DomainCapabilities + +```ts +export type DomainCapabilities = Record +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + +## Type: RequestOptions + +```ts +export type RequestOptions = Omit & { + method?: 'GET' | 'POST' + body?: unknown +} +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) --- diff --git a/packages/messaging/ts-paymail/docs/paymailRouter.md b/packages/messaging/ts-paymail/docs/paymailRouter.md index a85d68121..bdd08ab3d 100644 --- a/packages/messaging/ts-paymail/docs/paymailRouter.md +++ b/packages/messaging/ts-paymail/docs/paymailRouter.md @@ -1,14 +1,28 @@ +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) -Links: [API](#api), [Classes](#classes), [Types](#types) +# Interfaces + +## Interface: PaymailRouteParams + +```ts +export interface PaymailRouteParams { + [key: string]: string + paymail: string +} +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- # Classes -| | -| --- | -| [PaymailRoute](#class-paymailroute) | +| | +| ------------------------------------- | +| [PaymailRoute](#class-paymailroute) | | [PaymailRouter](#class-paymailrouter) | -Links: [API](#api), [Classes](#classes), [Types](#types) +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) --- @@ -16,27 +30,30 @@ Links: [API](#api), [Classes](#classes), [Types](#types) ```ts export default class PaymailRoute { - protected readonly domainLogicHandler: DomainLogicHandler; - constructor(config: PaymailRouteConfig) - protected async defaultHandler(req: Request, res: Response, next: NextFunction): Promise - protected async validateBody(body: any): Promise - protected serializeResponse(response): string - protected sendSuccessResponse(res: Response, content: string): Response - public getHandler(): RequestHandler - public getCode(): string - public getEndpoint(): string - public getMethod(): "GET" | "POST" - static getNameAndDomain(params: any): { - name: string; - domain: string; - pubkey?: string; - } + protected readonly domainLogicHandler: DomainLogicHandler + constructor(config: PaymailRouteConfig) + protected async defaultHandler(req: Request, res: Response, next: NextFunction): Promise + protected async validateBody(body: unknown): Promise + protected serializeResponse(response: unknown): string + protected sendSuccessResponse(res: Response, content: string): Response + public getHandler(): RequestHandler + public getCode(): string + public getEndpoint(): string + public getMethod(): 'GET' | 'POST' + static getNameAndDomain(params: PaymailRouteParams): { + name: string + domain: string + pubkey?: string + } } ``` -Links: [API](#api), [Classes](#classes), [Types](#types) +See also: [DomainLogicHandler](#type-domainlogichandler), [PaymailRouteParams](#interface-paymailrouteparams) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) --- + ## Class: PaymailRouter PaymailRouter is responsible for routing and handling Paymail requests. @@ -44,15 +61,17 @@ It sets up the necessary routes and handlers based on the given configuration. ```ts export default class PaymailRouter { - public baseUrl: string; - public basePath: string; - public routes: PaymailRoute[]; - public requestSenderValidation: boolean; - constructor(config: PaymailRouterConfig) - public getRouter(): Router + public baseUrl: string + public basePath: string + public routes: PaymailRoute[] + public requestSenderValidation: boolean + constructor(config: PaymailRouterConfig) + public getRouter(): Router } ``` +See also: [PaymailRoute](#class-paymailroute) +
Class PaymailRouter Details @@ -62,20 +81,20 @@ export default class PaymailRouter { Creates an instance of PaymailRouter. ```ts -constructor(config: PaymailRouterConfig) +constructor(config: PaymailRouterConfig) ``` Argument Details -+ **config** - + Configuration options for the PaymailRouter. +- **config** + - Configuration options for the PaymailRouter. ### Method getRouter Gets the configured express Router. ```ts -public getRouter(): Router +public getRouter(): Router ``` Returns @@ -84,17 +103,26 @@ The express Router with all configured routes and handlers.
-Links: [API](#api), [Classes](#classes), [Types](#types) +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) --- + +# Functions + # Types ## Type: DomainLogicHandler ```ts -export type DomainLogicHandler = (params: any, body?: any, pubkey?: string) => Promise +export type DomainLogicHandler = ( + params: PaymailRouteParams, + body?: unknown, + pubkey?: string +) => unknown | Promise ``` -Links: [API](#api), [Classes](#classes), [Types](#types) +See also: [PaymailRouteParams](#interface-paymailrouteparams) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) --- diff --git a/packages/messaging/ts-paymail/jest.config.js b/packages/messaging/ts-paymail/jest.config.js index d97be747d..7c7104c03 100644 --- a/packages/messaging/ts-paymail/jest.config.js +++ b/packages/messaging/ts-paymail/jest.config.js @@ -1,25 +1,33 @@ /** @type {import('ts-jest').JestConfigWithTsJest} */ export default { - preset: 'ts-jest/presets/default-esm', - testEnvironment: 'node', - testPathIgnorePatterns: ['dist/', ...(process.env.CI ? ['src/paymailClient/__tests'] : [])], - testTimeout: 30000, - setupFilesAfterEnv: ['/jest.setup.cjs'], - extensionsToTreatAsEsm: ['.ts'], - transform: { - '^.+\\.ts$': ['ts-jest', { - useESM: true, - tsconfig: { - module: 'ESNext', - moduleResolution: 'bundler' - } - }] - }, - moduleNameMapper: { - '^(\\.{1,2}/.*)\\.js$': '$1' - }, - transformIgnorePatterns: [ - 'node_modules/(?!(node-fetch|cross-fetch)/)' + preset: 'ts-jest/presets/default-esm', + testEnvironment: 'node', + testPathIgnorePatterns: ['dist/', 'dist-*/'], + testTimeout: 30000, + collectCoverageFrom: ['src/**/*.ts', '!src/**/__tests/**'], + coverageThreshold: { + global: { + branches: 85, + functions: 95, + lines: 95, + statements: 95 + } + }, + extensionsToTreatAsEsm: ['.ts'], + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + module: 'ESNext', + moduleResolution: 'bundler' + } + } ] + }, + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1' + }, + transformIgnorePatterns: ['node_modules/(?!(node-fetch|cross-fetch)/)'] } - diff --git a/packages/messaging/ts-paymail/jest.setup.cjs b/packages/messaging/ts-paymail/jest.setup.cjs deleted file mode 100644 index 22a9ca9f0..000000000 --- a/packages/messaging/ts-paymail/jest.setup.cjs +++ /dev/null @@ -1,2 +0,0 @@ -const fetch = require('cross-fetch'); -globalThis.fetch = fetch; \ No newline at end of file diff --git a/packages/messaging/ts-paymail/mod.ts b/packages/messaging/ts-paymail/mod.ts index bb4cb9b3d..27c0ccf1d 100644 --- a/packages/messaging/ts-paymail/mod.ts +++ b/packages/messaging/ts-paymail/mod.ts @@ -1,4 +1,4 @@ -export * from './src/capability/index.js'; -export * from './src/paymailClient/index.js'; -export * from './src/paymailRouter/index.js'; -export * from './src/errors/index.js'; \ No newline at end of file +export * from './src/capability/index.js' +export * from './src/paymailClient/index.js' +export * from './src/paymailRouter/index.js' +export * from './src/errors/index.js' diff --git a/packages/messaging/ts-paymail/package.json b/packages/messaging/ts-paymail/package.json index 6b4ca1e9f..37f43150f 100644 --- a/packages/messaging/ts-paymail/package.json +++ b/packages/messaging/ts-paymail/package.json @@ -9,90 +9,161 @@ "publishConfig": { "access": "public" }, - "main": "dist/cjs/mod.js", - "module": "dist/esm/mod.js", - "types": "dist/types/mod.d.ts", + "main": "./dist/mod.cjs", + "module": "./dist/mod.js", + "types": "./dist/mod.d.ts", + "typesVersions": { + "*": { + "client": [ + "dist/src/paymailClient/index.d.cts" + ], + "client/*": [ + "dist/src/paymailClient/*.d.cts" + ], + "capability": [ + "dist/src/capability/index.d.cts" + ], + "capability/*": [ + "dist/src/capability/*.d.cts" + ], + "router": [ + "dist/src/paymailRouter/index.d.cts" + ], + "router/*": [ + "dist/src/paymailRouter/*.d.cts" + ], + "errors": [ + "dist/src/errors/index.d.cts" + ], + "errors/*": [ + "dist/src/errors/*.d.cts" + ] + } + }, "type": "module", "files": [ - "dist/**/*.js", - "dist/**/*.js.map", - "dist/**/*.json", - "dist/**/*.d.ts", - "dist/**/*.d.ts.map", - "src/**/*.ts", - "!src/**/__tests__/**", + "dist", + "README.md", "docs/*.md", "docs/specs", "LICENSE.txt" ], "exports": { ".": { - "types": "./dist/types/mod.d.ts", - "browser": "./dist/esm/mod.browser.js", - "import": "./dist/esm/mod.js", - "require": "./dist/cjs/mod.js" - }, - "./*.ts": { - "types": "./dist/types/src/*.d.ts", - "import": "./dist/esm/src/*.js", - "require": "./dist/cjs/src/*.js" + "browser": { + "types": "./dist/mod.browser.d.ts", + "default": "./dist/mod.browser.js" + }, + "import": { + "types": "./dist/mod.d.ts", + "default": "./dist/mod.js" + }, + "require": { + "types": "./dist/mod.d.cts", + "default": "./dist/mod.cjs" + } }, "./client": { - "import": "./dist/esm/src/paymailClient/index.js", - "require": "./dist/cjs/src/paymailClient/index.js", - "types": "./dist/types/src/paymailClient/index.d.ts" + "import": { + "types": "./dist/src/paymailClient/index.d.ts", + "default": "./dist/src/paymailClient/index.js" + }, + "require": { + "types": "./dist/src/paymailClient/index.d.cts", + "default": "./dist/src/paymailClient/index.cjs" + } }, "./client/*": { - "import": "./dist/esm/src/paymailClient/*.js", - "require": "./dist/cjs/src/paymailClient/*.js", - "types": "./dist/types/src/paymailClient/*.d.ts" + "import": { + "types": "./dist/src/paymailClient/*.d.ts", + "default": "./dist/src/paymailClient/*.js" + }, + "require": { + "types": "./dist/src/paymailClient/*.d.cts", + "default": "./dist/src/paymailClient/*.cjs" + } }, "./capability": { - "import": "./dist/esm/src/capability/index.js", - "require": "./dist/cjs/src/capability/index.js", - "types": "./dist/types/src/capability/index.d.ts" + "import": { + "types": "./dist/src/capability/index.d.ts", + "default": "./dist/src/capability/index.js" + }, + "require": { + "types": "./dist/src/capability/index.d.cts", + "default": "./dist/src/capability/index.cjs" + } }, "./capability/*": { - "import": "./dist/esm/src/capability/*.js", - "require": "./dist/cjs/src/capability/*.js", - "types": "./dist/types/src/capability/*.d.ts" + "import": { + "types": "./dist/src/capability/*.d.ts", + "default": "./dist/src/capability/*.js" + }, + "require": { + "types": "./dist/src/capability/*.d.cts", + "default": "./dist/src/capability/*.cjs" + } }, "./router": { - "import": "./dist/esm/src/paymailRouter/index.js", - "require": "./dist/cjs/src/paymailRouter/index.js", - "types": "./dist/types/src/paymailRouter/index.d.ts" + "import": { + "types": "./dist/src/paymailRouter/index.d.ts", + "default": "./dist/src/paymailRouter/index.js" + }, + "require": { + "types": "./dist/src/paymailRouter/index.d.cts", + "default": "./dist/src/paymailRouter/index.cjs" + } }, "./router/*": { - "import": "./dist/esm/src/paymailRouter/*.js", - "require": "./dist/cjs/src/paymailRouter/*.js", - "types": "./dist/types/src/paymailRouter/*.d.ts" + "import": { + "types": "./dist/src/paymailRouter/*.d.ts", + "default": "./dist/src/paymailRouter/*.js" + }, + "require": { + "types": "./dist/src/paymailRouter/*.d.cts", + "default": "./dist/src/paymailRouter/*.cjs" + } }, "./errors": { - "import": "./dist/esm/src/errors/index.js", - "require": "./dist/cjs/src/errors/index.js", - "types": "./dist/types/src/errors/index.d.ts" + "import": { + "types": "./dist/src/errors/index.d.ts", + "default": "./dist/src/errors/index.js" + }, + "require": { + "types": "./dist/src/errors/index.d.cts", + "default": "./dist/src/errors/index.cjs" + } }, "./errors/*": { - "import": "./dist/esm/src/errors/*.js", - "require": "./dist/cjs/src/errors/*.js", - "types": "./dist/types/src/errors/*.d.ts" - } + "import": { + "types": "./dist/src/errors/*.d.ts", + "default": "./dist/src/errors/*.js" + }, + "require": { + "types": "./dist/src/errors/*.d.cts", + "default": "./dist/src/errors/*.cjs" + } + }, + "./package.json": "./package.json" }, "scripts": { - "test": "npm run build && jest", - "test:watch": "npm run build && jest --watch", - "test:coverage": "npm run build && jest --coverage", - "lint": "oxlint src", - "ex-lint": "oxlint docs/examples", - "docs-lint": "oxlint docs", - "build": "tsc -b && tsconfig-to-dual-package tsconfig.cjs.json", - "dev": "tsc -b -w", - "prepublish": "npm run build", - "build:example": "npm run build && npm link && cd docs/examples && npm i && npm link @bsv/paymail && tsc -p tsconfig.json", - "start:example:server": "npm run build:example && cd docs/examples && npm run server", - "example:getProfile": "npm run build:example && cd docs/examples && npm run getProfile", - "example:sendP2P": "npm run build:example && cd docs/examples && npm run sendP2P", - "doc": "ts2md --inputFilename=src/capability/index.ts --outputFilename=docs/capability.md --filenameSubString=capability --firstHeadingLevel=1 && ts2md --inputFilename=src/paymailClient/paymailClient.ts --outputFilename=docs/paymailClient.md --filenameSubString=paymailClient --firstHeadingLevel=1 && npx ts2md --inputFilename=src/paymailRouter/paymailRouter.ts --outputFilename=docs/paymailRouter.md --filenameSubString=paymailRouter --firstHeadingLevel=1" + "build": "tsdown", + "build:example": "pnpm build && pnpm --filter example-paymail build", + "dev": "tsdown --watch", + "doc": "ts2md --inputFilename=src/capability/index.ts --outputFilename=docs/capability.md --filenameSubString=capability --firstHeadingLevel=1 && ts2md --inputFilename=src/paymailClient/paymailClient.ts --outputFilename=docs/paymailClient.md --filenameSubString=paymailClient --firstHeadingLevel=1 && ts2md --inputFilename=src/paymailRouter/paymailRouter.ts --outputFilename=docs/paymailRouter.md --filenameSubString=paymailRouter --firstHeadingLevel=1", + "docs-lint": "oxlint docs --deny-warnings", + "ex-lint": "pnpm --filter example-paymail lint", + "example:getProfile": "pnpm build:example && pnpm --filter example-paymail getProfile", + "example:sendP2P": "pnpm build:example && pnpm --filter example-paymail sendP2P", + "format:check": "pnpm --workspace-root exec prettier --check \"packages/messaging/ts-paymail/**/*.{cjs,js,json,ts}\"", + "lint": "oxlint mod.ts mod.browser.ts src --deny-warnings", + "pack:check": "pnpm build && node ../../../scripts/check-package-artifact.mjs . --exports Capability,PublicKeyInfrastructureCapability,PublicProfileCapability,P2pPaymentDestinationCapability,ReceiveTransactionCapability,RequestSenderValidationCapability,VerifyPublicKeyOwnerCapability,P2pReceiveBeefTransactionCapability,NegotiationCapability,TransactionNegotiationCapability,SimpleP2pOrdinalDestinationsCapability,SimpleP2pOrdinalReceiveCapability,PaymailClient,DNSResolver,HttpClient,AbstractDnsResolver,PaymailRouter,PaymailRoute,P2pPaymentDestinationRoute,PublicKeyInfrastructureRoute,ReceiveTransactionRoute,PublicProfileRoute,VerifyPublicKeyOwnerRoute,ReceiveBeefTransactionRoute,NegotiationCapabilitiesRoute,TransactionNegotiationCapabilitiesRoute,OrdinalP2pPaymentDestinationRoute,SimpleP2pOrdinalReceiveRoute,PaymailError,PaymailBadRequestError,PaymailServerResponseError --entry-exports \"./client=PaymailClient|DNSResolver|HttpClient|AbstractDnsResolver;./capability=Capability|PublicKeyInfrastructureCapability|PublicProfileCapability|P2pPaymentDestinationCapability|ReceiveTransactionCapability|RequestSenderValidationCapability|VerifyPublicKeyOwnerCapability|P2pReceiveBeefTransactionCapability|NegotiationCapability|TransactionNegotiationCapability|SimpleP2pOrdinalDestinationsCapability|SimpleP2pOrdinalReceiveCapability;./router=PaymailRouter|PaymailRoute|P2pPaymentDestinationRoute|PublicKeyInfrastructureRoute|ReceiveTransactionRoute|PublicProfileRoute|VerifyPublicKeyOwnerRoute|ReceiveBeefTransactionRoute|NegotiationCapabilitiesRoute|TransactionNegotiationCapabilitiesRoute|OrdinalP2pPaymentDestinationRoute|SimpleP2pOrdinalReceiveRoute;./errors=PaymailError|PaymailBadRequestError|PaymailServerResponseError\"", + "prepublishOnly": "pnpm build", + "start:example:server": "pnpm build:example && pnpm --filter example-paymail server", + "test": "jest", + "test:browser": "pnpm build && node ../../../scripts/check-browser-package.mjs .", + "test:coverage": "jest --coverage", + "test:watch": "jest --watch", + "typecheck": "tsc --project tsconfig.typecheck.json" }, "author": "BSV Blockchain Association", "license": "SEE LICENSE IN LICENSE.txt", @@ -121,15 +192,16 @@ "devDependencies": { "@bsv/sdk": "workspace:^", "@jest/globals": "^30.4.1", - "@types/body-parser": "^1.19.6", + "@types/express": "^5.0.6", "@types/jest": "^30.0.0", "@types/node": "^26.1.1", + "@types/supertest": "^7.2.1", "jest": "^30.4.2", "oxlint": "^1.75.0", "supertest": "^7.2.2", "ts-jest": "^29.4.12", "ts2md": "^0.2.8", - "tsconfig-to-dual-package": "^1.2.0", + "tsdown": "0.22.14", "typescript": "^6.0.3" }, "peerDependencies": { diff --git a/packages/messaging/ts-paymail/src/capability/__tests/capability.test.ts b/packages/messaging/ts-paymail/src/capability/__tests/capability.test.ts new file mode 100644 index 000000000..1f4e92ac9 --- /dev/null +++ b/packages/messaging/ts-paymail/src/capability/__tests/capability.test.ts @@ -0,0 +1,35 @@ +import Capability from '../capability.js' + +describe('Capability', () => { + it('requires a non-empty title', () => { + expect(() => new Capability({ title: '' })).toThrow('Capability requires a title') + }) + + it('generates a stable BFRC identifier and defaults to GET', () => { + const first = new Capability({ + title: 'Example capability', + authors: ['Alice', 'Bob'], + version: '1' + }) + const second = new Capability({ + title: 'Example capability', + authors: ['Alice', 'Bob'], + version: '1' + }) + + expect(first.getCode()).toMatch(/^[0-9a-f]{12}$/) + expect(second.getCode()).toBe(first.getCode()) + expect(first.getMethod()).toBe('GET') + }) + + it('preserves explicit identifiers and methods', () => { + const capability = new Capability({ + code: 'example', + title: 'Example capability', + method: 'POST' + }) + + expect(capability.getCode()).toBe('example') + expect(capability.getMethod()).toBe('POST') + }) +}) diff --git a/packages/messaging/ts-paymail/src/capability/capability.ts b/packages/messaging/ts-paymail/src/capability/capability.ts index 81e68ecaa..ffc98b785 100644 --- a/packages/messaging/ts-paymail/src/capability/capability.ts +++ b/packages/messaging/ts-paymail/src/capability/capability.ts @@ -18,7 +18,7 @@ export default class Capability { /** * The authors of the capability. */ - private readonly authors?: string[] + private readonly authors: string[] /** * The version of the capability. @@ -39,7 +39,7 @@ export default class Capability { * Constructs a new Capability instance. * @param params - The parameters for the capability. */ - constructor ({ + constructor({ code, title, authors, @@ -67,7 +67,7 @@ export default class Capability { * Retrieves the code of the capability. * @returns The capability code or a generated code if not explicitly set. */ - public getCode (): string { + public getCode(): string { return this.code || this.bfrc() } @@ -75,7 +75,7 @@ export default class Capability { * Retrieves the HTTP method of the capability. * @returns The HTTP method ('GET' or 'POST'). */ - public getMethod (): 'GET' | 'POST' { + public getMethod(): 'GET' | 'POST' { return this.method || 'GET' } @@ -84,9 +84,15 @@ export default class Capability { * This is used when an explicit code is not provided. * @returns A generated unique code for the capability. */ - private bfrc () { - const stringToHash = [this.title.trim() + this.authors.join(', ').trim() + (this.version?.toString() || '')].join('').trim() - const bufferHash = new Hash.SHA256().update(new Hash.SHA256().update(stringToHash).digest()).digest() + private bfrc(): string { + const stringToHash = [ + this.title.trim() + this.authors.join(', ').trim() + (this.version?.toString() || '') + ] + .join('') + .trim() + const bufferHash = new Hash.SHA256() + .update(new Hash.SHA256().update(stringToHash).digest()) + .digest() const hash = bufferHash.toReversed() return Buffer.from(hash).toString('hex').substring(0, 12) } diff --git a/packages/messaging/ts-paymail/src/capability/index.ts b/packages/messaging/ts-paymail/src/capability/index.ts index be2b84b6e..2ea298e13 100644 --- a/packages/messaging/ts-paymail/src/capability/index.ts +++ b/packages/messaging/ts-paymail/src/capability/index.ts @@ -8,3 +8,6 @@ export { default as VerifyPublicKeyOwnerCapability } from './verifyPublicKeyOwne export { default as P2pReceiveBeefTransactionCapability } from './p2pReceiveBeefTransactionCapability.js' export { default as NegotiationCapability } from './negotiationCapabilities.js' export { default as TransactionNegotiationCapability } from './transactionNegotiationCapability.js' +export type { TransactionNegotiationBody } from './transactionNegotiationCapability.js' +export { default as SimpleP2pOrdinalDestinationsCapability } from './simpleP2pOrdinalDestinationsCapability.js' +export { default as SimpleP2pOrdinalReceiveCapability } from './simpleP2pOrdinalReceiveCapability.js' diff --git a/packages/messaging/ts-paymail/src/capability/p2pPaymentDestinationCapability.ts b/packages/messaging/ts-paymail/src/capability/p2pPaymentDestinationCapability.ts index 2090f48f3..97d707102 100644 --- a/packages/messaging/ts-paymail/src/capability/p2pPaymentDestinationCapability.ts +++ b/packages/messaging/ts-paymail/src/capability/p2pPaymentDestinationCapability.ts @@ -2,7 +2,12 @@ import Capability from './capability.js' const P2pPaymentDestinationCapability = new Capability({ title: 'Get no monitored payment destination (p2p payment destination)', - authors: ['Miguel Duarte (Money Button)', 'Ryan X. Charles (Money Button)', 'Ivan Mlinaric (Handcash)', 'Rafa (Handcash)'], + authors: [ + 'Miguel Duarte (Money Button)', + 'Ryan X. Charles (Money Button)', + 'Ivan Mlinaric (Handcash)', + 'Rafa (Handcash)' + ], version: '1.1', method: 'POST', code: '2a40af698840' diff --git a/packages/messaging/ts-paymail/src/capability/p2pReceiveTransactionCapability.ts b/packages/messaging/ts-paymail/src/capability/p2pReceiveTransactionCapability.ts index b180830de..e20831ba1 100644 --- a/packages/messaging/ts-paymail/src/capability/p2pReceiveTransactionCapability.ts +++ b/packages/messaging/ts-paymail/src/capability/p2pReceiveTransactionCapability.ts @@ -2,7 +2,12 @@ import Capability from './capability.js' const ReceiveTransactionCapability = new Capability({ title: 'Receive Transaction', - authors: ['Miguel Duarte (Money Button)', 'Ryan X. Charles (Money Button)', 'Ivan Mlinaric (Handcash)', 'Rafa (Handcash)'], + authors: [ + 'Miguel Duarte (Money Button)', + 'Ryan X. Charles (Money Button)', + 'Ivan Mlinaric (Handcash)', + 'Rafa (Handcash)' + ], version: '1.1', method: 'POST', code: '5f1323cddf31' diff --git a/packages/messaging/ts-paymail/src/capability/transactionNegotiationCapability.ts b/packages/messaging/ts-paymail/src/capability/transactionNegotiationCapability.ts index 1f8da11e6..fa06954f3 100644 --- a/packages/messaging/ts-paymail/src/capability/transactionNegotiationCapability.ts +++ b/packages/messaging/ts-paymail/src/capability/transactionNegotiationCapability.ts @@ -12,11 +12,11 @@ export default TransactionNegotiationCapability export interface TransactionNegotiationBody { thread_id: string - fees: Array<{ feeType: string, satoshis: number, bytes: number }> + fees?: Array<{ feeType: string; satoshis: number; bytes: number }> expanded_tx: { tx: string - ancestors: Array<{ tx: string, merkle_proofs?: any[], miner_responses?: any[] }> - spent_outputs: Array<{ value: number, locking_script: string }> + ancestors?: Array<{ tx: string; merkle_proofs?: unknown[]; miner_responses?: unknown[] }> + spent_outputs?: Array<{ value: number; locking_script: string }> } expiry: number timestamp: number diff --git a/packages/messaging/ts-paymail/src/capability/verifyPublicKeyOwnerCapability.ts b/packages/messaging/ts-paymail/src/capability/verifyPublicKeyOwnerCapability.ts index b55de9960..6c46d9ba2 100644 --- a/packages/messaging/ts-paymail/src/capability/verifyPublicKeyOwnerCapability.ts +++ b/packages/messaging/ts-paymail/src/capability/verifyPublicKeyOwnerCapability.ts @@ -1,4 +1,3 @@ - import Capability from './capability.js' const VerifyPublicKeyOwnerCapability = new Capability({ diff --git a/packages/messaging/ts-paymail/src/errors/index.ts b/packages/messaging/ts-paymail/src/errors/index.ts index 9c1139d85..acda0e850 100644 --- a/packages/messaging/ts-paymail/src/errors/index.ts +++ b/packages/messaging/ts-paymail/src/errors/index.ts @@ -1,21 +1,22 @@ class PaymailError extends Error { - status: any - constructor (message, status) { + status: number + + constructor(message: string, status: number) { super(message) this.status = status } } class PaymailBadRequestError extends PaymailError { - constructor (message) { + constructor(message: string) { super(message, 400) } } class PaymailServerResponseError extends PaymailError { - constructor (message) { + constructor(message: string) { super(message, 503) } -}; +} export { PaymailError, PaymailBadRequestError, PaymailServerResponseError } diff --git a/packages/messaging/ts-paymail/src/p2pSignature.ts b/packages/messaging/ts-paymail/src/p2pSignature.ts new file mode 100644 index 000000000..d8412b6c0 --- /dev/null +++ b/packages/messaging/ts-paymail/src/p2pSignature.ts @@ -0,0 +1,58 @@ +import { BigNumber, ECDSA, Hash, PrivateKey, Signature, Utils } from '@bsv/sdk' + +const BSM_PREFIX = 'Bitcoin Signed Message:\n' + +function bsmVarInt(n: number): number[] { + return Utils.Writer.varIntNum(n) +} + +function p2pMessageHash(message: string): BigNumber { + const prefixBytes = Utils.toArray(BSM_PREFIX, 'utf8') + const messageBytes = Utils.toArray(message, 'utf8') + return new BigNumber( + Hash.hash256([ + ...bsmVarInt(prefixBytes.length), + ...prefixBytes, + ...bsmVarInt(messageBytes.length), + ...messageBytes + ]) + ) +} + +export function createP2PSignature(message: string, privateKey: PrivateKey): string { + const messageHash = p2pMessageHash(message) + const signature = ECDSA.sign(messageHash, privateKey, true) + const recovery = signature.CalculateRecoveryFactor(privateKey.toPublicKey(), messageHash) + return signature.toCompact(recovery, true, 'base64') as string +} + +export interface P2PSignatureVerification { + publicKeyMatches: boolean + signatureValid: boolean +} + +export function verifyP2PSignature( + message: string, + encodedSignature: string, + expectedPublicKey: string +): P2PSignatureVerification { + const compactBytes = Utils.toArray(encodedSignature, 'base64') + const header = compactBytes[0] + if (compactBytes.length !== 65 || header === undefined) { + throw new Error('Invalid Compact Signature') + } + + const recovery = header - (header >= 31 ? 31 : 27) + if (recovery < 0 || recovery > 3) { + throw new Error('Invalid Compact Signature') + } + + const signature = Signature.fromCompact(encodedSignature, 'base64') + const messageHash = p2pMessageHash(message) + const recoveredPublicKey = signature.RecoverPublicKey(recovery, messageHash) + + return { + publicKeyMatches: recoveredPublicKey.toString() === expectedPublicKey, + signatureValid: ECDSA.verify(messageHash, signature, recoveredPublicKey) + } +} diff --git a/packages/messaging/ts-paymail/src/paymailAddress.ts b/packages/messaging/ts-paymail/src/paymailAddress.ts new file mode 100644 index 000000000..1ac4b4d7b --- /dev/null +++ b/packages/messaging/ts-paymail/src/paymailAddress.ts @@ -0,0 +1,27 @@ +export interface ParsedPaymail { + name: string + domain: string +} + +const DOMAIN_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i +const PAYMAIL_ALIAS = /^[a-z0-9._%+-]+$/i + +export function parsePaymail(value: string): ParsedPaymail | undefined { + const separator = value.indexOf('@') + if (separator <= 0 || separator !== value.lastIndexOf('@') || separator === value.length - 1) { + return undefined + } + + const name = value.slice(0, separator) + const domain = value.slice(separator + 1) + if ( + !PAYMAIL_ALIAS.test(name) || + name.length > 64 || + domain.length > 253 || + !domain.split('.').every(label => DOMAIN_LABEL.test(label)) + ) { + return undefined + } + + return { name, domain } +} diff --git a/packages/messaging/ts-paymail/src/paymailClient/__tests/capabilityDiscovery.test.ts b/packages/messaging/ts-paymail/src/paymailClient/__tests/capabilityDiscovery.test.ts deleted file mode 100644 index 3dfd1c35f..000000000 --- a/packages/messaging/ts-paymail/src/paymailClient/__tests/capabilityDiscovery.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import PaymailClient from '../../../dist/cjs/src/paymailClient/paymailClient.js' -import HttpClient from '../../../dist/cjs/src/paymailClient/httpClient.js' - -describe('#PaymailClient - Capability Discovery', () => { - let paymailClient: PaymailClient - - beforeAll(() => { - paymailClient = new PaymailClient(new HttpClient(), undefined, undefined) - }) - - it('should get capabilities for paymail', async () => { - const capabilities = await paymailClient.getDomainCapabilities('handcash.io') - expect(capabilities).toHaveProperty('pki') - }) - - it('should get capabilities for paymail', async () => { - const capabilities = await paymailClient.getDomainCapabilities('handcash.io') - expect(capabilities).toHaveProperty('pki') - }) -}) diff --git a/packages/messaging/ts-paymail/src/paymailClient/__tests/httpClient.test.ts b/packages/messaging/ts-paymail/src/paymailClient/__tests/httpClient.test.ts new file mode 100644 index 000000000..03118ac60 --- /dev/null +++ b/packages/messaging/ts-paymail/src/paymailClient/__tests/httpClient.test.ts @@ -0,0 +1,130 @@ +import fetch from 'cross-fetch' + +import { PaymailServerResponseError } from '../../errors/index.js' +import HttpClient from '../httpClient.js' + +jest.mock('cross-fetch') + +const mockedFetch = jest.mocked(fetch) + +describe('HttpClient', () => { + afterEach(() => { + jest.clearAllMocks() + jest.useRealTimers() + }) + + it('returns successful responses and forwards GET options', async () => { + const response = new Response('{}', { status: 200 }) + mockedFetch.mockResolvedValue(response) + const client = new HttpClient() + + await expect( + client.request('https://example.test', { + method: 'GET', + headers: { Accept: 'application/json' } + }) + ).resolves.toBe(response) + expect(mockedFetch).toHaveBeenCalledWith( + 'https://example.test', + expect.objectContaining({ + method: 'GET', + headers: { Accept: 'application/json' }, + signal: expect.any(AbortSignal) + }) + ) + }) + + it('serializes POST bodies and preserves caller headers', async () => { + mockedFetch.mockResolvedValue(new Response('{}', { status: 200 })) + const client = new HttpClient() + + await client.request('https://example.test', { + method: 'POST', + body: { value: 1 }, + headers: { Authorization: 'Bearer token' } + }) + + const requestOptions = mockedFetch.mock.calls[0]?.[1] + const headers = new Headers(requestOptions?.headers) + expect(mockedFetch).toHaveBeenCalledWith( + 'https://example.test', + expect.objectContaining({ + method: 'POST', + body: '{"value":1}' + }) + ) + expect(headers.get('Content-Type')).toBe('application/json') + expect(headers.get('Authorization')).toBe('Bearer token') + }) + + it('preserves a caller-provided content type', async () => { + mockedFetch.mockResolvedValue(new Response('{}', { status: 200 })) + const client = new HttpClient() + + await client.request('https://example.test', { + method: 'POST', + body: { value: 1 }, + headers: { 'Content-Type': 'application/paymail+json' } + }) + + const requestOptions = mockedFetch.mock.calls[0]?.[1] + expect(new Headers(requestOptions?.headers).get('Content-Type')).toBe( + 'application/paymail+json' + ) + }) + + it('does not synthesize a body when a POST body is omitted', async () => { + mockedFetch.mockResolvedValue(new Response('{}', { status: 200 })) + const client = new HttpClient() + + await client.request('https://example.test', { method: 'POST' }) + + expect(mockedFetch.mock.calls[0]?.[1]).not.toHaveProperty('body') + }) + + it('turns non-success responses into Paymail server errors', async () => { + mockedFetch.mockResolvedValue(new Response('upstream failed', { status: 503 })) + + await expect(new HttpClient().request('https://example.test')).rejects.toEqual( + new PaymailServerResponseError('upstream failed') + ) + }) + + it('aborts requests at the configured timeout and clears the timer', async () => { + jest.useFakeTimers() + mockedFetch.mockImplementation( + async (_url, options): Promise => + new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => { + reject(new DOMException('aborted', 'AbortError')) + }) + }) + ) + const request = new HttpClient(50).request('https://example.test') + const rejection = expect(request).rejects.toMatchObject({ name: 'AbortError' }) + + await jest.advanceTimersByTimeAsync(50) + await rejection + expect(jest.getTimerCount()).toBe(0) + }) + + it('lets a per-request timeout override the default', async () => { + jest.useFakeTimers() + mockedFetch.mockImplementation( + async (_url, options): Promise => + new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => { + reject(new DOMException('aborted', 'AbortError')) + }) + }) + ) + const request = new HttpClient(10_000).request('https://example.test', { + method: 'GET', + timeout: 25 + }) + const rejection = expect(request).rejects.toMatchObject({ name: 'AbortError' }) + + await jest.advanceTimersByTimeAsync(25) + await rejection + }) +}) diff --git a/packages/messaging/ts-paymail/src/paymailClient/__tests/p2pPaymentDestination.test.ts b/packages/messaging/ts-paymail/src/paymailClient/__tests/p2pPaymentDestination.test.ts deleted file mode 100644 index 87ef0da58..000000000 --- a/packages/messaging/ts-paymail/src/paymailClient/__tests/p2pPaymentDestination.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import PaymailClient from '../../../dist/cjs/src/paymailClient/paymailClient.js' -import HttpClient from '../../../dist/cjs/src/paymailClient/httpClient.js' - -describe('#PaymailClient - P2P Payment Destination', () => { - let paymailClient: PaymailClient - - beforeAll(() => { - paymailClient = new PaymailClient(new HttpClient(), undefined, undefined) - }) - - it('should get payment destinations with reference', async () => { - const paymentDestinations = await paymailClient.getP2pPaymentDestination('brandonbryant@handcash.io', 1000) - expect(paymentDestinations).toHaveProperty('outputs') - expect(paymentDestinations.outputs[0]).toHaveProperty('script') - expect(paymentDestinations.outputs[0]).toHaveProperty('satoshis') - expect(paymentDestinations).toHaveProperty('reference') - expect(paymentDestinations.outputs.reduce((acc, { satoshis }) => satoshis + acc, 0)).toBe(1000) - }) -}) diff --git a/packages/messaging/ts-paymail/src/paymailClient/__tests/paymailClient.test.ts b/packages/messaging/ts-paymail/src/paymailClient/__tests/paymailClient.test.ts new file mode 100644 index 000000000..504df1871 --- /dev/null +++ b/packages/messaging/ts-paymail/src/paymailClient/__tests/paymailClient.test.ts @@ -0,0 +1,365 @@ +import { PrivateKey } from '@bsv/sdk' + +import { + NegotiationCapability, + P2pPaymentDestinationCapability, + P2pReceiveBeefTransactionCapability, + PublicKeyInfrastructureCapability, + PublicProfileCapability, + ReceiveTransactionCapability, + SimpleP2pOrdinalDestinationsCapability, + SimpleP2pOrdinalReceiveCapability, + TransactionNegotiationCapability, + VerifyPublicKeyOwnerCapability +} from '../../capability/index.js' +import { PaymailServerResponseError } from '../../errors/index.js' +import { verifyP2PSignature } from '../../p2pSignature.js' +import type { TransactionNegotiationBody } from '../../capability/transactionNegotiationCapability.js' +import HttpClient, { type RequestOptions } from '../httpClient.js' +import PaymailClient from '../paymailClient.js' +import type { DnsResolver } from '../resolver/dnsResolver.js' + +const capabilities = [ + PublicProfileCapability, + PublicKeyInfrastructureCapability, + P2pPaymentDestinationCapability, + ReceiveTransactionCapability, + VerifyPublicKeyOwnerCapability, + P2pReceiveBeefTransactionCapability, + NegotiationCapability, + TransactionNegotiationCapability, + SimpleP2pOrdinalDestinationsCapability, + SimpleP2pOrdinalReceiveCapability +] + +function jsonResponse(value: unknown): Response { + return { json: async () => value } as Response +} + +interface ClientFixture { + client: PaymailClient + request: jest.MockedFunction<(url: string, options?: RequestOptions) => Promise> + responses: Map +} + +function createClientFixture(): ClientFixture { + const responses = new Map() + const request = jest.fn(async (url: string): Promise => { + if (url.endsWith('/.well-known/bsvalias')) { + return jsonResponse({ + bsvalias: '1.0', + capabilities: Object.fromEntries( + capabilities.map(capability => [ + capability.getCode(), + `http://localhost:4100/service/${capability.getCode()}/{alias}@{domain.tld}/{pubkey}` + ]) + ) + }) + } + + const capability = capabilities.find(candidate => + url.includes(`/service/${candidate.getCode()}/`) + ) + return jsonResponse(capability ? responses.get(capability.getCode()) : undefined) + }) + const client = new PaymailClient({ request } as unknown as HttpClient, undefined, 4100) + return { client, request, responses } +} + +describe('PaymailClient', () => { + it('discovers, caches, and aliases capabilities for localhost', async () => { + const { client, request } = createClientFixture() + + const discovered = await client.getCapabilities('localhost') + const cached = await client.getDomainCapabilities('localhost') + + expect(discovered[PublicProfileCapability.getCode()]).toContain( + PublicProfileCapability.getCode() + ) + expect(cached).toBe(discovered) + expect(request).toHaveBeenCalledTimes(1) + expect(request).toHaveBeenCalledWith('http://localhost:4100/.well-known/bsvalias') + }) + + it('discovers an externally resolved HTTPS service', async () => { + const request = jest.fn(async () => + jsonResponse({ bsvalias: '1.0', capabilities: { profile: 'https://profile.test' } }) + ) + const dns: DnsResolver = { + resolveSrv(_domain, callback) { + callback(null, [{ name: 'paymail.example.test', port: 8443 }]) + } + } + const client = new PaymailClient({ request } as unknown as HttpClient, { dns }) + + await expect(client.getCapabilities('example.test')).resolves.toEqual({ + profile: 'https://profile.test' + }) + expect(request).toHaveBeenCalledWith('https://paymail.example.test:8443/.well-known/bsvalias') + }) + + it('rejects malformed discovery documents and unsupported capabilities', async () => { + const malformedRequest = jest.fn(async () => jsonResponse({ bsvalias: '1.0' })) + const malformedClient = new PaymailClient( + { request: malformedRequest } as unknown as HttpClient, + undefined, + 4100 + ) + + await expect(malformedClient.getCapabilities('localhost')).rejects.toThrow( + PaymailServerResponseError + ) + + const { client } = createClientFixture() + await expect(client.ensureCapabilityFor('localhost', 'missing')).rejects.toThrow( + 'does not support capability' + ) + }) + + it('builds capability URLs and rejects invalid Paymail addresses', async () => { + const { client, request, responses } = createClientFixture() + responses.set(PublicProfileCapability.getCode(), { ok: true }) + + await expect( + client.request('alice@localhost', PublicProfileCapability, { requested: true }) + ).resolves.toEqual({ ok: true }) + expect(request).toHaveBeenLastCalledWith( + `http://localhost:4100/service/${PublicProfileCapability.getCode()}/alice@localhost/{pubkey}`, + { + method: 'GET', + body: { requested: true } + } + ) + await expect(client.request('not-a-paymail', PublicProfileCapability)).rejects.toThrow( + 'Invalid Paymail address' + ) + await expect( + client.request('alice@localhost@attacker.test', PublicProfileCapability) + ).rejects.toThrow('Invalid Paymail address') + await expect(client.request('alice@bad domain', PublicProfileCapability)).rejects.toThrow( + 'Invalid Paymail address' + ) + await expect(client.request('alice/../../@localhost', PublicProfileCapability)).rejects.toThrow( + 'Invalid Paymail address' + ) + await expect( + client.request(`${'a'.repeat(65)}@localhost`, PublicProfileCapability) + ).rejects.toThrow('Invalid Paymail address') + + await client.request('alice+tag%value@localhost', PublicProfileCapability) + expect(request.mock.calls.at(-1)?.[0]).toContain('alice%2Btag%25value@localhost') + }) + + it('validates public profiles and strips unknown fields', async () => { + const { client, responses } = createClientFixture() + responses.set(PublicProfileCapability.getCode(), { + name: 'Alice', + avatar: 'https://example.test/alice.png', + ignored: true + }) + + await expect(client.getPublicProfile('alice@localhost')).resolves.toEqual({ + name: 'Alice', + avatar: 'https://example.test/alice.png' + }) + + responses.set(PublicProfileCapability.getCode(), { name: 'Alice', avatar: 'not-a-url' }) + await expect(client.getPublicProfile('bob@localhost')).rejects.toThrow('Validation error') + }) + + it('validates PKI responses', async () => { + const { client, responses } = createClientFixture() + responses.set(PublicKeyInfrastructureCapability.getCode(), { + bsvalias: '1.0', + handle: 'alice@localhost', + pubkey: '02abc' + }) + await expect(client.getPki('alice@localhost')).resolves.toMatchObject({ + handle: 'alice@localhost', + pubkey: '02abc' + }) + + responses.set(PublicKeyInfrastructureCapability.getCode(), { handle: 'alice@localhost' }) + await expect(client.getPki('bob@localhost')).rejects.toThrow('Validation error') + }) + + it('validates P2P payment destinations and exact satoshi totals', async () => { + const { client, responses } = createClientFixture() + responses.set(P2pPaymentDestinationCapability.getCode(), { + outputs: [ + { script: '51', satoshis: 400 }, + { script: '52', satoshis: 600 } + ], + reference: 'payment-ref' + }) + await expect(client.getP2pPaymentDestination('alice@localhost', 1000)).resolves.toMatchObject({ + reference: 'payment-ref' + }) + + await expect(client.getP2pPaymentDestination('bob@localhost', 999)).rejects.toThrow( + 'expected amount of satoshis' + ) + responses.set(P2pPaymentDestinationCapability.getCode(), { + outputs: [], + reference: 'empty' + }) + await expect(client.getP2pPaymentDestination('carol@localhost', 1)).rejects.toThrow( + 'Validation error' + ) + }) + + it('validates ordinal destinations', async () => { + const { client, responses } = createClientFixture() + responses.set(SimpleP2pOrdinalDestinationsCapability.getCode(), { + outputs: [{ script: '51' }], + reference: 'ordinal-ref' + }) + await expect(client.getP2pOrdinalDestinations('alice@localhost', 1)).resolves.toEqual({ + outputs: [{ script: '51' }], + reference: 'ordinal-ref' + }) + + responses.set(SimpleP2pOrdinalDestinationsCapability.getCode(), { + outputs: [], + reference: 'empty' + }) + await expect(client.getP2pOrdinalDestinations('bob@localhost', 1)).rejects.toThrow( + 'Validation error' + ) + }) + + it.each([ + ['raw', ReceiveTransactionCapability, 'sendTransactionP2P', 'hex'], + ['ordinal', SimpleP2pOrdinalReceiveCapability, 'sendOrdinalTransactionP2P', 'hex'], + ['BEEF', P2pReceiveBeefTransactionCapability, 'sendBeefTransactionP2P', 'beef'] + ] as const)( + 'validates %s transaction responses', + async (_label, capability, method, transactionField) => { + const { client, request, responses } = createClientFixture() + responses.set(capability.getCode(), { txid: 'abc123', note: null, ignored: true }) + + await expect( + client[method]('alice@localhost', 'transaction-data', 'reference') + ).resolves.toEqual({ txid: 'abc123', note: null }) + expect(request).toHaveBeenLastCalledWith(expect.stringContaining(capability.getCode()), { + method: 'POST', + body: { + [transactionField]: 'transaction-data', + reference: 'reference', + metadata: undefined + } + }) + + responses.set(capability.getCode(), { note: 'missing txid' }) + await expect(client[method]('bob@localhost', 'bad', 'reference')).rejects.toThrow( + 'Validation error' + ) + } + ) + + it('creates a compact P2P signature that the receiver verifier accepts', () => { + const { client } = createClientFixture() + const privateKey = PrivateKey.fromString('1'.padStart(64, '0'), 16) + const signature = client.createP2PSignature('transaction-id', privateKey) + const longMessage = 'a'.repeat(65_536) + const longMessageSignature = client.createP2PSignature(longMessage, privateKey) + + expect( + verifyP2PSignature('transaction-id', signature, privateKey.toPublicKey().toString()) + ).toEqual({ + publicKeyMatches: true, + signatureValid: true + }) + expect( + verifyP2PSignature( + 'transaction-id', + signature, + PrivateKey.fromString('2'.padStart(64, '0'), 16).toPublicKey().toString() + ).publicKeyMatches + ).toBe(false) + expect( + verifyP2PSignature(longMessage, longMessageSignature, privateKey.toPublicKey().toString()) + .signatureValid + ).toBe(true) + expect(longMessageSignature).toBe( + 'IFdT7RWSoOJrs2AkhhmlT+b3ghMY8tdC857R2mZtFADAL6F9RGtD9cAueB7iOeoBRKHT3T/hmCl6ibbgfsQDnP8=' + ) + }) + + it('rejects malformed compact P2P signatures before recovery', () => { + const { client } = createClientFixture() + const privateKey = PrivateKey.fromString('1'.padStart(64, '0'), 16) + const signature = client.createP2PSignature('transaction-id', privateKey) + const invalidHeaderBytes = Buffer.from(signature, 'base64') + invalidHeaderBytes[0] = 26 + + expect(() => + verifyP2PSignature('transaction-id', '', privateKey.toPublicKey().toString()) + ).toThrow('Invalid Compact Signature') + expect(() => + verifyP2PSignature( + 'transaction-id', + invalidHeaderBytes.toString('base64'), + privateKey.toPublicKey().toString() + ) + ).toThrow('Invalid Compact Signature') + }) + + it('validates public-key ownership responses and substitutes the public key', async () => { + const { client, request, responses } = createClientFixture() + responses.set(VerifyPublicKeyOwnerCapability.getCode(), { + bsvalias: '1.0', + handle: 'alice@localhost', + pubkey: '02abc', + match: true + }) + + await expect(client.verifyPublicKey('alice@localhost', '02abc')).resolves.toMatchObject({ + match: true + }) + expect(request.mock.calls.at(-1)?.[0]).toContain('/02abc') + + responses.set(VerifyPublicKeyOwnerCapability.getCode(), { match: true }) + await expect(client.verifyPublicKey('bob@localhost', '02def')).rejects.toThrow( + 'Validation error' + ) + await expect(client.verifyPublicKey('invalid', '02def')).rejects.toThrow( + 'Invalid Paymail address' + ) + }) + + it('applies negotiation defaults and forwards negotiation requests', async () => { + const { client, request, responses } = createClientFixture() + responses.set(NegotiationCapability.getCode(), { receive: true, ignored: 'value' }) + await expect(client.getTransactionNegotiationCapabilities('alice@localhost')).resolves.toEqual({ + send_disabled: false, + auto_send_response: false, + receive: true, + three_step_exchange: false, + four_step_exchange: false, + auto_exchange_response: false + }) + + const body: TransactionNegotiationBody = { + thread_id: 'thread', + fees: [], + expanded_tx: { tx: 'transaction', ancestors: [], spent_outputs: [] }, + expiry: 1, + timestamp: 2, + reply_to: { handle: 'alice@localhost' } + } + responses.set(TransactionNegotiationCapability.getCode(), { accepted: true }) + await expect(client.sendTransactionNegotiation('alice@localhost', body)).resolves.toEqual({ + accepted: true + }) + expect(request).toHaveBeenLastCalledWith( + expect.stringContaining(TransactionNegotiationCapability.getCode()), + { method: 'POST', body } + ) + + responses.set(NegotiationCapability.getCode(), { receive: 'not-a-boolean' }) + await expect(client.getTransactionNegotiationCapabilities('bob@localhost')).rejects.toThrow( + 'Validation error' + ) + }) +}) diff --git a/packages/messaging/ts-paymail/src/paymailClient/__tests/pki.test.ts b/packages/messaging/ts-paymail/src/paymailClient/__tests/pki.test.ts deleted file mode 100644 index 1efa0f387..000000000 --- a/packages/messaging/ts-paymail/src/paymailClient/__tests/pki.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import PaymailClient from '../../../dist/cjs/src/paymailClient/paymailClient.js' -import HttpClient from '../../../dist/cjs/src/paymailClient/httpClient.js' - -describe('#PaymailClient - Public Key Infrastructure', () => { - let paymailClient - - beforeAll(() => { - paymailClient = new PaymailClient(new HttpClient(), undefined, undefined) - }) - - it('should retrieve the public key for a given paymail handle', async () => { - const paymail = 'brandonbryant@handcash.io' - const publicKeyInfo = await paymailClient.getPki(paymail) - expect(publicKeyInfo).toHaveProperty('bsvalias') - expect(publicKeyInfo.bsvalias).toBe('1.0') - expect(publicKeyInfo).toHaveProperty('handle') - expect(publicKeyInfo.handle).toBe(paymail) - expect(publicKeyInfo).toHaveProperty('pubkey') - expect(publicKeyInfo.pubkey).toMatch(/^(02|03)[a-fA-F0-9]{64}$/) - }) -}) diff --git a/packages/messaging/ts-paymail/src/paymailClient/__tests/publicProfile.test.ts b/packages/messaging/ts-paymail/src/paymailClient/__tests/publicProfile.test.ts deleted file mode 100644 index e91b64e66..000000000 --- a/packages/messaging/ts-paymail/src/paymailClient/__tests/publicProfile.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import PaymailClient from '../../../dist/cjs/src/paymailClient/paymailClient.js' -import HttpClient from '../../../dist/cjs/src/paymailClient/httpClient.js' - -describe('#PaymailClient - Public Profile', () => { - let paymailClient: PaymailClient - - beforeAll(() => { - paymailClient = new PaymailClient(new HttpClient(), undefined, undefined) - }) - - it('should get Public profile for paymail', async () => { - const publicProfile = await paymailClient.getPublicProfile('brandonbryant@handcash.io') - expect(publicProfile).toHaveProperty('name') - expect(publicProfile).toHaveProperty('avatar') - }) -}) diff --git a/packages/messaging/ts-paymail/src/paymailClient/__tests/verifyPublicKeyOwner.test.ts b/packages/messaging/ts-paymail/src/paymailClient/__tests/verifyPublicKeyOwner.test.ts deleted file mode 100644 index c0b539507..000000000 --- a/packages/messaging/ts-paymail/src/paymailClient/__tests/verifyPublicKeyOwner.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import PaymailClient from '../../../dist/cjs/src/paymailClient/paymailClient.js' -import HttpClient from '../../../dist/cjs/src/paymailClient/httpClient.js' - -describe('#PaymailClient - Verify Public Key Owner', () => { - let paymailClient - - beforeAll(() => { - paymailClient = new PaymailClient(new HttpClient(), undefined, undefined) - }) - - it('should verify the public key owner', async () => { - const paymail = 'brandonbryant@handcash.io' - const result = await paymailClient.verifyPublicKey(paymail, 'random key') - expect(result.match).toBe(false) - }) -}) diff --git a/packages/messaging/ts-paymail/src/paymailClient/httpClient.ts b/packages/messaging/ts-paymail/src/paymailClient/httpClient.ts index 3451b4d96..f621e20ba 100644 --- a/packages/messaging/ts-paymail/src/paymailClient/httpClient.ts +++ b/packages/messaging/ts-paymail/src/paymailClient/httpClient.ts @@ -2,36 +2,36 @@ import { PaymailServerResponseError } from '../errors/index.js' import fetch from 'cross-fetch' type FetchOptions = RequestInit & { timeout?: number } -type RequestOptions = FetchOptions & { method: 'GET' | 'POST', body?: any } +export type RequestOptions = Omit & { + method?: 'GET' | 'POST' + body?: unknown +} const defaultRequestOptions: RequestOptions = { method: 'GET' } export default class HttpClient { private readonly defaultTimeout: number - constructor (defaultTimeout = 30000) { + constructor(defaultTimeout = 30000) { this.defaultTimeout = defaultTimeout } - async request ( - url: string, - options: RequestOptions = defaultRequestOptions - ): Promise { + async request(url: string, options: RequestOptions = defaultRequestOptions): Promise { const controller = new AbortController() - const timeout = options.timeout ?? this.defaultTimeout + const { body, timeout: requestedTimeout, ...fetchOptions } = options + const timeout = requestedTimeout ?? this.defaultTimeout const timeoutId = setTimeout(() => controller.abort(), timeout) - const requestOptions: FetchOptions = { - ...options, + const requestOptions: RequestInit = { + ...fetchOptions, signal: controller.signal } - if (options.method === 'POST' && options.body) { - requestOptions.body = JSON.stringify(options.body) - requestOptions.headers = { - 'Content-Type': 'application/json', - ...options.headers - } + if (options.method === 'POST' && body !== undefined) { + requestOptions.body = JSON.stringify(body) + const headers = new Headers(options.headers) + if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/json') + requestOptions.headers = headers } try { diff --git a/packages/messaging/ts-paymail/src/paymailClient/index.ts b/packages/messaging/ts-paymail/src/paymailClient/index.ts index 8aa11f725..58680b777 100644 --- a/packages/messaging/ts-paymail/src/paymailClient/index.ts +++ b/packages/messaging/ts-paymail/src/paymailClient/index.ts @@ -1,4 +1,18 @@ export { default as PaymailClient } from './paymailClient.js' +export type { + DomainCapabilities, + P2PDestination, + P2POrdinalDestination, + P2POrdinalDestinations, + P2PPaymentDestination, + P2PTransactionMetadata, + P2PTransactionResponse, + PublicKeyInformation, + PublicKeyVerification, + PublicProfile +} from './paymailClient.js' export { default as DNSResolver } from './resolver/dnsResolver.js' +export type { DnsResolver, DNSResolverOptions } from './resolver/dnsResolver.js' export { default as HttpClient } from './httpClient.js' +export type { RequestOptions } from './httpClient.js' export { default as AbstractDnsResolver } from './resolver/abstractResolver.js' diff --git a/packages/messaging/ts-paymail/src/paymailClient/paymailClient.ts b/packages/messaging/ts-paymail/src/paymailClient/paymailClient.ts index dff7290bc..8e204ce52 100644 --- a/packages/messaging/ts-paymail/src/paymailClient/paymailClient.ts +++ b/packages/messaging/ts-paymail/src/paymailClient/paymailClient.ts @@ -4,7 +4,7 @@ import HttpClient from './httpClient.js' import Capability from '../capability/capability.js' import Joi from 'joi' import { PaymailServerResponseError } from '../errors/index.js' -import { PrivateKey, BigNumber, Hash, Utils, Signature, ECDSA } from '@bsv/sdk' +import { PrivateKey } from '@bsv/sdk' import PublicProfileCapability from '../capability/publicProfileCapability.js' import PublicKeyInfrastructureCapability from '../capability/pkiCapability.js' import P2pPaymentDestinationCapability from '../capability/p2pPaymentDestinationCapability.js' @@ -12,28 +12,60 @@ import ReceiveTransactionCapability from '../capability/p2pReceiveTransactionCap import VerifyPublicKeyOwnerCapability from '../capability/verifyPublicKeyOwnerCapability.js' import ReceiveBeefTransactionCapability from '../capability/p2pReceiveBeefTransactionCapability.js' import NegotiationCapability from '../capability/negotiationCapabilities.js' -import TransactionNegotiationCapabilities, { TransactionNegotiationBody } from '../capability/transactionNegotiationCapability.js' +import TransactionNegotiationCapabilities, { + TransactionNegotiationBody +} from '../capability/transactionNegotiationCapability.js' import SimpleP2pOrdinalDestinationsCapability from '../capability/simpleP2pOrdinalDestinationsCapability.js' import SimpleP2pOrdinalReceiveCapability from '../capability/simpleP2pOrdinalReceiveCapability.js' -const { sha256 } = Hash +import { createP2PSignature } from '../p2pSignature.js' +import { parsePaymail } from '../paymailAddress.js' -const BSM_PREFIX = 'Bitcoin Signed Message:\n' +export type DomainCapabilities = Record -/** Encode a small integer as a Bitcoin varint (up to 252 bytes). */ -function bsmVarInt (n: number): number[] { - if (n < 0xfd) return [n] - return [0xfd, n & 0xff, (n >> 8) & 0xff] +export interface PublicProfile { + name: string + avatar: string } -function bsmMagicHash (msg: number[]): number[] { - const prefixBytes = Utils.toArray(BSM_PREFIX, 'utf8') - const buf = [ - ...bsmVarInt(prefixBytes.length), - ...prefixBytes, - ...bsmVarInt(msg.length), - ...msg - ] - return Hash.hash256(buf) +export interface PublicKeyInformation { + bsvalias?: string + handle: string + pubkey: string +} + +export interface P2PDestination { + script: string + satoshis: number +} + +export interface P2PPaymentDestination { + outputs: P2PDestination[] + reference: string +} + +export interface P2POrdinalDestination { + script: string +} + +export interface P2POrdinalDestinations { + outputs: P2POrdinalDestination[] + reference: string +} + +export interface P2PTransactionMetadata { + sender: string + pubkey: string + signature: string + note: string +} + +export interface P2PTransactionResponse { + txid: string + note?: string | null +} + +export interface PublicKeyVerification extends PublicKeyInformation { + match: boolean } /** @@ -42,7 +74,7 @@ function bsmMagicHash (msg: number[]): number[] { */ export default class PaymailClient { // Cache for storing domain capabilities. - private readonly _domainCapabilityCache: Map> + private readonly _domainCapabilityCache: Map // Resolver for handling DNS queries. private readonly _resolver: AbstractResolver @@ -59,11 +91,11 @@ export default class PaymailClient { * @param dnsOptions - Configuration options for DNS resolution. * @param localhostPort - The port number for localhost development. Defaults to 3000 if not specified. */ - constructor (httpClient?: HttpClient, dnsOptions?: DNSResolverOptions, localhostPort?: number) { - this.httpClient = httpClient || new HttpClient() + constructor(httpClient?: HttpClient, dnsOptions?: DNSResolverOptions, localhostPort?: number) { + this.httpClient = httpClient ?? new HttpClient() this._domainCapabilityCache = new Map() this._resolver = new DNSResolver(this.httpClient, dnsOptions) - this._localHostPort = localhostPort || 3000 + this._localHostPort = localhostPort ?? 3000 } /** @@ -71,14 +103,14 @@ export default class PaymailClient { * @param aDomain - The domain to fetch the configuration for. * @returns The well-known configuration as a JSON object. */ - private readonly fetchWellKnown = async (aDomain: string): Promise => { + private readonly fetchWellKnown = async (aDomain: string): Promise => { const isLocalHost = this.isDomainLocalHost(aDomain) const protocol = isLocalHost ? 'http://' : 'https://' let domain = aDomain let port = isLocalHost ? this._localHostPort : null if (!isLocalHost) { - ({ domain, port } = await this._resolver.queryBsvaliasDomain(aDomain)) + ;({ domain, port } = await this._resolver.queryBsvaliasDomain(aDomain)) } const url = `${protocol}${domain}:${port}/.well-known/bsvalias` @@ -88,51 +120,67 @@ export default class PaymailClient { bsvalias: Joi.string().required(), capabilities: Joi.object().required() }).options({ stripUnknown: true }) - const { error } = schema.validate(json) + const { error, value } = schema.validate(json) if (error) { throw new PaymailServerResponseError(`Validation error: ${error.message}`) } - return json.capabilities + return value.capabilities as DomainCapabilities } - private isDomainLocalHost (aDomain) { + private isDomainLocalHost(aDomain: string): boolean { return aDomain === 'localhost' } - private readonly getDomainCapabilities = async (aDomain) => { - if (!this._domainCapabilityCache.has(aDomain)) { - const capabilities = await this.fetchWellKnown(aDomain) - this._domainCapabilityCache.set(aDomain, capabilities) - } - return this._domainCapabilityCache.get(aDomain) + public readonly getDomainCapabilities = async (aDomain: string): Promise => { + const cached = this._domainCapabilityCache.get(aDomain) + if (cached !== undefined) return cached + + const capabilities = await this.fetchWellKnown(aDomain) + this._domainCapabilityCache.set(aDomain, capabilities) + return capabilities } + public readonly getCapabilities = this.getDomainCapabilities + /** - * Ensures that a specified domain supports a given capability. - * @param aDomain - The domain to check for the capability. - * @param aCapability - The capability to check for. - * @returns The URL endpoint for the specified capability. - * @throws PaymailServerResponseError - Thrown if the domain does not support the requested capability. - */ - public ensureCapabilityFor = async (aDomain, aCapability) => { + * Ensures that a specified domain supports a given capability. + * @param aDomain - The domain to check for the capability. + * @param aCapability - The capability to check for. + * @returns The URL endpoint for the specified capability. + * @throws PaymailServerResponseError - Thrown if the domain does not support the requested capability. + */ + public ensureCapabilityFor = async (aDomain: string, aCapability: string): Promise => { const capabilities = await this.getDomainCapabilities(aDomain) - if (!capabilities[aCapability]) { - throw new PaymailServerResponseError(`Domain "${aDomain}" does not support capability "${aCapability}"`) + const endpoint = capabilities[aCapability] + if (typeof endpoint !== 'string' || endpoint.length === 0) { + throw new PaymailServerResponseError( + `Domain "${aDomain}" does not support capability "${aCapability}"` + ) } - return capabilities[aCapability] + return endpoint } /** - * Makes a generic request to a Paymail service. - * @param aDomain - The domain of the Paymail service. - * @param capability - The capability being requested. - * @param body - Optional request body. - * @returns The response from the Paymail service. - */ - public request = async (aDomain: string, capability: Capability, body?: any) => { - const [name, domain] = aDomain.split('@') + * Makes a generic request to a Paymail service. + * @param aDomain - The domain of the Paymail service. + * @param capability - The capability being requested. + * @param body - Optional request body. + * @returns The response from the Paymail service. + */ + public request = async ( + aDomain: string, + capability: Capability, + body?: unknown + ): Promise => { + const parsed = parsePaymail(aDomain) + if (!parsed) { + throw new PaymailServerResponseError(`Invalid Paymail address: "${aDomain}"`) + } + const { name, domain } = parsed const url = await this.ensureCapabilityFor(domain, capability.getCode()) - const requestUrl = url.replace('{alias}', name).replace('{domain.tld}', domain) + const requestUrl = url + .replaceAll('{alias}', encodeURIComponent(name)) + .replaceAll('{domain.tld}', encodeURIComponent(domain)) const response = await this.httpClient.request(requestUrl, { method: capability.getMethod(), body @@ -142,12 +190,12 @@ export default class PaymailClient { } /** - * Retrieves the public profile associated with a Paymail address. - * @param paymail - The Paymail address to fetch the profile for. - * @returns The public profile including name and avatar. - * @throws PaymailServerResponseError - Thrown if there is a validation error in the response. - */ - public getPublicProfile = async (paymail) => { + * Retrieves the public profile associated with a Paymail address. + * @param paymail - The Paymail address to fetch the profile for. + * @returns The public profile including name and avatar. + * @throws PaymailServerResponseError - Thrown if there is a validation error in the response. + */ + public getPublicProfile = async (paymail: string): Promise => { const response = await this.request(paymail, PublicProfileCapability) const schema = Joi.object({ name: Joi.string().required(), @@ -158,16 +206,16 @@ export default class PaymailClient { if (error) { throw new PaymailServerResponseError(`Validation error: ${error.message}`) } - return value + return value as PublicProfile } /** - * Retrieves the public key infrastructure (PKI) data for a given Paymail address. - * @param paymail - The Paymail address to fetch the PKI data for. - * @returns PKI data including bsvalias, handle, and pubkey. - * @throws PaymailServerResponseError - Thrown if there is a validation error in the response. - */ - public getPki = async (paymail) => { + * Retrieves the public key infrastructure (PKI) data for a given Paymail address. + * @param paymail - The Paymail address to fetch the PKI data for. + * @returns PKI data including bsvalias, handle, and pubkey. + * @throws PaymailServerResponseError - Thrown if there is a validation error in the response. + */ + public getPki = async (paymail: string): Promise => { const response = await this.request(paymail, PublicKeyInfrastructureCapability) const schema = Joi.object({ bsvalias: Joi.string().optional().allow('1.0'), @@ -178,7 +226,7 @@ export default class PaymailClient { if (error) { throw new PaymailServerResponseError(`Validation error: ${error.message}`) } - return value + return value as PublicKeyInformation } /** @@ -187,28 +235,37 @@ export default class PaymailClient { * @param satoshis - The amount of satoshis for the transaction. * @returns An object containing the payment destination details. */ - public getP2pPaymentDestination = async (paymail: string, satoshis: number): Promise => { + public getP2pPaymentDestination = async ( + paymail: string, + satoshis: number + ): Promise => { const response = await this.request(paymail, P2pPaymentDestinationCapability, { satoshis }) const schema = Joi.object({ - outputs: Joi.array().items( - Joi.object({ - script: Joi.string().required(), - satoshis: Joi.number().required() - }).required().min(1)), + outputs: Joi.array() + .items( + Joi.object({ + script: Joi.string().required(), + satoshis: Joi.number().required() + }).required() + ) + .min(1), reference: Joi.string().required() }).options({ stripUnknown: true }) - const { error } = schema.validate(response) + const { error, value } = schema.validate(response) if (error) { throw new PaymailServerResponseError(`Validation error: ${error.message}`) } - if (satoshis !== response.outputs.reduce((acc, output) => acc + output.satoshis, 0)) { - throw new PaymailServerResponseError('The server did not return the expected amount of satoshis') + const destination = value as P2PPaymentDestination + if (satoshis !== destination.outputs.reduce((acc, output) => acc + output.satoshis, 0)) { + throw new PaymailServerResponseError( + 'The server did not return the expected amount of satoshis' + ) } - return response + return destination } /** @@ -217,42 +274,48 @@ export default class PaymailClient { * @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 => { + 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)), + 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) + const { error, value } = schema.validate(response) if (error) { throw new PaymailServerResponseError(`Validation error: ${error.message}`) } - return response + return value as P2POrdinalDestinations } /** - * 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 sendTransactionP2P = async (paymail: string, hex: string, reference: string, metadata?: { - sender: string - pubkey: string - signature: string - note: string - }) => { + * 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 sendTransactionP2P = async ( + paymail: string, + hex: string, + reference: string, + metadata?: P2PTransactionMetadata + ): Promise => { const response = await this.request(paymail, ReceiveTransactionCapability, { hex, reference, @@ -267,26 +330,26 @@ export default class PaymailClient { if (error) { throw new PaymailServerResponseError(`Validation error: ${error.message}`) } - return value + return value as P2PTransactionResponse } /** - * 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 - }) => { + * 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?: P2PTransactionMetadata + ): Promise => { const response = await this.request(paymail, SimpleP2pOrdinalReceiveCapability, { hex, reference, @@ -301,22 +364,17 @@ export default class PaymailClient { if (error) { throw new PaymailServerResponseError(`Validation error: ${error.message}`) } - return value + return value as P2PTransactionResponse } /** * Creates a digital signature for a P2P transaction using a given private key. * @param txid - The transaction ID to be signed. * @param privKey - The private key used for signing the transaction. - * @returns A hex string representing the digital signature. + * @returns A Base64-encoded compact Bitcoin Signed Message signature. */ - public createP2PSignature = (txid: string, privKey: PrivateKey): string => { - const msg = Utils.toArray(txid, 'utf8') - const msgHash = bsmMagicHash(msg) - const sig = ECDSA.sign(new BigNumber(msgHash), privKey, true) - const recovery = sig.CalculateRecoveryFactor(privKey.toPublicKey(), new BigNumber(msgHash)) - return sig.toCompact(recovery, true, 'base64') as string - } + public createP2PSignature = (txid: string, privKey: PrivateKey): string => + createP2PSignature(txid, privKey) /** * Verifies the ownership of a public key for a given Paymail address. @@ -325,10 +383,20 @@ export default class PaymailClient { * @returns An object containing verification results. * @throws PaymailServerResponseError - Thrown if there is an error in the verification process. */ - public verifyPublicKey = async (paymail, pubkey) => { - const [name, domain] = paymail.split('@') + public verifyPublicKey = async ( + paymail: string, + pubkey: string + ): Promise => { + const parsed = parsePaymail(paymail) + if (!parsed) { + throw new PaymailServerResponseError(`Invalid Paymail address: "${paymail}"`) + } + const { name, domain } = parsed const url = await this.ensureCapabilityFor(domain, VerifyPublicKeyOwnerCapability.getCode()) - const requestUrl = url.replace('{alias}', name).replace('{domain.tld}', domain).replace('{pubkey}', pubkey) + const requestUrl = url + .replaceAll('{alias}', encodeURIComponent(name)) + .replaceAll('{domain.tld}', encodeURIComponent(domain)) + .replaceAll('{pubkey}', encodeURIComponent(pubkey)) const response = await this.httpClient.request(requestUrl) const responseBody = await response.json() @@ -338,28 +406,28 @@ export default class PaymailClient { pubkey: Joi.string().required(), match: Joi.boolean().required() }).options({ stripUnknown: true }) - const { error } = schema.validate(responseBody) + const { error, value } = schema.validate(responseBody) if (error) { throw new PaymailServerResponseError(`Validation error: ${error.message}`) } - return responseBody + return value as PublicKeyVerification } /** - * Sends a beef transaction using the Pay-to-Peer (P2P) protocol. - * @param paymail - The Paymail address to which the transaction is sent. - * @param beef - The transaction content in beef format. - * @param reference - A reference identifier for the transaction. - * @param metadata - Optional metadata including sender, public key, signature, and a note. - * @returns The transaction ID and an optional note in the response. - * @throws PaymailServerResponseError - Thrown if there is a validation error in the response. - */ - public sendBeefTransactionP2P = async (paymail: string, beef: string, reference: string, metadata?: { - sender: string - pubkey: string - signature: string - note: string - }) => { + * Sends a beef transaction using the Pay-to-Peer (P2P) protocol. + * @param paymail - The Paymail address to which the transaction is sent. + * @param beef - The transaction content in beef format. + * @param reference - A reference identifier for the transaction. + * @param metadata - Optional metadata including sender, public key, signature, and a note. + * @returns The transaction ID and an optional note in the response. + * @throws PaymailServerResponseError - Thrown if there is a validation error in the response. + */ + public sendBeefTransactionP2P = async ( + paymail: string, + beef: string, + reference: string, + metadata?: P2PTransactionMetadata + ): Promise => { const response = await this.request(paymail, ReceiveBeefTransactionCapability, { beef, reference, @@ -373,7 +441,7 @@ export default class PaymailClient { if (error) { throw new PaymailServerResponseError(`Validation error: ${error.message}`) } - return value + return value as P2PTransactionResponse } /** @@ -382,7 +450,9 @@ export default class PaymailClient { * @returns An object representing the negotiation capabilities. * @throws PaymailServerResponseError - Thrown if there is a validation error in the response. */ - public getTransactionNegotiationCapabilities = async (paymail: string) => { + public getTransactionNegotiationCapabilities = async ( + paymail: string + ): Promise> => { const response = await this.request(paymail, NegotiationCapability) const schema = Joi.object({ send_disabled: Joi.boolean().default(false), @@ -396,7 +466,7 @@ export default class PaymailClient { if (error) { throw new PaymailServerResponseError(`Validation error: ${error.message}`) } - return value + return value as Record } /** @@ -405,7 +475,10 @@ export default class PaymailClient { * @param body - The transaction negotiation request body. * @returns The response from the Paymail service. */ - public sendTransactionNegotiation = async (paymail: string, body: TransactionNegotiationBody) => { + public sendTransactionNegotiation = async ( + paymail: string, + body: TransactionNegotiationBody + ): Promise => { const response = await this.request(paymail, TransactionNegotiationCapabilities, body) return response } diff --git a/packages/messaging/ts-paymail/src/paymailClient/resolver/__tests/dnsResolver.test.ts b/packages/messaging/ts-paymail/src/paymailClient/resolver/__tests/dnsResolver.test.ts index bcacadb73..58d5588f6 100644 --- a/packages/messaging/ts-paymail/src/paymailClient/resolver/__tests/dnsResolver.test.ts +++ b/packages/messaging/ts-paymail/src/paymailClient/resolver/__tests/dnsResolver.test.ts @@ -1,12 +1,16 @@ import { describe, it, expect } from '@jest/globals' -import DNSResolver from '../../../../dist/cjs/src/paymailClient/resolver/dnsResolver.js' -import HttpClient from '../../../../dist/cjs/src/paymailClient/httpClient.js' +import DNSResolver from '../dnsResolver.js' +import HttpClient from '../../httpClient.js' // These tests previously hit live networks (system DNS + dns.google.com DoH), // which made them flaky in CI ("Premature close" / timeouts). The DNS and HTTP // layers are mocked here so the resolver logic is exercised deterministically. -interface DohJson { Status: number, AD?: boolean, Answer?: Array<{ data: string }> } +interface DohJson { + Status: number + AD?: boolean + Answer?: Array<{ data: string; type?: number }> +} const dohResponses: Record = { // SRV target on a subdomain of the queried domain => treated as secure. @@ -16,7 +20,7 @@ const dohResponses: Record = { } const mockHttpClient = { - async request (url: string): Promise<{ json: () => Promise }> { + async request(url: string): Promise<{ json: () => Promise }> { const key = Object.keys(dohResponses).find(k => url.includes(k)) const body = key ? dohResponses[key] : { Status: 3 } return { json: async () => body } @@ -26,7 +30,10 @@ const mockHttpClient = { const srvError = (code: string): NodeJS.ErrnoException => Object.assign(new Error(code), { code }) const mockDns = { - resolveSrv (domain: string, cb: (err: NodeJS.ErrnoException | null, records?: Array<{ name: string, port: number }>) => void): void { + resolveSrv( + domain: string, + cb: (err: NodeJS.ErrnoException | null, records?: Array<{ name: string; port: number }>) => void + ): void { if (domain.includes('handcash')) { cb(null, [{ name: 'cloud.handcash.io', port: 443 }]) } else if (domain.includes('relysia')) { @@ -65,6 +72,125 @@ describe('# DNS resolver', () => { it('should throw error with unsecured domain', async () => { const dnsResolver = new DNSResolver(mockHttpClient, { dns: mockDns }) - await expect(dnsResolver.queryBsvaliasDomain('centbee.com')).rejects.toThrow('centbee.com is not correctly configured: insecure domain') + await expect(dnsResolver.queryBsvaliasDomain('centbee.com')).rejects.toThrow( + 'centbee.com is not correctly configured: insecure domain' + ) + }) + + it('falls back to the original domain when DoH reports NXDOMAIN', async () => { + const dnsResolver = new DNSResolver(mockHttpClient) + await expect(dnsResolver.queryBsvaliasDomain('unknown.example')).resolves.toEqual({ + domain: 'unknown.example', + port: 443 + }) + }) + + it('rejects unsuccessful, empty, and malformed DoH answers', async () => { + const responses: DohJson[] = [ + { Status: 2 }, + { Status: 0, Answer: [] }, + { Status: 0, AD: true, Answer: [{ data: 'malformed' }] }, + { Status: 0, AD: true, Answer: [{ data: '10 10 not-a-port broken.example.' }] }, + { Status: 0, AD: true, Answer: [{ data: '10 10 0 broken.example.' }] }, + { Status: 0, AD: true, Answer: [{ data: '10 10 65536 broken.example.' }] }, + { Status: 0, AD: true, Answer: [{ data: '10 10 443 bad_target.example.' }] }, + { Status: 0, AD: true, Answer: [{ data: '-1 10 443 broken.example.' }] }, + { Status: 0, AD: true, Answer: [{ data: '10 65536 443 broken.example.' }] }, + { Status: 0, AD: true, Answer: [{ data: '10 10 443 broken.example. extra' }] }, + { Status: 0, AD: true, Answer: [{ type: 1, data: '10 10 443 broken.example.' }] } + ] + for (const response of responses) { + const client = { + request: async () => ({ json: async () => response }) + } as unknown as HttpClient + const resolver = new DNSResolver(client) + await expect(resolver.queryBsvaliasDomain('broken.example')).rejects.toThrow( + 'not correctly configured' + ) + } + }) + + it('selects the SRV answer when a DoH response also contains another record type', async () => { + const client = { + request: async () => ({ + json: async () => ({ + Status: 0, + AD: true, + Answer: [ + { type: 5, data: 'alias.example.' }, + { type: 33, data: '10 10 443 paymail.example.' } + ] + }) + }) + } as unknown as HttpClient + const resolver = new DNSResolver(client) + + await expect(resolver.queryBsvaliasDomain('example')).resolves.toEqual({ + domain: 'paymail.example', + port: 443 + }) + }) + + it('normalizes trailing dots and accepts parent/subdomain relationships', () => { + const resolver = new DNSResolver(mockHttpClient) + expect(resolver.domainsAreEqual('paymail.example.com.', 'example.com')).toBe(true) + expect(resolver.domainsAreEqual('PAYMAIL.EXAMPLE.COM.', 'example.com')).toBe(true) + expect(resolver.domainsAreEqual('example.com', 'example.com.')).toBe(true) + expect(resolver.domainsAreEqual('example.com', 'attacker.test')).toBe(false) + }) + + it('falls back to DoH when local DNS returns no records', async () => { + const dns = { + resolveSrv( + _domain: string, + callback: ( + error: NodeJS.ErrnoException | null, + records?: Array<{ name: string; port: number }> + ) => void + ): void { + callback(null, []) + } + } + const resolver = new DNSResolver(mockHttpClient, { dns }) + await expect(resolver.queryBsvaliasDomain('handcash.io')).resolves.toEqual({ + domain: 'cloud.handcash.io', + port: 443 + }) + }) + + it('falls back to DoH when local DNS omits the records argument', async () => { + const dns = { + resolveSrv( + _domain: string, + callback: ( + error: NodeJS.ErrnoException | null, + records?: Array<{ name: string; port: number }> + ) => void + ): void { + callback(null) + } + } + const resolver = new DNSResolver(mockHttpClient, { dns }) + await expect(resolver.queryBsvaliasDomain('handcash.io')).resolves.toEqual({ + domain: 'cloud.handcash.io', + port: 443 + }) + }) + + it('rejects invalid local DNS ports', async () => { + const dns = { + resolveSrv( + _domain: string, + callback: ( + error: NodeJS.ErrnoException | null, + records?: Array<{ name: string; port: number }> + ) => void + ): void { + callback(null, [{ name: 'example.test', port: 65_536 }]) + } + } + const resolver = new DNSResolver(mockHttpClient, { dns }) + + await expect(resolver.queryBsvaliasDomain('example.test')).rejects.toThrow('invalid SRV port') }) }) diff --git a/packages/messaging/ts-paymail/src/paymailClient/resolver/abstractResolver.ts b/packages/messaging/ts-paymail/src/paymailClient/resolver/abstractResolver.ts index ad803fa8c..592222107 100644 --- a/packages/messaging/ts-paymail/src/paymailClient/resolver/abstractResolver.ts +++ b/packages/messaging/ts-paymail/src/paymailClient/resolver/abstractResolver.ts @@ -5,18 +5,18 @@ export interface DnsResponse { abstract class AbstractResolver { /** - * Resolves the SRV records for a given domain. - * @param aDomain The domain for which to resolve SRV records. - * @returns A Promise resolving to the DNS resolution result. - */ - abstract resolveSrv (aDomain: string): Promise + * Resolves the SRV records for a given domain. + * @param aDomain The domain for which to resolve SRV records. + * @returns A Promise resolving to the DNS resolution result. + */ + abstract resolveSrv(aDomain: string): Promise /** - * Queries the BSV alias domain information. - * @param aDomain The domain to query for BSV alias information. - * @returns A Promise resolving to the query result. - */ - abstract queryBsvaliasDomain (aDomain: string): Promise + * Queries the BSV alias domain information. + * @param aDomain The domain to query for BSV alias information. + * @returns A Promise resolving to the query result. + */ + abstract queryBsvaliasDomain(aDomain: string): Promise } export default AbstractResolver diff --git a/packages/messaging/ts-paymail/src/paymailClient/resolver/dnsResolver.ts b/packages/messaging/ts-paymail/src/paymailClient/resolver/dnsResolver.ts index e6588175c..70ae2fdcf 100644 --- a/packages/messaging/ts-paymail/src/paymailClient/resolver/dnsResolver.ts +++ b/packages/messaging/ts-paymail/src/paymailClient/resolver/dnsResolver.ts @@ -1,18 +1,43 @@ import AbstractResolver from './abstractResolver.js' import HttpClient from '../httpClient.js' import { PaymailServerResponseError } from '../../errors/index.js' +import type { DnsResponse } from './abstractResolver.js' + +interface SrvRecord { + name: string + port: number +} + +interface DnsError { + code?: string +} + +export interface DnsResolver { + resolveSrv( + domain: string, + callback: (error: DnsError | null, records?: SrvRecord[]) => void + ): void +} + +interface DohResponse { + Status: number + AD?: boolean + Answer?: Array<{ data: string; type?: number }> +} + +const DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i export interface DNSResolverOptions { - dns?: any + dns?: DnsResolver dohServerBaseUrl?: string } class DNSResolver extends AbstractResolver { private readonly dohServiceBaseUrl: string private readonly httpClient: HttpClient - private readonly dns: any + private readonly dns?: DnsResolver - constructor (httpClient: HttpClient, options: DNSResolverOptions = {}) { + constructor(httpClient: HttpClient, options: DNSResolverOptions = {}) { super() const { dns, dohServerBaseUrl = 'https://dns.google.com/resolve' } = options this.dohServiceBaseUrl = dohServerBaseUrl @@ -20,10 +45,10 @@ class DNSResolver extends AbstractResolver { this.dns = dns } - async resolveSrv (aDomain: string): Promise<{ domain: string, port: number }> { + async resolveSrv(aDomain: string): Promise { // Try to resolve the domain using the local DNS server first if available (Node only) if (this.dns) { - const result = await this.resolveWithDns(aDomain) + const result = await this.resolveWithDns(aDomain, this.dns) if (result.isSecure) { return { domain: result.domain, @@ -31,57 +56,124 @@ class DNSResolver extends AbstractResolver { } } } - return await this.resolveWithDoh(aDomain) + return this.resolveWithDoh(aDomain) } - private domainWithoutBsvAliasPrefix (aDomain: string): string { + private domainWithoutBsvAliasPrefix(aDomain: string): string { return aDomain.replace('_bsvalias._tcp.', '') } - domainsAreEqual (domain1: string, domain2: string): boolean { - const normDomain1 = domain1.replace(/\.$/, '') - const normDomain2 = domain2.replace(/\.$/, '') + domainsAreEqual(domain1: string, domain2: string): boolean { + const normDomain1 = domain1.replace(/\.$/, '').toLowerCase() + const normDomain2 = domain2.replace(/\.$/, '').toLowerCase() // Domains are equal if they are identical after normalization, // or if one is a subdomain of the other (e.g., 'sub.example.com' and 'example.com'). - if (normDomain1 === normDomain2 || normDomain1.endsWith('.' + normDomain2) || normDomain2.endsWith('.' + normDomain1)) { + if ( + normDomain1 === normDomain2 || + normDomain1.endsWith(`.${normDomain2}`) || + normDomain2.endsWith(`.${normDomain1}`) + ) { return true } return false } - private async resolveWithDns (aDomain: string): Promise { - return await new Promise((resolve) => { - this.dns.resolveSrv(aDomain, (err, records) => { - if (err) { - if (err.code === 'ENODATA' || err.code === 'ENOTFOUND') { - // Record not found, assume port 443 and domain is the same as the input per spec - resolve({ - domain: this.domainWithoutBsvAliasPrefix(aDomain), - port: 443, - isSecure: true - }) + private validateDnsName(value: string, domain: string): string { + const normalized = value.replace(/\.$/, '').toLowerCase() + if ( + normalized.length === 0 || + normalized.length > 253 || + !normalized.split('.').every(label => DNS_LABEL.test(label)) + ) { + throw new PaymailServerResponseError( + `${domain} is not correctly configured: invalid SRV target` + ) + } + return normalized + } + + private validatePort(value: number | string, domain: string): number { + const port = this.validateUint16(value, domain, 'port') + if (port === 0) { + throw new PaymailServerResponseError( + `${domain} is not correctly configured: invalid SRV port` + ) + } + return port + } + + private validateUint16( + value: number | string, + domain: string, + field: 'port' | 'priority' | 'weight' + ): number { + let port: number + if (typeof value === 'number') { + port = value + } else { + port = /^\d+$/.test(value) ? Number.parseInt(value, 10) : Number.NaN + } + if (!Number.isInteger(port) || port < 0 || port > 65_535) { + throw new PaymailServerResponseError( + `${domain} is not correctly configured: invalid SRV ${field}` + ) + } + return port + } + + private async resolveWithDns( + aDomain: string, + dns: DnsResolver + ): Promise { + return new Promise((resolve, reject) => { + dns.resolveSrv(aDomain, (err, records) => { + try { + if (err) { + if (err.code === 'ENODATA' || err.code === 'ENOTFOUND') { + // Record not found, assume port 443 and domain is the same as the input per spec + resolve({ + domain: this.domainWithoutBsvAliasPrefix(aDomain), + port: 443, + isSecure: true + }) + } else { + // Handle other types of errors + resolve({ + domain: this.domainWithoutBsvAliasPrefix(aDomain), + port: 443, + isSecure: false + }) + } } else { - // Handle other types of errors - resolve({ - domain: this.domainWithoutBsvAliasPrefix(aDomain), - port: 443, - isSecure: false - }) + const [record] = records ?? [] + if (!record) { + resolve({ + domain: this.domainWithoutBsvAliasPrefix(aDomain), + port: 443, + isSecure: false + }) + return + } + const requestedDomain = this.domainWithoutBsvAliasPrefix(aDomain) + const domain = this.validateDnsName(record.name, requestedDomain) + const port = this.validatePort(record.port, requestedDomain) + const isSecure = this.domainsAreEqual(domain, requestedDomain) + resolve({ domain, port, isSecure }) } - } else { - const { name, port } = records[0] - const isSecure = this.domainsAreEqual(name, this.domainWithoutBsvAliasPrefix(aDomain)) - resolve({ domain: name, port, isSecure }) + } catch (error) { + reject(error) } }) }) } - private readonly resolveWithDoh = async (aDomain: string): Promise => { - const response = await this.httpClient.request(`${this.dohServiceBaseUrl}?name=${aDomain}&type=SRV&cd=0`) - const dohResponse = await response.json() + private readonly resolveWithDoh = async (aDomain: string): Promise => { + const response = await this.httpClient.request( + `${this.dohServiceBaseUrl}?name=${encodeURIComponent(aDomain)}&type=SRV&cd=0` + ) + const dohResponse = (await response.json()) as DohResponse const domain = this.domainWithoutBsvAliasPrefix(aDomain) // Record not found assume port 443 and domain is the same as the input per spec @@ -92,26 +184,52 @@ class DNSResolver extends AbstractResolver { } } if (dohResponse.Status !== 0 || !dohResponse.Answer) { - throw new PaymailServerResponseError(`${this.domainWithoutBsvAliasPrefix(aDomain)} is not correctly configured: insecure domain`) + throw new PaymailServerResponseError( + `${this.domainWithoutBsvAliasPrefix(aDomain)} is not correctly configured: insecure domain` + ) } - const data = dohResponse.Answer[0].data.split(' ') + const answer = + dohResponse.Answer.find(candidate => candidate.type === 33) ?? + dohResponse.Answer.find(candidate => candidate.type === undefined) + if (!answer) { + throw new PaymailServerResponseError( + `${domain} is not correctly configured: missing SRV answer` + ) + } + const data = answer.data.trim().split(/\s+/) + const priority = data[0] + const weight = data[1] const port = data[2] - let responseDomain = data[3] - responseDomain = responseDomain.endsWith('.') ? responseDomain.slice(0, -1) : responseDomain + const responseDomain = data[3] + if ( + priority === undefined || + weight === undefined || + port === undefined || + responseDomain === undefined || + data.length !== 4 + ) { + throw new PaymailServerResponseError( + `${domain} is not correctly configured: invalid SRV answer` + ) + } + this.validateUint16(priority, domain, 'priority') + this.validateUint16(weight, domain, 'weight') + const validatedPort = this.validatePort(port, domain) + const validatedDomain = this.validateDnsName(responseDomain, domain) - if (!dohResponse.AD && !this.domainsAreEqual(domain, responseDomain)) { - throw new PaymailServerResponseError(`${this.domainWithoutBsvAliasPrefix(aDomain)} is not correctly configured: insecure domain`) + if (!dohResponse.AD && !this.domainsAreEqual(domain, validatedDomain)) { + throw new PaymailServerResponseError(`${domain} is not correctly configured: insecure domain`) } return { - domain: responseDomain, - port: Number.parseInt(port) + domain: validatedDomain, + port: validatedPort } } - async queryBsvaliasDomain (aDomain) { - return await this.resolveSrv(`_bsvalias._tcp.${aDomain}`) + async queryBsvaliasDomain(aDomain: string): Promise { + return this.resolveSrv(`_bsvalias._tcp.${aDomain}`) } } diff --git a/packages/messaging/ts-paymail/src/paymailRouter/__tests/p2p-payment-destinations.test.ts b/packages/messaging/ts-paymail/src/paymailRouter/__tests/p2p-payment-destinations.test.ts index 2b9556436..ea8fecb91 100644 --- a/packages/messaging/ts-paymail/src/paymailRouter/__tests/p2p-payment-destinations.test.ts +++ b/packages/messaging/ts-paymail/src/paymailRouter/__tests/p2p-payment-destinations.test.ts @@ -1,25 +1,24 @@ import request from 'supertest' -import express from 'express' -import PaymailRouter from '../../../dist/cjs/src/paymailRouter/paymailRouter.js' -import P2pPaymentDestinationRoute from '../../../dist/cjs/src/paymailRouter/paymailRoutes/p2pPaymentDestinationRoute.js' -import PaymailClient from '../../../dist/cjs/src/paymailClient/paymailClient.js' +import express, { type Express } from 'express' +import PaymailRouter from '../paymailRouter.js' +import P2pPaymentDestinationRoute from '../paymailRoutes/p2pPaymentDestinationRoute.js' describe('#Paymail Server - P2P Payment Destinations', () => { - let app - let client: PaymailClient + let app: Express beforeAll(() => { app = express() const baseUrl = 'http://localhost:3000' - client = new PaymailClient(null, { dns: null }, null) const routes = [ new P2pPaymentDestinationRoute({ - domainLogicHandler: (_, body) => { + domainLogicHandler: (params, body) => { + void params + const { satoshis } = body as { satoshis: number } return { outputs: [ { script: '76a914f32281faa74e2ac037493f7a3cd317e8f5e9673688ac', - satoshis: body.satoshis + satoshis } ], reference: 'someref' @@ -36,7 +35,9 @@ describe('#Paymail Server - P2P Payment Destinations', () => { satoshis: 1000 }) expect(response.statusCode).toBe(200) - expect(response.body.outputs[0].script).toEqual('76a914f32281faa74e2ac037493f7a3cd317e8f5e9673688ac') + expect(response.body.outputs[0].script).toEqual( + '76a914f32281faa74e2ac037493f7a3cd317e8f5e9673688ac' + ) expect(response.body.outputs[0].satoshis).toEqual(1000) expect(response.body.reference).toEqual('someref') }) @@ -46,6 +47,14 @@ describe('#Paymail Server - P2P Payment Destinations', () => { BSV: 1 }) expect(response.statusCode).toBe(400) - expect(response.error.text).toEqual('Invalid body: "satoshis" is required') + expect(response.text).toEqual('Invalid body: "satoshis" is required') + }) + + it.each([0, -1, 1.5])('should reject invalid satoshi amount %s', async satoshis => { + const response = await request(app) + .post('/p2p-payment-destination/satoshi@bsv.org') + .send({ satoshis }) + + expect(response.statusCode).toBe(400) }) }) diff --git a/packages/messaging/ts-paymail/src/paymailRouter/__tests/paymailRoute.test.ts b/packages/messaging/ts-paymail/src/paymailRouter/__tests/paymailRoute.test.ts new file mode 100644 index 000000000..f888281f4 --- /dev/null +++ b/packages/messaging/ts-paymail/src/paymailRouter/__tests/paymailRoute.test.ts @@ -0,0 +1,159 @@ +import express, { type ErrorRequestHandler } from 'express' +import request from 'supertest' + +import Capability from '../../capability/capability.js' +import { PaymailBadRequestError } from '../../errors/index.js' +import PaymailRouter from '../paymailRouter.js' +import PaymailRoute from '../paymailRoutes/paymailRoute.js' + +function createRoute( + endpoint = '/generic/:paymail', + domainLogicHandler: ConstructorParameters< + typeof PaymailRoute + >[0]['domainLogicHandler'] = params => { + const { name, domain } = PaymailRoute.getNameAndDomain(params) + return { name, domain } + } +): PaymailRoute { + return new PaymailRoute({ + capability: new Capability({ + code: 'generic', + title: 'Generic Paymail capability' + }), + endpoint, + domainLogicHandler + }) +} + +describe('PaymailRoute', () => { + it('validates configured endpoints', () => { + expect(() => createRoute('')).toThrow('Invalid endpoint') + expect(() => createRoute('relative')).toThrow('Invalid endpoint') + }) + + it('parses Paymail handles and rejects malformed values', () => { + expect(PaymailRoute.getNameAndDomain({ paymail: 'alice@example.test' })).toEqual({ + name: 'alice', + domain: 'example.test' + }) + expect(() => PaymailRoute.getNameAndDomain({ paymail: 'invalid' })).toThrow( + PaymailBadRequestError + ) + expect(() => + PaymailRoute.getNameAndDomain({ paymail: 'alice@example.test@attacker.test' }) + ).toThrow(PaymailBadRequestError) + expect(() => PaymailRoute.getNameAndDomain({ paymail: 'alice@bad domain' })).toThrow( + PaymailBadRequestError + ) + expect(() => PaymailRoute.getNameAndDomain({ paymail: 'alice/../../@example.test' })).toThrow( + PaymailBadRequestError + ) + }) + + it('serves the base route contract and serializes its response', async () => { + const app = express() + app.use( + new PaymailRouter({ + baseUrl: 'https://example.test', + routes: [createRoute()] + }).getRouter() + ) + + const response = await request(app).get('/generic/alice@example.test') + + expect(response.statusCode).toBe(200) + expect(response.type).toBe('application/json') + expect(response.body).toEqual({ name: 'alice', domain: 'example.test' }) + }) + + it('uses the default error handler for bad requests and unexpected failures', async () => { + const app = express() + app.use( + new PaymailRouter({ + baseUrl: 'https://example.test', + routes: [ + createRoute('/missing/:other'), + createRoute('/error/:paymail', () => { + throw new Error('route failed') + }) + ] + }).getRouter() + ) + + const missing = await request(app).get('/missing/value') + const failure = await request(app).get('/error/alice@example.test') + + expect(missing.statusCode).toBe(400) + expect(missing.text).toBe('Paymail handle is required.') + expect(failure.statusCode).toBe(500) + expect(failure.text).toBe('Internal server error') + }) + + it('normalizes non-Error body validation failures into bad requests', async () => { + class InvalidBodyRoute extends PaymailRoute { + protected override async validateBody(): Promise { + throw 'invalid body' + } + } + + const route = new InvalidBodyRoute({ + capability: new Capability({ + code: 'invalid-body', + title: 'Invalid body test', + method: 'POST' + }), + endpoint: '/invalid-body/:paymail', + domainLogicHandler: () => ({ accepted: true }) + }) + const app = express() + app.use( + new PaymailRouter({ + baseUrl: 'https://example.test', + routes: [route] + }).getRouter() + ) + + const response = await request(app).post('/invalid-body/alice@example.test').send({}) + + expect(response.statusCode).toBe(400) + expect(response.text).toBe('invalid body') + }) + + it('rejects unsupported route methods defensively', () => { + const route = createRoute() + jest.spyOn(route, 'getMethod').mockReturnValue('PUT' as 'GET') + + expect( + () => + new PaymailRouter({ + baseUrl: 'https://example.test', + routes: [route] + }) + ).toThrow('Unsupported method: PUT') + }) + + it('allows applications to handle route errors before the safe fallback', async () => { + const customErrorHandler: ErrorRequestHandler = (error: unknown, _request, response, _next) => { + response.status(422).json({ + message: error instanceof Error ? error.message : 'unknown' + }) + } + const app = express() + app.use( + new PaymailRouter({ + baseUrl: 'https://example.test', + routes: [ + createRoute('/error/:paymail', () => { + throw 'non-error failure' + }) + ], + errorHandler: customErrorHandler + }).getRouter() + ) + + const response = await request(app).get('/error/alice@example.test') + + expect(response.statusCode).toBe(422) + expect(response.body).toEqual({ message: 'unknown' }) + }) +}) diff --git a/packages/messaging/ts-paymail/src/paymailRouter/__tests/pki.test.ts b/packages/messaging/ts-paymail/src/paymailRouter/__tests/pki.test.ts index 01e600771..7996d4a89 100644 --- a/packages/messaging/ts-paymail/src/paymailRouter/__tests/pki.test.ts +++ b/packages/messaging/ts-paymail/src/paymailRouter/__tests/pki.test.ts @@ -1,11 +1,12 @@ import request from 'supertest' -import express from 'express' -import PaymailRouter from '../../../dist/cjs/src/paymailRouter/paymailRouter.js' -import PublicKeyInfrastructureRoute from '../../../dist/cjs/src/paymailRouter/paymailRoutes/pki.js' +import express, { type Express } from 'express' +import PaymailRouter from '../paymailRouter.js' +import PublicKeyInfrastructureRoute from '../paymailRoutes/pki.js' +import VerifyPublicKeyOwnerRoute from '../paymailRoutes/verifyPublicKeyOwner.js' import { PrivateKey } from '@bsv/sdk' describe('#Paymail Server - PKI', () => { - let app + let app: Express const userIdentityKey = PrivateKey.fromRandom() beforeAll(() => { @@ -14,13 +15,23 @@ describe('#Paymail Server - PKI', () => { const routes = [ new PublicKeyInfrastructureRoute({ - domainLogicHandler: (params) => { + domainLogicHandler: params => { const { name, domain } = PublicKeyInfrastructureRoute.getNameAndDomain(params) return { handle: `${name}@${domain}`, pubkey: userIdentityKey.toPublicKey().toString() } } + }), + new VerifyPublicKeyOwnerRoute({ + domainLogicHandler: params => { + const { name, domain } = VerifyPublicKeyOwnerRoute.getNameAndDomain(params) + return { + handle: `${name}@${domain}`, + pubkey: params.pubkey, + match: params.pubkey === userIdentityKey.toPublicKey().toString() + } + } }) ] @@ -34,4 +45,17 @@ describe('#Paymail Server - PKI', () => { expect(response.body.handle).toEqual('satoshi@bsv.org') expect(response.body.pubkey).toEqual(userIdentityKey.toPublicKey().toString()) }) + + it('should verify whether a public key belongs to the Paymail user', async () => { + const publicKey = userIdentityKey.toPublicKey().toString() + const response = await request(app).get(`/verifypubkey/satoshi@bsv.org/${publicKey}`) + + expect(response.statusCode).toBe(200) + expect(response.body).toEqual({ + bsvalias: '1.0', + handle: 'satoshi@bsv.org', + pubkey: publicKey, + match: true + }) + }) }) diff --git a/packages/messaging/ts-paymail/src/paymailRouter/__tests/public-profile.test.ts b/packages/messaging/ts-paymail/src/paymailRouter/__tests/public-profile.test.ts index 66fc191e4..384d0d4a3 100644 --- a/packages/messaging/ts-paymail/src/paymailRouter/__tests/public-profile.test.ts +++ b/packages/messaging/ts-paymail/src/paymailRouter/__tests/public-profile.test.ts @@ -1,10 +1,10 @@ import request from 'supertest' -import express from 'express' -import PaymailRouter from '../../../dist/cjs/src/paymailRouter/paymailRouter.js' -import PublicProfileRoute from '../../../dist/cjs/src/paymailRouter/paymailRoutes/publicProfileRoute.js' +import express, { type Express } from 'express' +import PaymailRouter from '../paymailRouter.js' +import PublicProfileRoute from '../paymailRoutes/publicProfileRoute.js' describe('#Paymail Server - Get Public Profile', () => { - let app + let app: Express beforeAll(() => { app = express() @@ -12,7 +12,7 @@ describe('#Paymail Server - Get Public Profile', () => { const routes = [ new PublicProfileRoute({ - domainLogicHandler: (params) => { + domainLogicHandler: params => { const { name, domain } = PublicProfileRoute.getNameAndDomain(params) return { name, diff --git a/packages/messaging/ts-paymail/src/paymailRouter/__tests/receive-beef-transaction.test.ts b/packages/messaging/ts-paymail/src/paymailRouter/__tests/receive-beef-transaction.test.ts index dde4cc540..8d5a1902f 100644 --- a/packages/messaging/ts-paymail/src/paymailRouter/__tests/receive-beef-transaction.test.ts +++ b/packages/messaging/ts-paymail/src/paymailRouter/__tests/receive-beef-transaction.test.ts @@ -1,27 +1,28 @@ import request from 'supertest' -import express from 'express' -import PaymailRouter from '../../../dist/cjs/src/paymailRouter/paymailRouter.js' -import ReceiveBeefTransactionRoute from '../../../dist/cjs/src/paymailRouter/paymailRoutes/receiveBeefTransaction.js' -import PaymailClient from '../../../dist/cjs/src/paymailClient/paymailClient' -import { PrivateKey, Transaction } from '@bsv/sdk' +import express, { type Express } from 'express' +import PaymailRouter from '../paymailRouter.js' +import ReceiveBeefTransactionRoute from '../paymailRoutes/receiveBeefTransaction.js' +import PaymailClient from '../../paymailClient/paymailClient.js' +import { ECDSA, PrivateKey, Transaction } from '@bsv/sdk' describe('#Paymail Server - P2P Receive Beef Transaction', () => { - let app - let paymailClient + let app: Express + let paymailClient: PaymailClient + const domainLogicHandler = jest.fn((_params: unknown, _body?: unknown) => { + return { + txid: '5878f6efcb1aa3be389510ae2ff10d0368976bf867e8442b751908f19024f8dd' + } + }) beforeAll(() => { app = express() const baseUrl = 'http://localhost' const basePath = '/paymail' - paymailClient = new PaymailClient(null, { dns: null }, null) + paymailClient = new PaymailClient() const routes = [ new ReceiveBeefTransactionRoute({ - domainLogicHandler: () => { - return { - txid: '5878f6efcb1aa3be389510ae2ff10d0368976bf867e8442b751908f19024f8dd' - } - }, + domainLogicHandler, verifySignature: true, paymailClient }) @@ -32,26 +33,72 @@ describe('#Paymail Server - P2P Receive Beef Transaction', () => { }) it('should reject with a 400 with invalid Beef transaction', async () => { - const response = await request(app).post('/paymail/receive-beef-transaction/satoshi@bsv.org').send({ - beef: '01000000012adda020db81f2155ebba69e7c841275517ebf91674268c32ff2f5c7e2853b2c010000006b483045022100872051ef0b6c47714130c12a067db4f38b988bfc22fe270731c2146f5229386b02207abf68bbf092ec03e2c616defcc4c868ad1fc3cdbffb34bcedfab391a1274f3e412102affe8c91d0a61235a3d07b1903476a2e2f7a90451b2ed592fea9937696a07077ffffffff02ed1a0000000000001976a91491b3753cf827f139d2dc654ce36f05331138ddb588acc9670300000000001976a914da036233873cc6489ff65a0185e207d243b5154888ac00000000', - metadata: { - sender: 'halfinny@vistamail.org', - pubkey: '02d362a22ddc1a299122455690bec54397ae0b1b2765e4f1687cb479271c050a40', - signature: 'signature(txid)', - note: 'gm.' - }, - reference: 'someRefId' - }) + const response = await request(app) + .post('/paymail/receive-beef-transaction/satoshi@bsv.org') + .send({ + beef: '01000000012adda020db81f2155ebba69e7c841275517ebf91674268c32ff2f5c7e2853b2c010000006b483045022100872051ef0b6c47714130c12a067db4f38b988bfc22fe270731c2146f5229386b02207abf68bbf092ec03e2c616defcc4c868ad1fc3cdbffb34bcedfab391a1274f3e412102affe8c91d0a61235a3d07b1903476a2e2f7a90451b2ed592fea9937696a07077ffffffff02ed1a0000000000001976a91491b3753cf827f139d2dc654ce36f05331138ddb588acc9670300000000001976a914da036233873cc6489ff65a0185e207d243b5154888ac00000000', + metadata: { + sender: 'halfinny@vistamail.org', + pubkey: '02d362a22ddc1a299122455690bec54397ae0b1b2765e4f1687cb479271c050a40', + signature: 'signature(txid)', + note: 'gm.' + }, + reference: 'someRefId' + }) expect(response.statusCode).toBe(400) - expect(response.error.text).toEqual('Invalid body: Serialized BEEF must start with 4022206465 or 4022206466 but starts with 1') + expect(response.text).toEqual( + 'Invalid body: Serialized BEEF must start with 4022206465 or 4022206466 but starts with 1' + ) }) - it.skip('should receive beef transaction', async () => { + it('should receive beef transaction', async () => { + domainLogicHandler.mockClear() const privateKey = PrivateKey.fromRandom() - const tx = Transaction.fromHexBEEF('0100beef01fe636d0c0007021400fe507c0c7aa754cef1f7889d5fd395cf1f785dd7de98eed895dbedfe4e5bc70d1502ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e010b00bc4ff395efd11719b277694cface5aa50d085a0bb81f613f70313acd28cf4557010400574b2d9142b8d28b61d88e3b2c3f44d858411356b49a28a4643b6d1a6a092a5201030051a05fc84d531b5d250c23f4f886f6812f9fe3f402d61607f977b4ecd2701c19010000fd781529d58fc2523cf396a7f25440b409857e7e221766c57214b1d38c7b481f01010062f542f45ea3660f86c013ced80534cb5fd4c19d66c56e7e8c5d4bf2d40acc5e010100b121e91836fd7cd5102b654e9f72f3cf6fdbfd0b161c53a9c54b12c841126331020100000001cd4e4cac3c7b56920d1e7655e7e260d31f29d9a388d04910f1bbd72304a79029010000006b483045022100e75279a205a547c445719420aa3138bf14743e3f42618e5f86a19bde14bb95f7022064777d34776b05d816daf1699493fcdf2ef5a5ab1ad710d9c97bfb5b8f7cef3641210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013e660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000001000100000001ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e000000006a47304402203a61a2e931612b4bda08d541cfb980885173b8dcf64a3471238ae7abcd368d6402204cbf24f04b9aa2256d8901f0ed97866603d2be8324c2bfb7a37bf8fc90edd5b441210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013c660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000000') - paymailClient.verifyPublicKey = jest.fn().mockResolvedValue({ match: true }) + const tx = Transaction.fromHexBEEF( + '0100beef01fe636d0c0007021400fe507c0c7aa754cef1f7889d5fd395cf1f785dd7de98eed895dbedfe4e5bc70d1502ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e010b00bc4ff395efd11719b277694cface5aa50d085a0bb81f613f70313acd28cf4557010400574b2d9142b8d28b61d88e3b2c3f44d858411356b49a28a4643b6d1a6a092a5201030051a05fc84d531b5d250c23f4f886f6812f9fe3f402d61607f977b4ecd2701c19010000fd781529d58fc2523cf396a7f25440b409857e7e221766c57214b1d38c7b481f01010062f542f45ea3660f86c013ced80534cb5fd4c19d66c56e7e8c5d4bf2d40acc5e010100b121e91836fd7cd5102b654e9f72f3cf6fdbfd0b161c53a9c54b12c841126331020100000001cd4e4cac3c7b56920d1e7655e7e260d31f29d9a388d04910f1bbd72304a79029010000006b483045022100e75279a205a547c445719420aa3138bf14743e3f42618e5f86a19bde14bb95f7022064777d34776b05d816daf1699493fcdf2ef5a5ab1ad710d9c97bfb5b8f7cef3641210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013e660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000001000100000001ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e000000006a47304402203a61a2e931612b4bda08d541cfb980885173b8dcf64a3471238ae7abcd368d6402204cbf24f04b9aa2256d8901f0ed97866603d2be8324c2bfb7a37bf8fc90edd5b441210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013c660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000000' + ) + jest.spyOn(paymailClient, 'verifyPublicKey').mockResolvedValue({ + handle: 'halfinny@vistamail.org', + pubkey: privateKey.toPublicKey().toString(), + match: true + }) const signature = paymailClient.createP2PSignature(tx.id('hex'), privateKey) - const response = await request(app).post('/paymail/receive-beef-transaction/satoshi@bsv.org').send({ + const ecdsaVerify = jest.spyOn(ECDSA, 'verify').mockReturnValue(false) + const invalidSignatureResponse = await request(app) + .post('/paymail/receive-beef-transaction/satoshi@bsv.org') + .send({ + beef: tx.toHexBEEF(), + metadata: { + sender: 'halfinny@vistamail.org', + pubkey: privateKey.toPublicKey().toString(), + signature + }, + reference: 'someRefId' + }) + ecdsaVerify.mockRestore() + + expect(invalidSignatureResponse.statusCode).toBe(400) + expect(invalidSignatureResponse.text).toEqual('Invalid Signature') + + const response = await request(app) + .post('/paymail/receive-beef-transaction/satoshi@bsv.org') + .send({ + beef: tx.toHexBEEF(), + metadata: { + sender: 'halfinny@vistamail.org', + pubkey: privateKey.toPublicKey().toString(), + signature, + note: 'gm.', + unexpected: 'drop me' + }, + reference: 'someRefId', + unexpected: 'drop me' + }) + expect(response.statusCode).toBe(200) + expect(response.body.txid).toEqual( + '5878f6efcb1aa3be389510ae2ff10d0368976bf867e8442b751908f19024f8dd' + ) + expect(domainLogicHandler.mock.calls[0]?.[1]).toEqual({ beef: tx.toHexBEEF(), metadata: { sender: 'halfinny@vistamail.org', @@ -61,43 +108,104 @@ describe('#Paymail Server - P2P Receive Beef Transaction', () => { }, reference: 'someRefId' }) - expect(response.statusCode).toBe(200) - expect(response.body.txid).toEqual('5878f6efcb1aa3be389510ae2ff10d0368976bf867e8442b751908f19024f8dd') + + const unsignedApp = express() + const unsignedRoute = new ReceiveBeefTransactionRoute({ + domainLogicHandler: () => ({ txid: tx.id('hex') }), + verifySignature: false, + paymailClient + }) + unsignedApp.use( + new PaymailRouter({ + baseUrl: 'http://localhost', + basePath: '/paymail', + routes: [unsignedRoute] + }).getRouter() + ) + const unsignedResponse = await request(unsignedApp) + .post('/paymail/receive-beef-transaction/satoshi@bsv.org') + .send({ beef: tx.toHexBEEF(), reference: 'someRefId' }) + expect(unsignedResponse.statusCode).toBe(200) + expect(unsignedResponse.body.txid).toBe(tx.id('hex')) }) it('should reject with invalid signature', async () => { - paymailClient.verifyPublicKey = jest.fn().mockResolvedValue({ match: true }) const privateKey = PrivateKey.fromRandom() - const tx = Transaction.fromHexBEEF('0100beef01fe636d0c0007021400fe507c0c7aa754cef1f7889d5fd395cf1f785dd7de98eed895dbedfe4e5bc70d1502ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e010b00bc4ff395efd11719b277694cface5aa50d085a0bb81f613f70313acd28cf4557010400574b2d9142b8d28b61d88e3b2c3f44d858411356b49a28a4643b6d1a6a092a5201030051a05fc84d531b5d250c23f4f886f6812f9fe3f402d61607f977b4ecd2701c19010000fd781529d58fc2523cf396a7f25440b409857e7e221766c57214b1d38c7b481f01010062f542f45ea3660f86c013ced80534cb5fd4c19d66c56e7e8c5d4bf2d40acc5e010100b121e91836fd7cd5102b654e9f72f3cf6fdbfd0b161c53a9c54b12c841126331020100000001cd4e4cac3c7b56920d1e7655e7e260d31f29d9a388d04910f1bbd72304a79029010000006b483045022100e75279a205a547c445719420aa3138bf14743e3f42618e5f86a19bde14bb95f7022064777d34776b05d816daf1699493fcdf2ef5a5ab1ad710d9c97bfb5b8f7cef3641210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013e660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000001000100000001ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e000000006a47304402203a61a2e931612b4bda08d541cfb980885173b8dcf64a3471238ae7abcd368d6402204cbf24f04b9aa2256d8901f0ed97866603d2be8324c2bfb7a37bf8fc90edd5b441210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013c660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000000') - const response = await request(app).post('/paymail/receive-beef-transaction/satoshi@bsv.org').send({ - beef: tx.toHexBEEF(), - metadata: { - sender: 'halfinny@vistamail.org', - pubkey: privateKey.toPublicKey().toString(), - signature: 'cafebabe', - note: 'gm.' - }, - reference: 'someRefId' + const verifyPublicKey = jest.spyOn(paymailClient, 'verifyPublicKey').mockResolvedValue({ + handle: 'halfinny@vistamail.org', + pubkey: privateKey.toPublicKey().toString(), + match: true }) + verifyPublicKey.mockClear() + const tx = Transaction.fromHexBEEF( + '0100beef01fe636d0c0007021400fe507c0c7aa754cef1f7889d5fd395cf1f785dd7de98eed895dbedfe4e5bc70d1502ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e010b00bc4ff395efd11719b277694cface5aa50d085a0bb81f613f70313acd28cf4557010400574b2d9142b8d28b61d88e3b2c3f44d858411356b49a28a4643b6d1a6a092a5201030051a05fc84d531b5d250c23f4f886f6812f9fe3f402d61607f977b4ecd2701c19010000fd781529d58fc2523cf396a7f25440b409857e7e221766c57214b1d38c7b481f01010062f542f45ea3660f86c013ced80534cb5fd4c19d66c56e7e8c5d4bf2d40acc5e010100b121e91836fd7cd5102b654e9f72f3cf6fdbfd0b161c53a9c54b12c841126331020100000001cd4e4cac3c7b56920d1e7655e7e260d31f29d9a388d04910f1bbd72304a79029010000006b483045022100e75279a205a547c445719420aa3138bf14743e3f42618e5f86a19bde14bb95f7022064777d34776b05d816daf1699493fcdf2ef5a5ab1ad710d9c97bfb5b8f7cef3641210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013e660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000001000100000001ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e000000006a47304402203a61a2e931612b4bda08d541cfb980885173b8dcf64a3471238ae7abcd368d6402204cbf24f04b9aa2256d8901f0ed97866603d2be8324c2bfb7a37bf8fc90edd5b441210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013c660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000000' + ) + const response = await request(app) + .post('/paymail/receive-beef-transaction/satoshi@bsv.org') + .send({ + beef: tx.toHexBEEF(), + metadata: { + sender: 'halfinny@vistamail.org', + pubkey: privateKey.toPublicKey().toString(), + signature: 'cafebabe', + note: 'gm.' + }, + reference: 'someRefId' + }) expect(response.statusCode).toBe(400) - expect(response.error.text).toEqual('Signature DER must start with 0x30') + expect(response.text).toEqual('Invalid Compact Signature') + expect(verifyPublicKey).not.toHaveBeenCalled() + + const otherPrivateKey = PrivateKey.fromRandom() + const mismatchResponse = await request(app) + .post('/paymail/receive-beef-transaction/satoshi@bsv.org') + .send({ + beef: tx.toHexBEEF(), + metadata: { + sender: 'halfinny@vistamail.org', + pubkey: otherPrivateKey.toPublicKey().toString(), + signature: paymailClient.createP2PSignature(tx.id('hex'), privateKey) + }, + reference: 'someRefId' + }) + expect(mismatchResponse.statusCode).toBe(400) + expect(mismatchResponse.text).toEqual('PubKey does not match signature') + expect(verifyPublicKey).not.toHaveBeenCalled() }) it('should reject with invalid public key', async () => { - paymailClient.verifyPublicKey = jest.fn().mockResolvedValue({ match: false }) const privateKey = PrivateKey.fromRandom() - const tx = Transaction.fromHexBEEF('0100beef01fe636d0c0007021400fe507c0c7aa754cef1f7889d5fd395cf1f785dd7de98eed895dbedfe4e5bc70d1502ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e010b00bc4ff395efd11719b277694cface5aa50d085a0bb81f613f70313acd28cf4557010400574b2d9142b8d28b61d88e3b2c3f44d858411356b49a28a4643b6d1a6a092a5201030051a05fc84d531b5d250c23f4f886f6812f9fe3f402d61607f977b4ecd2701c19010000fd781529d58fc2523cf396a7f25440b409857e7e221766c57214b1d38c7b481f01010062f542f45ea3660f86c013ced80534cb5fd4c19d66c56e7e8c5d4bf2d40acc5e010100b121e91836fd7cd5102b654e9f72f3cf6fdbfd0b161c53a9c54b12c841126331020100000001cd4e4cac3c7b56920d1e7655e7e260d31f29d9a388d04910f1bbd72304a79029010000006b483045022100e75279a205a547c445719420aa3138bf14743e3f42618e5f86a19bde14bb95f7022064777d34776b05d816daf1699493fcdf2ef5a5ab1ad710d9c97bfb5b8f7cef3641210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013e660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000001000100000001ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e000000006a47304402203a61a2e931612b4bda08d541cfb980885173b8dcf64a3471238ae7abcd368d6402204cbf24f04b9aa2256d8901f0ed97866603d2be8324c2bfb7a37bf8fc90edd5b441210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013c660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000000') - const response = await request(app).post('/paymail/receive-beef-transaction/satoshi@bsv.org').send({ - beef: tx.toHexBEEF(), - metadata: { - sender: 'halfinny@vistamail.org', - pubkey: privateKey.toPublicKey().toString(), - signature: 'invalid signature', - note: 'gm.' - }, - reference: 'someRefId' + jest.spyOn(paymailClient, 'verifyPublicKey').mockResolvedValue({ + handle: 'halfinny@vistamail.org', + pubkey: privateKey.toPublicKey().toString(), + match: false }) + const tx = Transaction.fromHexBEEF( + '0100beef01fe636d0c0007021400fe507c0c7aa754cef1f7889d5fd395cf1f785dd7de98eed895dbedfe4e5bc70d1502ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e010b00bc4ff395efd11719b277694cface5aa50d085a0bb81f613f70313acd28cf4557010400574b2d9142b8d28b61d88e3b2c3f44d858411356b49a28a4643b6d1a6a092a5201030051a05fc84d531b5d250c23f4f886f6812f9fe3f402d61607f977b4ecd2701c19010000fd781529d58fc2523cf396a7f25440b409857e7e221766c57214b1d38c7b481f01010062f542f45ea3660f86c013ced80534cb5fd4c19d66c56e7e8c5d4bf2d40acc5e010100b121e91836fd7cd5102b654e9f72f3cf6fdbfd0b161c53a9c54b12c841126331020100000001cd4e4cac3c7b56920d1e7655e7e260d31f29d9a388d04910f1bbd72304a79029010000006b483045022100e75279a205a547c445719420aa3138bf14743e3f42618e5f86a19bde14bb95f7022064777d34776b05d816daf1699493fcdf2ef5a5ab1ad710d9c97bfb5b8f7cef3641210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013e660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000001000100000001ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e000000006a47304402203a61a2e931612b4bda08d541cfb980885173b8dcf64a3471238ae7abcd368d6402204cbf24f04b9aa2256d8901f0ed97866603d2be8324c2bfb7a37bf8fc90edd5b441210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013c660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000000' + ) + const signature = paymailClient.createP2PSignature(tx.id('hex'), privateKey) + const response = await request(app) + .post('/paymail/receive-beef-transaction/satoshi@bsv.org') + .send({ + beef: tx.toHexBEEF(), + metadata: { + sender: 'halfinny@vistamail.org', + pubkey: privateKey.toPublicKey().toString(), + signature, + note: 'gm.' + }, + reference: 'someRefId' + }) + expect(response.statusCode).toBe(400) + expect(response.text).toEqual('Invalid Public Key for sender') + }) + + it('validates request shape before BEEF transaction processing', async () => { + const response = await request(app) + .post('/paymail/receive-beef-transaction/satoshi@bsv.org') + .send({ reference: 'someRefId' }) + expect(response.statusCode).toBe(400) - expect(response.error.text).toEqual('Invalid Public Key for sender') + expect(response.text).toContain('"beef" is required') }) }) diff --git a/packages/messaging/ts-paymail/src/paymailRouter/__tests/receive-ordinal-transaction.test.ts b/packages/messaging/ts-paymail/src/paymailRouter/__tests/receive-ordinal-transaction.test.ts new file mode 100644 index 000000000..94ff36b4f --- /dev/null +++ b/packages/messaging/ts-paymail/src/paymailRouter/__tests/receive-ordinal-transaction.test.ts @@ -0,0 +1,179 @@ +import { ECDSA, PrivateKey, Transaction } from '@bsv/sdk' +import express, { type Express } from 'express' +import request from 'supertest' + +import PaymailClient from '../../paymailClient/paymailClient.js' +import PaymailRouter from '../paymailRouter.js' +import SimpleP2pOrdinalReceiveRoute from '../paymailRoutes/receiveOrdinalTransaction.js' + +const RAW_TRANSACTION = + '01000000012adda020db81f2155ebba69e7c841275517ebf91674268c32ff2f5c7e2853b2c010000006b483045022100872051ef0b6c47714130c12a067db4f38b988bfc22fe270731c2146f5229386b02207abf68bbf092ec03e2c616defcc4c868ad1fc3cdbffb34bcedfab391a1274f3e412102affe8c91d0a61235a3d07b1903476a2e2f7a90451b2ed592fea9937696a07077ffffffff02ed1a0000000000001976a91491b3753cf827f139d2dc654ce36f05331138ddb588acc9670300000000001976a914da036233873cc6489ff65a0185e207d243b5154888ac00000000' + +describe('#Paymail Server - Simple Ordinal P2P Receive Transaction', () => { + let app: Express + let paymailClient: PaymailClient + + beforeEach(() => { + app = express() + paymailClient = new PaymailClient() + const route = new SimpleP2pOrdinalReceiveRoute({ + domainLogicHandler: () => ({ txid: 'accepted-txid', note: 'accepted' }), + verifySignature: true, + paymailClient + }) + app.use(new PaymailRouter({ baseUrl: 'http://localhost:3000', routes: [route] }).getRouter()) + }) + + it('accepts a correctly signed ordinal transaction at the protocol endpoint', async () => { + const privateKey = PrivateKey.fromRandom() + const transaction = Transaction.fromHex(RAW_TRANSACTION) + jest.spyOn(paymailClient, 'verifyPublicKey').mockResolvedValue({ + handle: 'halfinny@vistamail.org', + pubkey: privateKey.toPublicKey().toString(), + match: true + }) + const signature = paymailClient.createP2PSignature(transaction.id('hex'), privateKey) + const ecdsaVerify = jest.spyOn(ECDSA, 'verify').mockReturnValue(false) + const invalidSignatureResponse = await request(app) + .post('/receive-ordinal-tx/satoshi@bsv.org') + .send({ + hex: transaction.toHex(), + metadata: { + sender: 'halfinny@vistamail.org', + pubkey: privateKey.toPublicKey().toString(), + signature + }, + reference: 'ordinal-reference' + }) + ecdsaVerify.mockRestore() + + expect(invalidSignatureResponse.statusCode).toBe(400) + expect(invalidSignatureResponse.text).toBe('Invalid Signature') + + const response = await request(app) + .post('/receive-ordinal-tx/satoshi@bsv.org') + .send({ + hex: transaction.toHex(), + metadata: { + sender: 'halfinny@vistamail.org', + pubkey: privateKey.toPublicKey().toString(), + signature, + note: 'ordinal' + }, + reference: 'ordinal-reference' + }) + + expect(response.statusCode).toBe(200) + expect(response.body).toEqual({ txid: 'accepted-txid', note: 'accepted' }) + }) + + it('rejects malformed transactions before signature or ownership work', async () => { + const verifyPublicKey = jest.spyOn(paymailClient, 'verifyPublicKey') + const response = await request(app) + .post('/receive-ordinal-tx/satoshi@bsv.org') + .send({ + hex: 'not-a-transaction', + metadata: { + sender: 'halfinny@vistamail.org', + pubkey: '02abc', + signature: 'invalid' + }, + reference: 'ordinal-reference' + }) + + expect(response.statusCode).toBe(400) + expect(response.text).toContain('Invalid body') + expect(verifyPublicKey).not.toHaveBeenCalled() + }) + + it('rejects malformed signatures before an outbound ownership lookup', async () => { + const privateKey = PrivateKey.fromRandom() + const transaction = Transaction.fromHex(RAW_TRANSACTION) + const verifyPublicKey = jest.spyOn(paymailClient, 'verifyPublicKey') + const response = await request(app) + .post('/receive-ordinal-tx/satoshi@bsv.org') + .send({ + hex: transaction.toHex(), + metadata: { + sender: 'halfinny@vistamail.org', + pubkey: privateKey.toPublicKey().toString(), + signature: 'invalid' + }, + reference: 'ordinal-reference' + }) + + expect(response.statusCode).toBe(400) + expect(response.text).toBe('Invalid Compact Signature') + expect(verifyPublicKey).not.toHaveBeenCalled() + + const otherPrivateKey = PrivateKey.fromRandom() + const mismatchResponse = await request(app) + .post('/receive-ordinal-tx/satoshi@bsv.org') + .send({ + hex: transaction.toHex(), + metadata: { + sender: 'halfinny@vistamail.org', + pubkey: otherPrivateKey.toPublicKey().toString(), + signature: paymailClient.createP2PSignature(transaction.id('hex'), privateKey) + }, + reference: 'ordinal-reference' + }) + expect(mismatchResponse.statusCode).toBe(400) + expect(mismatchResponse.text).toBe('PubKey does not match signature') + expect(verifyPublicKey).not.toHaveBeenCalled() + }) + + it('rejects a valid signature when Paymail ownership does not match', async () => { + const privateKey = PrivateKey.fromRandom() + const transaction = Transaction.fromHex(RAW_TRANSACTION) + jest.spyOn(paymailClient, 'verifyPublicKey').mockResolvedValue({ + handle: 'halfinny@vistamail.org', + pubkey: privateKey.toPublicKey().toString(), + match: false + }) + const response = await request(app) + .post('/receive-ordinal-tx/satoshi@bsv.org') + .send({ + hex: transaction.toHex(), + metadata: { + sender: 'halfinny@vistamail.org', + pubkey: privateKey.toPublicKey().toString(), + signature: paymailClient.createP2PSignature(transaction.id('hex'), privateKey) + }, + reference: 'ordinal-reference' + }) + + expect(response.statusCode).toBe(400) + expect(response.text).toBe('Invalid Public Key for sender') + }) + + it('supports an explicit endpoint with signature verification disabled', async () => { + const unsignedApp = express() + const route = new SimpleP2pOrdinalReceiveRoute({ + endpoint: '/custom-ordinal/:paymail', + domainLogicHandler: () => ({ txid: 'unsigned-txid' }), + verifySignature: false, + paymailClient + }) + unsignedApp.use( + new PaymailRouter({ baseUrl: 'http://localhost:3000', routes: [route] }).getRouter() + ) + + const response = await request(unsignedApp).post('/custom-ordinal/satoshi@bsv.org').send({ + hex: RAW_TRANSACTION, + reference: 'ordinal-reference' + }) + + expect(response.statusCode).toBe(200) + expect(response.body).toEqual({ txid: 'unsigned-txid', note: '' }) + }) + + it('validates request shape before ordinal transaction processing', async () => { + const response = await request(app) + .post('/receive-ordinal-tx/satoshi@bsv.org') + .send({ reference: 'ordinal-reference' }) + + expect(response.statusCode).toBe(400) + expect(response.text).toContain('"hex" is required') + }) +}) diff --git a/packages/messaging/ts-paymail/src/paymailRouter/__tests/receive-transaction.test.ts b/packages/messaging/ts-paymail/src/paymailRouter/__tests/receive-transaction.test.ts index 9a1d9f1e8..024b29c51 100644 --- a/packages/messaging/ts-paymail/src/paymailRouter/__tests/receive-transaction.test.ts +++ b/packages/messaging/ts-paymail/src/paymailRouter/__tests/receive-transaction.test.ts @@ -1,18 +1,18 @@ import request from 'supertest' -import express from 'express' -import PaymailRouter from '../../../dist/cjs/src/paymailRouter/paymailRouter.js' -import ReceiveTransactionRoute from '../../../dist/cjs/src/paymailRouter/paymailRoutes/receiveTransaction.js' -import PaymailClient from '../../../dist/cjs/src/paymailClient/paymailClient' -import { PrivateKey, Transaction, BigNumber, Utils, Signature, Hash } from '@bsv/sdk' +import express, { type Express } from 'express' +import PaymailRouter from '../paymailRouter.js' +import ReceiveTransactionRoute from '../paymailRoutes/receiveTransaction.js' +import PaymailClient from '../../paymailClient/paymailClient.js' +import { ECDSA, PrivateKey, Transaction } from '@bsv/sdk' describe('#Paymail Server - P2P Receive Transaction', () => { - let app - let paymailClient + let app: Express + let paymailClient: PaymailClient beforeAll(() => { app = express() const baseUrl = 'http://localhost:3000' - paymailClient = new PaymailClient(null, { dns: null }, null) + paymailClient = new PaymailClient() const routes = [ new ReceiveTransactionRoute({ @@ -31,56 +31,171 @@ describe('#Paymail Server - P2P Receive Transaction', () => { }) it('should receive transaction', async () => { const privateKey = PrivateKey.fromRandom() - const tx = Transaction.fromHex('01000000012adda020db81f2155ebba69e7c841275517ebf91674268c32ff2f5c7e2853b2c010000006b483045022100872051ef0b6c47714130c12a067db4f38b988bfc22fe270731c2146f5229386b02207abf68bbf092ec03e2c616defcc4c868ad1fc3cdbffb34bcedfab391a1274f3e412102affe8c91d0a61235a3d07b1903476a2e2f7a90451b2ed592fea9937696a07077ffffffff02ed1a0000000000001976a91491b3753cf827f139d2dc654ce36f05331138ddb588acc9670300000000001976a914da036233873cc6489ff65a0185e207d243b5154888ac00000000') - paymailClient.verifyPublicKey = jest.fn().mockResolvedValue({ match: true }) - const signature = paymailClient.createP2PSignature(tx.id('hex'), privateKey) - const response = await request(app).post('/receive-transaction/satoshi@bsv.org').send({ - hex: tx.toHex(), - metadata: { - sender: 'halfinny@vistamail.org', - pubkey: privateKey.toPublicKey().toString(), - signature, - note: 'gm.' - }, - reference: 'someRefId' + const tx = Transaction.fromHex( + '01000000012adda020db81f2155ebba69e7c841275517ebf91674268c32ff2f5c7e2853b2c010000006b483045022100872051ef0b6c47714130c12a067db4f38b988bfc22fe270731c2146f5229386b02207abf68bbf092ec03e2c616defcc4c868ad1fc3cdbffb34bcedfab391a1274f3e412102affe8c91d0a61235a3d07b1903476a2e2f7a90451b2ed592fea9937696a07077ffffffff02ed1a0000000000001976a91491b3753cf827f139d2dc654ce36f05331138ddb588acc9670300000000001976a914da036233873cc6489ff65a0185e207d243b5154888ac00000000' + ) + jest.spyOn(paymailClient, 'verifyPublicKey').mockResolvedValue({ + handle: 'halfinny@vistamail.org', + pubkey: privateKey.toPublicKey().toString(), + match: true }) + const signature = paymailClient.createP2PSignature(tx.id('hex'), privateKey) + const ecdsaVerify = jest.spyOn(ECDSA, 'verify').mockReturnValue(false) + const invalidSignatureResponse = await request(app) + .post('/receive-transaction/satoshi@bsv.org') + .send({ + hex: tx.toHex(), + metadata: { + sender: 'halfinny@vistamail.org', + pubkey: privateKey.toPublicKey().toString(), + signature + }, + reference: 'someRefId' + }) + ecdsaVerify.mockRestore() + + expect(invalidSignatureResponse.statusCode).toBe(400) + expect(invalidSignatureResponse.text).toEqual('Invalid Signature') + + const response = await request(app) + .post('/receive-transaction/satoshi@bsv.org') + .send({ + hex: tx.toHex(), + metadata: { + sender: 'halfinny@vistamail.org', + pubkey: privateKey.toPublicKey().toString(), + signature, + note: 'gm.' + }, + reference: 'someRefId' + }) expect(response.statusCode).toBe(200) - expect(response.body.txid).toEqual('5878f6efcb1aa3be389510ae2ff10d0368976bf867e8442b751908f19024f8dd') + expect(response.body.txid).toEqual( + '5878f6efcb1aa3be389510ae2ff10d0368976bf867e8442b751908f19024f8dd' + ) }) it('should reject with invalid signature', async () => { - paymailClient.verifyPublicKey = jest.fn().mockResolvedValue({ match: true }) const privateKey = PrivateKey.fromRandom() - const tx = Transaction.fromHex('01000000012adda020db81f2155ebba69e7c841275517ebf91674268c32ff2f5c7e2853b2c010000006b483045022100872051ef0b6c47714130c12a067db4f38b988bfc22fe270731c2146f5229386b02207abf68bbf092ec03e2c616defcc4c868ad1fc3cdbffb34bcedfab391a1274f3e412102affe8c91d0a61235a3d07b1903476a2e2f7a90451b2ed592fea9937696a07077ffffffff02ed1a0000000000001976a91491b3753cf827f139d2dc654ce36f05331138ddb588acc9670300000000001976a914da036233873cc6489ff65a0185e207d243b5154888ac00000000') - const response = await request(app).post('/receive-transaction/satoshi@bsv.org').send({ - hex: tx.toHex(), - metadata: { - sender: 'halfinny@vistamail.org', - pubkey: privateKey.toPublicKey().toString(), - signature: 'invalid signature', - note: 'gm.' - }, - reference: 'someRefId' + const verifyPublicKey = jest.spyOn(paymailClient, 'verifyPublicKey').mockResolvedValue({ + handle: 'halfinny@vistamail.org', + pubkey: privateKey.toPublicKey().toString(), + match: true }) + verifyPublicKey.mockClear() + const tx = Transaction.fromHex( + '01000000012adda020db81f2155ebba69e7c841275517ebf91674268c32ff2f5c7e2853b2c010000006b483045022100872051ef0b6c47714130c12a067db4f38b988bfc22fe270731c2146f5229386b02207abf68bbf092ec03e2c616defcc4c868ad1fc3cdbffb34bcedfab391a1274f3e412102affe8c91d0a61235a3d07b1903476a2e2f7a90451b2ed592fea9937696a07077ffffffff02ed1a0000000000001976a91491b3753cf827f139d2dc654ce36f05331138ddb588acc9670300000000001976a914da036233873cc6489ff65a0185e207d243b5154888ac00000000' + ) + const response = await request(app) + .post('/receive-transaction/satoshi@bsv.org') + .send({ + hex: tx.toHex(), + metadata: { + sender: 'halfinny@vistamail.org', + pubkey: privateKey.toPublicKey().toString(), + signature: 'invalid signature', + note: 'gm.' + }, + reference: 'someRefId' + }) expect(response.statusCode).toBe(400) - expect(response.error.text).toEqual('Invalid Compact Signature') + expect(response.text).toEqual('Invalid Compact Signature') + expect(verifyPublicKey).not.toHaveBeenCalled() + + const otherPrivateKey = PrivateKey.fromRandom() + const mismatchResponse = await request(app) + .post('/receive-transaction/satoshi@bsv.org') + .send({ + hex: tx.toHex(), + metadata: { + sender: 'halfinny@vistamail.org', + pubkey: otherPrivateKey.toPublicKey().toString(), + signature: paymailClient.createP2PSignature(tx.id('hex'), privateKey) + }, + reference: 'someRefId' + }) + expect(mismatchResponse.statusCode).toBe(400) + expect(mismatchResponse.text).toEqual('PubKey does not match signature') + expect(verifyPublicKey).not.toHaveBeenCalled() }) it('should reject with invalid public key', async () => { - paymailClient.verifyPublicKey = jest.fn().mockResolvedValue({ match: false }) const privateKey = PrivateKey.fromRandom() - const tx = Transaction.fromHex('01000000012adda020db81f2155ebba69e7c841275517ebf91674268c32ff2f5c7e2853b2c010000006b483045022100872051ef0b6c47714130c12a067db4f38b988bfc22fe270731c2146f5229386b02207abf68bbf092ec03e2c616defcc4c868ad1fc3cdbffb34bcedfab391a1274f3e412102affe8c91d0a61235a3d07b1903476a2e2f7a90451b2ed592fea9937696a07077ffffffff02ed1a0000000000001976a91491b3753cf827f139d2dc654ce36f05331138ddb588acc9670300000000001976a914da036233873cc6489ff65a0185e207d243b5154888ac00000000') - const response = await request(app).post('/receive-transaction/satoshi@bsv.org').send({ - hex: tx.toHex(), - metadata: { - sender: 'halfinny@vistamail.org', - pubkey: privateKey.toPublicKey().toString(), - signature: 'invalid signature', - note: 'gm.' - }, - reference: 'someRefId' + jest.spyOn(paymailClient, 'verifyPublicKey').mockResolvedValue({ + handle: 'halfinny@vistamail.org', + pubkey: privateKey.toPublicKey().toString(), + match: false }) + const tx = Transaction.fromHex( + '01000000012adda020db81f2155ebba69e7c841275517ebf91674268c32ff2f5c7e2853b2c010000006b483045022100872051ef0b6c47714130c12a067db4f38b988bfc22fe270731c2146f5229386b02207abf68bbf092ec03e2c616defcc4c868ad1fc3cdbffb34bcedfab391a1274f3e412102affe8c91d0a61235a3d07b1903476a2e2f7a90451b2ed592fea9937696a07077ffffffff02ed1a0000000000001976a91491b3753cf827f139d2dc654ce36f05331138ddb588acc9670300000000001976a914da036233873cc6489ff65a0185e207d243b5154888ac00000000' + ) + const signature = paymailClient.createP2PSignature(tx.id('hex'), privateKey) + const response = await request(app) + .post('/receive-transaction/satoshi@bsv.org') + .send({ + hex: tx.toHex(), + metadata: { + sender: 'halfinny@vistamail.org', + pubkey: privateKey.toPublicKey().toString(), + signature, + note: 'gm.' + }, + reference: 'someRefId' + }) + expect(response.statusCode).toBe(400) + expect(response.text).toEqual('Invalid Public Key for sender') + }) + + it('validates request shape before transaction processing', async () => { + const response = await request(app) + .post('/receive-transaction/satoshi@bsv.org') + .send({ reference: 'someRefId' }) + + expect(response.statusCode).toBe(400) + expect(response.text).toContain('"hex" is required') + }) + + it('accepts valid unsigned transactions when signature verification is disabled', async () => { + const unsignedApp = express() + const tx = Transaction.fromHex( + '01000000012adda020db81f2155ebba69e7c841275517ebf91674268c32ff2f5c7e2853b2c010000006b483045022100872051ef0b6c47714130c12a067db4f38b988bfc22fe270731c2146f5229386b02207abf68bbf092ec03e2c616defcc4c868ad1fc3cdbffb34bcedfab391a1274f3e412102affe8c91d0a61235a3d07b1903476a2e2f7a90451b2ed592fea9937696a07077ffffffff02ed1a0000000000001976a91491b3753cf827f139d2dc654ce36f05331138ddb588acc9670300000000001976a914da036233873cc6489ff65a0185e207d243b5154888ac00000000' + ) + const route = new ReceiveTransactionRoute({ + domainLogicHandler: () => ({ txid: tx.id('hex') }), + verifySignature: false, + paymailClient + }) + unsignedApp.use( + new PaymailRouter({ baseUrl: 'http://localhost:3000', routes: [route] }).getRouter() + ) + + const response = await request(unsignedApp) + .post('/receive-transaction/satoshi@bsv.org') + .send({ hex: tx.toHex(), reference: 'someRefId' }) + + expect(response.statusCode).toBe(200) + expect(response.body.txid).toBe(tx.id('hex')) + }) + + it('normalizes non-Error transaction parser failures', async () => { + const parser = jest.spyOn(Transaction, 'fromHex').mockImplementationOnce(() => { + throw 'parser failed' + }) + + const response = await request(app) + .post('/receive-transaction/satoshi@bsv.org') + .send({ + hex: '01', + reference: 'someRefId', + metadata: { + sender: 'halfinny@vistamail.org', + pubkey: '02abc', + signature: 'signature' + } + }) + parser.mockRestore() + expect(response.statusCode).toBe(400) - expect(response.error.text).toEqual('Invalid Public Key for sender') + expect(response.text).toBe('Invalid body: parser failed') }) }) diff --git a/packages/messaging/ts-paymail/src/paymailRouter/__tests/serviceDiscovery.test.ts b/packages/messaging/ts-paymail/src/paymailRouter/__tests/serviceDiscovery.test.ts index 3d682ee91..dba510c0e 100644 --- a/packages/messaging/ts-paymail/src/paymailRouter/__tests/serviceDiscovery.test.ts +++ b/packages/messaging/ts-paymail/src/paymailRouter/__tests/serviceDiscovery.test.ts @@ -1,10 +1,10 @@ import request from 'supertest' -import express from 'express' -import PaymailRouter from '../../../dist/cjs/src/paymailRouter/paymailRouter.js' -import PublicProfileRoute from '../../../dist/cjs/src/paymailRouter/paymailRoutes/publicProfileRoute.js' +import express, { type Express } from 'express' +import PaymailRouter from '../paymailRouter.js' +import PublicProfileRoute from '../paymailRoutes/publicProfileRoute.js' describe('#Paymail Server - Capability discovery', () => { - let app + let app: Express beforeAll(() => { app = express() @@ -12,7 +12,7 @@ describe('#Paymail Server - Capability discovery', () => { const routes = [ new PublicProfileRoute({ - domainLogicHandler: (params) => { + domainLogicHandler: params => { const { name, domain } = PublicProfileRoute.getNameAndDomain(params) return { name, @@ -36,7 +36,9 @@ describe('#Paymail Server - Capability discovery', () => { const response = await request(app).get('/.well-known/bsvalias') expect(response.statusCode).toBe(200) expect(response.body.bsvalias).toBe('1.0') - expect(response.body.capabilities.f12f968c92d6).toEqual('http://localhost:3000/paymail/public-profile/{alias}@{domain.tld}') + expect(response.body.capabilities.f12f968c92d6).toEqual( + 'http://localhost:3000/paymail/public-profile/{alias}@{domain.tld}' + ) expect(response.body.capabilities['6745385c3fc0']).toEqual(true) }) }) diff --git a/packages/messaging/ts-paymail/src/paymailRouter/__tests/simple-p2p-ordinal-destinations.test.ts b/packages/messaging/ts-paymail/src/paymailRouter/__tests/simple-p2p-ordinal-destinations.test.ts index 5434acc3a..02230666b 100644 --- a/packages/messaging/ts-paymail/src/paymailRouter/__tests/simple-p2p-ordinal-destinations.test.ts +++ b/packages/messaging/ts-paymail/src/paymailRouter/__tests/simple-p2p-ordinal-destinations.test.ts @@ -1,20 +1,17 @@ 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' +import express, { type Express } from 'express' +import PaymailRouter from '../paymailRouter.js' +import OrdinalP2pPaymentDestinationRoute from '../paymailRoutes/simpleP2pOrdinalDestinationsRoute.js' describe('#Paymail Server - Simple Ordinal P2P Payment Destinations', () => { - let app - let client: PaymailClient + let app: Express beforeAll(() => { app = express() const baseUrl = 'http://localhost:3000' - client = new PaymailClient(undefined, undefined, undefined) const routes = [ new OrdinalP2pPaymentDestinationRoute({ - domainLogicHandler: (name, domain, body) => { + domainLogicHandler: () => { return { outputs: [ { @@ -31,19 +28,33 @@ describe('#Paymail Server - Simple Ordinal P2P Payment Destinations', () => { }) it('should get ordinal p2p destination', async () => { - const response = await request(app).post('/ordinal-p2p-payment-destination/satoshi@bsv.org').send({ - ordinals: 1 - }) + 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.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 - }) + const response = await request(app) + .post('/ordinal-p2p-payment-destination/satoshi@bsv.org') + .send({ + BSV: 1 + }) + expect(response.statusCode).toBe(400) + expect(response.text).toEqual('Invalid body: "ordinals" is required') + }) + + it.each([0, -1, 1.5])('should reject invalid ordinal count %s', async ordinals => { + const response = await request(app) + .post('/ordinal-p2p-payment-destination/satoshi@bsv.org') + .send({ ordinals }) + expect(response.statusCode).toBe(400) - expect(response.error.text).toEqual('Invalid body: "ordinals" is required') }) }) diff --git a/packages/messaging/ts-paymail/src/paymailRouter/__tests/transaction-negotiation-capabilities.test.ts b/packages/messaging/ts-paymail/src/paymailRouter/__tests/transaction-negotiation-capabilities.test.ts index 2360acb4e..4ae8aee42 100644 --- a/packages/messaging/ts-paymail/src/paymailRouter/__tests/transaction-negotiation-capabilities.test.ts +++ b/packages/messaging/ts-paymail/src/paymailRouter/__tests/transaction-negotiation-capabilities.test.ts @@ -1,9 +1,9 @@ import request from 'supertest' -import express from 'express' -import PaymailRouter from '../../../dist/cjs/src/paymailRouter/paymailRouter.js' -import TransactionNegotiationCapabilitiesRoute from '../../../dist/cjs/src/paymailRouter/paymailRoutes/negotiationCapability.js' +import express, { type Express } from 'express' +import PaymailRouter from '../paymailRouter.js' +import TransactionNegotiationCapabilitiesRoute from '../paymailRoutes/negotiationCapability.js' describe('#Paymail Server - Transaction Negotiation Capabilities', () => { - let app + let app: Express beforeAll(() => { app = express() @@ -24,7 +24,9 @@ describe('#Paymail Server - Transaction Negotiation Capabilities', () => { }) it('should get transaction negotiation capabilities', async () => { - const response = await request(app).get('/transaction-negotiation-capabilities/satoshi@vistamail.org') + const response = await request(app).get( + '/transaction-negotiation-capabilities/satoshi@vistamail.org' + ) expect(response.statusCode).toBe(200) expect(response.body).toEqual({ send_disabled: false, diff --git a/packages/messaging/ts-paymail/src/paymailRouter/__tests/transaction-negotiation.test.ts b/packages/messaging/ts-paymail/src/paymailRouter/__tests/transaction-negotiation.test.ts index 69c11a7be..1519bcc1f 100644 --- a/packages/messaging/ts-paymail/src/paymailRouter/__tests/transaction-negotiation.test.ts +++ b/packages/messaging/ts-paymail/src/paymailRouter/__tests/transaction-negotiation.test.ts @@ -1,19 +1,16 @@ import request from 'supertest' -import express from 'express' -import PaymailRouter from '../../../dist/cjs/src/paymailRouter/paymailRouter.js' -import TransactionNegotiationCapabilitiesRoute from '../../../dist/cjs/src/paymailRouter/paymailRoutes/transactionNegotiationCapabilities.js' +import express, { type Express } from 'express' +import PaymailRouter from '../paymailRouter.js' +import TransactionNegotiationCapabilitiesRoute from '../paymailRoutes/transactionNegotiationCapabilities.js' describe('#Paymail Server - Transaction Negotiation', () => { - let app + let app: Express beforeAll(() => { app = express() const baseUrl = 'http://localhost:3000' - const domainLogicHandler = async (name, domain, body) => { - // Implement your domain logic here, and return the appropriate response - return {} // Placeholder response - } + const domainLogicHandler = () => ({}) const routes = [ new TransactionNegotiationCapabilitiesRoute({ @@ -43,4 +40,25 @@ describe('#Paymail Server - Transaction Negotiation', () => { expect(response.statusCode).toBe(202) }) + + it.each([ + { + thread_id: 'missing-expanded', + expiry: 1234567890, + timestamp: 1234567890, + reply_to: { handle: 'satoshi@vistamail.org' } + }, + { + thread_id: 'missing-reply', + expanded_tx: { tx: 'hexstring' }, + expiry: 1234567890, + timestamp: 1234567890 + } + ])('should reject incomplete transaction negotiation requests', async postData => { + const response = await request(app) + .post('/transaction-negotiation/satoshi@vistamail.org') + .send(postData) + + expect(response.statusCode).toBe(400) + }) }) diff --git a/packages/messaging/ts-paymail/src/paymailRouter/index.ts b/packages/messaging/ts-paymail/src/paymailRouter/index.ts index a5dbb4f0a..2022a667f 100644 --- a/packages/messaging/ts-paymail/src/paymailRouter/index.ts +++ b/packages/messaging/ts-paymail/src/paymailRouter/index.ts @@ -1,5 +1,6 @@ export { default as PaymailRouter } from './paymailRouter.js' export { default as PaymailRoute } from './paymailRoutes/paymailRoute.js' +export type { DomainLogicHandler, PaymailRouteParams } from './paymailRoutes/paymailRoute.js' export { default as P2pPaymentDestinationRoute } from './paymailRoutes/p2pPaymentDestinationRoute.js' export { default as PublicKeyInfrastructureRoute } from './paymailRoutes/pki.js' export { default as ReceiveTransactionRoute } from './paymailRoutes/receiveTransaction.js' @@ -8,3 +9,5 @@ export { default as VerifyPublicKeyOwnerRoute } from './paymailRoutes/verifyPubl export { default as ReceiveBeefTransactionRoute } from './paymailRoutes/receiveBeefTransaction.js' export { default as NegotiationCapabilitiesRoute } from './paymailRoutes/negotiationCapability.js' export { default as TransactionNegotiationCapabilitiesRoute } from './paymailRoutes/transactionNegotiationCapabilities.js' +export { default as OrdinalP2pPaymentDestinationRoute } from './paymailRoutes/simpleP2pOrdinalDestinationsRoute.js' +export { default as SimpleP2pOrdinalReceiveRoute } from './paymailRoutes/receiveOrdinalTransaction.js' diff --git a/packages/messaging/ts-paymail/src/paymailRouter/paymailRouter.ts b/packages/messaging/ts-paymail/src/paymailRouter/paymailRouter.ts index 24ed99905..29769ccf8 100644 --- a/packages/messaging/ts-paymail/src/paymailRouter/paymailRouter.ts +++ b/packages/messaging/ts-paymail/src/paymailRouter/paymailRouter.ts @@ -1,5 +1,5 @@ -import express, { Router, ErrorRequestHandler } from 'express' -import bodyParser from 'body-parser' +import express from 'express' +import type { ErrorRequestHandler, NextFunction, Request, Response, Router } from 'express' import PaymailRoute from './paymailRoutes/paymailRoute.js' import RequestSenderValidationCapability from '../capability/requestSenderValidationCapability.js' import { PaymailBadRequestError } from '../errors/index.js' @@ -27,11 +27,11 @@ export default class PaymailRouter { * Creates an instance of PaymailRouter. * @param config - Configuration options for the PaymailRouter. */ - constructor (config: PaymailRouterConfig) { + constructor(config: PaymailRouterConfig) { this.baseUrl = config.baseUrl this.basePath = config.basePath ?? '' this.router = express.Router() - this.router.use(bodyParser.json({ type: 'application/json' })) + this.router.use(express.json({ type: 'application/json' })) this.routes = config.routes this.requestSenderValidation = config.requestSenderValidation ?? false @@ -60,22 +60,26 @@ export default class PaymailRouter { * Default error handler for the PaymailRouter. * @returns An express middleware for handling errors. */ - private readonly defaultErrorHandler = () => { - return (err, req, res, next) => { + private readonly defaultErrorHandler = (): ErrorRequestHandler => { + return (err: unknown, _request: Request, res: Response, _next: NextFunction) => { if (err instanceof PaymailBadRequestError) { - return res.status(400).send(err.message) + res.status(400).send(err.message) + return } - res.status(500).send(err.message) + res.status(500).send('Internal server error') } } /** * Adds a route for handling the well-known BSV alias protocol. */ - private addWellKnownRouter (): void { - this.router.get('/.well-known/bsvalias', (req, res) => { - const capabilities = this.routes.reduce((map, route) => { - const endpoint = route.getEndpoint().split(':paymail').join('{alias}@{domain.tld}').split(':pubkey').join('{pubkey}') + private addWellKnownRouter(): void { + this.router.get('/.well-known/bsvalias', (_request, res) => { + const capabilities = this.routes.reduce>((map, route) => { + const endpoint = route + .getEndpoint() + .replaceAll(':paymail', '{alias}@{domain.tld}') + .replaceAll(':pubkey', '{pubkey}') map[route.getCode()] = this.joinUrl(this.baseUrl, this.getBasePath(), endpoint) return map }, {}) @@ -88,11 +92,11 @@ export default class PaymailRouter { }) } - private joinUrl (...parts: string[]): string { + private joinUrl(...parts: string[]): string { return parts.map(part => this.trimSlashes(part)).join('/') } - private trimSlashes (part: string): string { + private trimSlashes(part: string): string { let start = 0 let end = part.length while (start < end && part[start] === '/') start += 1 @@ -100,7 +104,7 @@ export default class PaymailRouter { return part.slice(start, end) } - private getBasePath (): string { + private getBasePath(): string { return this.basePath } @@ -108,7 +112,7 @@ export default class PaymailRouter { * Gets the configured express Router. * @returns The express Router with all configured routes and handlers. */ - public getRouter (): Router { + public getRouter(): Router { return this.router } } diff --git a/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/negotiationCapability.ts b/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/negotiationCapability.ts index 375c09507..f8077678d 100644 --- a/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/negotiationCapability.ts +++ b/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/negotiationCapability.ts @@ -12,16 +12,16 @@ interface NegotiationCapabilitiesResponse { interface NegotiationCapabilitiesRouteConfig { endpoint?: string - send_disabled: false - auto_send_response: false - receive: false - three_step_exchange: false - four_step_exchange: false - auto_exchange_response: false + send_disabled: boolean + auto_send_response: boolean + receive: boolean + three_step_exchange: boolean + four_step_exchange: boolean + auto_exchange_response: boolean } export default class TransactionNegotiationCapabilitiesRoute extends PaymailRoute { - constructor (config: NegotiationCapabilitiesRouteConfig) { + constructor(config: NegotiationCapabilitiesRouteConfig) { super({ capability: NegotiationCapabilities, endpoint: config.endpoint || '/transaction-negotiation-capabilities/:paymail', @@ -38,7 +38,7 @@ export default class TransactionNegotiationCapabilitiesRoute extends PaymailRout }) } - protected serializeResponse (domainLogicResponse: NegotiationCapabilitiesResponse): string { + protected serializeResponse(domainLogicResponse: NegotiationCapabilitiesResponse): string { return JSON.stringify({ send_disabled: domainLogicResponse.send_disabled, auto_send_response: domainLogicResponse.auto_send_response, diff --git a/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/p2pPaymentDestinationRoute.ts b/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/p2pPaymentDestinationRoute.ts index 6b2e72a90..eb7f610f1 100644 --- a/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/p2pPaymentDestinationRoute.ts +++ b/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/p2pPaymentDestinationRoute.ts @@ -18,7 +18,7 @@ interface P2pPaymentDestinationRouteConfig { } export default class P2pPaymentDestinationRoute extends PaymailRoute { - constructor (config: P2pPaymentDestinationRouteConfig) { + constructor(config: P2pPaymentDestinationRouteConfig) { super({ capability: P2pPaymentDestinationCapability, endpoint: '/p2p-payment-destination/:paymail', @@ -26,17 +26,18 @@ export default class P2pPaymentDestinationRoute extends PaymailRoute { }) } - protected async validateBody (body: any): Promise { + protected async validateBody(body: unknown): Promise { const schema = joi.object({ - satoshis: joi.number().required() + satoshis: joi.number().integer().min(1).required() }) - const { error } = schema.validate(body) + const { error, value } = schema.validate(body, { stripUnknown: true }) if (error) { throw new PaymailBadRequestError('Invalid body: ' + error.message) } + return value } - protected serializeResponse (domainLogicResponse: P2pDestinationsResponse): string { + protected serializeResponse(domainLogicResponse: P2pDestinationsResponse): string { return JSON.stringify({ outputs: domainLogicResponse.outputs.map(output => ({ script: output.script, diff --git a/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/paymailRoute.ts b/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/paymailRoute.ts index 9118698ac..151a3af93 100644 --- a/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/paymailRoute.ts +++ b/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/paymailRoute.ts @@ -1,79 +1,101 @@ -import { Request, Response, RequestHandler, NextFunction } from 'express' +import type { NextFunction, Request, RequestHandler, Response } from 'express' import Capability from '../../capability/capability.js' import { PaymailBadRequestError } from '../../errors/index.js' +import { parsePaymail } from '../../paymailAddress.js' interface PaymailRouteConfig { capability: Capability endpoint: string - domainLogicHandler: RequestHandler + domainLogicHandler: DomainLogicHandler } -export type DomainLogicHandler = (params: any, body?: any, pubkey?: string) => Promise +export interface PaymailRouteParams { + [key: string]: string + paymail: string +} + +export type DomainLogicHandler = ( + params: PaymailRouteParams, + body?: unknown, + pubkey?: string +) => unknown export default class PaymailRoute { private readonly capability: Capability private readonly endpoint: string protected readonly domainLogicHandler: DomainLogicHandler - constructor (config: PaymailRouteConfig) { + constructor(config: PaymailRouteConfig) { this.capability = config.capability this.endpoint = this.validateEndpoint(config.endpoint) this.domainLogicHandler = config.domainLogicHandler } - private validateEndpoint (endpoint: string): string { + private validateEndpoint(endpoint: string): string { if (!endpoint || typeof endpoint !== 'string' || !endpoint.startsWith('/')) { throw new Error('Invalid endpoint: Endpoint must be a non-empty string starting with "/".') } return endpoint } - protected async defaultHandler (req: Request, res: Response, next: NextFunction): Promise { + protected async defaultHandler(req: Request, res: Response, next: NextFunction): Promise { try { - await this.validateBody(req.body).catch((error) => { - throw new PaymailBadRequestError(error.message) - }) + let validatedBody: unknown + try { + validatedBody = await this.validateBody(req.body) + } catch (error) { + if (error instanceof PaymailBadRequestError) throw error + throw new PaymailBadRequestError(error instanceof Error ? error.message : String(error)) + } if (!req.params.paymail) { throw new PaymailBadRequestError('Paymail handle is required.') } - const response = await this.domainLogicHandler(req.params, req.body) + const response = await this.domainLogicHandler( + req.params as PaymailRouteParams, + validatedBody + ) const serializedResponse = this.serializeResponse(response) - return this.sendSuccessResponse(res, serializedResponse) + this.sendSuccessResponse(res, serializedResponse) } catch (error) { next(error) } } - protected async validateBody (body: any): Promise { - // This method does nothing by default. Child classes can override if needed. + protected async validateBody(body: unknown): Promise { + return body } - protected serializeResponse (response): string { + protected serializeResponse(response: unknown): string { return JSON.stringify(response) } - protected sendSuccessResponse (res: Response, content: string): Response { + protected sendSuccessResponse(res: Response, content: string): Response { return res.type('application/json').status(200).send(content) } - public getHandler (): RequestHandler { + public getHandler(): RequestHandler { return this.defaultHandler.bind(this) } - public getCode (): string { + public getCode(): string { return this.capability.getCode() } - public getEndpoint (): string { + public getEndpoint(): string { return this.endpoint } - public getMethod (): 'GET' | 'POST' { + public getMethod(): 'GET' | 'POST' { return this.capability.getMethod() } - static getNameAndDomain (params: any): { name: string, domain: string, pubkey?: string } { - const [name, domain] = params.paymail.split('@') - return { name, domain } + static getNameAndDomain(params: PaymailRouteParams): { + name: string + domain: string + pubkey?: string + } { + const parsed = parsePaymail(params.paymail) + if (!parsed) throw new PaymailBadRequestError('Invalid Paymail handle.') + return parsed } } diff --git a/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/pki.ts b/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/pki.ts index 5305da9fd..242566616 100644 --- a/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/pki.ts +++ b/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/pki.ts @@ -13,7 +13,7 @@ interface PublicKeyInfrastructureRouteConfig { } export default class PublicKeyInfrastructureRoute extends PaymailRoute { - constructor (config: PublicKeyInfrastructureRouteConfig) { + constructor(config: PublicKeyInfrastructureRouteConfig) { super({ capability: PublicKeyInfrastructureCapability, endpoint: config.endpoint || '/id/:paymail', @@ -21,7 +21,7 @@ export default class PublicKeyInfrastructureRoute extends PaymailRoute { }) } - protected serializeResponse (domainLogicResponse: PkiResponse): string { + protected serializeResponse(domainLogicResponse: PkiResponse): string { return JSON.stringify({ bsvalias: '1.0', handle: domainLogicResponse.handle, diff --git a/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/publicProfileRoute.ts b/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/publicProfileRoute.ts index c4c18d804..538e5707f 100644 --- a/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/publicProfileRoute.ts +++ b/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/publicProfileRoute.ts @@ -12,7 +12,7 @@ interface PublicProfileRouteConfig { } export default class PublicProfileRoute extends PaymailRoute { - constructor (config: PublicProfileRouteConfig) { + constructor(config: PublicProfileRouteConfig) { super({ capability: PublicProfileCapability, endpoint: config.endpoint || '/public-profile/:paymail', @@ -20,7 +20,7 @@ export default class PublicProfileRoute extends PaymailRoute { }) } - protected serializeResponse (domainLogicResponse: PublicProfileResponse): string { + protected serializeResponse(domainLogicResponse: PublicProfileResponse): string { return JSON.stringify({ avatar: domainLogicResponse.avatar, name: domainLogicResponse.name diff --git a/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/receiveBeefTransaction.ts b/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/receiveBeefTransaction.ts index ba9eee290..9807ea137 100644 --- a/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/receiveBeefTransaction.ts +++ b/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/receiveBeefTransaction.ts @@ -1,9 +1,10 @@ -import { Transaction, Signature, PublicKey } from '@bsv/sdk' +import { Transaction } from '@bsv/sdk' import Joi from 'joi' import PaymailRoute, { DomainLogicHandler } from './paymailRoute.js' import P2pReceiveBeefTransactionCapability from '../../capability/p2pReceiveBeefTransactionCapability.js' import { PaymailBadRequestError } from '../../errors/index.js' import PaymailClient from '../../paymailClient/paymailClient.js' +import { verifyP2PSignature } from '../../p2pSignature.js' interface ReceiveTransactionResponse { txid: string @@ -20,7 +21,7 @@ export default class ReceiveBeefTransactionRoute extends PaymailRoute { private readonly verifySignature: boolean private readonly paymailClient: PaymailClient - constructor (config: ReceiveBeefTransactionRouteConfig) { + constructor(config: ReceiveBeefTransactionRouteConfig) { super({ capability: P2pReceiveBeefTransactionCapability, endpoint: '/receive-beef-transaction/:paymail', @@ -30,71 +31,83 @@ export default class ReceiveBeefTransactionRoute extends PaymailRoute { this.paymailClient = config.paymailClient } - protected async validateBody (body: any): Promise { + protected async validateBody(body: unknown): Promise { const schema = this.buildSchema() const { error, value } = schema.validate(body) if (error) { throw new PaymailBadRequestError(error.message) } await this.validateBeefTransaction(value) + return value } - private buildSchema () { + private buildSchema() { const metadataSchema = Joi.object({ - sender: Joi.string().required(), - pubkey: Joi.string().required(), - signature: Joi.string().required(), + 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('', null).optional() - }) + }).options({ stripUnknown: true }) return Joi.object({ beef: Joi.string().required(), - metadata: this.verifySignature ? metadataSchema.required() : Joi.object().optional(), + metadata: this.verifySignature ? metadataSchema.required() : metadataSchema, reference: Joi.string().required() - }) + }).options({ stripUnknown: true }) } - private async validateBeefTransaction (value: any): Promise { + private async validateBeefTransaction(value: { + beef: string + metadata: { sender: string; pubkey: string; signature: string } + }): Promise { const tx = this.validateTransactionFormat(value.beef) if (this.verifySignature) { await this.validateSignature(tx, value.metadata) } } - private validateTransactionFormat (beef: string): Transaction { + private validateTransactionFormat(beef: string): Transaction { try { return Transaction.fromHexBEEF(beef) } catch (error) { - throw new PaymailBadRequestError('Invalid body: ' + error.message) + throw new PaymailBadRequestError( + `Invalid body: ${error instanceof Error ? error.message : String(error)}` + ) } } - private async validateSignature (tx: Transaction, metadata: { - sender: string - pubkey: string - signature: string - }): Promise { + private async validateSignature( + tx: Transaction, + metadata: { + sender: string + pubkey: string + signature: string + } + ): Promise { const { sender, pubkey, signature } = metadata + this.verifyTransactionSignature(tx.id('hex'), signature, pubkey) const match = await this.verifySenderPublicKey(sender, pubkey) if (!match) { throw new PaymailBadRequestError('Invalid Public Key for sender') } - this.verifyTransactionSignature(tx.id('hex'), signature, pubkey) } - private async verifySenderPublicKey (sender: string, pubkey: string): Promise { + 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))) { + private verifyTransactionSignature(message: string, signature: string, pubkey: string): void { + const verification = verifyP2PSignature(message, signature, pubkey) + if (!verification.publicKeyMatches) { + throw new PaymailBadRequestError('PubKey does not match signature') + } + if (!verification.signatureValid) { throw new PaymailBadRequestError('Invalid Signature') } } - protected serializeResponse (domainLogicResponse: ReceiveTransactionResponse): string { + protected serializeResponse(domainLogicResponse: ReceiveTransactionResponse): string { return JSON.stringify({ txid: domainLogicResponse.txid, note: domainLogicResponse.note || '' diff --git a/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/receiveOrdinalTransaction.ts b/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/receiveOrdinalTransaction.ts index 307486585..568ad8454 100644 --- a/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/receiveOrdinalTransaction.ts +++ b/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/receiveOrdinalTransaction.ts @@ -1,9 +1,10 @@ import Joi from 'joi' -import { PublicKey, Transaction, Signature } from '@bsv/sdk' +import { Transaction } from '@bsv/sdk' import PaymailRoute, { DomainLogicHandler } from './paymailRoute.js' import simpleP2pOrdinalReceiveCapability from '../../capability/simpleP2pOrdinalReceiveCapability.js' import { PaymailBadRequestError } from '../../errors/index.js' import PaymailClient from '../../paymailClient/paymailClient.js' +import { verifyP2PSignature } from '../../p2pSignature.js' interface ReceiveTransactionResponse { txid: string @@ -21,27 +22,27 @@ export default class SimpleP2pOrdinalReceiveRoute extends PaymailRoute { private readonly verifySignature: boolean private readonly paymailClient: PaymailClient - constructor (config: ReceiveTransactionRouteConfig) { + constructor(config: ReceiveTransactionRouteConfig) { super({ capability: simpleP2pOrdinalReceiveCapability, - endpoint: config.endpoint || '/receive-transaction/:paymail', + endpoint: config.endpoint || '/receive-ordinal-tx/:paymail', domainLogicHandler: config.domainLogicHandler }) this.verifySignature = config.verifySignature ?? false this.paymailClient = config.paymailClient } - protected async validateBody (body: any): Promise { + protected async validateBody(body: unknown): Promise { const schema = this.buildSchema() const { error, value } = schema.validate(body) if (error) { throw new PaymailBadRequestError(error.message) } await this.validateTransaction(value) + return value } - private buildSchema () { - console.log(this.verifySignature) + private buildSchema() { const metadataSchema = Joi.object({ sender: this.verifySignature ? Joi.string().required() : Joi.string().allow('').optional(), pubkey: this.verifySignature ? Joi.string().required() : Joi.string().allow('').optional(), @@ -55,47 +56,58 @@ export default class SimpleP2pOrdinalReceiveRoute extends PaymailRoute { }).options({ stripUnknown: true }) } - private async validateTransaction (value: any) { + private async validateTransaction(value: { + hex: string + metadata: { sender: string; pubkey: string; signature: string } + }): Promise { const tx = this.validateTransactionFormat(value.hex) if (this.verifySignature) { await this.validateSignature(tx, value.metadata) } } - private validateTransactionFormat (hex: string): Transaction { + private validateTransactionFormat(hex: string): Transaction { try { return Transaction.fromHex(hex) } catch (error) { - throw new PaymailBadRequestError('Invalid body: ' + error.message) + throw new PaymailBadRequestError( + `Invalid body: ${error instanceof Error ? error.message : String(error)}` + ) } } - private async validateSignature (tx: Transaction, metadata: { - sender: string - pubkey: string - signature: string - }): Promise { + private async validateSignature( + tx: Transaction, + metadata: { + sender: string + pubkey: string + signature: string + } + ): Promise { const { sender, pubkey, signature } = metadata + this.verifyTransactionSignature(tx.id('hex'), signature, pubkey) const match = await this.verifySenderPublicKey(sender, pubkey) if (!match) { throw new PaymailBadRequestError('Invalid Public Key for sender') } - this.verifyTransactionSignature(tx.id('hex'), signature, pubkey) } - private async verifySenderPublicKey (sender: string, pubkey: string): Promise { + 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))) { + private verifyTransactionSignature(message: string, signature: string, pubkey: string): void { + const verification = verifyP2PSignature(message, signature, pubkey) + if (!verification.publicKeyMatches) { + throw new PaymailBadRequestError('PubKey does not match signature') + } + if (!verification.signatureValid) { throw new PaymailBadRequestError('Invalid Signature') } } - protected serializeResponse (domainLogicResponse: ReceiveTransactionResponse): string { + protected serializeResponse(domainLogicResponse: ReceiveTransactionResponse): string { return JSON.stringify({ txid: domainLogicResponse.txid, note: domainLogicResponse.note || '' diff --git a/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/receiveTransaction.ts b/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/receiveTransaction.ts index 5aec95f45..04e3a57b9 100644 --- a/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/receiveTransaction.ts +++ b/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/receiveTransaction.ts @@ -1,27 +1,10 @@ import Joi from 'joi' -import { Transaction, Signature, Utils, Hash, BigNumber, ECDSA } from '@bsv/sdk' +import { Transaction } from '@bsv/sdk' import PaymailRoute, { DomainLogicHandler } from './paymailRoute.js' import P2pReceiveTransactionCapability from '../../capability/p2pReceiveTransactionCapability.js' import { PaymailBadRequestError } from '../../errors/index.js' import PaymailClient from '../../paymailClient/paymailClient.js' -const BSM_PREFIX = 'Bitcoin Signed Message:\n' - -/** Encode a small integer as a Bitcoin varint (up to 252 bytes). */ -function bsmVarInt (n: number): number[] { - if (n < 0xfd) return [n] - return [0xfd, n & 0xff, (n >> 8) & 0xff] -} - -function bsmMagicHash (msg: number[]): number[] { - const prefixBytes = Utils.toArray(BSM_PREFIX, 'utf8') - const buf = [ - ...bsmVarInt(prefixBytes.length), - ...prefixBytes, - ...bsmVarInt(msg.length), - ...msg - ] - return Hash.hash256(buf) -} +import { verifyP2PSignature } from '../../p2pSignature.js' interface ReceiveTransactionResponse { txid: string @@ -39,7 +22,7 @@ export default class ReceiveTransactionRoute extends PaymailRoute { private readonly verifySignature: boolean private readonly paymailClient: PaymailClient - constructor (config: ReceiveTransactionRouteConfig) { + constructor(config: ReceiveTransactionRouteConfig) { super({ capability: P2pReceiveTransactionCapability, endpoint: config.endpoint || '/receive-transaction/:paymail', @@ -49,16 +32,17 @@ export default class ReceiveTransactionRoute extends PaymailRoute { this.paymailClient = config.paymailClient } - protected async validateBody (body: any): Promise { + protected async validateBody(body: unknown): Promise { const schema = this.buildSchema() const { error, value } = schema.validate(body) if (error) { throw new PaymailBadRequestError(error.message) } await this.validateTransaction(value) + return value } - private buildSchema () { + private buildSchema() { const metadataSchema = Joi.object({ sender: this.verifySignature ? Joi.string().required() : Joi.string().allow('').optional(), pubkey: this.verifySignature ? Joi.string().required() : Joi.string().allow('').optional(), @@ -72,49 +56,57 @@ export default class ReceiveTransactionRoute extends PaymailRoute { }).options({ stripUnknown: true }) } - private async validateTransaction (value: any) { + private async validateTransaction(value: { + hex: string + metadata: { sender: string; pubkey: string; signature: string } + }): Promise { const tx = this.validateTransactionFormat(value.hex) if (this.verifySignature) { await this.validateSignature(tx, value.metadata) } } - private validateTransactionFormat (hex: string): Transaction { + private validateTransactionFormat(hex: string): Transaction { try { return Transaction.fromHex(hex) } catch (error) { - throw new PaymailBadRequestError('Invalid body: ' + error.message) + throw new PaymailBadRequestError( + `Invalid body: ${error instanceof Error ? error.message : String(error)}` + ) } } - private async validateSignature (tx: Transaction, metadata: { - sender: string - pubkey: string - signature: string - }): Promise { + private async validateSignature( + tx: Transaction, + metadata: { + sender: string + pubkey: string + signature: string + } + ): Promise { const { sender, pubkey, signature } = metadata - await this.verifySenderPublicKey(sender, pubkey) this.verifyTransactionSignature(tx.id('hex'), signature, pubkey) + await this.verifySenderPublicKey(sender, pubkey) } - private async verifySenderPublicKey (sender: string, pubkey: string): Promise { + private async verifySenderPublicKey(sender: string, pubkey: string): Promise { const { match } = await this.paymailClient.verifyPublicKey(sender, pubkey) if (!match) { throw new PaymailBadRequestError('Invalid Public Key for sender') } } - private verifyTransactionSignature (message: string, signature: string, pubkey: string): void { - const msg = Utils.toArray(message, 'utf8') - const sig = Signature.fromCompact(signature, 'base64') - const recovery = Utils.toArray(signature, 'base64')[0] - 27 - 4 - const msgHash = bsmMagicHash(msg) - const pkRecovered = sig.RecoverPublicKey(recovery, new BigNumber(msgHash)) - if (pkRecovered.toString() !== pubkey) throw new PaymailBadRequestError('PubKey does not match signature') - if (!ECDSA.verify(new BigNumber(msgHash), sig, pkRecovered)) throw new PaymailBadRequestError('Invalid Signature') + private verifyTransactionSignature(message: string, signature: string, pubkey: string): void { + const verification = verifyP2PSignature(message, signature, pubkey) + if (!verification.publicKeyMatches) { + throw new PaymailBadRequestError('PubKey does not match signature') + } + if (!verification.signatureValid) { + throw new PaymailBadRequestError('Invalid Signature') + } } - protected serializeResponse (domainLogicResponse: ReceiveTransactionResponse): string { + protected serializeResponse(domainLogicResponse: ReceiveTransactionResponse): string { return JSON.stringify({ txid: domainLogicResponse.txid, note: domainLogicResponse.note || '' diff --git a/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/simpleP2pOrdinalDestinationsRoute.ts b/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/simpleP2pOrdinalDestinationsRoute.ts index 5889030cf..1f32f9aac 100644 --- a/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/simpleP2pOrdinalDestinationsRoute.ts +++ b/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/simpleP2pOrdinalDestinationsRoute.ts @@ -17,7 +17,7 @@ interface OrdinalP2pPaymentDestinationRouteConfig { } export default class OrdinalP2pPaymentDestinationRoute extends PaymailRoute { - constructor (config: OrdinalP2pPaymentDestinationRouteConfig) { + constructor(config: OrdinalP2pPaymentDestinationRouteConfig) { super({ capability: simpleP2pOrdinalDestinationsCapability, endpoint: '/ordinal-p2p-payment-destination/:paymail', @@ -25,17 +25,18 @@ export default class OrdinalP2pPaymentDestinationRoute extends PaymailRoute { }) } - protected async validateBody (body: any): Promise { + protected async validateBody(body: unknown): Promise { const schema = joi.object({ - ordinals: joi.number().required() + ordinals: joi.number().integer().min(1).required() }) - const { error } = schema.validate(body) + const { error, value } = schema.validate(body, { stripUnknown: true }) if (error) { throw new PaymailBadRequestError('Invalid body: ' + error.message) } + return value } - protected serializeResponse (domainLogicResponse: OrdinalP2pDestinationsResponse): string { + protected serializeResponse(domainLogicResponse: OrdinalP2pDestinationsResponse): string { return JSON.stringify({ outputs: domainLogicResponse.outputs.map(output => ({ script: output.script diff --git a/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/transactionNegotiationCapabilities.ts b/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/transactionNegotiationCapabilities.ts index a384a0bd5..70465df95 100644 --- a/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/transactionNegotiationCapabilities.ts +++ b/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/transactionNegotiationCapabilities.ts @@ -10,7 +10,7 @@ interface TransactionNegotiationCapabilitiesRouteConfig { } export default class TransactionNegotiationCapabilitiesRoute extends PaymailRoute { - constructor (config: TransactionNegotiationCapabilitiesRouteConfig) { + constructor(config: TransactionNegotiationCapabilitiesRouteConfig) { super({ capability: TransactionNegotiationCapabilities, endpoint: config.endpoint || '/transaction-negotiation/:paymail', @@ -18,11 +18,11 @@ export default class TransactionNegotiationCapabilitiesRoute extends PaymailRout }) } - protected async validateBody (body: any): Promise { + protected async validateBody(body: unknown): Promise { const feeSchema = Joi.object({ feeType: Joi.string().valid('standard', 'data').required(), - satoshis: Joi.number().required(), - bytes: Joi.number().required() + satoshis: Joi.number().integer().min(0).required(), + bytes: Joi.number().integer().min(1).required() }) const txSchema = Joi.object({ @@ -43,26 +43,27 @@ export default class TransactionNegotiationCapabilitiesRoute extends PaymailRout tx: Joi.string().required(), ancestors: Joi.array().items(txSchema).optional(), spent_outputs: Joi.array().items(spentOutputSchema).optional() - }), + }).required(), expiry: Joi.number().required(), timestamp: Joi.number().required(), reply_to: Joi.object({ handle: Joi.string().required(), peer_channel: Joi.string().uri().optional() - }) + }).required() }) - const { error } = schema.validate(body) + const { error, value } = schema.validate(body, { stripUnknown: true }) if (error) { throw new PaymailBadRequestError('Invalid body: ' + error.message) } + return value } - protected serializeResponse (): string { + protected serializeResponse(): string { return JSON.stringify({}) } - protected sendSuccessResponse (res: Response): Response { + protected sendSuccessResponse(res: Response): Response { return res.type('application/json').status(202).send() } } diff --git a/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/verifyPublicKeyOwner.ts b/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/verifyPublicKeyOwner.ts index 1f3534fb3..b864bb0d3 100644 --- a/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/verifyPublicKeyOwner.ts +++ b/packages/messaging/ts-paymail/src/paymailRouter/paymailRoutes/verifyPublicKeyOwner.ts @@ -13,7 +13,7 @@ interface VerifyPublicKeyOwnerRouteConfig { } export default class VerifyPublicKeyOwnerRoute extends PaymailRoute { - constructor (config: VerifyPublicKeyOwnerRouteConfig) { + constructor(config: VerifyPublicKeyOwnerRouteConfig) { super({ capability: VerifyPublicKeyOwnerCapability, endpoint: config.endpoint || '/verifypubkey/:paymail/:pubkey', @@ -21,7 +21,7 @@ export default class VerifyPublicKeyOwnerRoute extends PaymailRoute { }) } - protected serializeResponse (domainLogicResponse: VerifyPublicKeyOwnerResponse): string { + protected serializeResponse(domainLogicResponse: VerifyPublicKeyOwnerResponse): string { return JSON.stringify({ bsvalias: '1.0', handle: domainLogicResponse.handle, diff --git a/packages/messaging/ts-paymail/tsconfig.base.json b/packages/messaging/ts-paymail/tsconfig.base.json index b8ec210a9..fa2b9704d 100644 --- a/packages/messaging/ts-paymail/tsconfig.base.json +++ b/packages/messaging/ts-paymail/tsconfig.base.json @@ -1,24 +1,17 @@ { "compilerOptions": { - "lib": ["dom", "ESNext"], + "lib": ["DOM", "ESNext"], "module": "NodeNext", "target": "esnext", "moduleResolution": "NodeNext", "moduleDetection": "force", "rootDir": "./", - "strict": false, + "strict": true, "isolatedModules": true, - "strictNullChecks": false, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "incremental": true, "sourceMap": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, "resolveJsonModule": true, "esModuleInterop": true - }, - "files": ["mod.ts", "mod.browser.ts"], - "include": ["src"], - "exclude": ["dist", "*/**/__tests"] + } } diff --git a/packages/messaging/ts-paymail/tsconfig.cjs.json b/packages/messaging/ts-paymail/tsconfig.cjs.json deleted file mode 100644 index aef79fe9b..000000000 --- a/packages/messaging/ts-paymail/tsconfig.cjs.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "target": "es2019", - "module": "commonjs", - "moduleResolution": "bundler", - "rootDir": "./", - "outDir": "./dist/cjs" - } -} \ No newline at end of file diff --git a/packages/messaging/ts-paymail/tsconfig.eslint.json b/packages/messaging/ts-paymail/tsconfig.eslint.json deleted file mode 100644 index ee4a2ca24..000000000 --- a/packages/messaging/ts-paymail/tsconfig.eslint.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "types": ["@types/node"], - "noEmit": true, - "allowJs": true - }, - "include": [ - "./src/**/*.ts", - ".eslintrc.js", - "jest.config.js", - ] -} diff --git a/packages/messaging/ts-paymail/tsconfig.esm.json b/packages/messaging/ts-paymail/tsconfig.esm.json deleted file mode 100644 index efa568e3e..000000000 --- a/packages/messaging/ts-paymail/tsconfig.esm.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "rootDir": "./", - "outDir": "./dist/esm", - "allowSyntheticDefaultImports": true - } -} diff --git a/packages/messaging/ts-paymail/tsconfig.json b/packages/messaging/ts-paymail/tsconfig.json index 2fa4d0874..ddf30e27a 100644 --- a/packages/messaging/ts-paymail/tsconfig.json +++ b/packages/messaging/ts-paymail/tsconfig.json @@ -1,22 +1,3 @@ { - "files": [], - "compilerOptions": { - "types": ["jest", "node"], - "esModuleInterop": true, - "allowJs": true, - "noImplicitAny": false, - "isolatedModules": true, - "rootDir": "./src" - }, - "references": [ - { - "path": "tsconfig.esm.json" - }, - { - "path": "tsconfig.cjs.json" - }, - { - "path": "tsconfig.types.json" - }, - ] + "extends": "./tsconfig.typecheck.json" } diff --git a/packages/messaging/ts-paymail/tsconfig.typecheck.json b/packages/messaging/ts-paymail/tsconfig.typecheck.json new file mode 100644 index 000000000..68ef15f67 --- /dev/null +++ b/packages/messaging/ts-paymail/tsconfig.typecheck.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "incremental": false, + "noEmit": true, + "types": ["jest", "node"] + }, + "include": ["mod.ts", "mod.browser.ts", "src/**/*.ts"], + "exclude": ["dist", "dist-*", "node_modules"] +} diff --git a/packages/messaging/ts-paymail/tsconfig.types.json b/packages/messaging/ts-paymail/tsconfig.types.json deleted file mode 100644 index 4a492387e..000000000 --- a/packages/messaging/ts-paymail/tsconfig.types.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "rootDir": "./", - "outDir": "./dist/types", - "emitDeclarationOnly": true, - "declaration": true, - "declarationMap": true - } -} diff --git a/packages/messaging/ts-paymail/tsdown.config.ts b/packages/messaging/ts-paymail/tsdown.config.ts new file mode 100644 index 000000000..33817feba --- /dev/null +++ b/packages/messaging/ts-paymail/tsdown.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + clean: true, + dts: true, + entry: [ + 'mod.ts', + 'mod.browser.ts', + 'src/capability/*.ts', + 'src/errors/*.ts', + 'src/paymailClient/*.ts', + 'src/paymailClient/resolver/*.ts', + 'src/paymailRouter/*.ts', + 'src/paymailRouter/paymailRoutes/*.ts' + ], + format: ['cjs', 'esm'], + outDir: 'dist', + platform: 'neutral', + sourcemap: true, + tsconfig: 'tsconfig.base.json', + unbundle: true +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88eec2767..2b19db3ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -558,15 +558,18 @@ importers: '@jest/globals': specifier: ^30.4.1 version: 30.4.1 - '@types/body-parser': - specifier: ^1.19.6 - version: 1.19.6 + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 '@types/jest': specifier: ^30.0.0 version: 30.0.0 '@types/node': specifier: ^26.1.1 version: 26.1.1 + '@types/supertest': + specifier: ^7.2.1 + version: 7.2.1 jest: specifier: ^30.4.2 version: 30.4.2(@types/node@26.1.1)(ts-node@10.9.2(@types/node@26.1.1)(typescript@6.0.3)) @@ -582,9 +585,9 @@ importers: ts2md: specifier: ^0.2.8 version: 0.2.8 - tsconfig-to-dual-package: - specifier: ^1.2.0 - version: 1.2.0(typescript@6.0.3) + tsdown: + specifier: 0.22.14 + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.22)(tsx@4.23.1)(typescript@6.0.3) typescript: specifier: ^6.0.3 version: 6.0.3