From 1a0e0bac0565f833217bc2c746ff48da45a621ee Mon Sep 17 00:00:00 2001 From: DHEBP <152355273+DHEBP@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:09:25 -0400 Subject: [PATCH 01/15] feat(walletapi): surface sender attribution as unverified for rings > 2 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. --- rpc/wallet_rpc.go | 8 +++++++- walletapi/daemon_communication.go | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/rpc/wallet_rpc.go b/rpc/wallet_rpc.go index 11f1a480..badcedfc 100644 --- a/rpc/wallet_rpc.go +++ b/rpc/wallet_rpc.go @@ -82,7 +82,13 @@ type Entry struct { Payload_RPC Arguments `json:"payload_rpc,omitempty"` // these fields are only valid based on payload type and if payload could be successfully parsed and will by default be equal to zero values - Sender string `json:"sender"` + Sender string `json:"sender"` + // SenderVerified is true ONLY when attribution is protocol-pinned (ring size 2, + // where the counterparty is necessarily the sender). For ring size > 2 the sender + // chose the unauthenticated attribution byte and entry.Sender MUST NOT be trusted. + SenderVerified bool `json:"sender_verified"` + // RingSize is the ring size of the payload this entry was decoded from (0 if unknown). + RingSize uint64 `json:"ringsize"` DestinationPort uint64 `json:"dstport"` SourcePort uint64 `json:"srcport"` } diff --git a/walletapi/daemon_communication.go b/walletapi/daemon_communication.go index 2cffea05..8677756c 100644 --- a/walletapi/daemon_communication.go +++ b/walletapi/daemon_communication.go @@ -1076,6 +1076,9 @@ func (w *Wallet_Memory) synchistory_block(scid crypto.Hash, topo int64) (err err addr.Mainnet = w.GetNetwork() entry.Sender = addr.String() } + entry.RingSize = uint64(tx.Payloads[t].Statement.RingSize) + // ring size 2 attribution is protocol-pinned (set above); larger rings are sender-chosen and unverified + entry.SenderVerified = uint(tx.Payloads[t].Statement.RingSize) == 2 entry.Payload = append(entry.Payload, tx.Payloads[t].RPCPayload[1:]...) entry.Data = append(entry.Data, tx.Payloads[t].RPCPayload[:]...) @@ -1115,6 +1118,9 @@ func (w *Wallet_Memory) synchistory_block(scid crypto.Hash, topo int64) (err err addr.Mainnet = w.GetNetwork() entry.Sender = addr.String() } + entry.RingSize = uint64(tx.Payloads[t].Statement.RingSize) + // ring size 2 attribution is protocol-pinned (set above); larger rings are sender-chosen and unverified + entry.SenderVerified = uint(tx.Payloads[t].Statement.RingSize) == 2 entry.Payload = append(entry.Payload, payload[1:]...) entry.Data = append(entry.Data, payload...) From a41ea04fdc708a63d0f712341132708bad82d4a5 Mon Sep 17 00:00:00 2001 From: DHEBP <152355273+DHEBP@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:28:16 -0400 Subject: [PATCH 02/15] feat(walletapi): add opt-in anonymous sender attribution mode 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. --- walletapi/attribution_guard_test.go | 40 ++++++++++++++++++ walletapi/transaction_build.go | 64 ++++++++++++++++++++++++++++- walletapi/wallet_transfer.go | 12 +++++- 3 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 walletapi/attribution_guard_test.go diff --git a/walletapi/attribution_guard_test.go b/walletapi/attribution_guard_test.go new file mode 100644 index 00000000..4e7d6026 --- /dev/null +++ b/walletapi/attribution_guard_test.go @@ -0,0 +1,40 @@ +package walletapi + +import ( + "os" + "regexp" + "testing" +) + +// Test_AttributionGuard_HonestWritesReceiverIndex locks the sender-attribution +// guardrail: honest mode MUST write witness_index[1] (the receiver's own slot), +// never witness_index[0] (the real sender slot). +// +// Writing witness_index[0] would make the receiver-decryptable attribution byte +// reveal the true sender on every transfer, de-anonymizing senders chain-wide. A +// previous change to witness_index[0] was reverted before release for exactly this +// reason; this test makes that regression fail loud rather than ship silently. +// +// It is a source-level guard (greps the build site) so it needs no wallet/daemon +// harness and cannot be defeated by an unrelated refactor of the surrounding code. +func Test_AttributionGuard_HonestWritesReceiverIndex(t *testing.T) { + src, err := os.ReadFile("transaction_build.go") + if err != nil { + t.Fatalf("cannot read transaction_build.go: %v", err) + } + + // The honest default assignment for the attribution byte index. + honest := regexp.MustCompile(`attrIndex\s*:=\s*witness_index\[1\]`) + if !honest.Match(src) { + t.Fatal("GUARDRAIL VIOLATED: honest attribution must be `attrIndex := witness_index[1]` " + + "(receiver slot). If you changed it to witness_index[0], you would reveal the real " + + "sender to every receiver. Do not change the honest byte.") + } + + // Defense in depth: the honest assignment must not be the sender slot. + forbidden := regexp.MustCompile(`attrIndex\s*:=\s*witness_index\[0\]`) + if forbidden.Match(src) { + t.Fatal("GUARDRAIL VIOLATED: honest attribution is set to witness_index[0] (the real " + + "sender slot). This de-anonymizes the sender on every transfer. Revert to witness_index[1].") + } +} diff --git a/walletapi/transaction_build.go b/walletapi/transaction_build.go index 17341871..550990a3 100644 --- a/walletapi/transaction_build.go +++ b/walletapi/transaction_build.go @@ -21,8 +21,59 @@ type GenerateProofFunc func(scid crypto.Hash, scid_index int, s *crypto.Statemen var GenerateProoffuncptr GenerateProofFunc = crypto.GenerateProof +// AttributionMode controls which ring slot index is written into the encrypted +// receiver payload (the leading byte at the witness_index write below). It changes +// only the sender-attribution metadata the receiver decrypts; it never affects which +// keys are sender/receiver, the amount, the recipient, or consensus validity. +type AttributionMode uint8 + +const ( + // AttributionHonest writes witness_index[1] (the receiver's own slot) — the default + // and today's behavior. The receiver already knows it is the receiver, so this leaks + // nothing about the sender. + // + // GUARDRAIL: honest mode MUST write witness_index[1] and MUST NOT be changed to + // witness_index[0] (the real sender slot). Writing the sender slot would make the + // receiver-decryptable byte reveal the true sender on every transfer. + AttributionHonest AttributionMode = iota + // AttributionAnonymous writes the slot index of a decoy ring member (drawn from the + // anonymity set, never the real sender or receiver). The receiver is reduced to the + // ring's 1-of-N anonymity instead of being handed the sender's slot directly. + AttributionAnonymous + // A "point attribution at a specific named address" mode is intentionally NOT defined: + // that is targeted impersonation, not privacy. +) + +// RingPreference is an opt-in decoy-curation hint for ring assembly. A nil +// *RingPreference reproduces today's behavior (pure DERO.GetRandomAddress selection). +// The curation logic that consumes it is wired in wallet_transfer.go. +type RingPreference struct { + // PreferredDecoys are base-address strings to place in the ring first (after dedup); + // random members top up to ringsize. Each is validated registered on the base balance + // tree before use. Addresses the user controls must NOT be supplied here — curating + // your own addresses collapses your anonymity set. + PreferredDecoys []string + // Strict: if true, a bad preferred decoy (unparseable / self / duplicate / unregistered) + // is a hard error. If false (default), it is skipped and random selection fills the slot. + Strict bool +} + +// TransferOptions carries opt-in, additive transfer privacy knobs. The zero value +// reproduces today's behavior exactly (honest attribution, random ring selection). +type TransferOptions struct { + Attribution AttributionMode // zero value = AttributionHonest + Ring *RingPreference // nil = today's random ring selection +} + // generate proof etc +// +// BuildTransaction is preserved verbatim as a shim over buildTransaction so existing +// callers (tests, benchmarks) keep compiling and get honest, non-curated behavior. func (w *Wallet_Memory) BuildTransaction(transfers []rpc.Transfer, emap [][][]byte, rings [][]*bn256.G1, block_hash crypto.Hash, height uint64, scdata rpc.Arguments, roothash []byte, max_bits int, fees uint64) *transaction.Transaction { + return w.buildTransaction(transfers, emap, rings, block_hash, height, scdata, roothash, max_bits, fees, TransferOptions{}) +} + +func (w *Wallet_Memory) buildTransaction(transfers []rpc.Transfer, emap [][][]byte, rings [][]*bn256.G1, block_hash crypto.Hash, height uint64, scdata rpc.Arguments, roothash []byte, max_bits int, fees uint64, opts TransferOptions) *transaction.Transaction { sender := w.account.Keys.Public.G1() sender_secret := w.account.Keys.Secret.BigInt() @@ -184,7 +235,18 @@ rebuild_tx: shared_key := crypto.GenerateSharedSecret(ephemeral_scalar, publickeylist[i]) - payload := append([]byte{byte(uint(witness_index[1]))}, data...) + // honest attribution writes witness_index[1] (the receiver's own slot). + // 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 { + // 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) + attrIndex = witness_index[decoyPos] + } + payload := append([]byte{byte(uint(attrIndex))}, data...) //fmt.Printf("buulding shared_key %x index of receiver %d\n",shared_key,i) //fmt.Printf("building plaintext payload %x\n",asset.RPCPayload) diff --git a/walletapi/wallet_transfer.go b/walletapi/wallet_transfer.go index cf3c0247..2e1aef01 100644 --- a/walletapi/wallet_transfer.go +++ b/walletapi/wallet_transfer.go @@ -59,7 +59,17 @@ func (w *Wallet_Memory) Transfer_Simplified(addr string, value uint64, data []by // we should reply to an entry // send amount to specific addresses +// TransferPayload0 is preserved with its exact signature as a shim over +// TransferPayload0WithOptions, so all existing callers keep compiling and get +// today's behavior (honest attribution, random ring selection). func (w *Wallet_Memory) TransferPayload0(transfers []rpc.Transfer, ringsize uint64, transfer_all bool, scdata rpc.Arguments, gasstorage uint64, dry_run bool) (tx *transaction.Transaction, err error) { + return w.TransferPayload0WithOptions(transfers, ringsize, transfer_all, scdata, gasstorage, dry_run, TransferOptions{}) +} + +// 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) { // var transfer_details structures.Outgoing_Transfer_Details w.transfer_mutex.Lock() @@ -395,7 +405,7 @@ func (w *Wallet_Memory) TransferPayload0(transfers []rpc.Transfer, ringsize uint max_bits += 6 // extra 6 bits if !dry_run { - tx = w.BuildTransaction(transfers, rings_balances, rings, block_hash, height, scdata, treehash_raw, max_bits, gasstorage) + tx = w.buildTransaction(transfers, rings_balances, rings, block_hash, height, scdata, treehash_raw, max_bits, gasstorage, opts) } if tx == nil { From 31b672b91d3c9d7fe7a69c5e1fafe2f51662433b Mon Sep 17 00:00:00 2001 From: DHEBP <152355273+DHEBP@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:46:59 -0400 Subject: [PATCH 03/15] feat(walletapi): add opt-in curated decoy ring selection 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. --- walletapi/wallet_transfer.go | 67 ++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/walletapi/wallet_transfer.go b/walletapi/wallet_transfer.go index 2e1aef01..6d209459 100644 --- a/walletapi/wallet_transfer.go +++ b/walletapi/wallet_transfer.go @@ -66,6 +66,58 @@ func (w *Wallet_Memory) TransferPayload0(transfers []rpc.Transfer, ringsize uint return w.TransferPayload0WithOptions(transfers, ringsize, transfer_all, scdata, gasstorage, dry_run, TransferOptions{}) } +// curatedRingCandidates returns an ordered list of candidate ring members: +// validated preferred decoys first, then the daemon's random members. With a nil +// RingPreference it returns exactly Random_ring_members(scid) — today's behavior. +// +// A preferred decoy is validated to be parseable, not the wallet's own address, not a +// duplicate, and registered on the BASE (zero-SCID) balance tree. The base tree is the +// one the consensus verifier falls back to for ring membership, so probing it (rather +// than the transfer's SCID tree) prevents a curated decoy that passes the wallet but +// then rejects at consensus after the user has signed. In Strict mode a bad decoy is a +// hard error; otherwise it is skipped and random members fill the slot. +func (w *Wallet_Memory) curatedRingCandidates(scid crypto.Hash, pref *RingPreference) (alist []string, err error) { + if pref == nil { + return w.Random_ring_members(scid), nil + } + + var zeroscid crypto.Hash + self := w.GetAddress().String() + seen := map[string]bool{self: true} + + for _, d := range pref.PreferredDecoys { + if _, e := rpc.NewAddress(d); e != nil { // must be a parseable address + if pref.Strict { + return nil, fmt.Errorf("preferred decoy is not a valid address: %s", d) + } + continue + } + if d == self { // curating your own address collapses your anonymity set + if pref.Strict { + return nil, fmt.Errorf("preferred decoy cannot be your own address") + } + continue + } + if seen[d] { // distinctness (consensus rejects duplicate ring members) + if pref.Strict { + return nil, fmt.Errorf("duplicate preferred decoy: %s", d) + } + continue + } + // registration: probe the BASE balance tree, the tree consensus checks against. + if _, _, _, _, e := w.GetEncryptedBalanceAtTopoHeight(zeroscid, -1, d); e != nil { + if pref.Strict { + return nil, fmt.Errorf("preferred decoy is not registered: %s", d) + } + continue + } + seen[d] = true + alist = append(alist, d) + } + + return append(alist, w.Random_ring_members(scid)...), nil +} + // TransferPayload0WithOptions is the additive variant carrying opt-in transfer // privacy knobs (sender-attribution mode, decoy curation). A zero-value // TransferOptions reproduces TransferPayload0 exactly. @@ -348,10 +400,21 @@ func (w *Wallet_Memory) TransferPayload0WithOptions(transfers []rpc.Transfer, ri deduplicator[w.GetAddress().String()] = true for ringsize != 2 { - 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) + if cerr != nil { + err = cerr + return + } if len(probable_members) <= 40 { // we do not have enough ring members for sure, extract ring members from base var zeroscid crypto.Hash - probable_members = w.Random_ring_members(zeroscid) + base_members, berr := w.curatedRingCandidates(zeroscid, opts.Ring) + if berr != nil { + err = berr + return + } + probable_members = base_members } for _, k := range probable_members { if _, collision := deduplicator[k]; collision { From 92b800569ca4f04ac013f8fd25b5df4a031c3101 Mon Sep 17 00:00:00 2001 From: DHEBP <152355273+DHEBP@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:42:24 -0400 Subject: [PATCH 04/15] docs(walletapi): describe ring-2 sender attribution as structural --- rpc/wallet_rpc.go | 7 ++++--- walletapi/daemon_communication.go | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/rpc/wallet_rpc.go b/rpc/wallet_rpc.go index badcedfc..c729aa10 100644 --- a/rpc/wallet_rpc.go +++ b/rpc/wallet_rpc.go @@ -83,9 +83,10 @@ type Entry struct { // these fields are only valid based on payload type and if payload could be successfully parsed and will by default be equal to zero values Sender string `json:"sender"` - // SenderVerified is true ONLY when attribution is protocol-pinned (ring size 2, - // where the counterparty is necessarily the sender). For ring size > 2 the sender - // chose the unauthenticated attribution byte and entry.Sender MUST NOT be trusted. + // SenderVerified is true ONLY when attribution is structural (ring size 2, where + // 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"` // RingSize is the ring size of the payload this entry was decoded from (0 if unknown). RingSize uint64 `json:"ringsize"` diff --git a/walletapi/daemon_communication.go b/walletapi/daemon_communication.go index 8677756c..f36c8d84 100644 --- a/walletapi/daemon_communication.go +++ b/walletapi/daemon_communication.go @@ -1077,7 +1077,7 @@ func (w *Wallet_Memory) synchistory_block(scid crypto.Hash, topo int64) (err err entry.Sender = addr.String() } entry.RingSize = uint64(tx.Payloads[t].Statement.RingSize) - // ring size 2 attribution is protocol-pinned (set above); larger rings are sender-chosen and unverified + // ring size 2 attribution is structural — the only other ring member is the sender; larger rings are sender-chosen and unverified entry.SenderVerified = uint(tx.Payloads[t].Statement.RingSize) == 2 entry.Payload = append(entry.Payload, tx.Payloads[t].RPCPayload[1:]...) @@ -1119,7 +1119,7 @@ func (w *Wallet_Memory) synchistory_block(scid crypto.Hash, topo int64) (err err entry.Sender = addr.String() } entry.RingSize = uint64(tx.Payloads[t].Statement.RingSize) - // ring size 2 attribution is protocol-pinned (set above); larger rings are sender-chosen and unverified + // ring size 2 attribution is structural — the only other ring member is the sender; larger rings are sender-chosen and unverified entry.SenderVerified = uint(tx.Payloads[t].Statement.RingSize) == 2 entry.Payload = append(entry.Payload, payload[1:]...) From 0ff58976d58cc5cf9e8c782a59f9255dcaf55fdb Mon Sep 17 00:00:00 2001 From: DHEBP <152355273+DHEBP@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:45:52 -0400 Subject: [PATCH 05/15] test(simulator): prove curated-ring carrier finalizes on-chain (A2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../curated_ring_finalization_test.go | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 cmd/simulator/curated_ring_finalization_test.go diff --git a/cmd/simulator/curated_ring_finalization_test.go b/cmd/simulator/curated_ring_finalization_test.go new file mode 100644 index 00000000..1f60ff72 --- /dev/null +++ b/cmd/simulator/curated_ring_finalization_test.go @@ -0,0 +1,251 @@ +package main + +// A2 — curated-ring finalization gate. +// +// The curated-decoy engine (feat/curated-decoy-attribution: curatedRingCandidates + +// TransferPayload0WithOptions) validates each preferred decoy is registered on the base +// balance tree BEFORE signing, on the reasoning that a decoy which passes the wallet but is +// not a real registered account would be rejected by the consensus verifier after the user +// has already signed. That reasoning is sound by inspection — this test makes it PROVEN-RUN: +// +// A transfer whose ring members are CURATED (user-supplied via RingPreference, not drawn +// from DERO.GetRandomAddress) and which also carries an action-less SCDATA body must build, +// be accepted into the pool, mine, and FINALIZE into a block — i.e. the curated ring is +// consensus-valid, not merely wallet-valid. +// +// This is the load-bearing precondition for the rotating-identity ("Gatling") design's +// curated-disjoint-ring allocator: if a self-chosen ring did not finalize, the whole +// disjoint-ring mitigation would be unbuildable. It does. +// +// Scope honesty: single-node simulator. "FINALIZED" here means PERSISTED into a mined block +// (Block_tx_store), proving consensus ACCEPTANCE of the curated ring + action-less carrier. +// It is 0-conf on a difficulty-1 sim that bypasses PoW/miniblock verify and the low-fee floor; +// multi-node fork-choice and fee-competition are out of scope (Gate-3 / the declined R1 probe). + +import ( + "bytes" + "encoding/hex" + "fmt" + "math/rand" + "os" + "path/filepath" + "testing" + "time" + + "github.com/deroproject/derohe/blockchain" + "github.com/deroproject/derohe/config" + "github.com/deroproject/derohe/cryptography/crypto" + "github.com/deroproject/derohe/globals" + "github.com/deroproject/derohe/rpc" + "github.com/deroproject/derohe/transaction" + "github.com/deroproject/derohe/walletapi" +) + +// buildActionlessSCDATABody mirrors the carrier wire shape: the body rides under the +// INV-4-locked arg name "B" as a DataString, hex-encoded so a non-UTF-8 frame survives CBOR. +// CRITICAL (INV-1): no SCACTION key, so the tx takes the action-less short-circuit and cannot +// trigger the blackhole burn at any ring size. +func buildActionlessSCDATABodyA2(frame []byte) rpc.Arguments { + return rpc.Arguments{ + rpc.Argument{Name: "B", DataType: rpc.DataString, Value: hex.EncodeToString(frame)}, + } +} + +func Test_CuratedRing_Finalizes_A2(t *testing.T) { + globals.Arguments["--testnet"] = true + globals.Arguments["--simulator"] = true + + walletapi.Initialize_LookupTable(1, 1<<17) + + const ring = 8 // ring > 4 so curated decoys fill real slots beyond sender+recipient + // We need genesis + sender + recipient + enough registered decoys to fill the ring. + const decoyCount = ring // a generous curation pool (more than ring-2 needed) + + // Create wallets directly (the Test_Creation_TX pattern) — self-contained, no register_wallets + // (which spins up RPC/XSWD servers and touches the simulator's package logger). + mkwallet := func(name, seedHex string) *walletapi.Wallet_Disk { + db := filepath.Join(os.TempDir(), "a2_curated_"+name+".db") + os.Remove(db) + t.Cleanup(func() { os.Remove(db) }) + seed, err := hex.DecodeString(seedHex) + if err != nil { + t.Fatalf("decode seed %s: %s", name, err) + } + w, err := walletapi.Create_Encrypted_Wallet(db, WALLET_PASSWORD, new(crypto.BNRed).SetBytes(seed)) + if err != nil { + t.Fatalf("create wallet %s: %s", name, err) + } + return w + } + + wgenesis := mkwallet("genesis", genesis_seed) + wsrc := mkwallet("src", wallets_seeds[0]) + wrecipient := mkwallet("dst", wallets_seeds[1]) + var decoys []*walletapi.Wallet_Disk + for i := 0; i < decoyCount && 2+i < len(wallets_seeds); i++ { + decoys = append(decoys, mkwallet(fmt.Sprintf("decoy%d", i), wallets_seeds[2+i])) + } + if len(decoys) < ring-2 { + t.Fatalf("not enough seeds to curate a ring of %d (have %d decoys)", ring, len(decoys)) + } + + // Fix genesis to our genesis wallet (the wiring/creation-test pattern). + genesis_tx := transaction.Transaction{Transaction_Prefix: transaction.Transaction_Prefix{Version: 1, Value: 2012345}} + copy(genesis_tx.MinerAddress[:], wgenesis.GetAddress().PublicKey.EncodeCompressed()) + config.Testnet.Genesis_Tx = fmt.Sprintf("%x", genesis_tx.Serialize()) + config.Mainnet.Genesis_Tx = fmt.Sprintf("%x", genesis_tx.Serialize()) + genesis_block := blockchain.Generate_Genesis_Block() + config.Testnet.Genesis_Block_Hash = genesis_block.GetHash() + config.Mainnet.Genesis_Block_Hash = genesis_block.GetHash() + + chain, rpcserver, _ := simulator_chain_start() + defer simulator_chain_stop(chain, rpcserver) + globals.Arguments["--daemon-address"] = rpcport_test + go walletapi.Keep_Connectivity() + + // Register sender, recipient, and every curated decoy — they MUST be registered accounts + // for the curated ring to be consensus-valid (the precise thing A2 proves). + allToRegister := append([]*walletapi.Wallet_Disk{wsrc, wrecipient}, decoys...) + for _, w := range allToRegister { + if err := chain.Add_TX_To_Pool(w.GetRegistrationTX()); err != nil { + t.Fatalf("regtx: %s", err) + } + } + simulator_chain_mineblock(chain, wgenesis.GetAddress(), t) + + for _, w := range append(allToRegister, wgenesis) { + w.SetDaemonAddress(rpcport) + w.SetOnlineMode() + } + + // Fund the sender: mine several blocks to it so it has a mature, spendable balance. + for i := 0; i < 8; i++ { + simulator_chain_mineblock(chain, wsrc.GetAddress(), t) + } + time.Sleep(time.Second) + if err := wsrc.Sync_Wallet_Memory_With_Daemon(); err != nil { + t.Fatalf("src sync: %s", err) + } + if bal, _ := wsrc.Get_Balance(); bal == 0 { + t.Fatalf("sender has zero balance after funding; cannot send an Amount>=1 carrier") + } + + // Curate the ring from the specific registered decoy wallets (NOT via GetRandomAddress). + recipient := wrecipient.GetAddress().String() + var preferred []string + for _, d := range decoys { + preferred = append(preferred, d.GetAddress().String()) + } + + opts := walletapi.TransferOptions{ + Ring: &walletapi.RingPreference{ + PreferredDecoys: preferred, + Strict: true, // hard-fail if any curated decoy is not consensus-valid — exactly the A2 claim + }, + } + + frame := make([]byte, 1200) + if _, err := rand.Read(frame); err != nil { + t.Fatal(err) + } + scdata := buildActionlessSCDATABodyA2(frame) + if scdata.Has(rpc.SCACTION, rpc.DataUint64) { + t.Fatal("INV-1: carrier SCDATA must not contain SCACTION") + } + + wsrc.SetRingSize(ring) + pre_src, _ := wsrc.Get_Balance() + + // THE CURATED-RING CARRIER: action-less SCDATA + Amount:1 to recipient, ring members CURATED. + tx, err := wsrc.TransferPayload0WithOptions( + []rpc.Transfer{{Destination: recipient, Amount: 1}}, + ring, false, scdata, 0, false, opts) + if err != nil { + // In Strict mode this errors if ANY curated decoy is not registered/valid — so a failure + // here would mean the curated ring could not even be assembled into a buildable tx. + t.Fatalf("A2 BUILD: curated-ring carrier did not build (strict curated decoys, ring %d): %s", ring, err) + } + + // Submit the WIRE form (the daemon needs the expanded publickeylist pointers). + var dtx transaction.Transaction + if err := dtx.Deserialize(tx.Serialize()); err != nil { + t.Fatalf("deserialize curated carrier: %s", err) + } + txhash := dtx.GetHash() + + // Capture the ring members from the BUILT tx. The serialized wire form stores the ring as + // compact index POINTERS (resolved back to keys only by the daemon at verify time), so a tx + // read back from the block store has an empty Publickeylist. The full points live on the + // freshly-built tx's Statement.Publickeylist — that is the authoritative record of which ring + // members the curated-selection path actually placed. + builtRingKeys := map[string]bool{} + for _, payload := range tx.Payloads { + for _, p := range payload.Statement.Publickeylist { + builtRingKeys[hex.EncodeToString((*crypto.Point)(p).EncodeCompressed())] = true + } + } + + // CONSENSUS ACCEPTANCE: if any curated ring member were not a real registered account, the + // verifier would reject here (this is the exact "passes wallet, rejects at consensus" failure + // the engine's registration-probe is meant to prevent — proven NOT to happen). + if err := chain.Add_TX_To_Pool(&dtx); err != nil { + t.Fatalf("A2 CONSENSUS: node REJECTED the curated-ring carrier (a curated decoy was not consensus-valid?): %s", err) + } + + // Mine it + a few more so it persists and the transfer matures. + simulator_chain_mineblock(chain, wgenesis.GetAddress(), t) + for i := 0; i < 4; i++ { + simulator_chain_mineblock(chain, wgenesis.GetAddress(), t) + } + wsrc.Sync_Wallet_Memory_With_Daemon() + + // FINALIZATION: read the tx back from the persisted block store. Block_tx_store.ReadTX reads + // ONLY from txs that landed in MINED blocks, so a successful read is itself proof the + // curated-ring carrier FINALIZED into a block, not merely sat in the mempool. + tx_bytes, err := chain.Store.Block_tx_store.ReadTX(txhash) + if err != nil || len(tx_bytes) == 0 { + t.Fatalf("A2 FINALIZATION: curated-ring carrier %x not found in the persisted block store (never mined into a block): %v", txhash, err) + } + var finalized_tx transaction.Transaction + if err := finalized_tx.Deserialize(tx_bytes); err != nil { + t.Fatalf("A2 FINALIZATION: persisted curated carrier failed to deserialize: %s", err) + } + + // PROVE THE RING WAS ACTUALLY CURATED (not random fallback): a ring of size `ring` has + // exactly `ring - 2` decoy slots (sender at witness_index[0], recipient at witness_index[1] + // are fixed). The curated-selection path places preferred decoys FIRST, so all `ring - 2` + // decoy slots must be filled from our preferred set — zero random fallback. We supply more + // preferred decoys (`ring`) than there are slots, so a fully-curated ring sees exactly + // `ring - 2` of them placed. Compare on the raw compressed public-key hex (HRP-independent). + _ = finalized_tx // finalization already proven by the successful ReadTX above + curatedSeen := 0 + for _, d := range preferred { + da, perr := rpc.NewAddress(d) + if perr != nil { + continue + } + if builtRingKeys[hex.EncodeToString(da.PublicKey.EncodeCompressed())] { + curatedSeen++ + } + } + wantCurated := ring - 2 // sender + recipient occupy the other two slots + if curatedSeen != wantCurated { + t.Fatalf("A2 CURATION: %d curated decoys landed in the ring, want all %d decoy slots curated (random fallback filled %d slots)", curatedSeen, wantCurated, wantCurated-curatedSeen) + } + + // INV-4 readback: the action-less body reads back byte-equal off the finalized tx. + if !dtx.SCDATA.Has("B", rpc.DataString) { + t.Fatalf("INV-4: finalized carrier missing SCDATA arg \"B\"") + } + bodyHex, _ := finalized_tx.SCDATA.Value("B", rpc.DataString).(string) + gotFrame, derr := hex.DecodeString(bodyHex) + if derr != nil || !bytes.Equal(gotFrame, frame) { + t.Fatalf("INV-4: body readback mismatch off the finalized curated carrier (err=%v)", derr) + } + + post_src, _ := wsrc.Get_Balance() + t.Log(fmt.Sprintf("A2 PROVEN-RUN: curated ring (size %d, %d/%d preferred decoys placed) + action-less SCDATA carrier "+ + "FINALIZED into a block, consensus-accepted, body byte-equal readback; sender %d->%d (debit incl. Amount+fee). "+ + "Scope: single-node sim persistence (0-conf, PoW/fee-floor bypassed); not a multi-node finality claim.", + ring, curatedSeen, len(preferred), pre_src, post_src)) +} From 8a5c6b1b596de8aa124e29a2d021eda438ee1933 Mon Sep 17 00:00:00 2001 From: DHEBP <152355273+DHEBP@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:41:30 -0400 Subject: [PATCH 06/15] test(simulator): harden A2 curated-ring proof per adversarial audit 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). --- .../curated_ring_finalization_test.go | 246 +++++++++++++++--- walletapi/wallet_transfer.go | 12 +- 2 files changed, 223 insertions(+), 35 deletions(-) diff --git a/cmd/simulator/curated_ring_finalization_test.go b/cmd/simulator/curated_ring_finalization_test.go index 1f60ff72..5d4923d0 100644 --- a/cmd/simulator/curated_ring_finalization_test.go +++ b/cmd/simulator/curated_ring_finalization_test.go @@ -1,26 +1,40 @@ package main -// A2 — curated-ring finalization gate. +// A2 — base-SCID curated-ring finalization gate. // // The curated-decoy engine (feat/curated-decoy-attribution: curatedRingCandidates + -// TransferPayload0WithOptions) validates each preferred decoy is registered on the base -// balance tree BEFORE signing, on the reasoning that a decoy which passes the wallet but is -// not a real registered account would be rejected by the consensus verifier after the user -// has already signed. That reasoning is sound by inspection — this test makes it PROVEN-RUN: +// TransferPayload0WithOptions) places user-supplied PreferredDecoys into the ring, each +// validated registered on the base balance tree before signing. This test makes the A2 +// claim PROVEN-RUN: // -// A transfer whose ring members are CURATED (user-supplied via RingPreference, not drawn -// from DERO.GetRandomAddress) and which also carries an action-less SCDATA body must build, -// be accepted into the pool, mine, and FINALIZE into a block — i.e. the curated ring is -// consensus-valid, not merely wallet-valid. +// A base-SCID transfer whose ring members are CURATED (user-supplied via RingPreference, +// not drawn from DERO.GetRandomAddress) and which carries an action-less SCDATA body builds, +// passes full non-coinbase consensus verification INCLUDING bulletproof verification +// (skip_proof=false — Verify_Transaction_NonCoinbase, transaction_verify.go:200-201), mines, +// and is persisted into a mined block — i.e. the curated ring is consensus-valid, not merely +// wallet-valid. // // This is the load-bearing precondition for the rotating-identity ("Gatling") design's // curated-disjoint-ring allocator: if a self-chosen ring did not finalize, the whole -// disjoint-ring mitigation would be unbuildable. It does. +// disjoint-ring mitigation would be unbuildable. It does. The companion negative-control test +// (Test_CuratedRing_NegativeControls_A2) makes the registration-probe / fail-closed branches +// PROVEN-RUN rather than EXPECTED-BY-INSPECTION. // -// Scope honesty: single-node simulator. "FINALIZED" here means PERSISTED into a mined block -// (Block_tx_store), proving consensus ACCEPTANCE of the curated ring + action-less carrier. -// It is 0-conf on a difficulty-1 sim that bypasses PoW/miniblock verify and the low-fee floor; -// multi-node fork-choice and fee-competition are out of scope (Gate-3 / the declined R1 probe). +// Scope honesty (audit-tightened): +// - Single-node simulator. "FINALIZED" = PERSISTED into ONE mined block (Block_tx_store), +// 0-conf on a difficulty-1 sim — NOT a multi-node finality claim. Multi-node fork-choice and +// fee competition are out of scope (Gate-3 / the declined R1 probe). +// - The sim bypasses ONLY PoW/miniblock verify (blockchain.go:580,682,1193) and the low-fee +// floor (blockchain.go:1271). Proof/ring verification is NOT bypassed — that is why +// "consensus-valid" is justified. +// - BASE-SCID ONLY. For a zero-SCID transfer the curation registration-probe +// (wallet_transfer.go:108) is REDUNDANT with the unconditional ring-assembly re-probe +// (wallet_transfer.go:429) and with the consensus base-tree check (no SCID-fallback fires, +// transaction_verify.go:374 gated on !SCID.IsZero()). The probe's necessity as the SOLE +// wallet-side defense is load-bearing only on the NON-zero-SCID path, which this test does +// NOT execute (EXPECTED-BY-INSPECTION, not run here). +// - Sender balance delta is display-only; the absolute fee is not mainnet-representative +// (fee floor bypassed in sim). import ( "bytes" @@ -211,21 +225,40 @@ func Test_CuratedRing_Finalizes_A2(t *testing.T) { t.Fatalf("A2 FINALIZATION: persisted curated carrier failed to deserialize: %s", err) } - // PROVE THE RING WAS ACTUALLY CURATED (not random fallback): a ring of size `ring` has - // exactly `ring - 2` decoy slots (sender at witness_index[0], recipient at witness_index[1] - // are fixed). The curated-selection path places preferred decoys FIRST, so all `ring - 2` - // decoy slots must be filled from our preferred set — zero random fallback. We supply more - // preferred decoys (`ring`) than there are slots, so a fully-curated ring sees exactly - // `ring - 2` of them placed. Compare on the raw compressed public-key hex (HRP-independent). - _ = finalized_tx // finalization already proven by the successful ReadTX above - curatedSeen := 0 + // BIND CURATION TO FINALIZATION (audit fix #5): the ring is read off the BUILT tx (`tx`), + // while finalization is proven off the PERSISTED tx (`finalized_tx`). Those are only the same + // transaction if their hashes match — the tx hash commits the serialized ring pointers, so + // equal hashes mean the keys we counted are the keys that finalized. Assert it explicitly + // rather than leaving it implicit. + if finalized_tx.GetHash() != txhash { + t.Fatalf("A2 BIND: persisted tx hash %x != built+submitted tx hash %x — the curated ring read off the built tx is not provably the ring that finalized", finalized_tx.GetHash(), txhash) + } + + // PROVE THE RING WAS ACTUALLY CURATED (not random fallback), POSITIONALLY (audit fix #6): + // a ring of size `ring` has exactly `ring - 2` decoy slots; sender sits at witness_index[0] + // and recipient at witness_index[1] (PROVEN-SOURCE transaction_build.go:85,100). The + // curated-selection path places preferred decoys FIRST, so every decoy slot must hold a + // member of our preferred set — zero random fallback. We assert SUBSET-EQUALITY (every + // non-sender/non-recipient ring member is one of our preferred decoys, and all `ring - 2` + // slots are filled from them), not a bare count, so the proof survives a future ring-size or + // pool-size change. Compare on raw compressed public-key hex (HRP-independent). + senderKey := hex.EncodeToString(wsrc.GetAddress().PublicKey.EncodeCompressed()) + recipientKey := hex.EncodeToString(wrecipient.GetAddress().PublicKey.EncodeCompressed()) + preferredKeys := map[string]bool{} for _, d := range preferred { - da, perr := rpc.NewAddress(d) - if perr != nil { - continue + if da, perr := rpc.NewAddress(d); perr == nil { + preferredKeys[hex.EncodeToString(da.PublicKey.EncodeCompressed())] = true } - if builtRingKeys[hex.EncodeToString(da.PublicKey.EncodeCompressed())] { - curatedSeen++ + } + curatedSeen := 0 + for k := range builtRingKeys { + switch { + case k == senderKey || k == recipientKey: + // the two fixed slots — expected + case preferredKeys[k]: + curatedSeen++ // a curated decoy slot + default: + t.Fatalf("A2 CURATION: ring contains member %s that is neither sender, recipient, nor a preferred decoy — random fallback contaminated the curated ring", k) } } wantCurated := ring - 2 // sender + recipient occupy the other two slots @@ -244,8 +277,159 @@ func Test_CuratedRing_Finalizes_A2(t *testing.T) { } post_src, _ := wsrc.Get_Balance() - t.Log(fmt.Sprintf("A2 PROVEN-RUN: curated ring (size %d, %d/%d preferred decoys placed) + action-less SCDATA carrier "+ - "FINALIZED into a block, consensus-accepted, body byte-equal readback; sender %d->%d (debit incl. Amount+fee). "+ - "Scope: single-node sim persistence (0-conf, PoW/fee-floor bypassed); not a multi-node finality claim.", - ring, curatedSeen, len(preferred), pre_src, post_src)) + t.Log(fmt.Sprintf("A2 PROVEN-RUN (base-SCID curated-ring finalization): curated ring (size %d, %d/%d decoy slots "+ + "filled from the preferred set, zero random fallback) + action-less SCDATA carrier passed full non-coinbase "+ + "consensus verification incl. bulletproof (skip_proof=false, transaction_verify.go:200-201), mined, and was "+ + "persisted into a mined block (Block_tx_store); body byte-equal readback; built-ring hash == finalized-ring hash. "+ + "sender %d->%d (delta display-only; fee floor bypassed in sim, absolute fee NOT mainnet-representative). "+ + "Scope: single-node sim, FINALIZED = persisted into one mined block (0-conf), not multi-node finality; sim "+ + "bypasses ONLY PoW/miniblock verify + low-fee floor — proof/ring verification is NOT bypassed.", + ring, curatedSeen, ring-2, pre_src, post_src)) +} + +// Test_CuratedRing_NegativeControls_A2 makes the registration-probe / fail-closed branches +// PROVEN-RUN (audit fixes #3, #4) — the failures the curation guard exists to prevent: +// +// (a) Strict:true + an UNREGISTERED preferred decoy → TransferPayload0WithOptions hard-errors +// BEFORE signing (wallet_transfer.go:108-111), returning no tx. +// (b) Strict:false + an UNREGISTERED preferred decoy → the bad decoy is skipped, a random member +// fills the slot, the build succeeds, and only the REGISTERED preferred decoys appear in the +// ring (curatedSeen == registered count, < decoy slots). +// (c) A never-mined carrier → Block_tx_store.ReadTX returns not-found, proving the positive +// test's finalization assertion is non-vacuous (ReadTX does not always succeed). +// +// Without these, the positive test's consensus-rejection and finalization branches are +// EXPECTED-BY-INSPECTION; this test makes them demonstrably falsifiable. +func Test_CuratedRing_NegativeControls_A2(t *testing.T) { + globals.Arguments["--testnet"] = true + globals.Arguments["--simulator"] = true + + walletapi.Initialize_LookupTable(1, 1<<17) + + const ring = 8 + + mkwallet := func(name, seedHex string) *walletapi.Wallet_Disk { + db := filepath.Join(os.TempDir(), "a2neg_"+name+".db") + os.Remove(db) + t.Cleanup(func() { os.Remove(db) }) + seed, err := hex.DecodeString(seedHex) + if err != nil { + t.Fatalf("decode seed %s: %s", name, err) + } + w, err := walletapi.Create_Encrypted_Wallet(db, WALLET_PASSWORD, new(crypto.BNRed).SetBytes(seed)) + if err != nil { + t.Fatalf("create wallet %s: %s", name, err) + } + return w + } + + wgenesis := mkwallet("genesis", genesis_seed) + wsrc := mkwallet("src", wallets_seeds[0]) + wrecipient := mkwallet("dst", wallets_seeds[1]) + // Registered decoys: only ring-3 of them, so even a fully-curated ring needs ONE more slot + // than we have registered decoys — that slot is where the unregistered decoy (rejected) vs a + // random member (substituted) shows up. + var regDecoys []*walletapi.Wallet_Disk + for i := 0; i < ring-3 && 2+i < len(wallets_seeds); i++ { + regDecoys = append(regDecoys, mkwallet(fmt.Sprintf("rdecoy%d", i), wallets_seeds[2+i])) + } + // The unregistered decoy: created, NEVER registered on-chain. + wUnreg := mkwallet("unreg", wallets_seeds[len(wallets_seeds)-1]) + + genesis_tx := transaction.Transaction{Transaction_Prefix: transaction.Transaction_Prefix{Version: 1, Value: 2012345}} + copy(genesis_tx.MinerAddress[:], wgenesis.GetAddress().PublicKey.EncodeCompressed()) + config.Testnet.Genesis_Tx = fmt.Sprintf("%x", genesis_tx.Serialize()) + config.Mainnet.Genesis_Tx = fmt.Sprintf("%x", genesis_tx.Serialize()) + genesis_block := blockchain.Generate_Genesis_Block() + config.Testnet.Genesis_Block_Hash = genesis_block.GetHash() + config.Mainnet.Genesis_Block_Hash = genesis_block.GetHash() + + chain, rpcserver, _ := simulator_chain_start() + defer simulator_chain_stop(chain, rpcserver) + globals.Arguments["--daemon-address"] = rpcport_test + go walletapi.Keep_Connectivity() + + // Register sender, recipient, and ONLY the registered decoys — wUnreg is deliberately left out. + toRegister := append([]*walletapi.Wallet_Disk{wsrc, wrecipient}, regDecoys...) + for _, w := range toRegister { + if err := chain.Add_TX_To_Pool(w.GetRegistrationTX()); err != nil { + t.Fatalf("regtx: %s", err) + } + } + simulator_chain_mineblock(chain, wgenesis.GetAddress(), t) + for _, w := range append(toRegister, wgenesis) { + w.SetDaemonAddress(rpcport) + w.SetOnlineMode() + } + for i := 0; i < 8; i++ { + simulator_chain_mineblock(chain, wsrc.GetAddress(), t) + } + time.Sleep(time.Second) + if err := wsrc.Sync_Wallet_Memory_With_Daemon(); err != nil { + t.Fatalf("src sync: %s", err) + } + + recipient := wrecipient.GetAddress().String() + var regPreferred []string + for _, d := range regDecoys { + regPreferred = append(regPreferred, d.GetAddress().String()) + } + // preferred set that INCLUDES the unregistered decoy. + preferredWithUnreg := append(append([]string{}, regPreferred...), wUnreg.GetAddress().String()) + + frame := make([]byte, 64) + if _, err := rand.Read(frame); err != nil { + t.Fatal(err) + } + scdata := buildActionlessSCDATABodyA2(frame) + wsrc.SetRingSize(ring) + + // (a) STRICT + unregistered decoy → build MUST hard-error before signing, no tx. + strictTx, strictErr := wsrc.TransferPayload0WithOptions( + []rpc.Transfer{{Destination: recipient, Amount: 1}}, ring, false, scdata, 0, false, + walletapi.TransferOptions{Ring: &walletapi.RingPreference{PreferredDecoys: preferredWithUnreg, Strict: true}}) + if strictErr == nil || strictTx != nil { + t.Fatalf("NEG(a): Strict-mode build with an unregistered preferred decoy MUST hard-error before signing, got err=%v tx=%v", strictErr, strictTx != nil) + } + t.Logf("NEG(a) OK: Strict-mode unregistered decoy rejected before signing: %v", strictErr) + + // (b) NON-STRICT + unregistered decoy → build SUCCEEDS, the unregistered decoy is dropped and a + // random member fills the slot. Only the REGISTERED preferred decoys appear in the ring. + lenientTx, lenientErr := wsrc.TransferPayload0WithOptions( + []rpc.Transfer{{Destination: recipient, Amount: 1}}, ring, false, scdata, 0, false, + walletapi.TransferOptions{Ring: &walletapi.RingPreference{PreferredDecoys: preferredWithUnreg, Strict: false}}) + if lenientErr != nil || lenientTx == nil { + t.Fatalf("NEG(b): non-Strict build with an unregistered decoy should SUCCEED (skip+substitute), got err=%v", lenientErr) + } + lenientRingKeys := map[string]bool{} + for _, pl := range lenientTx.Payloads { + for _, p := range pl.Statement.Publickeylist { + lenientRingKeys[hex.EncodeToString((*crypto.Point)(p).EncodeCompressed())] = true + } + } + if lenientRingKeys[hex.EncodeToString(wUnreg.GetAddress().PublicKey.EncodeCompressed())] { + t.Fatalf("NEG(b): the UNREGISTERED decoy appears in the non-Strict ring — it must have been dropped, not placed") + } + regSeen := 0 + for _, d := range regPreferred { + da, _ := rpc.NewAddress(d) + if lenientRingKeys[hex.EncodeToString(da.PublicKey.EncodeCompressed())] { + regSeen++ + } + } + if regSeen != len(regPreferred) { + t.Fatalf("NEG(b): expected all %d registered preferred decoys in the ring, saw %d", len(regPreferred), regSeen) + } + t.Logf("NEG(b) OK: non-Strict dropped the unregistered decoy, kept all %d registered preferred, random-filled the rest", regSeen) + + // (c) NEVER-MINED control → the lenient tx was built but never submitted/mined; ReadTX MUST + // return not-found, proving the positive test's finalization assertion is non-vacuous. + var lenientD transaction.Transaction + if err := lenientD.Deserialize(lenientTx.Serialize()); err != nil { + t.Fatalf("deserialize lenient tx: %s", err) + } + if b, err := chain.Store.Block_tx_store.ReadTX(lenientD.GetHash()); err == nil && len(b) > 0 { + t.Fatalf("NEG(c): a never-mined tx was found in Block_tx_store — the finalization assertion is vacuous (ReadTX always succeeds)") + } + t.Logf("NEG(c) OK: a never-mined carrier is absent from Block_tx_store — finalization assertion is non-vacuous") } diff --git a/walletapi/wallet_transfer.go b/walletapi/wallet_transfer.go index 6d209459..3d32ad8d 100644 --- a/walletapi/wallet_transfer.go +++ b/walletapi/wallet_transfer.go @@ -72,10 +72,14 @@ func (w *Wallet_Memory) TransferPayload0(transfers []rpc.Transfer, ringsize uint // // A preferred decoy is validated to be parseable, not the wallet's own address, not a // duplicate, and registered on the BASE (zero-SCID) balance tree. The base tree is the -// one the consensus verifier falls back to for ring membership, so probing it (rather -// than the transfer's SCID tree) prevents a curated decoy that passes the wallet but -// then rejects at consensus after the user has signed. In Strict mode a bad decoy is a -// hard error; otherwise it is skipped and random members fill the slot. +// PRIMARY tree the consensus verifier checks for a zero-SCID transfer's ring members, and +// the FALLBACK tree it consults for a non-zero-SCID transfer's members not found in the SC +// tree (transaction_verify.go). Probing the base tree (rather than the transfer's SCID tree) +// therefore prevents a curated decoy that passes the wallet but rejects at consensus after +// the user has signed. NOTE: for a zero-SCID transfer this probe is redundant with the +// unconditional per-candidate re-probe in the ring-assembly loop; it is the SOLE wallet-side +// defense only on the non-zero-SCID path. In Strict mode a bad decoy is a hard error; +// otherwise it is skipped and random members fill the slot. func (w *Wallet_Memory) curatedRingCandidates(scid crypto.Hash, pref *RingPreference) (alist []string, err error) { if pref == nil { return w.Random_ring_members(scid), nil From 3dfd70d463a2a5bc6b3eedbb18d338023f87c13f Mon Sep 17 00:00:00 2001 From: DHEBP <152355273+DHEBP@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:08:57 -0400 Subject: [PATCH 07/15] feat(walletapi): canonicalize curated decoys + fail closed on ring-2 anon Addresses the #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 #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 #3, #9) The bounds guard is sufficient for the same reason as #21: bad ring keys panic at NewAddress before the Publickeylist rebuild, so len==RingSize. (review #4) --- .../curated_ring_altencoding_test.go | 259 ++++++++++++++++ rpc/wallet_rpc.go | 9 +- .../attribution_anonymous_ring2_guard_test.go | 62 ++++ walletapi/attribution_scrub_behavior_test.go | 285 ++++++++++++++++++ walletapi/daemon_communication.go | 36 ++- walletapi/wallet_transfer.go | 55 +++- 6 files changed, 690 insertions(+), 16 deletions(-) create mode 100644 cmd/simulator/curated_ring_altencoding_test.go create mode 100644 walletapi/attribution_anonymous_ring2_guard_test.go create mode 100644 walletapi/attribution_scrub_behavior_test.go diff --git a/cmd/simulator/curated_ring_altencoding_test.go b/cmd/simulator/curated_ring_altencoding_test.go new file mode 100644 index 00000000..b57ae608 --- /dev/null +++ b/cmd/simulator/curated_ring_altencoding_test.go @@ -0,0 +1,259 @@ +// Copyright 2017-2021 DERO Project. All rights reserved. +// Use of this source code in any form is governed by RESEARCH license. +// license can be found in the LICENSE file. + +package main + +import ( + "encoding/hex" + "fmt" + "math/rand" + "os" + "path/filepath" + "testing" + "time" + + "github.com/deroproject/derohe/blockchain" + "github.com/deroproject/derohe/config" + "github.com/deroproject/derohe/cryptography/crypto" + "github.com/deroproject/derohe/globals" + "github.com/deroproject/derohe/rpc" + "github.com/deroproject/derohe/transaction" + "github.com/deroproject/derohe/walletapi" +) + +// Test_CuratedRing_AltEncoding_NoDuplicate_A2 is the teeth-test for the canonicalization +// fix in curatedRingCandidates (PR #22 review finding #1). +// +// A ring member is identified by its PUBLIC KEY, not its address string. An integrated +// address (or any payment-id encoding) of an account already in the ring — the recipient, +// the sender, or an already-curated decoy — is a DIFFERENT string but the SAME pubkey. +// Before the fix, the wallet's string-keyed distinctness checks let such an alt-encoding +// through as a "distinct" decoy, placing the same pubkey in the ring twice. The wallet +// signed it; consensus then rejected it at transaction_verify.go (duplicate ring member) +// AFTER the user had signed. +// +// It exercises FOUR alt-encoding vectors as independent subtests, each supplying the +// poisoned encoding as the FIRST curated decoy (with only one other real decoy, ring 8) so +// the buggy path must consume and place it before random members fill the ring: +// - recipient-alt: integrated encoding of the recipient +// - sender-alt: integrated encoding of the sender (self) +// - decoy-vs-decoy: two integrated encodings of the SAME extra decoy +// - cross-network-recipient-alt: a wrong-network HRP encoding of the recipient pubkey +// +// Each asserts the built ring has NO duplicate pubkey and exactly `ring` distinct members — +// i.e. the alt-encoding was canonicalized away, not placed. With the canonicalization +// reverted, the ring carries a duplicate pubkey (fewer than `ring` distinct keys) — caught. +// +// NOTE: like every in-process simulator test here this spins a chain on the fixed RPC port +// rpcport_test and the shared simulator data-dir, so it must be run WITHOUT -race and not +// concurrently with the other chain-spin-up tests in this package (the project's standing +// simulator-flake constraint — no per-pid isolation in the test harness). +func Test_CuratedRing_AltEncoding_NoDuplicate_A2(t *testing.T) { + globals.Arguments["--testnet"] = true + globals.Arguments["--simulator"] = true + + walletapi.Initialize_LookupTable(1, 1<<17) + + const ring = 8 + + mkwallet := func(name, seedHex string) *walletapi.Wallet_Disk { + db := filepath.Join(os.TempDir(), "a2alt_"+name+".db") + os.Remove(db) + t.Cleanup(func() { os.Remove(db) }) + seed, err := hex.DecodeString(seedHex) + if err != nil { + t.Fatalf("decode seed %s: %s", name, err) + } + w, err := walletapi.Create_Encrypted_Wallet(db, WALLET_PASSWORD, new(crypto.BNRed).SetBytes(seed)) + if err != nil { + t.Fatalf("create wallet %s: %s", name, err) + } + return w + } + + wgenesis := mkwallet("genesis", genesis_seed) + wsrc := mkwallet("src", wallets_seeds[0]) + wrecipient := mkwallet("dst", wallets_seeds[1]) + // Register only ONE real decoy. With ring=8 and sender+recipient fixed, 6 decoy slots + // remain; supplying the integrated-recipient as the FIRST curated decoy forces the buggy + // path to consume and place it (it is not crowded out by other curated members), so a + // missing canonicalization produces a real duplicate pubkey in the ring. The remaining + // slots top up from random members. + var regDecoys []*walletapi.Wallet_Disk + regDecoys = append(regDecoys, mkwallet("rdecoy0", wallets_seeds[2])) + // a second registered decoy, used only by the decoy-vs-decoy vector below (two different + // integrated encodings of THIS account must collapse to one ring slot). + wdecoyDup := mkwallet("rdecoyDup", wallets_seeds[3]) + + genesis_tx := transaction.Transaction{Transaction_Prefix: transaction.Transaction_Prefix{Version: 1, Value: 2012345}} + copy(genesis_tx.MinerAddress[:], wgenesis.GetAddress().PublicKey.EncodeCompressed()) + config.Testnet.Genesis_Tx = fmt.Sprintf("%x", genesis_tx.Serialize()) + config.Mainnet.Genesis_Tx = fmt.Sprintf("%x", genesis_tx.Serialize()) + genesis_block := blockchain.Generate_Genesis_Block() + config.Testnet.Genesis_Block_Hash = genesis_block.GetHash() + config.Mainnet.Genesis_Block_Hash = genesis_block.GetHash() + + chain, rpcserver, _ := simulator_chain_start() + defer simulator_chain_stop(chain, rpcserver) + globals.Arguments["--daemon-address"] = rpcport_test + go walletapi.Keep_Connectivity() + + toRegister := append([]*walletapi.Wallet_Disk{wsrc, wrecipient, wdecoyDup}, regDecoys...) + for _, w := range toRegister { + if err := chain.Add_TX_To_Pool(w.GetRegistrationTX()); err != nil { + t.Fatalf("regtx: %s", err) + } + } + simulator_chain_mineblock(chain, wgenesis.GetAddress(), t) + for _, w := range append(toRegister, wgenesis) { + w.SetDaemonAddress(rpcport) + w.SetOnlineMode() + } + for i := 0; i < 8; i++ { + simulator_chain_mineblock(chain, wsrc.GetAddress(), t) + } + time.Sleep(time.Second) + if err := wsrc.Sync_Wallet_Memory_With_Daemon(); err != nil { + t.Fatalf("src sync: %s", err) + } + + recipient := wrecipient.GetAddress().String() + + wsrc.SetRingSize(ring) + + // integratedOf returns an integrated-address encoding (same pubkey, different string) + // of the given base address, carrying a distinct destination-port argument. + integratedOf := func(t *testing.T, base rpc.Address, port uint64) string { + a := base // value copy + a.Arguments = rpc.Arguments{{Name: rpc.RPC_DESTINATION_PORT, DataType: rpc.DataUint64, Value: port}} + s := a.String() + if s == base.String() { + t.Fatalf("integrated encoding produced the same string as the base address; test premise broken") + } + if pa, err := rpc.NewAddress(s); err != nil { + t.Fatalf("alt encoding not parseable: %s", err) + } else if pa.BaseAddress().String() != base.BaseAddress().String() { + t.Fatalf("alt encoding does not canonicalize to the same base key") + } + return s + } + + // pubkeyHex of an account, for counting appearances in the built ring. + pubkeyHex := func(w *walletapi.Wallet_Disk) string { + return hex.EncodeToString(w.GetAddress().PublicKey.EncodeCompressed()) + } + + // build runs one transfer with the given curated decoys and returns the built tx, asserting + // the build succeeds (non-strict: a poisoned alt-encoding is dropped + a random member tops up). + build := func(t *testing.T, label string, preferred []string) *transaction.Transaction { + frame := make([]byte, 64) + if _, err := rand.Read(frame); err != nil { + t.Fatal(err) + } + scdata := buildActionlessSCDATABodyA2(frame) + tx, err := wsrc.TransferPayload0WithOptions( + []rpc.Transfer{{Destination: recipient, Amount: 1}}, ring, false, scdata, 0, false, + walletapi.TransferOptions{Ring: &walletapi.RingPreference{PreferredDecoys: preferred, Strict: false}}) + if err != nil || tx == nil { + t.Fatalf("%s: build with an alt-encoded decoy should SUCCEED (drop+substitute), got err=%v", label, err) + } + return tx + } + + // assertNoDup is THE invariant consensus enforces (transaction_verify.go): every ring slot + // is a DISTINCT pubkey, and the ring carries exactly `ring` distinct members. With the + // canonicalization reverted, the poisoned alt-encoding lands the same pubkey twice and + // this fails. wantOnce names accounts that MUST appear exactly once (not doubled via decoy). + assertNoDup := func(t *testing.T, label string, tx *transaction.Transaction, wantOnce map[string]string) { + for _, pl := range tx.Payloads { + keys := map[string]bool{} + counts := map[string]int{} + for _, p := range pl.Statement.Publickeylist { + k := hex.EncodeToString((*crypto.Point)(p).EncodeCompressed()) + if keys[k] { + t.Fatalf("%s: DUPLICATE ring member pubkey %s — an alt-encoding was placed in the ring "+ + "as a distinct decoy; consensus would reject this tx AFTER signing", label, k[:16]) + } + keys[k] = true + counts[k]++ + } + if len(keys) != int(ring) { + t.Fatalf("%s: ring has %d distinct pubkeys, expected %d (a collapsed duplicate shrank the ring)", label, len(keys), ring) + } + for who, key := range wantOnce { + if counts[key] != 1 { + t.Fatalf("%s: %s pubkey appears %d times in the ring, expected exactly 1", label, who, counts[key]) + } + } + } + } + + recipientBase := wrecipient.GetAddress() + senderBase := wsrc.GetAddress() + dupDecoyBase := wdecoyDup.GetAddress() + + // Each vector is an independent subtest so a failure in one is reported without masking + // the others — the canonicalization fix must close ALL of them, not just the recipient. + + // VECTOR 1 — alt-encoded RECIPIENT as the first curated decoy. seen[recipientBase] must + // reject it (wallet_transfer.go: seen seeded with recipientBase). recipient appears once. + t.Run("recipient-alt", func(t *testing.T) { + preferred := []string{integratedOf(t, recipientBase, 0xDEAD)} + for _, d := range regDecoys { + preferred = append(preferred, d.GetAddress().String()) + } + tx := build(t, "recipient-alt", preferred) + assertNoDup(t, "recipient-alt", tx, map[string]string{"recipient": pubkeyHex(wrecipient)}) + }) + + // VECTOR 2 — alt-encoded SENDER as the first curated decoy. The sender is already in the + // ring (slot 0); an alt-encoding must be rejected by the `base == self` check (self = + // BaseAddress().String()). Reverting canonicalization (raw d == self) slips the integrated + // string through and doubles the sender's pubkey. sender appears once. + t.Run("sender-alt", func(t *testing.T) { + preferred := []string{integratedOf(t, senderBase, 0xBEEF)} + for _, d := range regDecoys { + preferred = append(preferred, d.GetAddress().String()) + } + tx := build(t, "sender-alt", preferred) + assertNoDup(t, "sender-alt", tx, map[string]string{"sender": pubkeyHex(wsrc)}) + }) + + // VECTOR 3 — DECOY-vs-DECOY: two DIFFERENT integrated encodings of the SAME registered + // account (wdecoyDup), supplied as the first two curated decoys. The second must collapse + // into the first via seen[base]. Reverting canonicalization lets both through as distinct + // strings and doubles that pubkey. The shared decoy pubkey appears exactly once. + t.Run("decoy-vs-decoy", func(t *testing.T) { + preferred := []string{ + integratedOf(t, dupDecoyBase, 0x1111), + integratedOf(t, dupDecoyBase, 0x2222), + } + tx := build(t, "decoy-vs-decoy", preferred) + assertNoDup(t, "decoy-vs-decoy", tx, map[string]string{"shared-decoy": pubkeyHex(wdecoyDup)}) + }) + + // VECTOR 4 — CROSS-NETWORK alt-encoding of the recipient. BaseAddress() preserves the + // Mainnet flag and MarshalText picks the HRP from it (rpc/address.go), so a wrong-network + // encoding of an in-ring pubkey (here the testnet recipient rendered with the mainnet + // `dero` HRP) is a DIFFERENT string but the SAME 33-byte pubkey — which consensus keys on + // (transaction_verify.go). The fix network-pins the canonical key to the wallet's network, + // so this collapses onto the recipient. Without that pin it slips every string check and + // doubles the recipient pubkey. + t.Run("cross-network-recipient-alt", func(t *testing.T) { + altNet := recipientBase.BaseAddress() + altNet.Mainnet = !altNet.Mainnet // flip testnet(deto) -> mainnet(dero), same pubkey + altStr := altNet.String() + if pa, err := rpc.NewAddress(altStr); err != nil { + t.Fatalf("cross-network alt not parseable: %s", err) + } else if hex.EncodeToString(pa.PublicKey.EncodeCompressed()) != pubkeyHex(wrecipient) { + t.Fatalf("cross-network alt does not carry the recipient pubkey") + } + preferred := []string{altStr} + for _, d := range regDecoys { + preferred = append(preferred, d.GetAddress().String()) + } + tx := build(t, "cross-network-recipient-alt", preferred) + assertNoDup(t, "cross-network-recipient-alt", tx, map[string]string{"recipient": pubkeyHex(wrecipient)}) + }) +} diff --git a/rpc/wallet_rpc.go b/rpc/wallet_rpc.go index c729aa10..bf1f5d49 100644 --- a/rpc/wallet_rpc.go +++ b/rpc/wallet_rpc.go @@ -83,10 +83,11 @@ type Entry struct { // these fields are only valid based on payload type and if payload could be successfully parsed and will by default be equal to zero values Sender string `json:"sender"` - // SenderVerified is true ONLY when attribution is structural (ring size 2, where - // 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 is true when entry.Sender can be trusted: either attribution is + // structural (ring size 2, where the only other ring member is necessarily the + // sender) or this wallet authored the transaction (an outgoing/self send, certain + // at any ring size). For an incoming ring size > 2 transfer the sender chose the + // unauthenticated attribution byte and entry.Sender MUST NOT be trusted. SenderVerified bool `json:"sender_verified"` // RingSize is the ring size of the payload this entry was decoded from (0 if unknown). RingSize uint64 `json:"ringsize"` diff --git a/walletapi/attribution_anonymous_ring2_guard_test.go b/walletapi/attribution_anonymous_ring2_guard_test.go new file mode 100644 index 00000000..d176f955 --- /dev/null +++ b/walletapi/attribution_anonymous_ring2_guard_test.go @@ -0,0 +1,62 @@ +// Copyright 2017-2021 DERO Project. All rights reserved. +// Use of this source code in any form is governed by RESEARCH license. +// license can be found in the LICENSE file. + +package walletapi + +import ( + "strings" + "testing" + + "github.com/deroproject/derohe/rpc" +) + +// Test_AttributionAnonymous_Ring2_FailsClosed locks the fail-closed guard for anonymous +// attribution at ring 2 (PR #22 review finding #8). +// +// At ring 2 there are no decoy slots (witness_index[2:] is empty), so the build branch +// `opts.Attribution == AttributionAnonymous && len(witness_index) > 2` is never taken and +// attribution silently falls through to honest (witness_index[1]). Without an explicit +// guard, a caller that asked for anonymity would broadcast a verifiably-attributed transfer +// while believing it was anonymized. The guard rejects the combination before building. +// +// This is a pure parameter-validation guard that fires BEFORE any daemon/network call +// (wallet_transfer.go, right after ring-size validation), so it runs on an OFFLINE in-memory +// wallet with no chain — fast and deterministic. +func Test_AttributionAnonymous_Ring2_FailsClosed(t *testing.T) { + w, err := Create_Encrypted_Wallet_From_Recovery_Words_Memory("", "sequence atlas unveil summon pebbles tuesday beer rudely snake rockets different fuselage woven tagged bested dented vegan hover rapid fawns obvious muppet randomly seasons randomly") + if err != nil { + t.Fatalf("cannot create in-memory wallet: %s", err) + } + defer w.Close_Encrypted_Wallet() + + dest := w.GetAddress().String() // any valid address; the guard fires before it is used + + anon := TransferOptions{Attribution: AttributionAnonymous} + + // ring 2 explicit → MUST hard-error before building, naming the ring-size cause. + tx, err := w.TransferPayload0WithOptions( + []rpc.Transfer{{Destination: dest, Amount: 1}}, 2, false, rpc.Arguments{}, 0, false, anon) + if err == nil || tx != nil { + t.Fatalf("anonymous attribution at ring 2 MUST fail closed before building, got err=%v tx=%v", err, tx != nil) + } + if !strings.Contains(err.Error(), "anonymous attribution requires ring size") { + t.Fatalf("ring-2 anon rejection should explain the ring-size cause, got: %v", err) + } + + // honest mode at ring 2 must be UNAFFECTED by the guard (it errors later, on the offline + // network call, NOT with the anonymous-attribution message). + if _, herr := w.TransferPayload0WithOptions( + []rpc.Transfer{{Destination: dest, Amount: 1}}, 2, false, rpc.Arguments{}, 0, false, TransferOptions{}); herr != nil && + strings.Contains(herr.Error(), "anonymous attribution requires ring size") { + t.Fatalf("honest mode at ring 2 wrongly tripped the anonymous-attribution guard: %v", herr) + } + + // anonymous at ring 4 must NOT trip the guard (it proceeds past validation and fails later + // on the offline network call, never with the anonymous-attribution message). + if _, aerr := w.TransferPayload0WithOptions( + []rpc.Transfer{{Destination: dest, Amount: 1}}, 4, false, rpc.Arguments{}, 0, false, anon); aerr != nil && + strings.Contains(aerr.Error(), "anonymous attribution requires ring size") { + t.Fatalf("anonymous mode at ring 4 wrongly tripped the ring-2 guard: %v", aerr) + } +} diff --git a/walletapi/attribution_scrub_behavior_test.go b/walletapi/attribution_scrub_behavior_test.go new file mode 100644 index 00000000..afb3377d --- /dev/null +++ b/walletapi/attribution_scrub_behavior_test.go @@ -0,0 +1,285 @@ +// Copyright 2017-2021 DERO Project. All rights reserved. +// Use of this source code in any form is governed by RESEARCH license. +// license can be found in the LICENSE file. +// GPG: 0F39 E425 8C65 3947 702A 8234 08B2 0360 A03A 9DE8 +// +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY +// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +// THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF +// THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package walletapi + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/deroproject/derohe/blockchain" + "github.com/deroproject/derohe/config" + "github.com/deroproject/derohe/cryptography/bn256" + "github.com/deroproject/derohe/cryptography/crypto" + "github.com/deroproject/derohe/globals" + "github.com/deroproject/derohe/rpc" + "github.com/deroproject/derohe/transaction" +) + +// Test_Attribution_Scrub_Behavior is the behavioral companion to the source-level +// grep guard (Test_AttributionExportGuard_UnverifiedSlotByteScrubbed). The grep guard +// proves the scrub TEXT is present at both decode sites; this test drives the REAL +// receiver decode path end-to-end on a simulated chain and asserts the scrub's effect +// on the exported Entry fields. A logically-wrong-but-textually-present scrub (inverted +// condition, wrong index, wrong RingSize comparison) passes the grep guard but FAILS here. +// +// It asserts the contract for both cases: +// - ring == 2 (verified, structural): entry.Sender is populated, entry.Data[0] (the +// attribution slot byte) is preserved, SenderVerified == true. +// - ring > 2 (unverified, sender-chosen byte): entry.Sender == "", entry.Data[0] == 0, +// SenderVerified == false. The decrypted Payload (parsed args) survives in both cases. +func Test_Attribution_Scrub_Behavior(t *testing.T) { + + time.Sleep(time.Millisecond) + + Initialize_LookupTable(1, 1<<17) + + wsrc_temp_db := filepath.Join(os.TempDir(), "scrub_test_wallet_src.db") + wdst_temp_db := filepath.Join(os.TempDir(), "scrub_test_wallet_dst.db") + + os.Remove(wsrc_temp_db) + os.Remove(wdst_temp_db) + + wsrc, err := Create_Encrypted_Wallet_From_Recovery_Words(wsrc_temp_db, "QWER", "sequence atlas unveil summon pebbles tuesday beer rudely snake rockets different fuselage woven tagged bested dented vegan hover rapid fawns obvious muppet randomly seasons randomly") + if err != nil { + t.Fatalf("Cannot create encrypted wallet, err %s", err) + } + + wdst, err := Create_Encrypted_Wallet_From_Recovery_Words(wdst_temp_db, "QWER", "Dekade Spagat Bereich Radclub Yeti Dialekt Unimog Nomade Anlage Hirte Besitz Märzluft Krabbe Nabel Halsader Chefarzt Hering tauchen Neuerung Reifen Umgang Hürde Alchimie Amnesie Reifen") + if err != nil { + 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") + if err != nil { + t.Fatalf("Cannot create encrypted wallet, err %s", err) + } + + // fix genesis tx and genesis tx hash + genesis_tx := transaction.Transaction{Transaction_Prefix: transaction.Transaction_Prefix{Version: 1, Value: 2012345}} + copy(genesis_tx.MinerAddress[:], wgenesis.account.Keys.Public.EncodeCompressed()) + + config.Testnet.Genesis_Tx = fmt.Sprintf("%x", genesis_tx.Serialize()) + config.Mainnet.Genesis_Tx = fmt.Sprintf("%x", genesis_tx.Serialize()) + + genesis_block := blockchain.Generate_Genesis_Block() + config.Testnet.Genesis_Block_Hash = genesis_block.GetHash() + config.Mainnet.Genesis_Block_Hash = genesis_block.GetHash() + + chain, rpcserver, params := simulator_chain_start() + _ = params + + defer func() { + simulator_chain_stop(chain, rpcserver) + wsrc.Close_Encrypted_Wallet() + wdst.Close_Encrypted_Wallet() + os.Remove(wsrc_temp_db) + os.Remove(wdst_temp_db) + }() + + globals.Arguments["--daemon-address"] = rpcport + + go Keep_Connectivity() + + if err := chain.Add_TX_To_Pool(wsrc.GetRegistrationTX()); err != nil { + t.Fatalf("Cannot add regtx to pool err %s", err) + } + if err := chain.Add_TX_To_Pool(wdst.GetRegistrationTX()); err != nil { + t.Fatalf("Cannot add regtx to pool err %s", err) + } + + for i := 0; i < 5; i++ { + simulator_chain_mineblock(chain, wgenesis.GetAddress(), t) + } + + wgenesis.SetDaemonAddress(rpcport) + wsrc.SetDaemonAddress(rpcport) + wdst.SetDaemonAddress(rpcport) + wgenesis.SetOnlineMode() + wsrc.SetOnlineMode() + wdst.SetOnlineMode() + + time.Sleep(time.Second * 2) + if err = wsrc.Sync_Wallet_Memory_With_Daemon(); err != nil { + t.Fatalf("Wallet sync error err %s", err) + } + if err = wdst.Sync_Wallet_Memory_With_Daemon(); err != nil { + t.Fatalf("Wallet sync error err %s", err) + } + + testPayload := rpc.Arguments{ + {Name: rpc.RPC_COMMENT, DataType: rpc.DataString, Value: "scrub probe"}, + {Name: rpc.RPC_DESTINATION_PORT, DataType: rpc.DataUint64, Value: uint64(123456789)}, + } + + // send one transfer at each ring size: index 0 = ring 2 (verified control), + // index 1 = ring 4 (unverified, triggers the scrub). + ringSizes := []int{2, 4} + + for idx, rs := range ringSizes { + wsrc.Sync_Wallet_Memory_With_Daemon() + wdst.Sync_Wallet_Memory_With_Daemon() + + wsrc.account.Ringsize = rs + + // The honest pre-scrub attribution byte is witness_index[1] (transaction_build.go:187) — + // the RECEIVER's OWN ring slot index. At ring 4 the receiver lands in slot 0 in 25% of + // valid permutations, in which case the honest byte is ALREADY 0x00 and a post-decode + // entry.Data[0]==0 assertion passes EVEN IF THE SCRUB IS BROKEN (coincidence, not the + // scrub). To give the ring>2 assertion real teeth we rebuild (re-randomizing the ring) + // until the receiver lands in a NON-ZERO slot: then entry.Data[0]==0 can ONLY be the + // scrub's doing. Ring 2 is exempt — its byte is protocol-pinned, not scrub-asserted. + var tx *transaction.Transaction + recvSlot := -1 + for attempt := 0; attempt < 64; attempt++ { + var berr error + tx, berr = wsrc.TransferPayload0([]rpc.Transfer{{Destination: wdst.GetAddress().String(), Amount: 90000, Payload_RPC: testPayload}}, 0, false, rpc.Arguments{}, 10000, false) + if berr != nil { + t.Fatalf("ring %d (send %d): cannot create transaction, err %s", rs, idx, berr) + } + if got := len(tx.Payloads[0].Statement.Publickeylist); got != rs { + t.Fatalf("ring %d (send %d): built ring size %d, expected %d", rs, idx, got, rs) + } + recvSlot = -1 + dstKey := wdst.account.Keys.Public.EncodeCompressed() + for i, p := range tx.Payloads[0].Statement.Publickeylist { + if string((*crypto.Point)(p).EncodeCompressed()) == string(dstKey) { + recvSlot = i + break + } + } + if recvSlot < 0 { + t.Fatalf("ring %d (send %d): receiver pubkey not found in built ring", rs, idx) + } + if rs == 2 || recvSlot != 0 { + break // ring 2 needs no teeth; ring>2 needs a non-zero honest byte + } + } + if rs > 2 && recvSlot == 0 { + t.Fatalf("ring %d (send %d): could not build a ring with the receiver in a non-zero slot "+ + "after 64 attempts; the entry.Data[0]==0 assertion would have no teeth", rs, idx) + } + + // BEHAVIORAL GUARD (honest attribution invariant): for a ring > 2 honest transfer, + // decrypt the receiver's payload directly from the built tx and assert the plaintext + // attribution byte points at the RECEIVER's own ring slot (witness_index[1]), never + // the sender's slot 0. This pins the same invariant the source-grep guard + // (Test_AttributionGuard_HonestWritesReceiverIndex) protects, but behaviorally — so a + // regression that reassigns attrIndex with `=` below the matched `:=` line (which the + // grep cannot see) fails here. recvSlot is guaranteed non-zero by the loop above, so + // "byte == recvSlot" is a real assertion, not a coincidence with the sender slot. + if rs > 2 { + rp := tx.Payloads[0].RPCPayload + ephemeral_pub := new(bn256.G1) + if err := ephemeral_pub.DecodeCompressed(rp[:33]); err != nil { + t.Fatalf("ring %d: cannot decode ephemeral pubkey from built tx: %s", rs, err) + } + plain := append([]byte{}, rp[33:]...) // copy: EncryptDecryptUserData mutates in place + shared_key := crypto.GenerateSharedSecret(wdst.account.Keys.Secret.BigInt(), ephemeral_pub) + crypto.EncryptDecryptUserData(crypto.Keccak256(shared_key[:], wdst.GetAddress().PublicKey.EncodeCompressed()), plain) + honestByte := int(plain[0]) + if honestByte == 0 { + t.Fatalf("ring %d: honest attribution byte is 0 (the SENDER slot) — a sender-revealing "+ + "regression; honest mode must point at the receiver slot %d", rs, recvSlot) + } + if honestByte != recvSlot { + t.Fatalf("ring %d: honest attribution byte is %d, expected the receiver's own slot %d "+ + "(witness_index[1]); honest mode must write the receiver slot, not slot %d", rs, honestByte, recvSlot, honestByte) + } + } + + var dtx transaction.Transaction + dtx.Deserialize(tx.Serialize()) + + simulator_chain_mineblock(chain, wgenesis.GetAddress(), t) + wsrc.Sync_Wallet_Memory_With_Daemon() + wdst.Sync_Wallet_Memory_With_Daemon() + + if err := chain.Add_TX_To_Pool(&dtx); err != nil { + t.Fatalf("ring %d (send %d): cannot add transfer to pool, err %s", rs, idx, err) + } + + simulator_chain_mineblock(chain, wgenesis.GetAddress(), t) + wgenesis.Sync_Wallet_Memory_With_Daemon() + } + + time.Sleep(time.Second) + wdst.Sync_Wallet_Memory_With_Daemon() + + minHeight := uint64(0) + maxHeight := uint64(chain.Get_Height()) + 1 + + dstEntries := wdst.Show_Transfers(crypto.ZEROHASH, false, true, false, minHeight, maxHeight, "", "", 0, 0) + if len(dstEntries) != len(ringSizes) { + t.Fatalf("receiver expected %d transfers, got %d", len(ringSizes), len(dstEntries)) + } + + var sawVerified, sawUnverified bool + for _, e := range dstEntries { + // the decrypted payload must survive intact regardless of scrub: the comment + // arg parses in both cases (scrub only touches the leading attribution slot byte). + args, err := e.ProcessPayload() + if err != nil { + t.Fatalf("ring %d: payload did not parse after decode: %s", e.RingSize, err) + } + if !args.HasValue(rpc.RPC_COMMENT, rpc.DataString) { + t.Fatalf("ring %d: decrypted payload lost its comment arg after scrub", e.RingSize) + } + + switch e.RingSize { + case 2: + sawVerified = true + if !e.SenderVerified { + t.Fatalf("ring 2: SenderVerified must be true (structural attribution)") + } + if e.Sender == "" { + t.Fatalf("ring 2: verified Sender must be populated, got empty") + } + if len(e.Data) == 0 { + t.Fatalf("ring 2: entry.Data unexpectedly empty") + } + // ring-2 attribution byte is protocol-pinned and intentionally preserved: + // the receiver overrides sender_idx to the counterparty slot, so a zero + // here would only be coincidental. The contract is that it is NOT scrubbed, + // which is proven by Sender being populated above. + default: // ring > 2 + sawUnverified = true + if e.SenderVerified { + t.Fatalf("ring %d: SenderVerified must be false for sender-chosen attribution", e.RingSize) + } + if e.Sender != "" { + t.Fatalf("ring %d: unverified Sender must be scrubbed to empty, got %q", e.RingSize, e.Sender) + } + if len(e.Data) == 0 { + t.Fatalf("ring %d: entry.Data unexpectedly empty", e.RingSize) + } + if e.Data[0] != 0x00 { + t.Fatalf("ring %d: attribution slot byte entry.Data[0] must be zeroed, got 0x%02x — "+ + "it re-derives the claimed sender via the public Publickeylist", e.RingSize, e.Data[0]) + } + } + } + + if !sawVerified { + t.Fatalf("no ring-2 (verified) transfer observed; control case did not run") + } + if !sawUnverified { + t.Fatalf("no ring>2 (unverified) transfer observed; scrub case did not run") + } +} diff --git a/walletapi/daemon_communication.go b/walletapi/daemon_communication.go index f36c8d84..77b133ce 100644 --- a/walletapi/daemon_communication.go +++ b/walletapi/daemon_communication.go @@ -985,6 +985,10 @@ func (w *Wallet_Memory) synchistory_block(scid crypto.Hash, topo int64) (err err addr := rpc.NewAddressFromKeys((*crypto.Point)(w.account.Keys.Public.G1())) addr.Mainnet = w.GetNetwork() entry.Sender = addr.String() + // this wallet authored the tx, so the sender (ourselves) is certain + // at any ring size; mark it verified so consumers do not distrust our own sends. + entry.RingSize = uint64(tx.Payloads[t].Statement.RingSize) + entry.SenderVerified = true entry.Payload = append(entry.Payload, tx.Payloads[t].RPCPayload[1:]...) entry.Data = append(entry.Data, tx.Payloads[t].RPCPayload[:]...) @@ -1010,6 +1014,10 @@ func (w *Wallet_Memory) synchistory_block(scid crypto.Hash, topo int64) (err err addr := rpc.NewAddressFromKeys((*crypto.Point)(w.account.Keys.Public.G1())) addr.Mainnet = w.GetNetwork() entry.Sender = addr.String() + // this wallet authored the tx, so the sender (ourselves) is certain + // at any ring size; mark it verified so consumers do not distrust our own sends. + entry.RingSize = uint64(tx.Payloads[t].Statement.RingSize) + entry.SenderVerified = true entry.Payload = append(entry.Payload, payload[1:]...) entry.Data = append(entry.Data, payload...) @@ -1080,8 +1088,20 @@ func (w *Wallet_Memory) synchistory_block(scid crypto.Hash, topo int64) (err err // ring size 2 attribution is structural — the only other ring member is the sender; larger rings are sender-chosen and unverified entry.SenderVerified = uint(tx.Payloads[t].Statement.RingSize) == 2 + // sanitized copy of the decrypted payload for the exported entry fields. + // for an unverified attribution (ring > 2, where payload[0] is sender-chosen + // and unauthenticated) the leading attribution slot byte must NOT be exported: + // 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 + if !entry.SenderVerified { + entry.Sender = "" + exported_payload = append([]byte{0x00}, tx.Payloads[t].RPCPayload[1:]...) + } + entry.Payload = append(entry.Payload, tx.Payloads[t].RPCPayload[1:]...) - entry.Data = append(entry.Data, tx.Payloads[t].RPCPayload[:]...) + entry.Data = append(entry.Data, exported_payload[:]...) args, _ := entry.ProcessPayload() _ = args @@ -1122,8 +1142,20 @@ func (w *Wallet_Memory) synchistory_block(scid crypto.Hash, topo int64) (err err // ring size 2 attribution is structural — the only other ring member is the sender; larger rings are sender-chosen and unverified entry.SenderVerified = uint(tx.Payloads[t].Statement.RingSize) == 2 + // sanitized copy of the decrypted payload for the exported entry fields. + // for an unverified attribution (ring > 2, where payload[0] is sender-chosen + // and unauthenticated) the leading attribution slot byte must NOT be exported: + // 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 := payload + if !entry.SenderVerified { + entry.Sender = "" + exported_payload = append([]byte{0x00}, payload[1:]...) + } + entry.Payload = append(entry.Payload, payload[1:]...) - entry.Data = append(entry.Data, payload...) + entry.Data = append(entry.Data, exported_payload...) args, _ := entry.ProcessPayload() _ = args diff --git a/walletapi/wallet_transfer.go b/walletapi/wallet_transfer.go index 3d32ad8d..d3d26b52 100644 --- a/walletapi/wallet_transfer.go +++ b/walletapi/wallet_transfer.go @@ -80,43 +80,64 @@ func (w *Wallet_Memory) TransferPayload0(transfers []rpc.Transfer, ringsize uint // unconditional per-candidate re-probe in the ring-assembly loop; it is the SOLE wallet-side // defense only on the non-zero-SCID path. In Strict mode a bad decoy is a hard error; // otherwise it is skipped and random members fill the slot. -func (w *Wallet_Memory) curatedRingCandidates(scid crypto.Hash, pref *RingPreference) (alist []string, err error) { +func (w *Wallet_Memory) curatedRingCandidates(scid crypto.Hash, recipientBase string, pref *RingPreference) (alist []string, err error) { if pref == nil { return w.Random_ring_members(scid), nil } var zeroscid crypto.Hash - self := w.GetAddress().String() + // A ring member is identified by its public key, not its address string. Integrated + // and payment-id encodings of the SAME account share one pubkey but differ as strings, + // so all distinctness checks here canonicalize to the BASE address. Without this, an + // alt-encoding of the sender, recipient, or an already-curated decoy slips every check + // and lands the same pubkey in the ring twice — which the wallet accepts but consensus + // rejects (transaction_verify.go) AFTER the user has signed. + self := w.GetAddress().BaseAddress().String() + // seed with sender AND recipient base keys: both are already in the ring, so an + // alt-encoding of either is a duplicate that must not be curated as a decoy. seen := map[string]bool{self: true} + if recipientBase != "" { + seen[recipientBase] = true + } for _, d := range pref.PreferredDecoys { - if _, e := rpc.NewAddress(d); e != nil { // must be a parseable address + addr, e := rpc.NewAddress(d) + if e != nil { // must be a parseable address if pref.Strict { return nil, fmt.Errorf("preferred decoy is not a valid address: %s", d) } continue } - if d == self { // curating your own address collapses your anonymity set + // The canonical identity for distinctness is the PUBKEY, which is network-agnostic: + // consensus keys duplicate ring members on the raw 33-byte pubkey (transaction_verify.go), + // so a wrong-network HRP encoding (dero… on a deto wallet, or vice versa) of an in-ring + // pubkey is the SAME ring member even though BaseAddress() preserves the Mainnet flag and + // would render a different string. Pin the network to this wallet's before stringifying so + // 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 + if base == self { // curating your own address collapses your anonymity set if pref.Strict { return nil, fmt.Errorf("preferred decoy cannot be your own address") } continue } - if seen[d] { // distinctness (consensus rejects duplicate ring members) + if seen[base] { // distinctness (consensus rejects duplicate ring members) if pref.Strict { return nil, fmt.Errorf("duplicate preferred decoy: %s", d) } continue } // registration: probe the BASE balance tree, the tree consensus checks against. - if _, _, _, _, e := w.GetEncryptedBalanceAtTopoHeight(zeroscid, -1, d); e != nil { + if _, _, _, _, e := w.GetEncryptedBalanceAtTopoHeight(zeroscid, -1, base); e != nil { if pref.Strict { return nil, fmt.Errorf("preferred decoy is not registered: %s", d) } continue } - seen[d] = true - alist = append(alist, d) + seen[base] = true + alist = append(alist, base) // ring carries the canonical base form } return append(alist, w.Random_ring_members(scid)...), nil @@ -148,6 +169,15 @@ func (w *Wallet_Memory) TransferPayload0WithOptions(transfers []rpc.Transfer, ri } } + // fail closed on anonymous attribution at ring 2: there are no decoy slots + // (witness_index[2:] is empty), so the build would silently fall through to honest + // attribution and broadcast a verifiably-attributed transfer while the caller believes + // it is anonymized. ringsize is now the effective value (supplied or wallet default). + if opts.Attribution == AttributionAnonymous && ringsize < 3 { + err = fmt.Errorf("anonymous attribution requires ring size >= 4; ring size %d has no decoy slots", ringsize) + return + } + //ringsize = 2 // if wallet is online,take the fees from the network itself @@ -392,6 +422,11 @@ func (w *Wallet_Memory) TransferPayload0WithOptions(transfers []rpc.Transfer, ri }*/ receiver_without_payment_id := addr.BaseAddress() + // network-pin the recipient's distinctness key to this wallet's network, matching how + // curatedRingCandidates canonicalizes decoy keys: a ring member's identity is its pubkey, + // not its network-tagged string, so the seed handed to the curated check (and the + // deduplicator below) must be on the same network as the decoy keys it is compared against. + receiver_without_payment_id.Mainnet = w.GetNetwork() //sending to self is not supported if w.GetAddress().String() == receiver_without_payment_id.String() { @@ -406,14 +441,14 @@ func (w *Wallet_Memory) TransferPayload0WithOptions(transfers []rpc.Transfer, ri for ringsize != 2 { // 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) + probable_members, cerr := w.curatedRingCandidates(transfers[t].SCID, receiver_without_payment_id.String(), opts.Ring) if cerr != nil { err = cerr return } if len(probable_members) <= 40 { // we do not have enough ring members for sure, extract ring members from base var zeroscid crypto.Hash - base_members, berr := w.curatedRingCandidates(zeroscid, opts.Ring) + base_members, berr := w.curatedRingCandidates(zeroscid, receiver_without_payment_id.String(), opts.Ring) if berr != nil { err = berr return From fc656f75fa979073b51e66c1f715e9cd4b9ae01f Mon Sep 17 00:00:00 2001 From: DHEBP <152355273+DHEBP@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:16:10 -0400 Subject: [PATCH 08/15] feat(walletapi): add explicit self-attribution mode (AttributionSelf) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- walletapi/attribution_self_behavior_test.go | 388 ++++++++++++++++++++ walletapi/transaction_build.go | 29 +- 2 files changed, 411 insertions(+), 6 deletions(-) create mode 100644 walletapi/attribution_self_behavior_test.go diff --git a/walletapi/attribution_self_behavior_test.go b/walletapi/attribution_self_behavior_test.go new file mode 100644 index 00000000..36c409dc --- /dev/null +++ b/walletapi/attribution_self_behavior_test.go @@ -0,0 +1,388 @@ +// Copyright 2017-2021 DERO Project. All rights reserved. +// Use of this source code in any form is governed by RESEARCH license. +// license can be found in the LICENSE file. + +package walletapi + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/deroproject/derohe/blockchain" + "github.com/deroproject/derohe/config" + "github.com/deroproject/derohe/cryptography/bn256" + "github.com/deroproject/derohe/cryptography/crypto" + "github.com/deroproject/derohe/globals" + "github.com/deroproject/derohe/rpc" + "github.com/deroproject/derohe/transaction" +) + +// Test_AttributionSelf_WritesSenderSlot is the behavioral proof of the AttributionSelf +// engine branch (transaction_build.go): a transfer built with opts.Attribution == +// AttributionSelf writes the SENDER's own ring slot index into the receiver-readable +// attribution byte (witness_index[0]), deliberately self-doxxing — the opposite of the +// honest default (which writes the receiver slot, witness_index[1]). +// +// It drives the REAL build + receiver-decode path on a simulated chain (cloning the +// scrub test's harness) and asserts the decrypted attribution byte equals the sender's +// slot in the built ring. A ring > 2 is used so the sender slot is a meaningful, distinct +// index; the test re-randomizes the ring until the sender lands in a NON-ZERO slot so the +// "byte == senderSlot" assertion has real teeth (slot 0 is the all-zero default a broken +// branch would also produce). MUTATION CHECK: change the engine branch to write +// witness_index[1] and this test goes RED. +// +// Shares the fixed rpcport with the other sim-chain tests, so it must NOT run in parallel. +func Test_AttributionSelf_WritesSenderSlot(t *testing.T) { + + time.Sleep(time.Millisecond) + + Initialize_LookupTable(1, 1<<17) + + wsrc_temp_db := filepath.Join(os.TempDir(), "self_attr_test_wallet_src.db") + wdst_temp_db := filepath.Join(os.TempDir(), "self_attr_test_wallet_dst.db") + + os.Remove(wsrc_temp_db) + os.Remove(wdst_temp_db) + + wsrc, err := Create_Encrypted_Wallet_From_Recovery_Words(wsrc_temp_db, "QWER", "sequence atlas unveil summon pebbles tuesday beer rudely snake rockets different fuselage woven tagged bested dented vegan hover rapid fawns obvious muppet randomly seasons randomly") + if err != nil { + t.Fatalf("Cannot create encrypted wallet, err %s", err) + } + + wdst, err := Create_Encrypted_Wallet_From_Recovery_Words(wdst_temp_db, "QWER", "Dekade Spagat Bereich Radclub Yeti Dialekt Unimog Nomade Anlage Hirte Besitz Märzluft Krabbe Nabel Halsader Chefarzt Hering tauchen Neuerung Reifen Umgang Hürde Alchimie Amnesie Reifen") + if err != nil { + 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") + if err != nil { + t.Fatalf("Cannot create encrypted wallet, err %s", err) + } + + // fix genesis tx and genesis tx hash + genesis_tx := transaction.Transaction{Transaction_Prefix: transaction.Transaction_Prefix{Version: 1, Value: 2012345}} + copy(genesis_tx.MinerAddress[:], wgenesis.account.Keys.Public.EncodeCompressed()) + + config.Testnet.Genesis_Tx = fmt.Sprintf("%x", genesis_tx.Serialize()) + config.Mainnet.Genesis_Tx = fmt.Sprintf("%x", genesis_tx.Serialize()) + + genesis_block := blockchain.Generate_Genesis_Block() + config.Testnet.Genesis_Block_Hash = genesis_block.GetHash() + config.Mainnet.Genesis_Block_Hash = genesis_block.GetHash() + + chain, rpcserver, params := simulator_chain_start() + _ = params + + defer func() { + simulator_chain_stop(chain, rpcserver) + wsrc.Close_Encrypted_Wallet() + wdst.Close_Encrypted_Wallet() + os.Remove(wsrc_temp_db) + os.Remove(wdst_temp_db) + }() + + globals.Arguments["--daemon-address"] = rpcport + + go Keep_Connectivity() + + if err := chain.Add_TX_To_Pool(wsrc.GetRegistrationTX()); err != nil { + t.Fatalf("Cannot add regtx to pool err %s", err) + } + if err := chain.Add_TX_To_Pool(wdst.GetRegistrationTX()); err != nil { + t.Fatalf("Cannot add regtx to pool err %s", err) + } + + for i := 0; i < 5; i++ { + simulator_chain_mineblock(chain, wgenesis.GetAddress(), t) + } + + wgenesis.SetDaemonAddress(rpcport) + wsrc.SetDaemonAddress(rpcport) + wdst.SetDaemonAddress(rpcport) + wgenesis.SetOnlineMode() + wsrc.SetOnlineMode() + wdst.SetOnlineMode() + + time.Sleep(time.Second * 2) + if err = wsrc.Sync_Wallet_Memory_With_Daemon(); err != nil { + t.Fatalf("Wallet sync error err %s", err) + } + if err = wdst.Sync_Wallet_Memory_With_Daemon(); err != nil { + t.Fatalf("Wallet sync error err %s", err) + } + + testPayload := rpc.Arguments{ + {Name: rpc.RPC_COMMENT, DataType: rpc.DataString, Value: "self probe"}, + {Name: rpc.RPC_DESTINATION_PORT, DataType: rpc.DataUint64, Value: uint64(123456789)}, + } + + const rs = 4 // ring > 2 so the sender slot is a distinct, meaningful index + + // build with AttributionSelf, re-randomizing the ring until the SENDER lands in a + // non-zero slot (so "byte == senderSlot" cannot be a coincidence with the all-zero + // default a broken/honest branch would write). + var tx *transaction.Transaction + senderSlot := -1 + srcKey := wsrc.account.Keys.Public.EncodeCompressed() + for attempt := 0; attempt < 64; attempt++ { + var berr error + tx, berr = wsrc.TransferPayload0WithOptions( + []rpc.Transfer{{Destination: wdst.GetAddress().String(), Amount: 90000, Payload_RPC: testPayload}}, + rs, false, rpc.Arguments{}, 0, false, TransferOptions{Attribution: AttributionSelf}) + if berr != nil { + t.Fatalf("cannot create self-attributed transaction: %s", berr) + } + if got := len(tx.Payloads[0].Statement.Publickeylist); got != rs { + t.Fatalf("built ring size %d, expected %d", got, rs) + } + senderSlot = -1 + for i, p := range tx.Payloads[0].Statement.Publickeylist { + if string((*crypto.Point)(p).EncodeCompressed()) == string(srcKey) { + senderSlot = i + break + } + } + if senderSlot < 0 { + t.Fatalf("sender pubkey not found in built ring") + } + if senderSlot != 0 { + break // a non-zero sender slot gives the assertion teeth + } + } + if senderSlot == 0 { + t.Fatalf("could not build a ring with the sender in a non-zero slot after 64 attempts; "+ + "the byte==senderSlot assertion would have no teeth") + } + + // decrypt the receiver's payload directly from the built tx (the same 4-line ECDH the + // scrub test uses) and assert the attribution byte points at the SENDER's own slot. + rp := tx.Payloads[0].RPCPayload + ephemeral_pub := new(bn256.G1) + if err := ephemeral_pub.DecodeCompressed(rp[:33]); err != nil { + t.Fatalf("cannot decode ephemeral pubkey from built tx: %s", err) + } + plain := append([]byte{}, rp[33:]...) // copy: EncryptDecryptUserData mutates in place + shared_key := crypto.GenerateSharedSecret(wdst.account.Keys.Secret.BigInt(), ephemeral_pub) + crypto.EncryptDecryptUserData(crypto.Keccak256(shared_key[:], wdst.GetAddress().PublicKey.EncodeCompressed()), plain) + attrByte := int(plain[0]) + + if attrByte != senderSlot { + t.Fatalf("AttributionSelf must point the byte at the SENDER slot %d, got %d "+ + "(honest mode would write the receiver slot — this is the mutation guard)", senderSlot, attrByte) + } +} + +// Test_AttributionSelf_EndToEnd_ScrubHolds is the end-to-end (build → broadcast → mined → +// receiver-decode) companion to Test_AttributionSelf_WritesSenderSlot, closing the teeth +// gap that the build-only test leaves open (redteam O3): no prior test scanned a +// self-attributed tx through the REAL daemon_communication.go decode path. +// +// It pins the DESIGN-INTENDED behavior (SCOPE-self-attribution.md VERIFY step 2): a CURRENT, +// up-to-date wallet still hides the byte even when the SENDER deliberately chose Self — +// because the #21 scrub keys off ring>2 (sender-chosen ⇒ unverified), NOT off the mode. So +// at ring 4 a Self-attributed transfer must STILL decode with entry.Sender == "" and the +// exported entry.Data[0] == 0. This is the opposite of O1's framing of a "broken feature": +// the current-wallet no-op is the intended, preserved guarantee; Self's only effect is the +// permanent on-chain raw byte for a non-blanking / old / future-break reader — exactly what +// the warning claims and exactly what attributionResultLine now says. +// +// TEETH: this would go RED if a future change made Self bypass the scrub (e.g. forced +// SenderVerified=true, or exported the raw payload for self-mode), which is the silent +// self-doxx-against-current-wallets regression the design forbids. It also gives O3 the +// missing build→decode coverage: the engine writes slot 0, but the receiver layer must be +// shown to consume it coherently with what the CLI reports. +// +// Shares the fixed rpcport with the other sim-chain tests, so it must NOT run in parallel. +func Test_AttributionSelf_EndToEnd_ScrubHolds(t *testing.T) { + + time.Sleep(time.Millisecond) + + Initialize_LookupTable(1, 1<<17) + + wsrc_temp_db := filepath.Join(os.TempDir(), "self_e2e_test_wallet_src.db") + wdst_temp_db := filepath.Join(os.TempDir(), "self_e2e_test_wallet_dst.db") + + os.Remove(wsrc_temp_db) + os.Remove(wdst_temp_db) + + wsrc, err := Create_Encrypted_Wallet_From_Recovery_Words(wsrc_temp_db, "QWER", "sequence atlas unveil summon pebbles tuesday beer rudely snake rockets different fuselage woven tagged bested dented vegan hover rapid fawns obvious muppet randomly seasons randomly") + if err != nil { + t.Fatalf("Cannot create encrypted wallet, err %s", err) + } + + wdst, err := Create_Encrypted_Wallet_From_Recovery_Words(wdst_temp_db, "QWER", "Dekade Spagat Bereich Radclub Yeti Dialekt Unimog Nomade Anlage Hirte Besitz Märzluft Krabbe Nabel Halsader Chefarzt Hering tauchen Neuerung Reifen Umgang Hürde Alchimie Amnesie Reifen") + if err != nil { + 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") + if err != nil { + t.Fatalf("Cannot create encrypted wallet, err %s", err) + } + + genesis_tx := transaction.Transaction{Transaction_Prefix: transaction.Transaction_Prefix{Version: 1, Value: 2012345}} + copy(genesis_tx.MinerAddress[:], wgenesis.account.Keys.Public.EncodeCompressed()) + + config.Testnet.Genesis_Tx = fmt.Sprintf("%x", genesis_tx.Serialize()) + config.Mainnet.Genesis_Tx = fmt.Sprintf("%x", genesis_tx.Serialize()) + + genesis_block := blockchain.Generate_Genesis_Block() + config.Testnet.Genesis_Block_Hash = genesis_block.GetHash() + config.Mainnet.Genesis_Block_Hash = genesis_block.GetHash() + + chain, rpcserver, params := simulator_chain_start() + _ = params + + defer func() { + simulator_chain_stop(chain, rpcserver) + wsrc.Close_Encrypted_Wallet() + wdst.Close_Encrypted_Wallet() + os.Remove(wsrc_temp_db) + os.Remove(wdst_temp_db) + }() + + globals.Arguments["--daemon-address"] = rpcport + + go Keep_Connectivity() + + if err := chain.Add_TX_To_Pool(wsrc.GetRegistrationTX()); err != nil { + t.Fatalf("Cannot add regtx to pool err %s", err) + } + if err := chain.Add_TX_To_Pool(wdst.GetRegistrationTX()); err != nil { + t.Fatalf("Cannot add regtx to pool err %s", err) + } + + for i := 0; i < 5; i++ { + simulator_chain_mineblock(chain, wgenesis.GetAddress(), t) + } + + wgenesis.SetDaemonAddress(rpcport) + wsrc.SetDaemonAddress(rpcport) + wdst.SetDaemonAddress(rpcport) + wgenesis.SetOnlineMode() + wsrc.SetOnlineMode() + wdst.SetOnlineMode() + + time.Sleep(time.Second * 2) + if err = wsrc.Sync_Wallet_Memory_With_Daemon(); err != nil { + t.Fatalf("Wallet sync error err %s", err) + } + if err = wdst.Sync_Wallet_Memory_With_Daemon(); err != nil { + t.Fatalf("Wallet sync error err %s", err) + } + + testPayload := rpc.Arguments{ + {Name: rpc.RPC_COMMENT, DataType: rpc.DataString, Value: "self e2e probe"}, + {Name: rpc.RPC_DESTINATION_PORT, DataType: rpc.DataUint64, Value: uint64(123456789)}, + } + + const rs = 4 // ring > 2: the scrub branch the design must preserve even under Self + + // Build a SELF-attributed transfer, re-randomizing until the sender lands in a NON-ZERO + // slot so the BUILT byte is provably the sender slot (not a coincidental 0). We then + // assert that despite the sender choosing Self, the receiver STILL scrubs. + var tx *transaction.Transaction + senderSlot := -1 + srcKey := wsrc.account.Keys.Public.EncodeCompressed() + for attempt := 0; attempt < 64; attempt++ { + var berr error + tx, berr = wsrc.TransferPayload0WithOptions( + []rpc.Transfer{{Destination: wdst.GetAddress().String(), Amount: 90000, Payload_RPC: testPayload}}, + rs, false, rpc.Arguments{}, 10000, false, TransferOptions{Attribution: AttributionSelf}) + if berr != nil { + t.Fatalf("cannot create self-attributed transaction: %s", berr) + } + if got := len(tx.Payloads[0].Statement.Publickeylist); got != rs { + t.Fatalf("built ring size %d, expected %d", got, rs) + } + senderSlot = -1 + for i, p := range tx.Payloads[0].Statement.Publickeylist { + if string((*crypto.Point)(p).EncodeCompressed()) == string(srcKey) { + senderSlot = i + break + } + } + if senderSlot < 0 { + t.Fatalf("sender pubkey not found in built ring") + } + if senderSlot != 0 { + break // a non-zero sender slot proves the built byte really is the sender slot + } + } + if senderSlot == 0 { + t.Fatalf("could not build a ring with the sender in a non-zero slot after 64 attempts") + } + + // Sanity: the BUILT byte really is the (non-zero) sender slot before broadcast — so any + // post-decode scrub is the receiver layer's doing, not an already-zero byte. + { + rp := tx.Payloads[0].RPCPayload + eph := new(bn256.G1) + if err := eph.DecodeCompressed(rp[:33]); err != nil { + t.Fatalf("cannot decode ephemeral pubkey from built tx: %s", err) + } + plain := append([]byte{}, rp[33:]...) + sk := crypto.GenerateSharedSecret(wdst.account.Keys.Secret.BigInt(), eph) + crypto.EncryptDecryptUserData(crypto.Keccak256(sk[:], wdst.GetAddress().PublicKey.EncodeCompressed()), plain) + if int(plain[0]) != senderSlot { + t.Fatalf("pre-broadcast self byte = %d, expected sender slot %d", int(plain[0]), senderSlot) + } + } + + // Broadcast → mine → receiver scans via the REAL decode path. + var dtx transaction.Transaction + dtx.Deserialize(tx.Serialize()) + + simulator_chain_mineblock(chain, wgenesis.GetAddress(), t) + wsrc.Sync_Wallet_Memory_With_Daemon() + wdst.Sync_Wallet_Memory_With_Daemon() + + if err := chain.Add_TX_To_Pool(&dtx); err != nil { + t.Fatalf("cannot add self-attributed transfer to pool: %s", err) + } + simulator_chain_mineblock(chain, wgenesis.GetAddress(), t) + wgenesis.Sync_Wallet_Memory_With_Daemon() + + time.Sleep(time.Second) + wdst.Sync_Wallet_Memory_With_Daemon() + + minHeight := uint64(0) + maxHeight := uint64(chain.Get_Height()) + 1 + dstEntries := wdst.Show_Transfers(crypto.ZEROHASH, false, true, false, minHeight, maxHeight, "", "", 0, 0) + if len(dstEntries) != 1 { + t.Fatalf("receiver expected exactly 1 incoming transfer, got %d", len(dstEntries)) + } + e := dstEntries[0] + + // the payload itself must survive decode (scrub only touches the leading slot byte). + args, perr := e.ProcessPayload() + if perr != nil { + t.Fatalf("self-attributed payload did not parse after decode: %s", perr) + } + if !args.HasValue(rpc.RPC_COMMENT, rpc.DataString) { + t.Fatalf("decrypted self payload lost its comment arg") + } + + // THE DESIGN-INTENDED INVARIANT: even though the SENDER chose Self (built byte == its own + // non-zero slot), a CURRENT wallet at ring>2 still treats the byte as unverified and + // scrubs it. Self does NOT defeat #21 against an up-to-date recipient — coherent with + // what the CLI reports ("a current wallet still hides it") and with the warning. + if e.RingSize != rs { + t.Fatalf("decoded ring size %d, expected %d", e.RingSize, rs) + } + if e.SenderVerified { + t.Fatalf("ring>2 self-attributed: SenderVerified must be false (sender-chosen byte is unverified)") + } + if e.Sender != "" { + t.Fatalf("ring>2 self-attributed: entry.Sender must be scrubbed to empty even under Self, got %q", e.Sender) + } + if len(e.Data) == 0 { + t.Fatalf("entry.Data unexpectedly empty") + } + if e.Data[0] != 0x00 { + t.Fatalf("ring>2 self-attributed: exported entry.Data[0] must be zeroed (scrub holds under Self), got 0x%02x", e.Data[0]) + } +} diff --git a/walletapi/transaction_build.go b/walletapi/transaction_build.go index 550990a3..9dad7dc9 100644 --- a/walletapi/transaction_build.go +++ b/walletapi/transaction_build.go @@ -32,14 +32,24 @@ const ( // and today's behavior. The receiver already knows it is the receiver, so this leaks // nothing about the sender. // - // GUARDRAIL: honest mode MUST write witness_index[1] and MUST NOT be changed to - // witness_index[0] (the real sender slot). Writing the sender slot would make the - // receiver-decryptable byte reveal the true sender on every transfer. + // GUARDRAIL: honest/default mode MUST write witness_index[1] and MUST NOT be changed to + // witness_index[0] (the real sender slot). The sender slot is reachable ONLY via the + // explicit AttributionSelf value below — never via honest. This keeps the guardrail + // against ACCIDENTAL/default self-pointing intact while allowing a deliberate, named opt-in. AttributionHonest AttributionMode = iota // AttributionAnonymous writes the slot index of a decoy ring member (drawn from the // anonymity set, never the real sender or receiver). The receiver is reduced to the // ring's 1-of-N anonymity instead of being handed the sender's slot directly. AttributionAnonymous + // AttributionSelf writes witness_index[0] (the real sender's own slot) — an explicit, + // advanced-only, deliberately self-doxxing choice. It points the receiver-readable + // attribution byte at the true sender: the recipient (and anyone who ever decrypts this + // transaction in the future) can prove who sent it. This is the attribution field's + // ORIGINAL purpose; DERO's default hides it instead. It works at ANY ring size — the + // sender slot [0] always exists (unlike a decoy slot, which needs ring > 2). It is NEVER + // the default and is reachable only by naming this value (the CLI gates it behind a + // mandatory loud warning). + AttributionSelf // A "point attribution at a specific named address" mode is intentionally NOT defined: // that is targeted impersonation, not privacy. ) @@ -235,9 +245,11 @@ rebuild_tx: shared_key := crypto.GenerateSharedSecret(ephemeral_scalar, publickeylist[i]) - // honest attribution writes witness_index[1] (the receiver's own slot). - // GUARDRAIL: never witness_index[0] (the real sender) — that would reveal - // the true sender to the receiver on every transfer. + // honest/default attribution writes witness_index[1] (the receiver's own slot). + // GUARDRAIL: honest mode NEVER writes witness_index[0] (the real sender) — that + // would reveal the true sender to the receiver on every transfer. The sender slot + // is reachable ONLY via the explicit, named AttributionSelf branch below, never by + // the default path; so the guardrail against ACCIDENTAL self-pointing stands. attrIndex := witness_index[1] if opts.Attribution == AttributionAnonymous && len(witness_index) > 2 { // witness_index[2:] are the anonymity-set (decoy) slots: real, registered @@ -245,6 +257,11 @@ rebuild_tx: // Pointing attribution at one reduces the receiver to 1-of-N ring anonymity. decoyPos := 2 + crand.Intn(len(witness_index)-2) attrIndex = witness_index[decoyPos] + } else if opts.Attribution == AttributionSelf { + // explicit, advanced-only, deliberate self-doxx: point the byte at the real + // sender's own slot. witness_index[0] is the logical sender and always exists, + // so this needs no ring-size guard (unlike a decoy slot). + attrIndex = witness_index[0] } payload := append([]byte{byte(uint(attrIndex))}, data...) //fmt.Printf("buulding shared_key %x index of receiver %d\n",shared_key,i) From 2b30d56194b2d20dd16168e4c58f1617dfeab047 Mon Sep 17 00:00:00 2001 From: DHEBP <152355273+DHEBP@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:01:49 -0400 Subject: [PATCH 09/15] fix(walletapi): close deroproof dedup axis + factor decode-arm helpers (review #13/#14/#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Dirtybird99's re-review of #22: - #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"). - #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 #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. - #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 #22 head too — unrelated to this change. --- .../curated_ring_altencoding_test.go | 98 ++++++++++++++++++- walletapi/attribution_scrub_behavior_test.go | 6 +- walletapi/daemon_communication.go | 67 +++++++------ walletapi/wallet_transfer.go | 69 +++++++------ 4 files changed, 178 insertions(+), 62 deletions(-) diff --git a/cmd/simulator/curated_ring_altencoding_test.go b/cmd/simulator/curated_ring_altencoding_test.go index b57ae608..0e6c1fd7 100644 --- a/cmd/simulator/curated_ring_altencoding_test.go +++ b/cmd/simulator/curated_ring_altencoding_test.go @@ -33,13 +33,18 @@ import ( // signed it; consensus then rejected it at transaction_verify.go (duplicate ring member) // AFTER the user had signed. // -// It exercises FOUR alt-encoding vectors as independent subtests, each supplying the -// poisoned encoding as the FIRST curated decoy (with only one other real decoy, ring 8) so +// It exercises SIX alt-encoding vectors as independent subtests, each supplying the +// poisoned encoding as a leading curated decoy (with only one other real decoy, ring 8) so // the buggy path must consume and place it before random members fill the ring: // - recipient-alt: integrated encoding of the recipient // - sender-alt: integrated encoding of the sender (self) // - decoy-vs-decoy: two integrated encodings of the SAME extra decoy // - cross-network-recipient-alt: a wrong-network HRP encoding of the recipient pubkey +// - deroproof-recipient-alt: a deroproof-HRP encoding of the recipient (defense-in-depth) +// - deroproof-decoy-alt: deroproof + plain of the SAME extra decoy — THE deroproof +// teeth (#13); the only deroproof axis the caller-side +// line-482 backstop does NOT guard, so curatedRingCandidates' +// pubkey-keyed distinctness is the sole defense // // Each asserts the built ring has NO duplicate pubkey and exactly `ring` distinct members — // i.e. the alt-encoding was canonicalized away, not placed. With the canonicalization @@ -256,4 +261,93 @@ func Test_CuratedRing_AltEncoding_NoDuplicate_A2(t *testing.T) { tx := build(t, "cross-network-recipient-alt", preferred) assertNoDup(t, "cross-network-recipient-alt", tx, map[string]string{"recipient": pubkeyHex(wrecipient)}) }) + + // deroproofOf returns a deroproof-HRP encoding (same pubkey, different string) of the given + // base address. MarshalText forces hrp="deroproof" whenever a.Proof is set (rpc/address.go: + // 53-54), OVERRIDING the dero/deto network HRP — so this is yet another DIFFERENT string for + // the SAME 33-byte pubkey (PublicKey is always encoded, :58). This axis is nastier than the + // integrated/cross-network ones: BaseAddress() PRESERVES Proof (Clone, :97), and a parsed + // deroproof address comes back with Proof=true (:168-169), so the PRE-#13 string fix + // (BaseAddress + pin Mainnet) STILL re-renders it as "deroproof..." (Proof never cleared) — + // a distinct string that slips a string-keyed seen-set. + // + // SOURCE NOTE: UnmarshalText unconditionally decodes Arguments for the deroproof HRP + // (rpc/address.go:174-177), and Arguments.UnmarshalBinary on an EMPTY byte slice returns EOF + // (rpc/rpc.go:226) — so a BARE-pubkey deroproof string does NOT round-trip. A real deroproof + // address always carries a proof argument, so we attach one (cleared again by BaseAddress in + // the wallet). This makes the poison stress BOTH the Proof axis AND the Arguments axis at once. + deroproofOf := func(t *testing.T, base rpc.Address, port uint64) string { + a := base.BaseAddress() + a.Proof = true + a.Arguments = rpc.Arguments{{Name: rpc.RPC_DESTINATION_PORT, DataType: rpc.DataUint64, Value: port}} + s := a.String() + if len(s) < 9 || s[:9] != "deroproof" { + t.Fatalf("expected a deroproof-prefixed string, got %q", s) + } + pa, err := rpc.NewAddress(s) + if err != nil { + t.Fatalf("deroproof encoding not parseable: %s", err) + } + if !pa.Proof { + t.Fatalf("parsed deroproof address did not carry Proof=true") + } + if pa.BaseAddress().String() == base.BaseAddress().String() { + t.Fatalf("deroproof encoding rendered the same string as the base; test premise broken") + } + if hex.EncodeToString(pa.PublicKey.EncodeCompressed()) != hex.EncodeToString(base.PublicKey.EncodeCompressed()) { + t.Fatalf("deroproof encoding does not carry the base pubkey") + } + return s + } + + // VECTOR 5 — DEROPROOF recipient poison (defense-in-depth). A deroproof encoding of the + // recipient as the first curated decoy. The recipient is multiply-defended (the #13 pubkey + // seen-seed, canon.Proof=false making the emitted base collide with the recipient's clean + // string, AND the caller-side `k != receiver` backstop at wallet_transfer.go:474), so this + // is GREEN under the fix and stays GREEN even under partial reverts — it documents that the + // recipient deroproof axis is closed, but it is NOT the teeth (see VECTOR 6). + t.Run("deroproof-recipient-alt", func(t *testing.T) { + preferred := []string{deroproofOf(t, recipientBase, 0xC0FFEE)} + for _, d := range regDecoys { + preferred = append(preferred, d.GetAddress().String()) + } + tx := build(t, "deroproof-recipient-alt", preferred) + assertNoDup(t, "deroproof-recipient-alt", tx, map[string]string{"recipient": pubkeyHex(wrecipient)}) + }) + + // VECTOR 6 — DEROPROOF decoy canonicalizes & is PLACED (THE deroproof teeth, #13), asserted in + // STRICT mode so the regression is DETERMINISTIC. A deroproof encoding of an extra decoy + // (wdecoyDup) as the sole curated decoy. The fix that closes the deroproof axis is + // `canon.Proof = false` at wallet_transfer.go:140 — without it the emitted ring string stays + // "deroproof…", which the BASE-tree registration probe at :144 REJECTS (a deroproof HRP is not + // a usable ring entry — GetEncryptedBalanceAtTopoHeight cannot resolve it). + // + // ARCHITECTURAL NOTE (verified by mutation, see the re-review report): the deroproof axis + // canNOT be made to PLACE A DUPLICATE by reverting #13 in isolation — the caller-side string + // deduplicator at wallet_transfer.go:470-473 collapses any two same-pubkey entries, because + // canon.Proof=false makes BOTH emit the identical clean base string. So the meaningful + // regression a broken deroproof fix causes is a REJECTED/DROPPED decoy, not a duplicate. We + // assert it in STRICT mode: under the fix the deroproof decoy canonicalizes to the clean base, + // passes the probe, and the build SUCCEEDS with wdecoyDup placed exactly once; drop + // `canon.Proof=false` and Strict turns the failed registration probe into a HARD ERROR + // (wallet_transfer.go:145-147) — the build fails. Strict makes the RED unconditional (no + // dependence on whether a random top-up happens to re-draw the dropped decoy). + t.Run("deroproof-decoy-alt", func(t *testing.T) { + preferred := []string{deroproofOf(t, dupDecoyBase, 0x3333)} // deroproof encoding of wdecoyDup + frame := make([]byte, 64) + if _, err := rand.Read(frame); err != nil { + t.Fatal(err) + } + scdata := buildActionlessSCDATABodyA2(frame) + tx, err := wsrc.TransferPayload0WithOptions( + []rpc.Transfer{{Destination: recipient, Amount: 1}}, ring, false, scdata, 0, false, + walletapi.TransferOptions{Ring: &walletapi.RingPreference{PreferredDecoys: preferred, Strict: true}}) + if err != nil || tx == nil { + t.Fatalf("deroproof-decoy-alt: STRICT build with a deroproof decoy should SUCCEED "+ + "(it must canonicalize Proof away to a registered base); got err=%v — this means "+ + "canon.Proof=false is NOT applied and the deroproof axis is OPEN", err) + } + // corroborate: the deroproof decoy's pubkey landed in the ring exactly once (clean base). + assertNoDup(t, "deroproof-decoy-alt", tx, map[string]string{"deroproof-decoy": pubkeyHex(wdecoyDup)}) + }) } diff --git a/walletapi/attribution_scrub_behavior_test.go b/walletapi/attribution_scrub_behavior_test.go index afb3377d..3e480622 100644 --- a/walletapi/attribution_scrub_behavior_test.go +++ b/walletapi/attribution_scrub_behavior_test.go @@ -52,9 +52,11 @@ func Test_Attribution_Scrub_Behavior(t *testing.T) { wsrc_temp_db := filepath.Join(os.TempDir(), "scrub_test_wallet_src.db") wdst_temp_db := filepath.Join(os.TempDir(), "scrub_test_wallet_dst.db") + wgenesis_temp_db := filepath.Join(os.TempDir(), "scrub_test_wallet_genesis.db") os.Remove(wsrc_temp_db) os.Remove(wdst_temp_db) + os.Remove(wgenesis_temp_db) wsrc, err := Create_Encrypted_Wallet_From_Recovery_Words(wsrc_temp_db, "QWER", "sequence atlas unveil summon pebbles tuesday beer rudely snake rockets different fuselage woven tagged bested dented vegan hover rapid fawns obvious muppet randomly seasons randomly") if err != nil { @@ -66,7 +68,7 @@ func Test_Attribution_Scrub_Behavior(t *testing.T) { 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") + wgenesis, err := Create_Encrypted_Wallet_From_Recovery_Words(wgenesis_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") if err != nil { t.Fatalf("Cannot create encrypted wallet, err %s", err) } @@ -89,8 +91,10 @@ func Test_Attribution_Scrub_Behavior(t *testing.T) { simulator_chain_stop(chain, rpcserver) wsrc.Close_Encrypted_Wallet() wdst.Close_Encrypted_Wallet() + wgenesis.Close_Encrypted_Wallet() os.Remove(wsrc_temp_db) os.Remove(wdst_temp_db) + os.Remove(wgenesis_temp_db) }() globals.Arguments["--daemon-address"] = rpcport diff --git a/walletapi/daemon_communication.go b/walletapi/daemon_communication.go index 77b133ce..84fdc76b 100644 --- a/walletapi/daemon_communication.go +++ b/walletapi/daemon_communication.go @@ -59,6 +59,35 @@ import ( // this global variable should be within wallet structure var Connected bool = false +// scrubExportedPayload returns the copy of the decrypted payload that is safe to +// expose via the exported entry fields (entry.Data). For an unverified attribution +// (ring > 2, where payload[0] is the sender-chosen, unauthenticated attribution slot +// byte) the leading byte must NOT be exported: it re-derives the claimed sender via +// the public Publickeylist even after entry.Sender is blanked. So it blanks +// entry.Sender AND zeroes payload[0] in the returned copy. The verified case +// (ring 2, structural) is returned untouched. +// +// This is the single source of truth for the #3 attribution scrub; both the CBOR +// and CBOR_V2 receive arms call it so a one-arm edit cannot silently reopen the leak. +func scrubExportedPayload(entry *rpc.Entry, payload []byte) []byte { + exported_payload := payload + if !entry.SenderVerified { + entry.Sender = "" + exported_payload = append([]byte{0x00}, payload[1:]...) + } + return exported_payload +} + +// markSelfAuthored records that this wallet authored the tx, so the sender (ourselves) +// is certain at any ring size; it is marked verified so consumers do not distrust our +// own sends. This is the single source of truth for the self-trust block; both +// self-send arms (CBOR and CBOR_V2) call it so the trust polarity cannot drift between +// the two arms. +func markSelfAuthored(entry *rpc.Entry, ringsize uint64) { + entry.RingSize = ringsize + entry.SenderVerified = true +} + var daemon_height int64 var daemon_topoheight int64 var last_event_topoheight_tracked int64 @@ -985,10 +1014,7 @@ func (w *Wallet_Memory) synchistory_block(scid crypto.Hash, topo int64) (err err addr := rpc.NewAddressFromKeys((*crypto.Point)(w.account.Keys.Public.G1())) addr.Mainnet = w.GetNetwork() entry.Sender = addr.String() - // this wallet authored the tx, so the sender (ourselves) is certain - // at any ring size; mark it verified so consumers do not distrust our own sends. - entry.RingSize = uint64(tx.Payloads[t].Statement.RingSize) - entry.SenderVerified = true + markSelfAuthored(&entry, tx.Payloads[t].Statement.RingSize) entry.Payload = append(entry.Payload, tx.Payloads[t].RPCPayload[1:]...) entry.Data = append(entry.Data, tx.Payloads[t].RPCPayload[:]...) @@ -1014,10 +1040,7 @@ func (w *Wallet_Memory) synchistory_block(scid crypto.Hash, topo int64) (err err addr := rpc.NewAddressFromKeys((*crypto.Point)(w.account.Keys.Public.G1())) addr.Mainnet = w.GetNetwork() entry.Sender = addr.String() - // this wallet authored the tx, so the sender (ourselves) is certain - // at any ring size; mark it verified so consumers do not distrust our own sends. - entry.RingSize = uint64(tx.Payloads[t].Statement.RingSize) - entry.SenderVerified = true + markSelfAuthored(&entry, tx.Payloads[t].Statement.RingSize) entry.Payload = append(entry.Payload, payload[1:]...) entry.Data = append(entry.Data, payload...) @@ -1088,17 +1111,9 @@ func (w *Wallet_Memory) synchistory_block(scid crypto.Hash, topo int64) (err err // ring size 2 attribution is structural — the only other ring member is the sender; larger rings are sender-chosen and unverified entry.SenderVerified = uint(tx.Payloads[t].Statement.RingSize) == 2 - // sanitized copy of the decrypted payload for the exported entry fields. - // for an unverified attribution (ring > 2, where payload[0] is sender-chosen - // and unauthenticated) the leading attribution slot byte must NOT be exported: - // 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 - if !entry.SenderVerified { - entry.Sender = "" - exported_payload = append([]byte{0x00}, tx.Payloads[t].RPCPayload[1:]...) - } + // scrub the unverified attribution slot byte before it reaches the + // exported entry fields (see scrubExportedPayload). + exported_payload := scrubExportedPayload(&entry, tx.Payloads[t].RPCPayload) entry.Payload = append(entry.Payload, tx.Payloads[t].RPCPayload[1:]...) entry.Data = append(entry.Data, exported_payload[:]...) @@ -1142,17 +1157,9 @@ func (w *Wallet_Memory) synchistory_block(scid crypto.Hash, topo int64) (err err // ring size 2 attribution is structural — the only other ring member is the sender; larger rings are sender-chosen and unverified entry.SenderVerified = uint(tx.Payloads[t].Statement.RingSize) == 2 - // sanitized copy of the decrypted payload for the exported entry fields. - // for an unverified attribution (ring > 2, where payload[0] is sender-chosen - // and unauthenticated) the leading attribution slot byte must NOT be exported: - // 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 := payload - if !entry.SenderVerified { - entry.Sender = "" - exported_payload = append([]byte{0x00}, payload[1:]...) - } + // scrub the unverified attribution slot byte before it reaches the + // exported entry fields (see scrubExportedPayload). + exported_payload := scrubExportedPayload(&entry, payload) entry.Payload = append(entry.Payload, payload[1:]...) entry.Data = append(entry.Data, exported_payload...) diff --git a/walletapi/wallet_transfer.go b/walletapi/wallet_transfer.go index d3d26b52..0f8f2395 100644 --- a/walletapi/wallet_transfer.go +++ b/walletapi/wallet_transfer.go @@ -80,24 +80,35 @@ func (w *Wallet_Memory) TransferPayload0(transfers []rpc.Transfer, ringsize uint // unconditional per-candidate re-probe in the ring-assembly loop; it is the SOLE wallet-side // defense only on the non-zero-SCID path. In Strict mode a bad decoy is a hard error; // otherwise it is skipped and random members fill the slot. -func (w *Wallet_Memory) curatedRingCandidates(scid crypto.Hash, recipientBase string, pref *RingPreference) (alist []string, err error) { +// +// recipientAddr is the transfer's recipient address string (any HRP/network/integrated +// encoding); its pubkey seeds the distinctness set so an alt-encoding of the recipient is +// rejected as a duplicate. Empty means "no recipient seed". +func (w *Wallet_Memory) curatedRingCandidates(scid crypto.Hash, recipientAddr string, pref *RingPreference) (alist []string, err error) { if pref == nil { return w.Random_ring_members(scid), nil } var zeroscid crypto.Hash - // A ring member is identified by its public key, not its address string. Integrated - // and payment-id encodings of the SAME account share one pubkey but differ as strings, - // so all distinctness checks here canonicalize to the BASE address. Without this, an - // alt-encoding of the sender, recipient, or an already-curated decoy slips every check - // and lands the same pubkey in the ring twice — which the wallet accepts but consensus - // rejects (transaction_verify.go) AFTER the user has signed. - self := w.GetAddress().BaseAddress().String() - // seed with sender AND recipient base keys: both are already in the ring, so an - // alt-encoding of either is a duplicate that must not be curated as a decoy. + // A ring member is identified by its public key, not its address string. The seen-set + // is therefore keyed on the raw 33-byte compressed pubkey, NOT a stringified address. + // Address strings vary along several axes that all encode the SAME pubkey — network HRP + // (dero/deto), the deroproof HRP (rpc/address.go MarshalText overrides the network HRP + // whenever Proof is set), and the integrated "i" HRP/Arguments — so a string key lets an + // alt-encoding of the sender, recipient, or an already-curated decoy slip every check and + // land the same pubkey in the ring twice, which the wallet accepts but consensus rejects + // (transaction_verify.go) AFTER the user has signed. A pubkey key collapses every such + // axis (including any future HRP axis) into one identity. + pkKey := func(a *rpc.Address) string { return hex.EncodeToString(a.PublicKey.EncodeCompressed()) } + selfAddr := w.GetAddress() + self := pkKey(&selfAddr) + // seed with sender AND recipient pubkeys: both are already in the ring, so an alt-encoding + // of either is a duplicate that must not be curated as a decoy. seen := map[string]bool{self: true} - if recipientBase != "" { - seen[recipientBase] = true + if recipientAddr != "" { + if ra, e := rpc.NewAddress(recipientAddr); e == nil { + seen[pkKey(ra)] = true + } } for _, d := range pref.PreferredDecoys { @@ -108,27 +119,27 @@ func (w *Wallet_Memory) curatedRingCandidates(scid crypto.Hash, recipientBase st } continue } - // The canonical identity for distinctness is the PUBKEY, which is network-agnostic: - // consensus keys duplicate ring members on the raw 33-byte pubkey (transaction_verify.go), - // so a wrong-network HRP encoding (dero… on a deto wallet, or vice versa) of an in-ring - // pubkey is the SAME ring member even though BaseAddress() preserves the Mainnet flag and - // would render a different string. Pin the network to this wallet's before stringifying so - // 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 - if base == self { // curating your own address collapses your anonymity set + key := pkKey(addr) // pubkey is the network-/proof-/integrated-agnostic identity + if key == self { // curating your own address collapses your anonymity set if pref.Strict { return nil, fmt.Errorf("preferred decoy cannot be your own address") } continue } - if seen[base] { // distinctness (consensus rejects duplicate ring members) + if seen[key] { // distinctness (consensus rejects duplicate ring members) if pref.Strict { return nil, fmt.Errorf("duplicate preferred decoy: %s", d) } continue } + // The ring carries a normal BASE address (Arguments cleared, Proof cleared, network + // pinned to this wallet's) — a deroproof or integrated encoding is not a usable ring + // entry. The seen-set is keyed on the pubkey above, so this stringification only has + // to produce a resolvable address; its HRP axes no longer affect distinctness. + canon := addr.BaseAddress() + canon.Proof = false + canon.Mainnet = w.GetNetwork() + base := canon.String() // registration: probe the BASE balance tree, the tree consensus checks against. if _, _, _, _, e := w.GetEncryptedBalanceAtTopoHeight(zeroscid, -1, base); e != nil { if pref.Strict { @@ -136,7 +147,7 @@ func (w *Wallet_Memory) curatedRingCandidates(scid crypto.Hash, recipientBase st } continue } - seen[base] = true + seen[key] = true alist = append(alist, base) // ring carries the canonical base form } @@ -422,11 +433,11 @@ func (w *Wallet_Memory) TransferPayload0WithOptions(transfers []rpc.Transfer, ri }*/ receiver_without_payment_id := addr.BaseAddress() - // network-pin the recipient's distinctness key to this wallet's network, matching how - // curatedRingCandidates canonicalizes decoy keys: a ring member's identity is its pubkey, - // not its network-tagged string, so the seed handed to the curated check (and the - // deduplicator below) must be on the same network as the decoy keys it is compared against. - receiver_without_payment_id.Mainnet = w.GetNetwork() + // curatedRingCandidates now keys distinctness on the recipient's raw pubkey (it parses + // the address string we hand it), so the recipient seed no longer has to be network-pinned + // to stay in lockstep with the decoy keys. Leaving it on the recipient's native network + // keeps the caller-side deduplicator below consistent with the recipient ring member added + // above (also native-network), so a duplicate is caught on the same string form. //sending to self is not supported if w.GetAddress().String() == receiver_without_payment_id.String() { From 0097229cea76bdeaa04719c8e7b0fae493019c45 Mon Sep 17 00:00:00 2001 From: DHEBP <152355273+DHEBP@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:07:22 -0400 Subject: [PATCH 10/15] fix(walletapi): bound ring assembly against candidate-pool exhaustion (review #1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- cmd/simulator/ring2_payload_floor_test.go | 127 ++++++++++++ cmd/simulator/ring_pool_exhaustion_test.go | 205 ++++++++++++++++++++ cmd/simulator/ring_scarce_band_test.go | 193 ++++++++++++++++++ glue/rwc/rwc.go | 30 ++- glue/rwc/rwc_write_timeout_test.go | 84 ++++++++ walletapi/daemon_communication.go | 58 +++++- walletapi/daemon_connectivity.go | 6 +- walletapi/ring_backoff_test.go | 69 +++++++ walletapi/rpc_call_timeout_test.go | 113 +++++++++++ walletapi/transaction_build.go | 8 + walletapi/transfer_array_cap_test.go | 56 ++++++ walletapi/wallet_transfer.go | 215 +++++++++++++++++++-- 12 files changed, 1142 insertions(+), 22 deletions(-) create mode 100644 cmd/simulator/ring2_payload_floor_test.go create mode 100644 cmd/simulator/ring_pool_exhaustion_test.go create mode 100644 cmd/simulator/ring_scarce_band_test.go create mode 100644 glue/rwc/rwc_write_timeout_test.go create mode 100644 walletapi/ring_backoff_test.go create mode 100644 walletapi/rpc_call_timeout_test.go create mode 100644 walletapi/transfer_array_cap_test.go diff --git a/cmd/simulator/ring2_payload_floor_test.go b/cmd/simulator/ring2_payload_floor_test.go new file mode 100644 index 00000000..5ba3519b --- /dev/null +++ b/cmd/simulator/ring2_payload_floor_test.go @@ -0,0 +1,127 @@ +// Copyright 2017-2021 DERO Project. All rights reserved. +// Use of this source code in any form is governed by RESEARCH license. +// license can be found in the LICENSE file. + +package main + +// Ring-2 payload size floor (PR #22 re-review O13). +// +// walletapi.MaxTransfersPerBuild caps the transfer array BEFORE assembly so the +// transfer_mutex hold bound has a constant multiplier — the consensus tx-size limit +// (STARGATE_HE_MAX_TX_SIZE) cannot do that job because it is enforced at +// verification/broadcast, after assembly already paid the full per-transfer cost. +// The cap claims to be a NECESSARY condition of the consensus limit: every payload +// serializes to more than STARGATE_HE_MAX_TX_SIZE/MaxTransfersPerBuild bytes even in +// the minimal configuration, so no array longer than the cap could ever broadcast and +// the cap rejects nothing previously usable. +// +// This test pins that arithmetic on a REAL transaction in its wire form (the same +// bytes blockchain.go:Add_TX_To_Pool measures): a single-payload ring-2 base transfer +// — the smallest payload the wallet can produce (fewest ring keys, fewest per-member +// commitments, smallest CT proof) — must exceed the per-payload floor the cap assumes. + +import ( + "encoding/hex" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/deroproject/derohe/blockchain" + "github.com/deroproject/derohe/config" + "github.com/deroproject/derohe/cryptography/crypto" + "github.com/deroproject/derohe/globals" + "github.com/deroproject/derohe/rpc" + "github.com/deroproject/derohe/transaction" + "github.com/deroproject/derohe/walletapi" +) + +func Test_Ring2PayloadFloor_JustifiesTransferCap(t *testing.T) { + globals.Arguments["--testnet"] = true + globals.Arguments["--simulator"] = true + + walletapi.Initialize_LookupTable(1, 1<<17) + + mkwallet := func(name, seedHex string) *walletapi.Wallet_Disk { + db := filepath.Join(os.TempDir(), "floor_"+name+".db") + os.Remove(db) + t.Cleanup(func() { os.Remove(db) }) + seed, err := hex.DecodeString(seedHex) + if err != nil { + t.Fatalf("decode seed %s: %s", name, err) + } + w, err := walletapi.Create_Encrypted_Wallet(db, WALLET_PASSWORD, new(crypto.BNRed).SetBytes(seed)) + if err != nil { + t.Fatalf("create wallet %s: %s", name, err) + } + return w + } + + wgenesis := mkwallet("genesis", genesis_seed) + wsrc := mkwallet("src", wallets_seeds[0]) + wrecipient := mkwallet("dst", wallets_seeds[1]) + + genesis_tx := transaction.Transaction{Transaction_Prefix: transaction.Transaction_Prefix{Version: 1, Value: 2012345}} + copy(genesis_tx.MinerAddress[:], wgenesis.GetAddress().PublicKey.EncodeCompressed()) + config.Testnet.Genesis_Tx = fmt.Sprintf("%x", genesis_tx.Serialize()) + config.Mainnet.Genesis_Tx = fmt.Sprintf("%x", genesis_tx.Serialize()) + genesis_block := blockchain.Generate_Genesis_Block() + config.Testnet.Genesis_Block_Hash = genesis_block.GetHash() + config.Mainnet.Genesis_Block_Hash = genesis_block.GetHash() + + chain, rpcserver, _ := simulator_chain_start() + defer simulator_chain_stop(chain, rpcserver) + globals.Arguments["--daemon-address"] = rpcport_test + go walletapi.Keep_Connectivity() + + for _, w := range []*walletapi.Wallet_Disk{wsrc, wrecipient} { + if err := chain.Add_TX_To_Pool(w.GetRegistrationTX()); err != nil { + t.Fatalf("regtx: %s", err) + } + } + simulator_chain_mineblock(chain, wgenesis.GetAddress(), t) + + for _, w := range []*walletapi.Wallet_Disk{wgenesis, wsrc, wrecipient} { + w.SetDaemonAddress(rpcport) + w.SetOnlineMode() + } + + for i := 0; i < 8; i++ { + simulator_chain_mineblock(chain, wsrc.GetAddress(), t) + } + time.Sleep(time.Second) + if err := wsrc.Sync_Wallet_Memory_With_Daemon(); err != nil { + t.Fatalf("src sync: %s", err) + } + if bal, _ := wsrc.Get_Balance(); bal == 0 { + t.Fatalf("sender has zero base balance after funding") + } + + // the minimal payload: ring 2, one base transfer, no SC data + wsrc.SetRingSize(2) + tx, err := wsrc.TransferPayload0( + []rpc.Transfer{{Destination: wrecipient.GetAddress().String(), Amount: 1}}, + 2, false, rpc.Arguments{}, 0, false) + if err != nil { + t.Fatalf("ring-2 build failed: %s", err) + } + + // measure the WIRE form — the bytes the consensus size check sees + wire := tx.Serialize() + var wtx transaction.Transaction + if err := wtx.Deserialize(wire); err != nil { + t.Fatalf("wire round-trip: %s", err) + } + if len(wtx.Payloads) != 1 { + t.Fatalf("expected the minimal single-payload tx, got %d payloads", len(wtx.Payloads)) + } + + const floor = config.STARGATE_HE_MAX_TX_SIZE / walletapi.MaxTransfersPerBuild + if uint64(len(wire)) <= floor { + t.Fatalf("minimal ring-2 payload is %d bytes, want > %d: MaxTransfersPerBuild=%d is NOT a necessary condition of the %d-byte consensus limit — lower the cap", + len(wire), floor, walletapi.MaxTransfersPerBuild, config.STARGATE_HE_MAX_TX_SIZE) + } + t.Logf("minimal ring-2 tx: %d bytes wire (> %d-byte floor); cap %d × floor = consensus limit %d", + len(wire), floor, walletapi.MaxTransfersPerBuild, config.STARGATE_HE_MAX_TX_SIZE) +} diff --git a/cmd/simulator/ring_pool_exhaustion_test.go b/cmd/simulator/ring_pool_exhaustion_test.go new file mode 100644 index 00000000..3b590df0 --- /dev/null +++ b/cmd/simulator/ring_pool_exhaustion_test.go @@ -0,0 +1,205 @@ +// Copyright 2017-2021 DERO Project. All rights reserved. +// Use of this source code in any form is governed by RESEARCH license. +// license can be found in the LICENSE file. + +package main + +// Ring-assembly termination gate (PR #22 review finding #1). +// +// The ring-assembly loop in TransferPayload0WithOptions (wallet_transfer.go) collects +// ringsize-2 DISTINCT members through a deduplicator that persists across passes. On a +// non-zero SCID the per-member balance probe cannot error (unregistered accounts get +// synthesized zero balances with err=nil, daemon_communication.go), so when the distinct +// candidate pool is smaller than the ring AND the <=40 base-tree top-up is skipped, no +// pass can make progress once the pool drains — the loop spins forever HOLDING +// w.transfer_mutex, wedging every transfer build on the wallet. +// +// The starved configuration must be a TOKEN ring: the base (zero-SCID) tree can never +// starve because genesis processes the hardcoded premine list — 1326 registered accounts +// before the first block (blockchain/transaction_execute.go:82-111, premine/list.txt) — +// which fills any legal ring. This test builds the deterministic token-path trap: +// +// - transfer SCID = a nonexistent token tree (zero on-chain holders, the scarcest pool: +// the daemon's GetRandomAddress on that tree returns an empty list, status OK); +// - 41 curated decoys, all registered on the base tree (Strict-valid), so the combined +// candidate count exceeds the <=40 threshold and the base-tree top-up NEVER fires; +// - ring 64: candidates = 41 decoys forever, ring sticks at 43 of 64. Zero progress +// every pass after the first. +// +// Pre-fix the build never returns — this test's own timeout is the only bound (the suite +// has none), and its firing is the live reproduction of the hang. Post-fix the build must +// SUCCEED: the top-up threshold measures only the random tail (empty here), the base-tree +// rescue fires, and base members legally fill the token ring with synthesized zero +// balances — which is simultaneously the regression assertion for the tail-only fix. +// +// Scope honesty: +// - The exhaustion-ERROR branch of the bound cannot fire on a healthy chain (the base +// rescue always fills a legal ring from the 1326-account premine floor); it guards +// daemon degradation (Random_ring_members swallows RPC errors into empty lists) and +// is covered by inspection, not run here. +// - Single-node simulator; consensus-side verification of the built tx is out of scope +// (the curated finalization tests cover that). + +import ( + crand "crypto/rand" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/deroproject/derohe/blockchain" + "github.com/deroproject/derohe/config" + "github.com/deroproject/derohe/cryptography/crypto" + "github.com/deroproject/derohe/globals" + "github.com/deroproject/derohe/rpc" + "github.com/deroproject/derohe/transaction" + "github.com/deroproject/derohe/walletapi" +) + +func Test_RingPoolExhaustion_Terminates(t *testing.T) { + globals.Arguments["--testnet"] = true + globals.Arguments["--simulator"] = true + + walletapi.Initialize_LookupTable(1, 1<<17) + + const ring = 64 + const decoyCount = 41 // > 40: defeats the base-tree top-up threshold, yet 41+2 < 64 + + mkwallet := func(name, seedHex string) *walletapi.Wallet_Disk { + db := filepath.Join(os.TempDir(), "exh_"+name+".db") + os.Remove(db) + t.Cleanup(func() { os.Remove(db) }) + seed, err := hex.DecodeString(seedHex) + if err != nil { + t.Fatalf("decode seed %s: %s", name, err) + } + w, err := walletapi.Create_Encrypted_Wallet(db, WALLET_PASSWORD, new(crypto.BNRed).SetBytes(seed)) + if err != nil { + t.Fatalf("create wallet %s: %s", name, err) + } + return w + } + + wgenesis := mkwallet("genesis", genesis_seed) + wsrc := mkwallet("src", wallets_seeds[0]) + wrecipient := mkwallet("dst", wallets_seeds[1]) + + // 41 decoys: the fixed seed table has 22 entries; synthesize the surplus randomly. + var decoys []*walletapi.Wallet_Disk + for i := 0; i < decoyCount; i++ { + if 2+i < len(wallets_seeds) { + decoys = append(decoys, mkwallet(fmt.Sprintf("decoy%d", i), wallets_seeds[2+i])) + } else { + seedb := make([]byte, 32) + if _, err := crand.Read(seedb); err != nil { + t.Fatal(err) + } + decoys = append(decoys, mkwallet(fmt.Sprintf("decoy%d", i), hex.EncodeToString(seedb))) + } + } + + genesis_tx := transaction.Transaction{Transaction_Prefix: transaction.Transaction_Prefix{Version: 1, Value: 2012345}} + copy(genesis_tx.MinerAddress[:], wgenesis.GetAddress().PublicKey.EncodeCompressed()) + config.Testnet.Genesis_Tx = fmt.Sprintf("%x", genesis_tx.Serialize()) + config.Mainnet.Genesis_Tx = fmt.Sprintf("%x", genesis_tx.Serialize()) + genesis_block := blockchain.Generate_Genesis_Block() + config.Testnet.Genesis_Block_Hash = genesis_block.GetHash() + config.Mainnet.Genesis_Block_Hash = genesis_block.GetHash() + + chain, rpcserver, _ := simulator_chain_start() + defer simulator_chain_stop(chain, rpcserver) + globals.Arguments["--daemon-address"] = rpcport_test + go walletapi.Keep_Connectivity() + + allToRegister := append([]*walletapi.Wallet_Disk{wsrc, wrecipient}, decoys...) + for _, w := range allToRegister { + if err := chain.Add_TX_To_Pool(w.GetRegistrationTX()); err != nil { + t.Fatalf("regtx: %s", err) + } + } + // One block takes all pending registrations (miner includes up to 100 regtxs/block); + // Regpool_List_TX stays stale after inclusion, so it cannot be used as a drain check. + simulator_chain_mineblock(chain, wgenesis.GetAddress(), t) + + for _, w := range append(allToRegister, wgenesis) { + w.SetDaemonAddress(rpcport) + w.SetOnlineMode() + } + + for i := 0; i < 8; i++ { + simulator_chain_mineblock(chain, wsrc.GetAddress(), t) + } + time.Sleep(time.Second) + if err := wsrc.Sync_Wallet_Memory_With_Daemon(); err != nil { + t.Fatalf("src sync: %s", err) + } + if bal, _ := wsrc.Get_Balance(); bal == 0 { + t.Fatalf("sender has zero base balance after funding") + } + + // Prove the synthetic-seed decoys really registered (base-tree probe of the last one); + // an unregistered decoy would make Strict validation error out and mask the hang. + var zeroscid crypto.Hash + if _, _, _, _, err := wsrc.GetEncryptedBalanceAtTopoHeight(zeroscid, -1, decoys[len(decoys)-1].GetAddress().String()); err != nil { + t.Fatalf("decoy registration did not land: %s", err) + } + + // A nonexistent token tree: zero holders, the scarcest possible pool. Sending + // Amount 0 of it is cryptographically valid (the sender's synthesized balance is zero). + var tokenSCID crypto.Hash + tokenSCID[31] = 0x45 + + var preferred []string + for _, d := range decoys { + preferred = append(preferred, d.GetAddress().String()) + } + opts := walletapi.TransferOptions{ + Ring: &walletapi.RingPreference{ + PreferredDecoys: preferred, + Strict: true, // all 41 are base-registered, so validation passes every pass + }, + } + + wsrc.SetRingSize(ring) + + // The build MUST be bounded by the test itself: pre-fix it never returns and holds + // transfer_mutex, so a bare call would wedge the whole test binary. + type result struct { + tx *transaction.Transaction + err error + } + done := make(chan result, 1) + // transfers[0] must be base: the upfront self-balance check (wallet_transfer.go, the + // GetSelfEncryptedBalanceAtTopoHeight call) probes transfers[0].SCID WITHOUT the + // zero-balance synthesis and errors for a token the sender never held. The token ride + // as transfers[1] reaches the ring loop through the synthesizing per-transfer probes. + go func() { + tx, err := wsrc.TransferPayload0WithOptions( + []rpc.Transfer{ + {Destination: wrecipient.GetAddress().String(), Amount: 1}, + {SCID: tokenSCID, Destination: wrecipient.GetAddress().String(), Amount: 0}, + }, + ring, false, rpc.Arguments{}, 0, false, opts) + done <- result{tx, err} + }() + + select { + case r := <-done: + if r.err != nil { + t.Fatalf("token ring build failed (expected base-tree rescue to fill it): %s", r.err) + } + if r.tx == nil { + t.Fatal("nil tx without error") + } + for i := range r.tx.Payloads { + if n := len(r.tx.Payloads[i].Statement.Publickeylist); n != ring { + t.Fatalf("payload %d ring is %d, want %d", i, n, ring) + } + } + t.Logf("token ring assembled at %d via base-tree rescue; build terminated", ring) + case <-time.After(120 * time.Second): + t.Fatal("ring assembly did not terminate — hang regression (wallet_transfer.go ring loop)") + } +} diff --git a/cmd/simulator/ring_scarce_band_test.go b/cmd/simulator/ring_scarce_band_test.go new file mode 100644 index 00000000..d90f950b --- /dev/null +++ b/cmd/simulator/ring_scarce_band_test.go @@ -0,0 +1,193 @@ +// Copyright 2017-2021 DERO Project. All rights reserved. +// Use of this source code in any form is governed by RESEARCH license. +// license can be found in the LICENSE file. + +package main + +// Scarce-band rescue (PR #22 review finding #1, re-review O3). +// +// The <=40 scarcity fast-path in TransferPayload0WithOptions arms the base-tree fill only +// when the SCID tree's random tail is <= 40. A token tree with MORE than 40 members but +// FEWER than the ring needs sits in a structural dead zone: the fast path never arms, yet +// the tree can never fill the ring. Upstream hung there forever; the first cut of the +// exhaustion bound converted the hang into a false "pool exhausted" error. The stall +// rescue (base_rescue in wallet_transfer.go) must instead fill the ring from the base +// tree — the same fill the fast path uses — after consecutive barren passes prove the +// tree is starved. +// +// The band is built ON CHAIN, not mocked: executing a token transfer Puts EVERY ring +// member into the token tree (blockchain/transaction_execute.go — homomorphic changes +// touch all members), so one mined tx with three ring-32 token payloads seeds the +// token's tree with well over 40 but under 126 distinct decoy-capable leaves: the fast +// path stays dark on every pass (the daemon samples ~100 draws over ~92 leaves, far +// above 40), yet a ring-128 build needs 126 decoys — the tree alone can never satisfy +// it, since sender and recipient are also leaves. The margin matters: with a band only +// slightly above 40, per-pass sampling variance can dip under the threshold and arm the +// legacy fast path, masking the dead zone. +// +// Pre-rescue this build errors "candidate pool exhausted" in seconds; post-rescue it must +// SUCCEED via base fill. The simulator runs below topoheight 100 where the daemon's +// recent-activity filter is disabled, so the band here is purely structural — the +// filter-induced variant of the same band is covered by the backoff-window pin in +// walletapi/ring_backoff_test.go. + +import ( + "encoding/hex" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/deroproject/derohe/blockchain" + "github.com/deroproject/derohe/config" + "github.com/deroproject/derohe/cryptography/crypto" + "github.com/deroproject/derohe/globals" + "github.com/deroproject/derohe/rpc" + "github.com/deroproject/derohe/transaction" + "github.com/deroproject/derohe/walletapi" +) + +func Test_RingScarceBand_RescuesViaBaseTree(t *testing.T) { + globals.Arguments["--testnet"] = true + globals.Arguments["--simulator"] = true + + walletapi.Initialize_LookupTable(1, 1<<17) + + const seedRing = 32 // three payloads at 32 seed <= 92 distinct decoy-capable leaves + const ring = 128 // needs 126 decoys: strictly more than the seeded tree can hold + + mkwallet := func(name, seedHex string) *walletapi.Wallet_Disk { + db := filepath.Join(os.TempDir(), "band_"+name+".db") + os.Remove(db) + t.Cleanup(func() { os.Remove(db) }) + seed, err := hex.DecodeString(seedHex) + if err != nil { + t.Fatalf("decode seed %s: %s", name, err) + } + w, err := walletapi.Create_Encrypted_Wallet(db, WALLET_PASSWORD, new(crypto.BNRed).SetBytes(seed)) + if err != nil { + t.Fatalf("create wallet %s: %s", name, err) + } + return w + } + + wgenesis := mkwallet("genesis", genesis_seed) + wsrc := mkwallet("src", wallets_seeds[0]) + wrecipient := mkwallet("dst", wallets_seeds[1]) + + genesis_tx := transaction.Transaction{Transaction_Prefix: transaction.Transaction_Prefix{Version: 1, Value: 2012345}} + copy(genesis_tx.MinerAddress[:], wgenesis.GetAddress().PublicKey.EncodeCompressed()) + config.Testnet.Genesis_Tx = fmt.Sprintf("%x", genesis_tx.Serialize()) + config.Mainnet.Genesis_Tx = fmt.Sprintf("%x", genesis_tx.Serialize()) + genesis_block := blockchain.Generate_Genesis_Block() + config.Testnet.Genesis_Block_Hash = genesis_block.GetHash() + config.Mainnet.Genesis_Block_Hash = genesis_block.GetHash() + + chain, rpcserver, _ := simulator_chain_start() + defer simulator_chain_stop(chain, rpcserver) + globals.Arguments["--daemon-address"] = rpcport_test + go walletapi.Keep_Connectivity() + + for _, w := range []*walletapi.Wallet_Disk{wsrc, wrecipient} { + if err := chain.Add_TX_To_Pool(w.GetRegistrationTX()); err != nil { + t.Fatalf("regtx: %s", err) + } + } + simulator_chain_mineblock(chain, wgenesis.GetAddress(), t) + + for _, w := range []*walletapi.Wallet_Disk{wgenesis, wsrc, wrecipient} { + w.SetDaemonAddress(rpcport) + w.SetOnlineMode() + } + + for i := 0; i < 8; i++ { + simulator_chain_mineblock(chain, wsrc.GetAddress(), t) + } + time.Sleep(time.Second) + if err := wsrc.Sync_Wallet_Memory_With_Daemon(); err != nil { + t.Fatalf("src sync: %s", err) + } + if bal, _ := wsrc.Get_Balance(); bal == 0 { + t.Fatalf("sender has zero base balance after funding") + } + + // The token tree must belong to a DEPLOYED contract: block commit walks every touched + // SC tree and panics on a missing SC_META entry (blockchain.go, sc_meta.Get in + // Add_Complete_Block), so a fabricated SCID verifies and pools but can never mine. + // The simulator installs the hardcoded nameservice contract at SCID [31]=1 at genesis + // (blockchain/hardcoded_contracts.go); its tree starts with zero ACCOUNT leaves + // (GetRandomAddress skips non-pubkey keys such as the SC code), which is exactly the + // empty token tree the seed transfer needs. + var tokenSCID crypto.Hash + tokenSCID[31] = 0x01 + + // ── seed: three ring-32 token payloads, mined, populate the token tree ── + wsrc.SetRingSize(seedRing) + seedtx, err := wsrc.TransferPayload0( + []rpc.Transfer{ + {Destination: wrecipient.GetAddress().String(), Amount: 1}, + {SCID: tokenSCID, Destination: wrecipient.GetAddress().String(), Amount: 0}, + {SCID: tokenSCID, Destination: wrecipient.GetAddress().String(), Amount: 0}, + {SCID: tokenSCID, Destination: wrecipient.GetAddress().String(), Amount: 0}, + }, + seedRing, false, rpc.Arguments{}, 0, false) + if err != nil { + t.Fatalf("seed token tx build failed: %s", err) + } + // the pool wants the WIRE form (ring as index pointers, built on serialize) + var seeddtx transaction.Transaction + if err := seeddtx.Deserialize(seedtx.Serialize()); err != nil { + t.Fatalf("seed tx deserialize: %s", err) + } + if err := chain.Add_TX_To_Pool(&seeddtx); err != nil { + t.Fatalf("seed token tx rejected by pool: %s", err) + } + simulator_chain_mineblock(chain, wgenesis.GetAddress(), t) + time.Sleep(time.Second) + if err := wsrc.Sync_Wallet_Memory_With_Daemon(); err != nil { + t.Fatalf("src re-sync: %s", err) + } + + // precondition: the seeded tree must actually sit in the dead band — random tail > 40 + // (fast path stays dark) and distinct decoy capacity < ring-2 (tree can never fill it). + tail := len(wsrc.Random_ring_members(tokenSCID)) + if tail <= 40 || tail > 3*seedRing-2 { + t.Fatalf("precondition broken: token tail %d not in the dead band (41..%d)", tail, 3*seedRing-2) + } + + // ── the dead-band build: must terminate AND succeed via the stall rescue ── + wsrc.SetRingSize(ring) + type result struct { + tx *transaction.Transaction + err error + } + done := make(chan result, 1) + go func() { + tx, err := wsrc.TransferPayload0( + []rpc.Transfer{ + {Destination: wrecipient.GetAddress().String(), Amount: 1}, + {SCID: tokenSCID, Destination: wrecipient.GetAddress().String(), Amount: 0}, + }, + ring, false, rpc.Arguments{}, 0, false) + done <- result{tx, err} + }() + + select { + case r := <-done: + if r.err != nil { + t.Fatalf("dead-band token ring build failed (expected stall rescue to fill from base tree): %s", r.err) + } + if r.tx == nil { + t.Fatal("nil tx without error") + } + for i := range r.tx.Payloads { + if n := len(r.tx.Payloads[i].Statement.Publickeylist); n != ring { + t.Fatalf("payload %d ring is %d, want %d", i, n, ring) + } + } + t.Logf("dead-band ring assembled at %d (token tail was %d) via stall rescue", ring, tail) + case <-time.After(120 * time.Second): + t.Fatal("dead-band ring assembly did not terminate") + } +} diff --git a/glue/rwc/rwc.go b/glue/rwc/rwc.go index 31371ffc..499b043a 100644 --- a/glue/rwc/rwc.go +++ b/glue/rwc/rwc.go @@ -4,20 +4,40 @@ package rwc import ( - "github.com/gorilla/websocket" "io" + "time" + + "github.com/gorilla/websocket" ) type ReadWriteCloser struct { WS *websocket.Conn r io.Reader w io.WriteCloser + + // wtimeout, when non-zero, arms a fresh write deadline on the underlying + // websocket before every frame write/flush. A write blocked on a half-open + // peer (accepted TCP, receive window full, no RST) then errors at the + // deadline instead of stalling for the kernel TCP retransmit timeout + // (~15+ min on Linux) — and a jrpc2 client multiplexed over this channel + // serializes ALL sends and its context-deadline delivery on one mutex, so + // one such stalled write would otherwise freeze every concurrent call. + // Zero preserves the historical deadline-free behavior (daemon/explorer + // server paths use New and are unchanged). + wtimeout time.Duration } func New(conn *websocket.Conn) *ReadWriteCloser { return &ReadWriteCloser{WS: conn} } +// NewWithWriteTimeout is New with a per-write deadline (see wtimeout). On expiry +// gorilla/websocket fails the write and poisons the connection: subsequent writes +// fail fast, the reader errors, and the owner is expected to reconnect. +func NewWithWriteTimeout(conn *websocket.Conn, d time.Duration) *ReadWriteCloser { + return &ReadWriteCloser{WS: conn, wtimeout: d} +} + func (rwc *ReadWriteCloser) Read(p []byte) (n int, err error) { if rwc.r == nil { _, rwc.r, err = rwc.WS.NextReader() @@ -40,6 +60,9 @@ func (rwc *ReadWriteCloser) Read(p []byte) (n int, err error) { } func (rwc *ReadWriteCloser) Write(p []byte) (n int, err error) { + if rwc.wtimeout > 0 { + rwc.WS.SetWriteDeadline(time.Now().Add(rwc.wtimeout)) + } if rwc.w == nil { rwc.w, err = rwc.WS.NextWriter(websocket.TextMessage) if err != nil { @@ -62,6 +85,11 @@ func (rwc *ReadWriteCloser) Write(p []byte) (n int, err error) { func (rwc *ReadWriteCloser) Close() (err error) { if rwc.w != nil { + if rwc.wtimeout > 0 { + // Close flushes the frame — a socket write — so it needs its own + // deadline when called outside Write (e.g. by the channel layer). + rwc.WS.SetWriteDeadline(time.Now().Add(rwc.wtimeout)) + } err = rwc.w.Close() rwc.w = nil } diff --git a/glue/rwc/rwc_write_timeout_test.go b/glue/rwc/rwc_write_timeout_test.go new file mode 100644 index 00000000..dc1e1949 --- /dev/null +++ b/glue/rwc/rwc_write_timeout_test.go @@ -0,0 +1,84 @@ +//go:build !wasm +// +build !wasm + +package rwc + +// Write-deadline pin (PR #22 re-review O14). +// +// A half-open peer (TCP accepted, receive window full, no RST) blocks a websocket +// write for the kernel TCP retransmit timeout (~15+ min on Linux). The wallet +// multiplexes ALL daemon RPCs over one jrpc2 client whose send() holds the +// client-wide mutex ACROSS the socket write and whose context-deadline delivery +// (waitComplete) must take the same mutex — so one blocked deadline-free write +// (e.g. the background test_connectivity Echo) would freeze every concurrent +// call, including transfer-path calls holding transfer_mutex, with their 45s +// call deadlines undeliverable. NewWithWriteTimeout bounds every frame write, so +// the blocked write errors at the deadline and poisons the connection instead. +// +// The test reproduces the exact peer behavior: a websocket server that completes +// the handshake and then never reads. Writes are absorbed by the send/receive +// socket buffers until they fill, then block; the write deadline must convert +// that block into an error within the timeout, and subsequent writes must fail +// fast (poisoned connection → owner reconnects). + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func Test_WriteTimeout_UnsticksBlockedWrite(t *testing.T) { + upgrader := websocket.Upgrader{} + block := make(chan struct{}) + defer close(block) + srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + c, err := upgrader.Upgrade(rw, r, nil) + if err != nil { + return + } + defer c.Close() + <-block // half-open: handshake done, then never read a single frame + })) + defer srv.Close() + + conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), nil) + if err != nil { + t.Fatalf("dial: %s", err) + } + defer conn.Close() + + const wtimeout = 500 * time.Millisecond + rw := NewWithWriteTimeout(conn, wtimeout) + + // Fill the socket buffers until the write blocks. Kernel auto-tuning tops out + // far below the 256MB ceiling, so the loop always hits the blocked state. + payload := make([]byte, 1<<20) + start := time.Now() + var werr error + for i := 0; i < 256; i++ { + if _, werr = rw.Write(payload); werr != nil { + break + } + } + elapsed := time.Since(start) + if werr == nil { + t.Fatal("writes against a peer that stopped reading never failed — write deadline not armed") + } + // Bound: buffered writes are memcpy-fast; the blocked one errors within its + // deadline. Generous multiple for CI scheduling noise — the point is minutes + // (TCP retransmit) vs sub-second (deadline). + if elapsed > 30*time.Second { + t.Fatalf("blocked write took %s to fail; want ~%s (deadline not effective)", elapsed, wtimeout) + } + t.Logf("blocked write failed after %s: %s", elapsed, werr) + + // Deadline expiry poisons the connection: the next write must fail fast, so a + // broken transport is never silently reused (the wallet reconnects instead). + if _, err := rw.Write([]byte("x")); err == nil { + t.Fatal("connection usable after write-deadline expiry; expected poisoned connection") + } +} diff --git a/walletapi/daemon_communication.go b/walletapi/daemon_communication.go index 84fdc76b..80c551d3 100644 --- a/walletapi/daemon_communication.go +++ b/walletapi/daemon_communication.go @@ -251,6 +251,42 @@ func (cli *Client) Call(method string, params interface{}, result interface{}) e return cli.RPC.CallResult(context.Background(), method, params, result) } +// walletDaemonCallTimeout bounds each daemon RPC issued on the transfer path (ring +// candidates, balance/registration probes, name resolution): those calls run under transfer_mutex, and a +// daemon that accepts the websocket but never answers would otherwise park the build +// inside a deadline-free CallResult holding the mutex forever (PR #22 re-review O10). +// Sized orders of magnitude above honest daemon latency for these lookups (sub-second, +// even over high-RTT links) so it can only fire on a hung or hostile peer. A var so +// tests can shrink it. +var walletDaemonCallTimeout = 45 * time.Second + +// walletDaemonWriteTimeout bounds every websocket WRITE on the wallet's daemon client +// (armed per write by rwc.NewWithWriteTimeout in Connect). The call-context deadline +// below cannot bound the transmit phase: the vendored jrpc2 client serializes send() +// and context-deadline delivery on one client-wide mutex (client.go — send holds c.mu +// ACROSS the socket write; waitComplete must take c.mu to deliver a timeout), so a +// single write blocked on a half-open peer — notably the deadline-free background +// test_connectivity Echo/GetInfo issued every ~5s — would freeze every concurrent +// call's transmit AND its timeout delivery until the kernel TCP retransmit timeout +// (~15+ min on Linux), including transfer-path calls holding transfer_mutex (PR #22 +// re-review O14). On expiry the write errors, the connection is poisoned, pending +// calls fail, and Keep_Connectivity reconnects. A var so tests can shrink it. +var walletDaemonWriteTimeout = 45 * time.Second + +// CallWithTimeout is Call with a hard per-call deadline: the vendored jrpc2 client +// completes the pending call with the context error when the deadline passes, even if +// the daemon never replies (client.go waitComplete). The deadline covers the +// await-response phase; the transmit phase — including transmit stalls induced by a +// CONCURRENT writer on the shared client, which also block this deadline's delivery — +// is bounded separately by the per-write deadline on the connection +// (walletDaemonWriteTimeout above), so one call is bounded by (concurrent writer's +// write deadline) + (own write deadline) + (response deadline). +func (cli *Client) CallWithTimeout(timeout time.Duration, method string, params interface{}, result interface{}) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return cli.RPC.CallResult(ctx, method, params, result) +} + // returns whether wallet was online some time ago func (w *Wallet_Memory) IsDaemonOnlineCached() bool { return Connected @@ -345,8 +381,12 @@ func (w *Wallet_Memory) NameToAddress(name string) (addr string, err error) { return } + // Deadline-bounded: TransferPayload0WithOptions resolves name destinations under + // transfer_mutex BEFORE ring assembly (even at ring 2, where the assembly loop never + // runs), so a deadline-free call here would park the build on a hung daemon with + // every ring-assembly bound bypassed (see walletDaemonCallTimeout). var result rpc.NameToAddress_Result - if err = rpc_client.Call("DERO.NameToAddress", rpc.NameToAddress_Params{Name: name, TopoHeight: -1}, &result); err != nil { + if err = rpc_client.CallWithTimeout(walletDaemonCallTimeout, "DERO.NameToAddress", rpc.NameToAddress_Params{Name: name, TopoHeight: -1}, &result); err != nil { return } @@ -404,7 +444,9 @@ func (w *Wallet_Memory) GetSelfEncryptedBalanceAtTopoHeight(scid crypto.Hash, to } }() - err = rpc_client.Call("DERO.GetEncryptedBalance", rpc.GetEncryptedBalance_Params{SCID: scid, Address: w.GetAddress().String(), TopoHeight: topoheight}, &r) + // deadline-bounded: called under transfer_mutex on the transfer path (see + // walletDaemonCallTimeout). + err = rpc_client.CallWithTimeout(walletDaemonCallTimeout, "DERO.GetEncryptedBalance", rpc.GetEncryptedBalance_Params{SCID: scid, Address: w.GetAddress().String(), TopoHeight: topoheight}, &r) return } @@ -435,8 +477,11 @@ func (w *Wallet_Memory) GetEncryptedBalanceAtTopoHeight(scid crypto.Hash, topohe //var params rpc.GetEncryptedBalance_Params var result rpc.GetEncryptedBalance_Result - // Issue a call with a response. - if err = rpc_client.Call("DERO.GetEncryptedBalance", rpc.GetEncryptedBalance_Params{SCID: scid, Address: accountaddr, TopoHeight: topoheight}, &result); err != nil { + // Issue a call with a response. Deadline-bounded: this is the per-member balance + // fetch and decoy registration probe of ring assembly, issued under transfer_mutex + // (see walletDaemonCallTimeout). A deadline error is a transport failure, not an + // unregistered verdict, so it classifies as "could not verify" on the probe path. + if err = rpc_client.CallWithTimeout(walletDaemonCallTimeout, "DERO.GetEncryptedBalance", rpc.GetEncryptedBalance_Params{SCID: scid, Address: accountaddr, TopoHeight: topoheight}, &result); err != nil { logger.Error(err, "DERO.GetEncryptedBalance Call failed:") if strings.Contains(strings.ToLower(err.Error()), strings.ToLower(errormsg.ErrAccountUnregistered.Error())) && accountaddr == w.GetAddress().String() && scid.IsZero() { @@ -546,8 +591,9 @@ func (w *Wallet_Memory) Random_ring_members(scid crypto.Hash) (alist []string) { //fmt.Printf("getting ring members %s %s\n",scid.String(), debug.Stack()) - // Issue a call with a response. - if err := rpc_client.Call("DERO.GetRandomAddress", rpc.GetRandomAddress_Params{SCID: scid}, &result); err != nil { + // Issue a call with a response. Deadline-bounded: ring assembly calls this once per + // pass under transfer_mutex (see walletDaemonCallTimeout). + if err := rpc_client.CallWithTimeout(walletDaemonCallTimeout, "DERO.GetRandomAddress", rpc.GetRandomAddress_Params{SCID: scid}, &result); err != nil { logger.V(1).Error(err, "DERO.GetRandomAddress Call failed:") return } diff --git a/walletapi/daemon_connectivity.go b/walletapi/daemon_connectivity.go index b9a8a139..8f12e7db 100644 --- a/walletapi/daemon_connectivity.go +++ b/walletapi/daemon_connectivity.go @@ -107,7 +107,11 @@ func Connect(endpoint string) (err error) { return } - input_output := rwc.New(rpc_client.WS) + // per-write deadline: without it, one write blocked on a half-open daemon holds + // the jrpc2 client-wide send mutex for the kernel TCP retransmit timeout and + // freezes every concurrent call INCLUDING its context-deadline delivery — see + // walletDaemonWriteTimeout (daemon_communication.go). + input_output := rwc.NewWithWriteTimeout(rpc_client.WS, walletDaemonWriteTimeout) rpc_client.RPC = jrpc2.NewClient(channel.RawJSON(input_output, input_output), &jrpc2.ClientOptions{OnNotify: Notify_broadcaster}) if err = test_connectivity(); err != nil { diff --git a/walletapi/ring_backoff_test.go b/walletapi/ring_backoff_test.go new file mode 100644 index 00000000..667e8919 --- /dev/null +++ b/walletapi/ring_backoff_test.go @@ -0,0 +1,69 @@ +// Copyright 2017-2021 DERO Project. All rights reserved. +// Use of this source code in any form is governed by RESEARCH license. +// license can be found in the LICENSE file. + +package walletapi + +// Pins the barren-backoff schedule against the daemon's recent-activity filter +// (PR #22 review finding #1 / re-review O1). +// +// GetRandomAddress (cmd/derod/rpc/rpc_dero_getrandomaddress.go) filters out any account +// whose balance changed within the last 5 blocks, so a burst of activity can transiently +// thin the candidate pool for up to 5 × config.BLOCK_TIME seconds. The exhaustion error +// must not be reachable INSIDE that window on an otherwise healthy pool: the cumulative +// consecutive-barren wait must exceed it, or a transiently filtered pool reads as +// permanently exhausted and the bound converts a recoverable send into a false error. +// The window cannot be exercised end-to-end in the simulator (the filter is disabled +// below topoheight 100 and mainnet block time makes it a 90s test), so the schedule sum +// is pinned arithmetically here against the same constants the daemon uses. + +import ( + "testing" + "time" + + "github.com/deroproject/derohe/config" +) + +func Test_BarrenBackoff_Spans_Filter(t *testing.T) { + // Sum exactly the sleeps that can occur: the exhaustion cap fires at + // barren_passes == maxBarrenRingPasses BEFORE that pass's sleep, so the + // realizable consecutive-barren window is sleep(1..maxBarrenRingPasses-1). + var total time.Duration + for i := 1; i <= maxBarrenRingPasses-1; i++ { + d := barrenRingSleep(i) + if d <= 0 { + t.Fatalf("barrenRingSleep(%d) = %v, must be positive", i, d) + } + if d > 4*time.Second { + t.Fatalf("barrenRingSleep(%d) = %v exceeds the 4s cap", i, d) + } + if prev := barrenRingSleep(i - 1); i > 1 && d < prev { + t.Fatalf("backoff must be non-decreasing: sleep(%d)=%v < sleep(%d)=%v", i, d, i-1, prev) + } + total += d + } + + filterWindow := time.Duration(5*config.BLOCK_TIME) * time.Second // daemon: old_topoheight -= 5 + if total <= filterWindow { + t.Fatalf("consecutive-barren window %v does not span the daemon's 5-block recent-activity filter %v: a transiently filtered pool would be declared exhausted", total, filterWindow) + } + + // first pass must stay snappy: genuine exhaustion (degraded daemon) should not pay + // the full window before the caps can fire on the fast total-passes bound. + if barrenRingSleep(1) != 250*time.Millisecond { + t.Fatalf("first barren backoff = %v, want 250ms", barrenRingSleep(1)) + } + + // The per-transfer stall budget must admit one full honest consecutive-barren window + // (or the filter-recovery path is cut short and the budget reintroduces the false + // exhaustion error), while still hard-capping cumulative mutex-held sleep so an + // adversarial trickle cannot multiply windows (re-review O5). The budget is scoped + // per transfer (re-review O8), so this single-window pin is exactly the invariant + // EVERY transfer in a multi-transfer array gets — not just the first to go barren. + if maxRingBuildStallBudget <= total { + t.Fatalf("stall budget %v does not admit one full consecutive-barren window %v", maxRingBuildStallBudget, total) + } + if maxRingBuildStallBudget > 2*total { + t.Fatalf("stall budget %v exceeds 2x the barren window %v: documented mutex-hold cap drifted", maxRingBuildStallBudget, total) + } +} diff --git a/walletapi/rpc_call_timeout_test.go b/walletapi/rpc_call_timeout_test.go new file mode 100644 index 00000000..c9caf4eb --- /dev/null +++ b/walletapi/rpc_call_timeout_test.go @@ -0,0 +1,113 @@ +// Copyright 2017-2021 DERO Project. All rights reserved. +// Use of this source code in any form is governed by RESEARCH license. +// license can be found in the LICENSE file. + +package walletapi + +import ( + "io" + "testing" + "time" + + "github.com/creachadair/jrpc2" + "github.com/creachadair/jrpc2/channel" + "github.com/deroproject/derohe/cryptography/crypto" + "github.com/gorilla/websocket" +) + +// hungReader models a daemon that accepts the connection and then never answers: +// reads block until the test finishes, so no response ever arrives. +type hungReader struct{ done chan struct{} } + +func (h hungReader) Read(p []byte) (int, error) { <-h.done; return 0, io.EOF } + +type discardWC struct{} + +func (discardWC) Write(p []byte) (int, error) { return len(p), nil } +func (discardWC) Close() error { return nil } + +// Test_CallWithTimeout_HungDaemon pins the per-RPC deadline on the transfer path +// (PR #22 re-review O10). +// +// TransferPayload0WithOptions holds transfer_mutex across every daemon RPC it issues. +// The barren backoff, pass caps, and stall budget bound the SLEEP term of that hold — +// but a daemon that accepts the websocket and never answers used to park the build +// inside a deadline-free CallResult forever, with IsDaemonOnline() still true (it only +// checks WS/RPC non-nil). Post-fix: every RPC the transfer path issues +// (Random_ring_members, GetEncryptedBalanceAtTopoHeight, the decoy probe, and +// NameToAddress — reached pre-assembly, even at ring 2) carries +// walletDaemonCallTimeout, so the first hung call errors the build at the deadline — +// and a deadline error classifies as "could not verify" on the probe path (a transport +// failure, never a silent lenient drop). +func Test_CallWithTimeout_HungDaemon(t *testing.T) { + done := make(chan struct{}) + defer close(done) + + hung := &Client{RPC: jrpc2.NewClient(channel.RawJSON(hungReader{done}, discardWC{}), nil)} + + // ── the mechanism: a hung call returns at the deadline, not never ── + var result interface{} + start := time.Now() + err := hung.CallWithTimeout(150*time.Millisecond, "DERO.Ping", nil, &result) + if err == nil { + t.Fatal("a call the daemon never answers must error at the deadline") + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("deadline did not bound the hung call: took %s", elapsed) + } + + // ── end to end: the transfer-path helpers return under a hung daemon ── + savedWS, savedRPC, savedTimeout := rpc_client.WS, rpc_client.RPC, walletDaemonCallTimeout + defer func() { rpc_client.WS, rpc_client.RPC, walletDaemonCallTimeout = savedWS, savedRPC, savedTimeout }() + rpc_client.WS = &websocket.Conn{} // non-nil: IsDaemonOnline() stays true (O10 premise) + rpc_client.RPC = hung.RPC + walletDaemonCallTimeout = 200 * time.Millisecond + + if !IsDaemonOnline() { + t.Fatal("injection premise broken: IsDaemonOnline must be true with a hung daemon") + } + + w, werr := Create_Encrypted_Wallet_Random_Memory("") + if werr != nil { + t.Fatalf("cannot create in-memory wallet: %s", werr) + } + defer w.Close_Encrypted_Wallet() + w.wallet_online_mode = true // online mode without starting the sync loop + + wdecoy, werr := Create_Encrypted_Wallet_Random_Memory("") + if werr != nil { + t.Fatalf("cannot create decoy wallet: %s", werr) + } + defer wdecoy.Close_Encrypted_Wallet() + + var zeroscid crypto.Hash + + start = time.Now() + if _, _, _, _, berr := w.GetEncryptedBalanceAtTopoHeight(zeroscid, -1, w.GetAddress().String()); berr == nil { + t.Fatal("balance fetch against a hung daemon must error at the deadline") + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("balance fetch not bounded by the deadline: took %s", elapsed) + } + + start = time.Now() + if members := w.Random_ring_members(zeroscid); len(members) != 0 { + t.Fatalf("hung daemon cannot yield ring members, got %d", len(members)) + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("ring-member fetch not bounded by the deadline: took %s", elapsed) + } + + // name-destination resolution runs under transfer_mutex BEFORE ring assembly and is + // reached even at ring 2, so a deadline-free NameToAddress would bypass every + // ring-assembly bound (PR #22 re-review O11). + start = time.Now() + if _, nerr := w.NameToAddress("somename"); nerr == nil { + t.Fatal("name resolution against a hung daemon must error at the deadline") + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("name resolution not bounded by the deadline: took %s", elapsed) + } + + _ = wdecoy // the decoy-probe classification assertion arrives with review #3's classifier +} diff --git a/walletapi/transaction_build.go b/walletapi/transaction_build.go index 9dad7dc9..e573c970 100644 --- a/walletapi/transaction_build.go +++ b/walletapi/transaction_build.go @@ -62,6 +62,14 @@ type RingPreference struct { // random members top up to ringsize. Each is validated registered on the base balance // tree before use. Addresses the user controls must NOT be supplied here — curating // your own addresses collapses your anonymity set. + // + // Ring composition on a scarce tree: when the transfer SCID's own tree cannot supply + // enough random members (its random tail is <= 40), assembly falls back to base-tree + // members exactly as it does without curation — for a non-zero SCID those fill token + // slots with synthesized zero balances. Curated decoys do not widen this behavior and + // do not count toward the scarcity measurement. If even the fallback cannot fill the + // ring, assembly fails with an explicit pool-exhaustion error instead of retrying + // forever (Strict only governs per-decoy validation, never composition). PreferredDecoys []string // Strict: if true, a bad preferred decoy (unparseable / self / duplicate / unregistered) // is a hard error. If false (default), it is skipped and random selection fills the slot. diff --git a/walletapi/transfer_array_cap_test.go b/walletapi/transfer_array_cap_test.go new file mode 100644 index 00000000..d1378640 --- /dev/null +++ b/walletapi/transfer_array_cap_test.go @@ -0,0 +1,56 @@ +// Copyright 2017-2021 DERO Project. All rights reserved. +// Use of this source code in any form is governed by RESEARCH license. +// license can be found in the LICENSE file. + +package walletapi + +import ( + "strings" + "testing" + + "github.com/deroproject/derohe/rpc" +) + +// Test_TransferArrayCap_FailsClosed locks the wallet-side cap on the transfer array +// (PR #22 re-review O13). +// +// Assembly cost — and the transfer_mutex hold — scales linearly in len(transfers): +// each transfer owns a full stall budget plus its pass-capped RPCs. The consensus +// 300KB tx-size limit fires only at verification/broadcast, AFTER the whole assembly +// loop has run, so without a wallet-side gate an oversized array would pay the entire +// N × per-transfer worst case under the mutex and only then be rejected. The cap is +// request validation: it must fire BEFORE any daemon RPC or sleep — offline in-memory +// wallet, no chain (same pattern as the other pre-build guards). +func Test_TransferArrayCap_FailsClosed(t *testing.T) { + w, err := Create_Encrypted_Wallet_From_Recovery_Words_Memory("", "sequence atlas unveil summon pebbles tuesday beer rudely snake rockets different fuselage woven tagged bested dented vegan hover rapid fawns obvious muppet randomly seasons randomly") + if err != nil { + t.Fatalf("cannot create in-memory wallet: %s", err) + } + defer w.Close_Encrypted_Wallet() + + dest := w.GetAddress().String() // any valid address; the guard fires before it is used + + transfers := make([]rpc.Transfer, MaxTransfersPerBuild+1) + for i := range transfers { + transfers[i] = rpc.Transfer{Destination: dest, Amount: 0} + } + + // one past the cap MUST fail closed before any network activity, naming the cap. + tx, err := w.TransferPayload0WithOptions(transfers, 2, false, rpc.Arguments{}, 0, false, TransferOptions{}) + if err == nil || tx != nil { + t.Fatalf("array of %d transfers must fail the build cap, got err=%v tx=%v", len(transfers), err, tx != nil) + } + if !strings.Contains(err.Error(), "too many transfers") { + t.Fatalf("over-cap rejection should name the transfer cap, got: %v", err) + } + + // exactly at the cap must NOT trip the guard: the build proceeds and fails later, + // differently, on the offline network call — the cap rejects only what could never + // broadcast (necessary condition of the consensus size limit; the per-payload size + // floor behind that arithmetic is pinned on a real built transaction by + // Test_Ring2PayloadFloor_JustifiesTransferCap in cmd/simulator). + if _, aerr := w.TransferPayload0WithOptions(transfers[:MaxTransfersPerBuild], 2, false, rpc.Arguments{}, 0, false, TransferOptions{}); aerr != nil && + strings.Contains(aerr.Error(), "too many transfers") { + t.Fatalf("array exactly at the cap wrongly tripped the transfer cap: %v", aerr) + } +} diff --git a/walletapi/wallet_transfer.go b/walletapi/wallet_transfer.go index 0f8f2395..218828a3 100644 --- a/walletapi/wallet_transfer.go +++ b/walletapi/wallet_transfer.go @@ -19,6 +19,7 @@ package walletapi import ( "encoding/hex" "fmt" + "time" "github.com/deroproject/derohe/config" "github.com/deroproject/derohe/cryptography/bn256" @@ -84,9 +85,13 @@ func (w *Wallet_Memory) TransferPayload0(transfers []rpc.Transfer, ringsize uint // recipientAddr is the transfer's recipient address string (any HRP/network/integrated // encoding); its pubkey seeds the distinctness set so an alt-encoding of the recipient is // rejected as a duplicate. Empty means "no recipient seed". -func (w *Wallet_Memory) curatedRingCandidates(scid crypto.Hash, recipientAddr string, pref *RingPreference) (alist []string, err error) { +// +// curated reports how many validated preferred decoys lead alist, so the caller can +// measure candidate scarcity on the random tail alone (the count varies per call in +// non-Strict mode: a failed registration probe silently drops a decoy for that call). +func (w *Wallet_Memory) curatedRingCandidates(scid crypto.Hash, recipientAddr string, pref *RingPreference) (alist []string, curated int, err error) { if pref == nil { - return w.Random_ring_members(scid), nil + return w.Random_ring_members(scid), 0, nil } var zeroscid crypto.Hash @@ -115,20 +120,20 @@ func (w *Wallet_Memory) curatedRingCandidates(scid crypto.Hash, recipientAddr st addr, e := rpc.NewAddress(d) if e != nil { // must be a parseable address if pref.Strict { - return nil, fmt.Errorf("preferred decoy is not a valid address: %s", d) + return nil, 0, fmt.Errorf("preferred decoy is not a valid address: %s", d) } continue } key := pkKey(addr) // pubkey is the network-/proof-/integrated-agnostic identity if key == self { // curating your own address collapses your anonymity set if pref.Strict { - return nil, fmt.Errorf("preferred decoy cannot be your own address") + return nil, 0, fmt.Errorf("preferred decoy cannot be your own address") } continue } if seen[key] { // distinctness (consensus rejects duplicate ring members) if pref.Strict { - return nil, fmt.Errorf("duplicate preferred decoy: %s", d) + return nil, 0, fmt.Errorf("duplicate preferred decoy: %s", d) } continue } @@ -143,7 +148,7 @@ func (w *Wallet_Memory) curatedRingCandidates(scid crypto.Hash, recipientAddr st // registration: probe the BASE balance tree, the tree consensus checks against. if _, _, _, _, e := w.GetEncryptedBalanceAtTopoHeight(zeroscid, -1, base); e != nil { if pref.Strict { - return nil, fmt.Errorf("preferred decoy is not registered: %s", d) + return nil, 0, fmt.Errorf("preferred decoy is not registered: %s", d) } continue } @@ -151,7 +156,112 @@ func (w *Wallet_Memory) curatedRingCandidates(scid crypto.Hash, recipientAddr st alist = append(alist, base) // ring carries the canonical base form } - return append(alist, w.Random_ring_members(scid)...), nil + curated = len(alist) // count captured before the random tail is appended + return append(alist, w.Random_ring_members(scid)...), curated, nil +} + +// Ring-assembly termination bounds (review #1). A pass over the candidate list that adds +// no new distinct candidate to the deduplicator is "barren": the daemon's sample was fully +// saturated. Without bounds a candidate pool smaller than the ring spins forever holding +// transfer_mutex: the per-member balance probe cannot error on a non-zero SCID +// (unregistered accounts get synthesized zero balances with err=nil), success needs a full +// ring, and the candidate stream stops yielding new members. +// +// Two recovery layers run BEFORE the exhaustion error: +// +// 1. Stall rescue: after ringStallRescueAfter consecutive barren passes the loop +// permanently switches to base-tree candidates for the remaining slots — the same +// fill the <=40 scarcity fast-path uses, just triggered by observed starvation +// instead of a size heuristic. This covers the band the heuristic cannot see: a +// token tree with more than 40 members but fewer than ringsize (the daemon's +// 5-block recent-activity filter can also push a larger tree into this band). +// 2. Barren backoff: consecutive barren passes sleep with exponential backoff (250ms +// doubling to a 4s cap). The sleeps that can actually occur before the cap fires +// (barren 1..maxBarrenRingPasses-1) total ~112s — longer than the daemon's +// recent-activity filter window (5 blocks × 18s = 90s, +// rpc_dero_getrandomaddress.go / config.BLOCK_TIME), so a BASE pool transiently +// thinned by the filter can roll past it before exhaustion is declared. The +// schedule sum is pinned against the filter window by Test_BarrenBackoff_Spans_Filter. +// +// The pool is declared exhausted only after maxBarrenRingPasses CONSECUTIVE barren passes +// with the rescue already armed, or maxTotalRingPassesFactor × ringsize total passes +// (bounding adversarial trickle progress: the deduplicator is monotone, so even a daemon +// feeding one fresh member per pass terminates). +// +// Pass counts alone do not bound WALL CLOCK: barren_passes resets on any deduplicator +// growth, so a daemon trickling one fresh member per ~31 passes could re-arm the full +// backoff window once per trickle inside the total-pass cap (~32 windows at ring 128). +// maxRingBuildStallBudget therefore caps the CUMULATIVE backoff sleep PER TRANSFER, +// regardless of trickle pattern. It is scoped per transfer, not per build: barren/rescue +// state resets per transfer, so each transfer owns a full consecutive-barren window +// (~112s) and the honest filter-recovery path is never cut short — even when an earlier +// transfer in the array already consumed its own window (a shared budget would convert +// the second recovery into a false "stall budget exhausted" error). The resulting +// mutex-held sleep bound is len(transfers) × the budget, and len(transfers) is itself +// capped wallet-side at MaxTransfersPerBuild BEFORE any mutex-held RPC or sleep. That +// cap is what makes the multiplier a constant: the consensus tx-size limit +// (STARGATE_HE_MAX_TX_SIZE = 300KB) is enforced only at verification/broadcast — AFTER +// the whole assembly loop has run — so it bounds what can broadcast, never how long +// assembly holds the mutex. The wallet cap is a necessary condition of that limit +// (every payload serializes to more than STARGATE_HE_MAX_TX_SIZE/MaxTransfersPerBuild +// bytes even at the ring-2 minimum — statement ring keys + CT proof; pinned on a real +// built transaction by Test_Ring2PayloadFloor_JustifiesTransferCap), so it can never +// reject an array that could have broadcast. Exceeding the +// budget means the daemon is starving assembly and the build errors. The remaining +// hold term is RPC latency — bounded in COUNT by the pass caps and in TIME by the +// per-call deadline on every daemon RPC the transfer path issues +// (walletDaemonCallTimeout, daemon_communication.go): a daemon that accepts the +// connection but never answers no longer parks the build inside a deadline-free +// CallResult holding transfer_mutex forever; the first hung call errors the build at +// the deadline. RPC_COUNT_BOUND covers the whole body, not just ring assembly: +// per BUILD, ≤2 fee/SC-call random-member fetches (each may append one 0-amount +// transfer, so the loops below run over an EFFECTIVE transfer count ≤ len(transfers)+1 +// — the appends are mutually exclusive: the SC-call append creates the base transfer +// the fee append checks for); per EFFECTIVE transfer, 1 balance probe, ≤20 +// empty-destination resolver fetches, ≤1 NameToAddress resolution, then the +// pass-capped assembly RPCs (≤8×ringsize member fetches + ≤maxPreferredDecoys +// memoized probes + ringsize member-balance fetches). The full worst-case hold is +// therefore (min(len(transfers), MaxTransfersPerBuild)+1) × (150s sleep + +// RPC_COUNT_BOUND × per-RPC time bound) — an absolute constant, astronomical only +// against a daemon stalling EVERY reply just under the deadline (visible, and strictly +// narrower than the unbounded pre-fix hold), and ~one deadline for the common +// hung-daemon case. The per-RPC time bound covers the transmit side too: every +// websocket WRITE on the wallet's daemon client carries a write deadline +// (rwc.NewWithWriteTimeout in Connect; walletDaemonWriteTimeout). The call-context +// deadline alone cannot bound a blocked write — the vendored jrpc2 client serializes +// send() and deadline delivery on one client-wide mutex, so one write blocked on a +// half-open peer (including the deadline-free background test_connectivity Echo/GetInfo) +// would otherwise freeze every concurrent call's transmit AND its timeout delivery for +// the kernel TCP retransmit timeout (~15+ min), not 45s. With the write deadline the +// blocked write errors and poisons the connection, so one transfer-path RPC is bounded +// by (concurrent writer's write deadline) + (own write deadline) + (response deadline) +// ≤ 3 × 45s. +const maxBarrenRingPasses = 32 +const maxTotalRingPassesFactor = 8 +const ringStallRescueAfter = 2 +const maxRingBuildStallBudget = 150 * time.Second + +// MaxTransfersPerBuild caps the transfer array accepted by a single build, checked +// before any mutex-held RPC or sleep (see the bounds comment above: it is what turns +// the per-transfer hold bound into an absolute one). 256 is a NECESSARY condition of +// the consensus 300KB tx-size limit: a single payload — statement ring keys, per-member +// commitments and the CT proof — serializes to well over 300*1024/256 = 1200 bytes even +// at ring 2 (Test_Ring2PayloadFloor_JustifiesTransferCap pins this on a real built tx), +// so any array longer than 256 could never have broadcast anyway and the cap rejects no +// previously-usable input. Exported: it is part of the build API contract. +const MaxTransfersPerBuild = 256 + +// barrenRingSleep is the backoff before retrying after the barren-th consecutive +// fruitless pass: 250ms, 500ms, 1s, 2s, then 4s flat (see the bounds comment above). +func barrenRingSleep(barren int) time.Duration { + d := 250 * time.Millisecond + for i := 1; i < barren && d < 4*time.Second; i++ { + d *= 2 + } + if d > 4*time.Second { + d = 4 * time.Second + } + return d } // TransferPayload0WithOptions is the additive variant carrying opt-in transfer @@ -167,6 +277,15 @@ func (w *Wallet_Memory) TransferPayload0WithOptions(transfers []rpc.Transfer, ri // return nil, fmt.Error("transfers is nil, cannot send.") //} + // request-size validation before ANY daemon RPC or sleep: assembly cost — and the + // transfer_mutex hold — scales linearly in the transfer count, and the consensus + // 300KB tx-size limit only fires at broadcast, after that cost is already paid. + // See MaxTransfersPerBuild. + if len(transfers) > MaxTransfersPerBuild { + err = fmt.Errorf("too many transfers in one build: %d (maximum %d; a transaction this large could not broadcast under the %d-byte consensus limit) — split the batch", len(transfers), MaxTransfersPerBuild, config.STARGATE_HE_MAX_TX_SIZE) + return + } + if ringsize == 0 { ringsize = uint64(w.account.Ringsize) // use wallet ringsize, if ringsize not provided } else { // we need to use supplied ringsize @@ -449,23 +568,53 @@ func (w *Wallet_Memory) TransferPayload0WithOptions(transfers []rpc.Transfer, ri deduplicator[receiver_without_payment_id.String()] = true deduplicator[w.GetAddress().String()] = true + // Termination guarantee: deduplicator growth is monotone and bounded by the finite + // candidate universe (curated decoys + tree leaves), so requiring growth at least + // once every maxBarrenRingPasses passes bounds the loop; the absolute cap bounds + // even adversarial trickle progress. See the consts' comment (review #1). + barren_passes := 0 + total_passes := 0 + base_rescue := false // armed on stall or the <=40 fast path; sticky for the rest of this transfer + // per-TRANSFER stall budget, matching the per-transfer barren/rescue state above: + // caps this transfer's cumulative barren-backoff sleep under transfer_mutex while + // guaranteeing each transfer a full filter-recovery window (see the bounds comment + // on maxRingBuildStallBudget). + ring_stall_budget := maxRingBuildStallBudget for ringsize != 2 { // 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, receiver_without_payment_id.String(), opts.Ring) - if cerr != nil { - err = cerr - return + // RingPreference this returns exactly Random_ring_members(scid). base_rescue is + // sticky: once assembly has switched to base-tree fill, a per-pass SCID fetch + // would be discarded unread, so rescue passes fetch the base tree ONLY — one + // random-member RPC per pass, not two. Curated decoys still lead the base list + // (curatedRingCandidates prepends them regardless of tree). + var probable_members []string + if !base_rescue { + members, curated, cerr := w.curatedRingCandidates(transfers[t].SCID, receiver_without_payment_id.String(), opts.Ring) + if cerr != nil { + err = cerr + return + } + probable_members = members + // Scarcity is measured on the RANDOM TAIL alone — upstream's own measure (its + // list had no curated head). Counting curated decoys here lets them mask a + // scarce tree, the base-tree rescue never fires, and the loop starves (review #1). + // The <=40 size check is only the fast path: base_rescue also arms on OBSERVED + // starvation (consecutive barren passes) to catch trees the heuristic + // cannot — more than 40 members but fewer than the ring needs. + if len(probable_members)-curated <= 40 { // we do not have enough ring members for sure, extract ring members from base + base_rescue = true + } } - if len(probable_members) <= 40 { // we do not have enough ring members for sure, extract ring members from base + if base_rescue { var zeroscid crypto.Hash - base_members, berr := w.curatedRingCandidates(zeroscid, receiver_without_payment_id.String(), opts.Ring) + base_members, _, berr := w.curatedRingCandidates(zeroscid, receiver_without_payment_id.String(), opts.Ring) if berr != nil { err = berr return } probable_members = base_members } + seen_before := len(deduplicator) for _, k := range probable_members { if _, collision := deduplicator[k]; collision { continue @@ -495,6 +644,44 @@ func (w *Wallet_Memory) TransferPayload0WithOptions(transfers []rpc.Transfer, ri } } + total_passes++ + if len(deduplicator) > seen_before { + barren_passes = 0 + } else { + barren_passes++ + if !base_rescue && barren_passes >= ringStallRescueAfter { + // The SCID tree stopped yielding new members with the ring unfilled: + // switch to base-tree fill (the same fill the <=40 fast path uses) + // and give the base pool its own full barren budget. + base_rescue = true + barren_passes = 0 + } + } + if barren_passes >= maxBarrenRingPasses || total_passes >= maxTotalRingPassesFactor*int(ringsize) { + if len(probable_members) == 0 { + // Random_ring_members swallows RPC failures into an empty list — an + // empty final fetch means a degraded daemon, not a small pool. + err = fmt.Errorf("cannot assemble ring for scid %s: daemon returned no ring candidates after %d attempts — check the daemon connection", transfers[t].SCID, total_passes) + } else { + err = fmt.Errorf("cannot assemble ring for scid %s: candidate pool exhausted after %d passes without progress (have %d of %d members, %d distinct candidates seen) — retry later or lower the ringsize", transfers[t].SCID, barren_passes, len(ring_balances), ringsize, len(deduplicator)-2) + } + return + } + if barren_passes > 0 { + // exponential backoff so the full barren window outlasts the daemon's + // 5-block recent-activity filter (see the bounds comment on the consts). + // Every sleep draws on this transfer's stall budget: barren_passes resets on + // any progress, so without the budget a trickling daemon could re-arm the + // backoff window repeatedly and hold transfer_mutex for the product of the + // windows instead of one. + d := barrenRingSleep(barren_passes) + if d > ring_stall_budget { + err = fmt.Errorf("cannot assemble ring for scid %s: stall budget exhausted after %d passes (have %d of %d members, %d distinct candidates seen) — the daemon is starving ring assembly; retry later or lower the ringsize", transfers[t].SCID, total_passes, len(ring_balances), ringsize, len(deduplicator)-2) + return + } + ring_stall_budget -= d + time.Sleep(d) + } } ring_members_collected: From 217798cb6ca4771065983e31e0616be39428163e Mon Sep 17 00:00:00 2001 From: DHEBP <152355273+DHEBP@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:54:15 -0400 Subject: [PATCH 11/15] fix(walletapi): fail closed on strict curation at ring 2 + classify decoy-probe failures (review #2/#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 #23 CLI (Strict:false, ambient session decoys, any ring size). Lenient ring-2 drops feed the funnel below. #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 #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. --- .../curated_ring_finalization_test.go | 6 +- walletapi/curated_drop_signal_test.go | 98 ++++++++++ .../curated_probe_classification_test.go | 132 ++++++++++++++ walletapi/curated_ring2_guard_test.go | 91 ++++++++++ walletapi/curated_slot_cap_test.go | 113 ++++++++++++ walletapi/daemon_communication.go | 7 + walletapi/rpc_call_timeout_test.go | 15 +- walletapi/transaction_build.go | 40 +++- walletapi/wallet_transfer.go | 171 +++++++++++++++++- 9 files changed, 659 insertions(+), 14 deletions(-) create mode 100644 walletapi/curated_drop_signal_test.go create mode 100644 walletapi/curated_probe_classification_test.go create mode 100644 walletapi/curated_ring2_guard_test.go create mode 100644 walletapi/curated_slot_cap_test.go diff --git a/cmd/simulator/curated_ring_finalization_test.go b/cmd/simulator/curated_ring_finalization_test.go index 5d4923d0..a5ffe3ac 100644 --- a/cmd/simulator/curated_ring_finalization_test.go +++ b/cmd/simulator/curated_ring_finalization_test.go @@ -73,7 +73,11 @@ func Test_CuratedRing_Finalizes_A2(t *testing.T) { const ring = 8 // ring > 4 so curated decoys fill real slots beyond sender+recipient // We need genesis + sender + recipient + enough registered decoys to fill the ring. - const decoyCount = ring // a generous curation pool (more than ring-2 needed) + // Exactly ring-2: that is the ring's decoy capacity (sender and recipient hold the + // other two slots), and Strict over-supply is a hard error — this fixture originally + // supplied `ring` decoys and the surplus two were validated then silently never + // placed, the precise silent-truncation defect the slot-capacity guard now rejects. + const decoyCount = ring - 2 // Create wallets directly (the Test_Creation_TX pattern) — self-contained, no register_wallets // (which spins up RPC/XSWD servers and touches the simulator's package logger). diff --git a/walletapi/curated_drop_signal_test.go b/walletapi/curated_drop_signal_test.go new file mode 100644 index 00000000..5e82157a --- /dev/null +++ b/walletapi/curated_drop_signal_test.go @@ -0,0 +1,98 @@ +// Copyright 2017-2021 DERO Project. All rights reserved. +// Use of this source code in any form is governed by RESEARCH license. +// license can be found in the LICENSE file. + +package walletapi + +import ( + "strings" + "testing" + + "github.com/deroproject/derohe/cryptography/crypto" +) + +// Pins the lenient drop record against silent wallet-side curation loss +// (PR #22 review finding #3 / re-review O7). +// +// Lenient mode drops a decoy for four reasons: unparseable, own address, duplicate, +// or a daemon unregistered verdict. The pre-signing "reduced curation" summary is +// derived from the drops record, so a reason that does not write the record recreates +// the finding-#3 failure class for that reason: an all-dropped lenient list would sign +// a fully random ring the caller believes curated, with zero signal — reachable on a +// healthy chain with a healthy daemon (e.g. every ambient decoy from the #23 CLI +// duplicating the recipient). The three wallet-side reasons fire before any RPC, so +// they are pinned offline; the unregistered reason is exercised on the sim chain in +// curated_probe_classification_test.go. +func Test_LenientDecoyDrops_AreRecorded(t *testing.T) { + w, err := Create_Encrypted_Wallet_From_Recovery_Words_Memory("", "sequence atlas unveil summon pebbles tuesday beer rudely snake rockets different fuselage woven tagged bested dented vegan hover rapid fawns obvious muppet randomly seasons randomly") + if err != nil { + t.Fatalf("cannot create in-memory wallet: %s", err) + } + defer w.Close_Encrypted_Wallet() + + w2, err := Create_Encrypted_Wallet_From_Recovery_Words_Memory("", "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") + if err != nil { + t.Fatalf("cannot create second in-memory wallet: %s", err) + } + defer w2.Close_Encrypted_Wallet() + + var zeroscid crypto.Hash + self := w.GetAddress().String() + recipient := w2.GetAddress().String() + + // Every decoy below is rejected BEFORE the registration probe (parse, self, and + // recipient-duplicate checks are pure wallet-side validation), so this runs offline + // and no RPC error can mask a missing record. Offline, the validator's TRAILING + // Random_ring_members fetch panics on the nil rpc client — but that fetch runs after + // the whole validation loop, so every drop decision has already been recorded, which + // is exactly what this test pins. The panic is absorbed; the drops map is the output. + validateLenient := func(drops map[string]string) { + defer func() { _ = recover() }() // nil rpc client: the post-validation random fetch + if _, curated, cerr := w.curatedRingCandidates(zeroscid, recipient, &RingPreference{ + PreferredDecoys: []string{"not_an_address", self, recipient}, + }, 126, nil, drops); cerr != nil { + t.Errorf("lenient wallet-side rejections must not error, got: %v", cerr) + } else if curated != 0 { + t.Errorf("all decoys are invalid, want curated=0, got %d", curated) + } + } + drops := map[string]string{} + validateLenient(drops) + // The core O7 assertion: curation shrank to zero, and EVERY drop left a record the + // caller's summary can see — none of the three wallet-side reasons is silent. + if len(drops) != 3 { + t.Fatalf("want 3 recorded drops (unparseable/self/duplicate), got %d: %v", len(drops), drops) + } + for decoy, wantReason := range map[string]string{ + "not_an_address": "parseable", + self: "own address", + recipient: "duplicate", + } { + reason, recorded := drops[decoy] + if !recorded { + t.Fatalf("drop of %q not recorded: %v", decoy, drops) + } + if !strings.Contains(reason, wantReason) { + t.Fatalf("drop of %q recorded with reason %q, want it to name %q", decoy, reason, wantReason) + } + } + + // Re-validation must not double-count: the assembly loop calls the validator once + // per pass, and the summary reports supplied decoys, not passes. + validateLenient(drops) + if len(drops) != 3 { + t.Fatalf("drops record must stay keyed per supplied decoy across passes, got %d entries: %v", len(drops), drops) + } + + // Strict never records a drop — it hard-errors on the first bad decoy instead, so a + // Strict build can never reach the reduced-curation summary with a non-empty record. + strictDrops := map[string]string{} + if _, _, cerr := w.curatedRingCandidates(zeroscid, recipient, &RingPreference{ + PreferredDecoys: []string{"not_an_address"}, Strict: true, + }, 126, nil, strictDrops); cerr == nil { + t.Fatal("strict unparseable decoy must hard-error") + } + if len(strictDrops) != 0 { + t.Fatalf("strict mode must not record drops, got: %v", strictDrops) + } +} diff --git a/walletapi/curated_probe_classification_test.go b/walletapi/curated_probe_classification_test.go new file mode 100644 index 00000000..d1b30a8d --- /dev/null +++ b/walletapi/curated_probe_classification_test.go @@ -0,0 +1,132 @@ +// Copyright 2017-2021 DERO Project. All rights reserved. +// Use of this source code in any form is governed by RESEARCH license. +// license can be found in the LICENSE file. + +package walletapi + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/deroproject/derohe/blockchain" + "github.com/deroproject/derohe/config" + "github.com/deroproject/derohe/cryptography/crypto" + "github.com/deroproject/derohe/globals" + "github.com/deroproject/derohe/transaction" +) + +// Test_CuratedProbe_Classification locks the decoy-probe error classification +// (PR #22 review finding #3). +// +// The registration probe in curatedRingCandidates cannot see WHY +// GetEncryptedBalanceAtTopoHeight failed: for a decoy address (never self, always +// zero SCID) both a genuine "Account Unregistered" daemon verdict and a transport +// failure return as an opaque error. Treating every failure as "decoy invalid" means +// a transient daemon/RPC blip in non-Strict mode silently drops EVERY preferred decoy +// and the user signs a fully random ring believing curation was applied. +// +// Post-fix contract, pinned here: +// - "Account Unregistered" verdict → unchanged: Strict hard-errors, lenient skips; +// - any other probe failure → hard error in BOTH modes ("could not verify …"), never +// a silent drop. +// +// The transport failure is injected deterministically: the client websocket is closed +// directly, which leaves IsDaemonOnline() true (it only checks WS/RPC non-nil), so the +// failure occurs genuinely inside the probe's rpc_client.Call. Keep_Connectivity is +// deliberately NOT started — it has no quit path and would heal the connection on a +// ~5s cadence, racing the assertion. Shares the fixed rpcport — must NOT run in +// parallel with other simulator-backed tests. +func Test_CuratedProbe_Classification(t *testing.T) { + Initialize_LookupTable(1, 1<<17) + + mkwallet := func(name, words string) *Wallet_Disk { + db := filepath.Join(os.TempDir(), "probe_cls_"+name+".db") + os.Remove(db) + t.Cleanup(func() { os.Remove(db) }) + w, err := Create_Encrypted_Wallet_From_Recovery_Words(db, "QWER", words) + if err != nil { + t.Fatalf("cannot create wallet %s: %s", name, err) + } + return w + } + + wsrc := mkwallet("src", "sequence atlas unveil summon pebbles tuesday beer rudely snake rockets different fuselage woven tagged bested dented vegan hover rapid fawns obvious muppet randomly seasons randomly") + wdecoy := mkwallet("decoy", "Dekade Spagat Bereich Radclub Yeti Dialekt Unimog Nomade Anlage Hirte Besitz Märzluft Krabbe Nabel Halsader Chefarzt Hering tauchen Neuerung Reifen Umgang Hürde Alchimie Amnesie Reifen") + wgenesis := mkwallet("genesis", "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") + + // wunreg supplies a valid, never-registered address (in-memory, no chain presence). + wunreg, err := Create_Encrypted_Wallet_Random_Memory("") + if err != nil { + t.Fatalf("cannot create in-memory wallet: %s", err) + } + defer wunreg.Close_Encrypted_Wallet() + + genesis_tx := transaction.Transaction{Transaction_Prefix: transaction.Transaction_Prefix{Version: 1, Value: 2012345}} + copy(genesis_tx.MinerAddress[:], wgenesis.account.Keys.Public.EncodeCompressed()) + config.Testnet.Genesis_Tx = fmt.Sprintf("%x", genesis_tx.Serialize()) + config.Mainnet.Genesis_Tx = fmt.Sprintf("%x", genesis_tx.Serialize()) + genesis_block := blockchain.Generate_Genesis_Block() + config.Testnet.Genesis_Block_Hash = genesis_block.GetHash() + config.Mainnet.Genesis_Block_Hash = genesis_block.GetHash() + + chain, rpcserver, _ := simulator_chain_start() + defer simulator_chain_stop(chain, rpcserver) + globals.Arguments["--daemon-address"] = rpcport + + if err := chain.Add_TX_To_Pool(wsrc.GetRegistrationTX()); err != nil { + t.Fatalf("regtx src: %s", err) + } + if err := chain.Add_TX_To_Pool(wdecoy.GetRegistrationTX()); err != nil { + t.Fatalf("regtx decoy: %s", err) + } + simulator_chain_mineblock(chain, wgenesis.GetAddress(), t) + + wsrc.SetDaemonAddress(rpcport) + wsrc.SetOnlineMode() + + // Synchronous connect, no Keep_Connectivity (see doc comment). + if err := Connect(""); err != nil { + t.Fatalf("cannot connect to sim daemon: %s", err) + } + + var zeroscid crypto.Hash + registered := wdecoy.GetAddress().String() + unregistered := wunreg.GetAddress().String() + + // ── healthy connection: the verdict path is unchanged ── + + if _, curated, err := wsrc.curatedRingCandidates(zeroscid, "", &RingPreference{ + PreferredDecoys: []string{unregistered}}, 126, nil, nil); err != nil || curated != 0 { + t.Fatalf("lenient unregistered decoy must be skipped without error, got curated=%d err=%v", curated, err) + } + if _, _, err := wsrc.curatedRingCandidates(zeroscid, "", &RingPreference{ + PreferredDecoys: []string{unregistered}, Strict: true}, 126, nil, nil); err == nil || + !strings.Contains(err.Error(), "not registered") { + t.Fatalf("strict unregistered decoy must hard-error with the registration verdict, got: %v", err) + } + if _, curated, err := wsrc.curatedRingCandidates(zeroscid, "", &RingPreference{ + PreferredDecoys: []string{registered}}, 126, nil, nil); err != nil || curated != 1 { + t.Fatalf("registered decoy must validate on a healthy connection, got curated=%d err=%v", curated, err) + } + + // ── transport failure: probe errors must surface, never silently drop curation ── + + rpc_client.WS.Close() + if !IsDaemonOnline() { + t.Fatal("injection premise broken: IsDaemonOnline must stay true after WS.Close") + } + + if _, curated, err := wsrc.curatedRingCandidates(zeroscid, "", &RingPreference{ + PreferredDecoys: []string{registered}}, 126, nil, nil); err == nil || + !strings.Contains(err.Error(), "could not verify preferred decoy") { + t.Fatalf("lenient mode silently abandoned curation on a transport failure (finding #3): curated=%d err=%v", curated, err) + } + if _, _, err := wsrc.curatedRingCandidates(zeroscid, "", &RingPreference{ + PreferredDecoys: []string{registered}, Strict: true}, 126, nil, nil); err == nil || + !strings.Contains(err.Error(), "could not verify preferred decoy") { + t.Fatalf("strict mode must report the probe failure, not a registration verdict, got: %v", err) + } +} diff --git a/walletapi/curated_ring2_guard_test.go b/walletapi/curated_ring2_guard_test.go new file mode 100644 index 00000000..ac075e2f --- /dev/null +++ b/walletapi/curated_ring2_guard_test.go @@ -0,0 +1,91 @@ +// Copyright 2017-2021 DERO Project. All rights reserved. +// Use of this source code in any form is governed by RESEARCH license. +// license can be found in the LICENSE file. + +package walletapi + +import ( + "strings" + "testing" + + "github.com/deroproject/derohe/cryptography/crypto" + "github.com/deroproject/derohe/rpc" +) + +// Test_CuratedRing2Guard_FailsClosed locks the fail-closed guard for Strict decoy +// curation at ring 2 (PR #22 review finding #2). +// +// At ring 2 the ring-assembly loop (`for ringsize != 2`) never runs, so +// curatedRingCandidates — the only decoy validator — is never invoked: without an +// explicit guard, RingPreference{PreferredDecoys: [garbage], Strict: true} builds and +// can broadcast with no error, while the identical input at ring 4 is a hard error. +// The guard rejects Strict curation before the ringsize branch. Non-Strict curation at +// ring 2 stays permitted (documented lenient contract; decoys are unplaceable and the +// build proceeds without them). +// +// Like the anonymous-attribution ring-2 guard, this is pure parameter validation that +// fires BEFORE any daemon/network call — offline in-memory wallet, no chain. +func Test_CuratedRing2Guard_FailsClosed(t *testing.T) { + w, err := Create_Encrypted_Wallet_From_Recovery_Words_Memory("", "sequence atlas unveil summon pebbles tuesday beer rudely snake rockets different fuselage woven tagged bested dented vegan hover rapid fawns obvious muppet randomly seasons randomly") + if err != nil { + t.Fatalf("cannot create in-memory wallet: %s", err) + } + defer w.Close_Encrypted_Wallet() + + dest := w.GetAddress().String() // any valid address; the guard fires before it is used + + strictGarbage := TransferOptions{Ring: &RingPreference{ + PreferredDecoys: []string{"not_an_address"}, + Strict: true, + }} + + // Strict curation at ring 2 MUST hard-error before building, naming the ring-size cause. + tx, err := w.TransferPayload0WithOptions( + []rpc.Transfer{{Destination: dest, Amount: 1}}, 2, false, rpc.Arguments{}, 0, false, strictGarbage) + if err == nil || tx != nil { + t.Fatalf("strict curation at ring 2 MUST fail closed before building, got err=%v tx=%v", err, tx != nil) + } + if !strings.Contains(err.Error(), "strict decoy curation requires ring size") { + t.Fatalf("ring-2 strict-curation rejection should explain the ring-size cause, got: %v", err) + } + + // non-Strict decoys at ring 2 must NOT trip the guard (lenient contract: decoys are + // unplaceable and dropped; the build errors later on the offline network call). + lenient := TransferOptions{Ring: &RingPreference{PreferredDecoys: []string{"not_an_address"}}} + if _, lerr := w.TransferPayload0WithOptions( + []rpc.Transfer{{Destination: dest, Amount: 1}}, 2, false, rpc.Arguments{}, 0, false, lenient); lerr != nil && + strings.Contains(lerr.Error(), "strict decoy curation requires ring size") { + t.Fatalf("non-Strict curation at ring 2 wrongly tripped the strict-curation guard: %v", lerr) + } + + // Strict at ring 4 must NOT trip the ring-2 guard (it proceeds past the guard and fails + // later — offline network gate or decoy validation — never with the guard message). + if _, serr := w.TransferPayload0WithOptions( + []rpc.Transfer{{Destination: dest, Amount: 1}}, 4, false, rpc.Arguments{}, 0, false, strictGarbage); serr != nil && + strings.Contains(serr.Error(), "strict decoy curation requires ring size") { + t.Fatalf("strict curation at ring 4 wrongly tripped the ring-2 guard: %v", serr) + } + + // A nil Ring and an empty PreferredDecoys slice curate nothing — the guard must not fire. + for _, opts := range []TransferOptions{{}, {Ring: &RingPreference{Strict: true}}} { + if _, nerr := w.TransferPayload0WithOptions( + []rpc.Transfer{{Destination: dest, Amount: 1}}, 2, false, rpc.Arguments{}, 0, false, opts); nerr != nil && + strings.Contains(nerr.Error(), "strict decoy curation requires ring size") { + t.Fatalf("empty curation at ring 2 wrongly tripped the strict-curation guard: %v", nerr) + } + } + + // Validation parity when curatedRingCandidates IS invoked (ring-agnostic, no daemon): + // parse and self checks fire before any RPC, so they are directly testable offline. + var zeroscid crypto.Hash + if _, _, cerr := w.curatedRingCandidates(zeroscid, "", &RingPreference{ + PreferredDecoys: []string{"not_an_address"}, Strict: true}, 126, nil, nil); cerr == nil || + !strings.Contains(cerr.Error(), "not a valid address") { + t.Fatalf("strict garbage decoy must fail parse validation, got: %v", cerr) + } + if _, _, cerr := w.curatedRingCandidates(zeroscid, "", &RingPreference{ + PreferredDecoys: []string{w.GetAddress().String()}, Strict: true}, 126, nil, nil); cerr == nil || + !strings.Contains(cerr.Error(), "your own address") { + t.Fatalf("strict self decoy must fail validation, got: %v", cerr) + } +} diff --git a/walletapi/curated_slot_cap_test.go b/walletapi/curated_slot_cap_test.go new file mode 100644 index 00000000..bd6ee0b1 --- /dev/null +++ b/walletapi/curated_slot_cap_test.go @@ -0,0 +1,113 @@ +// Copyright 2017-2021 DERO Project. All rights reserved. +// Use of this source code in any form is governed by RESEARCH license. +// license can be found in the LICENSE file. + +package walletapi + +import ( + "strings" + "testing" + + "github.com/deroproject/derohe/cryptography/crypto" + "github.com/deroproject/derohe/rpc" +) + +// Test_CuratedDecoySlotCap pins the decoy slot-capacity contract (PR #22 re-review O9). +// +// A ring places at most ringsize-2 curated decoys (sender and recipient hold the other +// two positions). Before this fix, VALID decoys beyond that capacity were accepted by +// validation and then simply never read by the assembly loop (it stops the instant the +// ring fills) — a silent curation shortfall the drop funnel could not see, because the +// funnel only recorded REJECTED decoys. Post-fix contract: +// - Strict over-supply is a hard error, both at the TransferPayload0WithOptions +// pre-guard (supplied count vs slots) and inside curatedRingCandidates (defense in +// depth for direct callers); +// - lenient over-supply drops the surplus WITH a per-decoy record, feeding the +// pre-signing "reduced curation" summary — never a silent truncation. +// +// Offline, no daemon: the registration probe is bypassed with a pre-seeded verdicts +// memo (the memo is keyed on the canonical base form, exactly as the validator writes +// it), so the slot logic is exercised in isolation. +func Test_CuratedDecoySlotCap(t *testing.T) { + w, err := Create_Encrypted_Wallet_From_Recovery_Words_Memory("", "sequence atlas unveil summon pebbles tuesday beer rudely snake rockets different fuselage woven tagged bested dented vegan hover rapid fawns obvious muppet randomly seasons randomly") + if err != nil { + t.Fatalf("cannot create in-memory wallet: %s", err) + } + defer w.Close_Encrypted_Wallet() + + mkdecoy := func() string { + wd, err := Create_Encrypted_Wallet_Random_Memory("") + if err != nil { + t.Fatalf("cannot create decoy wallet: %s", err) + } + t.Cleanup(wd.Close_Encrypted_Wallet) + return wd.GetAddress().String() + } + d1, d2 := mkdecoy(), mkdecoy() + + // canonical base form, exactly as curatedRingCandidates stores verdicts. + canonOf := func(a string) string { + addr, err := rpc.NewAddress(a) + if err != nil { + t.Fatalf("bad decoy fixture %q: %s", a, err) + } + canon := addr.BaseAddress() + canon.Proof = false + canon.Mainnet = w.GetNetwork() + return canon.String() + } + verdicts := map[string]bool{canonOf(d1): true, canonOf(d2): true} // both "registered" + + var zeroscid crypto.Hash + + // ── lenient over-supply: surplus decoy is dropped WITH a record, curation = slots ── + drops := map[string]string{} + func() { + defer func() { _ = recover() }() // nil rpc client: the post-validation random fetch + alist, curated, cerr := w.curatedRingCandidates(zeroscid, "", &RingPreference{ + PreferredDecoys: []string{d1, d2}}, 1 /* slots */, verdicts, drops) + if cerr != nil { + t.Errorf("lenient over-supply must not error, got: %v", cerr) + } + if curated != 1 || len(alist) < 1 || alist[0] != canonOf(d1) { + t.Errorf("want the first decoy placed and curated=1, got curated=%d alist=%v", curated, alist) + } + }() + reason, recorded := drops[d2] + if len(drops) != 1 || !recorded || !strings.Contains(reason, "no decoy slot") { + t.Fatalf("surplus valid decoy must be recorded dropped with the slot reason, got: %v", drops) + } + + // ── strict over-supply inside the validator: hard error, nothing placed ── + if _, _, cerr := w.curatedRingCandidates(zeroscid, "", &RingPreference{ + PreferredDecoys: []string{d1, d2}, Strict: true}, 1 /* slots */, verdicts, nil); cerr == nil || + !strings.Contains(cerr.Error(), "too many preferred decoys") { + t.Fatalf("strict over-supply must hard-error on the slot cap, got: %v", cerr) + } + + // ── strict over-supply pre-guard: count-checked before any validation or RPC ── + dest := w.GetAddress().String() // any parseable destination; the guard fires first + if _, gerr := w.TransferPayload0WithOptions( + []rpc.Transfer{{Destination: dest, Amount: 1}}, 4, false, rpc.Arguments{}, 0, false, + TransferOptions{Ring: &RingPreference{PreferredDecoys: []string{d1, d2, "third"}, Strict: true}}); gerr == nil || + !strings.Contains(gerr.Error(), "too many preferred decoys for ring size 4") { + t.Fatalf("strict 3 decoys at ring 4 (2 slots) must fail the pre-guard, got: %v", gerr) + } + + // exactly filling the slots must NOT trip the guard (it proceeds and fails later, + // offline, with an unrelated error). + if _, gerr := w.TransferPayload0WithOptions( + []rpc.Transfer{{Destination: dest, Amount: 1}}, 4, false, rpc.Arguments{}, 0, false, + TransferOptions{Ring: &RingPreference{PreferredDecoys: []string{d1, d2}, Strict: true}}); gerr != nil && + strings.Contains(gerr.Error(), "too many preferred decoys") { + t.Fatalf("strict 2 decoys at ring 4 wrongly tripped the slot pre-guard: %v", gerr) + } + + // lenient over-supply must NOT trip the guard either (surplus is dropped/recorded). + if _, gerr := w.TransferPayload0WithOptions( + []rpc.Transfer{{Destination: dest, Amount: 1}}, 4, false, rpc.Arguments{}, 0, false, + TransferOptions{Ring: &RingPreference{PreferredDecoys: []string{d1, d2, "third"}}}); gerr != nil && + strings.Contains(gerr.Error(), "too many preferred decoys") { + t.Fatalf("lenient over-supply at ring 4 wrongly tripped the strict slot pre-guard: %v", gerr) + } +} diff --git a/walletapi/daemon_communication.go b/walletapi/daemon_communication.go index 80c551d3..fae64994 100644 --- a/walletapi/daemon_communication.go +++ b/walletapi/daemon_communication.go @@ -450,6 +450,13 @@ func (w *Wallet_Memory) GetSelfEncryptedBalanceAtTopoHeight(scid crypto.Hash, to return } +// isUnregisteredError reports whether err carries the daemon's account-unregistered +// verdict, as opposed to a transport/daemon failure where no verdict was reached. +// Same detection idiom as the unregistered special-cases below. +func isUnregisteredError(err error) bool { + return err != nil && strings.Contains(strings.ToLower(err.Error()), strings.ToLower(errormsg.ErrAccountUnregistered.Error())) +} + // this is as simple as it gets // single threaded communication gets whether the the key image is spent in pool or in blockchain // this can leak informtion which keyimage belongs to us diff --git a/walletapi/rpc_call_timeout_test.go b/walletapi/rpc_call_timeout_test.go index c9caf4eb..1e2c1140 100644 --- a/walletapi/rpc_call_timeout_test.go +++ b/walletapi/rpc_call_timeout_test.go @@ -6,6 +6,7 @@ package walletapi import ( "io" + "strings" "testing" "time" @@ -85,6 +86,8 @@ func Test_CallWithTimeout_HungDaemon(t *testing.T) { start = time.Now() if _, _, _, _, berr := w.GetEncryptedBalanceAtTopoHeight(zeroscid, -1, w.GetAddress().String()); berr == nil { t.Fatal("balance fetch against a hung daemon must error at the deadline") + } else if isUnregisteredError(berr) { + t.Fatalf("a deadline error must never read as an unregistered verdict, got: %v", berr) } if elapsed := time.Since(start); elapsed > 5*time.Second { t.Fatalf("balance fetch not bounded by the deadline: took %s", elapsed) @@ -109,5 +112,15 @@ func Test_CallWithTimeout_HungDaemon(t *testing.T) { t.Fatalf("name resolution not bounded by the deadline: took %s", elapsed) } - _ = wdecoy // the decoy-probe classification assertion arrives with review #3's classifier + // the decoy probe classifies the deadline as a transport failure in BOTH modes — + // hard error, never a silent lenient drop (finding #3 contract under O10's daemon). + start = time.Now() + if _, _, cerr := w.curatedRingCandidates(zeroscid, "", &RingPreference{ + PreferredDecoys: []string{wdecoy.GetAddress().String()}}, 126, nil, nil); cerr == nil || + !strings.Contains(cerr.Error(), "could not verify preferred decoy") { + t.Fatalf("hung probe must surface as 'could not verify', got: %v", cerr) + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("decoy probe not bounded by the deadline: took %s", elapsed) + } } diff --git a/walletapi/transaction_build.go b/walletapi/transaction_build.go index e573c970..198b5322 100644 --- a/walletapi/transaction_build.go +++ b/walletapi/transaction_build.go @@ -63,16 +63,44 @@ type RingPreference struct { // tree before use. Addresses the user controls must NOT be supplied here — curating // your own addresses collapses your anonymity set. // + // Slot capacity: a ring places at most ringsize-2 decoys (the sender and the + // recipient hold the other two positions). Supplying more than fits is a hard error + // in Strict mode ("too many preferred decoys for ring size …"); in lenient mode the + // surplus is dropped with a per-decoy log record and counted in the pre-signing + // "reduced curation" summary — never placed silently short. + // // Ring composition on a scarce tree: when the transfer SCID's own tree cannot supply - // enough random members (its random tail is <= 40), assembly falls back to base-tree - // members exactly as it does without curation — for a non-zero SCID those fill token - // slots with synthesized zero balances. Curated decoys do not widen this behavior and - // do not count toward the scarcity measurement. If even the fallback cannot fill the - // ring, assembly fails with an explicit pool-exhaustion error instead of retrying - // forever (Strict only governs per-decoy validation, never composition). + // enough random members — its random tail is <= 40, OR assembly observes it has + // stalled (consecutive passes adding no new member) — assembly falls back to + // base-tree members exactly as it does without curation; for a non-zero SCID those + // fill token slots with synthesized zero balances. Curated decoys do not widen this + // behavior and do not count toward the scarcity measurement. If even the fallback + // cannot fill the ring within the documented pass/backoff bounds, assembly fails + // with an explicit pool-exhaustion error instead of retrying forever. Strict governs + // per-decoy validation, not composition — with two fail-closed exceptions: Strict + // curation at ring 2 is rejected outright (a ring with no decoy slots cannot honor + // the curation it was asked to validate), and Strict over-supply beyond ringsize-2 + // slots is rejected outright (see slot capacity above). Lenient curation in both + // cases proceeds without the unplaceable decoys, each drop recorded and summarized. + // + // At most 256 entries (2× the maximum ringsize; a ring holds at most ringsize-2 + // decoys). Exceeding the cap is a hard error in BOTH modes: each decoy costs a + // registration RPC under the wallet's transfer mutex, so list length is request + // validation, not decoy quality. PreferredDecoys []string // Strict: if true, a bad preferred decoy (unparseable / self / duplicate / unregistered) // is a hard error. If false (default), it is skipped and random selection fills the slot. + // Either way, a registration probe that FAILS (daemon/transport fault — no verdict) is a + // hard error: a transient blip must not silently strip curation from the ring. + // + // TRUST NOTE — the "unregistered" verdict itself comes from the connected daemon and + // the wallet cannot check it independently. In lenient mode a malicious daemon can + // therefore veto curated decoys by answering unregistered; the wallet cannot prevent + // that, but it never lets it pass silently (per-decoy V(1) log plus a default-verbosity + // summary before signing). Callers who require the build to FAIL rather than degrade + // when the daemon disputes their decoys must set Strict — that is the fail-closed mode + // against a decoy-vetoing daemon. (A daemon that hostile already controls all RANDOM + // member selection and every balance the wallet sees; run your own node.) Strict bool } diff --git a/walletapi/wallet_transfer.go b/walletapi/wallet_transfer.go index 218828a3..ecf4995e 100644 --- a/walletapi/wallet_transfer.go +++ b/walletapi/wallet_transfer.go @@ -87,9 +87,39 @@ func (w *Wallet_Memory) TransferPayload0(transfers []rpc.Transfer, ringsize uint // rejected as a duplicate. Empty means "no recipient seed". // // curated reports how many validated preferred decoys lead alist, so the caller can -// measure candidate scarcity on the random tail alone (the count varies per call in -// non-Strict mode: a failed registration probe silently drops a decoy for that call). -func (w *Wallet_Memory) curatedRingCandidates(scid crypto.Hash, recipientAddr string, pref *RingPreference) (alist []string, curated int, err error) { +// measure candidate scarcity on the random tail alone. The count is measured from the +// validated list, not derived from len(PreferredDecoys): in non-Strict mode a decoy is +// dropped when it is unparseable, the wallet's own address, a duplicate, or judged +// unregistered by the daemon — EVERY drop, whatever the reason, is recorded in drops and +// logged (a lenient build whose curation shrank must never look identical to one whose +// curation fully applied; review #3 / re-review O7). A probe that FAILS — as opposed to +// returning an unregistered verdict — errors in both modes; it never silently shrinks +// the curation (review #3). +// +// verdicts, if non-nil, memoizes registration verdicts (key: canonical base address, +// value: registered) across calls within ONE transfer build, so a pass costs O(1) probes +// after the first instead of O(len(PreferredDecoys)) — the assembly loop may run up to +// maxTotalRingPassesFactor×ringsize passes, and registration probes are sequential +// RPCs issued under +// transfer_mutex. Memoizing is sound within a build: registration is permanent, and an +// unregistered verdict going stale for the few seconds of one build only means the decoy +// is picked up on the NEXT send. Pass nil to force fresh probes. +// +// drops, if non-nil, accumulates lenient drops across calls within one build (key: the +// supplied decoy string, value: the reason) — the caller derives its pre-signing +// "reduced curation" summary from it, and it doubles as the once-per-build log +// deduplicator (the assembly loop re-validates decoys every pass; only the first drop +// of each decoy is logged). With drops == nil every drop is logged on every call. +// Strict mode never records a drop: it hard-errors on the first bad decoy instead. +// +// slots is the ring's decoy capacity (ringsize-2: the sender and the recipient hold the +// other two positions). Validation stops accepting decoys once slots are filled: the +// assembly loop places the curated head in order and stops at a full ring, so a decoy +// past the capacity would be validated (one RPC) and then silently never placed — a +// curation shortfall with no signal (re-review O9). Overflow is a hard error in Strict +// mode (also pre-checked by TransferPayload0WithOptions against the supplied count) and +// a recorded, logged drop in lenient mode. +func (w *Wallet_Memory) curatedRingCandidates(scid crypto.Hash, recipientAddr string, pref *RingPreference, slots int, verdicts map[string]bool, drops map[string]string) (alist []string, curated int, err error) { if pref == nil { return w.Random_ring_members(scid), 0, nil } @@ -116,12 +146,35 @@ func (w *Wallet_Memory) curatedRingCandidates(scid crypto.Hash, recipientAddr st } } + // recordDecoyDrop is the single funnel for LENIENT drops: every rejected decoy is + // logged and (when the caller keeps a drops record) accumulated for the pre-signing + // summary, whatever the rejection reason — wallet-side validation (parse/self/dup) + // and the daemon's unregistered verdict alike. A drop that only some reasons report + // recreates the silent-degradation bug for the unreported reasons (re-review O7). + recordDecoyDrop := func(d, reason string) { + if drops != nil { + if _, already := drops[d]; already { + return // logged on first sight; the loop re-validates every pass + } + drops[d] = reason + } + logger.V(1).Info("preferred decoy dropped", "decoy", d, "reason", reason) + } + for _, d := range pref.PreferredDecoys { + if len(alist) >= slots { // every decoy slot is spoken for: anything further cannot ride + if pref.Strict { // defense in depth: the caller pre-checks the supplied count + return nil, 0, fmt.Errorf("too many preferred decoys: only %d decoy slots at this ring size", slots) + } + recordDecoyDrop(d, fmt.Sprintf("no decoy slot left (this ring holds %d decoys)", slots)) + continue + } addr, e := rpc.NewAddress(d) if e != nil { // must be a parseable address if pref.Strict { return nil, 0, fmt.Errorf("preferred decoy is not a valid address: %s", d) } + recordDecoyDrop(d, "not a parseable address") continue } key := pkKey(addr) // pubkey is the network-/proof-/integrated-agnostic identity @@ -129,12 +182,14 @@ func (w *Wallet_Memory) curatedRingCandidates(scid crypto.Hash, recipientAddr st if pref.Strict { return nil, 0, fmt.Errorf("preferred decoy cannot be your own address") } + recordDecoyDrop(d, "own address") continue } if seen[key] { // distinctness (consensus rejects duplicate ring members) if pref.Strict { return nil, 0, fmt.Errorf("duplicate preferred decoy: %s", d) } + recordDecoyDrop(d, "duplicate of the sender, recipient, or another decoy") continue } // The ring carries a normal BASE address (Arguments cleared, Proof cleared, network @@ -146,11 +201,41 @@ func (w *Wallet_Memory) curatedRingCandidates(scid crypto.Hash, recipientAddr st canon.Mainnet = w.GetNetwork() base := canon.String() // registration: probe the BASE balance tree, the tree consensus checks against. - if _, _, _, _, e := w.GetEncryptedBalanceAtTopoHeight(zeroscid, -1, base); e != nil { + // The probe error is CLASSIFIED: only the daemon's explicit unregistered verdict is + // a judgment on the decoy (Strict: hard error; lenient: skip). Any other failure — + // offline, transport, daemon fault — is an unknown verdict and errors in BOTH modes: + // treating it as "invalid decoy" would let a transient blip silently strip every + // curated decoy and ship a fully random ring the user believes is curated. + if reg, cached := verdicts[base]; cached { + if !reg { + if pref.Strict { + return nil, 0, fmt.Errorf("preferred decoy is not registered: %s", d) + } + // recorded again because drops is keyed on the SUPPLIED string: the same + // unregistered pubkey under a second encoding shares the verdict memo but + // is a distinct supplied decoy the summary must count. + recordDecoyDrop(d, "daemon reports it unregistered") + continue + } + } else if _, _, _, _, e := w.GetEncryptedBalanceAtTopoHeight(zeroscid, -1, base); e != nil { + if !isUnregisteredError(e) { + return nil, 0, fmt.Errorf("could not verify preferred decoy %s: %s — retry the send", d, e) + } + if verdicts != nil { + verdicts[base] = false + } if pref.Strict { return nil, 0, fmt.Errorf("preferred decoy is not registered: %s", d) } + // The unregistered verdict comes solely from the daemon — the wallet has no + // independent view of the tree — so a lenient drop must never be silent: a + // daemon falsely vetoing curated decoys would otherwise strip curation with + // zero signal. Recorded per decoy (logged once per build via drops) and + // summarized at default verbosity by the caller. + recordDecoyDrop(d, "daemon reports it unregistered") continue + } else if verdicts != nil { + verdicts[base] = true } seen[key] = true alist = append(alist, base) // ring carries the canonical base form @@ -251,6 +336,13 @@ const maxRingBuildStallBudget = 150 * time.Second // previously-usable input. Exported: it is part of the build API contract. const MaxTransfersPerBuild = 256 +// maxPreferredDecoys bounds request size: preferred decoys are validated with sequential +// registration RPCs under transfer_mutex, so the list length is a cost dimension the +// caller controls. 256 = 2× the maximum legal ringsize — far above any usable curation +// (decoy slots max out at ringsize-2 = 126) while cutting off degenerate inputs. Exceeding +// it is request validation, not decoy quality, so it errors in BOTH modes. +const maxPreferredDecoys = 256 + // barrenRingSleep is the backoff before retrying after the barren-th consecutive // fruitless pass: 250ms, 500ms, 1s, 2s, then 4s flat (see the bounds comment above). func barrenRingSleep(barren int) time.Duration { @@ -308,6 +400,58 @@ func (w *Wallet_Memory) TransferPayload0WithOptions(transfers []rpc.Transfer, ri return } + // request-size validation, both modes: see maxPreferredDecoys. + if opts.Ring != nil && len(opts.Ring.PreferredDecoys) > maxPreferredDecoys { + err = fmt.Errorf("too many preferred decoys: %d (maximum %d; a ring holds at most %d decoys)", len(opts.Ring.PreferredDecoys), maxPreferredDecoys, config.MAX_RINGSIZE-2) + return + } + + // per-build registration-verdict memo for curated decoys (see curatedRingCandidates): + // caps validation cost at one probe per distinct decoy per build. decoy_drops is the + // per-build record of EVERY lenient drop with its reason — wallet-side rejections + // (unparseable/self/duplicate/no free slot) and daemon unregistered verdicts alike — + // feeding the pre-signing summary log after ring assembly (re-review O7). decoy_slots + // is the ring's decoy capacity: sender and recipient hold the other two positions. + var decoy_verdicts map[string]bool + var decoy_drops map[string]string + decoy_slots := int(ringsize) - 2 + if opts.Ring != nil && len(opts.Ring.PreferredDecoys) > 0 { + decoy_verdicts = map[string]bool{} + decoy_drops = map[string]string{} + } + + // fail closed on Strict decoy curation at ring 2: the assembly loop ("for ringsize != 2") + // never runs, so curatedRingCandidates — the only decoy validator — is never invoked and + // Strict's hard-error contract would be silently void (garbage decoys build and broadcast + // at ring 2 while the identical input at ring 4 hard-errors). Lenient curation stays + // permitted: its documented contract is silent drop, which a ring with no decoy slots + // satisfies — every decoy is recorded dropped so the pre-signing reduced-curation + // summary fires at default verbosity (re-review O9 closed the log-only gap). + if opts.Ring != nil && len(opts.Ring.PreferredDecoys) > 0 && ringsize < 3 { + if opts.Ring.Strict { + err = fmt.Errorf("strict decoy curation requires ring size >= 4; ring size %d has no decoy slots", ringsize) + return + } + for _, d := range opts.Ring.PreferredDecoys { + if _, already := decoy_drops[d]; !already { + decoy_drops[d] = "no decoy slot left (ring size 2 holds no decoys)" + logger.V(1).Info("preferred decoy dropped", "decoy", d, "reason", "ring size 2 holds no decoys") + } + } + } + + // fail closed on Strict over-supply at ANY ring size: a ring places at most + // decoy_slots curated decoys; the surplus would be validated and then silently + // never placed — the caller asked for a curation the ring cannot physically honor + // (re-review O9). Checked on the SUPPLIED count, which for a Strict list that can + // build at all equals the validated count (duplicates/garbage already hard-error). + // Lenient over-supply proceeds: the surplus is dropped with a per-decoy record and + // the pre-signing reduced-curation summary. + if opts.Ring != nil && opts.Ring.Strict && ringsize >= 3 && len(opts.Ring.PreferredDecoys) > decoy_slots { + err = fmt.Errorf("too many preferred decoys for ring size %d: %d supplied but only %d decoy slots (sender and recipient hold the other two) — raise the ring size or trim the list", ringsize, len(opts.Ring.PreferredDecoys), decoy_slots) + return + } + //ringsize = 2 // if wallet is online,take the fees from the network itself @@ -589,7 +733,7 @@ func (w *Wallet_Memory) TransferPayload0WithOptions(transfers []rpc.Transfer, ri // (curatedRingCandidates prepends them regardless of tree). var probable_members []string if !base_rescue { - members, curated, cerr := w.curatedRingCandidates(transfers[t].SCID, receiver_without_payment_id.String(), opts.Ring) + members, curated, cerr := w.curatedRingCandidates(transfers[t].SCID, receiver_without_payment_id.String(), opts.Ring, decoy_slots, decoy_verdicts, decoy_drops) if cerr != nil { err = cerr return @@ -607,7 +751,7 @@ func (w *Wallet_Memory) TransferPayload0WithOptions(transfers []rpc.Transfer, ri } if base_rescue { var zeroscid crypto.Hash - base_members, _, berr := w.curatedRingCandidates(zeroscid, receiver_without_payment_id.String(), opts.Ring) + base_members, _, berr := w.curatedRingCandidates(zeroscid, receiver_without_payment_id.String(), opts.Ring, decoy_slots, decoy_verdicts, decoy_drops) if berr != nil { err = berr return @@ -696,6 +840,21 @@ func (w *Wallet_Memory) TransferPayload0WithOptions(transfers []rpc.Transfer, ri } max_bits_array = append(max_bits_array, max_bits) } + + // Lenient curation signal (Strict never reaches here with a drop — it errors): if ANY + // curated decoy was dropped — unparseable, self, duplicate, or daemon-judged + // unregistered — say so at default verbosity BEFORE the transaction is signed. A build + // whose curation was reduced must never look identical to one whose curation fully + // applied, whatever the drop reason: an all-dropped lenient list otherwise signs a + // fully random ring the caller believes curated (re-review O7). + if len(decoy_drops) > 0 { + reasons := map[string]int{} + for _, r := range decoy_drops { + reasons[r]++ + } + logger.Info("some preferred decoys were dropped — ring built with reduced curation", "dropped", len(decoy_drops), "supplied", len(opts.Ring.PreferredDecoys), "reasons", fmt.Sprintf("%v", reasons)) + } + max_bits := 0 for i := range max_bits_array { if max_bits < max_bits_array[i] { From 8150d1e0d209e5939f1a5f677b2b94c2dc6cb4d7 Mon Sep 17 00:00:00 2001 From: DHEBP <152355273+DHEBP@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:21:05 -0400 Subject: [PATCH 12/15] feat(wallet-cli): opt-in anonymous attribution + curated decoys (red-team hardened) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI surface for the #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 #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. --- cmd/dero-wallet-cli/anonymity.go | 326 +++++++++++++++++++ cmd/dero-wallet-cli/anonymity_test.go | 365 ++++++++++++++++++++++ cmd/dero-wallet-cli/easymenu_post_open.go | 32 +- cmd/dero-wallet-cli/easymenu_pre_open.go | 16 + cmd/dero-wallet-cli/main.go | 2 + cmd/dero-wallet-cli/prompt.go | 26 ++ 6 files changed, 765 insertions(+), 2 deletions(-) create mode 100644 cmd/dero-wallet-cli/anonymity.go create mode 100644 cmd/dero-wallet-cli/anonymity_test.go diff --git a/cmd/dero-wallet-cli/anonymity.go b/cmd/dero-wallet-cli/anonymity.go new file mode 100644 index 00000000..16c6a070 --- /dev/null +++ b/cmd/dero-wallet-cli/anonymity.go @@ -0,0 +1,326 @@ +// Copyright 2017-2021 DERO Project. All rights reserved. +// Use of this source code in any form is governed by RESEARCH license. +// license can be found in the LICENSE file. +// GPG: 0F39 E425 8C65 3947 702A 8234 08B2 0360 A03A 9DE8 +// +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY +// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +// THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF +// THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package main + +import ( + "encoding/hex" + "fmt" + "strings" + + "github.com/chzyer/readline" + "github.com/deroproject/derohe/cryptography/crypto" + "github.com/deroproject/derohe/globals" + "github.com/deroproject/derohe/rpc" + "github.com/deroproject/derohe/transaction" + "github.com/deroproject/derohe/walletapi" +) + +// session-scoped sticky default for "Anonymize sender?". NOT persisted across +// wallet close/reopen (would require an engine Account field, which is forbidden). +// Seeded by --anonymous at startup; toggled by "set anonymous on/off". +var anonymize_default bool + +// session-scoped preferred decoys seeded by --decoys; used as prompt defaults. +var decoys_default []string + +// buildTransferOptions constructs opts honoring the no-op contract: when anonymize +// is false AND members is empty, it returns a LITERAL zero value, so Ring stays nil +// (the engine fast path) and Attribution stays AttributionHonest. opts.Ring is +// assigned ONLY when len(members) > 0 — a non-nil empty Ring pointer would change +// the engine code path and break byte-identity with today's behavior. +func buildTransferOptions(anonymize bool, members []string) walletapi.TransferOptions { + opts := walletapi.TransferOptions{} + if anonymize { + opts.Attribution = walletapi.AttributionAnonymous + } + if len(members) > 0 { + opts.Ring = &walletapi.RingPreference{PreferredDecoys: members, Strict: false} + } + return opts +} + +// anonymizeEffectiveAtRingsize reports whether AttributionAnonymous can actually +// take effect at the given effective ringsize. Anonymous attribution needs a decoy +// slot beyond [0]=sender and [1]=receiver; the ring is a power of 2, so ringsize 2 +// has none and 4 is the first that does. This mirrors the cli-head engine gate +// (transaction_build.go: `len(witness_index) > 2`) AND the rebase-target hard guard +// (wallet_transfer.go: `ringsize < 3` => error). It is the single source of truth the +// CLI uses to decide what it PROMISES the user, so the promise can never outrun the tx. +// +// O10 (latent assumption — re-verify post-rebase): this reads the REQUESTED ringsize +// (wallet.GetRingSize()), not the as-built witness count. The coherence "report never +// outruns the tx" depends on the engine FAILING the build if it cannot fill the ring to +// the requested size — and it does today: the ring-assembly loop (wallet_transfer.go: +// 402-448) has NO exhaustion exit; it either reaches the full ring (goto :442) or errors +// at GetEncryptedBalanceAtTopoHeight/NewAddress (:429-434) on an unregistered/insufficient +// member, an error the CLI surfaces ("Error while building Transaction") and aborts on. +// So a silently-shrunk ring under an ANONYMOUS label cannot ship. If a future engine +// change ever allowed a silently-smaller ring (e.g. best-effort fill), THIS would become +// a real false-anonymity and the truth-source here must switch to the built witness count. +func anonymizeEffectiveAtRingsize(ringsize int) bool { + return ringsize >= 4 +} + +// resolveAnonymizeOrDowngrade enforces the prompt's promise against the engine reality. +// When the user asked to anonymize but the effective ringsize has no decoy slot, the +// engine would ship an HONEST (verifiably-attributed) transfer (cli-head) or hard-error +// (post-rebase). Either way the user's belief "I am hidden" is false. So the CLI +// fails closed: it DOWNGRADES anonymize to false here and tells the user plainly, so +// what is built, broadcast, and reported all agree (criterion 1). Returns the effective +// anonymize flag the rest of the flow must use. +func resolveAnonymizeOrDowngrade(anonymize bool) bool { + if anonymize && !anonymizeEffectiveAtRingsize(wallet.GetRingSize()) { + logger.Info(color_yellow + + "Anonymize CANCELLED: ringsize " + fmt.Sprintf("%d", wallet.GetRingSize()) + + " has no decoy slot (needs 4 or higher). This transfer will ship HONEST — " + + "your sender address IS visible to the receiver. Run 'set ringsize 16' and " + + "resend if you want to be anonymized." + color_normal) + return false + } + return anonymize +} + +// canonBase reduces an address string to its canonical base (pubkey) form for +// distinctness comparison, matching the engine's curatedRingCandidates. Unparseable +// or empty input yields "" (which never matches a real seeded key). +func canonBase(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + a, err := globals.ParseValidateAddress(raw) + if err != nil { + return "" + } + return a.BaseAddress().String() +} + +// collectDecoys prompts for base-address decoys one per line until a blank line. +// Each entry is validated at prompt time: parseable, not your own address, not an +// integrated address, no duplicates. Bad entries are reported and skipped (the loop +// simply does not advance). Returns the accepted base-address strings. The engine +// re-validates registration/base-tree at send time (Strict:false skips the rest), +// so this is fast immediate feedback, not the security boundary. +// decoyCollector holds the validation + dedup state for curated decoys. It is the +// pure, readline-free core so the security-relevant logic (canonicalization, the +// sender/recipient/duplicate rejection of O5) is unit-testable without a terminal. +type decoyCollector struct { + self string // canonical base of the sender + seen map[string]bool + members []string +} + +// newDecoyCollector seeds the seen-set with the sender AND recipient canonical bases: +// both are already real members of the ring, so curating either (under ANY encoding) +// as a "decoy" creates a duplicate pubkey that consensus rejects after signing. This +// is the ONLY prompt-time guard for the recipient case on cli-head (the engine +// recipient-seed lives in the rebase target only), so it must hold here (O5). +func newDecoyCollector(recipient string) *decoyCollector { + self := canonBase(wallet.GetAddress().String()) + seen := map[string]bool{} + if self != "" { + seen[self] = true + } + if rb := canonBase(recipient); rb != "" { + seen[rb] = true + } + return &decoyCollector{self: self, seen: seen} +} + +// add validates one raw entry and appends its canonical base form on success. Bad +// entries are reported and skipped. Distinctness is by PUBKEY (BaseAddress), not the +// raw network-tagged string, matching the engine fix (curatedRingCandidates): a +// raw-string seen-map misses an alt-encoding of the sender, recipient, or an already +// curated decoy. +func (c *decoyCollector) add(raw string) { + raw = strings.TrimSpace(raw) + if raw == "" { + return + } + a, err := globals.ParseValidateAddress(raw) + if err != nil { + logger.Error(err, "Not a valid address; skipping decoy", "decoy", raw) + return + } + if a.IsIntegratedAddress() { + logger.Error(fmt.Errorf("integrated address"), "Decoy must be a base address (no payment id); skipping", "decoy", raw) + return + } + base := a.BaseAddress().String() // canonical pubkey identity + if base == c.self { + logger.Error(fmt.Errorf("own address"), "A decoy cannot be your own address; skipping") + return + } + if c.seen[base] { + // covers exact dupes, the recipient (incl. an alt-encoding of it), and the + // sender under any encoding — all are already in the ring. + logger.Error(fmt.Errorf("duplicate ring member"), "Decoy is already in the ring (recipient/sender/duplicate); skipping", "decoy", raw) + return + } + c.seen[base] = true + c.members = append(c.members, base) // carry the canonical base form, as the engine does +} + +// collectDecoys prompts for base-address decoys one per line until a blank line. +// Each entry is validated at prompt time via decoyCollector: parseable, not your own +// address, not the transfer recipient, not an integrated address, no duplicates. Bad +// entries are reported and skipped. Returns the accepted canonical base-address +// strings. The engine re-validates registration/base-tree at send time (Strict:false +// skips the rest), so this is fast immediate feedback, not the sole security boundary. +func collectDecoys(l *readline.Instance, defaults []string, recipient string) []string { + c := newDecoyCollector(recipient) + + for _, d := range defaults { // pre-seed from --decoys + c.add(d) + } + + for { + v, err := ReadString(l, "decoy address (blank to finish)", "") + if err != nil { + break + } + if strings.TrimSpace(v) == "" { + break + } + c.add(v) + } + return c.members +} + +// promptAnonymizeAndDecoys runs the full interactive flow: anonymize y/N (default +// seeded from anonymize_default), ring-size warn, then decoy collection (only when +// anonymize is yes). Returns the built opts plus the chosen flags for the summary. +func promptAnonymizeAndDecoys(l *readline.Instance, recipient string) (opts walletapi.TransferOptions, anonymize bool, members []string) { + if anonymize_default { + anonymize = ConfirmYesNoDefaultYes(l, "Anonymize sender? (decoys hide you from the receiver) (Y/n) ") + } else { + anonymize = ConfirmYesNoDefaultNo(l, "Anonymize sender? (decoys hide you from the receiver) (y/N) ") + } + // Enforce the promise against engine reality BEFORE collecting decoys or building + // opts: if the ringsize cannot host anonymity, downgrade to honest now so every + // downstream artifact (built opts, review line, post-send report) tells the truth. + anonymize = resolveAnonymizeOrDowngrade(anonymize) + if anonymize { + members = collectDecoys(l, decoys_default, recipient) + } + return buildTransferOptions(anonymize, members), anonymize, members +} + +// curatedDecoysInTx counts how many of the user's curated decoy base-addresses +// ACTUALLY landed in the ring that DELIVERS the transfer to `recipient`, rather than +// echoing the prompt-time request count. The engine (curatedRingCandidates) runs under +// Strict:false, so a curated decoy that is unparseable / unregistered / +// deregistered-since-prompt is SILENTLY skipped and a random member fills the slot — +// len(members) would then over-report curation. The authoritative record of what +// finalized is the freshly built tx's Statement.Publickeylist (the serialized wire form +// keeps only index pointers, so this must read the in-memory built tx, exactly as the +// curated-ring finalization test does). +// +// O9: a token transfer (non-zero SCID) auto-injects a SECOND base (zero-SCID) 0-value +// payload to a RANDOM member (wallet_transfer.go:172-191); each payload builds its OWN +// ring with INDEPENDENT random fill. The slot that hides the sender from the RECIPIENT is +// in the RECIPIENT's payload ring only. Flattening BOTH rings would count a curated decoy +// that landed only in the throwaway base ring as "landed", over-reporting cover the +// delivered transfer never got. So we count membership ONLY in the payload whose ring +// contains the recipient's own pubkey — the delivering payload. The throwaway base ring +// goes to a random member, never the recipient, so this scopes cleanly for both menus +// (plain DERO = single zero-SCID payload that holds the recipient; token = the token-SCID +// payload). If the recipient cannot be resolved/located (defensive), we fall back to the +// union of all rings (the prior, possibly-over-counting behavior) rather than report 0. +// O(ring) per payload; tiny. +func curatedDecoysInTx(tx *transaction.Transaction, members []string, recipient string) int { + if tx == nil || len(members) == 0 { + return 0 + } + // resolve the recipient's compressed pubkey so we can find the delivering payload. + var recipientKey string + if a, err := rpc.NewAddress(recipient); err == nil { + recipientKey = hex.EncodeToString(a.PublicKey.EncodeCompressed()) + } + // build the set of ring keys present in the DELIVERING payload's ring (the one whose + // ring contains the recipient). Fall back to the union if we cannot identify it. + ringKeys := map[string]bool{} + addPayload := func(payload transaction.AssetPayload) { + for _, p := range payload.Statement.Publickeylist { + if p == nil { + continue + } + ringKeys[hex.EncodeToString((*crypto.Point)(p).EncodeCompressed())] = true + } + } + if recipientKey != "" { + for _, payload := range tx.Payloads { + for _, p := range payload.Statement.Publickeylist { + if p == nil { + continue + } + if hex.EncodeToString((*crypto.Point)(p).EncodeCompressed()) == recipientKey { + addPayload(payload) + break // this payload delivers to the recipient; scope to it + } + } + } + } + if len(ringKeys) == 0 { // recipient not resolvable/located: defensive union fallback + for _, payload := range tx.Payloads { + addPayload(payload) + } + } + // count distinct requested members whose pubkey is in the ring. members are already + // canonical, distinct base addresses (decoyCollector dedups by pubkey), so each maps + // to one key; a key counted once even if (defensively) it appeared twice. + counted := map[string]bool{} + n := 0 + for _, m := range members { + a, err := rpc.NewAddress(m) + if err != nil { + continue + } + k := hex.EncodeToString(a.PublicKey.EncodeCompressed()) + if ringKeys[k] && !counted[k] { + counted[k] = true + n++ + } + } + return n +} + +// reportAttribution prints the truthful attribution + decoy count to the CONSOLE ONLY +// (l.Stderr()), bypassing logger.Info which tees to the on-disk wallet log. This gives +// the user a clear pre-send and post-send statement of how the tx is actually attributed +// (O2) WITHOUT writing an anonymize-intent/decoy forensic artifact to disk (O3). +// +// `decoys` is the count to display and its `label` (e.g. "requested" pre-send, "landed +// in ring" post-send) so the post-send line reflects what the engine ACTUALLY placed — +// curated decoys are dropped silently under Strict:false, so an echoed prompt-time count +// would over-report curation (O8). +func reportAttribution(l *readline.Instance, opts walletapi.TransferOptions, decoys int, label string) { + fmt.Fprintf(l.Stderr(), "%sAttribution: %s (curated decoys %s: %d)%s\n", + color_extra_white, attributionResultLine(opts), label, decoys, color_normal) +} + +// attributionResultLine renders the TRUTH about how a built/sent transfer is attributed, +// derived from the same effective ringsize the engine uses — not the user's requested +// flag. Used both in the pre-send review and the post-send confirmation so the user can +// always tell after the fact whether they were actually anonymized (criterion 1). +func attributionResultLine(opts walletapi.TransferOptions) string { + if opts.Attribution == walletapi.AttributionAnonymous && + anonymizeEffectiveAtRingsize(wallet.GetRingSize()) { + return "ANONYMOUS (sender hidden in the decoy ring)" + } + return "HONEST (your sender address is visible to the receiver)" +} diff --git a/cmd/dero-wallet-cli/anonymity_test.go b/cmd/dero-wallet-cli/anonymity_test.go new file mode 100644 index 00000000..3a18c609 --- /dev/null +++ b/cmd/dero-wallet-cli/anonymity_test.go @@ -0,0 +1,365 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/deroproject/derohe/config" + "github.com/deroproject/derohe/cryptography/crypto" + "github.com/deroproject/derohe/cryptography/bn256" + "github.com/deroproject/derohe/globals" + "github.com/deroproject/derohe/rpc" + "github.com/deroproject/derohe/transaction" + "github.com/deroproject/derohe/walletapi" +) + +// TestBuildTransferOptionsNoOp locks the backward-compat contract: with anonymize +// off and no decoys, buildTransferOptions MUST return the literal zero value so the +// engine reproduces today's behavior byte-for-byte (honest attribution, nil Ring → +// random ring selection). A regression here would silently change every default send. +func TestBuildTransferOptionsNoOp(t *testing.T) { + opts := buildTransferOptions(false, nil) + + if opts != (walletapi.TransferOptions{}) { + t.Fatalf("expected zero-value TransferOptions, got %+v", opts) + } + if opts.Ring != nil { + t.Fatalf("expected nil Ring for no-op, got %+v", opts.Ring) + } + if opts.Attribution != walletapi.AttributionHonest { + t.Fatalf("expected AttributionHonest for no-op, got %v", opts.Attribution) + } + + // an empty (non-nil) members slice must also stay a no-op (Ring must stay nil). + if got := buildTransferOptions(false, []string{}); got.Ring != nil { + t.Fatalf("empty members must not produce a non-nil Ring, got %+v", got.Ring) + } +} + +// TestBuildTransferOptionsAnonymous verifies the opt-in paths flip exactly the +// expected fields and nothing else. +func TestBuildTransferOptionsAnonymous(t *testing.T) { + opts := buildTransferOptions(true, nil) + if opts.Attribution != walletapi.AttributionAnonymous { + t.Fatalf("expected AttributionAnonymous, got %v", opts.Attribution) + } + if opts.Ring != nil { + t.Fatalf("expected nil Ring with no decoys, got %+v", opts.Ring) + } + + members := []string{"dero1qyabc"} + opts = buildTransferOptions(false, members) + if opts.Attribution != walletapi.AttributionHonest { + t.Fatalf("decoys alone must not change attribution, got %v", opts.Attribution) + } + if opts.Ring == nil || len(opts.Ring.PreferredDecoys) != 1 { + t.Fatalf("expected Ring with 1 preferred decoy, got %+v", opts.Ring) + } + if opts.Ring.Strict { + t.Fatalf("Strict must default to false (forgiving skip + random-fill)") + } +} + +// TestAnonymizeEffectiveAtRingsize pins the single source of truth the CLI uses to +// decide what it PROMISES. It MUST agree with the engine: anonymity needs a decoy slot +// beyond [0]=sender, [1]=receiver, and the ring is a power of 2, so 2 has none and 4 is +// the first that does. (Teeth for O1/O2: change >= 4 to >= 2 here and this fails.) +func TestAnonymizeEffectiveAtRingsize(t *testing.T) { + cases := map[int]bool{1: false, 2: false, 3: false, 4: true, 8: true, 16: true} + for rs, want := range cases { + if got := anonymizeEffectiveAtRingsize(rs); got != want { + t.Fatalf("anonymizeEffectiveAtRingsize(%d) = %v, want %v", rs, got, want) + } + } +} + +// newTestWallet creates a disk wallet in a temp dir and installs it as the package +// global so the wallet-dependent helpers (GetAddress, GetRingSize, SetRingSize) work. +func newTestWallet(t *testing.T) (cleanup func()) { + t.Helper() + // Create_Encrypted_Wallet mints testnet (deto1...) addresses by default; pin the + // global network to testnet so globals.ParseValidateAddress accepts them (it rejects + // cross-network addresses). Without this, every decoy fails to parse. + prev := globals.Config + globals.Config = config.Testnet + t.Cleanup(func() { globals.Config = prev }) + + dir := t.TempDir() + path := filepath.Join(dir, "anon_test.db") + w, err := walletapi.Create_Encrypted_Wallet(path, "x", crypto.RandomScalarBNRed()) + if err != nil { + t.Fatalf("create wallet: %s", err) + } + wallet = w + return func() { + w.Close_Encrypted_Wallet() + wallet = nil + os.Remove(path) + } +} + +// TestResolveAnonymizeOrDowngrade is the O1 teeth: when the user asks to anonymize but +// the ringsize cannot host anonymity, the CLI MUST downgrade the flag to false so the +// built opts, the review line, and the post-send report all agree with the engine +// (which ships HONEST at ring<4). A false return here is the false-anonymity bug. +func TestResolveAnonymizeOrDowngrade(t *testing.T) { + defer newTestWallet(t)() + + // ring 2: anonymize must be CANCELLED (downgraded to honest). + wallet.SetRingSize(2) + if resolveAnonymizeOrDowngrade(true) { + t.Fatalf("ring 2: anonymize must downgrade to false (engine ships honest); got true") + } + // ring 4: anonymize survives. + wallet.SetRingSize(4) + if !resolveAnonymizeOrDowngrade(true) { + t.Fatalf("ring 4: anonymize must survive; got false") + } + // honest request is never upgraded. + if resolveAnonymizeOrDowngrade(false) { + t.Fatalf("honest request must stay honest at any ringsize") + } +} + +// TestAttributionResultLine is the O2 teeth: the report derives the truth from the +// effective ringsize, NOT the requested flag. At ring<4 an AttributionAnonymous opts +// MUST report HONEST, so the user can tell after the fact. +func TestAttributionResultLine(t *testing.T) { + defer newTestWallet(t)() + anonOpts := walletapi.TransferOptions{Attribution: walletapi.AttributionAnonymous} + + wallet.SetRingSize(2) + if got := attributionResultLine(anonOpts); got == "" || got[0] != 'H' { + t.Fatalf("ring 2 anonymous opts must report HONEST, got %q", got) + } + wallet.SetRingSize(8) + if got := attributionResultLine(anonOpts); got == "" || got[0] != 'A' { + t.Fatalf("ring 8 anonymous opts must report ANONYMOUS, got %q", got) + } + // honest opts always report honest. + if got := attributionResultLine(walletapi.TransferOptions{}); got[0] != 'H' { + t.Fatalf("honest opts must report HONEST, got %q", got) + } +} + +// TestCollectDecoysRejectsRecipientAndSelf is the O5 teeth: collectDecoys must reject +// (a) the sender's own address, (b) the transfer recipient, and (c) an INTEGRATED +// alt-encoding of the recipient's pubkey — all are already real members of the ring, +// so curating them as decoys produces a duplicate pubkey that consensus rejects after +// signing. The check canonicalizes to BaseAddress so the alt-encoding cannot slip +// through a raw-string seen-map (the class of hole the #22 engine fix closes). +func TestCollectDecoysRejectsRecipientAndSelf(t *testing.T) { + defer newTestWallet(t)() + wallet.SetRingSize(16) + + self := wallet.GetAddress().String() + + // build a second, distinct wallet to act as the recipient. + dir := t.TempDir() + rw, err := walletapi.Create_Encrypted_Wallet(filepath.Join(dir, "rcpt.db"), "x", crypto.RandomScalarBNRed()) + if err != nil { + t.Fatalf("create recipient wallet: %s", err) + } + defer rw.Close_Encrypted_Wallet() + recipientBase := rw.GetAddress().String() + + // an INTEGRATED encoding of the recipient: same pubkey, different string. + recipientIntegrated := rw.GetRandomIAddress8() + if !recipientIntegrated.IsIntegratedAddress() { + t.Fatalf("expected an integrated recipient encoding") + } + if recipientIntegrated.String() == recipientBase { + t.Fatalf("integrated form must differ from base string (else the test proves nothing)") + } + + // a clean, distinct decoy that MUST be accepted. + dir2 := t.TempDir() + dw, err := walletapi.Create_Encrypted_Wallet(filepath.Join(dir2, "decoy.db"), "x", crypto.RandomScalarBNRed()) + if err != nil { + t.Fatalf("create decoy wallet: %s", err) + } + defer dw.Close_Encrypted_Wallet() + goodDecoy := dw.GetAddress().String() + + // feed: self, recipient(base), recipient(integrated alt-encoding), a dupe of the + // good decoy, and the good decoy. Only ONE (the good decoy) may survive. We drive + // the readline-free core (decoyCollector) directly — the same code path the + // interactive collectDecoys uses for every entry. + c := newDecoyCollector(recipientBase) + for _, d := range []string{ + self, + recipientBase, + recipientIntegrated.String(), + goodDecoy, + goodDecoy, // raw duplicate + } { + c.add(d) + } + members := c.members + + if len(members) != 1 { + t.Fatalf("expected exactly 1 surviving decoy (the clean one), got %d: %v", len(members), members) + } + // the survivor must be the canonical base of the good decoy. + wantBase := canonBase(goodDecoy) + if members[0] != wantBase { + t.Fatalf("survivor must be the canonical good decoy %q, got %q", wantBase, members[0]) + } + // explicitly: neither recipient encoding nor self may have survived. + for _, m := range members { + if m == canonBase(recipientBase) { + t.Fatalf("recipient leaked into decoys: %q", m) + } + if m == canonBase(self) { + t.Fatalf("self leaked into decoys: %q", m) + } + } +} + +// pubkeyG1 returns the bn256.G1 ring point for a base address string. +func pubkeyG1(t *testing.T, addr string) *bn256.G1 { + t.Helper() + a, err := rpc.NewAddress(addr) + if err != nil { + t.Fatalf("parse address %q: %s", addr, err) + } + return (*bn256.G1)(a.PublicKey) +} + +// TestCuratedDecoysInTx is the O8 teeth: the post-send report must count curated decoys +// that ACTUALLY landed in the built ring, not echo the prompt-time request. The engine +// runs Strict:false, so an unregistered/dropped decoy is silently replaced by a random +// member; len(members) would over-report. We build a synthetic tx whose ring contains +// only SOME of the requested members and assert the count reflects the ring, not the ask. +func TestCuratedDecoysInTx(t *testing.T) { + defer newTestWallet(t)() + + // mint four distinct registered-shaped wallets to use as curated members. + mk := func(name string) string { + dir := t.TempDir() + w, err := walletapi.Create_Encrypted_Wallet(filepath.Join(dir, name+".db"), "x", crypto.RandomScalarBNRed()) + if err != nil { + t.Fatalf("create %s: %s", name, err) + } + t.Cleanup(func() { w.Close_Encrypted_Wallet() }) + return w.GetAddress().String() + } + recipient := mk("recipient") + landed1, landed2 := mk("landed1"), mk("landed2") + dropped := mk("dropped") // requested but NOT placed in the ring (engine dropped it) + unrelated := mk("unrelated") + + // build a tx whose (single, recipient-delivering) ring holds: sender, the recipient, + // an unrelated random fill, and the two landed curated members — but NOT `dropped`. + // This is exactly the Strict:false outcome where one curated decoy was unregistered + // and a random member took its slot. + ring := []*bn256.G1{ + pubkeyG1(t, wallet.GetAddress().String()), + pubkeyG1(t, recipient), + pubkeyG1(t, unrelated), + pubkeyG1(t, landed1), + pubkeyG1(t, landed2), + } + tx := &transaction.Transaction{} + tx.Payloads = append(tx.Payloads, transaction.AssetPayload{ + Statement: crypto.Statement{Publickeylist: ring}, + }) + + // requested = 3 curated (landed1, landed2, dropped); only 2 actually landed. + requested := []string{landed1, landed2, dropped} + got := curatedDecoysInTx(tx, requested, recipient) + if got != 2 { + t.Fatalf("curatedDecoysInTx must count ONLY decoys present in the built ring (2), got %d — the report would over-state curation", got) + } + + // nil tx and empty members are both zero (no panic). + if curatedDecoysInTx(nil, requested, recipient) != 0 { + t.Fatalf("nil tx must count 0") + } + if curatedDecoysInTx(tx, nil, recipient) != 0 { + t.Fatalf("empty members must count 0") + } + + // a member duplicated in the request must not be double-counted off one ring slot. + if n := curatedDecoysInTx(tx, []string{landed1, landed1}, recipient); n != 1 { + t.Fatalf("a requested member duplicated must count once (1), got %d", n) + } +} + +// TestCuratedDecoysInTx_TokenTransferTwoRings is the O9 teeth: a token transfer builds +// TWO payloads each with its OWN ring — the token-SCID payload that DELIVERS to the +// recipient, and an auto-injected zero-SCID 0-value payload to a RANDOM member. A curated +// decoy that landed ONLY in the throwaway base ring (e.g. displaced from the token ring by +// independent random fill) provides ZERO cover for the delivered transfer, so it must NOT +// be counted. curatedDecoysInTx must scope to the recipient-delivering payload's ring. +func TestCuratedDecoysInTx_TokenTransferTwoRings(t *testing.T) { + defer newTestWallet(t)() + + mk := func(name string) string { + dir := t.TempDir() + w, err := walletapi.Create_Encrypted_Wallet(filepath.Join(dir, name+".db"), "x", crypto.RandomScalarBNRed()) + if err != nil { + t.Fatalf("create %s: %s", name, err) + } + t.Cleanup(func() { w.Close_Encrypted_Wallet() }) + return w.GetAddress().String() + } + self := wallet.GetAddress().String() + recipient := mk("recipient") + inTokenRing := mk("inTokenRing") // curated decoy that landed in the DELIVERING ring + onlyInBaseRing := mk("onlyInBase") // curated decoy that landed ONLY in the throwaway ring + baseRandom := mk("baseRandom") // the random member the base payload was sent to + + // payload 0: the token-SCID payload that DELIVERS to the recipient. Holds the curated + // decoy `inTokenRing` but NOT `onlyInBaseRing`. + tokenRing := []*bn256.G1{ + pubkeyG1(t, self), + pubkeyG1(t, recipient), + pubkeyG1(t, inTokenRing), + } + // payload 1: the auto-injected zero-SCID 0-value base payload to a RANDOM member. + // Holds `onlyInBaseRing` (a curated decoy that happened to land here) but NOT the + // recipient. Counting this ring would over-report cover the delivered transfer lacks. + baseRing := []*bn256.G1{ + pubkeyG1(t, self), + pubkeyG1(t, baseRandom), + pubkeyG1(t, onlyInBaseRing), + } + tx := &transaction.Transaction{} + tx.Payloads = append(tx.Payloads, + transaction.AssetPayload{Statement: crypto.Statement{Publickeylist: tokenRing}}, + transaction.AssetPayload{Statement: crypto.Statement{Publickeylist: baseRing}}, + ) + + // the user curated BOTH decoys; only ONE (inTokenRing) actually covers the delivery. + requested := []string{inTokenRing, onlyInBaseRing} + got := curatedDecoysInTx(tx, requested, recipient) + if got != 1 { + t.Fatalf("O9: must count ONLY the curated decoy in the recipient-delivering ring (1), got %d — a base-ring-only decoy was over-reported as cover", got) + } + + // defensive fallback: an unresolvable recipient must NOT report 0 (it unions the rings, + // the prior behavior) — better to over-count than to silently report no cover. + if n := curatedDecoysInTx(tx, requested, "not-an-address"); n != 2 { + t.Fatalf("unresolvable recipient must fall back to the union (2), got %d", n) + } +} + +// TestCanonBase confirms an integrated address and its base reduce to the SAME +// canonical key (the property the dedup relies on), and junk yields "". +func TestCanonBase(t *testing.T) { + defer newTestWallet(t)() + base := wallet.GetAddress().String() + integrated := wallet.GetRandomIAddress8().String() + + if canonBase(base) != canonBase(integrated) { + t.Fatalf("base and integrated of the same key must canonicalize equal:\n base=%q\n int =%q", canonBase(base), canonBase(integrated)) + } + if canonBase("not-an-address") != "" { + t.Fatalf("unparseable input must canonicalize to empty string") + } + if canonBase(" ") != "" { + t.Fatalf("blank input must canonicalize to empty string") + } +} diff --git a/cmd/dero-wallet-cli/easymenu_post_open.go b/cmd/dero-wallet-cli/easymenu_post_open.go index 2486366e..93e0156c 100644 --- a/cmd/dero-wallet-cli/easymenu_post_open.go +++ b/cmd/dero-wallet-cli/easymenu_post_open.go @@ -216,8 +216,19 @@ func handle_easymenu_post_open_command(l *readline.Instance, line string) (proce break // invalid amount provided, bail out } + opts, _, members := promptAnonymizeAndDecoys(l, a.String()) + + logger.Info("Review token transfer", + "scid", scid.String(), + "to", a.String(), + "amount", globals.FormatMoney(amount_to_transfer), + "ringsize", wallet.GetRingSize()) + // attribution + decoy intent are console-only (never tee'd to the on-disk log), + // so the disk artifact never binds this recipient to anonymize-intent (O3). + reportAttribution(l, opts, len(members), "requested") + if ConfirmYesNoDefaultNo(l, "Confirm Transaction (y/N)") { - tx, err := wallet.TransferPayload0([]rpc.Transfer{{SCID: scid, Amount: amount_to_transfer, Destination: a.String()}}, 0, false, rpc.Arguments{}, 0, false) // empty SCDATA + tx, err := wallet.TransferPayload0WithOptions([]rpc.Transfer{{SCID: scid, Amount: amount_to_transfer, Destination: a.String()}}, 0, false, rpc.Arguments{}, 0, false, opts) // empty SCDATA if err != nil { logger.Error(err, "Error while building Transaction") @@ -228,6 +239,9 @@ func handle_easymenu_post_open_command(l *readline.Instance, line string) (proce break } logger.Info("Dispatched tx", "txid", tx.GetHash().String()) + // post-send count is read off the BUILT ring, not the prompt-time request: + // Strict:false drops unregistered curated decoys silently (O8). + reportAttribution(l, opts, curatedDecoysInTx(tx, members, a.String()), "landed in ring") // truthful post-send confirmation (O2/O9: count only the recipient-delivering payload's ring) } case "5": @@ -378,11 +392,21 @@ func handle_easymenu_post_open_command(l *readline.Instance, line string) (proce return } + opts, _, members := promptAnonymizeAndDecoys(l, a.String()) + + logger.Info("Review transfer", + "to", a.String(), + "amount", globals.FormatMoney(amount_to_transfer), + "ringsize", wallet.GetRingSize()) + // attribution + decoy intent are console-only (never tee'd to the on-disk log), + // so the disk artifact never binds this recipient to anonymize-intent (O3). + reportAttribution(l, opts, len(members), "requested") + if ConfirmYesNoDefaultNo(l, "Confirm Transaction (y/N)") { //src_port := uint64(0xffffffffffffffff) - tx, err := wallet.TransferPayload0([]rpc.Transfer{{Amount: amount_to_transfer, Destination: a.String(), Payload_RPC: arguments}}, 0, false, rpc.Arguments{}, 0, false) // empty SCDATA + tx, err := wallet.TransferPayload0WithOptions([]rpc.Transfer{{Amount: amount_to_transfer, Destination: a.String(), Payload_RPC: arguments}}, 0, false, rpc.Arguments{}, 0, false, opts) // empty SCDATA if err != nil { logger.Error(err, "Error while building Transaction") @@ -394,6 +418,9 @@ func handle_easymenu_post_open_command(l *readline.Instance, line string) (proce break } logger.Info("Dispatched tx", "txid", tx.GetHash().String()) + // post-send count is read off the BUILT ring, not the prompt-time request: + // Strict:false drops unregistered curated decoys silently (O8). + reportAttribution(l, opts, curatedDecoysInTx(tx, members, a.String()), "landed in ring") // truthful post-send confirmation (O2/O9: count only the recipient-delivering payload's ring) //fmt.Printf("queued tx err %s\n") } @@ -406,6 +433,7 @@ func handle_easymenu_post_open_command(l *readline.Instance, line string) (proce break } + // note: transfer-all + curated TransferOptions is unsupported by the engine; do not wire opts here. logger.Error(err, "Not supported ") /* diff --git a/cmd/dero-wallet-cli/easymenu_pre_open.go b/cmd/dero-wallet-cli/easymenu_pre_open.go index b56ef134..7b187e92 100644 --- a/cmd/dero-wallet-cli/easymenu_pre_open.go +++ b/cmd/dero-wallet-cli/easymenu_pre_open.go @@ -17,6 +17,7 @@ package main import "io" +import "os" import "fmt" import "time" import "strconv" @@ -249,6 +250,21 @@ func common_processing(wallet *walletapi.Wallet_Disk) { wallet.SetNetwork(!globals.Arguments["--testnet"].(bool)) + if globals.Arguments["--anonymous"] != nil && globals.Arguments["--anonymous"].(bool) { + anonymize_default = true + // console-only (NOT the on-disk log): never persist anonymize-intent to disk (O3). + fmt.Fprintf(os.Stderr, "Anonymize sender default ON (per-send prompt may still override)\n") + } + if globals.Arguments["--decoys"] != nil && globals.Arguments["--decoys"].(string) != "" { + for _, d := range strings.Split(globals.Arguments["--decoys"].(string), ",") { + if d = strings.TrimSpace(d); d != "" { + decoys_default = append(decoys_default, d) + } + } + // console-only (NOT the on-disk log): never persist the curated decoy set to disk (O3). + fmt.Fprintf(os.Stderr, "Loaded preferred decoys: %d\n", len(decoys_default)) + } + // start rpc server if requested if globals.Arguments["--rpc-server"].(bool) == true { rpc_address := "127.0.0.1:" + fmt.Sprintf("%d", config.Mainnet.Wallet_RPC_Default_Port) diff --git a/cmd/dero-wallet-cli/main.go b/cmd/dero-wallet-cli/main.go index 8e51ff3c..66e12a68 100644 --- a/cmd/dero-wallet-cli/main.go +++ b/cmd/dero-wallet-cli/main.go @@ -82,6 +82,8 @@ Usage: --allow-rpc-password-change RPC server will change password if you send "Pass" header with new password --scan-top-n-blocks=<100000> Only scan top N blocks --save-every-x-seconds=<300> Save wallet every x seconds + --anonymous Default the per-send 'Anonymize sender?' prompt to yes (decoys hide you from the receiver) + --decoys= Comma-separated base addresses to prefer as ring decoys (prompt defaults; bad entries skipped) ` var menu_mode bool = true // default display menu mode // var account_valid bool = false // if an account has been opened, do not allow to create new account in this session diff --git a/cmd/dero-wallet-cli/prompt.go b/cmd/dero-wallet-cli/prompt.go index 73fa50d1..5bb5b703 100644 --- a/cmd/dero-wallet-cli/prompt.go +++ b/cmd/dero-wallet-cli/prompt.go @@ -482,6 +482,25 @@ func handle_set_command(l *readline.Instance, line string) { language := choose_seed_language(l) logger.Info("Setting seed language", "language", wallet.SetSeedLanguage(language)) + case "anonymous": + if len(line_parts) != 3 { + logger.Info("Wrong number of arguments, see help eg") + help = true + break + } + switch strings.ToLower(line_parts[2]) { + case "on", "true", "yes", "1": + anonymize_default = true + case "off", "false", "no", "0": + anonymize_default = false + default: + logger.Info("Use 'set anonymous on' or 'set anonymous off'") + help = true + break + } + // console-only (NOT the on-disk log): never persist anonymize-intent to disk (O3). + fmt.Fprintf(l.Stderr(), "Anonymize sender default (session only): %v\n", anonymize_default) + default: help = true } @@ -497,6 +516,12 @@ func handle_set_command(l *readline.Instance, line string) { fmt.Fprintf(l.Stderr(), color_normal+"Priority: "+color_extra_white+"%0.2f\t"+color_normal+"eg. "+color_extra_white+"set priority 4.0\t"+color_normal+"Transaction priority on DERO network \n", wallet.GetFeeMultiplier()) fmt.Fprintf(l.Stderr(), "\t\tMinimum priority is 1.00. High priority = high fees\n") + anon := "off" + if anonymize_default { + anon = "on" + } + fmt.Fprintf(l.Stderr(), color_normal+"Anonymize sender: "+color_extra_white+"%s\t"+color_normal+"eg. "+color_extra_white+"set anonymous on\t"+color_normal+"Session default; per-send prompt still decides. Needs ringsize >= 4.\n", anon) + } } @@ -1034,6 +1059,7 @@ var completer = readline.NewPrefixCompleter( readline.PcItem("mixin"), readline.PcItem("seed"), readline.PcItem("priority"), + readline.PcItem("anonymous"), ), readline.PcItem("show_transfers"), readline.PcItem("spendkey"), From e5307646ff3a1c1605c75178af40bf3bfa507af2 Mon Sep 17 00:00:00 2001 From: DHEBP <152355273+DHEBP@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:50:49 -0400 Subject: [PATCH 13/15] feat(wallet-cli): unified Transaction Build Options menu + UX hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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. --- cmd/dero-wallet-cli/anonymity.go | 160 +++++++++++++++++++--- cmd/dero-wallet-cli/anonymity_test.go | 100 ++++++++++++++ cmd/dero-wallet-cli/easymenu_post_open.go | 58 ++++---- cmd/dero-wallet-cli/easymenu_pre_open.go | 6 +- cmd/dero-wallet-cli/main.go | 4 +- cmd/dero-wallet-cli/prompt.go | 41 ++---- 6 files changed, 283 insertions(+), 86 deletions(-) diff --git a/cmd/dero-wallet-cli/anonymity.go b/cmd/dero-wallet-cli/anonymity.go index 16c6a070..c740cde4 100644 --- a/cmd/dero-wallet-cli/anonymity.go +++ b/cmd/dero-wallet-cli/anonymity.go @@ -19,6 +19,7 @@ package main import ( "encoding/hex" "fmt" + "strconv" "strings" "github.com/chzyer/readline" @@ -29,14 +30,21 @@ import ( "github.com/deroproject/derohe/walletapi" ) -// session-scoped sticky default for "Anonymize sender?". NOT persisted across -// wallet close/reopen (would require an engine Account field, which is forbidden). -// Seeded by --anonymous at startup; toggled by "set anonymous on/off". +// session-scoped "extra sender privacy" flag. NOT persisted across wallet close/reopen +// (would require an engine Account field, which is forbidden). Seeded by --anonymous at +// startup; toggled in the Advanced Privacy Options menu or by "set anonymous on/off". var anonymize_default bool -// session-scoped preferred decoys seeded by --decoys; used as prompt defaults. +// session-scoped user-chosen ring decoys, seeded by --decoys and edited in the +// Transaction Build Options menu. Only ever addresses the user supplies — never auto-selected. var decoys_default []string +// the ring size a post-send reset restores to (the "set first, then fire" contract: +// each tx is configured fresh, then build settings snap back). Captured when the wallet +// is opened so a one-off per-tx ring size never silently persists. Defaults to the DERO +// wallet default until set from the opened wallet. +var default_ringsize = 16 + // buildTransferOptions constructs opts honoring the no-op contract: when anonymize // is false AND members is empty, it returns a LITERAL zero value, so Ring stays nil // (the engine fast path) and Attribution stays AttributionHonest. opts.Ring is @@ -201,25 +209,128 @@ func collectDecoys(l *readline.Instance, defaults []string, recipient string) [] return c.members } -// promptAnonymizeAndDecoys runs the full interactive flow: anonymize y/N (default -// seeded from anonymize_default), ring-size warn, then decoy collection (only when -// anonymize is yes). Returns the built opts plus the chosen flags for the summary. -func promptAnonymizeAndDecoys(l *readline.Instance, recipient string) (opts walletapi.TransferOptions, anonymize bool, members []string) { - if anonymize_default { - anonymize = ConfirmYesNoDefaultYes(l, "Anonymize sender? (decoys hide you from the receiver) (Y/n) ") - } else { - anonymize = ConfirmYesNoDefaultNo(l, "Anonymize sender? (decoys hide you from the receiver) (y/N) ") - } - // Enforce the promise against engine reality BEFORE collecting decoys or building - // opts: if the ringsize cannot host anonymity, downgrade to honest now so every - // downstream artifact (built opts, review line, post-send report) tells the truth. - anonymize = resolveAnonymizeOrDowngrade(anonymize) +// applySessionPrivacy builds the transfer options for a send SILENTLY from the session +// privacy settings (set via the Advanced Privacy Options menu / --anonymous / --decoys). +// The default send path asks NOTHING extra — DERO already hides amount, sender and +// receiver by default, so a per-send "anonymize?" prompt would wrongly imply the default +// is exposed. Extra sender cover is opt-in by visiting the Advanced menu, not by a prompt. +// +// It still enforces the promise against engine reality: if the user enabled extra privacy +// but the effective ringsize cannot host it, anonymize is downgraded to honest (with a +// plain notice) so what is built, broadcast, and reported all agree. +func applySessionPrivacy(recipient string) (opts walletapi.TransferOptions, anonymize bool, members []string) { + anonymize = resolveAnonymizeOrDowngrade(anonymize_default) if anonymize { - members = collectDecoys(l, decoys_default, recipient) + // session decoys are already user-entered (Advanced menu / --decoys); re-validate + // them against THIS recipient (the recipient seed is per-send) and canonicalize. + c := newDecoyCollector(recipient) + for _, d := range decoys_default { + c.add(d) + } + members = c.members } return buildTransferOptions(anonymize, members), anonymize, members } +// advancedSettingsActive reports whether any opt-in build setting is engaged (extra +// privacy on, or user-chosen decoys present). Drives the red "(advanced settings +// enabled)" suffix on the Transfer menu label so the user always sees, before they +// commit, that this tx will be built differently from the plain default path. +func advancedSettingsActive() bool { + return anonymize_default || len(decoys_default) > 0 +} + +// transferLabelSuffix is appended to the Transfer (option 5) menu line so option 5 is a +// live readout of how the next tx will be built: the plain default path reads +// "(default, ringsize N)"; once extra privacy / curated decoys are engaged it reads +// "(ringsize N) (advanced settings enabled)" with the advanced part in red. +func transferLabelSuffix() string { + if advancedSettingsActive() { + return fmt.Sprintf(" (ringsize %d)", wallet.GetRingSize()) + + color_red + " (advanced settings enabled)" + color_normal + } + return fmt.Sprintf(" (default, ringsize %d)", wallet.GetRingSize()) +} + +// resetTransferBuildToDefaults restores the "set first, then fire" contract: after a +// transfer is dispatched, the per-tx build settings snap back to defaults so an advanced +// configuration cannot silently persist into the next, unrelated send. Resets ALL three: +// extra privacy OFF, chosen decoys cleared, and ring size back to the session default. +func resetTransferBuildToDefaults() { + anonymize_default = false + decoys_default = nil + wallet.SetRingSize(default_ringsize) +} + +// handleTransactionBuildMenu is the opt-in, unified "Transaction Build Options" submenu. +// It is the single place to configure how the NEXT transfer's ring is built — ring size, +// extra sender privacy, and user-chosen decoys — so there is ONE consistent flow rather +// than ringsize-by-command + privacy-by-menu. It NEVER auto-selects anything: decoys are +// only addresses the user types (Azylem's rule). The typed `set ringsize` / `set anonymous` +// commands remain and set the same state. +func handleTransactionBuildMenu(l *readline.Instance) { + for { + on := "OFF" + if anonymize_default { + on = "ON" + } + decoyState := "(none — uses random ring members)" + if len(decoys_default) > 0 { + decoyState = fmt.Sprintf("(%d chosen)", len(decoys_default)) + } + fmt.Fprintf(l.Stderr(), "\n%s── Transaction Build Options ─────────────────────%s\n", color_extra_white, color_normal) + fmt.Fprintf(l.Stderr(), "%s DERO already hides your amount, sender, and receiver\n", color_normal) + fmt.Fprintf(l.Stderr(), " by default. Configure how your next transfer is built\n") + fmt.Fprintf(l.Stderr(), " (extra sender cover is for advanced users).%s\n\n", color_normal) + fmt.Fprintf(l.Stderr(), "\t%s1%s\tRing size: %s%d%s\n", color_extra_white, color_normal, color_yellow, wallet.GetRingSize(), color_normal) + fmt.Fprintf(l.Stderr(), "\t%s2%s\tExtra sender privacy: %s%s%s\n", color_extra_white, color_normal, color_yellow, on, color_normal) + fmt.Fprintf(l.Stderr(), "\t%s3%s\tYour chosen ring decoys: %s\n", color_extra_white, color_normal, decoyState) + fmt.Fprintf(l.Stderr(), "\t%s4%s\tClear chosen decoys\n", color_extra_white, color_normal) + fmt.Fprintf(l.Stderr(), "\t%s0%s\tBack\n", color_extra_white, color_normal) + fmt.Fprintf(l.Stderr(), "%s──────────────────────────────────────────────────%s\n", color_extra_white, color_normal) + + choice, err := ReadString(l, "choice", "0") + if err != nil { + return + } + switch strings.TrimSpace(choice) { + case "1": + v, e := ReadString(l, "ring size (power of 2, 2-128)", fmt.Sprintf("%d", wallet.GetRingSize())) + if e != nil { + break + } + n, perr := strconv.Atoi(strings.TrimSpace(v)) + if perr != nil { + logger.Error(perr, "Not a number") + break + } + // SetRingSize self-validates (power of 2, 2..128) and returns the effective value; + // it silently ignores an invalid value, so compare to detect a rejected input. + if got := wallet.SetRingSize(n); got != n { + logger.Error(fmt.Errorf("invalid ring size"), "Ring size must be a power of 2 between 2 and 128; unchanged", "requested", n, "current", got) + } + case "2": + anonymize_default = !anonymize_default + if anonymize_default && !anonymizeEffectiveAtRingsize(wallet.GetRingSize()) { + logger.Info(color_yellow + "Note: ring size is " + fmt.Sprintf("%d", wallet.GetRingSize()) + + "; extra sender privacy needs ring size 4+ to take effect. Set ring size (option 1) to 16." + color_normal) + } + case "3": + // decoys are collected from what the user TYPES; no recipient is known here + // (it is a per-send value), so seed the collector with the sender only. The + // recipient is re-checked per send in applySessionPrivacy. + decoys_default = collectDecoys(l, decoys_default, "") + case "4": + decoys_default = nil + logger.Info("Chosen decoys cleared") + case "0", "": + return + default: + logger.Error(nil, "Unknown choice") + } + } +} + // curatedDecoysInTx counts how many of the user's curated decoy base-addresses // ACTUALLY landed in the ring that DELIVERS the transfer to `recipient`, rather than // echoing the prompt-time request count. The engine (curatedRingCandidates) runs under @@ -299,16 +410,21 @@ func curatedDecoysInTx(tx *transaction.Transaction, members []string, recipient return n } -// reportAttribution prints the truthful attribution + decoy count to the CONSOLE ONLY -// (l.Stderr()), bypassing logger.Info which tees to the on-disk wallet log. This gives -// the user a clear pre-send and post-send statement of how the tx is actually attributed -// (O2) WITHOUT writing an anonymize-intent/decoy forensic artifact to disk (O3). +// reportAttribution prints the attribution + decoy count to the CONSOLE ONLY (l.Stderr()), +// bypassing logger.Info which tees to the on-disk wallet log (O3). It only speaks when the +// user engaged extra privacy / curated decoys — a plain default (honest) send prints +// NOTHING, so a normal transfer is never burdened with a "your sender is visible" notice +// the user didn't ask for. On an advanced send it confirms, pre- and post-send, exactly how +// the tx was attributed (O2). // // `decoys` is the count to display and its `label` (e.g. "requested" pre-send, "landed // in ring" post-send) so the post-send line reflects what the engine ACTUALLY placed — // curated decoys are dropped silently under Strict:false, so an echoed prompt-time count // would over-report curation (O8). func reportAttribution(l *readline.Instance, opts walletapi.TransferOptions, decoys int, label string) { + if !advancedSettingsActive() { + return // default/honest send: stay silent, no unsolicited privacy notice + } fmt.Fprintf(l.Stderr(), "%sAttribution: %s (curated decoys %s: %d)%s\n", color_extra_white, attributionResultLine(opts), label, decoys, color_normal) } diff --git a/cmd/dero-wallet-cli/anonymity_test.go b/cmd/dero-wallet-cli/anonymity_test.go index 3a18c609..bd00455c 100644 --- a/cmd/dero-wallet-cli/anonymity_test.go +++ b/cmd/dero-wallet-cli/anonymity_test.go @@ -3,6 +3,7 @@ package main import ( "os" "path/filepath" + "strings" "testing" "github.com/deroproject/derohe/config" @@ -363,3 +364,102 @@ func TestCanonBase(t *testing.T) { t.Fatalf("blank input must canonicalize to empty string") } } + +// TestApplySessionPrivacy locks the NEW silent send path (the Advanced Privacy menu +// redesign): a send no longer prompts — it reads the session settings (anonymize_default, +// decoys_default) and builds opts silently. The contract: +// - privacy OFF -> literal zero-value opts (no-op; engine fast path unchanged) +// - privacy ON, ring>=4 -> AttributionAnonymous; any user-chosen decoys carried +// - privacy ON, ring<4 -> fails closed: downgraded to honest (no false anonymity) +// - never auto-selects decoys (Azylem): Ring is nil unless the user supplied members +func TestApplySessionPrivacy(t *testing.T) { + defer newTestWallet(t)() + + // save+restore the package session globals this test mutates. + prevAnon, prevDecoys := anonymize_default, decoys_default + t.Cleanup(func() { anonymize_default, decoys_default = prevAnon, prevDecoys }) + + // ── case 1: privacy OFF -> exact no-op (zero value) ─────────────────────────────── + anonymize_default, decoys_default = false, nil + wallet.SetRingSize(16) + opts, anon, members := applySessionPrivacy("") + if anon || members != nil || opts.Ring != nil || opts.Attribution != walletapi.AttributionHonest { + t.Fatalf("privacy OFF must be a literal no-op; got anon=%v members=%v ring=%v attr=%v", + anon, members, opts.Ring != nil, opts.Attribution) + } + + // ── case 2: privacy ON at ring>=4 -> anonymous, no decoys -> Ring stays nil ──────── + anonymize_default, decoys_default = true, nil + wallet.SetRingSize(16) + opts, anon, members = applySessionPrivacy("") + if !anon || opts.Attribution != walletapi.AttributionAnonymous { + t.Fatalf("privacy ON ring16: expected anonymous, got anon=%v attr=%v", anon, opts.Attribution) + } + if opts.Ring != nil || len(members) != 0 { + t.Fatalf("no user decoys -> Ring must stay nil (never auto-selected); got ring=%v members=%v", opts.Ring != nil, members) + } + + // ── case 3: privacy ON at ring<4 -> fails closed to honest ───────────────────────── + anonymize_default, decoys_default = true, nil + wallet.SetRingSize(2) + opts, anon, _ = applySessionPrivacy("") + if anon || opts.Attribution != walletapi.AttributionHonest { + t.Fatalf("privacy ON ring2 MUST downgrade to honest (no false anonymity); got anon=%v attr=%v", anon, opts.Attribution) + } +} + +// TestResetTransferBuildToDefaults locks the "set first, then fire" contract: after a +// send, ALL per-tx build settings snap back to defaults — extra privacy OFF, decoys +// cleared, and ring size back to the captured session default — so an advanced +// configuration cannot silently persist into the next, unrelated transfer. +func TestResetTransferBuildToDefaults(t *testing.T) { + defer newTestWallet(t)() + + prevAnon, prevDecoys, prevDef := anonymize_default, decoys_default, default_ringsize + t.Cleanup(func() { anonymize_default, decoys_default, default_ringsize = prevAnon, prevDecoys, prevDef }) + + // the session default the reset must restore to. + default_ringsize = 16 + wallet.SetRingSize(16) + + // simulate an advanced configuration for one tx. + anonymize_default = true + decoys_default = []string{"deto1aaa", "deto1bbb"} + wallet.SetRingSize(8) // a one-off ring size for this tx + + resetTransferBuildToDefaults() + + if anonymize_default { + t.Fatalf("reset must turn extra privacy OFF; still on") + } + if decoys_default != nil { + t.Fatalf("reset must clear chosen decoys; got %v", decoys_default) + } + if wallet.GetRingSize() != 16 { + t.Fatalf("reset must restore ring size to the session default (16); got %d", wallet.GetRingSize()) + } +} + +// TestTransferLabelSuffix locks option 5's state readout: the plain default path reads +// "(default, ringsize N)"; once any advanced setting is engaged it switches to +// "(ringsize N)" plus the "(advanced settings enabled)" marker — so the user always sees, +// before committing, how the next tx will be built. +func TestTransferLabelSuffix(t *testing.T) { + defer newTestWallet(t)() + prevAnon, prevDecoys := anonymize_default, decoys_default + t.Cleanup(func() { anonymize_default, decoys_default = prevAnon, prevDecoys }) + + anonymize_default, decoys_default = false, nil + wallet.SetRingSize(16) + if s := transferLabelSuffix(); !strings.Contains(s, "default") || !strings.Contains(s, "ringsize 16") { + t.Fatalf("default path label must read (default, ringsize 16); got %q", s) + } + if strings.Contains(transferLabelSuffix(), "advanced settings enabled") { + t.Fatalf("default path must NOT show the advanced marker") + } + + anonymize_default = true + if s := transferLabelSuffix(); !strings.Contains(s, "advanced settings enabled") || strings.Contains(s, "default") { + t.Fatalf("advanced path label must show the advanced marker and drop 'default'; got %q", s) + } +} diff --git a/cmd/dero-wallet-cli/easymenu_post_open.go b/cmd/dero-wallet-cli/easymenu_post_open.go index 93e0156c..e66222b9 100644 --- a/cmd/dero-wallet-cli/easymenu_post_open.go +++ b/cmd/dero-wallet-cli/easymenu_post_open.go @@ -56,23 +56,24 @@ func display_easymenu_post_open_command(l *readline.Instance) { io.WriteString(w, "\n") } else { // hide some commands, if view only wallet io.WriteString(w, "\t\033[1m4\033[0m\tDisplay wallet pool\n") - io.WriteString(w, "\t\033[1m5\033[0m\tTransfer (send DERO) to Another Wallet\n") + io.WriteString(w, "\t\033[1m5\033[0m\tTransfer (send DERO) to Another Wallet"+transferLabelSuffix()+"\n") io.WriteString(w, "\t\033[1m6\033[0m\tToken transfer to another wallet\n") + io.WriteString(w, "\t\033[1m7\033[0m\tAdvanced Privacy Options (extra sender cover)\n") io.WriteString(w, "\n") } - io.WriteString(w, "\t\033[1m7\033[0m\tChange wallet password\n") - io.WriteString(w, "\t\033[1m8\033[0m\tClose Wallet\n") + io.WriteString(w, "\t\033[1m8\033[0m\tChange wallet password\n") + io.WriteString(w, "\t\033[1m10\033[0m\tClose Wallet\n") if wallet.IsRegistered() { - io.WriteString(w, "\t\033[1m12\033[0m\tTransfer all balance (send DERO) To Another Wallet\n") - io.WriteString(w, "\t\033[1m13\033[0m\tShow transaction history\n") - io.WriteString(w, "\t\033[1m14\033[0m\tRescan transaction history\n") - io.WriteString(w, "\t\033[1m15\033[0m\tExport all transaction history in json format\n") + io.WriteString(w, "\t\033[1m11\033[0m\tTransfer all balance (send DERO) To Another Wallet\n") + io.WriteString(w, "\t\033[1m12\033[0m\tShow transaction history\n") + io.WriteString(w, "\t\033[1m13\033[0m\tRescan transaction history\n") + io.WriteString(w, "\t\033[1m14\033[0m\tExport all transaction history in json format\n") if xswd_server == nil { - io.WriteString(w, "\t\033[1m16\033[0m\tStart XSWD Server\n") + io.WriteString(w, "\t\033[1m15\033[0m\tStart XSWD Server\n") } else { - io.WriteString(w, "\t\033[1m16\033[0m\tStop XSWD Server\n") - io.WriteString(w, "\t\033[1m17\033[0m\tList XSWD Applications\n") + io.WriteString(w, "\t\033[1m15\033[0m\tStop XSWD Server\n") + io.WriteString(w, "\t\033[1m16\033[0m\tList XSWD Applications\n") } } @@ -216,17 +217,13 @@ func handle_easymenu_post_open_command(l *readline.Instance, line string) (proce break // invalid amount provided, bail out } - opts, _, members := promptAnonymizeAndDecoys(l, a.String()) + opts, _, members := applySessionPrivacy(a.String()) logger.Info("Review token transfer", "scid", scid.String(), "to", a.String(), "amount", globals.FormatMoney(amount_to_transfer), "ringsize", wallet.GetRingSize()) - // attribution + decoy intent are console-only (never tee'd to the on-disk log), - // so the disk artifact never binds this recipient to anonymize-intent (O3). - reportAttribution(l, opts, len(members), "requested") - if ConfirmYesNoDefaultNo(l, "Confirm Transaction (y/N)") { tx, err := wallet.TransferPayload0WithOptions([]rpc.Transfer{{SCID: scid, Amount: amount_to_transfer, Destination: a.String()}}, 0, false, rpc.Arguments{}, 0, false, opts) // empty SCDATA @@ -242,6 +239,7 @@ func handle_easymenu_post_open_command(l *readline.Instance, line string) (proce // post-send count is read off the BUILT ring, not the prompt-time request: // Strict:false drops unregistered curated decoys silently (O8). reportAttribution(l, opts, curatedDecoysInTx(tx, members, a.String()), "landed in ring") // truthful post-send confirmation (O2/O9: count only the recipient-delivering payload's ring) + resetTransferBuildToDefaults() // set-first-then-fire: build settings do not persist into the next send } case "5": @@ -392,16 +390,12 @@ func handle_easymenu_post_open_command(l *readline.Instance, line string) (proce return } - opts, _, members := promptAnonymizeAndDecoys(l, a.String()) + opts, _, members := applySessionPrivacy(a.String()) logger.Info("Review transfer", "to", a.String(), "amount", globals.FormatMoney(amount_to_transfer), "ringsize", wallet.GetRingSize()) - // attribution + decoy intent are console-only (never tee'd to the on-disk log), - // so the disk artifact never binds this recipient to anonymize-intent (O3). - reportAttribution(l, opts, len(members), "requested") - if ConfirmYesNoDefaultNo(l, "Confirm Transaction (y/N)") { //src_port := uint64(0xffffffffffffffff) @@ -421,10 +415,11 @@ func handle_easymenu_post_open_command(l *readline.Instance, line string) (proce // post-send count is read off the BUILT ring, not the prompt-time request: // Strict:false drops unregistered curated decoys silently (O8). reportAttribution(l, opts, curatedDecoysInTx(tx, members, a.String()), "landed in ring") // truthful post-send confirmation (O2/O9: count only the recipient-delivering payload's ring) + resetTransferBuildToDefaults() // set-first-then-fire: build settings do not persist into the next send //fmt.Printf("queued tx err %s\n") } - case "12": + case "11": // transfer all balance if !valid_registration_or_display_error(l, wallet) { break } @@ -465,7 +460,7 @@ func handle_easymenu_post_open_command(l *readline.Instance, line string) (proce //PressAnyKey(l, wallet) // wait for a key press - case "7": // change password + case "8": // change password if ConfirmYesNoDefaultNo(l, "Change wallet password (y/N)") && ValidateCurrentPassword(l, wallet) { @@ -478,7 +473,7 @@ func handle_easymenu_post_open_command(l *readline.Instance, line string) (proce } } - case "8": // close and discard user key + case "10": // close and discard user key wallet.Close_Encrypted_Wallet() prompt_mutex.Lock() @@ -500,14 +495,14 @@ func handle_easymenu_post_open_command(l *readline.Instance, line string) (proce fmt.Fprintf(l.Stderr(), color_yellow+"Wallet closed"+color_white) fmt.Fprintf(l.Stderr(), color_yellow+"Exiting"+color_white) - case "13": + case "12": // show transaction history var zeroscid crypto.Hash show_transfers(l, wallet, zeroscid, 100) - case "14": + case "13": // rescan transaction history logger.Info("Rescanning wallet history") rescan_bc(wallet) - case "15": + case "14": // export transaction history as json if !ValidateCurrentPassword(l, wallet) { logger.Error(fmt.Errorf("Invalid password"), "") break @@ -535,7 +530,7 @@ func handle_easymenu_post_open_command(l *readline.Instance, line string) (proce logger.Info("successfully exported history", "file", filename) } } - case "16": // start/stop xswd server + case "15": // start/stop xswd server if xswd_server != nil { xswd_server.Stop() xswd_server = nil @@ -554,7 +549,7 @@ func handle_easymenu_post_open_command(l *readline.Instance, line string) (proce if !xswd_server.IsRunning() { xswd_server = nil } - case "17": + case "16": // list xswd applications if xswd_server == nil { logger.Error(nil, "XSWD server is not running") break @@ -572,6 +567,13 @@ func handle_easymenu_post_open_command(l *readline.Instance, line string) (proce } } + case "7": // Advanced Privacy Options — opt-in extra sender cover + if !wallet.IsRegistered() { + logger.Error(nil, "Register the account first") + break + } + handleTransactionBuildMenu(l) + default: processed = false // just loop diff --git a/cmd/dero-wallet-cli/easymenu_pre_open.go b/cmd/dero-wallet-cli/easymenu_pre_open.go index 7b187e92..f9dea7ad 100644 --- a/cmd/dero-wallet-cli/easymenu_pre_open.go +++ b/cmd/dero-wallet-cli/easymenu_pre_open.go @@ -250,10 +250,14 @@ func common_processing(wallet *walletapi.Wallet_Disk) { wallet.SetNetwork(!globals.Arguments["--testnet"].(bool)) + // capture the wallet's ring size as the post-send reset target ("set first, then fire": + // a one-off per-tx ring size set in the Transaction Build Options menu reverts here). + default_ringsize = wallet.GetRingSize() + if globals.Arguments["--anonymous"] != nil && globals.Arguments["--anonymous"].(bool) { anonymize_default = true // console-only (NOT the on-disk log): never persist anonymize-intent to disk (O3). - fmt.Fprintf(os.Stderr, "Anonymize sender default ON (per-send prompt may still override)\n") + fmt.Fprintf(os.Stderr, "Extra sender privacy ON for this session (configure in the Transaction Build Options menu)\n") } if globals.Arguments["--decoys"] != nil && globals.Arguments["--decoys"].(string) != "" { for _, d := range strings.Split(globals.Arguments["--decoys"].(string), ",") { diff --git a/cmd/dero-wallet-cli/main.go b/cmd/dero-wallet-cli/main.go index 66e12a68..45bb46f5 100644 --- a/cmd/dero-wallet-cli/main.go +++ b/cmd/dero-wallet-cli/main.go @@ -82,8 +82,8 @@ Usage: --allow-rpc-password-change RPC server will change password if you send "Pass" header with new password --scan-top-n-blocks=<100000> Only scan top N blocks --save-every-x-seconds=<300> Save wallet every x seconds - --anonymous Default the per-send 'Anonymize sender?' prompt to yes (decoys hide you from the receiver) - --decoys= Comma-separated base addresses to prefer as ring decoys (prompt defaults; bad entries skipped) + --anonymous Turn on extra sender privacy for this session (see menu: Advanced Privacy Options). DERO already hides amount/sender/receiver by default; this adds an extra layer. + --decoys= Comma-separated base addresses you choose as ring decoys for the session (bad entries skipped) ` var menu_mode bool = true // default display menu mode // var account_valid bool = false // if an account has been opened, do not allow to create new account in this session diff --git a/cmd/dero-wallet-cli/prompt.go b/cmd/dero-wallet-cli/prompt.go index 5bb5b703..2f2c1750 100644 --- a/cmd/dero-wallet-cli/prompt.go +++ b/cmd/dero-wallet-cli/prompt.go @@ -450,19 +450,9 @@ func handle_set_command(l *readline.Instance, line string) { help := false switch command { case "help": - case "ringsize": - if len(line_parts) != 3 { - logger.Info("Wrong number of arguments, see help eg", "") - help = true - break - } - s, err := strconv.ParseUint(line_parts[2], 10, 64) - if err != nil { - logger.Error(err, "Error parsing ringsize") - return - } - wallet.SetRingSize(int(s)) - logger.Info("New Ring size", "ringsize", wallet.GetRingSize()) + // ring size is configured in the Transaction Build Options menu (option 7), the + // single consistent place to define how a transfer's ring is built. The old + // `set ringsize` command was removed to avoid a second, colliding config path. case "priority": if len(line_parts) != 3 { @@ -482,24 +472,9 @@ func handle_set_command(l *readline.Instance, line string) { language := choose_seed_language(l) logger.Info("Setting seed language", "language", wallet.SetSeedLanguage(language)) - case "anonymous": - if len(line_parts) != 3 { - logger.Info("Wrong number of arguments, see help eg") - help = true - break - } - switch strings.ToLower(line_parts[2]) { - case "on", "true", "yes", "1": - anonymize_default = true - case "off", "false", "no", "0": - anonymize_default = false - default: - logger.Info("Use 'set anonymous on' or 'set anonymous off'") - help = true - break - } - // console-only (NOT the on-disk log): never persist anonymize-intent to disk (O3). - fmt.Fprintf(l.Stderr(), "Anonymize sender default (session only): %v\n", anonymize_default) + // extra sender privacy is configured in the Transaction Build Options menu (option 7), + // the single consistent place to define a transfer's ring build. The old + // `set anonymous` command was removed to avoid a second, colliding config path. default: help = true @@ -509,7 +484,7 @@ func handle_set_command(l *readline.Instance, line string) { fmt.Fprintf(l.Stderr(), color_extra_white+"Current settings"+color_extra_white+"\n") fmt.Fprintf(l.Stderr(), color_normal+"Seed Language: "+color_extra_white+"%s\t"+color_normal+"eg. "+color_extra_white+"set seed language\n"+color_normal, wallet.GetSeedLanguage()) - fmt.Fprintf(l.Stderr(), color_normal+"Ringsize: "+color_extra_white+"%d\t"+color_normal+"eg. "+color_extra_white+"set ringsize 16\n"+color_normal, wallet.GetRingSize()) + fmt.Fprintf(l.Stderr(), color_normal+"Ring size: "+color_extra_white+"%d\t"+color_normal+"set in the "+color_extra_white+"Transaction Build Options"+color_normal+" menu (option 7)\n"+color_normal, wallet.GetRingSize()) fmt.Fprintf(l.Stderr(), color_normal+"Save Every : "+color_extra_white+"%s \t"+color_normal+"eg. "+color_extra_white+"default value:0 (set using command line)\n"+color_normal, wallet.SetSaveDuration(-1)) fmt.Fprintf(l.Stderr(), color_normal+"Track Recent Blocks : "+color_extra_white+"%d \t"+color_normal+"eg. "+color_extra_white+"default value:0 means track all blocks (set using command line)\n"+color_normal, wallet.SetTrackRecentBlocks(-1)) @@ -520,7 +495,7 @@ func handle_set_command(l *readline.Instance, line string) { if anonymize_default { anon = "on" } - fmt.Fprintf(l.Stderr(), color_normal+"Anonymize sender: "+color_extra_white+"%s\t"+color_normal+"eg. "+color_extra_white+"set anonymous on\t"+color_normal+"Session default; per-send prompt still decides. Needs ringsize >= 4.\n", anon) + fmt.Fprintf(l.Stderr(), color_normal+"Extra sender privacy: "+color_extra_white+"%s\t"+color_normal+"set in the "+color_extra_white+"Transaction Build Options"+color_normal+" menu (option 7). Needs ring size >= 4.\n", anon) } } From 0003c619f315018f5991052d12e277195d1ec3bc Mon Sep 17 00:00:00 2001 From: DHEBP <152355273+DHEBP@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:44:35 -0400 Subject: [PATCH 14/15] feat(wallet): explicit, warned self-attribution (Default/Self) mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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 #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. --- cmd/dero-wallet-cli/anonymity.go | 225 ++++++++++++++++++----- cmd/dero-wallet-cli/anonymity_test.go | 209 +++++++++++++++------ cmd/dero-wallet-cli/easymenu_pre_open.go | 4 +- cmd/dero-wallet-cli/prompt.go | 6 +- 4 files changed, 331 insertions(+), 113 deletions(-) diff --git a/cmd/dero-wallet-cli/anonymity.go b/cmd/dero-wallet-cli/anonymity.go index c740cde4..001103f6 100644 --- a/cmd/dero-wallet-cli/anonymity.go +++ b/cmd/dero-wallet-cli/anonymity.go @@ -30,10 +30,17 @@ import ( "github.com/deroproject/derohe/walletapi" ) -// session-scoped "extra sender privacy" flag. NOT persisted across wallet close/reopen -// (would require an engine Account field, which is forbidden). Seeded by --anonymous at -// startup; toggled in the Advanced Privacy Options menu or by "set anonymous on/off". -var anonymize_default bool +// session-scoped sender-attribution mode for the next transfer. NOT persisted across +// wallet close/reopen (would require an engine Account field, which is forbidden). The +// zero value is walletapi.AttributionHonest — the receiver-pointed DEFAULT — so an +// untouched session reproduces today's behavior exactly (the no-op contract). Cycled in +// the Transaction Build Options menu (Default -> Anonymous -> Self -> Default); --anonymous +// seeds it to Anonymous at startup. +// - AttributionHonest (default): receiver-pointed; sender hidden, nothing extra leaked. +// - AttributionAnonymous : sender hidden in a decoy ring slot (needs ring size >= 4). +// - AttributionSelf : DELIBERATE self-doxx; writes the real sender slot. Advanced-only, +// gated behind a mandatory loud warning; works at ANY ring size. +var attribution_mode walletapi.AttributionMode // session-scoped user-chosen ring decoys, seeded by --decoys and edited in the // Transaction Build Options menu. Only ever addresses the user supplies — never auto-selected. @@ -45,16 +52,16 @@ var decoys_default []string // wallet default until set from the opened wallet. var default_ringsize = 16 -// buildTransferOptions constructs opts honoring the no-op contract: when anonymize -// is false AND members is empty, it returns a LITERAL zero value, so Ring stays nil -// (the engine fast path) and Attribution stays AttributionHonest. opts.Ring is -// assigned ONLY when len(members) > 0 — a non-nil empty Ring pointer would change -// the engine code path and break byte-identity with today's behavior. -func buildTransferOptions(anonymize bool, members []string) walletapi.TransferOptions { +// buildTransferOptions constructs opts honoring the no-op contract: when mode is +// AttributionHonest (the zero value) AND members is empty, it returns a LITERAL zero +// value, so Ring stays nil (the engine fast path) and Attribution stays AttributionHonest. +// opts.Ring is assigned ONLY when len(members) > 0 — a non-nil empty Ring pointer would +// change the engine code path and break byte-identity with today's behavior. The mode is +// carried through verbatim: Anonymous and Self are independently selectable and either may +// be combined with curated decoys (they configure different fields). +func buildTransferOptions(mode walletapi.AttributionMode, members []string) walletapi.TransferOptions { opts := walletapi.TransferOptions{} - if anonymize { - opts.Attribution = walletapi.AttributionAnonymous - } + opts.Attribution = mode if len(members) > 0 { opts.Ring = &walletapi.RingPreference{PreferredDecoys: members, Strict: false} } @@ -83,23 +90,30 @@ func anonymizeEffectiveAtRingsize(ringsize int) bool { return ringsize >= 4 } -// resolveAnonymizeOrDowngrade enforces the prompt's promise against the engine reality. -// When the user asked to anonymize but the effective ringsize has no decoy slot, the -// engine would ship an HONEST (verifiably-attributed) transfer (cli-head) or hard-error -// (post-rebase). Either way the user's belief "I am hidden" is false. So the CLI -// fails closed: it DOWNGRADES anonymize to false here and tells the user plainly, so -// what is built, broadcast, and reported all agree (criterion 1). Returns the effective -// anonymize flag the rest of the flow must use. -func resolveAnonymizeOrDowngrade(anonymize bool) bool { - if anonymize && !anonymizeEffectiveAtRingsize(wallet.GetRingSize()) { +// resolveModeOrDowngrade enforces the prompt's promise against the engine reality. ONLY +// AttributionAnonymous depends on the ring being large enough: it needs a decoy slot +// (witness_index[2:], i.e. ring size >= 4). When the user asked for Anonymous but the +// effective ringsize has no decoy slot, the engine would ship an HONEST (verifiably- +// attributed) transfer (cli-head) or hard-error (post-rebase). Either way the user's +// belief "I am hidden" is false, so the CLI fails closed: it DOWNGRADES the mode to +// Honest here and tells the user plainly, so what is built, broadcast, and reported all +// agree (criterion 1). +// +// AttributionSelf and AttributionHonest pass through UNCHANGED: both write a slot that +// always exists at any ring size (sender [0] / receiver [1]). Self in particular must NOT +// be downgraded — it is a deliberate, valid choice at ring 2, and silently "fixing" it to +// Honest would defeat the user's explicit intent to be attributable. Returns the effective +// mode the rest of the flow must use. +func resolveModeOrDowngrade(mode walletapi.AttributionMode) walletapi.AttributionMode { + if mode == walletapi.AttributionAnonymous && !anonymizeEffectiveAtRingsize(wallet.GetRingSize()) { logger.Info(color_yellow + "Anonymize CANCELLED: ringsize " + fmt.Sprintf("%d", wallet.GetRingSize()) + " has no decoy slot (needs 4 or higher). This transfer will ship HONEST — " + "your sender address IS visible to the receiver. Run 'set ringsize 16' and " + "resend if you want to be anonymized." + color_normal) - return false + return walletapi.AttributionHonest } - return anonymize + return mode } // canonBase reduces an address string to its canonical base (pubkey) form for @@ -218,18 +232,21 @@ func collectDecoys(l *readline.Instance, defaults []string, recipient string) [] // It still enforces the promise against engine reality: if the user enabled extra privacy // but the effective ringsize cannot host it, anonymize is downgraded to honest (with a // plain notice) so what is built, broadcast, and reported all agree. -func applySessionPrivacy(recipient string) (opts walletapi.TransferOptions, anonymize bool, members []string) { - anonymize = resolveAnonymizeOrDowngrade(anonymize_default) - if anonymize { - // session decoys are already user-entered (Advanced menu / --decoys); re-validate - // them against THIS recipient (the recipient seed is per-send) and canonicalize. +func applySessionPrivacy(recipient string) (opts walletapi.TransferOptions, mode walletapi.AttributionMode, members []string) { + mode = resolveModeOrDowngrade(attribution_mode) + // curated decoys are an independent knob: they may accompany ANY non-default mode + // (Anonymous OR Self — Azylem: mix freely). They are NOT collected for a plain default + // (Honest, no decoys) send, preserving the no-op fast path. session decoys are already + // user-entered (Advanced menu / --decoys); re-validate them against THIS recipient (the + // recipient seed is per-send) and canonicalize. + if len(decoys_default) > 0 { c := newDecoyCollector(recipient) for _, d := range decoys_default { c.add(d) } members = c.members } - return buildTransferOptions(anonymize, members), anonymize, members + return buildTransferOptions(mode, members), mode, members } // advancedSettingsActive reports whether any opt-in build setting is engaged (extra @@ -237,17 +254,37 @@ func applySessionPrivacy(recipient string) (opts walletapi.TransferOptions, anon // enabled)" suffix on the Transfer menu label so the user always sees, before they // commit, that this tx will be built differently from the plain default path. func advancedSettingsActive() bool { - return anonymize_default || len(decoys_default) > 0 + return attribution_mode != walletapi.AttributionHonest || len(decoys_default) > 0 +} + +// attributionModeLabel is the short, human label for a session attribution mode, used in +// the menu readout and the advanced-settings suffix so the user always sees which of the +// three modes the next tx will use. SELF is the one the user must consciously have chosen. +func attributionModeLabel(mode walletapi.AttributionMode) string { + switch mode { + case walletapi.AttributionAnonymous: + return "ANONYMOUS" + case walletapi.AttributionSelf: + return "SELF" + default: + return "DEFAULT" + } } // transferLabelSuffix is appended to the Transfer (option 5) menu line so option 5 is a // live readout of how the next tx will be built: the plain default path reads // "(default, ringsize N)"; once extra privacy / curated decoys are engaged it reads -// "(ringsize N) (advanced settings enabled)" with the advanced part in red. +// "(ringsize N) (advanced settings enabled — attribution)" with the advanced part +// in red, so a self-attribution build is loudly distinguished from an anonymous one. func transferLabelSuffix() string { if advancedSettingsActive() { + detail := " (advanced settings enabled" + if attribution_mode != walletapi.AttributionHonest { + detail += " — " + strings.ToLower(attributionModeLabel(attribution_mode)) + " attribution" + } + detail += ")" return fmt.Sprintf(" (ringsize %d)", wallet.GetRingSize()) + - color_red + " (advanced settings enabled)" + color_normal + color_red + detail + color_normal } return fmt.Sprintf(" (default, ringsize %d)", wallet.GetRingSize()) } @@ -257,11 +294,83 @@ func transferLabelSuffix() string { // configuration cannot silently persist into the next, unrelated send. Resets ALL three: // extra privacy OFF, chosen decoys cleared, and ring size back to the session default. func resetTransferBuildToDefaults() { - anonymize_default = false + attribution_mode = walletapi.AttributionHonest decoys_default = nil wallet.SetRingSize(default_ringsize) } +// nextAttributionMode is the pure cycle transition for option 2: +// DEFAULT(Honest) → ANONYMOUS → SELF → DEFAULT. It is readline-free so the cycle order +// is unit-testable without a terminal. Landing on SELF is gated by the caller's warning, +// not here — this only computes the next value. +func nextAttributionMode(mode walletapi.AttributionMode) walletapi.AttributionMode { + switch mode { + case walletapi.AttributionHonest: + return walletapi.AttributionAnonymous + case walletapi.AttributionAnonymous: + return walletapi.AttributionSelf + default: // AttributionSelf (or any unexpected value) cycles back to the safe default + return walletapi.AttributionHonest + } +} + +// selfAttributionWarning is the mandatory, loud confirmation shown when the cycle LANDS on +// SELF. Self-attribution is a deliberate self-doxx: it writes the real sender slot into the +// receiver-readable attribution byte, which the recipient — and anyone who ever decrypts +// this transaction in the future (an old/non-scrubbing wallet, a third-party tool, a future +// crypto-break) — can read to prove the sender. The #21 scrub blanks it for up-to-date +// receiver wallets at ring>2, but does NOT make this "safe"; the raw byte is permanent. +// Returns true only on an explicit "y"/"yes"; any other answer (incl. read error) declines. +func selfAttributionWarning(l *readline.Instance) bool { + fmt.Fprintf(l.Stderr(), "\n%s⚠ SELF-ATTRIBUTION — DELIBERATE, PERMANENT SELF-DOXX%s\n", color_red, color_normal) + fmt.Fprintf(l.Stderr(), "%sThis writes YOUR real ring slot into the attribution field of this transaction,\n", color_yellow) + fmt.Fprintf(l.Stderr(), "permanently, on-chain. A CURRENT, up-to-date wallet still hides it on decode\n") + fmt.Fprintf(l.Stderr(), "(ring>2 is blanked, ring 2 is structural either way), so a modern recipient\n") + fmt.Fprintf(l.Stderr(), "gains nothing extra TODAY. The point is the permanent record: anyone who\n") + fmt.Fprintf(l.Stderr(), "decrypts this transfer with the recipient's view key — an OLD or non-blanking\n") + fmt.Fprintf(l.Stderr(), "wallet, a third-party tool, or a future crypto-break — can then prove YOU sent\n") + fmt.Fprintf(l.Stderr(), "it. This is irreversible once broadcast. Choose this ONLY to deliberately and\n") + fmt.Fprintf(l.Stderr(), "permanently stake that you are the sender.%s\n", color_normal) + ans, err := ReadString(l, "Enable SELF-attribution? (y/N)", "N") + if err != nil { + return false + } + switch strings.ToLower(strings.TrimSpace(ans)) { + case "y", "yes": + return true + default: + return false + } +} + +// cycleAttributionMode advances the session attribution mode one step on the cycle and, +// when the next mode is SELF, gates it behind the mandatory loud warning: declining leaves +// the mode unchanged (it does NOT skip past SELF — the user stays where they were so a +// careless extra press can't silently land them in DEFAULT having intended SELF). For the +// non-SELF transitions it just applies, with a ring-size note when ANONYMOUS won't fit. +func cycleAttributionMode(l *readline.Instance) { + next := nextAttributionMode(attribution_mode) + if next == walletapi.AttributionSelf { + if !selfAttributionWarning(l) { + logger.Info("Self-attribution NOT enabled; sender attribution unchanged (" + attributionModeLabel(attribution_mode) + ").") + return + } + } + attribution_mode = next + switch attribution_mode { + case walletapi.AttributionAnonymous: + if !anonymizeEffectiveAtRingsize(wallet.GetRingSize()) { + logger.Info(color_yellow + "Note: ring size is " + fmt.Sprintf("%d", wallet.GetRingSize()) + + "; anonymous attribution needs ring size 4+ to take effect. Set ring size (option 1) to 16." + color_normal) + } + case walletapi.AttributionSelf: + logger.Info(color_red + "SELF-attribution ENABLED — your sender slot is written permanently on-chain " + + "(a current wallet still hides it; an old/non-blanking/future reader can prove you sent it)." + color_normal) + default: // back to DEFAULT + logger.Info("Sender attribution back to DEFAULT (receiver-pointed; your sender is hidden).") + } +} + // handleTransactionBuildMenu is the opt-in, unified "Transaction Build Options" submenu. // It is the single place to configure how the NEXT transfer's ring is built — ring size, // extra sender privacy, and user-chosen decoys — so there is ONE consistent flow rather @@ -270,10 +379,6 @@ func resetTransferBuildToDefaults() { // commands remain and set the same state. func handleTransactionBuildMenu(l *readline.Instance) { for { - on := "OFF" - if anonymize_default { - on = "ON" - } decoyState := "(none — uses random ring members)" if len(decoys_default) > 0 { decoyState = fmt.Sprintf("(%d chosen)", len(decoys_default)) @@ -282,8 +387,12 @@ func handleTransactionBuildMenu(l *readline.Instance) { fmt.Fprintf(l.Stderr(), "%s DERO already hides your amount, sender, and receiver\n", color_normal) fmt.Fprintf(l.Stderr(), " by default. Configure how your next transfer is built\n") fmt.Fprintf(l.Stderr(), " (extra sender cover is for advanced users).%s\n\n", color_normal) + modeColor := color_yellow + if attribution_mode == walletapi.AttributionSelf { + modeColor = color_red // self attribution is a deliberate self-doxx — show it in red + } fmt.Fprintf(l.Stderr(), "\t%s1%s\tRing size: %s%d%s\n", color_extra_white, color_normal, color_yellow, wallet.GetRingSize(), color_normal) - fmt.Fprintf(l.Stderr(), "\t%s2%s\tExtra sender privacy: %s%s%s\n", color_extra_white, color_normal, color_yellow, on, color_normal) + fmt.Fprintf(l.Stderr(), "\t%s2%s\tSender attribution: %s%s%s (press 2 to cycle: DEFAULT → ANONYMOUS → SELF)\n", color_extra_white, color_normal, modeColor, attributionModeLabel(attribution_mode), color_normal) fmt.Fprintf(l.Stderr(), "\t%s3%s\tYour chosen ring decoys: %s\n", color_extra_white, color_normal, decoyState) fmt.Fprintf(l.Stderr(), "\t%s4%s\tClear chosen decoys\n", color_extra_white, color_normal) fmt.Fprintf(l.Stderr(), "\t%s0%s\tBack\n", color_extra_white, color_normal) @@ -310,11 +419,7 @@ func handleTransactionBuildMenu(l *readline.Instance) { logger.Error(fmt.Errorf("invalid ring size"), "Ring size must be a power of 2 between 2 and 128; unchanged", "requested", n, "current", got) } case "2": - anonymize_default = !anonymize_default - if anonymize_default && !anonymizeEffectiveAtRingsize(wallet.GetRingSize()) { - logger.Info(color_yellow + "Note: ring size is " + fmt.Sprintf("%d", wallet.GetRingSize()) + - "; extra sender privacy needs ring size 4+ to take effect. Set ring size (option 1) to 16." + color_normal) - } + cycleAttributionMode(l) case "3": // decoys are collected from what the user TYPES; no recipient is known here // (it is a per-send value), so seed the collector with the sender only. The @@ -434,9 +539,33 @@ func reportAttribution(l *readline.Instance, opts walletapi.TransferOptions, dec // flag. Used both in the pre-send review and the post-send confirmation so the user can // always tell after the fact whether they were actually anonymized (criterion 1). func attributionResultLine(opts walletapi.TransferOptions) string { - if opts.Attribution == walletapi.AttributionAnonymous && - anonymizeEffectiveAtRingsize(wallet.GetRingSize()) { - return "ANONYMOUS (sender hidden in the decoy ring)" + switch opts.Attribution { + case walletapi.AttributionSelf: + // self always WRITES — witness_index[0] exists at any ring size — so the build state + // is reported verbatim, never downgraded (this is the criterion-3 teeth: Self bypasses + // the ring-size gate and the renderer must agree with that build state). But be accurate + // about WHAT the written byte actually buys, which DIFFERS by ring size (O7): + // ring > 2: decode reads the byte (daemon_communication.go:1073/:1127). The #21 scrub + // blanks it for a current wallet (ring>2 ⇒ SenderVerified=false ⇒ Sender="" and + // payload[0]→0x00, :1098/:1152), but the RAW on-chain byte = witness_index[0] is + // permanent: a non-blanking/old/future reader recovers the real sender from it. So + // here Self DOES create an incremental, provable record over Honest. + // ring 2: decode HARD-OVERRIDES sender_idx from the recipient's own loop position and + // NEVER reads the byte (:1075-1080/:1129-1134), and SenderVerified=true exports the + // sender for Honest too (:1089/:1143). The true sender is provable from ring-2 + // STRUCTURE alone, identically for Honest and Self — so the written byte is inert and + // Self adds NOTHING incremental. Don't claim Self "creates" the provable record here. + if uint64(wallet.GetRingSize()) == 2 { + return "SELF (ring 2 — your sender is already structurally provable; the written byte adds nothing over HONEST)" + } + return "SELF (your sender slot is written permanently on-chain — provable by a non-blanking/future reader)" + case walletapi.AttributionAnonymous: + if anonymizeEffectiveAtRingsize(wallet.GetRingSize()) { + return "ANONYMOUS (sender hidden in the decoy ring)" + } + // requested ANONYMOUS but ring too small — the engine ships honest; report the truth. + return "HONEST (your sender address is visible to the receiver)" + default: + return "HONEST (your sender address is visible to the receiver)" } - return "HONEST (your sender address is visible to the receiver)" } diff --git a/cmd/dero-wallet-cli/anonymity_test.go b/cmd/dero-wallet-cli/anonymity_test.go index bd00455c..2ca6978a 100644 --- a/cmd/dero-wallet-cli/anonymity_test.go +++ b/cmd/dero-wallet-cli/anonymity_test.go @@ -15,12 +15,13 @@ import ( "github.com/deroproject/derohe/walletapi" ) -// TestBuildTransferOptionsNoOp locks the backward-compat contract: with anonymize -// off and no decoys, buildTransferOptions MUST return the literal zero value so the -// engine reproduces today's behavior byte-for-byte (honest attribution, nil Ring → -// random ring selection). A regression here would silently change every default send. +// TestBuildTransferOptionsNoOp locks the backward-compat contract: with the DEFAULT +// (AttributionHonest, the zero value) mode and no decoys, buildTransferOptions MUST return +// the literal zero value so the engine reproduces today's behavior byte-for-byte (honest +// attribution, nil Ring → random ring selection). A regression here would silently change +// every default send. func TestBuildTransferOptionsNoOp(t *testing.T) { - opts := buildTransferOptions(false, nil) + opts := buildTransferOptions(walletapi.AttributionHonest, nil) if opts != (walletapi.TransferOptions{}) { t.Fatalf("expected zero-value TransferOptions, got %+v", opts) @@ -33,15 +34,16 @@ func TestBuildTransferOptionsNoOp(t *testing.T) { } // an empty (non-nil) members slice must also stay a no-op (Ring must stay nil). - if got := buildTransferOptions(false, []string{}); got.Ring != nil { + if got := buildTransferOptions(walletapi.AttributionHonest, []string{}); got.Ring != nil { t.Fatalf("empty members must not produce a non-nil Ring, got %+v", got.Ring) } } -// TestBuildTransferOptionsAnonymous verifies the opt-in paths flip exactly the -// expected fields and nothing else. -func TestBuildTransferOptionsAnonymous(t *testing.T) { - opts := buildTransferOptions(true, nil) +// TestBuildTransferOptionsModes verifies each opt-in mode flips exactly the expected fields +// and nothing else, and that curated decoys are an INDEPENDENT knob (they may accompany any +// mode and never change the attribution). +func TestBuildTransferOptionsModes(t *testing.T) { + opts := buildTransferOptions(walletapi.AttributionAnonymous, nil) if opts.Attribution != walletapi.AttributionAnonymous { t.Fatalf("expected AttributionAnonymous, got %v", opts.Attribution) } @@ -49,8 +51,17 @@ func TestBuildTransferOptionsAnonymous(t *testing.T) { t.Fatalf("expected nil Ring with no decoys, got %+v", opts.Ring) } + // SELF carries through verbatim with nil Ring when no decoys are supplied. + opts = buildTransferOptions(walletapi.AttributionSelf, nil) + if opts.Attribution != walletapi.AttributionSelf { + t.Fatalf("expected AttributionSelf, got %v", opts.Attribution) + } + if opts.Ring != nil { + t.Fatalf("self with no decoys must keep Ring nil, got %+v", opts.Ring) + } + members := []string{"dero1qyabc"} - opts = buildTransferOptions(false, members) + opts = buildTransferOptions(walletapi.AttributionHonest, members) if opts.Attribution != walletapi.AttributionHonest { t.Fatalf("decoys alone must not change attribution, got %v", opts.Attribution) } @@ -60,6 +71,26 @@ func TestBuildTransferOptionsAnonymous(t *testing.T) { if opts.Ring.Strict { t.Fatalf("Strict must default to false (forgiving skip + random-fill)") } + + // SELF + decoys mix freely (Azylem: independently optional). + opts = buildTransferOptions(walletapi.AttributionSelf, members) + if opts.Attribution != walletapi.AttributionSelf || opts.Ring == nil || len(opts.Ring.PreferredDecoys) != 1 { + t.Fatalf("self must coexist with curated decoys; got attr=%v ring=%+v", opts.Attribution, opts.Ring) + } +} + +// TestNextAttributionMode locks the cycle order DEFAULT → ANONYMOUS → SELF → DEFAULT. +// (Teeth: change any arm and the wheel either skips SELF or never returns to DEFAULT.) +func TestNextAttributionMode(t *testing.T) { + if got := nextAttributionMode(walletapi.AttributionHonest); got != walletapi.AttributionAnonymous { + t.Fatalf("DEFAULT must cycle to ANONYMOUS, got %v", got) + } + if got := nextAttributionMode(walletapi.AttributionAnonymous); got != walletapi.AttributionSelf { + t.Fatalf("ANONYMOUS must cycle to SELF, got %v", got) + } + if got := nextAttributionMode(walletapi.AttributionSelf); got != walletapi.AttributionHonest { + t.Fatalf("SELF must cycle back to DEFAULT, got %v", got) + } } // TestAnonymizeEffectiveAtRingsize pins the single source of truth the CLI uses to @@ -100,26 +131,33 @@ func newTestWallet(t *testing.T) (cleanup func()) { } } -// TestResolveAnonymizeOrDowngrade is the O1 teeth: when the user asks to anonymize but -// the ringsize cannot host anonymity, the CLI MUST downgrade the flag to false so the -// built opts, the review line, and the post-send report all agree with the engine -// (which ships HONEST at ring<4). A false return here is the false-anonymity bug. -func TestResolveAnonymizeOrDowngrade(t *testing.T) { +// TestResolveModeOrDowngrade is the O1 teeth: when the user asks for ANONYMOUS but the +// ringsize cannot host it, the CLI MUST downgrade the mode to DEFAULT so the built opts, +// the review line, and the post-send report all agree with the engine (which ships HONEST +// at ring<4). It must also NEVER downgrade SELF — slot 0 always exists, so Self is valid +// at any ring size and silently "fixing" it would defeat the user's explicit intent. +func TestResolveModeOrDowngrade(t *testing.T) { defer newTestWallet(t)() - // ring 2: anonymize must be CANCELLED (downgraded to honest). + // ring 2: ANONYMOUS must be CANCELLED (downgraded to DEFAULT/honest). wallet.SetRingSize(2) - if resolveAnonymizeOrDowngrade(true) { - t.Fatalf("ring 2: anonymize must downgrade to false (engine ships honest); got true") + if got := resolveModeOrDowngrade(walletapi.AttributionAnonymous); got != walletapi.AttributionHonest { + t.Fatalf("ring 2: anonymous must downgrade to DEFAULT (engine ships honest); got %v", got) } - // ring 4: anonymize survives. + // ring 4: ANONYMOUS survives. wallet.SetRingSize(4) - if !resolveAnonymizeOrDowngrade(true) { - t.Fatalf("ring 4: anonymize must survive; got false") + if got := resolveModeOrDowngrade(walletapi.AttributionAnonymous); got != walletapi.AttributionAnonymous { + t.Fatalf("ring 4: anonymous must survive; got %v", got) } - // honest request is never upgraded. - if resolveAnonymizeOrDowngrade(false) { - t.Fatalf("honest request must stay honest at any ringsize") + // DEFAULT request is never upgraded, at any ringsize. + if got := resolveModeOrDowngrade(walletapi.AttributionHonest); got != walletapi.AttributionHonest { + t.Fatalf("DEFAULT request must stay DEFAULT at any ringsize; got %v", got) + } + // SELF must NEVER be downgraded, even at ring 2 (slot 0 always exists). This is the + // teeth that Self bypasses the ring-size gate. + wallet.SetRingSize(2) + if got := resolveModeOrDowngrade(walletapi.AttributionSelf); got != walletapi.AttributionSelf { + t.Fatalf("ring 2: SELF must pass through (never downgraded); got %v", got) } } @@ -142,6 +180,40 @@ func TestAttributionResultLine(t *testing.T) { if got := attributionResultLine(walletapi.TransferOptions{}); got[0] != 'H' { t.Fatalf("honest opts must report HONEST, got %q", got) } + + // SELF opts always report SELF, at ANY ring size (never downgraded — slot 0 always + // exists). This is the teeth that Self bypasses the ring-size gate. + selfOpts := walletapi.TransferOptions{Attribution: walletapi.AttributionSelf} + wallet.SetRingSize(2) + selfRing2 := attributionResultLine(selfOpts) + if selfRing2 == "" || selfRing2[0] != 'S' { + t.Fatalf("ring 2 self opts must report SELF (never downgraded), got %q", selfRing2) + } + wallet.SetRingSize(16) + selfRing16 := attributionResultLine(selfOpts) + if selfRing16 == "" || selfRing16[0] != 'S' { + t.Fatalf("ring 16 self opts must report SELF, got %q", selfRing16) + } + + // O7 teeth: the renderer must be ring-2-AWARE, not a flat overstating string. At ring 2 + // the written byte is inert (decode hard-overrides sender_idx from loop position and never + // reads it; SenderVerified=true exports the sender for HONEST too), so Self adds nothing + // incremental — the line must say so and must NOT claim the byte makes the sender provable. + // At ring>2 the byte IS the incremental permanent record. The two lines MUST differ. + // Mutation: revert the Self branch to the single flat string and both checks below go RED. + if selfRing2 == selfRing16 { + t.Fatalf("O7: ring-2 SELF line must differ from ring>2 (ring 2 byte is inert); both were %q", selfRing2) + } + if strings.Contains(selfRing2, "provable by a non-blanking") || strings.Contains(selfRing2, "future reader") { + t.Fatalf("O7: ring-2 SELF line must NOT claim the written byte creates a future-reader record; got %q", selfRing2) + } + if !strings.Contains(selfRing2, "HONEST") { + t.Fatalf("O7: ring-2 SELF line must convey it adds nothing over HONEST; got %q", selfRing2) + } + // ring>2 must still carry the permanent-record warning (the real incremental leak). + if !strings.Contains(selfRing16, "permanently") { + t.Fatalf("O7: ring>2 SELF line must warn of the permanent on-chain record; got %q", selfRing16) + } } // TestCollectDecoysRejectsRecipientAndSelf is the O5 teeth: collectDecoys must reject @@ -366,45 +438,54 @@ func TestCanonBase(t *testing.T) { } // TestApplySessionPrivacy locks the NEW silent send path (the Advanced Privacy menu -// redesign): a send no longer prompts — it reads the session settings (anonymize_default, +// redesign): a send no longer prompts — it reads the session settings (attribution_mode, // decoys_default) and builds opts silently. The contract: -// - privacy OFF -> literal zero-value opts (no-op; engine fast path unchanged) -// - privacy ON, ring>=4 -> AttributionAnonymous; any user-chosen decoys carried -// - privacy ON, ring<4 -> fails closed: downgraded to honest (no false anonymity) +// - DEFAULT mode, no decoys -> literal zero-value opts (no-op; engine fast path unchanged) +// - ANONYMOUS, ring>=4 -> AttributionAnonymous; any user-chosen decoys carried +// - ANONYMOUS, ring<4 -> fails closed: downgraded to DEFAULT (no false anonymity) +// - SELF, ANY ring -> AttributionSelf, NEVER downgraded (slot 0 always exists) // - never auto-selects decoys (Azylem): Ring is nil unless the user supplied members func TestApplySessionPrivacy(t *testing.T) { defer newTestWallet(t)() // save+restore the package session globals this test mutates. - prevAnon, prevDecoys := anonymize_default, decoys_default - t.Cleanup(func() { anonymize_default, decoys_default = prevAnon, prevDecoys }) + prevMode, prevDecoys := attribution_mode, decoys_default + t.Cleanup(func() { attribution_mode, decoys_default = prevMode, prevDecoys }) - // ── case 1: privacy OFF -> exact no-op (zero value) ─────────────────────────────── - anonymize_default, decoys_default = false, nil + // ── case 1: DEFAULT, no decoys -> exact no-op (zero value) ───────────────────────── + attribution_mode, decoys_default = walletapi.AttributionHonest, nil wallet.SetRingSize(16) - opts, anon, members := applySessionPrivacy("") - if anon || members != nil || opts.Ring != nil || opts.Attribution != walletapi.AttributionHonest { - t.Fatalf("privacy OFF must be a literal no-op; got anon=%v members=%v ring=%v attr=%v", - anon, members, opts.Ring != nil, opts.Attribution) + opts, mode, members := applySessionPrivacy("") + if mode != walletapi.AttributionHonest || members != nil || opts.Ring != nil || opts.Attribution != walletapi.AttributionHonest { + t.Fatalf("DEFAULT must be a literal no-op; got mode=%v members=%v ring=%v attr=%v", + mode, members, opts.Ring != nil, opts.Attribution) } - // ── case 2: privacy ON at ring>=4 -> anonymous, no decoys -> Ring stays nil ──────── - anonymize_default, decoys_default = true, nil + // ── case 2: ANONYMOUS at ring>=4 -> anonymous, no decoys -> Ring stays nil ────────── + attribution_mode, decoys_default = walletapi.AttributionAnonymous, nil wallet.SetRingSize(16) - opts, anon, members = applySessionPrivacy("") - if !anon || opts.Attribution != walletapi.AttributionAnonymous { - t.Fatalf("privacy ON ring16: expected anonymous, got anon=%v attr=%v", anon, opts.Attribution) + opts, mode, members = applySessionPrivacy("") + if mode != walletapi.AttributionAnonymous || opts.Attribution != walletapi.AttributionAnonymous { + t.Fatalf("ANONYMOUS ring16: expected anonymous, got mode=%v attr=%v", mode, opts.Attribution) } if opts.Ring != nil || len(members) != 0 { t.Fatalf("no user decoys -> Ring must stay nil (never auto-selected); got ring=%v members=%v", opts.Ring != nil, members) } - // ── case 3: privacy ON at ring<4 -> fails closed to honest ───────────────────────── - anonymize_default, decoys_default = true, nil + // ── case 3: ANONYMOUS at ring<4 -> fails closed to DEFAULT ────────────────────────── + attribution_mode, decoys_default = walletapi.AttributionAnonymous, nil + wallet.SetRingSize(2) + opts, mode, _ = applySessionPrivacy("") + if mode != walletapi.AttributionHonest || opts.Attribution != walletapi.AttributionHonest { + t.Fatalf("ANONYMOUS ring2 MUST downgrade to DEFAULT (no false anonymity); got mode=%v attr=%v", mode, opts.Attribution) + } + + // ── case 4: SELF at ring<4 -> NOT downgraded (slot 0 always exists) ───────────────── + attribution_mode, decoys_default = walletapi.AttributionSelf, nil wallet.SetRingSize(2) - opts, anon, _ = applySessionPrivacy("") - if anon || opts.Attribution != walletapi.AttributionHonest { - t.Fatalf("privacy ON ring2 MUST downgrade to honest (no false anonymity); got anon=%v attr=%v", anon, opts.Attribution) + opts, mode, _ = applySessionPrivacy("") + if mode != walletapi.AttributionSelf || opts.Attribution != walletapi.AttributionSelf { + t.Fatalf("SELF ring2 MUST pass through (never downgraded); got mode=%v attr=%v", mode, opts.Attribution) } } @@ -415,22 +496,23 @@ func TestApplySessionPrivacy(t *testing.T) { func TestResetTransferBuildToDefaults(t *testing.T) { defer newTestWallet(t)() - prevAnon, prevDecoys, prevDef := anonymize_default, decoys_default, default_ringsize - t.Cleanup(func() { anonymize_default, decoys_default, default_ringsize = prevAnon, prevDecoys, prevDef }) + prevMode, prevDecoys, prevDef := attribution_mode, decoys_default, default_ringsize + t.Cleanup(func() { attribution_mode, decoys_default, default_ringsize = prevMode, prevDecoys, prevDef }) // the session default the reset must restore to. default_ringsize = 16 wallet.SetRingSize(16) - // simulate an advanced configuration for one tx. - anonymize_default = true + // simulate an advanced configuration for one tx — use SELF (the strongest non-default) + // to prove reset snaps even a self-doxx setting back to the safe default. + attribution_mode = walletapi.AttributionSelf decoys_default = []string{"deto1aaa", "deto1bbb"} wallet.SetRingSize(8) // a one-off ring size for this tx resetTransferBuildToDefaults() - if anonymize_default { - t.Fatalf("reset must turn extra privacy OFF; still on") + if attribution_mode != walletapi.AttributionHonest { + t.Fatalf("reset must restore DEFAULT attribution; got %v", attribution_mode) } if decoys_default != nil { t.Fatalf("reset must clear chosen decoys; got %v", decoys_default) @@ -446,10 +528,10 @@ func TestResetTransferBuildToDefaults(t *testing.T) { // before committing, how the next tx will be built. func TestTransferLabelSuffix(t *testing.T) { defer newTestWallet(t)() - prevAnon, prevDecoys := anonymize_default, decoys_default - t.Cleanup(func() { anonymize_default, decoys_default = prevAnon, prevDecoys }) + prevMode, prevDecoys := attribution_mode, decoys_default + t.Cleanup(func() { attribution_mode, decoys_default = prevMode, prevDecoys }) - anonymize_default, decoys_default = false, nil + attribution_mode, decoys_default = walletapi.AttributionHonest, nil wallet.SetRingSize(16) if s := transferLabelSuffix(); !strings.Contains(s, "default") || !strings.Contains(s, "ringsize 16") { t.Fatalf("default path label must read (default, ringsize 16); got %q", s) @@ -458,8 +540,17 @@ func TestTransferLabelSuffix(t *testing.T) { t.Fatalf("default path must NOT show the advanced marker") } - anonymize_default = true - if s := transferLabelSuffix(); !strings.Contains(s, "advanced settings enabled") || strings.Contains(s, "default") { - t.Fatalf("advanced path label must show the advanced marker and drop 'default'; got %q", s) + attribution_mode = walletapi.AttributionAnonymous + if s := transferLabelSuffix(); !strings.Contains(s, "advanced settings enabled") || strings.Contains(s, "(default,") { + t.Fatalf("advanced path label must show the advanced marker and drop '(default,'; got %q", s) + } + if s := transferLabelSuffix(); !strings.Contains(s, "anonymous attribution") { + t.Fatalf("anonymous path label must name the mode; got %q", s) + } + + // SELF must be loudly named in the suffix so a self-doxx build is distinguishable. + attribution_mode = walletapi.AttributionSelf + if s := transferLabelSuffix(); !strings.Contains(s, "self attribution") { + t.Fatalf("self path label must name 'self attribution'; got %q", s) } } diff --git a/cmd/dero-wallet-cli/easymenu_pre_open.go b/cmd/dero-wallet-cli/easymenu_pre_open.go index f9dea7ad..d78a5796 100644 --- a/cmd/dero-wallet-cli/easymenu_pre_open.go +++ b/cmd/dero-wallet-cli/easymenu_pre_open.go @@ -255,8 +255,10 @@ func common_processing(wallet *walletapi.Wallet_Disk) { default_ringsize = wallet.GetRingSize() if globals.Arguments["--anonymous"] != nil && globals.Arguments["--anonymous"].(bool) { - anonymize_default = true + attribution_mode = walletapi.AttributionAnonymous // console-only (NOT the on-disk log): never persist anonymize-intent to disk (O3). + // --anonymous only seeds ANONYMOUS; SELF is never reachable from a flag (it is a + // deliberate, warned, in-session choice only). fmt.Fprintf(os.Stderr, "Extra sender privacy ON for this session (configure in the Transaction Build Options menu)\n") } if globals.Arguments["--decoys"] != nil && globals.Arguments["--decoys"].(string) != "" { diff --git a/cmd/dero-wallet-cli/prompt.go b/cmd/dero-wallet-cli/prompt.go index 2f2c1750..3809729a 100644 --- a/cmd/dero-wallet-cli/prompt.go +++ b/cmd/dero-wallet-cli/prompt.go @@ -491,11 +491,7 @@ func handle_set_command(l *readline.Instance, line string) { fmt.Fprintf(l.Stderr(), color_normal+"Priority: "+color_extra_white+"%0.2f\t"+color_normal+"eg. "+color_extra_white+"set priority 4.0\t"+color_normal+"Transaction priority on DERO network \n", wallet.GetFeeMultiplier()) fmt.Fprintf(l.Stderr(), "\t\tMinimum priority is 1.00. High priority = high fees\n") - anon := "off" - if anonymize_default { - anon = "on" - } - fmt.Fprintf(l.Stderr(), color_normal+"Extra sender privacy: "+color_extra_white+"%s\t"+color_normal+"set in the "+color_extra_white+"Transaction Build Options"+color_normal+" menu (option 7). Needs ring size >= 4.\n", anon) + fmt.Fprintf(l.Stderr(), color_normal+"Sender attribution: "+color_extra_white+"%s\t"+color_normal+"set in the "+color_extra_white+"Transaction Build Options"+color_normal+" menu (option 7). ANONYMOUS needs ring size >= 4.\n", attributionModeLabel(attribution_mode)) } } From c301caff9c7dff77888b094bd558226e7627aefd Mon Sep 17 00:00:00 2001 From: DHEBP <152355273+DHEBP@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:51:37 -0400 Subject: [PATCH 15/15] docs(wallet-cli): lenient drops cover unregistered verdicts only post review #3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A registration probe that FAILS (daemon/transport fault — no verdict) now hard-errors the build even under Strict:false (engine review #3 fix), so the two comments pinning lenient silent-drop needed the caveat: silent skip applies only to the daemon's unregistered verdict. --- cmd/dero-wallet-cli/anonymity.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cmd/dero-wallet-cli/anonymity.go b/cmd/dero-wallet-cli/anonymity.go index 001103f6..fdb997b6 100644 --- a/cmd/dero-wallet-cli/anonymity.go +++ b/cmd/dero-wallet-cli/anonymity.go @@ -441,7 +441,9 @@ func handleTransactionBuildMenu(l *readline.Instance) { // echoing the prompt-time request count. The engine (curatedRingCandidates) runs under // Strict:false, so a curated decoy that is unparseable / unregistered / // deregistered-since-prompt is SILENTLY skipped and a random member fills the slot — -// len(members) would then over-report curation. The authoritative record of what +// len(members) would then over-report curation. (Since review #3 the silent skip covers +// only the daemon's unregistered VERDICT; a probe that FAILS — daemon/transport fault, +// no verdict — hard-errors the build even under Strict:false, so it never reaches here.) The authoritative record of what // finalized is the freshly built tx's Statement.Publickeylist (the serialized wire form // keeps only index pointers, so this must read the in-memory built tx, exactly as the // curated-ring finalization test does). @@ -525,7 +527,8 @@ func curatedDecoysInTx(tx *transaction.Transaction, members []string, recipient // `decoys` is the count to display and its `label` (e.g. "requested" pre-send, "landed // in ring" post-send) so the post-send line reflects what the engine ACTUALLY placed — // curated decoys are dropped silently under Strict:false, so an echoed prompt-time count -// would over-report curation (O8). +// would over-report curation (O8). (Drops cover unregistered verdicts only — a FAILED +// probe hard-errors the build even under Strict:false since review #3.) func reportAttribution(l *readline.Instance, opts walletapi.TransferOptions, decoys int, label string) { if !advancedSettingsActive() { return // default/honest send: stay silent, no unsolicited privacy notice