From e1de80b763c455652f6eeda08fe5b361a8fce46a Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Tue, 19 May 2026 15:56:00 +0100 Subject: [PATCH 01/13] fix: entropy index problem and unregistered function problem --- src/contracts/Random.h | 70 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/src/contracts/Random.h b/src/contracts/Random.h index 290b92585..b79b3704b 100644 --- a/src/contracts/Random.h +++ b/src/contracts/Random.h @@ -25,6 +25,33 @@ struct RANDOM : public ContractBase uint32 i; }; + struct Fees_input + { + }; + + struct Fees_output + { + Array fees; + }; + + struct BuyEntropy_input + { + uint8 collateralTier; + uint16 numberOfBits; + }; + + struct BuyEntropy_output + { + Array entropy; + }; + + struct BuyEntropy_locals + { + bit_4096 zeroEntropy; + uint32 stream; + uint32 entropyIdx; + }; + struct StateData { uint64 earnedAmount; @@ -42,6 +69,42 @@ struct RANDOM : public ContractBase Array entropy; // 3 * 10 }; + PUBLIC_FUNCTION(Fees) + { + output.fees.set(0, 100); + output.fees.set(1, 100); + output.fees.set(2, 100); + output.fees.set(3, 100); + output.fees.set(4, 100); + output.fees.set(5, 100); + output.fees.set(6, 100); + output.fees.set(7, 100); + output.fees.set(8, 100); + output.fees.set(9, 100); + } + + PUBLIC_PROCEDURE_WITH_LOCALS(BuyEntropy) + { + if (input.collateralTier <= 9 + && input.numberOfBits >= 1 && input.numberOfBits <= 4096 + && qpi.invocationReward() >= input.numberOfBits * 100) + { + locals.stream = mod(qpi.tick() + 2, 3); + locals.entropyIdx = locals.stream *10 + input.collateralTier; + + if (state.get().entropy.get(locals.entropyIdx) == locals.zeroEntropy) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + else + { + state.mut().earnedAmount += qpi.invocationReward(); + + output.entropy.setRange(0, input.numberOfBits, state.get().entropy.get(locals.entropyIdx)); + } + } + } + private: PUBLIC_PROCEDURE_WITH_LOCALS(RevealAndCommit) { @@ -299,11 +362,14 @@ struct RANDOM : public ContractBase REGISTER_USER_FUNCTIONS_AND_PROCEDURES() { + REGISTER_USER_FUNCTION(Fees, 1); + REGISTER_USER_PROCEDURE(RevealAndCommit, 1); + REGISTER_USER_PROCEDURE(BuyEntropy, 2); } INITIALIZE() { - state.mut().bitFee = 1000; + // state.mut().bitFee = 1000; } -}; +}; \ No newline at end of file From 10b5282f7fd37bd72fd2120d35aa986244dfd450 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Wed, 20 May 2026 05:38:48 +0900 Subject: [PATCH 02/13] feat: add test file --- test/contract_random.cpp | 342 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 test/contract_random.cpp diff --git a/test/contract_random.cpp b/test/contract_random.cpp new file mode 100644 index 000000000..9be0f51c9 --- /dev/null +++ b/test/contract_random.cpp @@ -0,0 +1,342 @@ +#define NO_UEFI + +#include "contract_testing.h" +#include "kangaroo_twelve.h" + +// Helper: build a deterministic non-zero bit_4096 from a seed. +static QPI::bit_4096 makeReveal(uint64 seed) +{ + QPI::bit_4096 v; + v.setAll(0); + for (uint64 i = 0; i < 4096; ++i) + { + v.set(i, (bit)(((seed * 0x9E3779B97F4A7C15ULL + i * 0xBF58476D1CE4E5B9ULL) >> 32) & 1)); + } + return v; +} + +// Helper: compute K12(reveal) -> id, exactly the same operation the contract +// performs via qpi.K12(input.reveal) when validating reveals against commits. +static id commitOf(const QPI::bit_4096& reveal) +{ + id digest; + KangarooTwelve(&reveal, sizeof(reveal), &digest, sizeof(digest)); + return digest; +} + +static id getUser(uint64 i) +{ + return id(i + 1, i / 2 + 4, i + 10, i * 3 + 8); +} + +// Reward amount required by RevealAndCommit to select a given collateral tier. +static sint64 collateralForTier(uint8 tier) +{ + sint64 v = 1; + for (uint8 t = 0; t < tier; ++t) + v *= 10; + return v; +} + +class ContractTestingRandom : public ContractTesting +{ +public: + ContractTestingRandom() + { + initEmptySpectrum(); + initEmptyUniverse(); + system.epoch = contractDescriptions[RANDOM_CONTRACT_INDEX].constructionEpoch; + system.tick = 1000; + INIT_CONTRACT(RANDOM); + callSystemProcedure(RANDOM_CONTRACT_INDEX, INITIALIZE); + } + + RANDOM::StateData* state() + { + return (RANDOM::StateData*)contractStates[RANDOM_CONTRACT_INDEX]; + } + + void setTick(uint32 t) { system.tick = t; } + uint32 tick() const { return system.tick; } + void endTick() { callSystemProcedure(RANDOM_CONTRACT_INDEX, END_TICK); } + + RANDOM::Fees_output fees() + { + RANDOM::Fees_input input{}; + RANDOM::Fees_output output{}; + callFunction(RANDOM_CONTRACT_INDEX, 1, input, output); + return output; + } + + void revealAndCommit(const id& user, const QPI::bit_4096& reveal, + const id& commit, sint64 collateral) + { + RANDOM::RevealAndCommit_input input{}; + input.reveal = reveal; + input.commit = commit; + RANDOM::RevealAndCommit_output output{}; + invokeUserProcedure(RANDOM_CONTRACT_INDEX, 1, input, output, user, collateral); + } + + RANDOM::BuyEntropy_output buyEntropy(const id& user, uint8 collateralTier, + uint16 numberOfBits, sint64 amount) + { + RANDOM::BuyEntropy_input input{}; + input.collateralTier = collateralTier; + input.numberOfBits = numberOfBits; + RANDOM::BuyEntropy_output output{}; + invokeUserProcedure(RANDOM_CONTRACT_INDEX, 2, input, output, user, amount); + return output; + } +}; + +// --------------------------------------------------------------------------- +// Basic sanity tests +// --------------------------------------------------------------------------- + +TEST(ContractRandom, FeesReturns100PerBitForFirstTenTiers) +{ + ContractTestingRandom r; + auto out = r.fees(); + for (uint64 i = 0; i < 10; ++i) + { + EXPECT_EQ(out.fees.get(i), 100u) << "fees[" << i << "] should be 100 qu/bit"; + } +} + +TEST(ContractRandom, BuyEntropyRefundsWhenEntropyMissing) +{ + ContractTestingRandom r; + + const uint8 tier = 3; + const uint16 numberOfBits = 64; + const sint64 fee = (sint64)numberOfBits * 100; + + id user = getUser(1); + increaseEnergy(user, fee); + const long long balanceBefore = getBalance(user); + + auto out = r.buyEntropy(user, tier, numberOfBits, fee); + + EXPECT_EQ(getBalance(user), balanceBefore) << "BuyEntropy must refund when no entropy is available"; + + QPI::bit_4096 zero; zero.setAll(0); + EXPECT_TRUE(out.entropy.get(0) == zero) << "Output must remain all-zero on refund"; + EXPECT_EQ(r.state()->earnedAmount, 0u) << "Refund must not credit earnedAmount"; +} + +// Specification (per CFB): "BuyEntropy procedure is for buying entropy. The +// fee is returned if there is no entropy, in this case output will be all +// zeros." -- the user only loses funds when entropy is actually delivered. +// By symmetry, when the request is rejected because of invalid inputs (bad +// tier, zero/oversize bit count, underpaid fee) no entropy is delivered, so +// the spec-correct behaviour is also a full refund. earnedAmount must NOT +// be credited and output must stay all-zero. +// +// KNOWN BUG (Random.h, BuyEntropy): the procedure has no `else` branch on +// the top-level guard, so invalid inputs silently keep the fee. These tests +// encode the spec and will fail until the contract adds an explicit refund +// for invalid inputs: +// else { qpi.transfer(qpi.invocator(), qpi.invocationReward()); } + +TEST(ContractRandom, BuyEntropyRefundsOnInvalidTier) +{ + ContractTestingRandom r; + const sint64 fee = 64 * 100; + + id user = getUser(200); + increaseEnergy(user, fee); + const long long before = getBalance(user); + const uint64 earnedBefore = r.state()->earnedAmount; + + auto out = r.buyEntropy(user, /*tier*/10, /*bits*/64, fee); + + QPI::bit_4096 zero; zero.setAll(0); + EXPECT_EQ(getBalance(user), before) + << "invalid tier must refund the full fee"; + EXPECT_EQ(r.state()->earnedAmount, earnedBefore) + << "invalid tier must NOT credit earnedAmount"; + EXPECT_TRUE(out.entropy.get(0) == zero) + << "rejected request must not deliver entropy"; +} + +TEST(ContractRandom, BuyEntropyRefundsOnZeroBits) +{ + ContractTestingRandom r; + const sint64 fee = 64 * 100; + + id user = getUser(201); + increaseEnergy(user, fee); + const long long before = getBalance(user); + const uint64 earnedBefore = r.state()->earnedAmount; + + auto out = r.buyEntropy(user, /*tier*/0, /*bits*/0, fee); + + QPI::bit_4096 zero; zero.setAll(0); + EXPECT_EQ(getBalance(user), before) + << "numberOfBits=0 must refund the full fee"; + EXPECT_EQ(r.state()->earnedAmount, earnedBefore); + EXPECT_TRUE(out.entropy.get(0) == zero); +} + +TEST(ContractRandom, BuyEntropyRefundsOnUnderpaidFee) +{ + ContractTestingRandom r; + const sint64 fee = 64 * 100; + + id user = getUser(202); + increaseEnergy(user, fee - 1); + const long long before = getBalance(user); + const uint64 earnedBefore = r.state()->earnedAmount; + + auto out = r.buyEntropy(user, /*tier*/0, /*bits*/64, fee - 1); + + QPI::bit_4096 zero; zero.setAll(0); + EXPECT_EQ(getBalance(user), before) + << "underpaid fee must be refunded (no entropy was delivered)"; + EXPECT_EQ(r.state()->earnedAmount, earnedBefore); + EXPECT_TRUE(out.entropy.get(0) == zero); +} + +TEST(ContractRandom, BuyEntropyRefundsOnOversizeBits) +{ + ContractTestingRandom r; + // 4097 bits is one more than the bit_4096 capacity -> must be rejected. + const uint16 oversize = 4097; + const sint64 fee = (sint64)oversize * 100; + + id user = getUser(203); + increaseEnergy(user, fee); + const long long before = getBalance(user); + const uint64 earnedBefore = r.state()->earnedAmount; + + auto out = r.buyEntropy(user, /*tier*/0, oversize, fee); + + QPI::bit_4096 zero; zero.setAll(0); + EXPECT_EQ(getBalance(user), before) + << "numberOfBits > 4096 must be refunded"; + EXPECT_EQ(r.state()->earnedAmount, earnedBefore); + EXPECT_TRUE(out.entropy.get(0) == zero); +} + +// --------------------------------------------------------------------------- +// End-to-end test: drive RevealAndCommit + END_TICK so the contract itself +// produces real entropy, then verify BuyEntropy hands back the *latest* +// finalized entropy slot (not the bug's hardcoded stream-0 slot). +// +// Lifecycle of one provider on stream s (= tick % 3): +// tick T : RevealAndCommit(reveal=0, commit=K12(reveal1)) -> provider +// registered, commit stored. Reveals stay zero, so END_TICK +// finalizes entropy[s*10+tier] = 0 (XOR with zero). +// tick T+3 : RevealAndCommit(reveal=reveal1, commit=K12(reveal2)) -> reveal +// reveal1 is accepted because K12(reveal1) matches the prior +// commit. END_TICK XORs reveal1 into entropy[s*10+tier], so +// entropy = reveal1. +// tick T+4 : BuyEntropy(tier). The contract reads +// latestStream = (tick + 2) % 3 = (T+4+2) % 3 = T % 3 = s, +// so it must return entropy[s*10+tier] = reveal1. +// +// Done for every residue of T mod 3, so the test is sensitive to the original +// bug where BuyEntropy always read stream-0 instead of the latest stream. +// --------------------------------------------------------------------------- + +namespace +{ + void runFullRandomCycle(uint32 startTick) + { + ContractTestingRandom r; + r.setTick(startTick); + + const uint8 tier = 2; // 100 qu collateral + const sint64 collateral = collateralForTier(tier); // 100 + const uint32 stream = startTick % 3; + + // NOTE: identifiers C1/C2 (and R1/R2 with the FourQ headers) are + // macros in src/four_q.h, so we use longer names here to avoid the + // macro expansion. + const QPI::bit_4096 reveal1 = makeReveal(startTick * 1234567u + 1u); + const QPI::bit_4096 reveal2 = makeReveal(startTick * 1234567u + 2u); + const id commit1 = commitOf(reveal1); + const id commit2 = commitOf(reveal2); + + const id provider = getUser(0xC0DE + startTick); + + // ----- Tick T : initial commit only ------------------------------- + increaseEnergy(provider, collateral); + QPI::bit_4096 zero; zero.setAll(0); + r.revealAndCommit(provider, /*reveal=*/zero, /*commit=*/commit1, collateral); + + // Provider was registered at slot 0 of this stream + EXPECT_EQ(r.state()->populations.get(stream), 1u); + + r.endTick(); + // After END_TICK on stream s, collateral was refunded, flag cleared, + // entropy slot stays zero (reveal was zero). + EXPECT_TRUE(r.state()->entropy.get(stream * 10 + tier) == zero); + EXPECT_EQ(r.state()->populations.get(stream), 1u); + + // Advance two ticks (streams (s+1)%3 and (s+2)%3). We don't need to + // call END_TICK for them; they have no providers, so the BuyEntropy + // verification only depends on stream s. + // ----- Tick T+3 : reveal reveal1, commit commit2 ---------------------- + r.setTick(startTick + 3); + increaseEnergy(provider, collateral); + r.revealAndCommit(provider, /*reveal=*/reveal1, /*commit=*/commit2, collateral); + + // The reveal must have been accepted (commits[i] == K12(reveal1)) + EXPECT_FALSE(r.state()->reveals.get(stream * 1365 + 0) == zero) + << "Reveal was not accepted by RevealAndCommit"; + + r.endTick(); + + // END_TICK XORs reveal1 into entropy[stream*10+tier], leaving it = reveal1. + EXPECT_TRUE(r.state()->entropy.get(stream * 10 + tier) == reveal1) + << "END_TICK should have finalized entropy = reveal1 for stream " + << stream << " tier " << tier; + + // ----- Tick T+4 : BuyEntropy must return the latest entropy ------- + r.setTick(startTick + 4); + + // Sanity: the contract's "latest finalized stream" formula must + // resolve to s, which is where we just wrote reveal1. + const uint32 expectedLatest = (r.tick() + 2u) % 3u; + ASSERT_EQ(expectedLatest, stream); + + const uint16 numberOfBits = 256; + const sint64 fee = (sint64)numberOfBits * 100; + + id buyer = getUser(0xBEEF + startTick); + increaseEnergy(buyer, fee); + const long long buyerBefore = getBalance(buyer); + const uint64 earnedBefore = r.state()->earnedAmount; + + auto out = r.buyEntropy(buyer, tier, numberOfBits, fee); + + // Fee was consumed (no refund path) + EXPECT_EQ(getBalance(buyer), buyerBefore - fee) + << "BuyEntropy must consume fee when entropy is available"; + EXPECT_EQ(r.state()->earnedAmount, earnedBefore + (uint64)fee); + + // Output entropy must equal what END_TICK produced + EXPECT_TRUE(out.entropy.get(0) == reveal1) + << "BuyEntropy at tick " << r.tick() + << " (stream " << stream + << ") returned wrong entropy. With the old bug " + "(entropy[tier] = stream 0 only) this fails on startTick%3 != 0."; + } +} + +TEST(ContractRandom, EndToEnd_LatestEntropyRetrieved_TickMod3_Is0) +{ + runFullRandomCycle(/*startTick=*/1002); // 1002 % 3 == 0 -> stream 0 +} + +TEST(ContractRandom, EndToEnd_LatestEntropyRetrieved_TickMod3_Is1) +{ + runFullRandomCycle(/*startTick=*/1003); // 1003 % 3 == 1 -> stream 1 +} + +TEST(ContractRandom, EndToEnd_LatestEntropyRetrieved_TickMod3_Is2) +{ + runFullRandomCycle(/*startTick=*/1004); // 1004 % 3 == 2 -> stream 2 +} From e8c71adee8bdcaf8419720a0268df5b4bb15d594 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Wed, 20 May 2026 06:22:53 +0900 Subject: [PATCH 03/13] fix: refund on invalid BuyEntropy inputs; always clear reveal slot - BuyEntropy: add missing else branch so invalid inputs (bad tier, zero/oversize bits, underpaid fee) refund the full invocation reward instead of silently keeping it. - END_TICK: move reveals.set(..., zeroReveal) out of the a no-show aren't permanently locked out. --- src/contracts/Random.h | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/contracts/Random.h b/src/contracts/Random.h index b79b3704b..bd19728dc 100644 --- a/src/contracts/Random.h +++ b/src/contracts/Random.h @@ -99,10 +99,15 @@ struct RANDOM : public ContractBase else { state.mut().earnedAmount += qpi.invocationReward(); - + output.entropy.setRange(0, input.numberOfBits, state.get().entropy.get(locals.entropyIdx)); } } + else + { + // Invalid input: no entropy is delivered, so refund in full. + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } } private: @@ -343,8 +348,13 @@ struct RANDOM : public ContractBase } state.mut().entropy.set(locals.stream * 10 + state.get().collateralTiers.get(locals.stream * 1365 + locals.i), locals.entropy); - state.mut().reveals.set(locals.stream * 1365 + locals.i, locals.zeroReveal); } + // Always clear the reveal slot, even when the XOR was skipped because + // the tier was poisoned by a no-show. Otherwise this provider would + // be permanently locked out: next round their RAC is rejected at the + // "reveals[i] != zeroReveal" guard, and END_TICK would then burn their + // (never-deposited) collateral as a no-show. + state.mut().reveals.set(locals.stream * 1365 + locals.i, locals.zeroReveal); } state.mut().revealOrCommitFlags.set(locals.stream * 1365 + locals.i, 0); From c01f543d25b05c21c182de71c56d4b8be4a4f20e Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Wed, 20 May 2026 06:38:03 +0900 Subject: [PATCH 04/13] fix: correct BuyEntropy output format and refund overpayment Change BuyEntropy_output.entropy from Array to a single bit_4096, copy the first numberOfBits bits, and refund any payment beyond numberOfBits*100 instead of silently keeping it. Co-Authored-By: N-010 --- src/contracts/Random.h | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/src/contracts/Random.h b/src/contracts/Random.h index bd19728dc..70a35aea1 100644 --- a/src/contracts/Random.h +++ b/src/contracts/Random.h @@ -42,14 +42,17 @@ struct RANDOM : public ContractBase struct BuyEntropy_output { - Array entropy; + bit_4096 entropy; }; struct BuyEntropy_locals { bit_4096 zeroEntropy; + bit_4096 entropy; + uint64 i; + uint64 entropyIdx; + sint64 entropyCost; uint32 stream; - uint32 entropyIdx; }; struct StateData @@ -85,22 +88,38 @@ struct RANDOM : public ContractBase PUBLIC_PROCEDURE_WITH_LOCALS(BuyEntropy) { + locals.entropyCost = static_cast(input.numberOfBits) * 100; + if (input.collateralTier <= 9 && input.numberOfBits >= 1 && input.numberOfBits <= 4096 - && qpi.invocationReward() >= input.numberOfBits * 100) + && qpi.invocationReward() >= locals.entropyCost) { + // Read from the stream finalized at the previous END_TICK -- the + // current tick's entropy slot is overwritten at the start of this + // tick's END_TICK and not yet refilled. locals.stream = mod(qpi.tick() + 2, 3); - locals.entropyIdx = locals.stream *10 + input.collateralTier; + locals.entropyIdx = locals.stream * 10 + input.collateralTier; + locals.entropy = state.get().entropy.get(locals.entropyIdx); - if (state.get().entropy.get(locals.entropyIdx) == locals.zeroEntropy) + if (locals.entropy == locals.zeroEntropy) { qpi.transfer(qpi.invocator(), qpi.invocationReward()); } else { - state.mut().earnedAmount += qpi.invocationReward(); + state.mut().earnedAmount += static_cast(locals.entropyCost); + if (qpi.invocationReward() > locals.entropyCost) + { + // Refund any overpayment beyond the per-bit cost. + qpi.transfer(qpi.invocator(), qpi.invocationReward() - locals.entropyCost); + } - output.entropy.setRange(0, input.numberOfBits, state.get().entropy.get(locals.entropyIdx)); + // Copy the first numberOfBits bits of entropy into the output. + // Remaining bits stay zero (output buffer is zeroed by the framework). + for (locals.i = 0; locals.i < input.numberOfBits; locals.i++) + { + output.entropy.set(locals.i, locals.entropy.get(locals.i)); + } } } else From 5cc652206dd45dd23c91e94e92258660a7609371 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Wed, 20 May 2026 11:04:07 +0900 Subject: [PATCH 05/13] fix: hold provider collateral until reveal Rework the collateral lifecycle so a provider's stake stays locked from commit until they reveal: - refund the stake on a valid reveal, slash (burn) it on a no-show - reject an empty commit before any state change to avoid double refund - stop paying silent providers from the treasury at END_TICK Adds lockedCollateralAmounts and revealedThisTickFlags state, and gtests covering the collateral lifecycle. --- src/contracts/Random.h | 296 ++++++++++++++++++--------------------- test/contract_random.cpp | 215 +++++++++++++++++++++++++++- 2 files changed, 345 insertions(+), 166 deletions(-) diff --git a/src/contracts/Random.h b/src/contracts/Random.h index 70a35aea1..1781187b9 100644 --- a/src/contracts/Random.h +++ b/src/contracts/Random.h @@ -23,6 +23,7 @@ struct RANDOM : public ContractBase uint32 stream; uint32 collateralTier; uint32 i; + uint32 index; }; struct Fees_input @@ -70,6 +71,16 @@ struct RANDOM : public ContractBase Array reveals; // 3 * 1365 bit_4096 revealOrCommitFlags; // 3 * 1365 Array entropy; // 3 * 10 + + // Collateral lifecycle (appended fields): + // - lockedCollateralAmounts[index] holds the exact stake currently + // locked for a provider. It is refunded only when the provider + // reveals, and slashed (burned) if they fail to reveal. + // - revealedThisTickFlags[index] is set when the provider revealed + // this tick (vs. only committing), so END_TICK knows whether to mix + // their reveal into entropy. + Array lockedCollateralAmounts; // 3 * 1365 + bit_4096 revealedThisTickFlags; // 3 * 1365 }; PUBLIC_FUNCTION(Fees) @@ -159,15 +170,24 @@ struct RANDOM : public ContractBase default: qpi.transfer(qpi.invocator(), qpi.invocationReward()); return; } + // A commit for the next round is mandatory. Reject an empty commit + // BEFORE touching any state: otherwise the reveal path below would set + // the participation flag and refund here, and END_TICK would then + // refund the collateral a second time (double-pay). + if (input.commit == id::zero()) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + return; + } + locals.stream = mod(qpi.tick(), 3); - if (input.reveal != locals.zeroReveal) // Don't need to initialize [locals.zeroReveal] because - // locals struct has been zeroed (bad practice, but - // it's for spreading awareness about this nuance) + if (input.reveal != locals.zeroReveal) { - for (; locals.i < state.get().populations.get(locals.stream); locals.i++) // Don't need to initialize [locals.i] because locals - // struct has been zeroed (bad practice, but it's for - // spreading awareness about this nuance) + // Reveal path: an existing provider reveals the preimage of their + // previous commit and, in the same transaction, commits for the + // next round. + for (locals.i = 0; locals.i < state.get().populations.get(locals.stream); locals.i++) { if (qpi.invocator() == state.get().providers.get(locals.stream * 1365 + locals.i) && locals.collateralTier == state.get().collateralTiers.get(locals.stream * 1365 + locals.i)) @@ -183,208 +203,166 @@ struct RANDOM : public ContractBase return; } - state.mut().reveals.set(locals.stream * 1365 + locals.i, input.reveal); - state.mut().revealOrCommitFlags.set(locals.stream * 1365 + locals.i, 1); - } + locals.index = locals.stream * 1365 + locals.i; + + // Refund the collateral locked by the previous commit; the provider + // fulfilled their obligation by revealing. + if (state.get().lockedCollateralAmounts.get(locals.index) > 0) + { + qpi.transfer(qpi.invocator(), state.get().lockedCollateralAmounts.get(locals.index)); + } + + // Record the reveal for END_TICK entropy mixing. + state.mut().reveals.set(locals.index, input.reveal); + + // Store the next commit and lock fresh collateral for it. + state.mut().commits.set(locals.index, input.commit); + state.mut().lockedCollateralAmounts.set(locals.index, qpi.invocationReward()); + + state.mut().revealOrCommitFlags.set(locals.index, 1); + state.mut().revealedThisTickFlags.set(locals.index, 1); - if (input.commit == id::zero()) - { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); return; } + // First-commit path: register a brand-new provider for this stream/tier. for (locals.i = 0; locals.i < state.get().populations.get(locals.stream); locals.i++) { if (qpi.invocator() == state.get().providers.get(locals.stream * 1365 + locals.i) && locals.collateralTier == state.get().collateralTiers.get(locals.stream * 1365 + locals.i)) { - break; - } - } - if (locals.i == state.get().populations.get(locals.stream)) - { - if (locals.i == 1365) - { + // An existing provider cannot replace their commit without + // first revealing the previous one. qpi.transfer(qpi.invocator(), qpi.invocationReward()); return; } - - state.mut().providers.set(locals.stream * 1365 + locals.i, qpi.invocator()); - state.mut().collateralTiers.set(locals.stream * 1365 + locals.i, locals.collateralTier); - state.mut().populations.set(locals.stream, locals.i + 1); } - else + if (locals.i == 1365) { - if (state.get().reveals.get(locals.stream * 1365 + locals.i) == locals.zeroReveal) - { - qpi.transfer(qpi.invocator(), qpi.invocationReward()); - return; - } + // The stream is full. + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + return; } - state.mut().commits.set(locals.stream * 1365 + locals.i, input.commit); - state.mut().revealOrCommitFlags.set(locals.stream * 1365 + locals.i, 1); + + locals.index = locals.stream * 1365 + locals.i; + + state.mut().providers.set(locals.index, qpi.invocator()); + state.mut().collateralTiers.set(locals.index, locals.collateralTier); + state.mut().commits.set(locals.index, input.commit); + state.mut().reveals.set(locals.index, locals.zeroReveal); + + // Lock the collateral. It stays in the contract until the provider + // reveals (refund) or fails to reveal (slash) in a future round. + state.mut().lockedCollateralAmounts.set(locals.index, qpi.invocationReward()); + + state.mut().revealOrCommitFlags.set(locals.index, 1); + state.mut().revealedThisTickFlags.set(locals.index, 0); + + state.mut().populations.set(locals.stream, locals.i + 1); } struct END_TICK_locals { bit_4096 zeroReveal; // TODO: Use a constant from either QPI or global state bit_4096 entropy; - id collateralRecipient; uint32 stream; uint32 i, j; + uint32 index; + uint32 lastIndex; + uint32 tier; uint16 collateralTierFlags; + uint64 lockedAmount; }; END_TICK_WITH_LOCALS() { locals.stream = mod(qpi.tick(), 3); - for (; locals.i < 10; locals.i++) // Don't need to initialize [locals.i] because locals - // struct has been zeroed (bad practice, but it's for - // spreading awareness about this nuance) + // Entropy for this stream is recomputed from scratch every cycle. + for (locals.i = 0; locals.i < 10; locals.i++) { - state.mut().entropy.set(locals.stream * 10 + locals.i, - locals.zeroReveal); // Don't need to initialize [locals.zeroReveal] - // because locals struct has been zeroed (bad - // practice, but it's for spreading awareness - // about this nuance) + state.mut().entropy.set(locals.stream * 10 + locals.i, locals.zeroReveal); } - for (locals.i = 0; locals.i < state.get().populations.get(locals.stream); locals.i++) - { - if (state.get().revealOrCommitFlags.get(locals.stream * 1365 + locals.i)) - { - break; - } - } - if (locals.i == state.get().populations.get(locals.stream)) // Nobody provided their reveal, that - // tick was probably empty - { - while (locals.i--) - { - switch (state.get().collateralTiers.get(locals.stream * 1365 + locals.i)) - { - case 0: qpi.transfer(state.get().providers.get(locals.stream * 1365 + locals.i), 1); break; - - case 1: qpi.transfer(state.get().providers.get(locals.stream * 1365 + locals.i), 10); break; - - case 2: qpi.transfer(state.get().providers.get(locals.stream * 1365 + locals.i), 100); break; - - case 3: qpi.transfer(state.get().providers.get(locals.stream * 1365 + locals.i), 1000); break; - - case 4: qpi.transfer(state.get().providers.get(locals.stream * 1365 + locals.i), 10000); break; - - case 5: qpi.transfer(state.get().providers.get(locals.stream * 1365 + locals.i), 100000); break; - - case 6: qpi.transfer(state.get().providers.get(locals.stream * 1365 + locals.i), 1000000); break; - - case 7: qpi.transfer(state.get().providers.get(locals.stream * 1365 + locals.i), 10000000); break; - - case 8: qpi.transfer(state.get().providers.get(locals.stream * 1365 + locals.i), 100000000); break; - - default: qpi.transfer(state.get().providers.get(locals.stream * 1365 + locals.i), 1000000000); - } - state.mut().providers.set(locals.stream * 1365 + locals.i, id::zero()); - state.mut().collateralTiers.set(locals.stream * 1365 + locals.i, 0); - // Don't need to zero [state.reveals], they are all-zeros anyway - state.mut().commits.set(locals.stream * 1365 + locals.i, id::zero()); - } - state.mut().populations.set(locals.stream, 0); - } - else + // Walk providers back-to-front so removed slots can be swap-deleted + // without disturbing slots not yet visited. + for (locals.i = state.get().populations.get(locals.stream); locals.i--;) { - // Don't need to initialize [locals.collateralTierFlags] because locals - // struct has been zeroed (bad practice, but it's for spreading awareness - // about this nuance) + locals.index = locals.stream * 1365 + locals.i; + locals.tier = static_cast(state.get().collateralTiers.get(locals.index)); - for (locals.i = state.get().populations.get(locals.stream); locals.i--;) + if (state.get().revealOrCommitFlags.get(locals.index)) { - if (state.get().revealOrCommitFlags.get(locals.stream * 1365 + locals.i)) - { - locals.collateralRecipient = state.get().providers.get(locals.stream * 1365 + locals.i); - } - else + // Provider participated this tick (revealed and/or committed). + // Their collateral stays locked until they reveal it later. + if (state.get().revealedThisTickFlags.get(locals.index)) { - locals.collateralRecipient = id::zero(); - } - - switch (state.get().collateralTiers.get(locals.stream * 1365 + locals.i)) - { - case 0: qpi.transfer(locals.collateralRecipient, 1); break; - - case 1: qpi.transfer(locals.collateralRecipient, 10); break; - - case 2: qpi.transfer(locals.collateralRecipient, 100); break; - - case 3: qpi.transfer(locals.collateralRecipient, 1000); break; - - case 4: qpi.transfer(locals.collateralRecipient, 10000); break; - - case 5: qpi.transfer(locals.collateralRecipient, 100000); break; - - case 6: qpi.transfer(locals.collateralRecipient, 1000000); break; - - case 7: qpi.transfer(locals.collateralRecipient, 10000000); break; - - case 8: qpi.transfer(locals.collateralRecipient, 100000000); break; - - default: qpi.transfer(locals.collateralRecipient, 1000000000); - } - - if (locals.collateralRecipient == id::zero()) - { - locals.collateralTierFlags |= (1 << state.get().collateralTiers.get(locals.stream * 1365 + locals.i)); - - state.mut().providers.set(locals.stream * 1365 + locals.i, - state.get().providers.get(locals.stream * 1365 + state.get().populations.get(locals.stream))); - state.mut().providers.set(locals.stream * 1365 + state.get().populations.get(locals.stream), id::zero()); - - state.mut().collateralTiers.set( - locals.stream * 1365 + locals.i, - state.get().collateralTiers.get(locals.stream * 1365 + state.get().populations.get(locals.stream))); - state.mut().collateralTiers.set(locals.stream * 1365 + state.get().populations.get(locals.stream), 0); - - state.mut().reveals.set(locals.stream * 1365 + locals.i, - state.get().reveals.get(locals.stream * 1365 + state.get().populations.get(locals.stream))); - state.mut().reveals.set(locals.stream * 1365 + state.get().populations.get(locals.stream), locals.zeroReveal); - - state.mut().commits.set(locals.stream * 1365 + locals.i, - state.get().commits.get(locals.stream * 1365 + state.get().populations.get(locals.stream))); - state.mut().commits.set(locals.stream * 1365 + state.get().populations.get(locals.stream), id::zero()); - - state.mut().populations.set(locals.stream, state.get().populations.get(locals.stream) - 1); - } - else - { - if (!(locals.collateralTierFlags & (1 << state.get().collateralTiers.get(locals.stream * 1365 + locals.i)))) + // A valid reveal contributes to this tier's entropy, unless + // the tier was already poisoned by a no-show found earlier + // in the walk. + if (!(locals.collateralTierFlags & (1 << locals.tier))) { - locals.entropy = - state.get().entropy.get(locals.stream * 10 + state.get().collateralTiers.get(locals.stream * 1365 + locals.i)); + locals.entropy = state.get().entropy.get(locals.stream * 10 + locals.tier); for (locals.j = 0; locals.j < 4096; locals.j++) { locals.entropy.set(locals.j, - locals.entropy.get(locals.j) ^ state.get().reveals.get(locals.stream * 1365 + locals.i).get(locals.j)); + locals.entropy.get(locals.j) ^ state.get().reveals.get(locals.index).get(locals.j)); } - state.mut().entropy.set(locals.stream * 10 + state.get().collateralTiers.get(locals.stream * 1365 + locals.i), - locals.entropy); + state.mut().entropy.set(locals.stream * 10 + locals.tier, locals.entropy); } - // Always clear the reveal slot, even when the XOR was skipped because - // the tier was poisoned by a no-show. Otherwise this provider would - // be permanently locked out: next round their RAC is rejected at the - // "reveals[i] != zeroReveal" guard, and END_TICK would then burn their - // (never-deposited) collateral as a no-show. - state.mut().reveals.set(locals.stream * 1365 + locals.i, locals.zeroReveal); + // Always clear the reveal so the provider can reveal again + // next round, even when the XOR above was skipped because + // the tier was poisoned. + state.mut().reveals.set(locals.index, locals.zeroReveal); } - state.mut().revealOrCommitFlags.set(locals.stream * 1365 + locals.i, 0); + state.mut().revealOrCommitFlags.set(locals.index, 0); + state.mut().revealedThisTickFlags.set(locals.index, 0); } - - for (locals.i = 0; locals.i < 10; locals.i++) + else { - if (locals.collateralTierFlags & (1 << locals.i)) + // Provider did nothing this tick: slash the collateral locked + // by their last commit and remove them from the pool. + locals.lockedAmount = state.get().lockedCollateralAmounts.get(locals.index); + if (locals.lockedAmount > 0) + { + qpi.burn(static_cast(locals.lockedAmount)); + state.mut().burnedAmount += locals.lockedAmount; + } + + // A missing reveal invalidates this tier's entropy for the round. + locals.collateralTierFlags |= (1 << locals.tier); + + // Swap-delete: move the last active provider into this slot. + locals.lastIndex = locals.stream * 1365 + state.get().populations.get(locals.stream) - 1; + if (locals.index != locals.lastIndex) { - state.mut().entropy.set(locals.stream * 10 + locals.i, locals.zeroReveal); + state.mut().providers.set(locals.index, state.get().providers.get(locals.lastIndex)); + state.mut().collateralTiers.set(locals.index, state.get().collateralTiers.get(locals.lastIndex)); + state.mut().commits.set(locals.index, state.get().commits.get(locals.lastIndex)); + state.mut().reveals.set(locals.index, state.get().reveals.get(locals.lastIndex)); + state.mut().lockedCollateralAmounts.set(locals.index, state.get().lockedCollateralAmounts.get(locals.lastIndex)); + state.mut().revealOrCommitFlags.set(locals.index, state.get().revealOrCommitFlags.get(locals.lastIndex)); + state.mut().revealedThisTickFlags.set(locals.index, state.get().revealedThisTickFlags.get(locals.lastIndex)); } + state.mut().providers.set(locals.lastIndex, id::zero()); + state.mut().collateralTiers.set(locals.lastIndex, 0); + state.mut().commits.set(locals.lastIndex, id::zero()); + state.mut().reveals.set(locals.lastIndex, locals.zeroReveal); + state.mut().lockedCollateralAmounts.set(locals.lastIndex, 0); + state.mut().revealOrCommitFlags.set(locals.lastIndex, 0); + state.mut().revealedThisTickFlags.set(locals.lastIndex, 0); + + state.mut().populations.set(locals.stream, state.get().populations.get(locals.stream) - 1); + } + } + + // Drop entropy for any tier that had a no-show this round. + for (locals.i = 0; locals.i < 10; locals.i++) + { + if (locals.collateralTierFlags & (1 << locals.i)) + { + state.mut().entropy.set(locals.stream * 10 + locals.i, locals.zeroReveal); } } } diff --git a/test/contract_random.cpp b/test/contract_random.cpp index 9be0f51c9..1259d4902 100644 --- a/test/contract_random.cpp +++ b/test/contract_random.cpp @@ -121,7 +121,7 @@ TEST(ContractRandom, BuyEntropyRefundsWhenEntropyMissing) EXPECT_EQ(getBalance(user), balanceBefore) << "BuyEntropy must refund when no entropy is available"; QPI::bit_4096 zero; zero.setAll(0); - EXPECT_TRUE(out.entropy.get(0) == zero) << "Output must remain all-zero on refund"; + EXPECT_TRUE(out.entropy == zero) << "Output must remain all-zero on refund"; EXPECT_EQ(r.state()->earnedAmount, 0u) << "Refund must not credit earnedAmount"; } @@ -156,7 +156,7 @@ TEST(ContractRandom, BuyEntropyRefundsOnInvalidTier) << "invalid tier must refund the full fee"; EXPECT_EQ(r.state()->earnedAmount, earnedBefore) << "invalid tier must NOT credit earnedAmount"; - EXPECT_TRUE(out.entropy.get(0) == zero) + EXPECT_TRUE(out.entropy == zero) << "rejected request must not deliver entropy"; } @@ -176,7 +176,7 @@ TEST(ContractRandom, BuyEntropyRefundsOnZeroBits) EXPECT_EQ(getBalance(user), before) << "numberOfBits=0 must refund the full fee"; EXPECT_EQ(r.state()->earnedAmount, earnedBefore); - EXPECT_TRUE(out.entropy.get(0) == zero); + EXPECT_TRUE(out.entropy == zero); } TEST(ContractRandom, BuyEntropyRefundsOnUnderpaidFee) @@ -195,7 +195,7 @@ TEST(ContractRandom, BuyEntropyRefundsOnUnderpaidFee) EXPECT_EQ(getBalance(user), before) << "underpaid fee must be refunded (no entropy was delivered)"; EXPECT_EQ(r.state()->earnedAmount, earnedBefore); - EXPECT_TRUE(out.entropy.get(0) == zero); + EXPECT_TRUE(out.entropy == zero); } TEST(ContractRandom, BuyEntropyRefundsOnOversizeBits) @@ -216,7 +216,7 @@ TEST(ContractRandom, BuyEntropyRefundsOnOversizeBits) EXPECT_EQ(getBalance(user), before) << "numberOfBits > 4096 must be refunded"; EXPECT_EQ(r.state()->earnedAmount, earnedBefore); - EXPECT_TRUE(out.entropy.get(0) == zero); + EXPECT_TRUE(out.entropy == zero); } // --------------------------------------------------------------------------- @@ -302,7 +302,9 @@ namespace const uint32 expectedLatest = (r.tick() + 2u) % 3u; ASSERT_EQ(expectedLatest, stream); - const uint16 numberOfBits = 256; + // Request the full 4096 bits so the whole entropy value is delivered + // and can be compared against reveal1 directly. + const uint16 numberOfBits = 4096; const sint64 fee = (sint64)numberOfBits * 100; id buyer = getUser(0xBEEF + startTick); @@ -318,7 +320,7 @@ namespace EXPECT_EQ(r.state()->earnedAmount, earnedBefore + (uint64)fee); // Output entropy must equal what END_TICK produced - EXPECT_TRUE(out.entropy.get(0) == reveal1) + EXPECT_TRUE(out.entropy == reveal1) << "BuyEntropy at tick " << r.tick() << " (stream " << stream << ") returned wrong entropy. With the old bug " @@ -340,3 +342,202 @@ TEST(ContractRandom, EndToEnd_LatestEntropyRetrieved_TickMod3_Is2) { runFullRandomCycle(/*startTick=*/1004); // 1004 % 3 == 2 -> stream 2 } + +// --------------------------------------------------------------------------- +// Collateral-lifecycle tests (C1 / C2 / C3 / H4). +// +// The contract holds a provider's stake from the moment they commit until +// they reveal it in a later round. The core invariant is: the contract never +// pays or burns a tier amount unless that exact amount is recorded in +// state.lockedCollateralAmounts[index]. +// --------------------------------------------------------------------------- + +// C3: a first commit must NOT be refunded at END_TICK. The stake stays locked +// in the contract until the provider reveals it (or is slashed for not doing +// so). Refunding at the commit tick means no real stake is ever held. +TEST(ContractRandom, FirstCommitHoldsCollateralThroughEndTick) +{ + ContractTestingRandom r; + const uint8 tier = 2; + const sint64 collateral = collateralForTier(tier); // 100 + const uint32 stream = r.tick() % 3; + const uint32 index = stream * 1365 + 0; + + id provider = getUser(1); + increaseEnergy(provider, collateral); + + QPI::bit_4096 zero; zero.setAll(0); + r.revealAndCommit(provider, zero, commitOf(makeReveal(1)), collateral); + + // Stake was paid in and recorded as locked. + EXPECT_EQ(getBalance(provider), 0); + EXPECT_EQ(r.state()->lockedCollateralAmounts.get(index), (uint64)collateral); + + r.endTick(); + + // C3: the collateral must still be locked after END_TICK, not refunded. + EXPECT_EQ(getBalance(provider), 0) + << "first-commit collateral must stay locked through END_TICK"; + EXPECT_EQ(r.state()->lockedCollateralAmounts.get(index), (uint64)collateral) + << "lockedCollateralAmounts must be unchanged by END_TICK"; + EXPECT_EQ(r.state()->populations.get(stream), 1u) + << "a provider who committed must remain in the pool"; +} + +// C3: a valid reveal refunds exactly the previously locked stake (once) and +// locks the freshly supplied stake for the next round. +TEST(ContractRandom, RevealRefundsOldCollateralExactlyOnce) +{ + ContractTestingRandom r; + const uint8 tier = 2; + const sint64 collateral = collateralForTier(tier); + const uint32 stream = r.tick() % 3; + const uint32 index = stream * 1365 + 0; + + auto reveal1 = makeReveal(1); + id provider = getUser(1); + QPI::bit_4096 zero; zero.setAll(0); + + // First commit. + increaseEnergy(provider, collateral); + r.revealAndCommit(provider, zero, commitOf(reveal1), collateral); + r.endTick(); + + // Next cycle: reveal the preimage and commit again. + r.setTick(r.tick() + 3); + increaseEnergy(provider, collateral); + const long long before = getBalance(provider); // just-funded new stake + + r.revealAndCommit(provider, reveal1, commitOf(makeReveal(2)), collateral); + + // The provider paid one new stake and got the old one back -> net zero. + EXPECT_EQ(getBalance(provider), before) + << "reveal must refund exactly the previously locked collateral"; + EXPECT_EQ(r.state()->lockedCollateralAmounts.get(index), (uint64)collateral) + << "the new commit's collateral must now be locked"; +} + +// C2: a reveal carrying an empty commit must be rejected BEFORE any state +// change and refunded exactly once. The old bug set the participation flag and +// refunded, then END_TICK refunded the collateral a second time. +TEST(ContractRandom, RevealWithZeroCommitIsRejectedNoDoublePay) +{ + ContractTestingRandom r; + const uint8 tier = 2; + const sint64 collateral = collateralForTier(tier); + const uint32 stream = r.tick() % 3; + const uint32 index = stream * 1365 + 0; + + auto reveal1 = makeReveal(1); + id provider = getUser(1); + QPI::bit_4096 zero; zero.setAll(0); + + // First commit. + increaseEnergy(provider, collateral); + r.revealAndCommit(provider, zero, commitOf(reveal1), collateral); + r.endTick(); + + // Next cycle: attempt to reveal with commit == 0. + r.setTick(r.tick() + 3); + increaseEnergy(provider, collateral); + const long long before = getBalance(provider); + + r.revealAndCommit(provider, reveal1, /*commit=*/id::zero(), collateral); + + // The call is rejected and refunded once; no state was touched. + EXPECT_EQ(getBalance(provider), before) + << "commit==0 must be refunded once, without double-paying"; + EXPECT_TRUE(r.state()->reveals.get(index) == zero) + << "a rejected commit==0 call must not store the reveal"; + EXPECT_EQ(r.state()->revealedThisTickFlags.get(index), 0) + << "a rejected commit==0 call must not set the participation flag"; +} + +// C1: a provider who does nothing in a round receives NOTHING. Their locked +// stake is burned (slashed) and they are removed from the pool. The old bug +// paid silent providers from the treasury. +TEST(ContractRandom, SilentProviderIsSlashedNotPaid) +{ + ContractTestingRandom r; + const uint8 tier = 2; + const sint64 collateral = collateralForTier(tier); + const uint32 stream = r.tick() % 3; + + id provider = getUser(1); + QPI::bit_4096 zero; zero.setAll(0); + + // First commit, then survive one END_TICK with the stake locked. + increaseEnergy(provider, collateral); + r.revealAndCommit(provider, zero, commitOf(makeReveal(1)), collateral); + r.endTick(); + + const uint64 burnedBefore = r.state()->burnedAmount; + const long long balanceBefore = getBalance(provider); + + // Next cycle: the provider stays silent. + r.setTick(r.tick() + 3); + r.endTick(); + + // C1: silent provider is not paid; their stake is burned and they are gone. + EXPECT_EQ(getBalance(provider), balanceBefore) + << "a silent provider must not receive any payout"; + EXPECT_EQ(r.state()->burnedAmount, burnedBefore + (uint64)collateral) + << "the silent provider's locked collateral must be burned"; + EXPECT_EQ(r.state()->populations.get(stream), 0u) + << "the silent provider must be removed from the pool"; +} + +// H4: when one provider in a tier is a no-show, the other providers in that +// same tier must not be locked out. END_TICK clears their reveal slot even +// though the poisoned tier's entropy XOR is skipped. +TEST(ContractRandom, NoShowInTierDoesNotLockOutOtherProviders) +{ + ContractTestingRandom r; + const uint8 tier = 2; + const sint64 collateral = collateralForTier(tier); + const uint32 stream = r.tick() % 3; + + auto good1 = makeReveal(10); + auto good2 = makeReveal(11); + auto good3 = makeReveal(12); + auto bad1 = makeReveal(20); + + id good = getUser(1); + id bad = getUser(2); + QPI::bit_4096 zero; zero.setAll(0); + + // Tick T: both providers register in the same tier. + increaseEnergy(good, collateral); + increaseEnergy(bad, collateral); + r.revealAndCommit(good, zero, commitOf(good1), collateral); + r.revealAndCommit(bad, zero, commitOf(bad1), collateral); + r.endTick(); + EXPECT_EQ(r.state()->populations.get(stream), 2u); + + // Tick T+3: good reveals, bad is a no-show. + r.setTick(r.tick() + 3); + increaseEnergy(good, collateral); + r.revealAndCommit(good, good1, commitOf(good2), collateral); + r.endTick(); + + EXPECT_EQ(r.state()->populations.get(stream), 1u) + << "the no-show provider must be removed from the pool"; + + // Tick T+6: good must still be able to reveal+commit. This only works if + // END_TICK cleared good's reveal slot at T+3 despite the poisoned tier. + r.setTick(r.tick() + 3); + increaseEnergy(good, collateral); + r.revealAndCommit(good, good2, commitOf(good3), collateral); + + bool found = false; + for (uint32 i = 0; i < r.state()->populations.get(stream); i++) + { + if (r.state()->providers.get(stream * 1365 + i) == good) + { + found = true; + EXPECT_EQ(r.state()->revealedThisTickFlags.get(stream * 1365 + i), 1) + << "good provider's reveal at T+6 must be accepted, not rejected"; + } + } + EXPECT_TRUE(found) << "good provider must still be in the pool"; +} From b559bf99de22b9636269569f868215d0b3eedd4f Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Mon, 25 May 2026 15:02:24 +0900 Subject: [PATCH 06/13] fix: const and padding problem --- src/contract_core/contract_def.h | 4 ++-- src/contracts/Random.h | 26 ++++++++++++++------------ 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/contract_core/contract_def.h b/src/contract_core/contract_def.h index 4053a2623..f7afd22d4 100644 --- a/src/contract_core/contract_def.h +++ b/src/contract_core/contract_def.h @@ -543,8 +543,8 @@ static void initializeContracts() // When enabling, replace both lines below, e.g.: // constexpr unsigned int paddableContracts[] = { RANDOM_CONTRACT_INDEX }; // constexpr unsigned int paddableCount = sizeof(paddableContracts) / sizeof(paddableContracts[0]); -constexpr const unsigned int* paddableContracts = nullptr; -constexpr unsigned int paddableCount = 0; +constexpr unsigned int paddableContracts[] = { RANDOM_CONTRACT_INDEX }; +constexpr unsigned int paddableCount = sizeof(paddableContracts) / sizeof(paddableContracts[0]); // Class for registering and looking up user procedures independently of input type, for example for notifications diff --git a/src/contracts/Random.h b/src/contracts/Random.h index 1781187b9..5b62d02e0 100644 --- a/src/contracts/Random.h +++ b/src/contracts/Random.h @@ -1,5 +1,7 @@ using namespace QPI; +constexpr uint32 RANDOM_BITFEE = 100; + struct RANDOM2 { }; @@ -85,21 +87,21 @@ struct RANDOM : public ContractBase PUBLIC_FUNCTION(Fees) { - output.fees.set(0, 100); - output.fees.set(1, 100); - output.fees.set(2, 100); - output.fees.set(3, 100); - output.fees.set(4, 100); - output.fees.set(5, 100); - output.fees.set(6, 100); - output.fees.set(7, 100); - output.fees.set(8, 100); - output.fees.set(9, 100); + output.fees.set(0, state.get().bitFee); + output.fees.set(1, state.get().bitFee); + output.fees.set(2, state.get().bitFee); + output.fees.set(3, state.get().bitFee); + output.fees.set(4, state.get().bitFee); + output.fees.set(5, state.get().bitFee); + output.fees.set(6, state.get().bitFee); + output.fees.set(7, state.get().bitFee); + output.fees.set(8, state.get().bitFee); + output.fees.set(9, state.get().bitFee); } PUBLIC_PROCEDURE_WITH_LOCALS(BuyEntropy) { - locals.entropyCost = static_cast(input.numberOfBits) * 100; + locals.entropyCost = static_cast(input.numberOfBits) * state.get().bitFee; if (input.collateralTier <= 9 && input.numberOfBits >= 1 && input.numberOfBits <= 4096 @@ -377,6 +379,6 @@ struct RANDOM : public ContractBase INITIALIZE() { - // state.mut().bitFee = 1000; + state.mut().bitFee = RANDOM_BITFEE; } }; \ No newline at end of file From 0ea95eec1c376b349f0b8a190a520a4ad7a17c88 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Mon, 25 May 2026 15:13:21 +0900 Subject: [PATCH 07/13] fix: const issue --- src/contracts/Random.h | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/contracts/Random.h b/src/contracts/Random.h index 5b62d02e0..06f12715c 100644 --- a/src/contracts/Random.h +++ b/src/contracts/Random.h @@ -1,6 +1,8 @@ using namespace QPI; constexpr uint32 RANDOM_BITFEE = 100; +constexpr uint32 RANDOM_STREAM_CAPACITY = 1365; +constexpr uint32 RANDOM_MAX_PROVIDERS = 4096; struct RANDOM2 { @@ -67,10 +69,10 @@ struct RANDOM : public ContractBase uint32 bitFee; // Amount of qus Array populations; // 3 - Array providers; // 3 * 1365 - Array collateralTiers; // 3 * 1365 - Array commits; // 3 * 1365 - Array reveals; // 3 * 1365 + Array providers; // 3 * 1365 + Array collateralTiers; // 3 * 1365 + Array commits; // 3 * 1365 + Array reveals; // 3 * 1365 bit_4096 revealOrCommitFlags; // 3 * 1365 Array entropy; // 3 * 10 @@ -81,7 +83,7 @@ struct RANDOM : public ContractBase // - revealedThisTickFlags[index] is set when the provider revealed // this tick (vs. only committing), so END_TICK knows whether to mix // their reveal into entropy. - Array lockedCollateralAmounts; // 3 * 1365 + Array lockedCollateralAmounts; // 3 * 1365 bit_4096 revealedThisTickFlags; // 3 * 1365 }; @@ -191,21 +193,21 @@ struct RANDOM : public ContractBase // next round. for (locals.i = 0; locals.i < state.get().populations.get(locals.stream); locals.i++) { - if (qpi.invocator() == state.get().providers.get(locals.stream * 1365 + locals.i) && - locals.collateralTier == state.get().collateralTiers.get(locals.stream * 1365 + locals.i)) + if (qpi.invocator() == state.get().providers.get(locals.stream * RANDOM_STREAM_CAPACITY + locals.i) && + locals.collateralTier == state.get().collateralTiers.get(locals.stream * RANDOM_STREAM_CAPACITY + locals.i)) { break; } } if (locals.i == state.get().populations.get(locals.stream) || - state.get().reveals.get(locals.stream * 1365 + locals.i) != locals.zeroReveal || - qpi.K12(input.reveal) != state.get().commits.get(locals.stream * 1365 + locals.i)) + state.get().reveals.get(locals.stream * RANDOM_STREAM_CAPACITY + locals.i) != locals.zeroReveal || + qpi.K12(input.reveal) != state.get().commits.get(locals.stream * RANDOM_STREAM_CAPACITY + locals.i)) { qpi.transfer(qpi.invocator(), qpi.invocationReward()); return; } - locals.index = locals.stream * 1365 + locals.i; + locals.index = locals.stream * RANDOM_STREAM_CAPACITY + locals.i; // Refund the collateral locked by the previous commit; the provider // fulfilled their obligation by revealing. @@ -230,8 +232,8 @@ struct RANDOM : public ContractBase // First-commit path: register a brand-new provider for this stream/tier. for (locals.i = 0; locals.i < state.get().populations.get(locals.stream); locals.i++) { - if (qpi.invocator() == state.get().providers.get(locals.stream * 1365 + locals.i) && - locals.collateralTier == state.get().collateralTiers.get(locals.stream * 1365 + locals.i)) + if (qpi.invocator() == state.get().providers.get(locals.stream * RANDOM_STREAM_CAPACITY + locals.i) && + locals.collateralTier == state.get().collateralTiers.get(locals.stream * RANDOM_STREAM_CAPACITY + locals.i)) { // An existing provider cannot replace their commit without // first revealing the previous one. @@ -239,14 +241,14 @@ struct RANDOM : public ContractBase return; } } - if (locals.i == 1365) + if (locals.i == RANDOM_STREAM_CAPACITY) { // The stream is full. qpi.transfer(qpi.invocator(), qpi.invocationReward()); return; } - locals.index = locals.stream * 1365 + locals.i; + locals.index = locals.stream * RANDOM_STREAM_CAPACITY + locals.i; state.mut().providers.set(locals.index, qpi.invocator()); state.mut().collateralTiers.set(locals.index, locals.collateralTier); @@ -290,7 +292,7 @@ struct RANDOM : public ContractBase // without disturbing slots not yet visited. for (locals.i = state.get().populations.get(locals.stream); locals.i--;) { - locals.index = locals.stream * 1365 + locals.i; + locals.index = locals.stream * RANDOM_STREAM_CAPACITY + locals.i; locals.tier = static_cast(state.get().collateralTiers.get(locals.index)); if (state.get().revealOrCommitFlags.get(locals.index)) @@ -336,7 +338,7 @@ struct RANDOM : public ContractBase locals.collateralTierFlags |= (1 << locals.tier); // Swap-delete: move the last active provider into this slot. - locals.lastIndex = locals.stream * 1365 + state.get().populations.get(locals.stream) - 1; + locals.lastIndex = locals.stream * RANDOM_STREAM_CAPACITY + state.get().populations.get(locals.stream) - 1; if (locals.index != locals.lastIndex) { state.mut().providers.set(locals.index, state.get().providers.get(locals.lastIndex)); From d7968d6a3bbed82d05b6587c50611f136ea6db41 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Wed, 27 May 2026 10:11:50 +0900 Subject: [PATCH 08/13] fix: empty tick problem --- src/contracts/Random.h | 51 ++++++++ test/contract_random.cpp | 250 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 289 insertions(+), 12 deletions(-) diff --git a/src/contracts/Random.h b/src/contracts/Random.h index 06f12715c..976c8e8df 100644 --- a/src/contracts/Random.h +++ b/src/contracts/Random.h @@ -288,6 +288,57 @@ struct RANDOM : public ContractBase state.mut().entropy.set(locals.stream * 10 + locals.i, locals.zeroReveal); } + for (locals.i = 0; locals.i < state.get().populations.get(locals.stream); locals.i++) + { + if (state.get().revealedThisTickFlags.get(locals.stream * RANDOM_STREAM_CAPACITY + locals.i)) + { + break; + } + } + if (locals.i == state.get().populations.get(locals.stream)) + { + for (locals.i = state.get().populations.get(locals.stream); locals.i--;) + { + locals.index = locals.stream * RANDOM_STREAM_CAPACITY + locals.i; + if (state.get().revealOrCommitFlags.get(locals.index)) + { + state.mut().revealOrCommitFlags.set(locals.index, 0); + continue; + } + + locals.lockedAmount = state.get().lockedCollateralAmounts.get(locals.index); + if (locals.lockedAmount > 0) + { + qpi.transfer(state.get().providers.get(locals.index), + static_cast(locals.lockedAmount)); + } + + // Swap-delete: move the last active provider into this slot + // so the stream stays compact. + locals.lastIndex = locals.stream * RANDOM_STREAM_CAPACITY + state.get().populations.get(locals.stream) - 1; + if (locals.index != locals.lastIndex) + { + state.mut().providers.set(locals.index, state.get().providers.get(locals.lastIndex)); + state.mut().collateralTiers.set(locals.index, state.get().collateralTiers.get(locals.lastIndex)); + state.mut().commits.set(locals.index, state.get().commits.get(locals.lastIndex)); + state.mut().reveals.set(locals.index, state.get().reveals.get(locals.lastIndex)); + state.mut().lockedCollateralAmounts.set(locals.index, state.get().lockedCollateralAmounts.get(locals.lastIndex)); + state.mut().revealOrCommitFlags.set(locals.index, state.get().revealOrCommitFlags.get(locals.lastIndex)); + state.mut().revealedThisTickFlags.set(locals.index, state.get().revealedThisTickFlags.get(locals.lastIndex)); + } + state.mut().providers.set(locals.lastIndex, id::zero()); + state.mut().collateralTiers.set(locals.lastIndex, 0); + state.mut().commits.set(locals.lastIndex, id::zero()); + state.mut().reveals.set(locals.lastIndex, locals.zeroReveal); + state.mut().lockedCollateralAmounts.set(locals.lastIndex, 0); + state.mut().revealOrCommitFlags.set(locals.lastIndex, 0); + state.mut().revealedThisTickFlags.set(locals.lastIndex, 0); + + state.mut().populations.set(locals.stream, state.get().populations.get(locals.stream) - 1); + } + return; + } + // Walk providers back-to-front so removed slots can be swap-deleted // without disturbing slots not yet visited. for (locals.i = state.get().populations.get(locals.stream); locals.i--;) diff --git a/test/contract_random.cpp b/test/contract_random.cpp index 1259d4902..77c2c3c90 100644 --- a/test/contract_random.cpp +++ b/test/contract_random.cpp @@ -453,9 +453,15 @@ TEST(ContractRandom, RevealWithZeroCommitIsRejectedNoDoublePay) << "a rejected commit==0 call must not set the participation flag"; } -// C1: a provider who does nothing in a round receives NOTHING. Their locked -// stake is burned (slashed) and they are removed from the pool. The old bug -// paid silent providers from the treasury. +// C1: a provider who does nothing in a round receives NOTHING - PROVIDED at +// least one reveal landed in the stream this tick (the reveal channel was +// usable). Their locked stake is burned (slashed) and they are removed from +// the pool. The old bug paid silent providers from the treasury. +// +// Note: a second "witness" provider reveals in T+3 so revealedThisTickFlags +// is set somewhere in the stream. Without a witness, END_TICK would take the +// empty-reveal-channel branch and refund instead of slash. See the dedicated +// EmptyRevealChannel tests for that semantics. TEST(ContractRandom, SilentProviderIsSlashedNotPaid) { ContractTestingRandom r; @@ -463,28 +469,248 @@ TEST(ContractRandom, SilentProviderIsSlashedNotPaid) const sint64 collateral = collateralForTier(tier); const uint32 stream = r.tick() % 3; + id silent = getUser(1); + id witness = getUser(2); + auto witR1 = makeReveal(101); + auto witR2 = makeReveal(102); + QPI::bit_4096 zero; zero.setAll(0); + + // T: both providers commit. T+3 will be a slash-eligible round once the + // witness reveals. + increaseEnergy(silent, collateral); + increaseEnergy(witness, collateral); + r.revealAndCommit(silent, zero, commitOf(makeReveal(1)), collateral); + r.revealAndCommit(witness, zero, commitOf(witR1), collateral); + r.endTick(); + + const uint64 burnedBefore = r.state()->burnedAmount; + const long long silentBefore = getBalance(silent); + + // T+3: witness reveals, silent does nothing. The reveal channel is proven + // usable, so slashing applies to silent. + r.setTick(r.tick() + 3); + increaseEnergy(witness, collateral); + r.revealAndCommit(witness, witR1, commitOf(witR2), collateral); + r.endTick(); + + EXPECT_EQ(getBalance(silent), silentBefore) + << "a silent provider must not receive any payout when a peer revealed"; + EXPECT_EQ(r.state()->burnedAmount, burnedBefore + (uint64)collateral) + << "the silent provider's locked collateral must be burned"; + EXPECT_EQ(r.state()->populations.get(stream), 1u) + << "only the witness must remain; the silent provider is removed"; +} + +TEST(ContractRandom, EmptyRevealChannelRefundsAndRemovesPendingCommit) +{ + ContractTestingRandom r; + const uint8 tier = 2; + const sint64 collateral = collateralForTier(tier); + const uint32 stream = r.tick() % 3; + id provider = getUser(1); QPI::bit_4096 zero; zero.setAll(0); - // First commit, then survive one END_TICK with the stake locked. increaseEnergy(provider, collateral); r.revealAndCommit(provider, zero, commitOf(makeReveal(1)), collateral); r.endTick(); + const uint32 index = stream * RANDOM_STREAM_CAPACITY; + EXPECT_EQ(r.state()->lockedCollateralAmounts.get(index), (uint64)collateral); - const uint64 burnedBefore = r.state()->burnedAmount; + const uint64 burnedBefore = r.state()->burnedAmount; const long long balanceBefore = getBalance(provider); - // Next cycle: the provider stays silent. + // T+3: nobody reveals (network glitch). END_TICK refunds the stake + // AND removes the provider so the slot is reusable. Leaving them + // registered with commits == 0 would brick the slot (the existing- + // provider check in the first-commit path and the K12 check in the + // reveal path both reject any return). r.setTick(r.tick() + 3); r.endTick(); - // C1: silent provider is not paid; their stake is burned and they are gone. - EXPECT_EQ(getBalance(provider), balanceBefore) - << "a silent provider must not receive any payout"; - EXPECT_EQ(r.state()->burnedAmount, burnedBefore + (uint64)collateral) - << "the silent provider's locked collateral must be burned"; + EXPECT_EQ(getBalance(provider), balanceBefore + collateral) + << "pending-commit stake must be refunded on empty reveal channel"; + EXPECT_EQ(r.state()->burnedAmount, burnedBefore) + << "nothing must be burned when the reveal channel was empty"; + EXPECT_EQ(r.state()->populations.get(stream), 0u) + << "stale-commit holder must be swap-deleted so slot is reusable"; + EXPECT_EQ(r.state()->lockedCollateralAmounts.get(index), 0u); + EXPECT_TRUE(r.state()->providers.get(index) == id::zero()) + << "provider slot must be cleared so re-registration is possible"; + EXPECT_TRUE(r.state()->commits.get(index) == id::zero()); +} + +// R1 regression test: a force-majeured provider must be able to register +// again on the next stream-tick. Without swap-delete in the empty branch +// their slot would stay bricked. +TEST(ContractRandom, ProviderCanReRegisterAfterEmptyRevealChannel) +{ + ContractTestingRandom r; + const uint8 tier = 2; + const sint64 collateral = collateralForTier(tier); + const uint32 stream = r.tick() % 3; + + id provider = getUser(1); + QPI::bit_4096 zero; zero.setAll(0); + + increaseEnergy(provider, collateral); + r.revealAndCommit(provider, zero, commitOf(makeReveal(1)), collateral); + r.endTick(); + + r.setTick(r.tick() + 3); + r.endTick(); + ASSERT_EQ(r.state()->populations.get(stream), 0u); + + // Walk forward until tick%3 == stream again so we are in this stream's + // commit window. + while ((r.tick() % 3) != stream) { r.setTick(r.tick() + 1); r.endTick(); } + + increaseEnergy(provider, collateral); + r.revealAndCommit(provider, zero, commitOf(makeReveal(2)), collateral); + + EXPECT_EQ(r.state()->populations.get(stream), 1u) + << "provider must be able to register again after a force-majeure refund"; + const uint32 index = stream * RANDOM_STREAM_CAPACITY; + EXPECT_TRUE(r.state()->providers.get(index) == provider) + << "fresh registration must populate the freed slot"; + EXPECT_EQ(r.state()->lockedCollateralAmounts.get(index), (uint64)collateral) + << "fresh stake must be locked"; +} + +TEST(ContractRandom, FirstCommitDoesNotCountAsRevealForEmptyChannel) +{ + ContractTestingRandom r; + const uint8 tier = 2; + const sint64 collateral = collateralForTier(tier); + const uint32 stream = r.tick() % 3; + + id stale = getUser(1); // committed at T, expected to reveal at T+3 + id joiner = getUser(2); // first-commits at T+3 + QPI::bit_4096 zero; zero.setAll(0); + + // T: stale commits. + increaseEnergy(stale, collateral); + r.revealAndCommit(stale, zero, commitOf(makeReveal(1)), collateral); + r.endTick(); + + const uint64 burnedBefore = r.state()->burnedAmount; + const long long staleBefore = getBalance(stale); + + // T+3: stale does nothing, joiner does a first-commit only. No actual + // reveal landed → empty-reveal-channel branch must fire. + r.setTick(r.tick() + 3); + increaseEnergy(joiner, collateral); + r.revealAndCommit(joiner, zero, commitOf(makeReveal(2)), collateral); + r.endTick(); + + EXPECT_EQ(getBalance(stale), staleBefore + collateral) + << "stale provider must be refunded - a first-commit is not a reveal"; + EXPECT_EQ(r.state()->burnedAmount, burnedBefore) + << "no burn when the reveal channel was empty"; + // Stale is swap-deleted; fresh joiner stays. After swap-delete the + // joiner may end up at any index (depends on which slot was vacated). + EXPECT_EQ(r.state()->populations.get(stream), 1u) + << "stale removed, joiner kept"; + + bool joinerFound = false; + for (uint32 i = 0; i < r.state()->populations.get(stream); i++) + { + const uint32 idx = stream * RANDOM_STREAM_CAPACITY + i; + if (r.state()->providers.get(idx) == joiner) + { + joinerFound = true; + EXPECT_EQ(r.state()->lockedCollateralAmounts.get(idx), (uint64)collateral) + << "joiner's fresh stake must remain locked for next round"; + EXPECT_FALSE(r.state()->commits.get(idx) == id::zero()) + << "joiner's fresh commit must be preserved"; + } + } + EXPECT_TRUE(joinerFound) << "joiner must remain in the pool"; +} + +TEST(ContractRandom, EmptyRevealChannelIsolatedToStream) +{ + ContractTestingRandom r; + const uint8 tier = 2; + const sint64 collateral = collateralForTier(tier); + + // Align so each stream has a committed provider going into its next round. + r.setTick(r.tick()); + const uint32 stream0 = r.tick() % 3; + const uint32 stream1 = (r.tick() + 1) % 3; + const uint32 stream2 = (r.tick() + 2) % 3; + + id p0 = getUser(10); + id p1 = getUser(11); + id p2 = getUser(12); + QPI::bit_4096 zero; zero.setAll(0); + + // Each provider commits in their own stream's tick. + increaseEnergy(p0, collateral); + r.revealAndCommit(p0, zero, commitOf(makeReveal(1)), collateral); + r.endTick(); + + r.setTick(r.tick() + 1); + increaseEnergy(p1, collateral); + r.revealAndCommit(p1, zero, commitOf(makeReveal(2)), collateral); + r.endTick(); + + r.setTick(r.tick() + 1); + increaseEnergy(p2, collateral); + r.revealAndCommit(p2, zero, commitOf(makeReveal(3)), collateral); + r.endTick(); + + // Now jump forward exactly 3 ticks (so we land on stream0's reveal round) + // and run END_TICK with no reveal activity. Only stream0 must be touched. + r.setTick(r.tick() + 1); // now == original_tick + 3, mod 3 == stream0 + const uint64 lockedS1Before = r.state()->lockedCollateralAmounts.get(stream1 * RANDOM_STREAM_CAPACITY); + const uint64 lockedS2Before = r.state()->lockedCollateralAmounts.get(stream2 * RANDOM_STREAM_CAPACITY); + r.endTick(); + + EXPECT_EQ(r.state()->lockedCollateralAmounts.get(stream0 * RANDOM_STREAM_CAPACITY), 0u) + << "stream0's locked stake must be refunded (empty reveal tick)"; + EXPECT_EQ(r.state()->lockedCollateralAmounts.get(stream1 * RANDOM_STREAM_CAPACITY), lockedS1Before) + << "stream1 must be untouched by stream0's END_TICK"; + EXPECT_EQ(r.state()->lockedCollateralAmounts.get(stream2 * RANDOM_STREAM_CAPACITY), lockedS2Before) + << "stream2 must be untouched by stream0's END_TICK"; + EXPECT_EQ(r.state()->populations.get(stream0), 0u) + << "stream0's stale-commit holder is swap-deleted"; + EXPECT_EQ(r.state()->populations.get(stream1), 1u) + << "stream1 untouched"; + EXPECT_EQ(r.state()->populations.get(stream2), 1u) + << "stream2 untouched"; +} + +TEST(ContractRandom, ConsecutiveEmptyRevealChannelsDoNotDoubleRefund) +{ + ContractTestingRandom r; + const uint8 tier = 2; + const sint64 collateral = collateralForTier(tier); + const uint32 stream = r.tick() % 3; + + id provider = getUser(1); + QPI::bit_4096 zero; zero.setAll(0); + + increaseEnergy(provider, collateral); + r.revealAndCommit(provider, zero, commitOf(makeReveal(1)), collateral); + r.endTick(); + + const long long balanceBefore = getBalance(provider); + + // First empty reveal tick: refund. + r.setTick(r.tick() + 3); + r.endTick(); + EXPECT_EQ(getBalance(provider), balanceBefore + collateral) + << "first empty reveal tick must refund once"; + + // After the first empty tick the provider was swap-deleted, so the + // second empty tick has no providers to process at all. + r.setTick(r.tick() + 3); + r.endTick(); + EXPECT_EQ(getBalance(provider), balanceBefore + collateral) + << "second empty reveal tick must NOT refund again"; EXPECT_EQ(r.state()->populations.get(stream), 0u) - << "the silent provider must be removed from the pool"; + << "stream must remain empty across consecutive empty ticks"; } // H4: when one provider in a tier is a no-show, the other providers in that From 4aaa0904e7c8e82d4633c15a7dae8f1cfb5a26df Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Sun, 31 May 2026 02:12:17 +0900 Subject: [PATCH 09/13] feat: add new variable to BuyEntropy for trust --- src/contracts/Random.h | 21 ++++- test/contract_random.cpp | 195 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 213 insertions(+), 3 deletions(-) diff --git a/src/contracts/Random.h b/src/contracts/Random.h index 976c8e8df..a7d5770be 100644 --- a/src/contracts/Random.h +++ b/src/contracts/Random.h @@ -43,6 +43,7 @@ struct RANDOM : public ContractBase { uint8 collateralTier; uint16 numberOfBits; + id trustee; }; struct BuyEntropy_output @@ -58,6 +59,8 @@ struct RANDOM : public ContractBase uint64 entropyIdx; sint64 entropyCost; uint32 stream; + uint32 index; + sint8 trusteeOk; }; struct StateData @@ -116,7 +119,23 @@ struct RANDOM : public ContractBase locals.entropyIdx = locals.stream * 10 + input.collateralTier; locals.entropy = state.get().entropy.get(locals.entropyIdx); - if (locals.entropy == locals.zeroEntropy) + locals.trusteeOk = (input.trustee == id::zero()) ? 1 : 0; + + if(!locals.trusteeOk) + { + for (locals.i = 0; locals.i < state.get().populations.get(locals.stream); locals.i++) + { + locals.index = locals.stream * RANDOM_STREAM_CAPACITY + static_cast(locals.i); + if (input.trustee == state.get().providers.get(locals.index) + && input.collateralTier == state.get().collateralTiers.get(locals.index)) + { + locals.trusteeOk = 1; + break; + } + } + } + + if (locals.entropy == locals.zeroEntropy || !locals.trusteeOk) { qpi.transfer(qpi.invocator(), qpi.invocationReward()); } diff --git a/test/contract_random.cpp b/test/contract_random.cpp index 77c2c3c90..93efbd8d6 100644 --- a/test/contract_random.cpp +++ b/test/contract_random.cpp @@ -79,11 +79,13 @@ class ContractTestingRandom : public ContractTesting } RANDOM::BuyEntropy_output buyEntropy(const id& user, uint8 collateralTier, - uint16 numberOfBits, sint64 amount) + uint16 numberOfBits, sint64 amount, + const id& trustee = id::zero()) { RANDOM::BuyEntropy_input input{}; input.collateralTier = collateralTier; input.numberOfBits = numberOfBits; + input.trustee = trustee; RANDOM::BuyEntropy_output output{}; invokeUserProcedure(RANDOM_CONTRACT_INDEX, 2, input, output, user, amount); return output; @@ -125,7 +127,7 @@ TEST(ContractRandom, BuyEntropyRefundsWhenEntropyMissing) EXPECT_EQ(r.state()->earnedAmount, 0u) << "Refund must not credit earnedAmount"; } -// Specification (per CFB): "BuyEntropy procedure is for buying entropy. The +// Specification : "BuyEntropy procedure is for buying entropy. The // fee is returned if there is no entropy, in this case output will be all // zeros." -- the user only loses funds when entropy is actually delivered. // By symmetry, when the request is rejected because of invalid inputs (bad @@ -343,6 +345,195 @@ TEST(ContractRandom, EndToEnd_LatestEntropyRetrieved_TickMod3_Is2) runFullRandomCycle(/*startTick=*/1004); // 1004 % 3 == 2 -> stream 2 } +// --------------------------------------------------------------------------- +// Trustee condition. BuyEntropy carries a single optional trustee: +// - trustee == 0 : unconditional, plain buy. +// - trustee is a provider in the +// stream/tier being bought : entropy delivered, fee charged. +// - trustee absent from that stream/tier : nothing delivered, nothing charged. +// --------------------------------------------------------------------------- + +namespace +{ + // Registers one provider on stream (startTick % 3) at the given tier and + // drives it commit -> reveal so that, at the returned buy tick, the tier's + // entropy slot holds a known non-zero value (= the revealed value). Leaves + // the contract's current tick set to the buy tick. + struct TrusteeScenario + { + uint8 tier; + uint32 stream; + id provider; + QPI::bit_4096 entropy; + }; + + TrusteeScenario buildEntropyWithProvider(ContractTestingRandom& r, uint32 startTick, + uint8 tier, uint64 providerSeed) + { + r.setTick(startTick); + const sint64 collateral = collateralForTier(tier); + const uint32 stream = startTick % 3; + + const QPI::bit_4096 reveal1 = makeReveal(providerSeed * 7654321u + 1u); + const id commit1 = commitOf(reveal1); + const id commit2 = commitOf(makeReveal(providerSeed * 7654321u + 2u)); + const id provider = getUser(providerSeed); + + QPI::bit_4096 zero; zero.setAll(0); + + // Tick T: first commit (no reveal yet). + increaseEnergy(provider, collateral); + r.revealAndCommit(provider, zero, commit1, collateral); + r.endTick(); + + // Tick T+3 (same stream): reveal reveal1, recommit. END_TICK XORs + // reveal1 into entropy[stream*10+tier]. + r.setTick(startTick + 3); + increaseEnergy(provider, collateral); + r.revealAndCommit(provider, reveal1, commit2, collateral); + r.endTick(); + + // Tick T+4: the latest finalized stream is exactly `stream`. + r.setTick(startTick + 4); + + TrusteeScenario s; + s.tier = tier; + s.stream = stream; + s.provider = provider; + s.entropy = reveal1; + return s; + } +} + +// A trustee that is a provider in the bought stream/tier: behaves like a plain +// buy (entropy delivered, fee charged). +TEST(ContractRandom, BuyEntropyWithMatchingTrusteeSucceeds) +{ + ContractTestingRandom r; + auto s = buildEntropyWithProvider(r, /*startTick=*/1002, /*tier=*/2, /*seed=*/0xA11CE); + ASSERT_TRUE(r.state()->entropy.get(s.stream * 10 + s.tier) == s.entropy); + + const uint16 numberOfBits = 4096; + const sint64 fee = (sint64)numberOfBits * 100; + id buyer = getUser(0xB0B); + increaseEnergy(buyer, fee); + const long long before = getBalance(buyer); + const uint64 earnedBefore = r.state()->earnedAmount; + + auto out = r.buyEntropy(buyer, s.tier, numberOfBits, fee, /*trustee=*/s.provider); + + EXPECT_EQ(getBalance(buyer), before - fee) + << "buy with a present trustee must charge the fee"; + EXPECT_EQ(r.state()->earnedAmount, earnedBefore + (uint64)fee); + EXPECT_TRUE(out.entropy == s.entropy) + << "buy with a present trustee must return the finalized entropy"; +} + +// A trustee absent from the stream: charge nothing, return all-zeros. +TEST(ContractRandom, BuyEntropyWithAbsentTrusteeRefundsAndReturnsZero) +{ + ContractTestingRandom r; + auto s = buildEntropyWithProvider(r, /*startTick=*/1002, /*tier=*/2, /*seed=*/0xA11CE); + + const uint16 numberOfBits = 4096; + const sint64 fee = (sint64)numberOfBits * 100; + id buyer = getUser(0xB0B); + increaseEnergy(buyer, fee); + const long long before = getBalance(buyer); + const uint64 earnedBefore = r.state()->earnedAmount; + + const id absentTrustee = getUser(0xDEAD); // never registered as a provider + auto out = r.buyEntropy(buyer, s.tier, numberOfBits, fee, /*trustee=*/absentTrustee); + + EXPECT_EQ(getBalance(buyer), before) + << "buy with an absent trustee must charge nothing (full refund)"; + EXPECT_EQ(r.state()->earnedAmount, earnedBefore) + << "buy with an absent trustee must not earn any fee"; + QPI::bit_4096 zero; zero.setAll(0); + EXPECT_TRUE(out.entropy == zero) + << "buy with an absent trustee must return all-zero entropy"; +} + +// A zero trustee imposes no condition: identical to the original BuyEntropy. +TEST(ContractRandom, BuyEntropyZeroTrusteeBehavesLikePlainBuy) +{ + ContractTestingRandom r; + auto s = buildEntropyWithProvider(r, /*startTick=*/1002, /*tier=*/2, /*seed=*/0xA11CE); + + const uint16 numberOfBits = 4096; + const sint64 fee = (sint64)numberOfBits * 100; + id buyer = getUser(0xB0B); + increaseEnergy(buyer, fee); + const long long before = getBalance(buyer); + + auto out = r.buyEntropy(buyer, s.tier, numberOfBits, fee, /*trustee=*/id::zero()); + + EXPECT_EQ(getBalance(buyer), before - fee); + EXPECT_TRUE(out.entropy == s.entropy); +} + +// A trustee that is a provider in the stream but in a DIFFERENT tier than the +// one being bought does not satisfy the condition: its reveal fed another +// tier's entropy, not the one the buyer reads. This isolates the tier match +// (the bought tier still has real, non-zero entropy of its own). +TEST(ContractRandom, BuyEntropyTrusteeInOtherTierRefunds) +{ + ContractTestingRandom r; + const uint32 startTick = 1002; + const uint32 stream = startTick % 3; + const uint8 boughtTier = 2; + const uint8 otherTier = 3; + const sint64 collateralBought = collateralForTier(boughtTier); + const sint64 collateralOther = collateralForTier(otherTier); + + const id providerBought = getUser(0xA11CE); + const id providerOther = getUser(0xB0B0); + + const QPI::bit_4096 revealBought = makeReveal(11); + const QPI::bit_4096 revealOther = makeReveal(22); + QPI::bit_4096 zero; zero.setAll(0); + + // Tick T: both providers first-commit on the same stream, different tiers. + r.setTick(startTick); + increaseEnergy(providerBought, collateralBought); + r.revealAndCommit(providerBought, zero, commitOf(revealBought), collateralBought); + increaseEnergy(providerOther, collateralOther); + r.revealAndCommit(providerOther, zero, commitOf(revealOther), collateralOther); + r.endTick(); + + // Tick T+3: both reveal, so tier 2 and tier 3 entropy both become non-zero. + r.setTick(startTick + 3); + increaseEnergy(providerBought, collateralBought); + r.revealAndCommit(providerBought, revealBought, commitOf(makeReveal(111)), collateralBought); + increaseEnergy(providerOther, collateralOther); + r.revealAndCommit(providerOther, revealOther, commitOf(makeReveal(222)), collateralOther); + r.endTick(); + + r.setTick(startTick + 4); + ASSERT_FALSE(r.state()->entropy.get(stream * 10 + boughtTier) == zero) + << "tier being bought must have its own real entropy for this test to isolate the tier check"; + + const uint16 numberOfBits = 4096; + const sint64 fee = (sint64)numberOfBits * 100; + id buyer = getUser(0xBEEF); + increaseEnergy(buyer, fee); + const long long before = getBalance(buyer); + + // Buy tier 2 but name the tier-3 provider as trustee. + auto out = r.buyEntropy(buyer, boughtTier, numberOfBits, fee, /*trustee=*/providerOther); + + EXPECT_EQ(getBalance(buyer), before) + << "a trustee present only in another tier must not satisfy the condition"; + EXPECT_TRUE(out.entropy == zero); + + // Sanity: naming the correct-tier provider succeeds against the same state. + increaseEnergy(buyer, fee); + const long long before2 = getBalance(buyer); + auto out2 = r.buyEntropy(buyer, boughtTier, numberOfBits, fee, /*trustee=*/providerBought); + EXPECT_EQ(getBalance(buyer), before2 - fee); + EXPECT_TRUE(out2.entropy == r.state()->entropy.get(stream * 10 + boughtTier)); +} + // --------------------------------------------------------------------------- // Collateral-lifecycle tests (C1 / C2 / C3 / H4). // From 0a4030705c5b72a33d6664d79aad362613c6241e Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Wed, 3 Jun 2026 00:47:12 +0900 Subject: [PATCH 10/13] fix: check trustee in buyentropy --- src/contracts/Random.h | 22 ++++++++- test/contract_random.cpp | 101 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/src/contracts/Random.h b/src/contracts/Random.h index a7d5770be..2a2edc755 100644 --- a/src/contracts/Random.h +++ b/src/contracts/Random.h @@ -88,6 +88,10 @@ struct RANDOM : public ContractBase // their reveal into entropy. Array lockedCollateralAmounts; // 3 * 1365 bit_4096 revealedThisTickFlags; // 3 * 1365 + // Set in END_TICK only when a provider's reveal is actually XOR'd into + // entropy. Persists until the stream's next END_TICK so BuyEntropy can + // verify that a named trustee genuinely contributed (not just enrolled). + bit_4096 contributedToEntropyFlags; // 3 * 1365 }; PUBLIC_FUNCTION(Fees) @@ -127,7 +131,8 @@ struct RANDOM : public ContractBase { locals.index = locals.stream * RANDOM_STREAM_CAPACITY + static_cast(locals.i); if (input.trustee == state.get().providers.get(locals.index) - && input.collateralTier == state.get().collateralTiers.get(locals.index)) + && input.collateralTier == state.get().collateralTiers.get(locals.index) + && state.get().contributedToEntropyFlags.get(locals.index)) { locals.trusteeOk = 1; break; @@ -307,6 +312,14 @@ struct RANDOM : public ContractBase state.mut().entropy.set(locals.stream * 10 + locals.i, locals.zeroReveal); } + // Clear contribution flags so only reveals in THIS tick can satisfy the + // trustee check in BuyEntropy — a fresh committer's flag stays 0. + for (locals.i = 0; locals.i < state.get().populations.get(locals.stream); locals.i++) + { + state.mut().contributedToEntropyFlags.set( + locals.stream * RANDOM_STREAM_CAPACITY + locals.i, 0); + } + for (locals.i = 0; locals.i < state.get().populations.get(locals.stream); locals.i++) { if (state.get().revealedThisTickFlags.get(locals.stream * RANDOM_STREAM_CAPACITY + locals.i)) @@ -344,6 +357,7 @@ struct RANDOM : public ContractBase state.mut().lockedCollateralAmounts.set(locals.index, state.get().lockedCollateralAmounts.get(locals.lastIndex)); state.mut().revealOrCommitFlags.set(locals.index, state.get().revealOrCommitFlags.get(locals.lastIndex)); state.mut().revealedThisTickFlags.set(locals.index, state.get().revealedThisTickFlags.get(locals.lastIndex)); + state.mut().contributedToEntropyFlags.set(locals.index, state.get().contributedToEntropyFlags.get(locals.lastIndex)); } state.mut().providers.set(locals.lastIndex, id::zero()); state.mut().collateralTiers.set(locals.lastIndex, 0); @@ -352,6 +366,7 @@ struct RANDOM : public ContractBase state.mut().lockedCollateralAmounts.set(locals.lastIndex, 0); state.mut().revealOrCommitFlags.set(locals.lastIndex, 0); state.mut().revealedThisTickFlags.set(locals.lastIndex, 0); + state.mut().contributedToEntropyFlags.set(locals.lastIndex, 0); state.mut().populations.set(locals.stream, state.get().populations.get(locals.stream) - 1); } @@ -383,6 +398,9 @@ struct RANDOM : public ContractBase locals.entropy.get(locals.j) ^ state.get().reveals.get(locals.index).get(locals.j)); } state.mut().entropy.set(locals.stream * 10 + locals.tier, locals.entropy); + // Mark that this provider's reveal was actually XOR'd in. + // BuyEntropy uses this to verify trustee guarantees. + state.mut().contributedToEntropyFlags.set(locals.index, 1); } // Always clear the reveal so the provider can reveal again // next round, even when the XOR above was skipped because @@ -418,6 +436,7 @@ struct RANDOM : public ContractBase state.mut().lockedCollateralAmounts.set(locals.index, state.get().lockedCollateralAmounts.get(locals.lastIndex)); state.mut().revealOrCommitFlags.set(locals.index, state.get().revealOrCommitFlags.get(locals.lastIndex)); state.mut().revealedThisTickFlags.set(locals.index, state.get().revealedThisTickFlags.get(locals.lastIndex)); + state.mut().contributedToEntropyFlags.set(locals.index, state.get().contributedToEntropyFlags.get(locals.lastIndex)); } state.mut().providers.set(locals.lastIndex, id::zero()); state.mut().collateralTiers.set(locals.lastIndex, 0); @@ -426,6 +445,7 @@ struct RANDOM : public ContractBase state.mut().lockedCollateralAmounts.set(locals.lastIndex, 0); state.mut().revealOrCommitFlags.set(locals.lastIndex, 0); state.mut().revealedThisTickFlags.set(locals.lastIndex, 0); + state.mut().contributedToEntropyFlags.set(locals.lastIndex, 0); state.mut().populations.set(locals.stream, state.get().populations.get(locals.stream) - 1); } diff --git a/test/contract_random.cpp b/test/contract_random.cpp index 93efbd8d6..d90d24e63 100644 --- a/test/contract_random.cpp +++ b/test/contract_random.cpp @@ -534,6 +534,107 @@ TEST(ContractRandom, BuyEntropyTrusteeInOtherTierRefunds) EXPECT_TRUE(out2.entropy == r.state()->entropy.get(stream * 10 + boughtTier)); } +// A trustee who is enrolled in the pool but whose reveal was NOT XOR'd into the +// entropy (first-commit path, no previous reveal) must NOT satisfy the trustee +// condition. BuyEntropy must refund even though the trustee is on the roster. +// +// Scenario (stream s = 1002 % 3 = 0): +// Tick 1002 : Bob first-commits. END_TICK -> empty channel, entropy = 0. +// Tick 1005 : Bob reveals (entropy becomes reveal_bob). +// Alice first-commits on the same stream/tier (no previous reveal). +// END_TICK -> Bob's flag set, Alice's flag stays 0. +// Tick 1006 : BuyEntropy with trustee=Alice -> refund (flag=0). +// BuyEntropy with trustee=Bob -> success (flag=1), as sanity check. +TEST(ContractRandom, BuyEntropyFreshTrusteeRefunds) +{ + ContractTestingRandom r; + const uint32 startTick = 1002; + const uint8 tier = 2; + const sint64 collateral = collateralForTier(tier); + const uint32 stream = startTick % 3; + + const id bob = getUser(0xB0B); + const id alice = getUser(0xA11CE); + + const QPI::bit_4096 revealBob = makeReveal(0xB0B * 7 + 1); + const id commitBob1 = commitOf(revealBob); + const id commitBob2 = commitOf(makeReveal(0xB0B * 7 + 2)); + const id commitAlice = commitOf(makeReveal(0xA11CE * 7 + 1)); + + QPI::bit_4096 zero; zero.setAll(0); + + // Tick T: Bob first-commits only. + r.setTick(startTick); + increaseEnergy(bob, collateral); + r.revealAndCommit(bob, zero, commitBob1, collateral); + r.endTick(); + // Empty reveal channel -> entropy stays zero, Bob stays enrolled. + ASSERT_TRUE(r.state()->entropy.get(stream * 10 + tier) == zero); + ASSERT_EQ(r.state()->populations.get(stream), 1u); + + // Tick T+3: Bob reveals (entropy becomes revealBob), Alice first-commits. + r.setTick(startTick + 3); + increaseEnergy(bob, collateral); + r.revealAndCommit(bob, revealBob, commitBob2, collateral); + increaseEnergy(alice, collateral); + r.revealAndCommit(alice, zero, commitAlice, collateral); + r.endTick(); + + // Sanity: entropy is now non-zero (Bob's reveal was XOR'd in). + ASSERT_FALSE(r.state()->entropy.get(stream * 10 + tier) == zero) + << "Bob's reveal must have produced non-zero entropy"; + ASSERT_EQ(r.state()->populations.get(stream), 2u) + << "Both Bob and Alice must be enrolled"; + + // Find Alice's index and verify her contributedToEntropyFlags is 0. + bool aliceFound = false; + for (uint32 i = 0; i < r.state()->populations.get(stream); i++) + { + uint32 idx = stream * RANDOM_STREAM_CAPACITY + i; + if (r.state()->providers.get(idx) == alice) + { + aliceFound = true; + EXPECT_EQ(r.state()->contributedToEntropyFlags.get(idx), 0) + << "Alice only committed this round; her contribution flag must be 0"; + break; + } + } + ASSERT_TRUE(aliceFound) << "Alice must be in the pool after first-commit"; + + // Tick T+4: BuyEntropy. + r.setTick(startTick + 4); + const uint16 numberOfBits = 4096; + const sint64 fee = (sint64)numberOfBits * 100; + + // --- Alice as trustee: must refund (she did not contribute to the entropy). + id buyer = getUser(0xBEEF); + increaseEnergy(buyer, fee); + const long long beforeAlice = getBalance(buyer); + const uint64 earnedBeforeAlice = r.state()->earnedAmount; + + auto outAlice = r.buyEntropy(buyer, tier, numberOfBits, fee, /*trustee=*/alice); + + EXPECT_EQ(getBalance(buyer), beforeAlice) + << "BuyEntropy with fresh-committer trustee must refund (trustee did not reveal)"; + EXPECT_EQ(r.state()->earnedAmount, earnedBeforeAlice) + << "earnedAmount must not increase on trustee refund"; + EXPECT_TRUE(outAlice.entropy == zero) + << "output must be all-zero when trustee check fails"; + + // --- Bob as trustee: must succeed (he did reveal and his flag is set). + increaseEnergy(buyer, fee); + const long long beforeBob = getBalance(buyer); + const uint64 earnedBeforeBob = r.state()->earnedAmount; + + auto outBob = r.buyEntropy(buyer, tier, numberOfBits, fee, /*trustee=*/bob); + + EXPECT_EQ(getBalance(buyer), beforeBob - fee) + << "BuyEntropy with an established trustee must charge the fee"; + EXPECT_EQ(r.state()->earnedAmount, earnedBeforeBob + (uint64)fee); + EXPECT_FALSE(outBob.entropy == zero) + << "BuyEntropy with an established trustee must return real entropy"; +} + // --------------------------------------------------------------------------- // Collateral-lifecycle tests (C1 / C2 / C3 / H4). // From e414bc52cace3688f0cc7f3128f9c76fb4a29f5b Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Fri, 5 Jun 2026 19:05:34 +0900 Subject: [PATCH 11/13] update comment --- src/contracts/Random.h | 108 +++++++++++++++------------------------ test/contract_random.cpp | 92 ++++++++++----------------------- 2 files changed, 68 insertions(+), 132 deletions(-) diff --git a/src/contracts/Random.h b/src/contracts/Random.h index 2a2edc755..e24788c89 100644 --- a/src/contracts/Random.h +++ b/src/contracts/Random.h @@ -23,7 +23,7 @@ struct RANDOM : public ContractBase struct RevealAndCommit_locals { - bit_4096 zeroReveal; // TODO: Use a constant from either QPI or global state + bit_4096 zeroReveal; uint32 stream; uint32 collateralTier; uint32 i; @@ -69,29 +69,22 @@ struct RANDOM : public ContractBase uint64 distributedAmount; uint64 burnedAmount; - uint32 bitFee; // Amount of qus - - Array populations; // 3 - Array providers; // 3 * 1365 - Array collateralTiers; // 3 * 1365 - Array commits; // 3 * 1365 - Array reveals; // 3 * 1365 - bit_4096 revealOrCommitFlags; // 3 * 1365 - Array entropy; // 3 * 10 - - // Collateral lifecycle (appended fields): - // - lockedCollateralAmounts[index] holds the exact stake currently - // locked for a provider. It is refunded only when the provider - // reveals, and slashed (burned) if they fail to reveal. - // - revealedThisTickFlags[index] is set when the provider revealed - // this tick (vs. only committing), so END_TICK knows whether to mix - // their reveal into entropy. - Array lockedCollateralAmounts; // 3 * 1365 - bit_4096 revealedThisTickFlags; // 3 * 1365 - // Set in END_TICK only when a provider's reveal is actually XOR'd into - // entropy. Persists until the stream's next END_TICK so BuyEntropy can - // verify that a named trustee genuinely contributed (not just enrolled). - bit_4096 contributedToEntropyFlags; // 3 * 1365 + uint32 bitFee; // in qu + + Array populations; + Array providers; + Array collateralTiers; + Array commits; + Array reveals; + bit_4096 revealOrCommitFlags; + Array entropy; + + // lockedCollateralAmounts: stake locked per-provider; refunded on reveal, burned on no-show. + // revealedThisTickFlags: set when provider revealed this tick (not just committed). + Array lockedCollateralAmounts; + bit_4096 revealedThisTickFlags; + // Cleared each END_TICK; set when reveal is XOR'd into entropy. BuyEntropy uses for trustee verification. + bit_4096 contributedToEntropyFlags; }; PUBLIC_FUNCTION(Fees) @@ -116,9 +109,7 @@ struct RANDOM : public ContractBase && input.numberOfBits >= 1 && input.numberOfBits <= 4096 && qpi.invocationReward() >= locals.entropyCost) { - // Read from the stream finalized at the previous END_TICK -- the - // current tick's entropy slot is overwritten at the start of this - // tick's END_TICK and not yet refilled. + // Offset +2: skip the current tick's slot (being overwritten) and read the last finalized stream. locals.stream = mod(qpi.tick() + 2, 3); locals.entropyIdx = locals.stream * 10 + input.collateralTier; locals.entropy = state.get().entropy.get(locals.entropyIdx); @@ -139,7 +130,7 @@ struct RANDOM : public ContractBase } } } - + if (locals.entropy == locals.zeroEntropy || !locals.trusteeOk) { qpi.transfer(qpi.invocator(), qpi.invocationReward()); @@ -149,12 +140,11 @@ struct RANDOM : public ContractBase state.mut().earnedAmount += static_cast(locals.entropyCost); if (qpi.invocationReward() > locals.entropyCost) { - // Refund any overpayment beyond the per-bit cost. + // Refund overpayment. qpi.transfer(qpi.invocator(), qpi.invocationReward() - locals.entropyCost); } - // Copy the first numberOfBits bits of entropy into the output. - // Remaining bits stay zero (output buffer is zeroed by the framework). + // Copy requested bits; remaining output bits are zero (framework-zeroed). for (locals.i = 0; locals.i < input.numberOfBits; locals.i++) { output.entropy.set(locals.i, locals.entropy.get(locals.i)); @@ -163,7 +153,7 @@ struct RANDOM : public ContractBase } else { - // Invalid input: no entropy is delivered, so refund in full. + // Invalid input — refund in full. qpi.transfer(qpi.invocator(), qpi.invocationReward()); } } @@ -171,8 +161,6 @@ struct RANDOM : public ContractBase private: PUBLIC_PROCEDURE_WITH_LOCALS(RevealAndCommit) { - // TODO: Reject transactions from smart contracts! - switch (qpi.invocationReward()) { case 1: locals.collateralTier = 0; break; @@ -198,10 +186,8 @@ struct RANDOM : public ContractBase default: qpi.transfer(qpi.invocator(), qpi.invocationReward()); return; } - // A commit for the next round is mandatory. Reject an empty commit - // BEFORE touching any state: otherwise the reveal path below would set - // the participation flag and refund here, and END_TICK would then - // refund the collateral a second time (double-pay). + // Reject empty commit before any state change — double-refund risk: reveal path sets + // the participation flag, then END_TICK would refund the collateral a second time. if (input.commit == id::zero()) { qpi.transfer(qpi.invocator(), qpi.invocationReward()); @@ -212,9 +198,7 @@ struct RANDOM : public ContractBase if (input.reveal != locals.zeroReveal) { - // Reveal path: an existing provider reveals the preimage of their - // previous commit and, in the same transaction, commits for the - // next round. + // Reveal path: verify preimage of prior commit and re-commit for next round. for (locals.i = 0; locals.i < state.get().populations.get(locals.stream); locals.i++) { if (qpi.invocator() == state.get().providers.get(locals.stream * RANDOM_STREAM_CAPACITY + locals.i) && @@ -233,8 +217,7 @@ struct RANDOM : public ContractBase locals.index = locals.stream * RANDOM_STREAM_CAPACITY + locals.i; - // Refund the collateral locked by the previous commit; the provider - // fulfilled their obligation by revealing. + // Refund prior collateral — reveal fulfills obligation. if (state.get().lockedCollateralAmounts.get(locals.index) > 0) { qpi.transfer(qpi.invocator(), state.get().lockedCollateralAmounts.get(locals.index)); @@ -259,15 +242,14 @@ struct RANDOM : public ContractBase if (qpi.invocator() == state.get().providers.get(locals.stream * RANDOM_STREAM_CAPACITY + locals.i) && locals.collateralTier == state.get().collateralTiers.get(locals.stream * RANDOM_STREAM_CAPACITY + locals.i)) { - // An existing provider cannot replace their commit without - // first revealing the previous one. + // Existing provider must reveal before re-committing. qpi.transfer(qpi.invocator(), qpi.invocationReward()); return; } } if (locals.i == RANDOM_STREAM_CAPACITY) { - // The stream is full. + // Stream is full. qpi.transfer(qpi.invocator(), qpi.invocationReward()); return; } @@ -279,8 +261,7 @@ struct RANDOM : public ContractBase state.mut().commits.set(locals.index, input.commit); state.mut().reveals.set(locals.index, locals.zeroReveal); - // Lock the collateral. It stays in the contract until the provider - // reveals (refund) or fails to reveal (slash) in a future round. + // Lock collateral until future reveal (refund) or no-show (slash). state.mut().lockedCollateralAmounts.set(locals.index, qpi.invocationReward()); state.mut().revealOrCommitFlags.set(locals.index, 1); @@ -291,7 +272,7 @@ struct RANDOM : public ContractBase struct END_TICK_locals { - bit_4096 zeroReveal; // TODO: Use a constant from either QPI or global state + bit_4096 zeroReveal; // TODO: replace with a QPI/global zero constant bit_4096 entropy; uint32 stream; uint32 i, j; @@ -312,14 +293,14 @@ struct RANDOM : public ContractBase state.mut().entropy.set(locals.stream * 10 + locals.i, locals.zeroReveal); } - // Clear contribution flags so only reveals in THIS tick can satisfy the - // trustee check in BuyEntropy — a fresh committer's flag stays 0. + // Reset contribution flags; only this tick's reveals can satisfy BuyEntropy trustee checks. for (locals.i = 0; locals.i < state.get().populations.get(locals.stream); locals.i++) { state.mut().contributedToEntropyFlags.set( locals.stream * RANDOM_STREAM_CAPACITY + locals.i, 0); } + // Check if any provider revealed this tick. for (locals.i = 0; locals.i < state.get().populations.get(locals.stream); locals.i++) { if (state.get().revealedThisTickFlags.get(locals.stream * RANDOM_STREAM_CAPACITY + locals.i)) @@ -329,6 +310,8 @@ struct RANDOM : public ContractBase } if (locals.i == state.get().populations.get(locals.stream)) { + // Empty tick: no reveals. Refund stale (unflagged) providers; keep fresh commits. + // No-shows are not slashed when the whole pool went silent. for (locals.i = state.get().populations.get(locals.stream); locals.i--;) { locals.index = locals.stream * RANDOM_STREAM_CAPACITY + locals.i; @@ -345,8 +328,7 @@ struct RANDOM : public ContractBase static_cast(locals.lockedAmount)); } - // Swap-delete: move the last active provider into this slot - // so the stream stays compact. + // Swap-delete: move the last active provider into this slot. locals.lastIndex = locals.stream * RANDOM_STREAM_CAPACITY + state.get().populations.get(locals.stream) - 1; if (locals.index != locals.lastIndex) { @@ -373,8 +355,7 @@ struct RANDOM : public ContractBase return; } - // Walk providers back-to-front so removed slots can be swap-deleted - // without disturbing slots not yet visited. + // Back-to-front walk so swap-delete doesn't disturb unvisited slots. for (locals.i = state.get().populations.get(locals.stream); locals.i--;) { locals.index = locals.stream * RANDOM_STREAM_CAPACITY + locals.i; @@ -382,13 +363,10 @@ struct RANDOM : public ContractBase if (state.get().revealOrCommitFlags.get(locals.index)) { - // Provider participated this tick (revealed and/or committed). - // Their collateral stays locked until they reveal it later. + // Participated this tick; collateral remains locked. if (state.get().revealedThisTickFlags.get(locals.index)) { - // A valid reveal contributes to this tier's entropy, unless - // the tier was already poisoned by a no-show found earlier - // in the walk. + // XOR reveal into tier entropy unless the tier is already poisoned by a no-show. if (!(locals.collateralTierFlags & (1 << locals.tier))) { locals.entropy = state.get().entropy.get(locals.stream * 10 + locals.tier); @@ -398,13 +376,10 @@ struct RANDOM : public ContractBase locals.entropy.get(locals.j) ^ state.get().reveals.get(locals.index).get(locals.j)); } state.mut().entropy.set(locals.stream * 10 + locals.tier, locals.entropy); - // Mark that this provider's reveal was actually XOR'd in. - // BuyEntropy uses this to verify trustee guarantees. + // Mark contribution for BuyEntropy trustee verification. state.mut().contributedToEntropyFlags.set(locals.index, 1); } - // Always clear the reveal so the provider can reveal again - // next round, even when the XOR above was skipped because - // the tier was poisoned. + // Clear reveal regardless — provider can reveal again next round. state.mut().reveals.set(locals.index, locals.zeroReveal); } @@ -413,8 +388,7 @@ struct RANDOM : public ContractBase } else { - // Provider did nothing this tick: slash the collateral locked - // by their last commit and remove them from the pool. + // No-show: burn collateral and evict from pool. locals.lockedAmount = state.get().lockedCollateralAmounts.get(locals.index); if (locals.lockedAmount > 0) { diff --git a/test/contract_random.cpp b/test/contract_random.cpp index d90d24e63..9afbba9af 100644 --- a/test/contract_random.cpp +++ b/test/contract_random.cpp @@ -15,8 +15,7 @@ static QPI::bit_4096 makeReveal(uint64 seed) return v; } -// Helper: compute K12(reveal) -> id, exactly the same operation the contract -// performs via qpi.K12(input.reveal) when validating reveals against commits. +// Helper: K12(reveal) -> id, matching qpi.K12() used by RevealAndCommit to validate reveals. static id commitOf(const QPI::bit_4096& reveal) { id digest; @@ -127,19 +126,10 @@ TEST(ContractRandom, BuyEntropyRefundsWhenEntropyMissing) EXPECT_EQ(r.state()->earnedAmount, 0u) << "Refund must not credit earnedAmount"; } -// Specification : "BuyEntropy procedure is for buying entropy. The -// fee is returned if there is no entropy, in this case output will be all -// zeros." -- the user only loses funds when entropy is actually delivered. -// By symmetry, when the request is rejected because of invalid inputs (bad -// tier, zero/oversize bit count, underpaid fee) no entropy is delivered, so -// the spec-correct behaviour is also a full refund. earnedAmount must NOT -// be credited and output must stay all-zero. -// -// KNOWN BUG (Random.h, BuyEntropy): the procedure has no `else` branch on -// the top-level guard, so invalid inputs silently keep the fee. These tests -// encode the spec and will fail until the contract adds an explicit refund -// for invalid inputs: -// else { qpi.transfer(qpi.invocator(), qpi.invocationReward()); } +// Spec: "BuyEntropy procedure is for buying entropy. The fee is returned if there +// is no entropy, in this case output will be all zeros." By symmetry, invalid +// inputs (bad tier, zero/oversize bits, underpaid fee) must also refund in full: +// no entropy delivered, earnedAmount unchanged. TEST(ContractRandom, BuyEntropyRefundsOnInvalidTier) { @@ -222,24 +212,16 @@ TEST(ContractRandom, BuyEntropyRefundsOnOversizeBits) } // --------------------------------------------------------------------------- -// End-to-end test: drive RevealAndCommit + END_TICK so the contract itself -// produces real entropy, then verify BuyEntropy hands back the *latest* -// finalized entropy slot (not the bug's hardcoded stream-0 slot). +// End-to-end: drive RevealAndCommit + END_TICK to produce real entropy, then +// verify BuyEntropy reads the correct finalized stream. // -// Lifecycle of one provider on stream s (= tick % 3): -// tick T : RevealAndCommit(reveal=0, commit=K12(reveal1)) -> provider -// registered, commit stored. Reveals stay zero, so END_TICK -// finalizes entropy[s*10+tier] = 0 (XOR with zero). -// tick T+3 : RevealAndCommit(reveal=reveal1, commit=K12(reveal2)) -> reveal -// reveal1 is accepted because K12(reveal1) matches the prior -// commit. END_TICK XORs reveal1 into entropy[s*10+tier], so -// entropy = reveal1. -// tick T+4 : BuyEntropy(tier). The contract reads -// latestStream = (tick + 2) % 3 = (T+4+2) % 3 = T % 3 = s, -// so it must return entropy[s*10+tier] = reveal1. +// Lifecycle on stream s = startTick % 3: +// Tick T : first-commit only. END_TICK leaves entropy[s*10+tier] = 0. +// Tick T+3 : reveal=reveal1, re-commit. END_TICK XORs reveal1 in -> entropy = reveal1. +// Tick T+4 : BuyEntropy reads stream (T+4+2)%3 = T%3 = s -> must return reveal1. // -// Done for every residue of T mod 3, so the test is sensitive to the original -// bug where BuyEntropy always read stream-0 instead of the latest stream. +// Repeated for all three residues of T mod 3 to catch the original bug where +// BuyEntropy always read stream 0 instead of the latest finalized stream. // --------------------------------------------------------------------------- namespace @@ -272,8 +254,7 @@ namespace EXPECT_EQ(r.state()->populations.get(stream), 1u); r.endTick(); - // After END_TICK on stream s, collateral was refunded, flag cleared, - // entropy slot stays zero (reveal was zero). + // After END_TICK on stream s: flag cleared, collateral stays locked, entropy stays zero. EXPECT_TRUE(r.state()->entropy.get(stream * 10 + tier) == zero); EXPECT_EQ(r.state()->populations.get(stream), 1u); @@ -355,10 +336,8 @@ TEST(ContractRandom, EndToEnd_LatestEntropyRetrieved_TickMod3_Is2) namespace { - // Registers one provider on stream (startTick % 3) at the given tier and - // drives it commit -> reveal so that, at the returned buy tick, the tier's - // entropy slot holds a known non-zero value (= the revealed value). Leaves - // the contract's current tick set to the buy tick. + // Drive one provider through commit -> reveal so entropy[stream*10+tier] = reveal1 + // at tick T+4. Leaves the contract at tick T+4. struct TrusteeScenario { uint8 tier; @@ -472,10 +451,8 @@ TEST(ContractRandom, BuyEntropyZeroTrusteeBehavesLikePlainBuy) EXPECT_TRUE(out.entropy == s.entropy); } -// A trustee that is a provider in the stream but in a DIFFERENT tier than the -// one being bought does not satisfy the condition: its reveal fed another -// tier's entropy, not the one the buyer reads. This isolates the tier match -// (the bought tier still has real, non-zero entropy of its own). +// A trustee present only in a different tier does not satisfy the condition +// (tier must match). The bought tier has its own real entropy. TEST(ContractRandom, BuyEntropyTrusteeInOtherTierRefunds) { ContractTestingRandom r; @@ -534,17 +511,11 @@ TEST(ContractRandom, BuyEntropyTrusteeInOtherTierRefunds) EXPECT_TRUE(out2.entropy == r.state()->entropy.get(stream * 10 + boughtTier)); } -// A trustee who is enrolled in the pool but whose reveal was NOT XOR'd into the -// entropy (first-commit path, no previous reveal) must NOT satisfy the trustee -// condition. BuyEntropy must refund even though the trustee is on the roster. +// Enrollment alone doesn't satisfy the trustee condition — the provider must have +// actually XOR'd a reveal into entropy this round (contributedToEntropyFlags=1). // -// Scenario (stream s = 1002 % 3 = 0): -// Tick 1002 : Bob first-commits. END_TICK -> empty channel, entropy = 0. -// Tick 1005 : Bob reveals (entropy becomes reveal_bob). -// Alice first-commits on the same stream/tier (no previous reveal). -// END_TICK -> Bob's flag set, Alice's flag stays 0. -// Tick 1006 : BuyEntropy with trustee=Alice -> refund (flag=0). -// BuyEntropy with trustee=Bob -> success (flag=1), as sanity check. +// Scenario (stream 0, T=1002): Bob commits; T+3 Bob reveals + Alice first-commits; +// T+4 trustee=Alice refunds (flag=0), trustee=Bob succeeds (flag=1). TEST(ContractRandom, BuyEntropyFreshTrusteeRefunds) { ContractTestingRandom r; @@ -745,15 +716,9 @@ TEST(ContractRandom, RevealWithZeroCommitIsRejectedNoDoublePay) << "a rejected commit==0 call must not set the participation flag"; } -// C1: a provider who does nothing in a round receives NOTHING - PROVIDED at -// least one reveal landed in the stream this tick (the reveal channel was -// usable). Their locked stake is burned (slashed) and they are removed from -// the pool. The old bug paid silent providers from the treasury. -// -// Note: a second "witness" provider reveals in T+3 so revealedThisTickFlags -// is set somewhere in the stream. Without a witness, END_TICK would take the -// empty-reveal-channel branch and refund instead of slash. See the dedicated -// EmptyRevealChannel tests for that semantics. +// C1: when at least one reveal lands in a round, a no-show provider's stake is +// burned and they are evicted. A witness provider proves the reveal channel was +// live — without one END_TICK takes the empty-tick path and refunds instead of slashing. TEST(ContractRandom, SilentProviderIsSlashedNotPaid) { ContractTestingRandom r; @@ -812,11 +777,8 @@ TEST(ContractRandom, EmptyRevealChannelRefundsAndRemovesPendingCommit) const uint64 burnedBefore = r.state()->burnedAmount; const long long balanceBefore = getBalance(provider); - // T+3: nobody reveals (network glitch). END_TICK refunds the stake - // AND removes the provider so the slot is reusable. Leaving them - // registered with commits == 0 would brick the slot (the existing- - // provider check in the first-commit path and the K12 check in the - // reveal path both reject any return). + // T+3: nobody reveals. END_TICK refunds the stake and evicts the provider + // so the slot is reusable — a stale commit would brick re-registration. r.setTick(r.tick() + 3); r.endTick(); From 2bfc0d0aafd3461ce5120f4c71740118bfd68442 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Fri, 5 Jun 2026 20:16:40 +0900 Subject: [PATCH 12/13] fix: reject transaction from SC --- src/contracts/Random.h | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/contracts/Random.h b/src/contracts/Random.h index e24788c89..26787f365 100644 --- a/src/contracts/Random.h +++ b/src/contracts/Random.h @@ -69,8 +69,8 @@ struct RANDOM : public ContractBase uint64 distributedAmount; uint64 burnedAmount; - uint32 bitFee; // in qu - + uint32 bitFee; + Array populations; Array providers; Array collateralTiers; @@ -161,6 +161,13 @@ struct RANDOM : public ContractBase private: PUBLIC_PROCEDURE_WITH_LOCALS(RevealAndCommit) { + // Entropy providers must be user accounts, not smart contracts. + if (qpi.isContractId(qpi.invocator())) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + return; + } + switch (qpi.invocationReward()) { case 1: locals.collateralTier = 0; break; From 20b5b8900d787ae8880b7a93aa6f055b9905ef37 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Mon, 8 Jun 2026 23:57:23 +0900 Subject: [PATCH 13/13] fix: commit and reveal in same tick --- src/contracts/Random.h | 3 +- test/contract_random.cpp | 63 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/contracts/Random.h b/src/contracts/Random.h index 26787f365..6f9b9e2cd 100644 --- a/src/contracts/Random.h +++ b/src/contracts/Random.h @@ -216,7 +216,8 @@ struct RANDOM : public ContractBase } if (locals.i == state.get().populations.get(locals.stream) || state.get().reveals.get(locals.stream * RANDOM_STREAM_CAPACITY + locals.i) != locals.zeroReveal || - qpi.K12(input.reveal) != state.get().commits.get(locals.stream * RANDOM_STREAM_CAPACITY + locals.i)) + qpi.K12(input.reveal) != state.get().commits.get(locals.stream * RANDOM_STREAM_CAPACITY + locals.i) || + state.get().revealOrCommitFlags.get(locals.stream * RANDOM_STREAM_CAPACITY + locals.i)) // same-tick commit+reveal is forbidden { qpi.transfer(qpi.invocator(), qpi.invocationReward()); return; diff --git a/test/contract_random.cpp b/test/contract_random.cpp index 9afbba9af..4b8e1f4f6 100644 --- a/test/contract_random.cpp +++ b/test/contract_random.cpp @@ -716,6 +716,69 @@ TEST(ContractRandom, RevealWithZeroCommitIsRejectedNoDoublePay) << "a rejected commit==0 call must not set the participation flag"; } +// S1: commit and reveal in the same tick must be rejected. The whole point of the +// commit-reveal scheme is that the preimage is locked before the reveal window opens. +// If both are allowed in one tick, a provider can observe other reveals, pick an +// optimal preimage, and submit commit+reveal together — breaking entropy security. +// +// The guard reuses revealOrCommitFlags: the first-commit path sets the flag; END_TICK +// clears it. So a reveal attempted while the flag is already set means the provider +// committed this tick and must be rejected. +TEST(ContractRandom, SameTickCommitAndRevealIsRejected) +{ + ContractTestingRandom r; + const uint8 tier = 2; + const sint64 collateral = collateralForTier(tier); + const uint32 stream = r.tick() % 3; + const uint32 index = stream * RANDOM_STREAM_CAPACITY + 0; + + const QPI::bit_4096 reveal1 = makeReveal(0xDEAD); + const id commit1 = commitOf(reveal1); + const id commit2 = commitOf(makeReveal(0xBEEF)); + id provider = getUser(1); + QPI::bit_4096 zero; zero.setAll(0); + + // Tx 1: first-commit. Registers the provider and sets revealOrCommitFlags=1. + increaseEnergy(provider, collateral); + r.revealAndCommit(provider, zero, commit1, collateral); + ASSERT_EQ(r.state()->populations.get(stream), 1u); + ASSERT_TRUE(r.state()->commits.get(index) == commit1); + + // Tx 2 (same tick): attempt to reveal the preimage of commit1 immediately. + // Must be rejected — provider must not be able to pick the preimage after + // observing other reveals in the same tick. + increaseEnergy(provider, collateral); + const long long before = getBalance(provider); + + r.revealAndCommit(provider, reveal1, commit2, collateral); + + EXPECT_EQ(getBalance(provider), before) + << "same-tick reveal must be refunded in full"; + EXPECT_TRUE(r.state()->reveals.get(index) == zero) + << "same-tick reveal must not store the reveal value"; + EXPECT_EQ(r.state()->revealedThisTickFlags.get(index), 0) + << "same-tick reveal must not set the revealed flag"; + EXPECT_TRUE(r.state()->commits.get(index) == commit1) + << "same-tick reveal must not overwrite the existing commit"; + + // After END_TICK the provider is still enrolled (they committed legitimately). + r.endTick(); + EXPECT_EQ(r.state()->populations.get(stream), 1u) + << "provider must remain enrolled after END_TICK"; + + // Tick T+3: the normal reveal must now succeed. + r.setTick(r.tick() + 3); + increaseEnergy(provider, collateral); + const long long before2 = getBalance(provider); + + r.revealAndCommit(provider, reveal1, commit2, collateral); + + EXPECT_EQ(getBalance(provider), before2) + << "legitimate reveal at T+3 must refund prior collateral (net zero)"; + EXPECT_EQ(r.state()->revealedThisTickFlags.get(index), 1) + << "legitimate reveal at T+3 must set the revealed flag"; +} + // C1: when at least one reveal lands in a round, a no-show provider's stake is // burned and they are evicted. A witness provider proves the reveal channel was // live — without one END_TICK takes the empty-tick path and refunds instead of slashing.