Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ jobs:

- name: Build examples
run: npm run build-example

- name: Test RPC input normalization
run: npm test
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
```
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
},
Expand Down
43 changes: 42 additions & 1 deletion src/compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import type {
SendTransactionRequest,
NicksLike,
SendTransactionResponse,
RpcRequest,
RpcResponse,
ConnectRequest,
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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<SendTransactionResponse> | 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<SendTransactionResponse> | 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 }
Expand Down
3 changes: 3 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
88 changes: 83 additions & 5 deletions src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NicksLike, bigint> {
return typeof value === 'bigint' ? (value.toString() as Exclude<NicksLike, bigint>) : value;
}

/**
* NockchainProvider class - Main interface for dApps to interact with Iris wallet
*
Expand All @@ -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 {
Expand Down Expand Up @@ -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<string> {
async sendTransaction(transaction: SendTransactionRequest): Promise<SendTransactionResponse> {
if (!this.isConnected) {
throw new NoAccountError();
}

return this.request<SendTransactionRequest, string>({
const normalizedTransaction: SendTransactionRequest = {
...transaction,
amount: normalizeNicksForRpc(transaction.amount),
};
if (transaction.fee !== undefined) {
normalizedTransaction.fee = normalizeNicksForRpc(transaction.fee);
}

return this.request<SendTransactionRequest, SendTransactionResponse>({
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<BuildSimpleTransactionResponse> {
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<BuildSimpleTransactionRequest, BuildSimpleTransactionResponse>({
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<EstimateTransactionFeeResponse> {
const { fee } = await this.buildSimpleTransaction(request);
return { fee };
}

/**
* Sign an arbitrary message with the current account
* @param message - The message to sign
Expand Down
Loading
Loading