-
Notifications
You must be signed in to change notification settings - Fork 22
feat: add ArkCash bearer instruments for address-free transfers #337
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
3fc8fc3
f499ac2
ae6b98f
d874b63
765630b
a9f2269
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| ); | ||
| 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); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"; | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid This rebuilds the contract from current 🩹 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();
}🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| // 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. | ||
| * | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
Does BIP-173/BIP-350 require decoders to reject mixed-case bech32/bech32m strings, and does@scure/basebech32m.decodeUnsafepreserve that check when the caller does not lowercase the input first?💡 Result:
Yes.
And
@scure/base’sbech32m.decodeUnsafepreserves that rule even if the caller does not lowercase first:@scure/basev2.0.0, the internaldecode()does:lowered = str.toLowerCase()stris neither exactlylowered(all-lowercase) nor exactlystr.toUpperCase()(all-uppercase):if (str !== lowered && str !== str.toUpperCase()) throw ...(i.e., mixed case is rejected)loweredfor parsing/checksumdecodeUnsafeis just anunsafeWrapper(decode)(returnsundefinedinstead 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/basev2.0.0genBech32().decode()anddecodeUnsafeimplementation https://app.unpkg.com/@scure/base@2.0.0/files/index.tsRemove
.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'sbech32m.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
📝 Committable suggestion
🤖 Prompt for AI Agents