diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4e2c4b..c1d59f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,3 +34,6 @@ jobs: - name: Build examples run: npm run build-example + + - name: Test RPC input normalization + run: npm test diff --git a/README.md b/README.md index 4e4e1d8..4ffb921 100644 --- a/README.md +++ b/README.md @@ -23,3 +23,43 @@ The current provider API is version 1. Legacy API 0 transaction signing remains supported through the compatibility mapper. Optional transaction notes remain in the request type so existing dApps and API 0 wallets can interoperate, but their security status is explicitly advisory. + +### Transaction fees + +- A `sendTransaction` request with `fee` sets that exact fee. +- When `fee` is omitted, the wallet may show an advisory estimate during + approval, then calculates the actual fee while building the transaction. +- The API 1 response includes the transaction ID, canonical amount, and actual + fee used. When the request is bridged to an API 0 wallet, only `txid` is + guaranteed because legacy wallets may return a bare transaction ID. +- `buildSimpleTransaction` was introduced with provider API 1 for constructing + a simple send without signing or broadcasting it. It returns the exact + unsigned `NockchainTx`, selected input notes, outputs projected at the returned + block height under the active transaction-engine settings, witness-independent + `intentId`, recipient, and the canonical amount/fee/change summary used for + that build. +- A build is an unreserved snapshot. Wallet state can change after it is + returned, so inputs must be revalidated when signing or sending. The wallet + must not silently substitute different notes for an approved intent. +- `notes` are provided so callers can inspect or bridge the exact inputs. They + remain untrusted sidecar data if passed back through `signTx`; Iris resolves + the transaction inputs against wallet-owned state before signing. +- `intentId` is stable across witness signatures and can be used to verify that + the transaction signed is the transaction built. The raw transaction ID may + change when signatures are added. +- `estimateTransactionFee` remains as a deprecated SDK convenience wrapper. It + calls `buildSimpleTransaction` and returns that build's `fee`; there is no + separate `nock_estimateTransactionFee` wire method. +- SDK methods accept bigint nicks and normalize them to canonical strings before + crossing the Chrome extension messaging boundary. Direct low-level provider + requests should use canonical strings or legacy safe integers. + +```typescript +const built = await provider.buildSimpleTransaction({ + to: recipient, + amount: 1_000_000n, +}); + +console.log(built.fee, built.minimumFee, built.change); +const signed = await provider.signTx(built.tx, built.notes); +``` diff --git a/package-lock.json b/package-lock.json index 86482b8..1dbf448 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nockbox/iris-sdk", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nockbox/iris-sdk", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "@nockbox/iris-wasm": "0.2.0", diff --git a/package.json b/package.json index 65e20c1..989d6bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nockbox/iris-sdk", - "version": "0.2.0", + "version": "0.2.1", "description": "TypeScript SDK for interacting with Iris wallet extension", "type": "module", "main": "./dist/index.js", @@ -27,6 +27,7 @@ "dev-example": "vite --config vite.config.examples.ts", "format": "prettier --write .", "format:check": "prettier --check .", + "test": "npm run build && node --test tests/*.test.mjs", "typecheck": "tsc --noEmit", "prepublishOnly": "npm run build" }, diff --git a/src/compat.ts b/src/compat.ts index e24ed9d..1989314 100644 --- a/src/compat.ts +++ b/src/compat.ts @@ -4,7 +4,7 @@ import type { SendTransactionRequest, - NicksLike, + SendTransactionResponse, RpcRequest, RpcResponse, ConnectRequest, @@ -108,6 +108,12 @@ function mapRequest(request: RpcRequest, fromApi?: string, toApi?: string): RpcR } return request; } + case PROVIDER_METHODS.BUILD_SIMPLE_TRANSACTION: { + // This primitive was introduced with API 1. Keep the method and payload + // intact across an API boundary so compatible direct callers still work; + // older wallets can return their normal method-not-found error. + return request; + } case PROVIDER_METHODS.SIGN_MESSAGE: { if (fromV1 && !toV1) { // API 1 → legacy: { message: string } → [message] @@ -245,6 +251,41 @@ function mapResponse( } return response; } + case PROVIDER_METHODS.SEND_TRANSACTION: { + if (!fromV1 && toV1) { + // Legacy wallets historically returned a bare transaction ID. Normalize + // it so API 1 callers always receive the object response shape. + if (typeof response.result === 'string') { + const result: SendTransactionResponse = { txid: response.result }; + return { ...response, result }; + } + + const result = response.result as Partial | undefined; + if (typeof result?.txid === 'string') { + return response; + } + throw new Error('Invalid legacy sendTransaction response'); + } + if (fromV1 && !toV1) { + // Preserve the API 0 response contract for legacy callers. Be tolerant + // of an already-normalized bare ID to avoid double conversion. + if (typeof response.result === 'string') { + return response; + } + + const result = response.result as Partial | undefined; + if (typeof result?.txid !== 'string') { + throw new Error('Invalid sendTransaction response'); + } + return { ...response, result: result.txid }; + } + return response; + } + case PROVIDER_METHODS.BUILD_SIMPLE_TRANSACTION: { + // No legacy response remapping is defined. Successful results therefore + // pass through unchanged; errors are returned by the early guard above. + return response; + } case PROVIDER_METHODS.SIGN_MESSAGE: { if (!fromV1 && toV1) { // legacy → API 1: { signature, publicKeyHex } → { signature, publicKey } diff --git a/src/constants.ts b/src/constants.ts index 3e41718..be1e455 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -22,6 +22,9 @@ export const PROVIDER_METHODS = { /** Sign a nockchain transaction */ SIGN_TX: 'nock_signTx', + + /** Build an unsigned simple-send transaction snapshot (read-only, no approval popup) */ + BUILD_SIMPLE_TRANSACTION: 'nock_buildSimpleTransaction', } as const; export type ProviderMethod = (typeof PROVIDER_METHODS)[keyof typeof PROVIDER_METHODS]; diff --git a/src/provider.ts b/src/provider.ts index 0b1756c..cce86be 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -15,11 +15,22 @@ import type { SignMessageResponse, ConnectRequest, SendTransactionRequest, + SendTransactionResponse, + BuildSimpleTransactionRequest, + BuildSimpleTransactionResponse, + EstimateTransactionFeeRequest, + EstimateTransactionFeeResponse, + NicksLike, } from './types.js'; import { WalletNotInstalledError, UserRejectedError, RpcError, NoAccountError } from './errors.js'; import { PROVIDER_METHODS, RPC_API_VERSION } from './constants.js'; import { NockchainTx, Note } from '@nockbox/iris-wasm'; +/** Chrome extension messaging cannot serialize bigint values. */ +function normalizeNicksForRpc(value: NicksLike): Exclude { + return typeof value === 'bigint' ? (value.toString() as Exclude) : value; +} + /** * NockchainProvider class - Main interface for dApps to interact with Iris wallet * @@ -36,7 +47,7 @@ import { NockchainTx, Note } from '@nockbox/iris-wasm'; * .amount(1_000_000) * .build(); * - * const txId = await nockchain.sendTransaction(tx); + * const { txid, fee } = await nockchain.sendTransaction(tx); * ``` */ export class NockchainProvider { @@ -118,22 +129,89 @@ export class NockchainProvider { /** * Send a transaction * @param transaction - The transaction object to send - * @returns Promise resolving to the transaction ID + * @returns Promise resolving to the transaction ID and, for API 1 wallets, + * the canonical amount and actual fee used by the built transaction * @throws {NoAccountError} If no account is connected * @throws {UserRejectedError} If the user rejects the transaction * @throws {RpcError} If the RPC call fails */ - async sendTransaction(transaction: SendTransactionRequest): Promise { + async sendTransaction(transaction: SendTransactionRequest): Promise { if (!this.isConnected) { throw new NoAccountError(); } - return this.request({ + const normalizedTransaction: SendTransactionRequest = { + ...transaction, + amount: normalizeNicksForRpc(transaction.amount), + }; + if (transaction.fee !== undefined) { + normalizedTransaction.fee = normalizeNicksForRpc(transaction.fee); + } + + return this.request({ method: PROVIDER_METHODS.SEND_TRANSACTION, - params: transaction, + params: normalizedTransaction, }); } + /** + * Build an exact unsigned simple-send transaction without signing or broadcasting it. + * Read-only: requires an approved origin and unlocked wallet, but shows no approval popup. + * Introduced with API 1. Older wallets generally do not implement + * `nock_buildSimpleTransaction`, though compatibility mapping passes the + * method through unchanged rather than rejecting it based on source API. + * + * The result is an unreserved snapshot of wallet state. Inputs can become + * unavailable after this call, so the wallet must revalidate them before + * signing or sending. Use `intentId` to bind later approval/signing to the + * same witness-independent transaction intent. + * + * @param request - Recipient, amount, and optional exact fee in nicks + * @returns Promise resolving to the unsigned transaction and its exact build metadata + * @throws {NoAccountError} If no account is connected + * @throws {RpcError} If the RPC call fails (e.g. wallet locked, no UTXOs) + * + * @example + * ```typescript + * const built = await provider.buildSimpleTransaction({ to: recipient, amount }); + * const signed = await provider.signTx(built.tx, built.notes); + * ``` + */ + async buildSimpleTransaction( + request: BuildSimpleTransactionRequest + ): Promise { + if (!this.isConnected) { + throw new NoAccountError(); + } + + const normalizedRequest: BuildSimpleTransactionRequest = { + ...request, + amount: normalizeNicksForRpc(request.amount), + }; + if (request.fee !== undefined) { + normalizedRequest.fee = normalizeNicksForRpc(request.fee); + } + + return this.request({ + method: PROVIDER_METHODS.BUILD_SIMPLE_TRANSACTION, + params: normalizedRequest, + }); + } + + /** + * Build a simple transaction and return its calculated fee without signing or sending it. + * + * @deprecated Use {@link buildSimpleTransaction} when the complete unsigned + * transaction snapshot is needed. This convenience method invokes + * `nock_buildSimpleTransaction`; there is no separate fee-estimation RPC. + */ + async estimateTransactionFee( + request: EstimateTransactionFeeRequest + ): Promise { + const { fee } = await this.buildSimpleTransaction(request); + return { fee }; + } + /** * Sign an arbitrary message with the current account * @param message - The message to sign diff --git a/src/types.ts b/src/types.ts index ec00617..a1ad237 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,7 +5,8 @@ import { Nicks, TxEngineSettings, PublicKey, Signature, Digest, NockchainTx, Note } from './wasm'; /** - * Transaction object representing a Nockchain transaction + * SDK-friendly nicks input. Bigints are normalized to canonical strings before + * crossing the browser-extension RPC boundary. */ export type NicksLike = number | Nicks | bigint; @@ -78,15 +79,106 @@ export interface SignMessageResponse { publicKey: PublicKey; } +/** + * Parameters for constructing a simple wallet-funded transaction. + * + * Iris selects the inputs and directs change back to the connected account. + */ +export interface BuildSimpleTransactionRequest { + /** Recipient address (base58-encoded public key hash / PKH) */ + to: Digest; + /** Amount to send in nicks (legacy number + canonical string or SDK bigint accepted) */ + amount: NicksLike; + /** + * Transaction fee in nicks (legacy number + canonical string or SDK bigint accepted). + * When provided, this is the exact fee the wallet must use. When omitted, the + * wallet calculates and encodes the fee while building the transaction. + */ + fee?: NicksLike; +} + export interface SendTransactionRequest { /** Recipient address (base58-encoded public key hash / PKH) */ to: Address; - /** Amount to send in nicks (legacy number + canonical string/bigint accepted) */ - amount: Nicks; - /** Transaction fee in nicks (legacy number + canonical string/bigint accepted) */ + /** Amount to send in nicks (legacy number + canonical string or SDK bigint accepted) */ + amount: NicksLike; + /** + * Transaction fee in nicks (legacy number + canonical string or SDK bigint accepted). + * When provided, this is the exact fee the wallet must use. When omitted, the + * wallet may show an advisory estimate for approval and calculates the actual + * fee while building the transaction. + */ + fee?: NicksLike; +} + +export interface SendTransactionResponse { + /** Broadcast transaction ID. */ + txid: string; + /** + * Amount sent in canonical nicks. API 1 wallets include this field; it is + * optional only when an API 1 request is bridged to a legacy API 0 wallet. + */ + amount?: Nicks; + /** + * Actual fee used by the built transaction in canonical nicks. API 1 wallets + * include this field; it is optional only for legacy API 0 responses. + */ fee?: Nicks; } +/** + * An unsigned, unreserved snapshot produced from the wallet's current state. + * + * `notes` and the summary fields describe this exact transaction snapshot. + * `outputs` are the wallet's projection at `blockHeight` under the active + * transaction-engine settings. A wallet must still resolve and revalidate the + * inputs before signing. Passing these notes back through `signTx` does not + * make them authoritative. + */ +export interface BuildSimpleTransactionResponse { + /** Exact unsigned transaction built by the wallet. */ + tx: NockchainTx; + /** Exact input notes selected for `tx`, in native WASM form. */ + notes: Note[]; + /** Output notes projected at `blockHeight` under the active transaction-engine settings. */ + outputs: Note[]; + /** Witness-independent hash of `tx.spends`, stable across signing. */ + intentId: Digest; + /** Wallet account used to select inputs and receive change. */ + accountAddress: Digest; + /** Recipient encoded by this transaction snapshot. */ + to: Digest; + /** Chain height against which the snapshot was constructed. */ + blockHeight: number; + /** Recipient amount encoded by the transaction, in canonical nicks. */ + amount: Nicks; + /** Sum of all selected input notes, in canonical nicks. */ + inputTotal: Nicks; + /** Actual fee encoded by the transaction, in canonical nicks. */ + fee: Nicks; + /** Minimum fee calculated for this transaction, in canonical nicks. */ + minimumFee: Nicks; + /** Change returned to the connected wallet account, in canonical nicks. */ + change: Nicks; +} + +/** @deprecated Use {@link BuildSimpleTransactionRequest}. */ +export interface EstimateTransactionFeeRequest { + /** Recipient address (base58-encoded public key hash / PKH) */ + to: Digest; + /** Amount to send in nicks (legacy number + canonical string or SDK bigint accepted) */ + amount: NicksLike; +} + +/** @deprecated Use the `fee` from {@link BuildSimpleTransactionResponse}. */ +export interface EstimateTransactionFeeResponse { + /** + * Actual fee encoded in the unsigned snapshot constructed for the estimate. + * A later build or send may differ if wallet state changes. + */ + fee: Nicks; +} + export interface SignTxRequest { tx: NockchainTx; /** diff --git a/tests/provider-nicks.test.mjs b/tests/provider-nicks.test.mjs new file mode 100644 index 0000000..8d6f116 --- /dev/null +++ b/tests/provider-nicks.test.mjs @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { PROVIDER_METHODS } from '../dist/constants.js'; +import { NockchainProvider } from '../dist/provider.js'; + +const buildResponse = { + tx: { + version: 1, + id: 'unsigned-id', + spends: [], + display: { inputs: { inputs: [] }, outputs: [] }, + witness_data: { data: [] }, + }, + notes: [ + { + version: 1, + origin_page: 54_000, + name: { first: 'first', last: 'last', _sig: 0 }, + note_data: [], + assets: '102', + }, + ], + outputs: [ + { + version: 1, + origin_page: 54_321, + name: { first: 'out', last: 'last', _sig: 0 }, + note_data: [], + assets: '99', + }, + ], + intentId: 'intent-id', + accountAddress: 'account', + to: 'recipient', + blockHeight: 54_321, + amount: '99', + inputTotal: '102', + fee: '3', + minimumFee: '2', + change: '0', +}; + +function createProvider() { + const requests = []; + globalThis.window = { + nockchain: { + provider: 'iris', + async request(request) { + requests.push(request); + switch (request.method) { + case PROVIDER_METHODS.CONNECT: + return { + account: { type: 'v1', address: 'account' }, + rpcConfig: { + rpcUrl: 'https://rpc.example', + networkName: 'test', + blockExplorerUrl: 'https://explorer.example', + txEngineActivationHeights: {}, + coinbaseTimelockBlocks: 0, + }, + }; + case PROVIDER_METHODS.SEND_TRANSACTION: + return { txid: 'tx', amount: '42', fee: '3' }; + case PROVIDER_METHODS.BUILD_SIMPLE_TRANSACTION: + return buildResponse; + default: + throw new Error(`Unexpected method: ${request.method}`); + } + }, + }, + addEventListener() {}, + removeEventListener() {}, + }; + + return { provider: new NockchainProvider(), requests }; +} + +test('normalizes bigint send values before invoking the injected provider', async () => { + const { provider, requests } = createProvider(); + await provider.connect(); + await provider.sendTransaction({ to: 'recipient', amount: 42n, fee: 3n }); + + const request = requests.at(-1); + assert.deepEqual(request.params, { to: 'recipient', amount: '42', fee: '3' }); + assert.doesNotThrow(() => JSON.stringify(request)); + provider.dispose(); +}); + +test('normalizes bigint build values and returns the complete unsigned snapshot', async () => { + const { provider, requests } = createProvider(); + await provider.connect(); + + const built = await provider.buildSimpleTransaction({ + to: 'recipient', + amount: 99n, + fee: 3n, + }); + assert.equal(requests.at(-1).method, PROVIDER_METHODS.BUILD_SIMPLE_TRANSACTION); + assert.deepEqual(requests.at(-1).params, { to: 'recipient', amount: '99', fee: '3' }); + assert.deepEqual(built, buildResponse); + assert.doesNotThrow(() => JSON.stringify(requests.at(-1))); + assert.doesNotThrow(() => JSON.stringify(built)); + assert.deepEqual(structuredClone(built), buildResponse); + + const withoutFee = await provider.buildSimpleTransaction({ to: 'recipient', amount: '99' }); + assert.deepEqual(withoutFee, buildResponse); + assert.deepEqual(requests.at(-1).params, { to: 'recipient', amount: '99' }); + assert.equal(Object.hasOwn(requests.at(-1).params, 'fee'), false); + provider.dispose(); +}); + +test('deprecated fee helper projects from buildSimpleTransaction without a second RPC method', async () => { + const { provider, requests } = createProvider(); + await provider.connect(); + + assert.deepEqual(await provider.estimateTransactionFee({ to: 'recipient', amount: 99n }), { + fee: '3', + }); + assert.equal(requests.at(-1).method, PROVIDER_METHODS.BUILD_SIMPLE_TRANSACTION); + assert.deepEqual(requests.at(-1).params, { to: 'recipient', amount: '99' }); + assert.equal(Object.hasOwn(PROVIDER_METHODS, 'ESTIMATE_TRANSACTION_FEE'), false); + provider.dispose(); +}); + +test('preserves string and number send inputs', async () => { + const { provider, requests } = createProvider(); + await provider.connect(); + + await provider.sendTransaction({ to: 'recipient', amount: '42', fee: 3 }); + assert.deepEqual(requests.at(-1).params, { to: 'recipient', amount: '42', fee: 3 }); + + await provider.sendTransaction({ to: 'recipient', amount: 42 }); + assert.deepEqual(requests.at(-1).params, { to: 'recipient', amount: 42 }); + assert.equal(Object.hasOwn(requests.at(-1).params, 'fee'), false); + provider.dispose(); +});