Skip to content

feat(wallet-cli): 3-way sender-attribution selector + curated decoys + unified build menu#23

Open
DHEBP wants to merge 15 commits into
DEROFDN:community-devfrom
DHEBP:feat/cli-anonymous-decoys-rebased
Open

feat(wallet-cli): 3-way sender-attribution selector + curated decoys + unified build menu#23
DHEBP wants to merge 15 commits into
DEROFDN:community-devfrom
DHEBP:feat/cli-anonymous-decoys-rebased

Conversation

@DHEBP

@DHEBP DHEBP commented Jun 24, 2026

Copy link
Copy Markdown

CLI surface for the opt-in attribution modes in #22. Reworks the Transaction Build Options menu into a 3-way sender-attribution selector (option 2 cycles DEFAULT -> ANONYMOUS -> SELF -> DEFAULT), adds curated-decoy entry, and a unified build-options menu; the default send path stays byte-identical (no prompt, honest/random). SELF lands behind a mandatory permanent-self-doxx warning and bypasses the ring-size downgrade (slot 0 always exists); ANONYMOUS fails closed to honest below ring 4; the result line reports the effective truth at each ring tier. --anonymous seeds ANONYMOUS only; SELF is reachable only via the warned in-session choice.

Stacked on #22 (it calls the engine modes added there) — the walletapi half of this diff IS #22; review/test together, merge after #22. Branch builds and runs end-to-end as-is (go build ./...; go test ./cmd/dero-wallet-cli/ ./walletapi/ green).

DHEBP added 8 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).
…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)
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 DHEBP changed the base branch from main to community-dev June 24, 2026 23:36
…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.
@Dirtybird99

Copy link
Copy Markdown

Did a deep review of this PR — read through all 11 commits and the full diff. Overall this is a careful, well-tested change: the privacy-critical machinery holds up. The no-op default contract, the ring<3 anonymous fail-closed guard, the pubkey canonicalization against alt-encoding ring duplicates, and the ring>2 attribution scrub are all correct and backed by behavioral + simulator tests with real mutation teeth. I couldn't find a correctness or security bug in the send/sign/scrub path.

A handful of smaller things worth considering:

Correctness (display-only)

  • curatedDecoysInTx (anonymity.go) scopes the post-send "curated decoys landed in ring: N" count by recipient ring-membership. On a token send the auto-injected zero-SCID throwaway payload can random-fill with the recipient's own pubkey, so both payloads match and the union over-counts curated decoys that only landed in the throwaway ring. Scoping by the delivering payload's SCID is deterministic. No consensus/signing/leak impact — only the truthfulness of that console line.

Cleanup / consistency

  • The CLI's decoy validation (canonBase / decoyCollector.add) uses globals.ParseValidateAddress (which rejects a cross-network HRP encoding) while the engine's curatedRingCandidates uses rpc.NewAddress + canon.Mainnet = w.GetNetwork() (which accepts and canonicalizes it). So the CLI silently drops a decoy the engine would honor — fail-safe, but the accepted sets diverge despite the in-code "matching the engine" comment.
  • set anonymous is still advertised by the set tab-completer (prompt.go), but its handler was removed in this PR — tab-completing it now falls through to default: help and changes nothing.
  • The ring>2 attribution-scrub block is copy-pasted verbatim between the CBOR and CBOR_V2 decode arms in daemon_communication.go; a future scrub-policy change to one arm would silently leave the other leaking the sender-chosen byte. A shared scrubUnverifiedAttribution helper makes it one source of truth.
  • On the curated path, the for ringsize != 2 loop re-calls curatedRingCandidates each pass (and again in the <=40 fallback), re-issuing one GetEncryptedBalanceAtTopoHeight per preferred decoy every pass — the validated set doesn't change between passes, so it can be hoisted once per transfer.
  • Two new test files (anonymity_test.go, attribution_guard_test.go) are missing the RESEARCH license header their siblings carry.

UX (judgment call)

  • The post-open menu renumber moves Close Wallet 8→10 and Change Password 7→8, so muscle-memory 8 now opens the password flow. Low-risk (the confirm defaults to No), but worth a heads-up.

I put together a patch covering the six clear-cut items (everything except the menu renumber — that one's a UX call better left to you). It builds and passes the targeted cmd/dero-wallet-cli and walletapi tests locally against this branch, including the end-to-end Test_Attribution_Scrub_Behavior. Happy to share it or open it as a follow-up however is easiest.

@Dirtybird99

Copy link
Copy Markdown

Review — CLI 3-way attribution selector + curated decoys

Checked out the exact head (2938de4) and verified empirically: go build ./... and go vet clean, and go test ./cmd/dero-wallet-cli/ passes (incl. the new anonymity_test.go suite). The design intent — fail-closed anonymity, warned SELF, truthful reporting — is right, and the test suite is genuinely thorough. The defects below are mostly seams between this CLI and the paths/commands it doesn't cover.

Structural note first: the PR body says "the walletapi half of this diff IS #22", but by commit ancestry this branch contains #22's first 8 commits but not its current head 2b30d56 (the pubkey-keyed decoy dedup that closes the deroproof/cross-network alt-encoding axes + the factored decode-arm helpers). That staleness is the direct cause of finding 6. Recommend rebasing onto #22's current head, then addressing 1–5 before merge.

Findings (most severe first)

1. Prompt-mode sends silently bypass session privacycmd/dero-wallet-cli/prompt.go:333
The typed burn command still calls wallet.TransferPayload0 (zero options), so a user who launched with --anonymous (or cycled to ANONYMOUS/SELF, or set --decoys) gets an honest, un-curated tx — no downgrade notice, no report line, no reset — while the set readout and the red "(advanced settings enabled)" label still claim the mode is active. Only the two easymenu_post_open.go send paths call applySessionPrivacy.

2. "Session" privacy lasts exactly one sendcmd/dero-wallet-cli/easymenu_pre_open.go:262
--anonymous/--decoys are advertised "for this session", but resetTransferBuildToDefaults() wipes both after the first successful transfer. Send #2 ships DEFAULT with no decoys, and reportAttribution stays silent (because advancedSettingsActive() is now false), so the user — told the privacy is "ON for this session" — gets no signal it's gone.

3. The PR violates its own O3 "never persist anonymize-intent to disk" invariantcmd/dero-wallet-cli/anonymity.go:109 and :178–193
logger.Info in resolveModeOrDowngrade/cycleAttributionMode ("SELF-attribution ENABLED…", "Anonymize CANCELLED…") and logger.Error(..., "decoy", raw) in decoyCollector.add tee attribution intent and rejected decoy address strings to the on-disk <exename>.log (zap NewTee, file level debug) — exactly the record the --decoys loader and reportAttribution deliberately keep console-only. Use l.Stderr() for these, matching the O3 sites.

4. Ring-size lifecycle is broken both directionscmd/dero-wallet-cli/anonymity.go:418 and :299
Menu option 1 writes through wallet.SetRingSize, which persists to the wallet DB immediately (defer w.save_if_disk()), so abandoning the send turns a "one-off" ring size into the permanent default (re-captured as default_ringsize next open). Meanwhile the post-send reset clobbers a deliberate change back to the open-time snapshot — and with set ringsize removed there is no durable way left to change ring size.

5. Dead recovery instruction at the exact failure momentcmd/dero-wallet-cli/anonymity.go:112
The downgrade notice says "Run 'set ringsize 16' and resend" — a command this same PR removed; typing it lands on the help screen, ring size unchanged, so a user following it verbatim keeps shipping honest sends. Point them at the menu (Advanced Privacy Options → option 1) instead. The handleTransactionBuildMenu doc comment (:379) also still claims the typed set ringsize/set anonymous commands "remain".

6. Deroproof decoy slips prompt-time dedup (interacts with the stale stack)cmd/dero-wallet-cli/anonymity.go:185
decoyCollector keys dedup on BaseAddress().String(), which preserves the Proof flag, so a deroproof re-encoding of the recipient/sender isn't rejected. On this branch's engine (also string-keyed) the duplicate pubkey reaches the ring and consensus rejects after signing; #22's head fixed it engine-side (pkKey on raw pubkey). Rebasing onto #22 head fixes the engine half; align the CLI's key to raw compressed pubkey too.

7. Anonymous ring gate + ring-identity re-implemented in a drifting second dialect (cleanup)cmd/dero-wallet-cli/anonymity.go:89, :143
The "anonymous needs ring ≥ 4" rule now exists in three textually divergent copies (CLI >= 4, engine ringsize < 3 hard error, len(witness_index) > 2), and the pubkey-hex ring-identity formula in a fourth unshared copy (curatedDecoysInTx). The CLI also rejects integrated addresses the engine accepts. Consider having the engine own the gate and the CLI query it.

8. UX-consistency bundle (cleanup)

  • prompt.go:1033 — the set completer newly advertises anonymous, but the set anonymous handler was removed in this PR; tab-completion suggests a dead command.
  • easymenu_post_open.go:61 — every cross-reference (set-help, --anonymous usage) names a "Transaction Build Options" menu, but the on-screen label is "Advanced Privacy Options" (and it's unreachable from prompt mode / unregistered wallets).
  • easymenu_post_open.go:69 — inserting option 7 renumbered six stable options; a habitual "13" (was show-history) now fires a full rescan with no confirmation, and "8" (was close) now opens change-password.
  • anonymity_test.go:1 — missing the repo's per-file RESEARCH-license header every sibling carries.
  • anonymity.go:134 — orphaned duplicate collectDecoys doc comment sits above the decoyCollector type.

Verification: exact head checked out and built/tested locally; findings surfaced via an 8-angle finder pass and individually verified against both this branch and the current #22 head (27 candidates confirmed, 0 refuted). Nothing here is a false-anonymity in the built tx — the engine's fail-closed guard holds; these are contract/UX seams and one signing-time reject.

DHEBP added 6 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.
…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.
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.
Add an advanced-only AttributionSelf mode that points the receiver-readable
attribution byte at the sender's own ring slot (witness_index[0]) — the
attribution field's original purpose, offered explicitly rather than by
default. The receiver-pointed default stays untouched and un-overridable;
self-pointing is reachable ONLY via the named AttributionSelf value.

Engine (walletapi/transaction_build.go):
- AttributionMode gains AttributionSelf; one build branch writes
  witness_index[0]. Works at any ring size (slot 0 always exists), so it is
  intentionally not subject to the anonymous ring<4 fail-closed guard.
- Guardrail comment reframed: honest/default can never self-point; the sender
  slot is reachable only via the explicit value.

CLI (cmd/dero-wallet-cli):
- session attribution flag becomes a 3-way mode; menu option 2 cycles
  Default -> Anonymous -> Self -> Default. Landing on Self triggers a
  mandatory, permanent-self-doxx warning; declining leaves the mode unchanged.
- Self bypasses the anonymous ring-size downgrade and is reported verbatim.
  The result line is ring-2-aware: at ring 2 the sender is already
  structurally provable (decode overrides sender_idx; SenderVerified=true for
  honest too), so the written byte adds nothing over honest; at ring>2 it is a
  permanent on-chain record recoverable by a non-blanking/future reader.
- --anonymous seeds Anonymous only; Self is never reachable from a flag.

Tests:
- Test_AttributionSelf_WritesSenderSlot: build-path proof the byte points at
  the sender slot (mutation-proven: flip to [1] -> RED).
- Test_AttributionSelf_EndToEnd_ScrubHolds: build->broadcast->mined->decode
  proof the DEROFDN#21 scrub still blanks the byte for a current wallet at ring>2.
- 3-way cycle, downgrade-bypass, result-line, and label-suffix unit tests.

The DEROFDN#21 scrub blanks the byte for current recipient wallets at ring>2 but does
not make self "safe" — the raw on-chain byte is permanent. That permanence is
why the warning is mandatory.
… review DEROFDN#3

A registration probe that FAILS (daemon/transport fault — no verdict) now
hard-errors the build even under Strict:false (engine review DEROFDN#3 fix), so
the two comments pinning lenient silent-drop needed the caveat: silent
skip applies only to the daemon's unregistered verdict.
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