Skip to content

A3 Strategy 2: on-chain recipient-cohort delivery + balance-arm discovery (D1 executable)#1

Open
DHEBP wants to merge 83 commits into
mainfrom
feat/recipient-cohort-onchain-a3s2
Open

A3 Strategy 2: on-chain recipient-cohort delivery + balance-arm discovery (D1 executable)#1
DHEBP wants to merge 83 commits into
mainfrom
feat/recipient-cohort-onchain-a3s2

Conversation

@DHEBP

@DHEBP DHEBP commented Jun 20, 2026

Copy link
Copy Markdown
Owner

What

Proves on a real DERO simulator that the recipient-cohort rotation's on-chain delivery + discovery half works (the crypto half was proven separately in A3 Strategy 1). Closes Decision D1 → Option C EXECUTABLE.

A carrier sealed to a co-held cohort member is delivered, finalized, and discovered by that member's wallet from the balance-increase arm ALONE — no in-body tag, no trial-decryption — while the other co-held members do NOT mis-attribute it.

Gates (all PROVEN-RUN, audited)

  • Test_RecipientCohort_OnChainDelivery_A3S2 — carrier to R2 finalizes (Block_tx_store.ReadTX); R2 attributes via the real daemon_communication.go balance arm (Incoming, balance +1); R1 does not. Positive-discrimination twin (a 2nd carrier to R1) proves attribution is directional. No-tag re-asserted on the persisted tx.
  • Test_RecipientCohort_OnChain_NegativeControls_A3S2 — never-mined carrier (non-vacuity) + wrong-wallet query.
  • Test_RecipientCohort_OnChain_R3Rotation_A3S2R=3 target rotation: each member attributes only its own carrier, the other two disambiguate cleanly. Closes the R>2 generalization.

Harness hardening (also in this PR)

blockchain_sim_test.go: each simulator_chain_start now gets a fresh ephemeral RPC port + unique data dir (+ teardown cleanup). The previous fixed port + fixed /tmp/dsimulator dir raced on a leaked RPC server / in-flight graviton commit, flaking the whole package (A2 ~40%, A3 S2 ~75-87%) with a daemon-side GetEncryptedBalance panic during wallet sync. After the fix: A3 S2 pair 20/20 consecutive, full package 8/8.

Adversarial review

20-agent red-team caught the flake + a confounded disambiguation control; both fixed and re-proven before this lands. Verdict: gates SURVIVED.

Scope (honest)

Single-node sim / 0-conf (persisted-in-one-mined-block, not multi-node finality); base-SCID plain ring≥4; cohort-birth conceded. Index/timing non-leak is PROVEN-SOURCE (per-tx ring shuffle, witness_index never serialized).

Slixe and others added 30 commits May 28, 2023 15:02
…ttacks

- Implements SHAKE256-based ephemeral key derivation using sender secret, roothash, ring inputs, and recipient public key.
- Introduces 32-byte prefix in payloads for compressed ephemeral public key.
- Adds randomized padding to obfuscate payload size and structure.
- Updates wallet sender/receiver logic to support new ENCRYPTED_DEFAULT_PAYLOAD_CBOR_V2 format.
- Prevents trial decryption and payload re-identification attacks.

Co-authored-by: Dank <44716232+DankFC@users.noreply.github.com>
Merge from dev to harden payload encryption
Merge pull request DEROFDN#3 from civilware/dev-xswd
Harden payload encryption and add websockets support
Previously, the simulator hardcoded --testnet=true, preventing users from
testing mainnet behavior. The simulator would always run in testnet mode
regardless of user input.

This change:
- Removes the hardcoded --testnet=true assignment
- Defaults to mainnet when --testnet flag is not provided
- Properly respects the --testnet flag when explicitly set by user
- Adds defensive nil checking in InitNetwork() to handle missing flag

Fixes simulator network mode selection to match user expectations.
- Changed logger name from 'derod' to 'simulator' for clarity
- Added genesis wallet address, seed, and restore command display
- Helps developers access the funded genesis wallet (~1.12 DERO)
- Other 22 wallets only get 0.002 DERO registration bonus in mainnet

The genesis wallet is the only one with significant funds in mainnet
simulator mode since the premine list is skipped for mainnet.
Alumn0 and others added 26 commits May 5, 2026 00:08
…erval into consideration

Replaced supply calculation formular in daemon to utilize the new function instead
…sholds

Security enhancement to prevent fake proof displays:
- Reject amounts exceeding DERO hard cap (22M, ~5% buffer above 21M)
- Reject amounts that would cause int64 wraparound (>2^63-1)
- Use SetUint64 instead of SetInt64 to prevent signed overflow
- Verify payload amount matches claimed proof amount
- Add comprehensive test coverage for known fake proof amounts

Thresholds aligned with HOLOGRAM implementation for consistency.
Complete UI refresh inspired by DERO Foundation's noir theme:
- Dark glassmorphism design with jade green accents
- Responsive layout optimized for mobile devices
- AJAX-based proof form for smooth UX (no page reload)
- Block age displayed as human-readable relative time
- Fixed epoch-0 timestamp display for Block 0
- User-facing note that payload proofs are display-only
- Branded header with DERO hex logo
- Updated footer link to DEROFDN/derohe
- favicon.svg and logo.png assets

Includes explorerlib_test.go for formatBlockAge coverage.
- Replace deprecated go get with git clone + go build workflow
- Update GitHub links from deroproject to DEROFDN/derohe
- Update explorer link to explorer.derofoundation.org
- Remove defunct web wallet link
- Add Matrix community channel link
- Fix typos and clarify build instructions
feat(explorer): proof validation security + modern UI refresh
The Connect() function sets Connected=true after a successful WebSocket
dial, but returns directly from test_connectivity() without resetting
it on failure.

This causes consumers to observe Connected==true even when the daemon
connection is invalid (e.g., mainnet/testnet mismatch). Downstream code
then incorrectly proceeds with background tasks against a broken connection.

Reset Connected=false explicitly before returning the error.
…over

Two related arithmetic-correctness fixes on the proof verification path,
both follow-ups to PR DEROFDN#14.

proof/proof_validation.go: the hard-cap and reasonable-amount thresholds
were computed as if 1 DERO = 1,000,000 atomic units, but DERO actually
uses 100,000 atomic units per DERO (matches globals/globals.go:295 and
the GetInfo RPC supply conversion at rpc_dero_getinfo.go:81). The constants
were therefore 10x too high — the validator's hard-cap check effectively
triggered at ~220M DERO instead of the documented 22M. Reorder the const
block so atomicUnitsPerDero is defined first, then rewrite each threshold
as X * atomicUnitsPerDero so the conversion factor is explicit at every
use site. Same fix applied to the largeThreshold local variable inside
DetectSuspiciousProofPatterns.

proof/proof_validation_test.go: four test cases referenced the buggy
constants by value (e.g., 21_000_000_000_000 // "21M DERO"). With the
corrected constants those amounts now correspond to 210M DERO atomic
units — outside the labelled intent. Update the four affected test
amounts and tighten their labels.

cryptography/crypto/proof_generate.go: mirror PR DEROFDN#14's verifier-side
SetInt64(int64(amount)) -> SetUint64(amount) hardening to the prover
side. The previous pattern is bounded in legitimate use (transfer
amounts and balances cap well below 2^63) and only causes self-DoS if
it ever fires, but the asymmetry between prover and verifier is forensic
noise once readers compare them. Drop the misleading "this should be
reduced" comments — values at this scale (bounded by 2^64) are far
smaller than the bn256 group order (~2^254), so modular reduction would
be a no-op.

No behavioral change for legitimate transfers. The validator's hard-cap
rejection now actually fires at 22M DERO as the comments claim, rather
than at 220M.
Warning 4 used a hardcoded 1 trillion atomic literal, which at the correct
100k atomic/DERO conversion flagged only multiples of 10M DERO (just 10M and
20M in the valid range). Restore the original intent of flagging exact
multiples of 1M DERO via 1_000_000 * atomicUnitsPerDero.

Note: DetectSuspiciousProofPatterns has zero callers in community-dev so
this has no user-visible effect in this tree today, but it aligns the
internal arithmetic with the corrected constants and matches the
matching change in HOLOGRAM (DHEBP/HOLOGRAM @ a8902d9).
Added PrunedHeight information to GetInfo result
fix(proof): correct atomic-unit thresholds + mirror SetUint64 to prover
Added PrunedHeight field to GetInfo_Result type
Fixed discrepancies in GetGasEstimate() result
…anic

The receiver decode paths used sender_idx <= RingSize against a
0-indexed Publickeylist of length RingSize, so a crafted byte equal
to RingSize indexed one slot past the slice and panicked the
receiving wallet during history processing. Tighten to < in both the
CBOR and CBOR_V2 paths so valid indices are 0..RingSize-1.
Add SenderVerified and RingSize to rpc.Entry, populated in both
receiver decode paths. SenderVerified is true only at ring size 2,
where the receiver override pins attribution to the counterparty;
for larger rings the sender chose the unauthenticated attribution
byte, so entry.Sender must not be presented as trusted. RingSize is
carried so consumers can render attribution confidence per entry.
Add an additive TransferPayload0WithOptions variant carrying a
TransferOptions struct; the existing TransferPayload0 becomes a shim
passing the zero value, so all callers keep today's behavior. The new
AttributionAnonymous mode writes a decoy ring slot index (drawn from
the anonymity set, never the sender or receiver slot) into the
encrypted receiver payload, reducing the receiver to the ring's 1-of-N
anonymity instead of being handed the sender's slot. Honest mode is
unchanged and still writes the receiver slot.

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

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

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

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

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

Proves on a real DERO simulator that a carrier sealed to receive-identity R2
of a co-held cohort is delivered, finalized, and DISCOVERED by R2's wallet
from the balance-increase arm ALONE (no in-body tag, no trial-decryption),
while the other co-held member R1 does NOT mis-attribute it -- the on-chain
half of the recipient-rotation claim (the crypto half was proven separately).

- Carrier to R2 finalizes (persisted in a mined block, Block_tx_store.ReadTX).
- R2 attributes it via the real daemon_communication.go balance arm (Incoming
  entry, balance +1, amount 1); R1's balance is unchanged and it does not attribute.
- Positive-discrimination twin: a second carrier to R1 proves the balance arm
  attributes each carrier to exactly the targeted member in BOTH directions, so
  co-held disambiguation is directional and live, not dormant.
- No-tag proven on the PERSISTED form (finalized tx carries body "B", no
  target-index field). Index/timing non-leak is a source property (per-tx ring
  shuffle + witness_index never serialized) noted in the header.
- Negative controls: never-mined carrier (R2 doesn't attribute -> non-vacuous);
  wrong-wallet query.

Also hardens the shared simulator test harness (blockchain_sim_test.go): each
simulator_chain_start now gets a fresh ephemeral RPC port + a unique data dir,
and simulator_chain_stop tears down and cleans up. The previous fixed port +
fixed /tmp/dsimulator dir raced on a leaked RPC server / in-flight graviton
commit, flaking the whole package (A2 ~40%, A3 S2 ~75-87%) with a daemon-side
GetEncryptedBalance panic during wallet sync. After the fix the package is
stable: A3 S2 pair 20/20 consecutive, full package 8/8.

Adversarially audited (20-agent panel, A3S2-AUDIT-RECORD.md): caught the flake
and a confounded disambiguation control; both fixed and re-proven before this
lands. Scope: single-node sim / 0-conf, base-SCID plain ring>=4, R=2.
…(A3 S2, red-team-owed)

Closes the R>2 generalization the 2-member A3 S2 test left EXPECTED-BY-INSPECTION
(and that the red-team explicitly flagged owed). On-chain analog of A3 Strategy 1's
TargetRotatesAcrossCohort (which ran R=3 in pure crypto), now run through the real
daemon_communication.go path on a simulator.

Test_RecipientCohort_OnChain_R3Rotation_A3S2: a 3-member co-held cohort (distinct
registered wallets), target rotated across all three. For each member in turn, a
carrier sealed to it is attributed by EXACTLY that member via the balance arm
(Incoming, balance +1) and by NEITHER of the other two (balance unchanged, no
Incoming entry). Proves balance-arm discovery + co-held disambiguation hold for
R>2, not just the pairwise R=2 case.

Disambiguation control mutation-verified green->RED (inverting the non-target
assertion fires). 8/8 stable in-process (the shared-harness race fix holds). Scope:
single-node sim / 0-conf, base-SCID plain ring>=4.
DHEBP added a commit that referenced this pull request Jun 24, 2026
…l scrub test

Addresses the DEROFDN#21 six-perspective review:

- Self-send sites set SenderVerified=true + RingSize (both decode arms): a
  wallet-authored tx has a certain sender at any ring size, so a contract-
  following consumer no longer distrusts the user's own sends. (review #2)
- Add Test_Attribution_Scrub_Behavior: drives the real receiver decode on a
  sim chain and asserts the scrub effect (ring2 keeps Sender+Data[0]; ring>2
  blanks Sender + zeroes Data[0]). Rebuilds until the receiver lands in a
  non-zero ring slot so the Data[0]==0 assertion has teeth. Mutation-checked.
  The source-grep guard is kept as a tripwire. (review DEROFDN#3)
- Document the (RingSize, SenderVerified, Sender) renderer truth table on the
  field; drop the redundant exported_payload[:] slice. (review DEROFDN#6, DEROFDN#9)

The <=->< bounds guard is sufficient: rpc.NewAddress panics on an
undecompressable ring key before the Publickeylist rebuild, so
len(Publickeylist)==RingSize holds and the index cannot go out of range. (review #1)
DHEBP added a commit that referenced this pull request Jun 24, 2026
…anon

Addresses the DEROFDN#22 review:

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

The bounds guard is sufficient for the same reason as DEROFDN#21: bad ring keys panic
at NewAddress before the Publickeylist rebuild, so len==RingSize. (review DEROFDN#4)
DHEBP added a commit that referenced this pull request Jul 2, 2026
… (review #1)

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

Seven changes:

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

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

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

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

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

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

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

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

The exhaustion-error branch is unreachable on a healthy chain (the
1326-account genesis premine floors the base pool; the rescue always
reaches it) — covered by inspection. Test_Payload_TX /
Test_Creation_TX_morecheck fail identically at base (pre-existing).
DHEBP added a commit that referenced this pull request Jul 2, 2026
…etroactive scrub (review #1/DEROFDN#4/DEROFDN#5/DEROFDN#6)

DEROFDN#6 — Entry.String() printed "Sender: %s" unconditionally: a scrubbed ring>2
transfer rendered a bare "Sender: " line that reads as a decode bug — the
exact "looks broken" outcome the design meant to avoid, contradicting the
truth table documented on SenderVerified in the same file. The Sender line
now follows the table: verified -> the address; scrubbed (no payload
error) -> "unknown (unverifiable, ring size N)"; decode failure -> a
distinct "unknown (payload decode failed)". Blast radius: two CLI debug
dumps (%+v in show_transfers error paths); JSON marshalling untouched.
Pinned by rpc/wallet_rpc_test.go (offline, sub-second): all three arms,
plus forbids the bare line and scrub/decode-failure conflation.

DEROFDN#4/DEROFDN#5 — the non-retroactive caveat lived only in commit 4e9de56's message.
Now in the docs consumers read: SenderVerified's doc comment states the
scrub is decode-time only (pre-upgrade entries keep their guessed Sender
and unscrubbed Data[0], served as-is by Get_Transfers; the guarantee holds
only for blocks decoded post-upgrade), the upgrade is one-way for the
privacy property, and pre-upgrade entries deserialize
SenderVerified=false, RingSize=0 — indistinguishable from a scrub without
re-decoding, so a migration MUST re-derive RingSize from chain data (a
read-path scrub keyed on persisted fields would wrongly blank the wallet's
own verified sends — why the rescan is a follow-up, not this commit).
RingSize's doc gains the "0 = pre-upgrade/unknown, not a real ring" note;
the GetTransfers handler doc points RPC consumers at the contract.

#1 hardening — both decode arms bounded sender_idx against
Statement.RingSize while indexing Statement.Publickeylist. Safe today
(len(Publickeylist)==RingSize holds at that point), but the guard now
bounds against the slice actually indexed, so it survives any future break
of that invariant.

Existing pins green: the scrub-behavior sim test and the source-grep guard
(its regexes key on the scrub lines, untouched here).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants