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
125 changes: 125 additions & 0 deletions packages/ootle-wallet-daemon-signer/src/daemon-stealth-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Copyright 2024 The Tari Project
// SPDX-License-Identifier: BSD-3-Clause

import type {
AccountsCreateStealthTransferStatementResponse,
ComponentAddressOrName,
OotleAddress,
ResourceAddress,
StealthTransferStatement as BindingsStealthTransferStatement,
TransferOutput,
} from "@tari-project/ootle-ts-bindings";
import type { WalletDaemonClient } from "@tari-project/wallet_jrpc_client";
import type { StealthOutputStatementFactory, StealthTransferStatement } from "@tari-project/ootle";

export interface DaemonStealthFactoryOptions {
/** The wallet daemon JRPC client (obtain via `WalletDaemonSigner.getClient()`). */
client: WalletDaemonClient;
/** The sender account that owns the stealth UTXOs to spend. */
senderAccount: ComponentAddressOrName;
/** The resource to transfer (e.g. the Tari token). */
resourceAddress: ResourceAddress;
/** The recipient's full Ootle address (bech32m-encoded, includes both owner and view key). */
recipientAddress: OotleAddress;
}

/**
* A `StealthOutputStatementFactory` backed by a wallet daemon's
* `accounts.create_stealth_transfer_statement` JRPC endpoint.
*
* The wallet daemon handles all cryptographic operations (DH-KDF, Pedersen
* commitments, range proofs, balance proofs) server-side. No WASM crypto
* is required on the client.
*
* ## Usage
*
* ```ts
* const signer = await WalletDaemonSigner.connect({ url: "http://localhost:18103" });
* const factory = new DaemonStealthFactory({
* client: signer.getClient(),
* senderAccount: { Name: "default" },
* resourceAddress: TARI_TOKEN,
* recipientAddress: "ootle1...",
* });
*
* const spec = await new StealthTransfer(network, factory)
* .from(sourceAccount, resourceAddress)
* .to(recipientPublicKeyHex, 1_000_000n)
* .feeFrom(feeAccount, 1000n)
* .build();
* ```
*
* ## Type mapping
*
* The wallet daemon returns statements in the canonical bindings format
* (`@tari-project/ootle-ts-bindings` `StealthTransferStatement`), which uses
* `inputs_statement` / `outputs_statement` / `balance_proof`.
*
* The `@tari-project/ootle` package currently defines a simplified
* `StealthTransferStatement` with `outputs` / `balanceProof`. These types
* are structurally incompatible. This factory returns the **bindings format**
* (cast to satisfy the interface) because that is the format the on-chain
* `deposit_stealth` method expects when deserializing the JSON payload.
*
* A follow-up PR should align the `@tari-project/ootle` types with the
* bindings to eliminate this cast.
*/
export class DaemonStealthFactory implements StealthOutputStatementFactory {
private readonly client: WalletDaemonClient;
private readonly senderAccount: ComponentAddressOrName;
private readonly resourceAddress: ResourceAddress;
private readonly recipientAddress: OotleAddress;

constructor(options: DaemonStealthFactoryOptions) {
this.client = options.client;
this.senderAccount = options.senderAccount;
this.resourceAddress = options.resourceAddress;
this.recipientAddress = options.recipientAddress;
}

/**
* Generates a stealth transfer statement by delegating to the wallet daemon.
*
* @param _recipientPublicKeyHex - Ignored in the daemon flow. The recipient is
* identified by the `recipientAddress` provided at construction time (which
* embeds the public key). This parameter is accepted to satisfy the
* `StealthOutputStatementFactory` interface.
* @param amounts - Amount(s) to send in each stealth output.
*/
public async generateOutputsStatement(
_recipientPublicKeyHex: string,
amounts: bigint[],
Comment on lines +83 to +91
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The _recipientPublicKeyHex parameter is ignored in favor of the recipientAddress provided during construction. This creates a risk where a caller of the StealthTransfer builder might specify a different recipient via .to(key, amount) than the one this factory is configured for, leading to funds being sent to the wrong destination without any warning or error.

Since the StealthOutputStatementFactory interface only provides a hex public key, but the wallet daemon requires a full OotleAddress (which includes the view key), this factory is effectively pinned to a single recipient. You should consider adding a check to verify that _recipientPublicKeyHex matches the public key part of this.recipientAddress (if possible to decode) or at least document this limitation more explicitly in the method body to prevent accidental misuse.

): Promise<StealthTransferStatement> {
const outputs: TransferOutput[] = amounts.map((amount) => ({
address: this.recipientAddress,
revealed_amount: 0,
blinded_amount: amount,
memo: null,
pay_to: "StealthPublicKey" as const,
}));

const response =
await this.client.sendRequest<AccountsCreateStealthTransferStatementResponse>(
"accounts.create_stealth_transfer_statement",
{
requests: [
{
sender_account: this.senderAccount,
resource_address: this.resourceAddress,
input_selection: { Selection: "PreferConfidential" },
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The input_selection strategy is hardcoded to PreferConfidential. While this is a sensible default for stealth transfers, it limits flexibility for users who may need to specify a different selection policy (e.g., OnlyConfidential). Consider making this configurable via DaemonStealthFactoryOptions.

outputs,
},
],
},
);

if (!response.statements || response.statements.length === 0) {
throw new Error("Wallet daemon returned no stealth transfer statements");
}

// The daemon returns the canonical bindings format. We cast to the tari.js
// StealthTransferStatement type. See class-level JSDoc for the type mismatch
// discussion — the bindings format is what deposit_stealth actually expects.
return response.statements[0] as unknown as StealthTransferStatement;
}
}
16 changes: 16 additions & 0 deletions packages/ootle-wallet-daemon-signer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ export { WalletDaemonSigner } from "./wallet-daemon-signer";
export type { WalletDaemonSignerOptions } from "./wallet-daemon-signer";
export { authenticate } from "./auth";
export type { AuthOptions } from "./auth";
export { DaemonStealthFactory } from "./daemon-stealth-factory";
export type { DaemonStealthFactoryOptions } from "./daemon-stealth-factory";

// Re-export from @tari-project/wallet_jrpc_client so consumers don't need a direct dependency
export { WalletDaemonClient } from "@tari-project/wallet_jrpc_client";

// Re-export commonly used stealth types from bindings for convenience
export type {
StealthTransferRequest,
StealthTransferResponse,
StealthUtxosListRequest,
StealthUtxosListResponse,
StealthUtxosDecryptValueRequest,
StealthUtxosDecryptValueResponse,
AccountsAssociateStealthResourceRequest,
AccountsAssociateStealthResourceResponse,
AccountsCreateStealthTransferStatementRequest,
AccountsCreateStealthTransferStatementResponse,
} from "@tari-project/ootle-ts-bindings";
88 changes: 87 additions & 1 deletion packages/ootle-wallet-daemon-signer/src/wallet-daemon-signer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
// Copyright 2024 The Tari Project
// SPDX-License-Identifier: BSD-3-Clause

import type { TransactionSignature, UnsignedTransactionV1 } from "@tari-project/ootle-ts-bindings";
import type {
TransactionSignature,
UnsignedTransactionV1,
AccountsAssociateStealthResourceRequest,
AccountsAssociateStealthResourceResponse,
AccountsCreateStealthTransferStatementRequest,
AccountsCreateStealthTransferStatementResponse,
StealthTransferRequest,
StealthTransferResponse,
StealthUtxosListRequest,
StealthUtxosListResponse,
StealthUtxosDecryptValueRequest,
StealthUtxosDecryptValueResponse,
} from "@tari-project/ootle-ts-bindings";
import { WalletDaemonClient } from "@tari-project/wallet_jrpc_client";
import { type Signer, fromHexStr } from "@tari-project/ootle";
import { authenticate, type AuthOptions } from "./auth";
Expand Down Expand Up @@ -102,4 +115,77 @@ export class WalletDaemonSigner implements Signer {
this._publicKey = fromHexStr(response.account.owner_public_key);
this._address = response.address;
}

/**
* Returns the underlying `WalletDaemonClient` for advanced JRPC operations
* not covered by the signer's high-level API.
*/
public getClient(): WalletDaemonClient {
return this.client;
}

// ---------------------------------------------------------------------------
// Stealth transfer operations
// ---------------------------------------------------------------------------

/**
* Performs a stealth transfer in one call. The wallet daemon handles statement
* generation, transaction building, signing, and submission internally.
*
* This is the simplest path to a working stealth transfer when a wallet daemon
* is available.
*/
public async stealthTransfer(
params: StealthTransferRequest,
): Promise<StealthTransferResponse> {
return this.client.stealthTransfer(params);
}

/**
* Generates a stealth transfer statement without building or submitting the
* transaction. The returned statement can be used with `TransactionBuilder`
* or `StealthTransfer` for custom transaction construction.
*
* Note: The `WalletDaemonClient` does not yet expose a typed method for this
* endpoint, so we use `sendRequest` directly.
*/
public async createStealthTransferStatement(
params: AccountsCreateStealthTransferStatementRequest,
): Promise<AccountsCreateStealthTransferStatementResponse> {
return this.client.sendRequest<AccountsCreateStealthTransferStatementResponse>(
"accounts.create_stealth_transfer_statement",
params,
);
}

/**
* Associates a resource address with stealth tracking for an account.
* Must be called before the wallet daemon can list or manage stealth UTXOs
* for the given resource.
*/
public async associateStealthResource(
params: AccountsAssociateStealthResourceRequest,
): Promise<AccountsAssociateStealthResourceResponse> {
return this.client.accountsAssociateStealthResource(params);
}

/**
* Lists stealth UTXOs known to the wallet daemon, optionally filtered
* by account and output status.
*/
public async stealthUtxosList(
params: StealthUtxosListRequest,
): Promise<StealthUtxosListResponse> {
return this.client.stealthUtxosList(params);
}

/**
* Decrypts the blinded value of stealth UTXOs using the wallet daemon's
* view key. Returns the decrypted value for each requested UTXO.
*/
public async stealthUtxosDecryptValue(
params: StealthUtxosDecryptValueRequest,
): Promise<StealthUtxosDecryptValueResponse> {
return this.client.stealthUtxosDecryptValue(params);
}
}