pyrxd.agent — sign-on-behalf signing daemon

The optional pyrxd agent daemon holds an unlocked wallet for a bounded window and signs transactions on request — with a per-spend confirmation on its own controlling terminal and prevout-authenticity checks — so the key is removed from the short-lived CLI process and a same-uid caller can only request a signature, never take the key.

pyrxd.agent — local sign-on-behalf signing agent (issue #8, Path A’).

A daemon holds the unlocked wallet and signs transactions the CLI builds watch-only; the key never leaves the agent. Reaching the agent lets a caller request a signature (gated by per-spend confirmation), never take the key.

This package is the in-process signing brain (signer) plus its wire types (protocol). The Unix-socket daemon and the CLI client are layered on top (later phases). It is NOT the generalized Signer seam (that is the deferred Path B); the agent simply wraps HdWallet.

Security model: see docs/plans/2026-06-08-feat-cli-signing-agent-a-prime-plan.md § “Load-bearing safety properties”. The signer independently verifies each input’s prevout (never trusts caller-claimed values), attributes outputs (change re-derived and verified, the rest shown as external), and requires confirmation before signing.

class pyrxd.agent.AgentClient[source]

Bases: object

Talks to a running AgentDaemon.

__init__(socket_path, *, connect_timeout_s=2.0)[source]
Parameters:
Return type:

None

account_xpub()[source]

The account xpub the agent vends (for watch-only tx building).

Return type:

str

is_live()[source]

True iff an agent answers a status query on the socket (never raises).

Return type:

bool

lock()[source]

Ask the agent to lock (zeroize + shut down). No-op if already down.

Return type:

None

sign(request)[source]

Send a signing request; return the signed tx or raise the typed error.

Parameters:

request (SigningRequest)

Return type:

SignedResult

class pyrxd.agent.AgentDaemon[source]

Bases: object

Holds an unlocked wallet and signs requests arriving on a Unix socket.

__init__(wallet, *, socket_path, confirm, idle_timeout_s=900.0, poll_interval_s=0.5, clock=<built-in function monotonic>, harden=True)[source]
Parameters:
Return type:

None

lock()[source]

Zeroize the seed, drop the wallet, and stop serving. Idempotent.

Return type:

None

property locked: bool
serve_forever()[source]

Bind, harden, then serve until locked (idle, on-demand, or signal).

Return type:

None

class pyrxd.agent.AgentSigner[source]

Bases: object

Signs transactions on behalf of an unlocked wallet, without ever exposing the wallet’s keys.

__init__(wallet)[source]
Parameters:

wallet (HdWallet)

Return type:

None

sign(request, *, confirm)[source]
Parameters:
Return type:

SignedResult

class pyrxd.agent.ChangeClaim[source]

Bases: object

A claim that output output_index is change to the wallet’s own change/index key. The agent VERIFIES it by re-deriving that address — a false claim (hiding an external payee as “change”) fails verification and the spend is rejected.

__init__(output_index, change, index)
Parameters:
  • output_index (int)

  • change (int)

  • index (int)

Return type:

None

classmethod from_dict(d)[source]
Parameters:

d (dict)

Return type:

ChangeClaim

to_dict()[source]
Return type:

dict

output_index: int
change: int
index: int
class pyrxd.agent.ExternalOutput[source]

Bases: object

A non-change output (a real payee), shown to the user before signing.

__init__(output_index, dest, amount)
Parameters:
Return type:

None

output_index: int
dest: str
amount: int
class pyrxd.agent.InputToSign[source]

Bases: object

One input the agent must sign.

source_tx_hex is the FULL previous transaction; the agent verifies it hashes to the input’s outpoint and reads the real prevout value/script from it — it never trusts values embedded in the unsigned tx (prevout-authenticity, C1). change/index are the BIP44 chain/index the agent derives the signing key from.

__init__(input_index, change, index, source_tx_hex, sighash=65)
Parameters:
  • input_index (int)

  • change (int)

  • index (int)

  • source_tx_hex (str)

  • sighash (int)

Return type:

None

classmethod from_dict(d)[source]
Parameters:

d (dict)

Return type:

InputToSign

sighash: int = 65
to_dict()[source]
Return type:

dict

input_index: int
change: int
index: int
source_tx_hex: str
class pyrxd.agent.SignedResult[source]

Bases: object

The agent’s response: a fully-signed, broadcast-ready transaction.

Carries a transaction only — never key material (enforced invariant).

__init__(signed_tx_hex)
Parameters:

signed_tx_hex (str)

Return type:

None

classmethod from_dict(d)[source]
Parameters:

d (dict)

Return type:

SignedResult

to_dict()[source]
Return type:

dict

signed_tx_hex: str
exception pyrxd.agent.SignerDeclined[source]

Bases: SignerError

The spend was declined at the confirmation gate (user said no).

exception pyrxd.agent.SignerError[source]

Bases: ValidationError

A signing request was malformed, unauthorized, or failed validation.

exception pyrxd.agent.SignerUnavailableError[source]

Bases: NetworkError

The agent is not reachable (socket absent, locked, or down).

A NetworkError subclass so callers can fall back to the mnemonic prompt without conflating it with a validation failure.

class pyrxd.agent.SigningRequest[source]

Bases: object

An unsigned tx plus everything the agent needs to verify and sign it.

__init__(unsigned_tx_hex, inputs, change_claims=())
Parameters:
Return type:

None

change_claims: tuple[ChangeClaim, ...] = ()
classmethod from_dict(d)[source]
Parameters:

d (dict)

Return type:

SigningRequest

to_dict()[source]
Return type:

dict

unsigned_tx_hex: str
inputs: tuple[InputToSign, ...]
class pyrxd.agent.SpendSummary[source]

Bases: object

What the agent is about to sign — handed to the confirmation gate.

Derived entirely from the verified tx (prevouts checked, change claims re-derived), never from caller-asserted free-form values.

__init__(external_outputs, total_external, change_total, input_total, fee, sighash_flags=<factory>)
Parameters:
Return type:

None

external_outputs: tuple[ExternalOutput, ...]
total_external: int
change_total: int
input_total: int
fee: int
sighash_flags: tuple[int, ...]
class pyrxd.agent.TtyConfirmer[source]

Bases: object

Confirms spends on the daemon’s controlling terminal (/dev/tty).

auto_confirm_under lets small spends (total external ≤ threshold) skip the prompt — documented as outside the trust boundary. With no tty available the call fails closed (returns False).

__init__(*, auto_confirm_under=0)[source]
Parameters:

auto_confirm_under (int)

Return type:

None

class pyrxd.agent.UnsignedSend[source]

Bases: object

The watch-only build result: an unsigned tx + the request to sign it.

input_total is the summed value of the SELECTED inputs (the builder picks a subset), so the caller can show an accurate fee = input_total − Σ outputs.

__init__(transaction, request, input_total)
Parameters:
Return type:

None

transaction: Transaction
request: SigningRequest
input_total: int
class pyrxd.agent.WatchOnlyScan[source]

Bases: object

Result of a watch-only scan: spendable UTXOs + the next unused change index.

__init__(utxos, next_change_index)
Parameters:
Return type:

None

utxos: list[WatchOnlyUtxo]
next_change_index: int
class pyrxd.agent.WatchOnlyTxBuilder[source]

Bases: object

Builds unsigned P2PKH sends + signing requests from an account xpub.

Holds only public material; structurally cannot derive a private key. The account xpub is the one the agent vends on unlock (m/44'/<coin>'/<acct>'), so addresses derived here match exactly what the agent re-derives when it verifies ownership and change claims.

__init__(account_xpub)[source]
Parameters:

account_xpub (Xpub)

Return type:

None

address(change, index)[source]

The P2PKH address at change/index — public derivation, no key.

Parameters:
Return type:

str

build_send(utxos, to_address, photons, *, change_index, change_chain=1, fee_rate=10000)[source]

Build an unsigned send of photons to to_address with change to change_chain/change_index. Returns the unsigned tx + a SigningRequest.

Mirrors HdWallet.build_send_tx()’s greedy selection and dust-burn rule, but key-free and with an estimated (not signature-measured) fee.

Parameters:
Return type:

UnsignedSend

class pyrxd.agent.WatchOnlyUtxo[source]

Bases: object

A spendable UTXO described by PUBLIC data only.

change/index are the BIP44 coords of the owning address — the agent re-derives the signing key from them and checks it owns the prevout. source_tx_hex is the FULL previous transaction; the agent verifies it hashes to (txid, vout) and reads the real value/script from it (C1).

__init__(txid, vout, value, change, index, source_tx_hex)
Parameters:
Return type:

None

txid: str
vout: int
value: int
change: int
index: int
source_tx_hex: str
pyrxd.agent.agent_socket_path(wallet_path)[source]

The agent socket co-located with the wallet file: <wallet dir>/agent.sock.

Single definition so the CLI’s agent and wallet send commands cannot drift to different (possibly looser) socket locations (security-panel M6).

Parameters:

wallet_path (Path)

Return type:

Path

async pyrxd.agent.collect_watch_only_utxos(account_xpub, client, *, gap_limit=20)[source]

Gap-limit scan both BIP44 chains from the public xpub and collect spendable UTXOs.

get_history marks a derived address used; get_utxos enumerates its spendable outputs; get_transaction fetches each output’s source tx (for the agent’s C1 prevout check). Also reports the next unused internal index, so the caller can place change on a fresh change address. No private key is touched.

Parameters:
Return type:

WatchOnlyScan

pyrxd.agent.format_spend_summary(summary)[source]

Render the verified spend for human review (pure; no I/O).

Shows every external payee + amount, the change total, the input total, and the fee — all derived from the verified tx (prevouts checked, change re-derived), so what the user sees is what gets signed.

Parameters:

summary (SpendSummary)

Return type:

str