From e596519075ba09e43ad284096713484c8a1585ae Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 11:02:56 +0000 Subject: [PATCH 1/7] feat: add nock_estimateTransactionFee provider method Adds a public, read-only provider method so dApps can query the required network fee for a simple send without duplicating wallet/WASM transaction-building logic: - PROVIDER_METHODS.ESTIMATE_TRANSACTION_FEE ('nock_estimateTransactionFee') - EstimateTransactionFeeRequest / EstimateTransactionFeeResponse types - NockchainProvider.estimateTransactionFee({ to, amount }) -> { fee } (fee in nicks, canonical string) Also documents the optional-fee semantics on SendTransactionRequest: when fee is omitted the wallet estimates it for the approval popup, auto-calculates the exact fee at build time, and reports the actual fee used in the response. The method is API 1-only; compat v0 mapping passes it through untouched (default case), which is intentional. --- src/constants.ts | 3 +++ src/provider.ts | 33 +++++++++++++++++++++++++++++++++ src/types.ts | 19 ++++++++++++++++++- 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/constants.ts b/src/constants.ts index 3e41718..20b7f0d 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', + + /** Estimate the network fee for a simple send (read-only, no approval popup) */ + ESTIMATE_TRANSACTION_FEE: 'nock_estimateTransactionFee', } as const; export type ProviderMethod = (typeof PROVIDER_METHODS)[keyof typeof PROVIDER_METHODS]; diff --git a/src/provider.ts b/src/provider.ts index 0b1756c..c7e11b0 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -15,6 +15,8 @@ import type { SignMessageResponse, ConnectRequest, SendTransactionRequest, + EstimateTransactionFeeRequest, + EstimateTransactionFeeResponse, } from './types.js'; import { WalletNotInstalledError, UserRejectedError, RpcError, NoAccountError } from './errors.js'; import { PROVIDER_METHODS, RPC_API_VERSION } from './constants.js'; @@ -134,6 +136,37 @@ export class NockchainProvider { }); } + /** + * Estimate the network fee for a simple send without sending it. + * Read-only: requires an approved origin and unlocked wallet, but shows no approval popup. + * Requires API 1 (`nock_estimateTransactionFee` is not available on legacy API 0 wallets). + * + * The estimate is advisory: it depends on the wallet's current UTXO set and may + * drift slightly between estimation and an actual send. + * + * @param request - Recipient and amount in nicks + * @returns Promise resolving to the estimated fee in nicks + * @throws {NoAccountError} If no account is connected + * @throws {RpcError} If the RPC call fails (e.g. wallet locked, no UTXOs) + * + * @example + * ```typescript + * const { fee } = await provider.estimateTransactionFee({ to: recipient, amount }); + * ``` + */ + async estimateTransactionFee( + request: EstimateTransactionFeeRequest + ): Promise { + if (!this.isConnected) { + throw new NoAccountError(); + } + + return this.request({ + method: PROVIDER_METHODS.ESTIMATE_TRANSACTION_FEE, + params: request, + }); + } + /** * 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..f3a4077 100644 --- a/src/types.ts +++ b/src/types.ts @@ -83,10 +83,27 @@ export interface SendTransactionRequest { 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) */ + /** + * Transaction fee in nicks (legacy number + canonical string/bigint accepted). + * Optional: when omitted, the wallet estimates the fee, shows it in the approval + * popup, and auto-calculates the exact fee at build time. The send response + * reports the actual fee used. + */ fee?: Nicks; } +export interface EstimateTransactionFeeRequest { + /** Recipient address (base58-encoded public key hash / PKH) */ + to: Address; + /** Amount to send in nicks */ + amount: Nicks; +} + +export interface EstimateTransactionFeeResponse { + /** Estimated fee in nicks (canonical string) */ + fee: Nicks; +} + export interface SignTxRequest { tx: NockchainTx; /** From b6bee071dd1262b189817d38b5a2b992da68e325 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 11:03:22 +0000 Subject: [PATCH 2/7] chore: bump version to 0.3.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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..e5aef5b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nockbox/iris-sdk", - "version": "0.2.0", + "version": "0.3.0", "description": "TypeScript SDK for interacting with Iris wallet extension", "type": "module", "main": "./dist/index.js", From e1d2831ad9e39bf446d1c00350fa27beb035abd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 11:32:12 +0000 Subject: [PATCH 3/7] docs: note accepted amount types on EstimateTransactionFeeRequest Mirror the type-coercion note from SendTransactionRequest.amount so the two parallel interfaces document the same accepted runtime types (legacy number + canonical string/bigint). Addresses review feedback on PR #6. --- src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types.ts b/src/types.ts index f3a4077..2f4c2e3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -95,7 +95,7 @@ export interface SendTransactionRequest { export interface EstimateTransactionFeeRequest { /** Recipient address (base58-encoded public key hash / PKH) */ to: Address; - /** Amount to send in nicks */ + /** Amount to send in nicks (legacy number + canonical string/bigint accepted) */ amount: Nicks; } From be9df136aaa722df1cf88a8628475cdfd56347e4 Mon Sep 17 00:00:00 2001 From: thomasurbit <99678438+thomasurbit@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:33:56 +0200 Subject: [PATCH 4/7] fix: align fee response contract across API versions --- README.md | 12 ++++++++++++ src/compat.ts | 32 +++++++++++++++++++++++++++++++- src/provider.ts | 10 ++++++---- src/types.ts | 30 ++++++++++++++++++++++++------ 4 files changed, 73 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 4e4e1d8..3cbe7ca 100644 --- a/README.md +++ b/README.md @@ -23,3 +23,15 @@ 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. +- `estimateTransactionFee` is read-only and advisory. Wallet state can change + between estimation and approval, so dApps must use the fee returned by + `sendTransaction` as the final value. diff --git a/src/compat.ts b/src/compat.ts index e24ed9d..656f7bb 100644 --- a/src/compat.ts +++ b/src/compat.ts @@ -4,7 +4,7 @@ import type { SendTransactionRequest, - NicksLike, + SendTransactionResponse, RpcRequest, RpcResponse, ConnectRequest, @@ -245,6 +245,36 @@ 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.SIGN_MESSAGE: { if (!fromV1 && toV1) { // legacy → API 1: { signature, publicKeyHex } → { signature, publicKey } diff --git a/src/provider.ts b/src/provider.ts index c7e11b0..b0d4871 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -15,6 +15,7 @@ import type { SignMessageResponse, ConnectRequest, SendTransactionRequest, + SendTransactionResponse, EstimateTransactionFeeRequest, EstimateTransactionFeeResponse, } from './types.js'; @@ -38,7 +39,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 { @@ -120,17 +121,18 @@ 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({ + return this.request({ method: PROVIDER_METHODS.SEND_TRANSACTION, params: transaction, }); diff --git a/src/types.ts b/src/types.ts index 2f4c2e3..dfed079 100644 --- a/src/types.ts +++ b/src/types.ts @@ -82,12 +82,27 @@ 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; + amount: NicksLike; /** * Transaction fee in nicks (legacy number + canonical string/bigint accepted). - * Optional: when omitted, the wallet estimates the fee, shows it in the approval - * popup, and auto-calculates the exact fee at build time. The send response - * reports the actual fee used. + * 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; } @@ -96,11 +111,14 @@ export interface EstimateTransactionFeeRequest { /** Recipient address (base58-encoded public key hash / PKH) */ to: Address; /** Amount to send in nicks (legacy number + canonical string/bigint accepted) */ - amount: Nicks; + amount: NicksLike; } export interface EstimateTransactionFeeResponse { - /** Estimated fee in nicks (canonical string) */ + /** + * Advisory fee estimate in canonical nicks. The actual fee reported by a + * later send may differ if wallet state or transaction construction changes. + */ fee: Nicks; } From e2bffef68b413f20d370b0bcbd7d1377c77ff6d9 Mon Sep 17 00:00:00 2001 From: thomasurbit <99678438+thomasurbit@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:47:24 +0200 Subject: [PATCH 5/7] fix: normalize bigint RPC inputs --- .github/workflows/ci.yml | 3 ++ README.md | 13 +++++++ package.json | 1 + src/provider.ts | 23 ++++++++++-- src/types.ts | 9 ++--- tests/provider-nicks.test.mjs | 66 +++++++++++++++++++++++++++++++++++ 6 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 tests/provider-nicks.test.mjs 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 3cbe7ca..fdec8d1 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,16 @@ security status is explicitly advisory. - `estimateTransactionFee` is read-only and advisory. Wallet state can change between estimation and approval, so dApps must use the fee returned by `sendTransaction` as the final value. +- 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. + +## 0.3 migration notes + +- **TypeScript breaking change:** `sendTransaction()` is now typed as returning + `{ txid, amount?, fee? }` instead of `string`. This corrects the API 1 type to + match the object Iris already returns at runtime. Update code that treated the + result itself as the transaction ID to read `result.txid` instead. +- API 0 retains its historical bare transaction-ID response. When an API 1 + wallet response is bridged to an API 0 caller, the compatibility layer returns + the bare `txid` string; API 1 callers receive the object response. diff --git a/package.json b/package.json index e5aef5b..13a30b2 100644 --- a/package.json +++ b/package.json @@ -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/provider.ts b/src/provider.ts index b0d4871..a087129 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -18,11 +18,17 @@ import type { SendTransactionResponse, 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 * @@ -132,9 +138,17 @@ export class NockchainProvider { throw new NoAccountError(); } + 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, }); } @@ -163,9 +177,14 @@ export class NockchainProvider { throw new NoAccountError(); } + const normalizedRequest: EstimateTransactionFeeRequest = { + ...request, + amount: normalizeNicksForRpc(request.amount), + }; + return this.request({ method: PROVIDER_METHODS.ESTIMATE_TRANSACTION_FEE, - params: request, + params: normalizedRequest, }); } diff --git a/src/types.ts b/src/types.ts index dfed079..312d846 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; @@ -81,10 +82,10 @@ export interface SignMessageResponse { 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 to send in nicks (legacy number + canonical string or SDK bigint accepted) */ amount: NicksLike; /** - * Transaction fee in nicks (legacy number + canonical string/bigint accepted). + * 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. @@ -110,7 +111,7 @@ export interface SendTransactionResponse { export interface EstimateTransactionFeeRequest { /** Recipient address (base58-encoded public key hash / PKH) */ to: Address; - /** Amount to send in nicks (legacy number + canonical string/bigint accepted) */ + /** Amount to send in nicks (legacy number + canonical string or SDK bigint accepted) */ amount: NicksLike; } diff --git a/tests/provider-nicks.test.mjs b/tests/provider-nicks.test.mjs new file mode 100644 index 0000000..6ece03f --- /dev/null +++ b/tests/provider-nicks.test.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { PROVIDER_METHODS } from '../dist/constants.js'; +import { NockchainProvider } from '../dist/provider.js'; + +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.ESTIMATE_TRANSACTION_FEE: + return { fee: '3' }; + 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 estimates while preserving string and number send inputs', async () => { + const { provider, requests } = createProvider(); + await provider.connect(); + + await provider.estimateTransactionFee({ to: 'recipient', amount: 99n }); + assert.deepEqual(requests.at(-1).params, { to: 'recipient', amount: '99' }); + + 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(); +}); From b550aa35f2fcb6bdbc854bbf694a107ae8d60354 Mon Sep 17 00:00:00 2001 From: thomasurbit <99678438+thomasurbit@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:10:23 +0200 Subject: [PATCH 6/7] feat: build unsigned simple transactions --- README.md | 35 ++++++++++++++-- src/compat.ts | 11 +++++ src/constants.ts | 4 +- src/provider.ts | 50 ++++++++++++++++------ src/types.ts | 62 ++++++++++++++++++++++++++-- tests/provider-nicks.test.mjs | 78 +++++++++++++++++++++++++++++++++-- 6 files changed, 215 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index fdec8d1..3c43b40 100644 --- a/README.md +++ b/README.md @@ -32,13 +32,38 @@ security status is explicitly advisory. - 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. -- `estimateTransactionFee` is read-only and advisory. Wallet state can change - between estimation and approval, so dApps must use the fee returned by - `sendTransaction` as the final value. +- `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); +``` + ## 0.3 migration notes - **TypeScript breaking change:** `sendTransaction()` is now typed as returning @@ -48,3 +73,7 @@ security status is explicitly advisory. - API 0 retains its historical bare transaction-ID response. When an API 1 wallet response is bridged to an API 0 caller, the compatibility layer returns the bare `txid` string; API 1 callers receive the object response. +- The unreleased fee-estimation RPC was broadened to + `nock_buildSimpleTransaction`, introduced with API 1. Older wallets generally + do not implement it, but compatibility mapping deliberately passes it through + unchanged so compatible direct callers are not rejected based on source API. diff --git a/src/compat.ts b/src/compat.ts index 656f7bb..1989314 100644 --- a/src/compat.ts +++ b/src/compat.ts @@ -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] @@ -275,6 +281,11 @@ function mapResponse( } 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 20b7f0d..be1e455 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -23,8 +23,8 @@ export const PROVIDER_METHODS = { /** Sign a nockchain transaction */ SIGN_TX: 'nock_signTx', - /** Estimate the network fee for a simple send (read-only, no approval popup) */ - ESTIMATE_TRANSACTION_FEE: 'nock_estimateTransactionFee', + /** 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 a087129..cce86be 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -16,6 +16,8 @@ import type { ConnectRequest, SendTransactionRequest, SendTransactionResponse, + BuildSimpleTransactionRequest, + BuildSimpleTransactionResponse, EstimateTransactionFeeRequest, EstimateTransactionFeeResponse, NicksLike, @@ -153,41 +155,63 @@ export class NockchainProvider { } /** - * Estimate the network fee for a simple send without sending it. + * 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. - * Requires API 1 (`nock_estimateTransactionFee` is not available on legacy API 0 wallets). + * 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 estimate is advisory: it depends on the wallet's current UTXO set and may - * drift slightly between estimation and an actual send. + * 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 and amount in nicks - * @returns Promise resolving to the estimated fee in nicks + * @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 { fee } = await provider.estimateTransactionFee({ to: recipient, amount }); + * const built = await provider.buildSimpleTransaction({ to: recipient, amount }); + * const signed = await provider.signTx(built.tx, built.notes); * ``` */ - async estimateTransactionFee( - request: EstimateTransactionFeeRequest - ): Promise { + async buildSimpleTransaction( + request: BuildSimpleTransactionRequest + ): Promise { if (!this.isConnected) { throw new NoAccountError(); } - const normalizedRequest: EstimateTransactionFeeRequest = { + const normalizedRequest: BuildSimpleTransactionRequest = { ...request, amount: normalizeNicksForRpc(request.amount), }; + if (request.fee !== undefined) { + normalizedRequest.fee = normalizeNicksForRpc(request.fee); + } - return this.request({ - method: PROVIDER_METHODS.ESTIMATE_TRANSACTION_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 312d846..a1ad237 100644 --- a/src/types.ts +++ b/src/types.ts @@ -79,6 +79,24 @@ 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; @@ -108,17 +126,55 @@ export interface SendTransactionResponse { 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: Address; + 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 { /** - * Advisory fee estimate in canonical nicks. The actual fee reported by a - * later send may differ if wallet state or transaction construction changes. + * Actual fee encoded in the unsigned snapshot constructed for the estimate. + * A later build or send may differ if wallet state changes. */ fee: Nicks; } diff --git a/tests/provider-nicks.test.mjs b/tests/provider-nicks.test.mjs index 6ece03f..8d6f116 100644 --- a/tests/provider-nicks.test.mjs +++ b/tests/provider-nicks.test.mjs @@ -3,6 +3,43 @@ 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 = { @@ -24,8 +61,8 @@ function createProvider() { }; case PROVIDER_METHODS.SEND_TRANSACTION: return { txid: 'tx', amount: '42', fee: '3' }; - case PROVIDER_METHODS.ESTIMATE_TRANSACTION_FEE: - return { fee: '3' }; + case PROVIDER_METHODS.BUILD_SIMPLE_TRANSACTION: + return buildResponse; default: throw new Error(`Unexpected method: ${request.method}`); } @@ -49,12 +86,45 @@ test('normalizes bigint send values before invoking the injected provider', asyn provider.dispose(); }); -test('normalizes bigint estimates while preserving string and number send inputs', async () => { +test('normalizes bigint build values and returns the complete unsigned snapshot', async () => { const { provider, requests } = createProvider(); await provider.connect(); - await provider.estimateTransactionFee({ to: 'recipient', amount: 99n }); + 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 }); From f0643f6da971712b9728f99b4ab0b7dec55915f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aurimas=20Bla=C5=BEulionis?= <0x60@pm.me> Date: Wed, 7 Oct 2026 12:35:05 +0100 Subject: [PATCH 7/7] Set version to 0.2.1 --- README.md | 14 -------------- package.json | 2 +- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/README.md b/README.md index 3c43b40..4ffb921 100644 --- a/README.md +++ b/README.md @@ -63,17 +63,3 @@ const built = await provider.buildSimpleTransaction({ console.log(built.fee, built.minimumFee, built.change); const signed = await provider.signTx(built.tx, built.notes); ``` - -## 0.3 migration notes - -- **TypeScript breaking change:** `sendTransaction()` is now typed as returning - `{ txid, amount?, fee? }` instead of `string`. This corrects the API 1 type to - match the object Iris already returns at runtime. Update code that treated the - result itself as the transaction ID to read `result.txid` instead. -- API 0 retains its historical bare transaction-ID response. When an API 1 - wallet response is bridged to an API 0 caller, the compatibility layer returns - the bare `txid` string; API 1 callers receive the object response. -- The unreleased fee-estimation RPC was broadened to - `nock_buildSimpleTransaction`, introduced with API 1. Older wallets generally - do not implement it, but compatibility mapping deliberately passes it through - unchanged so compatible direct callers are not rejected based on source API. diff --git a/package.json b/package.json index 13a30b2..989d6bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nockbox/iris-sdk", - "version": "0.3.0", + "version": "0.2.1", "description": "TypeScript SDK for interacting with Iris wallet extension", "type": "module", "main": "./dist/index.js",