Skip to content

feat(walletapi): opt-in attribution modes (anonymous, self) + curated decoy rings#22

Open
DHEBP wants to merge 12 commits into
DEROFDN:community-devfrom
DHEBP:feat/curated-decoy-attribution
Open

feat(walletapi): opt-in attribution modes (anonymous, self) + curated decoy rings#22
DHEBP wants to merge 12 commits into
DEROFDN:community-devfrom
DHEBP:feat/curated-decoy-attribution

Conversation

@DHEBP

@DHEBP DHEBP commented Jun 13, 2026

Copy link
Copy Markdown

Adds opt-in TransferOptions for sender-attribution control through additive ...WithOptions variants, so every existing caller keeps today's exact honest/random behavior. Three modes: anonymous (points the attribution byte at a decoy ring slot; needs ring >= 4, fails closed at ring 2), self (points the byte at the sender's own slot witness_index[0], the inverse of the receiver-pointed default; advanced-only, any ring size, unreachable from the default path), and curated decoy rings (user-supplied members, canonicalized and deduped). Behavioral + end-to-end tests prove the self byte points at the sender slot (mutation-proven) and that the ring>2 unverified-sender scrub still blanks it on decode. Builds on the receiver-honesty change in #21, which is what makes anonymous and self safe to expose.

DHEBP added 6 commits June 13, 2026 13:28
Add SenderVerified and RingSize to rpc.Entry, populated in both
receiver decode paths. SenderVerified is true only at ring size 2,
where the receiver override pins attribution to the counterparty;
for larger rings the sender chose the unauthenticated attribution
byte, so entry.Sender must not be presented as trusted. RingSize is
carried so consumers can render attribution confidence per entry.
Add an additive TransferPayload0WithOptions variant carrying a
TransferOptions struct; the existing TransferPayload0 becomes a shim
passing the zero value, so all callers keep today's behavior. The new
AttributionAnonymous mode writes a decoy ring slot index (drawn from
the anonymity set, never the sender or receiver slot) into the
encrypted receiver payload, reducing the receiver to the ring's 1-of-N
anonymity instead of being handed the sender's slot. Honest mode is
unchanged and still writes the receiver slot.

A source-level guard test locks honest attribution to witness_index[1]
so a change to the sender slot fails loud.
Add a RingPreference (via TransferOptions.Ring) letting a caller supply
preferred decoys for ring assembly; random members top up to ringsize.
With no preference the candidate source is unchanged, so behavior is
identical to today.

Preferred decoys are validated parseable, non-self, distinct, and
registered on the base balance tree before use. Registration is probed
against the base tree (the tree consensus falls back to for ring
membership) so a curated decoy cannot pass the wallet and then reject
at consensus after signing. Strict mode hard-errors a bad decoy;
otherwise it is skipped and random selection fills the slot.
Adds an on-chain gate for the curated-decoy ring-selection path: a transfer
whose ring members are user-curated via RingPreference (not drawn from
DERO.GetRandomAddress), carrying an action-less SCDATA body, is built at
ring 8, accepted into the pool, mined, and read back from the persisted
block store — proving the curated ring is consensus-valid, not merely
wallet-valid, and that the registration-probe in curatedRingCandidates
prevents the 'passes wallet, rejects at consensus' failure by construction.

Asserts all ring-2 decoy slots are filled from the curated set (no random
fallback) and the body reads back byte-equal. Falsifiable: disabling
curation drops curated members from the ring and fails the gate.

Single-node simulator scope: finalization = persisted into a mined block
(0-conf; PoW/miniblock verify and the low-fee floor are sim-bypassed).
Multi-node fork-choice and fee competition remain out of scope.
A 25-agent adversarial audit found the A2 proof SOUND (no false-green;
verified Verify_Transaction_NonCoinbase runs with skip_proof=false, so the
curated ring passes real bulletproof/ring verification, not just wallet
checks) but flagged honesty/falsifiability gaps. This closes them:

- Add Test_CuratedRing_NegativeControls_A2 making the registration-probe
  and finalization branches PROVEN-RUN, not EXPECTED-BY-INSPECTION:
  (a) Strict + unregistered decoy -> build hard-errors before signing;
  (b) non-Strict + unregistered decoy -> dropped, random member substitutes,
      only registered preferred decoys appear in the ring;
  (c) never-mined carrier -> absent from Block_tx_store (finalization
      assertion is non-vacuous).
- Bind curation to finalization: assert finalized_tx hash == submitted hash
  (the ring is read off the built tx; the hash commits the serialized ring).
- Make the curation assertion positional/subset-equality (every non-sender/
  non-recipient ring member is a preferred decoy) instead of a bare count.
- Correct the success log: drop the unproven fee-debit claim (sim bypasses
  the fee floor), state proof verification is NOT bypassed, scope to base-SCID.
- Scope the header + engine comment: for a zero-SCID transfer the curation
  registration-probe is redundant with the ring-assembly re-probe; it is the
  sole wallet-side defense only on the non-zero-SCID path (not run here).

@Dirtybird99 Dirtybird99 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed the anonymous-attribution + curated-decoy changes across correctness, security, test, and product lenses. The additive ...WithOptions shim design and the backward-compatible rpc.Entry wire change are genuinely clean and worth preserving as-is. That said, I found one correctness issue that fails after signing, a security combination that re-creates a capability the design comment declares out of scope, and a safety-signal (SenderVerified) that doesn't reach any real consumer. Flagging as comments rather than a hard block, but I'd treat #1#3 as must-fix before this is usable. Details inline; happy to discuss any of them.

Comment thread walletapi/wallet_transfer.go Outdated
continue
}
seen[d] = true
alist = append(alist, d)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 #1 correctness — fails after signing. Preferred decoys are deduped/validated by raw string (seen[d], append(alist, d)) and the assembly deduplicator (:402-404,:424) is string-keyed, but the actual ring member is the public key (:443). A decoy supplied in an alternate encoding (integrated / payment-id) of an account already in the ring — including the sender, or the recipient (never checked here; only d == self at :99) — slips past both string dedups and the :428 guard, placing the same pubkey twice. Consensus then rejects at transaction_verify.go:308 after the user has signed — the exact "passes wallet, rejects at consensus" failure the registration probe exists to prevent. The receiver path already canonicalizes via addr.BaseAddress() (:380,:394,:403); do the same here and seed seen with the sender and recipient base keys.

// witness_index[2:] are the anonymity-set (decoy) slots: real, registered
// ring members that are neither the sender [0] nor the receiver [1].
// Pointing attribution at one reduces the receiver to 1-of-N ring anonymity.
decoyPos := 2 + crand.Intn(len(witness_index)-2)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 #2 security — framing primitive + opt-in tell. The doc says targeted attribution is "intentionally NOT defined… targeted impersonation, not privacy" — but anonymous + curation re-creates exactly that. Curate a single decoy at ring 3: decoyPos := 2 + crand.Intn(1) is forced to that one slot, so the sender deterministically points the attribution byte at a chosen real registered third party, and the receiver/exchange/UI renders "from X." Separately, because honest always points the byte at the receiver's own slot and anonymous points elsewhere, the receiver detects opt-in with one equality (sender_idx == j) — a tell that shrinks the very anonymity set it grows. Consider randomizing decoyPos independently of the curated set, and/or refusing sender-chosen attribution while curation is active.

Comment thread rpc/wallet_rpc.go
// the only other ring member is necessarily the sender — nothing is attested by
// the protocol). For ring size > 2 the sender chose the unauthenticated
// attribution byte and entry.Sender MUST NOT be trusted.
SenderVerified bool `json:"sender_verified"`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 #3 security — SenderVerified is fail-open. entry.Sender is always populated, even when meaningless (at ring>2 honest mode it resolves to the receiver's own address; in anonymous mode to a sender-chosen decoy). Existing consumers have no reason to check the new bool: Entry.String() (:118) still prints a bare Sender: line and never renders SenderVerified/RingSize, and the Get_Transfers sender filter (:264) matches on Sender unconditionally. The field's whole purpose ("MUST NOT be trusted for ring>2") is defeated for anyone not parsing raw JSON. Leave Sender empty/sentinel unless verified, annotate it in String() (e.g. Sender: <addr> (UNVERIFIED — ring N)), and audit every reader.

}

if sender_idx <= uint(tx.Payloads[t].Statement.RingSize) {
if sender_idx < uint(tx.Payloads[t].Statement.RingSize) { // off-by-one fix: valid indices are 0..RingSize-1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 #4 security — bounds vs Publickeylist length → MITM DoS. This tightens the guard to sender_idx < RingSize, but it indexes Publickeylist[sender_idx], and Publickeylist is rebuilt at :866-873 by decompressing each key and skipping on failure — so len(Publickeylist) can be < RingSize. A crafted byte in [len, RingSize) passes the guard and panics. It's recover()-guarded in SyncHistory (:543) and unreachable via consensus-valid txs, but a hostile/MITM daemon can serve a tx with an undecompressable ring key to repeatedly abort history sync (availability DoS). Bound against len(Publickeylist) (or reject when len != RingSize) here and at the CBOR_V2 arm (:1116).

// TransferPayload0WithOptions is the additive variant carrying opt-in transfer
// privacy knobs (sender-attribution mode, decoy curation). A zero-value
// TransferOptions reproduces TransferPayload0 exactly.
func (w *Wallet_Memory) TransferPayload0WithOptions(transfers []rpc.Transfer, ringsize uint64, transfer_all bool, scdata rpc.Arguments, gasstorage uint64, dry_run bool, opts TransferOptions) (tx *transaction.Transaction, err error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 #5 product — feature ships dark. TransferOptions / AttributionAnonymous / RingPreference are reachable only through this Go method — there's no JSON-RPC transfer param, no CLI flag, no config. The audience that drives the wallet via wallet-rpc / the CLI wallet cannot opt in, so the user-facing privacy benefit is currently unreachable. Either wire an optional options object into the RPC transfer params (+ a CLI flag), or scope the PR title/description as "Go-API primitive; RPC surface to follow" so reviewers aren't misled on deliverable scope.

// GUARDRAIL: never witness_index[0] (the real sender) — that would reveal
// the true sender to the receiver on every transfer.
attrIndex := witness_index[1]
if opts.Attribution == AttributionAnonymous && len(witness_index) > 2 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 #8 API contract — silent no-op at ring 2. AttributionAnonymous silently falls through to honest witness_index[1] at ring 2 (the guard requires len(witness_index) > 2), with no error or signal — and the receiver decode then overrides to the counterparty and sets SenderVerified=true, so the caller broadcasts a verifiably attributed transfer while believing it's anonymized. Anonymity is impossible at ring 2, so reject the AttributionAnonymous + ring-2 combination with an explicit error rather than honoring neither.

t.Fatalf("cannot read transaction_build.go: %v", err)
}

// The honest default assignment for the attribution byte index.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 #9 test quality — brittle source-grep guard. This grep guard is brittle both ways. False negative: it matches the := literal at transaction_build.go:241, but the anonymous branch reassigns attrIndex two lines down (:247, with =) — a regression reassigning to witness_index[0] below the matched line ships green. False positive: a semantics-preserving refactor (attrIndex := honestAttrIndex(...)) fails CI, contradicting the docstring's claim that it "cannot be defeated by an unrelated refactor." A behavioral test that builds a tx in honest mode and asserts the decrypted leading byte equals the receiver slot pins the actual invariant.

Comment thread walletapi/wallet_transfer.go Outdated
probable_members := w.Random_ring_members(transfers[t].SCID)
// curated preferred decoys (if any) go first; random members top up. With no
// RingPreference this returns exactly Random_ring_members(transfers[t].SCID).
probable_members, cerr := w.curatedRingCandidates(transfers[t].SCID, opts.Ring)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 #10 efficiency. curatedRingCandidates runs at the top of for ringsize != 2 (re-iterable) and inside for t := range transfers, re-issuing one GetEncryptedBalanceAtTopoHeight per decoy each pass even though decoy validation is loop-invariant. The <=40 branch (:414) discards this call's result wholesale (:421) and couples the curation count to an unrelated daemon-pool heuristic; for a zero-SCID transfer each decoy is also probed again at :433. Hoist decoy validation above both loops and carry the curated prefix into the fallback instead of recomputing.

addr.Mainnet = w.GetNetwork()
entry.Sender = addr.String()
}
entry.RingSize = uint64(tx.Payloads[t].Statement.RingSize)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 #11 maintainability — copy-pasted decode arms. This RingSize/SenderVerified block (and the bounds guard + ring-2 override above it) is byte-for-byte identical to the CBOR_V2 arm at :1116-1123. Every decode-side fix above — the len-bound in #4, the Sender gating in #3 — must be applied to both arms or they silently diverge (the same class of bug the off-by-one fix just corrected). Factor an assignSender(entry, statement, senderIdx, network) helper and call it from both.

}
if bal, _ := wsrc.Get_Balance(); bal == 0 {
t.Fatalf("sender has zero balance after funding; cannot send an Amount>=1 carrier")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 #12 CI reliability. The new simulator tests are flaky under CI: wall-clock time.Sleep (:145,:373), fixed os.TempDir DB paths, and mutated package globals (globals.Arguments, config.Testnet/Mainnet.Genesis_*) make them unsafe to run concurrently / with -parallel. Use t.TempDir(), poll-until-synced with a deadline instead of fixed sleeps, and gate behind a build tag or a -short skip.

@Dirtybird99

Copy link
Copy Markdown

Follow-up to my review above — cross-checked against the PR description, the commit messages, and your in-code scope notes, to be fair about what's already flagged vs. what's new. Nothing retracted; just context.

Already acknowledged in the PR (treat as "you know this," not a discovery):

Two findings that contradict an explicit guarantee in your commits (the ones I'd weigh most):

  • fix(simulator): default to mainnet instead of forcing testnet #1 vs. 31b672b / A2 0ff5897: you state the probe means "a curated decoy cannot pass the wallet and then reject at consensus after signing… prevents [it] by construction." That holds for base-form addresses, but the self/distinct checks are string-keyed — an alternate encoding (integrated / payment-id) of the sender, recipient, or a daemon-returned random member evades dedup and yields a duplicate ring key → consensus reject after signing. The "by construction" guarantee has a canonicalization hole.
  • Pr/blocktime-check #4 vs. d821203: the bounds-fix message assumes "a 0-indexed Publickeylist of length RingSize." That isn't invariant — the decode rebuild at :866-873 skips keys that fail to decompress, so len(Publickeylist) can be < RingSize, and < RingSize still over-indexes. Bounding on len(Publickeylist) closes it fully.

Consistent with your stated intent (not a contradiction):

The rest of the findings are unaffected by the description/commits.

…anon

Addresses the DEROFDN#22 review:

- curatedRingCandidates canonicalizes the sender, recipient, and every
  preferred decoy to its base address before the distinctness/dedup checks,
  and the ring carries the base form. A ring member is identified by its
  pubkey, not its address string, so an integrated/payment-id encoding of an
  account already in the ring (sender, recipient, or another decoy) no longer
  slips the string-keyed checks and lands a duplicate pubkey that consensus
  rejects AFTER signing. Test_CuratedRing_AltEncoding_NoDuplicate_A2 covers
  recipient / sender / decoy-vs-decoy / cross-network, each mutation-proven. (review #1)
- Anonymous attribution now fails closed at ring size < 4 (no decoy slot)
  instead of silently falling through to honest attribution. (review DEROFDN#8)
- Port the unverified-attribution export scrub + self-send flag into the decode
  arms (this stack predates it); behavioral scrub test + an honest-byte guard
  that asserts honest mode writes the receiver slot (closes the grep guard's
  reassignment blind spot). (review DEROFDN#3, DEROFDN#9)

The bounds guard is sufficient for the same reason as DEROFDN#21: bad ring keys panic
at NewAddress before the Publickeylist rebuild, so len==RingSize. (review DEROFDN#4)

@Dirtybird99 Dirtybird99 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-reviewed 3dfd70d against the earlier comments. This closes #1's integrated/payment-id and cross-network (derodeto) vectors (and mutation-tests them nicely), ports the #3/#9 scrub + the behavioral honest-byte guard, fails closed on ring-2 anon (#8), and the #4 bounds claim holds — Publickeylist[sender_idx] is safe because NewAddress panics on a bad ring key before the rebuild, so len == RingSize. Three follow-ups inline: one correctness gap that reopens #1 through the deroproof HRP axis the fix doesn't normalize (#13, I'd treat as must-fix), a duplication hazard (#14), and a test-path bug (#15). Minor: the new ring-2 guard reads ringsize < 3 while its message says “>= 4” — equivalent under the power-of-2 rule, but < 4 matches the message; and #4's safety is invariant-dependent, so a len(Publickeylist) != RingSize skip in the rebuild would harden it structurally rather than by assumption.

Comment thread walletapi/wallet_transfer.go Outdated
// the seen-map and self/recipient checks compare pubkeys, not network-tagged strings.
canon := addr.BaseAddress()
canon.Mainnet = w.GetNetwork()
base := canon.String() // canonical identity for distinctness

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 #13 correctness — deroproof axis reopens #1, rejects after signing. The canonicalization closes the integrated/payment-id and cross-network (derodeto) vectors, but it pins only Mainnet. BaseAddress()Clone() preserves Proof (rpc/address.go:97) and MarshalText forces hrp = "deroproof" whenever Proof is set (rpc/address.go:53-54), overriding the network. So a PreferredDecoy supplied as a deroproof… encoding of the recipient, the sender, or another decoy canonicalizes to a deroproof… string that misses the dero…/deto… seeds in seen{} (:99,:114), clears the registration probe at :128 (same registered pubkey), and lands the same pubkey in the ring twice — transaction_verify.go then rejects it after the user has signed, exactly the #1 failure, via the one HRP axis neither the fix nor the 4-vector test (curated_ring_altencoding_test.go) covers. Root cause: distinctness is keyed on the stringified address, so every HRP flag has to be hand-normalized one at a time. Key seen/self/recipientBase on the raw addr.PublicKey.EncodeCompressed() (the 33-byte pubkey consensus actually dedups on) and Mainnet, Proof, integrated, and any future axis collapse together — which also removes the caller-side recipient pin (:429) that otherwise must stay in lockstep with this block.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 2b30d561 — keyed dedup on the raw 33-byte pubkey (EncodeCompressed) instead of the stringified address, so Mainnet/Proof/integrated collapse to one identity; clear Proof on the emitted base so a deroproof decoy resolves to a registered base. New deroproof-decoy-alt test mutation-proves it (revert canon.Proof=false → RED, "axis is OPEN"). Also dropped the dead recipient Mainnet pin as you noted.

Comment thread walletapi/daemon_communication.go Outdated
// it re-derives the claimed sender via the public Publickeylist even after
// entry.Sender is blanked. So we blank entry.Sender AND zero payload[0] in the
// copy that feeds entry.Data. The verified case (ring 2, structural) is untouched.
exported_payload := tx.Payloads[t].RPCPayload

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 #14 maintainability — scrub + self-trust copy-pasted across both decode arms. This unverified-attribution scrub is byte-for-byte identical in the CBOR_V2 arm (:1151), and the self-authored RingSize/SenderVerified = true block is duplicated across the two self-send arms (:990, :1019). Both are correct today, but a future change to the scrub — e.g. zeroing another sender-derivable byte, or correcting the !entry.SenderVerified condition — applied to one arm but not the other silently re-opens the #3 sender-deanonymization leak for whichever encoding (CBOR vs CBOR_V2) is missed, and the source-grep guard can't force a new arm to call it. Factor one scrubExportedPayload(entry, decrypted) (and a sibling self-trust helper) invoked from every decode arm so a missing call is impossible.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 2b30d561 — factored scrubExportedPayload() + markSelfAuthored() as the single source of truth, called from every decode arm (zero inline copies remain). Byte-identical; the scrub behavioral test is unchanged and still green.

t.Fatalf("Cannot create encrypted wallet, err %s", err)
}

wgenesis, err := Create_Encrypted_Wallet_From_Recovery_Words(wdst_temp_db, "QWER", "perfil lujo faja puma favor pedir detalle doble carbón neón paella cuarto ánimo cuento conga correr dental moneda león donar entero logro realidad acceso doble")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 #15 test — wgenesis reuses wdst_temp_db. This passes wdst_temp_db (the receiver's DB path) instead of its own file, so the genesis and receiver wallets share one on-disk DB — clobber / “wallet already exists” risk, and the defer os.Remove(wdst_temp_db) only cleans the one path while the second open leaks. Give wgenesis its own temp path (e.g. wgenesis_temp_db).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 2b30d561wgenesis now uses its own temp DB path with its own cleanup.

Third AttributionMode value pointing the receiver-readable attribution byte
at the sender's own ring slot (witness_index[0]) — an explicit, advanced-only
self-attribution path, the inverse of the receiver-pointed default. Works at
any ring size (slot 0 always exists), so unlike anonymous it needs no ring<3
fail-closed guard; the default path can never reach it (named value only).

Behavioral + end-to-end tests prove the byte points at the sender slot
(mutation-proven) and that the ring>2 unverified-sender scrub still blanks it
on decode.
DHEBP added a commit to DHEBP/derohe that referenced this pull request Jun 24, 2026
…team hardened)

CLI surface for the DEROFDN#22 anonymous-attribution + curated-decoy primitives,
rebased onto the fixed engine (canonicalization + ring-2 fail-closed) and
hardened by adversarial review (ledger: redteam-cli-anonymous-decoys.md):

- Fail-closed anonymity at ring<4 with a truthful HONEST/ANON report before
  confirm and after dispatch (no false sense of anonymity). (O1/O2/O8/O9)
- Attribution/decoy output console-only — no on-disk intent leak. (O3)
- Recipient-aware, BaseAddress()-canonical decoy validation mirroring the
  engine fix. (O5)

cmd/dero-wallet-cli only; engine + all four hardened DEROFDN#22 tests preserved
(take-theirs on the 6 walletapi/simulator files per review O4). Full build +
CLI tests + engine attribution + curated-ring sim suite green.
DHEBP added a commit to DHEBP/derohe that referenced this pull request Jun 24, 2026
Reworks the opt-in privacy CLI surface per design review (quickbrownfox + Azylem):

- Unify ring size, extra sender privacy, and curated decoys into ONE submenu
  ("Transaction Build Options", menu 7) — a single consistent place to configure
  how the next transfer's ring is built, instead of ringsize-by-command +
  privacy-by-menu.
- Remove the `set ringsize` and `set anonymous` prompt commands so the submenu is
  the single config path (no colliding third way to set the same state).
- Option 5 (Transfer) is now a live build readout: "(default, ringsize N)" on the
  plain path, or "(ringsize N) (advanced settings enabled)" in red once extra
  privacy / decoys are engaged — so the user sees the build before committing.
- "Set first, then fire": after a transfer dispatches, all per-tx build settings
  (privacy, decoys, ring size) reset to defaults so an advanced configuration
  cannot silently persist into the next, unrelated send.
- Suppress the attribution line on a plain default (honest) send — a normal
  transfer no longer carries an unsolicited "your sender is visible" notice; the
  truthful post-send "landed in ring" line is shown only on an advanced send, and
  the redundant pre-send "requested" line is dropped.
- Renumber the post-open menu to a clean sequence (1-8, 10-16; 9/0 reserved for
  navigation) so the new option does not leave a numbering gap.

cmd/dero-wallet-cli only; engine + all hardened DEROFDN#22 tests preserved. New unit tests
cover the silent send path, the post-send reset, and the option-5 state label
(mutation-checked). Build + go test ./cmd/dero-wallet-cli green.
@DHEBP DHEBP changed the title feat(walletapi): opt-in anonymous attribution + curated decoy rings feat(walletapi): opt-in attribution modes (anonymous, self) + curated decoy rings Jun 24, 2026
…s (review DEROFDN#13/DEROFDN#14/DEROFDN#15)

Addresses Dirtybird99's re-review of DEROFDN#22:

- DEROFDN#13 (correctness): curated-decoy distinctness was string-keyed, so a deroproof
  (Proof-flag) encoding of an in-ring account rendered a distinct base string and
  slipped the dedup, landing a duplicate pubkey that consensus rejects after the
  user signs. Key the dedup on the raw 33-byte pubkey
  (hex(addr.PublicKey.EncodeCompressed())) — the identity consensus dedups on — so
  Mainnet, Proof, and integrated axes all collapse to one key; clear Proof on the
  emitted base so a deroproof-supplied decoy resolves to a registered base address.
  Removes the now-dead caller-side recipient Mainnet pin (was only needed to keep
  the string canon in lockstep). New deroproof-decoy-alt vector mutation-proves it
  (revert canon.Proof=false -> RED "axis is OPEN").

- DEROFDN#14 (maintainability): the unverified-attribution scrub and the self-authored
  trust block were copy-pasted across the CBOR/CBOR_V2 decode arms; a future
  one-arm edit could silently reopen the DEROFDN#3 sender-deanonymization leak. Factor
  scrubExportedPayload() + markSelfAuthored() as the single source of truth, called
  from every arm. Byte-identical behavior; scrub behavioral test unchanged + green.

- DEROFDN#15 (test): wgenesis reused the receiver's temp DB; give it its own path + cleanup.

Build + vet clean. Alt-encoding suite (6 vectors incl. 2 deroproof) and scrub
behavioral test green (no -race per the pre-existing sim-daemon flake). Pre-existing
flaky sim tests (Test_Creation_TX_morecheck, Test_Payload_TX) fail on the unmodified
DEROFDN#22 head too — unrelated to this change.
@DHEBP

DHEBP commented Jun 25, 2026

Copy link
Copy Markdown
Author

Thanks for the thorough re-review — all 15 addressed.

First round (#1#12): closed in 3dfd70d4 (pubkey-aware canonicalization + ring-2 fail-closed); the product/UX items (#5 RPC/CLI surface, #8 ring-2 no-op, #9 grep guard) are handled in the CLI work (#23). #6/#7/#10/#11 tracked/acknowledged as noted.

Re-review (#13#15): 2b30d561

  • Fix for supply calculation #13 (deroproof): you were right — the string canon only normalized Mainnet, not the Proof HRP axis. Fixed as prescribed: distinctness now keys on the raw 33-byte pubkey (hex(addr.PublicKey.EncodeCompressed())), so Mainnet, Proof, integrated, and any future axis collapse to one identity; the emitted ring entry clears Proof so a deroproof-supplied decoy resolves to a registered base. Removed the now-dead caller-side recipient Mainnet pin. New deroproof-decoy-alt vector mutation-proves it (revert the fix → RED, "axis is OPEN").
  • feat(explorer): proof validation security + modern UI refresh #14: factored scrubExportedPayload() + markSelfAuthored() as the single source of truth, called from every decode arm — byte-identical, scrub behavioral test unchanged.
  • fix(proof): correct atomic-unit thresholds + mirror SetUint64 to prover #15: wgenesis now gets its own temp DB.

Heads-up: Test_Creation_TX_morecheck / Test_Payload_TX fail on the unmodified head too (pre-existing sim-daemon balance/race flake) — run the sim-backed tests without -race.

@Dirtybird99

Copy link
Copy Markdown

Review — opt-in attribution modes (anonymous, self) + curated decoy rings

Checked out the exact head (2b30d56) and verified empirically: go build ./... and go vet clean, and all 8 of the PR's tests pass when run individually (the shared simulator-rpcport constraint). This revision genuinely resolves the prior review's 15 findings — I confirmed that against source, not just by reading the summaries. Verdict: merge after fixing findings 1–3 (all three break a contract the feature itself is meant to provide); 4–8 are non-blocking follow-ups.

Findings (most severe first)

1. Curated decoys can hang the wallet (deadlock, holds transfer_mutex)walletapi/wallet_transfer.go:460
The len(probable_members) <= 40 low-pool fallback used to measure only the daemon's random pool; it now measures curated+random combined. On a non-zero-SCID transfer with a scarce SC pool (e.g. ring 64, ~45 curated decoys, a token with few holders), curated+random exceeds 40 so the zeroscid base-tree fallback never fires, the distinct pool stays smaller than the ring, and the for ringsize != 2 loop never reaches goto ring_members_collected. It spins forever re-issuing ~45 registration RPCs per pass while holding transfer_mutex (blocking every other transfer). No error escapes the loop because unregistered accounts on a non-zero SC tree get synthesized zero balances. Fix: measure the fallback threshold on the random tail only, and/or bound the loop with an exhaustion exit.

2. Strict is silently void at ring size 2walletapi/wallet_transfer.go:452
Ringsize 2 skips the whole curation path (for ringsize != 2 never runs), so RingPreference{PreferredDecoys: […garbage…], Strict: true} builds and can broadcast with no error — while the identical input at ring 4 is a hard error ("preferred decoy is not a valid address"). A caller relying on Strict as a pre-signing validation gate gets no validation at all. Fix: validate opts.Ring (or reject Strict curation) before the ringsize branch.

3. Lenient curation silently abandoned on a transient daemon errorwalletapi/wallet_transfer.go:144
In non-Strict mode a brief daemon/RPC failure during the registration probe hits the e != nil → continue branch for every preferred decoy: curatedRingCandidates returns only random members, and the user signs and broadcasts a fully random ring while believing the curated-disjoint-ring mitigation was applied — no error, log, or result signal. Fix: distinguish "not registered" (skip) from "probe failed" (retry or surface), and/or report the effective curation back to the caller.

4. Unknown-RPCType outgoing entries never marked self-authored (plausible; not reachable today)walletapi/daemon_communication.go:1050
markSelfAuthored is called only inside the two known-RPCType receiver-found branches, so the default arm leaves an own send with SenderVerified=false, RingSize=0 — contradicting the field's own doc ("outgoing/self send, certain at any ring size"). Unreachable with current software (only CBOR/CBOR_V2 are emitted) and conservative in direction, so this is a future-proofing note.

5. Per-decoy registration probe runs up to 3× (efficiency)walletapi/wallet_transfer.go:455, :462, :479
One uncached GetEncryptedBalanceAtTopoHeight RPC per decoy (uncapped — 100 supplied decoys for a ring-8 all get probed), inside the assembly loop, called twice per iteration when the fallback fires (byte-identical calls for a zero-SCID transfer), and each placed decoy re-probed a third time by the pre-existing per-member check at :479. Strict mode can also hard-fail on a daemon blip during a redundant re-pass. Fix: validate once per transfer before the loop and reuse the fetched balance (or skip the probe on the zero-SCID path where the placement loop is authoritative).

6. Anonymous→honest degrade guarded one layer above where it happens (altitude)walletapi/transaction_build.go:254
buildTransaction silently falls through anonymous → honest when len(witness_index) <= 2; the fail-closed guard lives only in TransferPayload0WithOptions. Any future second entry point (an RPC handler, a BuildTransactionWithOptions export mirroring the existing shim) re-opens the "verifiably attributed while believed anonymous" downgrade. Fix: make the build site itself return an error rather than degrade.

7. Trust-polarity block duplicated per decode arm (simplification)walletapi/daemon_communication.go:1110 / :1156
The 7-line RingSize/SenderVerified/scrub block is byte-identical in both incoming arms and order-coupled (scrubExportedPayload reads SenderVerified set two lines earlier) — exactly the one-arm-drift risk the PR's own markSelfAuthored helper was created to prevent. Fix: fold ringsize into the helper (scrubExportedPayload(entry, ringsize, payload) setting RingSize+SenderVerified itself).

8. Test-suite cleanup bundle (cleanup)

  • ~450 duplicated lines: the simulator bootstrap block ×3 (curated_ring_altencoding_test.go, curated_ring_finalization_test.go ×2), the walletapi wallet+chain harness ×3, and the 8-line receiver ECDH decrypt block ×3 — extract shared helpers.
  • Two new files miss the repo's RESEARCH-license header every sibling carries: cmd/simulator/curated_ring_finalization_test.go, walletapi/attribution_guard_test.go.
  • Comments hard-code cross-file line numbers (wallet_transfer.go:140/:144/:474…) that rot silently, and attribution_self_behavior_test.go references a CLI symbol attributionResultLine that does not exist in this repo (it's in feat(wallet-cli): 3-way sender-attribution selector + curated decoys + unified build menu #23).
  • The source-regex guard test (attribution_guard_test.go) is redundant with the behavioral honest-byte assertion and false-fails on harmless refactors.

Refuted (checked, not real): the wgenesis-on-wdst_temp_db "copy-paste bug" is the established upstream derohe test idiom (tx_creation_test.go etc. do the same); the ring-2 guard's ringsize < 3 vs its ">= 4" message is consistent because ring sizes snap to powers of 2.


Verification: exact head checked out and built/tested locally (8/8 tests pass individually); findings surfaced via an 8-angle finder pass and individually verified against the checkout (27 candidates confirmed, 7 refuted). The engine's fail-closed guard holds — no false-anonymity ships in a built tx; findings 1–3 are a deadlock and two silent contract violations on the new curated path.

DHEBP added 2 commits July 2, 2026 12:27
… (review #1)

The ring loop collected distinct members through a persistent deduplicator
with no bound on passes: a candidate pool smaller than the ring spins
forever holding transfer_mutex — the balance probe can't error on non-zero
SCIDs (unregistered accounts get synthesized zero balances), success needs
a full ring, and the candidate stream stops yielding. 41+ curated decoys
made it deterministic: the combined count passed the <=40 scarcity check,
so the base-tree rescue never fired.

Seven changes:

- Tail-only threshold: the <=40 check measures len(members)-curated
  (curatedRingCandidates reports its validated count per pass) — curated
  decoys no longer mask a scarce tree; upstream semantics restored.

- Stall rescue: <=40 is a size heuristic blind to the 41..ringsize-1 dead
  band (upstream also hung there). After 2 consecutive barren passes (no
  new distinct candidate) assembly switches to base-tree candidates —
  sticky, base-fetch-only per pass.

- Pass bounds: rescue armed, 32 further consecutive barren passes or
  8*ringsize total -> explicit retryable error; an empty final fetch
  reports daemon failure, not pool exhaustion (Random_ring_members
  swallows RPC errors into empty lists — this also ends upstream's latent
  spin on a persistently failing daemon).

- Wall-clock bound: exponential barren backoff (250ms doubling to 4s;
  realizable window ~112s > the daemon's 90s 5-block activity filter, so
  a transiently filtered pool recovers before exhaustion is declared) plus
  a 150s per-transfer stall budget (barren_passes resets on any progress,
  so a trickling daemon could otherwise re-arm ~32 windows ≈ 60 min at
  ring 128). Per-transfer scope matches the per-transfer barren state.

- MaxTransfersPerBuild = 256, checked before any mutex-held RPC: every
  per-transfer cost above multiplies by len(transfers), and the consensus
  300KB tx limit can't bound that (enforced after assembly has paid). Not
  a new restriction: the smallest payload serializes ~1.7KB wire, above
  the 300*1024/256 = 1200-byte floor — longer arrays could never have
  broadcast. Floor pinned on a real wire-form tx.

- 45s per-RPC deadline (CallWithTimeout) on every mutex-held daemon call:
  random members, balance fetches, and NameToAddress (pre-assembly,
  reachable at ring 2 — deadline-free it kept the hang reproducible with
  every assembly bound bypassed). Without it a daemon that accepts the
  socket but never replies parks the goroutine forever with
  IsDaemonOnline() still true. RPC count is bounded across the whole body:
  <=2 fee fetches; per transfer 1 probe, <=20 resolver fetches, <=1 name
  resolution, then pass-capped assembly.

- 45s per-write websocket deadline (rwc.NewWithWriteTimeout): jrpc2 holds
  one client mutex across socket writes AND timeout delivery, so one write
  blocked on a half-open daemon freezes every call and its deadline for
  the kernel TCP retransmit timeout (~15+ min) — and the wallet's own
  deadline-free 5s connectivity pinger makes that writer a certainty. The
  blocked write now errors at 45s, gorilla poisons the connection,
  Keep_Connectivity reconnects. Daemon/explorer rwc users keep the
  historical constructor. Worst-case mutex hold:
  (min(len(transfers),256)+1) x (150s + count-bounded RPCs x 3x45s) — an
  absolute constant. Residual: wasm keeps the deadline-free nhooyr channel
  (browser websockets buffer in the JS runtime); rides the send-layer
  timeout follow-up.

Tests, each red pre-fix on its target defect: ring_pool_exhaustion_test
(41 decoys + empty token tree at ring 64: hang -> seconds via rescue);
ring_scarce_band_test (~92-leaf token tree ON CHAIN at ring 128:
tail-only alone errors "have 90 of 128", the rescue fills it);
ring2_payload_floor_test (pins the 1200-byte floor on wire form);
transfer_array_cap_test (offline: cap+1 fails closed pre-network, cap
passes); rpc_call_timeout_test (never-answering daemon: probe, members,
name resolution all return at the deadline); rwc_write_timeout_test (peer
that never reads: the blocked write errors at the deadline, not the TCP
retransmit timeout); ring_backoff_test (arithmetic pin: backoff window >
5x BLOCK_TIME filter window — the filter is disabled below topoheight
100, so no simulator run can exercise it).

The exhaustion-error branch is unreachable on a healthy chain (the
1326-account genesis premine floors the base pool; the rescue always
reaches it) — covered by inspection. Test_Payload_TX /
Test_Creation_TX_morecheck fail identically at base (pre-existing).
…ecoy-probe failures (review DEROFDN#2/DEROFDN#3)

DEROFDN#2 — at ring 2 the assembly loop ("for ringsize != 2") never runs, so
curatedRingCandidates — the only decoy validator — is never invoked:
Strict garbage decoys built and could broadcast while the identical input
at ring 4 hard-errors. Fixed with a fail-closed guard beside the anon
ring-2 guard (after effective-ringsize resolution — the wallet default
can legally be 2 — and before any daemon call, so it validates offline).
Strict-only: lenient has no ring-2/ring-4 asymmetry, and its documented
silent-drop contract is load-bearing for the DEROFDN#23 CLI (Strict:false,
ambient session decoys, any ring size). Lenient ring-2 drops feed the
funnel below.

DEROFDN#3 — the registration probe treated EVERY failure as "decoy invalid":
for a decoy address the daemon's "Account Unregistered" verdict and a
transport failure return as the same opaque error (the unregistered
special-cases require self or non-zero SCID), so a transient blip in
lenient mode silently stripped all curation — the user signed a fully
random ring believing it curated. Probe errors are now classified via
isUnregisteredError (the repo's existing detection idiom): unregistered
verdict -> unchanged (Strict errors, lenient skips); any other failure ->
hard error in BOTH modes ("could not verify … — retry the send"). No
in-probe retry (Keep_Connectivity heals on ~5s; retry belongs at the send
layer). A daemon that rewords the verdict fails closed. Strict's
transient-failure message changes from "not registered" to "could not
verify" (nothing pinned the old text).

O9, same surface — decoy slot capacity: a ring holds at most ringsize-2
curated decoys, but nothing checked the supplied count: valid decoys
beyond capacity were probed, then silently never placed. Strict
over-supply is now a hard error (pre-guarded on the supplied count before
any RPC, re-checked in the validator); lenient surplus drops through the
funnel with a "no decoy slot left" record. Lenient ring-2 gets the same
upgrade, so the former log-only residual now gets the default-verbosity
summary. The guard caught an in-repo instance: the A2 finalization
fixture supplied 8 Strict decoys at ring 8; two validated and were never
placed — it now supplies exactly ring-2.

Two hardenings on the same surface:

- Lenient drops are never invisible: all four drop reasons (unparseable /
  own address / duplicate / unregistered verdict) funnel through one
  recorder — a per-decoy V(1) log and a default-verbosity reduced-curation
  summary before signing. Recording only the daemon-verdict class would
  keep the bug alive for the wallet-side classes (an all-duplicate ambient
  list from the hardcoded-lenient DEROFDN#23 CLI signs a random ring with zero
  signal on a healthy chain). TRUST NOTE on RingPreference: a malicious
  daemon can still veto lenient curation by lying "unregistered" —
  visible, not preventable wallet-side (it already controls random member
  selection); Strict is the fail-closed mode.

- Probe cost bounded and flat: registration verdicts are memoized per
  build (canonical-base-address key, shared by both call sites) — one
  probe per distinct decoy per build instead of O(decoys) per pass under
  transfer_mutex. Sound: registration is permanent; a stale unregistered
  verdict only defers the decoy to the next send. PreferredDecoys is
  capped at 256 (2x max ringsize) — hard error in both modes; list length
  is caller cost, not decoy quality.

Tests, each red pre-fix: curated_ring2_guard_test (offline: Strict
garbage at ring 2 passed every validation pre-fix);
curated_probe_classification_test (sim; client websocket killed so
IsDaemonOnline() stays true and the failure lands inside the probe's RPC
— pre-fix lenient returned curated=0, err=nil); curated_slot_cap_test
(offline, memo-seeded: lenient surplus records, Strict surplus errors at
guard and validator); curated_drop_signal_test (offline: all three
wallet-side drops recorded with reasons, no double-count across passes,
Strict records nothing); rpc_call_timeout_test gains the classification
leg (a hung daemon's probe deadline -> "could not verify", never a silent
drop). Keep_Connectivity deliberately not started in the probe test (no
quit path). RingPreference / curatedRingCandidates docs updated where the
fixes falsified them.

Test_Payload_TX / Test_Creation_TX_morecheck fail identically at base
(pre-existing), as does attribution_self_behavior_test.go's gofmt drift.
@DHEBP

DHEBP commented Jul 2, 2026

Copy link
Copy Markdown
Author

Thanks — #1#3 fixed and adversarially hardened: 0097229 (#1), 217798c (#2/#3). #4/#6#8 tracked; #5 largely subsumed (below).

#1 (curated-decoy spin): both halves of your "and/or", plus the holes a red-team pass found in them:

  • Tail-only thresholdcuratedRingCandidates returns its validated count; the <=40 check measures len(members)-curated per pass.
  • Exhaustion exit — 32 consecutive barren passes or 8×ringsize total → retryable error; an empty final fetch reports daemon failure, not pool exhaustion.
  • Stall rescue — a sticky base-tree fallback closes the 41..ringsize−1 band (tail big enough to skip the <=40 rescue, pool too small to fill).
  • Time bounds, not just pass counts — exponential barren backoff (realizable window 111.75s > the daemon's ~90s 5-block activity filter, pinned arithmetically against the daemon's constants), 150s per-transfer stall budget, 45s per-RPC deadline on the entire mutex-held RPC surface (incl. NameToAddress), 45s per-write websocket deadline (a half-open socket could otherwise freeze a write inside jrpc2's client mutex, under transfer_mutex, beneath any pass counter), and MaxTransfersPerBuild=256 (a necessary condition of the consensus 300KB tx limit). Net: worst-case transfer_mutex hold is an absolute constant.

#2 (Strict void at ring 2): fail-closed guard beside the anon ring-2 guard — after effective-ringsize resolution (the wallet default can legally be 2), before any daemon call. Strict-only by design: lenient has no ring-2/ring-4 asymmetry (it never errors on bad decoys at any ring) and its documented silent-drop contract is load-bearing for the #23 CLI. All lenient drops now feed one four-reason funnel with a default-verbosity reduced-curation summary; a programmatic signal rides the "report effective curation" follow-up.

#3 (silent curation abandonment): probe errors classified (isUnregisteredError, the repo's existing detection idiom): unregistered verdict → unchanged (Strict errors, lenient skips into the funnel); any other failure → hard error in BOTH modes ("could not verify preferred decoy … — retry the send"). No in-probe retry — Keep_Connectivity heals on a ~5s cadence; retry belongs at the send layer. A daemon that rewords the verdict fails closed. TRUST NOTE added at RingPreference: a malicious daemon can still veto lenient curation by falsely reporting decoys unregistered — visible in the funnel, not preventable wallet-side; Strict is the fail-closed mode.

#5 (3× re-probe): largely subsumed — per-build verdict memoization (registration is monotonic; verdicts frozen at build start) + a 256-decoy cap: the registration probe now runs once per decoy per build.

Proof: eleven pinning tests, each pre-fix red / post-fix green. Headline three: ring_pool_exhaustion_test.go (ring 64 + 41 curated decoys + empty token tree, ridden behind a base transfer: hangs at 2b30d56 — its own timeout fires — terminates via the rescue post-fix), curated_ring2_guard_test.go (offline, sub-second: garbage Strict decoys at ring 2 passed every validation pre-fix), curated_probe_classification_test.go (kills the client websocket so IsDaemonOnline() stays true and the failure lands inside the probe's RPC call; pre-fix lenient returned curated=0, err=nil).

Deliberately-scoped residuals: wasm keeps the deadline-free websocket write path (browser websockets buffer in the JS runtime — rides the send-layer-timeout follow-up); the 5-block activity filter can't be exercised in the sim (disabled below topo 100 — covered by the arithmetic pin plus the on-chain stall-rescue test).

Term nit: unbounded spin holding transfer_mutex, not deadlock — the cure is progress/time bounds, not lock ordering.

Heads-up: the new tests self-bound and share the fixed rpcport — same run-individually caveat as the existing sim tests. Test_Payload_TX/Test_Creation_TX_morecheck fail identically at base (pre-existing), as does the gofmt drift in attribution_self_behavior_test.go.

DHEBP added a commit to DHEBP/derohe that referenced this pull request Jul 2, 2026
…team hardened)

CLI surface for the DEROFDN#22 anonymous-attribution + curated-decoy primitives,
rebased onto the fixed engine (canonicalization + ring-2 fail-closed) and
hardened by adversarial review (ledger: redteam-cli-anonymous-decoys.md):

- Fail-closed anonymity at ring<4 with a truthful HONEST/ANON report before
  confirm and after dispatch (no false sense of anonymity). (O1/O2/O8/O9)
- Attribution/decoy output console-only — no on-disk intent leak. (O3)
- Recipient-aware, BaseAddress()-canonical decoy validation mirroring the
  engine fix. (O5)

cmd/dero-wallet-cli only; engine + all four hardened DEROFDN#22 tests preserved
(take-theirs on the 6 walletapi/simulator files per review O4). Full build +
CLI tests + engine attribution + curated-ring sim suite green.
DHEBP added a commit to DHEBP/derohe that referenced this pull request Jul 2, 2026
Reworks the opt-in privacy CLI surface per design review (quickbrownfox + Azylem):

- Unify ring size, extra sender privacy, and curated decoys into ONE submenu
  ("Transaction Build Options", menu 7) — a single consistent place to configure
  how the next transfer's ring is built, instead of ringsize-by-command +
  privacy-by-menu.
- Remove the `set ringsize` and `set anonymous` prompt commands so the submenu is
  the single config path (no colliding third way to set the same state).
- Option 5 (Transfer) is now a live build readout: "(default, ringsize N)" on the
  plain path, or "(ringsize N) (advanced settings enabled)" in red once extra
  privacy / decoys are engaged — so the user sees the build before committing.
- "Set first, then fire": after a transfer dispatches, all per-tx build settings
  (privacy, decoys, ring size) reset to defaults so an advanced configuration
  cannot silently persist into the next, unrelated send.
- Suppress the attribution line on a plain default (honest) send — a normal
  transfer no longer carries an unsolicited "your sender is visible" notice; the
  truthful post-send "landed in ring" line is shown only on an advanced send, and
  the redundant pre-send "requested" line is dropped.
- Renumber the post-open menu to a clean sequence (1-8, 10-16; 9/0 reserved for
  navigation) so the new option does not leave a numbering gap.

cmd/dero-wallet-cli only; engine + all hardened DEROFDN#22 tests preserved. New unit tests
cover the silent send path, the post-send reset, and the option-5 state label
(mutation-checked). Build + go test ./cmd/dero-wallet-cli green.
…line

The last transfer-path call still on a bare Call. A half-open daemon parked
the send goroutine for the kernel TCP retransmit timeout (~15+ min); the
write-deadline can't bound it (the write already succeeded). No lock held,
so a goroutine leak, not a wallet freeze. Now shares walletDaemonCallTimeout.
@DHEBP

DHEBP commented Jul 7, 2026

Copy link
Copy Markdown
Author

Pushed 3729c01: the SendRawTransaction broadcast was the last transfer-path call still on a bare Call, where a half-open daemon could hang the send goroutine (a leak, not a wallet freeze — SendTransaction holds no lock). Now on the same 45s deadline as the other RPCs.

Believe that's the last open item on #22. #23 will need a rebase onto 3729c01.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants