Skip to content

Feature: Add support for Nockster hardware wallet#54

Open
yapishu wants to merge 2 commits into
nockbox:mainfrom
SWPSCO:reid/nockster
Open

Feature: Add support for Nockster hardware wallet#54
yapishu wants to merge 2 commits into
nockbox:mainfrom
SWPSCO:reid/nockster

Conversation

@yapishu

@yapishu yapishu commented Jul 1, 2026

Copy link
Copy Markdown

added nockster hardware wallet support to iris using the @swps/nockster-js node lib

  • pairing works from the extension via hid or serial
  • if the device is locked, iris asks for the pin and lets you unlock over the wire (you can also use the touchscreen)
  • import shows all device wallets and lets you select any number of them
  • nockster accounts are stored as external seed sources
  • signing uses the nockster signDraft flow; iris sends the unsigned noun over the wire
  • made changes to allow fee estimation and max-send with a watch address
  • send flow prepares an unsigned tx, asks nockster to sign it, then completes/broadcasts it
  • nockster tx drafts default to non-private, so the device can show recipient/change pkhs
    • if fee-minimized is on, iris warns that the device will show lock roots instead of recipient/change pkhs
    • added a "minimize fee" toggle to opt out of the above
  • bumped package/manifest version to 1.3.0
image image image

@greptile-apps

greptile-apps Bot commented Jul 1, 2026

Copy link
Copy Markdown

Greptile Summary

This PR integrates the Nockster hardware wallet into Iris via the @swps/nockster-js library, supporting HID and Serial transports. It introduces a prepare→sign→complete transaction lifecycle for external signers, extends fee estimation to work with watch-only Nockster addresses, and adds a "minimize fee" toggle that controls whether output lock data (needed for the device to display human-readable PKH recipients) is included in the unsigned draft.

  • New Nockster transaction flow: prepareSendTransactionV2 reserves UTXOs and builds an unsigned draft; the popup signs via the device; completePreparedSendTransactionV2 broadcasts. Cancellation on signing failure correctly releases in-flight notes.
  • External fee estimation: Both estimateTransactionFee and estimateMaxSendAmount now support Nockster accounts, though the two use different UTXO sources (live RPC vs. UTXO store) for the same account type.
  • Approval screens updated: TransactionApprovalScreen, SignMessageScreen, and SignRawTxScreen all branch on whether the current account is a Nockster account, and signMessageWithNockster includes post-signing signature verification.

Confidence Score: 3/5

The core transaction flow is well-structured with correct cleanup on failure, but the fee estimation inconsistency and missing post-signing validation are worth addressing before shipping.

The prepare/complete/cancel lifecycle correctly handles in-flight note cleanup. Two gaps stand out: estimateTransactionFee for Nockster queries the live RPC while prepareSendTransactionV2 uses the filtered UTXO store, which can produce a fee estimate that then fails at send time; and signRawTxWithNockster does not verify the device-returned signatures against the account public key the way signMessageWithNockster does, so a wrong-slot or corrupt signature would be broadcast and rejected by the network, temporarily locking input notes.

extension/shared/vault.ts (estimateTransactionFee vs estimateMaxSendAmount UTXO source inconsistency) and extension/popup/utils/nockster.ts (signRawTxWithNockster missing signature verification).

Important Files Changed

Filename Overview
extension/popup/utils/nockster.ts New 502-line file implementing Nockster device connection (HID/Serial), account listing, message signing with verification, and raw tx signing without signature verification.
extension/shared/vault.ts Adds prepareSendTransactionV2/completePreparedSendTransactionV2/cancelPreparedSendTransactionV2; estimateTransactionFee uses live RPC for Nockster, inconsistent with estimateMaxSendAmount which uses the UTXO store.
extension/background/index.ts Adds six new internal message handlers for the Nockster signing flow; completePendingRequest uses == instead of ===.
extension/shared/transaction-builder.ts Refactors build functions into draft/signed variants; adds buildUnsignedTransaction and buildUnsignedMultiNotePayment for hardware wallet path with proper builder.free() cleanup.
extension/popup/screens/NocksterConnectScreen.tsx New screen for HID/Serial pairing, PIN entry, account listing, and multi-account import as external seed sources.
extension/popup/screens/SendReviewScreen.tsx Adds Nockster prepare-sign-complete send flow with correct cleanup on signing failure.
extension/popup/screens/approvals/TransactionApprovalScreen.tsx Adds Nockster signing for dapp-initiated sends with correct cleanup on signing failure.
extension/popup/store.ts Adds balanceError state, nockster-pair URL routing, SIGN_RAW_TX_HASH_PREFIX approval detection fix, and richer sync error propagation.
extension/shared/rpc-client-browser.ts Fixes height parsing to handle bigint/string/number variants and removes the height=0 early return blocking balance reads.
extension/popup/screens/SendScreen.tsx Adds Nockster-specific minimize fee toggle with proper fee re-estimation on change.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant U as User (Popup)
    participant BG as Background (SW)
    participant V as Vault
    participant N as Nockster Device

    note over U,N: Nockster Send Flow
    U->>BG: PREPARE_SEND_TRANSACTION_V2
    BG->>V: prepareSendTransactionV2(to, amount, fee, ...)
    V->>V: getAvailableNotes (UTXO store)
    V->>V: markNotesInFlight
    V->>V: buildUnsignedMultiNotePayment
    V-->>BG: "{ walletTx, rawTx, fee }"
    BG-->>U: "{ walletTx, rawTx }"

    U->>N: signRawTxWithNockster(rawTx, account)
    N-->>U: signedTx (NockchainTx)

    U->>BG: COMPLETE_SEND_TRANSACTION_V2 [walletTxId, signedTx]
    BG->>V: completePreparedSendTransactionV2(walletTxId, signedTx)
    V->>V: validate structure (no sig check)
    V->>V: broadcast via RPC
    V-->>BG: "{ txId, walletTx, broadcasted }"
    BG-->>U: "{ txid, broadcasted }"
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant U as User (Popup)
    participant BG as Background (SW)
    participant V as Vault
    participant N as Nockster Device

    note over U,N: Nockster Send Flow
    U->>BG: PREPARE_SEND_TRANSACTION_V2
    BG->>V: prepareSendTransactionV2(to, amount, fee, ...)
    V->>V: getAvailableNotes (UTXO store)
    V->>V: markNotesInFlight
    V->>V: buildUnsignedMultiNotePayment
    V-->>BG: "{ walletTx, rawTx, fee }"
    BG-->>U: "{ walletTx, rawTx }"

    U->>N: signRawTxWithNockster(rawTx, account)
    N-->>U: signedTx (NockchainTx)

    U->>BG: COMPLETE_SEND_TRANSACTION_V2 [walletTxId, signedTx]
    BG->>V: completePreparedSendTransactionV2(walletTxId, signedTx)
    V->>V: validate structure (no sig check)
    V->>V: broadcast via RPC
    V-->>BG: "{ txId, walletTx, broadcasted }"
    BG-->>U: "{ txid, broadcasted }"
Loading

Comments Outside Diff (2)

  1. extension/popup/utils/nockster.ts, line 2133-2143 (link)

    P2 Missing signature verification after device signing

    signRawTxWithNockster converts the device-returned draft back into a NockchainTx but never verifies that the embedded signatures are cryptographically valid for this account's public key. By contrast, signMessageWithNockster explicitly calls wasm.verifySignature and throws if verification fails.

    If the device signs with the wrong seed slot, returns a corrupt signature due to a firmware bug, or the account.slot selection silently picks the wrong key, the resulting NockchainTx will pass WASM structural checks and be submitted to the network. The network node will reject the invalid tx, and the input notes stay locked as broadcasted_unconfirmed until the wallet's status-polling detects the permanent failure — temporarily freezing those funds for the user.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: extension/popup/utils/nockster.ts
    Line: 2133-2143
    
    Comment:
    **Missing signature verification after device signing**
    
    `signRawTxWithNockster` converts the device-returned draft back into a `NockchainTx` but never verifies that the embedded signatures are cryptographically valid for this account's public key. By contrast, `signMessageWithNockster` explicitly calls `wasm.verifySignature` and throws if verification fails.
    
    If the device signs with the wrong seed slot, returns a corrupt signature due to a firmware bug, or the `account.slot` selection silently picks the wrong key, the resulting `NockchainTx` will pass WASM structural checks and be submitted to the network. The network node will reject the invalid tx, and the input notes stay locked as `broadcasted_unconfirmed` until the wallet's status-polling detects the permanent failure — temporarily freezing those funds for the user.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. extension/shared/vault.ts, line 3225-3270 (link)

    P2 estimateTransactionFee bypasses in-flight note filtering for Nockster accounts

    The Nockster path in estimateTransactionFee issues a fresh queryV1Balance RPC call, which returns all unspent notes regardless of whether they are already reserved by a concurrent prepare. estimateMaxSendAmount and prepareSendTransactionV2, on the other hand, both call this.getAvailableNotes(), which excludes in-flight notes.

    The practical failure: fee estimation succeeds using a UTXO that is already locked by a pending prepare, but the subsequent prepareSendTransactionV2 call (which uses the filtered store) returns "Insufficient available funds" even though the estimated fee implied the transaction was viable. The user sees a confusing mismatch between the displayed fee and the send failure.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: extension/shared/vault.ts
    Line: 3225-3270
    
    Comment:
    **`estimateTransactionFee` bypasses in-flight note filtering for Nockster accounts**
    
    The Nockster path in `estimateTransactionFee` issues a fresh `queryV1Balance` RPC call, which returns all unspent notes regardless of whether they are already reserved by a concurrent prepare. `estimateMaxSendAmount` and `prepareSendTransactionV2`, on the other hand, both call `this.getAvailableNotes()`, which excludes in-flight notes.
    
    The practical failure: fee estimation succeeds using a UTXO that is already locked by a pending prepare, but the subsequent `prepareSendTransactionV2` call (which uses the filtered store) returns "Insufficient available funds" even though the estimated fee implied the transaction was viable. The user sees a confusing mismatch between the displayed fee and the send failure.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
extension/popup/utils/nockster.ts:2133-2143
**Missing signature verification after device signing**

`signRawTxWithNockster` converts the device-returned draft back into a `NockchainTx` but never verifies that the embedded signatures are cryptographically valid for this account's public key. By contrast, `signMessageWithNockster` explicitly calls `wasm.verifySignature` and throws if verification fails.

If the device signs with the wrong seed slot, returns a corrupt signature due to a firmware bug, or the `account.slot` selection silently picks the wrong key, the resulting `NockchainTx` will pass WASM structural checks and be submitted to the network. The network node will reject the invalid tx, and the input notes stay locked as `broadcasted_unconfirmed` until the wallet's status-polling detects the permanent failure — temporarily freezing those funds for the user.

### Issue 2 of 4
extension/shared/vault.ts:3225-3270
**`estimateTransactionFee` bypasses in-flight note filtering for Nockster accounts**

The Nockster path in `estimateTransactionFee` issues a fresh `queryV1Balance` RPC call, which returns all unspent notes regardless of whether they are already reserved by a concurrent prepare. `estimateMaxSendAmount` and `prepareSendTransactionV2`, on the other hand, both call `this.getAvailableNotes()`, which excludes in-flight notes.

The practical failure: fee estimation succeeds using a UTXO that is already locked by a pending prepare, but the subsequent `prepareSendTransactionV2` call (which uses the filtered store) returns "Insufficient available funds" even though the estimated fee implied the transaction was viable. The user sees a confusing mismatch between the displayed fee and the send failure.

### Issue 3 of 4
extension/background/index.ts:1713-1737
**`COMPLETE_SIGN_MESSAGE` forwards `completeSignResponse` without shape validation**

`completeSignResponse` is taken directly from the message payload and passed verbatim to `completeSignPending.sendResponse()`, which forwards it to the requesting dapp provider. There is no check that the value conforms to the expected `SignMessageResponse` shape (`{ signature, publicKey }`). `COMPLETE_SIGN_RAW_TX` and `COMPLETE_TRANSACTION` both perform at least structural WASM validation before forwarding; this handler should do the same.

### Issue 4 of 4
extension/background/index.ts:248-253
Loose equality is used to compare string request IDs. Since both `currentRequestId` and `requestId` are strings (or `null`), strict equality is more correct and avoids any implicit coercion surprises.

```suggestion
function completePendingRequest(requestId: string): void {
  pendingRequests.delete(requestId);
  if (currentRequestId === requestId) {
    currentRequestId = null;
  }
}
```

Reviews (1): Last reviewed commit: "Fix HID and protocol exchange; add multi..." | Re-trigger Greptile

Comment on lines 1713 to 1737
}
return;

case INTERNAL_METHODS.COMPLETE_SIGN_MESSAGE:
const [completeSignId, completeSignResponse] = payload.params || [];
const completeSignPending = pendingRequests.get(completeSignId);
if (completeSignPending && isSignRequest(completeSignPending.request)) {
const signRequest = completeSignPending.request;
if (isRequestExpired(signRequest.timestamp)) {
cancelPendingRequest(completeSignId, 4003, 'Request expired');
sendResponse({ error: 'Request expired' });
return;
}
completeSignPending.sendResponse(completeSignResponse);
completePendingRequest(completeSignId);
processNextRequest();
sendResponse({ success: true });
} else {
sendResponse({ error: ERROR_CODES.NOT_FOUND });
}
return;

case INTERNAL_METHODS.APPROVE_SIGN_RAW_TX:
const approveSignRawTxId = payload.params?.[0];
const approveSignRawTxPending = pendingRequests.get(approveSignRawTxId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 COMPLETE_SIGN_MESSAGE forwards completeSignResponse without shape validation

completeSignResponse is taken directly from the message payload and passed verbatim to completeSignPending.sendResponse(), which forwards it to the requesting dapp provider. There is no check that the value conforms to the expected SignMessageResponse shape ({ signature, publicKey }). COMPLETE_SIGN_RAW_TX and COMPLETE_TRANSACTION both perform at least structural WASM validation before forwarding; this handler should do the same.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extension/background/index.ts
Line: 1713-1737

Comment:
**`COMPLETE_SIGN_MESSAGE` forwards `completeSignResponse` without shape validation**

`completeSignResponse` is taken directly from the message payload and passed verbatim to `completeSignPending.sendResponse()`, which forwards it to the requesting dapp provider. There is no check that the value conforms to the expected `SignMessageResponse` shape (`{ signature, publicKey }`). `COMPLETE_SIGN_RAW_TX` and `COMPLETE_TRANSACTION` both perform at least structural WASM validation before forwarding; this handler should do the same.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +248 to +253
function completePendingRequest(requestId: string): void {
pendingRequests.delete(requestId);
if (currentRequestId == requestId) {
currentRequestId = null;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Loose equality is used to compare string request IDs. Since both currentRequestId and requestId are strings (or null), strict equality is more correct and avoids any implicit coercion surprises.

Suggested change
function completePendingRequest(requestId: string): void {
pendingRequests.delete(requestId);
if (currentRequestId == requestId) {
currentRequestId = null;
}
}
function completePendingRequest(requestId: string): void {
pendingRequests.delete(requestId);
if (currentRequestId === requestId) {
currentRequestId = null;
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: extension/background/index.ts
Line: 248-253

Comment:
Loose equality is used to compare string request IDs. Since both `currentRequestId` and `requestId` are strings (or `null`), strict equality is more correct and avoids any implicit coercion surprises.

```suggestion
function completePendingRequest(requestId: string): void {
  pendingRequests.delete(requestId);
  if (currentRequestId === requestId) {
    currentRequestId = null;
  }
}
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant