Skip to content

feat: add BatchSignableIdentity for one-shot batch PSBT signing#395

Open
Kukks wants to merge 1 commit intomasterfrom
one-shot-sign
Open

feat: add BatchSignableIdentity for one-shot batch PSBT signing#395
Kukks wants to merge 1 commit intomasterfrom
one-shot-sign

Conversation

@Kukks
Copy link
Copy Markdown
Contributor

@Kukks Kukks commented Mar 31, 2026

Summary

  • Adds BatchSignableIdentity sub-interface with signMultiple() method that browser wallet providers (Xverse, UniSat, OKX) can implement to batch-sign all checkpoint + main tx PSBTs in a single wallet popup
  • Modifies buildAndSubmitOffchainTx() to detect batch-capable identities and pre-sign all PSBTs upfront, then merge stashed user signatures onto server-signed checkpoints using existing combineTapscriptSigs()
  • Exports BatchSignableIdentity, SignRequest, and isBatchSignable type guard from the SDK

Motivation

Arkade send transactions require N+1 PSBT signatures (N checkpoints + 1 main tx). With SingleKey this is invisible (local signing), but browser wallet extensions show a confirmation popup per signature. This change enables wallets with batch signing APIs to reduce N+1 popups to 1.

How it works

// Before (N+1 popups):
sign(arkTx)              → popup 1
submitTx(...)
sign(checkpoint[0])      → popup 2
sign(checkpoint[1])      → popup 3
...
finalizeTx(...)

// After with BatchSignableIdentity (1 popup):
signMultiple([arkTx, ...checkpoints])  → popup 1
submitTx(...)
combineTapscriptSigs(userSigned[i], serverSigned[i])  → no popups
finalizeTx(...)

Test plan

  • TypeScript type-checks clean (tsc --noEmit)
  • All 770 unit tests pass (e2e tests require nigiri local infra, pre-existing failures)
  • Existing Identity implementations (SingleKey, SeedIdentity, MnemonicIdentity) are unchanged and take the legacy code path
  • Integration test with a BatchSignableIdentity provider (to be done in arkade-os/packages repo)

Summary by CodeRabbit

  • New Features
    • Introduced batch signing capability to the identity system, enabling multiple transactions to be signed in a single operation.
    • Wallet updated to leverage batch signing when available, streamlining the transaction signing workflow.

Adds a `BatchSignableIdentity` sub-interface that browser wallet
providers can implement to sign all checkpoint + main tx PSBTs in a
single wallet popup instead of N+1 individual confirmations.

When the identity supports `signMultiple`, `buildAndSubmitOffchainTx`
pre-signs everything upfront and merges the stashed user signatures
onto the server-signed checkpoints after `submitTx` returns.
Identities without batch support fall back to the existing sequential
signing path unchanged.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 31, 2026

Walkthrough

This PR introduces batch signing capabilities to the identity system by adding types (SignRequest, BatchSignableIdentity) and a type guard (isBatchSignable), then integrates batch signing into the Wallet class to optionally sign multiple transactions in a single operation rather than sequentially, with conditional fallback to legacy signing behavior.

Changes

Cohort / File(s) Summary
Identity batch signing types
src/identity/index.ts
Introduced SignRequest interface wrapping a transaction with optional input indexes, BatchSignableIdentity interface extending Identity with a signMultiple() method, and isBatchSignable() type guard to detect batch-capable identities.
Public API exports
src/index.ts
Extended public exports to include SignRequest (type), BatchSignableIdentity (type), and isBatchSignable (value) from the identity module.
Wallet batch signing integration
src/wallet/wallet.ts
Integrated batch signing into buildAndSubmitOffchainTx() with conditional logic: if identity is batch-capable, sign the virtual transaction and all checkpoint PSBTs in one call; otherwise, preserve legacy single-transaction signing. Updated checkpoint finalization to merge batch-signed user signatures with server-signed PSBTs via combineTapscriptSigs() or fall back to individual signing.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Wallet as Wallet
    participant Identity as Identity (Batch)
    participant Server as Server

    rect rgba(100, 200, 100, 0.5)
    Note over Client,Server: Batch Signing Flow
    Client->>Wallet: buildAndSubmitOffchainTx()
    Wallet->>Wallet: Prepare offchainTx.arkTx + checkpoint PSBTs
    Wallet->>Identity: signMultiple([arkTx, checkpoint1, ...])
    Identity->>Identity: Sign all in batch
    Identity-->>Wallet: [signedVirtualTx, checkpoint1_signed, ...]
    Wallet->>Server: Submit signed virtual transaction
    Wallet->>Server: Fetch server-signed checkpoints
    Wallet->>Wallet: Merge user + server sigs via combineTapscriptSigs()
    Wallet-->>Client: finalCheckpoints[] (merged signatures)
    end

    rect rgba(100, 100, 200, 0.5)
    Note over Client,Server: Legacy Signing Flow (non-batch)
    Client->>Wallet: buildAndSubmitOffchainTx()
    Wallet->>Wallet: Prepare offchainTx.arkTx + checkpoint PSBTs
    Wallet->>Identity: sign(offchainTx.arkTx)
    Identity-->>Wallet: signedVirtualTx
    Wallet->>Server: Submit signed virtual transaction
    Wallet->>Server: Fetch server-signed checkpoints
    loop Per checkpoint
        Wallet->>Identity: sign(checkpoint PSBT)
        Identity-->>Wallet: signed PSBT
        Wallet->>Wallet: Encode signed PSBT
    end
    Wallet-->>Client: finalCheckpoints[] (individually signed)
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • ts-sdk#114: Adds foundational sign API with transaction and input indexes support that the batch signing feature builds upon.
  • ts-sdk#126: Modifies src/index.ts exports to include Transaction type, overlapping with the transaction-referencing types added in this PR.
  • ts-sdk#235: Also modifies Wallet.buildAndSubmitOffchainTx() and checkpoint signing/finalization logic, introducing related structural changes to transaction signing flow.

Suggested reviewers

  • pietro909
  • bordalix
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and clearly describes the main change: introducing BatchSignableIdentity for batch PSBT signing.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch one-shot-sign

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/identity/index.ts`:
- Around line 20-32: The BatchSignableIdentity.signMultiple contract is
ambiguous and can lead to misapplied signatures; update the API to return
explicit mapping between requests and results (e.g., change SignRequest to
include a unique id and change signMultiple to return results keyed by that id,
or else document and enforce a strict same-order/same-length guarantee) so
Wallet.buildAndSubmitOffchainTx() can reliably match signatures to requests;
modify the SignRequest type to include an identifier (e.g., requestId) and
change BatchSignableIdentity.signMultiple to return either a Record<requestId,
Transaction> or an array of {requestId, Transaction} and adjust callers to match
by id rather than by array position.

In `@src/wallet/wallet.ts`:
- Around line 2499-2501: Check that identity.signMultiple returned the exact
number of signatures before destructuring: after calling
this.identity.signMultiple(requests) verify signed.length === requests.length
and throw or return an explicit error if it does not, so signedVirtualTx =
signed[0] and userSignedCheckpoints = signed.slice(1) are only executed when
counts match; update the code around the call to identity.signMultiple, using
the variables signed, requests, signedVirtualTx, and userSignedCheckpoints to
perform the validation and fail fast with a clear error message when the lengths
differ.
- Around line 2495-2498: The requests array currently passes references to
offchainTx.checkpoints into signMultiple(), allowing providers to mutate the
original checkpoint objects; change the code that builds requests (the const
requests = [...] construction) to deep-clone each checkpoint (e.g., shallow
clone objects and nested signature arrays) before including them so
signMultiple() receives clones, leaving offchainTx.checkpoints unchanged for
submitTx(); ensure combineTapscriptSigs() later merges signatures from the
signed clones back into the original checkpoint objects (or deduplicates) to
avoid duplicating user signatures when combining tapscript signatures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 15354baf-c73f-427b-93ad-7ab19a7ba2d4

📥 Commits

Reviewing files that changed from the base of the PR and between 961a636 and 9cbb706.

📒 Files selected for processing (3)
  • src/identity/index.ts
  • src/index.ts
  • src/wallet/wallet.ts

Comment on lines +20 to +32
export interface SignRequest {
tx: Transaction;
inputIndexes?: number[];
}

/**
* Identity that supports signing multiple PSBTs in a single wallet interaction.
* Browser wallet providers that support batch signing (e.g. Xverse, UniSat, OKX)
* should implement this interface to reduce the number of confirmation popups
* from N+1 to 1 during Arkade send transactions.
*/
export interface BatchSignableIdentity extends Identity {
signMultiple(requests: SignRequest[]): Promise<Transaction[]>;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Encode the batch request/result mapping explicitly.

Wallet.buildAndSubmitOffchainTx() consumes signMultiple() positionally, but this API only promises a bare Transaction[]. A provider can legally reorder or drop results and still satisfy the type, which would misapply signatures during checkpoint finalization. Either make the response keyed/id-based, or at least document a strict same-order/same-length contract here so implementers don’t guess.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/identity/index.ts` around lines 20 - 32, The
BatchSignableIdentity.signMultiple contract is ambiguous and can lead to
misapplied signatures; update the API to return explicit mapping between
requests and results (e.g., change SignRequest to include a unique id and change
signMultiple to return results keyed by that id, or else document and enforce a
strict same-order/same-length guarantee) so Wallet.buildAndSubmitOffchainTx()
can reliably match signatures to requests; modify the SignRequest type to
include an identifier (e.g., requestId) and change
BatchSignableIdentity.signMultiple to return either a Record<requestId,
Transaction> or an array of {requestId, Transaction} and adjust callers to match
by id rather than by array position.

Comment on lines +2495 to +2498
const requests = [
{ tx: offchainTx.arkTx },
...offchainTx.checkpoints.map((c) => ({ tx: c })),
];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Send clones to signMultiple(), not the original checkpoints.

submitTx() still sends offchainTx.checkpoints as the unsigned checkpoint set. If a provider signs in place, those originals are mutated before submission, and the later combineTapscriptSigs() pass can duplicate user signatures. Batch the clones here instead of the shared offchainTx objects.

Possible fix
             const requests = [
-                { tx: offchainTx.arkTx },
-                ...offchainTx.checkpoints.map((c) => ({ tx: c })),
+                { tx: offchainTx.arkTx.clone() },
+                ...offchainTx.checkpoints.map((c) => ({ tx: c.clone() })),
             ];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wallet/wallet.ts` around lines 2495 - 2498, The requests array currently
passes references to offchainTx.checkpoints into signMultiple(), allowing
providers to mutate the original checkpoint objects; change the code that builds
requests (the const requests = [...] construction) to deep-clone each checkpoint
(e.g., shallow clone objects and nested signature arrays) before including them
so signMultiple() receives clones, leaving offchainTx.checkpoints unchanged for
submitTx(); ensure combineTapscriptSigs() later merges signatures from the
signed clones back into the original checkpoint objects (or deduplicates) to
avoid duplicating user signatures when combining tapscript signatures.

Comment on lines +2499 to +2501
const signed = await this.identity.signMultiple(requests);
signedVirtualTx = signed[0];
userSignedCheckpoints = signed.slice(1);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate the batch signer returned one result per request.

A short or extra response array will only fail later when signedVirtualTx or userSignedCheckpoints[i] is used. Check signed.length === requests.length immediately and fail fast before destructuring.

Possible fix
             const signed = await this.identity.signMultiple(requests);
-            signedVirtualTx = signed[0];
-            userSignedCheckpoints = signed.slice(1);
+            if (signed.length !== requests.length) {
+                throw new Error(
+                    `Batch signer returned ${signed.length} result(s) for ${requests.length} request(s)`
+                );
+            }
+            const [firstSignedTx, ...signedCheckpoints] = signed;
+            signedVirtualTx = firstSignedTx;
+            userSignedCheckpoints = signedCheckpoints;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wallet/wallet.ts` around lines 2499 - 2501, Check that
identity.signMultiple returned the exact number of signatures before
destructuring: after calling this.identity.signMultiple(requests) verify
signed.length === requests.length and throw or return an explicit error if it
does not, so signedVirtualTx = signed[0] and userSignedCheckpoints =
signed.slice(1) are only executed when counts match; update the code around the
call to identity.signMultiple, using the variables signed, requests,
signedVirtualTx, and userSignedCheckpoints to perform the validation and fail
fast with a clear error message when the lengths differ.

@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai bot commented Mar 31, 2026

🔍 Review — BatchSignableIdentity for one-shot batch PSBT signing

Clean, well-scoped change. The interface design is solid.

What works well

  • Type guard pattern (isBatchSignable) keeps the detection clean and avoids instanceof checks that break across module boundaries.
  • Zero-impact on existing identitiesSingleKey/SeedIdentity/MnemonicIdentity all take the legacy path unchanged.
  • Signature merging via combineTapscriptSigs reuses existing infrastructure rather than re-implementing sig combination.

Observations

  1. No length validation on signMultiple result. If a wallet provider returns fewer Transaction objects than the number of SignRequest entries, signed[0] succeeds but userSignedCheckpoints = signed.slice(1) silently produces a shorter array. Then combineTapscriptSigs(userSignedCheckpoints![i], serverSigned) will get undefined for missing indices, likely throwing a confusing runtime error. Consider:

    if (signed.length !== requests.length) {
      throw new Error(`signMultiple returned ${signed.length} txs, expected ${requests.length}`);
    }
  2. inputIndexes on SignRequest is optional but never populated. The batch path always constructs { tx: ... } without inputIndexes. This is fine as a forward-looking field for providers that support partial signing, but worth a doc comment noting the current behavior (sign all inputs when omitted).

  3. Error path when batch signing fails mid-flight. If signMultiple throws after the user has already confirmed in the wallet popup (e.g., wallet returns a partially-signed set), the code falls through to the submitTx call with signedVirtualTx from the batch. Since there is no fallback to sequential signing on batch failure, the tx will fail at submit. This is probably acceptable — a failed batch sign should abort — but worth documenting that this is intentional (no retry with sequential fallback).

  4. combineTapscriptSigs direction. The merge direction is combineTapscriptSigs(userSigned, serverSigned) — this copies user sigs onto the server PSBT. Confirm that combineTapscriptSigs mutates the second argument (target) and copies from the first (source). If it is the other way around, the sigs will be on the wrong PSBT and finalizeTx will fail.

Verdict

Looks good. The length validation (point 1) is the main thing I would add before merge — everything else is minor.

Kukks added a commit to arkade-os/packages that referenced this pull request Apr 1, 2026
Browser wallet provider Identity implementations for the Arkade SDK:
- UnisatIdentity (batch signing via signPsbts)
- OkxIdentity (batch signing via signPsbts)
- LeatherIdentity (single sign only)
- PhantomIdentity (single sign only)

UniSat and OKX implement BatchSignableIdentity, enabling the SDK
to batch-sign all checkpoint + main tx PSBTs in a single wallet popup
(depends on arkade-os/ts-sdk#395).

Includes a Vite test app at apps/wallet-providers-test/ for manual
testing with installed browser extensions.
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