diff --git a/cmd/dero-wallet-cli/anonymity.go b/cmd/dero-wallet-cli/anonymity.go new file mode 100644 index 00000000..fdb997b6 --- /dev/null +++ b/cmd/dero-wallet-cli/anonymity.go @@ -0,0 +1,574 @@ +// 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" + "strconv" + "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 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. +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 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{} + opts.Attribution = mode + 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 +} + +// 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 walletapi.AttributionHonest + } + return mode +} + +// 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 +} + +// 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, 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(mode, members), mode, 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 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 — 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 + detail + 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() { + 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 +// 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 { + 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) + 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\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) + 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": + 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 + // 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 +// 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. (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). +// +// 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 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). (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 + } + 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 { + 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)" + } +} diff --git a/cmd/dero-wallet-cli/anonymity_test.go b/cmd/dero-wallet-cli/anonymity_test.go new file mode 100644 index 00000000..2ca6978a --- /dev/null +++ b/cmd/dero-wallet-cli/anonymity_test.go @@ -0,0 +1,556 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "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 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(walletapi.AttributionHonest, 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(walletapi.AttributionHonest, []string{}); got.Ring != nil { + t.Fatalf("empty members must not produce a non-nil Ring, got %+v", got.Ring) + } +} + +// 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) + } + if opts.Ring != nil { + 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(walletapi.AttributionHonest, 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)") + } + + // 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 +// 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) + } +} + +// 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: ANONYMOUS must be CANCELLED (downgraded to DEFAULT/honest). + wallet.SetRingSize(2) + 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: ANONYMOUS survives. + wallet.SetRingSize(4) + if got := resolveModeOrDowngrade(walletapi.AttributionAnonymous); got != walletapi.AttributionAnonymous { + t.Fatalf("ring 4: anonymous must survive; got %v", got) + } + // 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) + } +} + +// 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) + } + + // 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 +// (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") + } +} + +// TestApplySessionPrivacy locks the NEW silent send path (the Advanced Privacy menu +// redesign): a send no longer prompts — it reads the session settings (attribution_mode, +// decoys_default) and builds opts silently. The contract: +// - 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. + prevMode, prevDecoys := attribution_mode, decoys_default + t.Cleanup(func() { attribution_mode, decoys_default = prevMode, prevDecoys }) + + // ── case 1: DEFAULT, no decoys -> exact no-op (zero value) ───────────────────────── + attribution_mode, decoys_default = walletapi.AttributionHonest, nil + wallet.SetRingSize(16) + 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: ANONYMOUS at ring>=4 -> anonymous, no decoys -> Ring stays nil ────────── + attribution_mode, decoys_default = walletapi.AttributionAnonymous, nil + wallet.SetRingSize(16) + 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: 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, 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) + } +} + +// 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)() + + 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 — 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 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) + } + 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)() + prevMode, prevDecoys := attribution_mode, decoys_default + t.Cleanup(func() { attribution_mode, decoys_default = prevMode, prevDecoys }) + + 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) + } + if strings.Contains(transferLabelSuffix(), "advanced settings enabled") { + t.Fatalf("default path must NOT show the advanced marker") + } + + 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_post_open.go b/cmd/dero-wallet-cli/easymenu_post_open.go index 2486366e..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,8 +217,15 @@ func handle_easymenu_post_open_command(l *readline.Instance, line string) (proce break // invalid amount provided, bail out } + 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()) 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 +236,10 @@ 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) + resetTransferBuildToDefaults() // set-first-then-fire: build settings do not persist into the next send } case "5": @@ -378,11 +390,17 @@ func handle_easymenu_post_open_command(l *readline.Instance, line string) (proce return } + opts, _, members := applySessionPrivacy(a.String()) + + logger.Info("Review transfer", + "to", a.String(), + "amount", globals.FormatMoney(amount_to_transfer), + "ringsize", wallet.GetRingSize()) 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,10 +412,14 @@ 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) + 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 } @@ -406,6 +428,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 ") /* @@ -437,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) { @@ -450,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() @@ -472,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 @@ -507,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 @@ -526,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 @@ -544,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 b56ef134..d78a5796 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,27 @@ 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) { + 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) != "" { + 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..45bb46f5 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 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 73fa50d1..3809729a 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,6 +472,10 @@ func handle_set_command(l *readline.Instance, line string) { language := choose_seed_language(l) logger.Info("Setting seed language", "language", wallet.SetSeedLanguage(language)) + // 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 } @@ -490,13 +484,15 @@ 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)) 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") + 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)) + } } @@ -1034,6 +1030,7 @@ var completer = readline.NewPrefixCompleter( readline.PcItem("mixin"), readline.PcItem("seed"), readline.PcItem("priority"), + readline.PcItem("anonymous"), ), readline.PcItem("show_transfers"), readline.PcItem("spendkey"), diff --git a/cmd/simulator/curated_ring_altencoding_test.go b/cmd/simulator/curated_ring_altencoding_test.go new file mode 100644 index 00000000..0e6c1fd7 --- /dev/null +++ b/cmd/simulator/curated_ring_altencoding_test.go @@ -0,0 +1,353 @@ +// 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 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 +// 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)}) + }) + + // 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/cmd/simulator/curated_ring_finalization_test.go b/cmd/simulator/curated_ring_finalization_test.go new file mode 100644 index 00000000..a5ffe3ac --- /dev/null +++ b/cmd/simulator/curated_ring_finalization_test.go @@ -0,0 +1,439 @@ +package main + +// A2 — base-SCID curated-ring finalization gate. +// +// The curated-decoy engine (feat/curated-decoy-attribution: curatedRingCandidates + +// 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 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. 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 (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" + "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. + // 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). + 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) + } + + // 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 { + if da, perr := rpc.NewAddress(d); perr == nil { + preferredKeys[hex.EncodeToString(da.PublicKey.EncodeCompressed())] = true + } + } + 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 + 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 (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/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/rpc/wallet_rpc.go b/rpc/wallet_rpc.go index 11f1a480..bf1f5d49 100644 --- a/rpc/wallet_rpc.go +++ b/rpc/wallet_rpc.go @@ -82,7 +82,15 @@ 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 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"` DestinationPort uint64 `json:"dstport"` SourcePort uint64 `json:"srcport"` } 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_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/attribution_scrub_behavior_test.go b/walletapi/attribution_scrub_behavior_test.go new file mode 100644 index 00000000..3e480622 --- /dev/null +++ b/walletapi/attribution_scrub_behavior_test.go @@ -0,0 +1,289 @@ +// 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") + 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 { + 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(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) + } + + // 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() + wgenesis.Close_Encrypted_Wallet() + os.Remove(wsrc_temp_db) + os.Remove(wdst_temp_db) + os.Remove(wgenesis_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/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/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 2cffea05..fae64994 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 @@ -222,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 @@ -316,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 } @@ -375,10 +444,19 @@ 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 } +// 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 @@ -406,8 +484,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() { @@ -517,8 +598,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 } @@ -985,6 +1067,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() + 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[:]...) @@ -1010,6 +1093,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() + markSelfAuthored(&entry, tx.Payloads[t].Statement.RingSize) entry.Payload = append(entry.Payload, payload[1:]...) entry.Data = append(entry.Data, payload...) @@ -1076,9 +1160,16 @@ 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 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 + + // 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, tx.Payloads[t].RPCPayload[:]...) + entry.Data = append(entry.Data, exported_payload[:]...) args, _ := entry.ProcessPayload() _ = args @@ -1115,9 +1206,16 @@ 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 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 + + // 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, payload...) + entry.Data = append(entry.Data, exported_payload...) args, _ := entry.ProcessPayload() _ = args 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..1e2c1140 --- /dev/null +++ b/walletapi/rpc_call_timeout_test.go @@ -0,0 +1,126 @@ +// 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" + "strings" + "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") + } 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) + } + + 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) + } + + // 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 17341871..198b5322 100644 --- a/walletapi/transaction_build.go +++ b/walletapi/transaction_build.go @@ -21,8 +21,105 @@ 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/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. +) + +// 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. + // + // 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, 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 +} + +// 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 +281,25 @@ rebuild_tx: shared_key := crypto.GenerateSharedSecret(ephemeral_scalar, publickeylist[i]) - payload := append([]byte{byte(uint(witness_index[1]))}, data...) + // 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 + // 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] + } 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) //fmt.Printf("building plaintext payload %x\n",asset.RPCPayload) 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 cf3c0247..ecf4995e 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" @@ -59,7 +60,306 @@ 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{}) +} + +// 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 +// 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. +// +// 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". +// +// curated reports how many validated preferred decoys lead alist, so the caller can +// 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 + } + + var zeroscid crypto.Hash + // 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 recipientAddr != "" { + if ra, e := rpc.NewAddress(recipientAddr); e == nil { + seen[pkKey(ra)] = true + } + } + + // 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 + if key == self { // curating your own address collapses your anonymity set + 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 + // 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. + // 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 + } + + 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 + +// 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 { + 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 +// 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() @@ -69,6 +369,15 @@ func (w *Wallet_Memory) TransferPayload0(transfers []rpc.Transfer, ringsize uint // 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 @@ -82,6 +391,67 @@ func (w *Wallet_Memory) TransferPayload0(transfers []rpc.Transfer, ringsize uint } } + // 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 + } + + // 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 @@ -326,6 +696,11 @@ func (w *Wallet_Memory) TransferPayload0(transfers []rpc.Transfer, ringsize uint }*/ receiver_without_payment_id := addr.BaseAddress() + // 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() { @@ -337,12 +712,53 @@ func (w *Wallet_Memory) TransferPayload0(transfers []rpc.Transfer, ringsize uint 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 { - probable_members := w.Random_ring_members(transfers[t].SCID) - if len(probable_members) <= 40 { // we do not have enough ring members for sure, extract ring members from base + // curated preferred decoys (if any) go first; random members top up. With no + // 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, decoy_slots, decoy_verdicts, decoy_drops) + 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 base_rescue { var zeroscid crypto.Hash - probable_members = w.Random_ring_members(zeroscid) + 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 + } + probable_members = base_members } + seen_before := len(deduplicator) for _, k := range probable_members { if _, collision := deduplicator[k]; collision { continue @@ -372,6 +788,44 @@ func (w *Wallet_Memory) TransferPayload0(transfers []rpc.Transfer, ringsize uint } } + 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: @@ -386,6 +840,21 @@ func (w *Wallet_Memory) TransferPayload0(transfers []rpc.Transfer, ringsize uint } 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] { @@ -395,7 +864,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 {