Skip to content
Open
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
127 changes: 127 additions & 0 deletions src/arkcash/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { bech32m } from "@scure/base";
import { pubSchnorr, randomPrivateKeyBytes } from "@scure/btc-signer/utils.js";
import { SingleKey } from "../identity/singleKey";
import { DefaultVtxo } from "../script/default";
import { ArkAddress } from "../script/address";
import { RelativeTimelock } from "../script/tapscript";
import {
sequenceToTimelock,
timelockToSequence,
} from "../contracts/handlers/helpers";

/**
* ArkCash is a bearer instrument for the Ark protocol.
* It encodes a private key and contract parameters as a bech32m string,
* enabling wallet-to-wallet transfers without address exchange.
*
* Format: arkcash1... (bech32m encoded)
* Payload: version (1 byte) + private key (32 bytes) + server pubkey (32 bytes) + csv timelock sequence (4 bytes)
*/
export class ArkCash {
static readonly DefaultHRP = "arkcash";
static readonly Version = 0;
static readonly PayloadLength = 1 + 32 + 32 + 4; // 69 bytes

readonly publicKey: Uint8Array;

constructor(
readonly privateKey: Uint8Array,
readonly serverPubKey: Uint8Array,
readonly csvTimelock: RelativeTimelock,
readonly hrp: string = ArkCash.DefaultHRP
) {
if (privateKey.length !== 32) {
throw new Error(
`Invalid private key length: expected 32 bytes, got ${privateKey.length}`
);
}
if (serverPubKey.length !== 32) {
throw new Error(
`Invalid server public key length: expected 32 bytes, got ${serverPubKey.length}`
);
}
this.publicKey = pubSchnorr(privateKey);
}

static generate(
serverPubKey: Uint8Array,
csvTimelock: RelativeTimelock,
hrp?: string
): ArkCash {
return new ArkCash(
randomPrivateKeyBytes(),
serverPubKey,
csvTimelock,
hrp
);
}

static fromString(encoded: string): ArkCash {
const decoded = bech32m.decodeUnsafe(
encoded.trim().toLowerCase(),
1023
);
Comment on lines +60 to +63
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

🧩 Analysis chain

🌐 Web query:

Does BIP-173/BIP-350 require decoders to reject mixed-case bech32/bech32m strings, and does @scure/base bech32m.decodeUnsafe preserve that check when the caller does not lowercase the input first?

💡 Result:

Yes.

  • BIP-173 explicitly requires that decoders MUST NOT accept mixed-case strings (strings containing both uppercase and lowercase characters). It allows all-lowercase or all-uppercase inputs, but not a mix. [1]
  • BIP-350 (bech32m) changes only the checksum constant and says all other aspects of Bech32 remain unchanged, and it includes a “Mixed case” example among invalid test vectors—so the same mixed-case rejection rule applies to bech32m too. [2]

And @scure/base’s bech32m.decodeUnsafe preserves that rule even if the caller does not lowercase first:

  • In @scure/base v2.0.0, the internal decode() does:
    1. lowered = str.toLowerCase()
    2. rejects if str is neither exactly lowered (all-lowercase) nor exactly str.toUpperCase() (all-uppercase): if (str !== lowered && str !== str.toUpperCase()) throw ... (i.e., mixed case is rejected)
    3. then proceeds using lowered for parsing/checksum
  • decodeUnsafe is just an unsafeWrapper(decode) (returns undefined instead of throwing), so the mixed-case check remains enforced. [3]

Sources:
[1] BIP-173 “Uppercase/lowercase” rule (mixed-case rejection) https://en.bitcoin.it/wiki/BIP_0173
[2] BIP-350 “All other aspects … unchanged” + invalid vector labeled “Mixed case” https://en.bitcoin.it/wiki/BIP_0350
[3] @scure/base v2.0.0 genBech32().decode() and decodeUnsafe implementation https://app.unpkg.com/@scure/base@2.0.0/files/index.ts


Remove .toLowerCase() to enforce strict bech32m format validation.

BIP-173 and BIP-350 require decoders to reject mixed-case bech32/bech32m strings, accepting only all-lowercase or all-uppercase inputs. By lowercasing the input before decoding, the code bypasses this validation check built into @scure/base's bech32m.decodeUnsafe. For a bearer token, enforce strict format compliance by passing the trimmed string as-is and add a regression test for mixed-case rejection.

🧩 Suggested change
-        const decoded = bech32m.decodeUnsafe(
-            encoded.trim().toLowerCase(),
-            1023
-        );
+        const decoded = bech32m.decodeUnsafe(encoded.trim(), 1023);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const decoded = bech32m.decodeUnsafe(
encoded.trim().toLowerCase(),
1023
);
const decoded = bech32m.decodeUnsafe(encoded.trim(), 1023);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/arkcash/index.ts` around lines 60 - 63, The call to bech32m.decodeUnsafe
in src/arkcash/index.ts currently lowercases the input
(encoded.trim().toLowerCase()), which disables strict mixed-case rejection;
change it to pass the trimmed string as-is (encoded.trim()) into
bech32m.decodeUnsafe so the library enforces BIP-173/BIP-350 validation, update
any surrounding validation logic that assumes lowercasing (e.g., where decoded
is used), and add a regression test that supplies a mixed-case bech32m string to
verify it is rejected.

if (!decoded) {
throw new Error("Invalid arkcash string: failed to decode bech32m");
}

const data = new Uint8Array(bech32m.fromWords(decoded.words));
if (data.length !== ArkCash.PayloadLength) {
throw new Error(
`Invalid arkcash data length: expected ${ArkCash.PayloadLength} bytes, got ${data.length}`
);
}

const version = data[0];
if (version !== ArkCash.Version) {
throw new Error(`Unsupported arkcash version: ${version}`);
}

const privateKey = data.slice(1, 33);
const serverPubKey = data.slice(33, 65);
const sequence = new DataView(
data.buffer,
data.byteOffset + 65,
4
).getUint32(0, false);
const csvTimelock = sequenceToTimelock(sequence);

return new ArkCash(
privateKey,
serverPubKey,
csvTimelock,
decoded.prefix
);
}

toString(): string {
const data = new Uint8Array(ArkCash.PayloadLength);
data[0] = ArkCash.Version;
data.set(this.privateKey, 1);
data.set(this.serverPubKey, 33);
const sequence = timelockToSequence(this.csvTimelock);
new DataView(data.buffer, data.byteOffset + 65, 4).setUint32(
0,
sequence,
false
);
const words = bech32m.toWords(data);
return bech32m.encode(this.hrp, words, 1023);
}

get identity(): SingleKey {
return SingleKey.fromPrivateKey(this.privateKey);
}

get vtxoScript(): DefaultVtxo.Script {
return new DefaultVtxo.Script({
pubKey: this.publicKey,
serverPubKey: this.serverPubKey,
csvTimelock: this.csvTimelock,
});
}

address(addressHrp: string): ArkAddress {
return this.vtxoScript.address(addressHrp, this.serverPubKey);
}
}
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ import {
import { Intent } from "./intent";
import { BIP322 } from "./bip322";
import { ArkNote } from "./arknote";
import { ArkCash } from "./arkcash";
import { networks, Network, NetworkName } from "./networks";
import {
RestIndexerProvider,
Expand Down Expand Up @@ -314,6 +315,9 @@ export {
// Arknote
ArkNote,

// Arkcash
ArkCash,

// Network
networks,

Expand Down
141 changes: 141 additions & 0 deletions src/wallet/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import {
VtxoManager,
} from "./vtxo-manager";
import { ArkNote } from "../arknote";
import { ArkCash } from "../arkcash";
import { Intent } from "../intent";
import { IndexerProvider, RestIndexerProvider } from "../providers/indexer";
import { TxTree } from "../tree/txTree";
Expand Down Expand Up @@ -1940,6 +1941,146 @@ export class Wallet extends ReadonlyWallet implements IWallet {
return { finalized, pending };
}

/**
* Create an ArkCash bearer instrument.
*
* Generates a fresh keypair, sends the specified amount to a DefaultVtxo
* controlled by the new key, and returns the encoded arkcash string.
* The receiver can claim the funds using `claimCash()` without ever
* sharing their address.
*
* @param amount - Amount in satoshis to send
* @returns The encoded arkcash string (e.g., "arkcash1...")
*/
async createCash(amount: number): Promise<string> {
const info = await this.arkProvider.getInfo();
const serverPubKey = hex.decode(info.signerPubkey).slice(1);

const csvTimelock: RelativeTimelock = {
value: info.unilateralExitDelay,
type: info.unilateralExitDelay < 512n ? "blocks" : "seconds",
};

// Derive HRP: ark→arkcash, tark→tarkcash, nark→narkcash
const cashHrp = this.network.hrp.replace(/ark$/, "arkcash");

const cash = ArkCash.generate(serverPubKey, csvTimelock, cashHrp);
const address = cash.address(this.network.hrp).encode();

await this.send({ address, amount });

return cash.toString();
}

/**
* Claim an ArkCash bearer instrument.
*
* Parses the arkcash string, queries the server for VTXOs belonging to
* the arkcash contract, and sweeps them to the wallet's own address.
*
* If VTXOs are spendable and above dust, they are swept offchain instantly.
* Otherwise, the arkcash contract is imported and VTXOs appear in the
* wallet's balance for manual management.
*
* Note: when VTXOs are imported as a contract, the arkcash private key
* is not stored. Retain the original arkcash string if you need to
* unilaterally exit these VTXOs later.
*
* @param cashStr - The encoded arkcash string (e.g., "arkcash1...")
* @returns Object with `swept` (amount swept offchain) and `imported` (amount imported as contract)
*/
async claimCash(
cashStr: string
): Promise<{ swept: number; imported: number }> {
const cash = ArkCash.fromString(cashStr);
const cashIdentity = cash.identity;
const cashPubKey = cash.publicKey;
const cashScript = cash.vtxoScript;
const cashPkScript = hex.encode(cashScript.pkScript);

// Query spendable VTXOs for the arkcash contract
const { vtxos } = await this.indexerProvider.getVtxos({
scripts: [cashPkScript],
spendableOnly: true,
});

if (vtxos.length === 0) {
throw new Error("No VTXOs found for this arkcash");
}

const myAddress = await this.getAddress();

// Separate spendable VTXOs from those that need import
const spendable: typeof vtxos = [];
const toImport: typeof vtxos = [];
let sweptAmount = 0;
let importedAmount = 0;

for (const vtxo of vtxos) {
if (
isSpendable(vtxo) &&
!isRecoverable(vtxo) &&
!isSubdust(vtxo, this.dustAmount) &&
vtxo.virtualStatus.state !== "swept"
) {
spendable.push(vtxo);
sweptAmount += vtxo.value;
} else {
toImport.push(vtxo);
importedAmount += vtxo.value;
}
}

// Sweep spendable VTXOs to own address
if (spendable.length > 0) {
// Create a temporary wallet with the arkcash identity to sign the sweep
const cashWallet = await Wallet.create({
identity: cashIdentity,
arkProvider: this.arkProvider,
indexerProvider: this.indexerProvider,
onchainProvider: this.onchainProvider,
storage: {
walletRepository: this.walletRepository,
contractRepository: this.contractRepository,
},
});

try {
await cashWallet.send({
address: myAddress,
amount: sweptAmount,
});
} catch (error) {
throw new Error(
`Failed to sweep arkcash VTXOs — they may have been claimed by another party: ${error instanceof Error ? error.message : String(error)}`
);
}
Comment on lines +2037 to +2057
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

Avoid Wallet.create() for the ArkCash sweep.

This rebuilds the contract from current getInfo() values, so a token minted before a signer/timelock rotation can stop matching the temporary wallet, and the temp wallet is never disposed even though Wallet.create() eagerly starts a VtxoManager. Sweep against cash.vtxoScript directly, or at least disable settlement and dispose the temp wallet in finally.

🩹 Minimum mitigation
             const cashWallet = await Wallet.create({
                 identity: cashIdentity,
                 arkProvider: this.arkProvider,
                 indexerProvider: this.indexerProvider,
                 onchainProvider: this.onchainProvider,
+                settlementConfig: false,
                 storage: {
                     walletRepository: this.walletRepository,
                     contractRepository: this.contractRepository,
                 },
             });

             try {
                 await cashWallet.send({
                     address: myAddress,
                     amount: sweptAmount,
                 });
             } catch (error) {
                 throw new Error(
                     `Failed to sweep arkcash VTXOs — they may have been claimed by another party: ${error instanceof Error ? error.message : String(error)}`
                 );
+            } finally {
+                await cashWallet.dispose();
             }
Based on learnings, `new VtxoManager(wallet)` is intentionally default-enabled unless `settlementConfig: false` is passed.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wallet/wallet.ts` around lines 2037 - 2057, The temporary
Wallet.create(...) used for sweeping ArkCash should not be used because it
rebuilds the contract and eagerly starts a VtxoManager; instead perform the
sweep against the existing cash.vtxoScript (i.e., use the cash contract’s
vtxoScript as the UTXO selector/witness for the send/sweep operation) OR if you
must construct a temporary wallet, create it with settlement disabled (pass
settlementConfig: false / or the equivalent option that prevents new VtxoManager
creation) and ensure you call wallet.dispose() in a finally block; update the
try/catch around the sweep to still surface the original error message but
guarantee disposal when a temp wallet was created.

}

// Import remaining VTXOs as a contract
if (toImport.length > 0) {
const manager = await this.getContractManager();
const csvTimelockStr = timelockToSequence(
cash.csvTimelock
).toString();

await manager.createContract({
type: "default",
params: {
pubKey: hex.encode(cashPubKey),
serverPubKey: hex.encode(cash.serverPubKey),
csvTimelock: csvTimelockStr,
},
script: cashPkScript,
address: cash.address(this.network.hrp).encode(),
state: "active",
label: "arkcash-import",
});
}

return { swept: sweptAmount, imported: importedAmount };
}

/**
* Send BTC and/or assets to one or more recipients.
*
Expand Down
Loading
Loading