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
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,42 @@ const balance = await readonlyWallet.getBalance()

The descriptor format (`tr([fingerprint/path']xpub.../0/0)`) is HD-ready — future versions will support deriving multiple addresses and change outputs from the same seed.

### Batch Signing for Browser Wallets

Arkade send transactions require N+1 PSBT signatures (N checkpoints + 1 main tx). With local identities like `SingleKey` or `SeedIdentity` this is invisible, but browser wallet extensions (Xverse, UniSat, OKX, etc.) show a confirmation popup per signature. The `BatchSignableIdentity` interface lets wallet providers reduce N+1 popups to a single batch confirmation.

```typescript
import {
BatchSignableIdentity,
SignRequest,
isBatchSignable,
Wallet
} from '@arkade-os/sdk'

// Implement the interface in your wallet provider
class MyBrowserWallet implements BatchSignableIdentity {
// ... implement Identity methods (sign, signMessage, xOnlyPublicKey, etc.)

async signMultiple(requests: SignRequest[]): Promise<Transaction[]> {
// Convert all PSBTs to your wallet's batch signing API format
const psbts = requests.map(r => r.tx.toPSBT())
// Single wallet popup for all signatures
const signedPsbts = await myWalletExtension.signAllPSBTs(psbts)
return signedPsbts.map(psbt => Transaction.fromPSBT(psbt))
}
}

// The SDK automatically detects batch-capable identities
const identity = new MyBrowserWallet()
console.log(isBatchSignable(identity)) // true

// Wallet.send() uses one popup instead of N+1
const wallet = await Wallet.create({ identity, arkServerUrl: '...' })
await wallet.sendBitcoin({ address: arkAddress, amount: 1000 })
```

Identities without `signMultiple` continue to work unchanged — each checkpoint is signed individually via `sign()`.

### Receiving Bitcoin

```typescript
Expand Down
30 changes: 30 additions & 0 deletions src/identity/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,35 @@ export interface ReadonlyIdentity {
compressedPublicKey(): Promise<Uint8Array>;
}

/** A single PSBT signing request within a batch. */
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.
*
* Contract: implementations MUST return exactly one `Transaction` per request,
* in the same order as the input array. The SDK validates this at runtime and
* will throw if the lengths do not match.
*/
export interface BatchSignableIdentity extends Identity {
signMultiple(requests: SignRequest[]): Promise<Transaction[]>;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/** Type guard for identities that support batch signing. */
export function isBatchSignable(
identity: Identity
): identity is BatchSignableIdentity {
return (
"signMultiple" in identity &&
typeof (identity as BatchSignableIdentity).signMultiple === "function"
);
}

export * from "./singleKey";
export * from "./seedIdentity";
11 changes: 10 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ import type {
NetworkOptions,
DescriptorOptions,
} from "./identity/seedIdentity";
import { Identity, ReadonlyIdentity } from "./identity";
import {
Identity,
ReadonlyIdentity,
BatchSignableIdentity,
SignRequest,
isBatchSignable,
} from "./identity";
import { ArkAddress } from "./script/address";
import { VHTLC } from "./script/vhtlc";
import { DefaultVtxo } from "./script/default";
Expand Down Expand Up @@ -263,6 +269,7 @@ export {
SeedIdentity,
MnemonicIdentity,
ReadonlyDescriptorIdentity,
isBatchSignable,
OnchainWallet,
Ramps,
VtxoManager,
Expand Down Expand Up @@ -400,6 +407,8 @@ export type {
// Types and Interfaces
Identity,
ReadonlyIdentity,
BatchSignableIdentity,
SignRequest,
IWallet,
IReadonlyWallet,
BaseWalletConfig,
Expand Down
55 changes: 46 additions & 9 deletions src/wallet/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
validateVtxoTxGraph,
} from "../tree/validation";
import { validateBatchRecipients } from "./validation";
import { Identity, ReadonlyIdentity } from "../identity";
import { Identity, ReadonlyIdentity, isBatchSignable } from "../identity";
import {
ArkTransaction,
Asset,
Expand Down Expand Up @@ -62,6 +62,7 @@ import { getSequence, VtxoScript } from "../script/base";
import { CSVMultisigTapscript, RelativeTimelock } from "../script/tapscript";
import {
buildOffchainTx,
combineTapscriptSigs,
hasBoardingTxExpired,
isValidArkAddress,
} from "../utils/arkTransaction";
Expand Down Expand Up @@ -2485,7 +2486,29 @@ export class Wallet extends ReadonlyWallet implements IWallet {
outputs,
this.serverUnrollScript
);
const signedVirtualTx = await this.identity.sign(offchainTx.arkTx);

let signedVirtualTx: Transaction;
let userSignedCheckpoints: Transaction[] | undefined;

if (isBatchSignable(this.identity)) {
// Batch-sign arkTx + all checkpoints in one wallet popup.
// Clone so the provider can't mutate originals before submitTx.
const requests = [
{ tx: offchainTx.arkTx.clone() },
...offchainTx.checkpoints.map((c) => ({ tx: c.clone() })),
];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const signed = await this.identity.signMultiple(requests);
if (signed.length !== requests.length) {
throw new Error(
`signMultiple returned ${signed.length} transactions, expected ${requests.length}`
);
}
const [firstSignedTx, ...signedCheckpoints] = signed;
signedVirtualTx = firstSignedTx;
userSignedCheckpoints = signedCheckpoints;
} else {
signedVirtualTx = await this.identity.sign(offchainTx.arkTx);
}

// Mark pending before submitting — if we crash between submit and
// finalize, the next init will recover via finalizePendingTxs.
Expand All @@ -2496,13 +2519,27 @@ export class Wallet extends ReadonlyWallet implements IWallet {
base64.encode(signedVirtualTx.toPSBT()),
offchainTx.checkpoints.map((c) => base64.encode(c.toPSBT()))
);
const finalCheckpoints = await Promise.all(
signedCheckpointTxs.map(async (c) => {
const tx = Transaction.fromPSBT(base64.decode(c));
const signedCheckpoint = await this.identity.sign(tx);
return base64.encode(signedCheckpoint.toPSBT());
})
);

let finalCheckpoints: string[];

if (userSignedCheckpoints) {
// Merge pre-signed user signatures onto server-signed checkpoints
finalCheckpoints = signedCheckpointTxs.map((c, i) => {
const serverSigned = Transaction.fromPSBT(base64.decode(c));
combineTapscriptSigs(userSignedCheckpoints![i], serverSigned);
return base64.encode(serverSigned.toPSBT());
});
Comment on lines +2525 to +2531
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 | 🟡 Minor

Validate submitTx() returned one checkpoint per pre-signed checkpoint.

On Line 2527, this branch indexes userSignedCheckpoints[i] for every server checkpoint but never checks that both arrays have the same length. A short or extra signedCheckpointTxs response will either discard a pre-signed checkpoint or pass undefined into combineTapscriptSigs(), which turns a protocol mismatch into a harder-to-diagnose runtime failure.

Possible fix
         if (userSignedCheckpoints) {
+            if (signedCheckpointTxs.length !== userSignedCheckpoints.length) {
+                throw new Error(
+                    `submitTx returned ${signedCheckpointTxs.length} checkpoint(s), expected ${userSignedCheckpoints.length}`
+                );
+            }
             // Merge pre-signed user signatures onto server-signed checkpoints
             finalCheckpoints = signedCheckpointTxs.map((c, i) => {
                 const serverSigned = Transaction.fromPSBT(base64.decode(c));
-                combineTapscriptSigs(userSignedCheckpoints![i], serverSigned);
+                combineTapscriptSigs(userSignedCheckpoints[i], serverSigned);
                 return base64.encode(serverSigned.toPSBT());
             });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wallet/wallet.ts` around lines 2525 - 2531, The code merges server
checkpoints into pre-signed user checkpoints but never verifies the arrays
match, so ensure submitTx() returned exactly one server checkpoint per
pre-signed checkpoint: before the mapping that produces finalCheckpoints,
compare signedCheckpointTxs.length with userSignedCheckpoints.length (or handle
the expected cardinality) and if they differ, surface a clear error/throw with
context (including counts and identifiers) rather than proceeding; only call
Transaction.fromPSBT and combineTapscriptSigs when lengths match (or explicitly
align entries), referencing signedCheckpointTxs, userSignedCheckpoints,
finalCheckpoints, submitTx(), Transaction.fromPSBT and combineTapscriptSigs to
locate the fix.

} else {
// Legacy: sign each checkpoint individually (N popups)
finalCheckpoints = await Promise.all(
signedCheckpointTxs.map(async (c) => {
const tx = Transaction.fromPSBT(base64.decode(c));
const signedCheckpoint = await this.identity.sign(tx);
return base64.encode(signedCheckpoint.toPSBT());
})
);
}

await this.arkProvider.finalizeTx(arkTxid, finalCheckpoints);

try {
Expand Down
116 changes: 116 additions & 0 deletions test/batchSignable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, it, expect, vi } from "vitest";
import {
isBatchSignable,
BatchSignableIdentity,
SignRequest,
Identity,
} from "../src/identity";
import { Transaction } from "../src/utils/transaction";
import { SignerSession, TreeSignerSession } from "../src/tree/signingSession";

function stubIdentity(): Identity {
return {
async xOnlyPublicKey() {
return new Uint8Array(32);
},
async compressedPublicKey() {
return new Uint8Array(33);
},
signerSession(): SignerSession {
return TreeSignerSession.random();
},
async sign(tx: Transaction) {
return tx;
},
async signMessage() {
return new Uint8Array(64);
},
};
}

function stubBatchIdentity(
signMultipleFn?: (requests: SignRequest[]) => Promise<Transaction[]>
): BatchSignableIdentity {
const base = stubIdentity();
return {
...base,
signMultiple:
signMultipleFn ??
(async (requests: SignRequest[]) =>
requests.map((r) => r.tx.clone())),
};
}

describe("isBatchSignable", () => {
it("should return true for BatchSignableIdentity", () => {
const identity = stubBatchIdentity();
expect(isBatchSignable(identity)).toBe(true);
});

it("should return false for plain Identity", () => {
const identity = stubIdentity();
expect(isBatchSignable(identity)).toBe(false);
});

it("should return false if signMultiple is not a function", () => {
const identity = stubIdentity() as any;
identity.signMultiple = "not a function";
expect(isBatchSignable(identity)).toBe(false);
});
});

describe("BatchSignableIdentity contract", () => {
it("should return same number of transactions as requests", async () => {
const identity = stubBatchIdentity();
const tx = new Transaction();
const requests: SignRequest[] = [
{ tx: tx.clone() },
{ tx: tx.clone() },
{ tx: tx.clone() },
];
const results = await identity.signMultiple(requests);
expect(results).toHaveLength(requests.length);
});

it("should handle empty requests", async () => {
const identity = stubBatchIdentity();
const results = await identity.signMultiple([]);
expect(results).toEqual([]);
});

it("should pass inputIndexes through to each request", async () => {
const receivedRequests: SignRequest[] = [];
const identity = stubBatchIdentity(async (requests) => {
receivedRequests.push(...requests);
return requests.map((r) => r.tx.clone());
});

const tx = new Transaction();
await identity.signMultiple([
{ tx: tx.clone(), inputIndexes: [0, 2] },
{ tx: tx.clone() },
]);

expect(receivedRequests[0].inputIndexes).toEqual([0, 2]);
expect(receivedRequests[1].inputIndexes).toBeUndefined();
});

it("should preserve request order in results", async () => {
const markers: string[] = [];
const identity = stubBatchIdentity(async (requests) => {
return requests.map((r, i) => {
markers.push(`signed-${i}`);
return r.tx.clone();
});
});

const tx = new Transaction();
await identity.signMultiple([
{ tx: tx.clone() },
{ tx: tx.clone() },
{ tx: tx.clone() },
]);

expect(markers).toEqual(["signed-0", "signed-1", "signed-2"]);
});
});
Loading