From 90a9ef0eec31adb89f89c4301c2d9cdc726119f3 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Thu, 6 Aug 2026 04:44:43 +0900 Subject: [PATCH 1/8] feat: initial commit for qpayhub --- src/contract_core/contract_def.h | 14 + src/contracts/QPayhub.h | 821 +++++++++++++++++++++++++++++++ 2 files changed, 835 insertions(+) create mode 100644 src/contracts/QPayhub.h diff --git a/src/contract_core/contract_def.h b/src/contract_core/contract_def.h index 2aa6e074..0406709a 100644 --- a/src/contract_core/contract_def.h +++ b/src/contract_core/contract_def.h @@ -301,6 +301,18 @@ #define CONTRACT_STATE2_TYPE WOLFPACK2 #include "contracts/GGWP.h" +#undef CONTRACT_INDEX +#undef CONTRACT_STATE_TYPE +#undef CONTRACT_STATE2_TYPE + +#define QPAYHUB_CONTRACT_INDEX 29 +#define CONTRACT_INDEX QPAYHUB_CONTRACT_INDEX +#define CONTRACT_STATE_TYPE QPAYHUB +#define CONTRACT_STATE2_TYPE QPAYHUB2 +#include "contracts/QPayhub.h" + + + // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES @@ -416,6 +428,7 @@ constexpr struct ContractDescription {"QUSINO", 208, 10000, sizeof(QUSINO::StateData)}, // proposal in epoch 206, IPO in 207, construction and first use in 208 {"ESCROW", 210, 10000, sizeof(ESCROW::StateData)}, // proposal in epoch 208, IPO in 209, construction and first use in 210 {"GGWP", 218, 10000, sizeof(WOLFPACK::StateData)}, // proposal in epoch 216, IPO in 217, construction and first use in 218 + {"QPAYHUB", 226, 10000, sizeof(IPO)}, // proposal in epoch 224, IPO in 225, construction and first use in 226 // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES {"TESTEXA", 138, 10000, sizeof(TESTEXA::StateData)}, @@ -548,6 +561,7 @@ static void initializeContracts() REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(QUSINO); REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(ESCROW); REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(WOLFPACK); + REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(QPAYHUB); // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(TESTEXA); diff --git a/src/contracts/QPayhub.h b/src/contracts/QPayhub.h new file mode 100644 index 00000000..b4fb2228 --- /dev/null +++ b/src/contracts/QPayhub.h @@ -0,0 +1,821 @@ +using namespace QPI; + +// ============================================================================ +// QPAY — x402 settlement layer for Qubic: on-chain receipts for +// pay-per-request commerce, plus a live QUBIC/USDT price feed subscribed +// from Qubic own Oracle Machines via QPI. +// +// FLOW +// A buyer invokes Pay with the purchase descriptor (seller, resourceId, +// nonce) and the price attached as the invocation reward. The contract +// retains a protocol fee, pushes the net amount to the seller in the same +// transaction (non-custodial), and records a receipt keyed by +// K12(payer, seller, resourceId, nonce). An off-chain facilitator reads +// the receipt via GetReceipt to confirm payment before serving the +// resource; the seller may additionally call Consume to mark a receipt +// used on-chain, making single-use enforcement global rather than +// per-facilitator. +// +// WHAT THIS CONTRACT DOES NOT DO +// No pricing for Pay itself (the 402 challenge is off-chain; the +// facilitator compares amountPaid against the advertised price), no +// delivery escrow, no seller registry — any identity can receive +// payments with zero registration. +// +// FEES +// Protocol fee is QPAY_FEE_PERMILLE of each payment (1%) or +// QPAY_FEE_FLOOR_QU (100 QU), whichever is greater - modeled like a card +// processor's percentage-plus-minimum, so a sale small enough that 1% +// would round to near-nothing still pays a floor rather than the +// protocol effectively processing it for free. The floor can never push +// fee above the paid amount itself (see Pay_locals.fee clamp) - the +// worst case is fee == amount, net == 0, at a payment right at the +// QPAY_MIN_PAYMENT boundary, never a negative transfer to the seller. +// Accrued to feePool and distributed each epoch above an execution-fee +// reserve: 10% to QPAY shareholders, 90% pro-rata to QPAY token holders. +// Receipts expire after QPAY_RECEIPT_RETENTION_EPOCHS epochs. +// +// REFUNDS: the fee is NOT returned on a refund. No path pays out of +// feePool, and invoice.js validates refunds against the GROSS amount, +// so a full refund costs the merchant the fee - as card processors do. +// +// Pricing near or below the floor nets the seller close to nothing, +// same as pricing near a card processor's minimum fee - not a bug, but +// worth flagging: this project's OTHER default, QUBIC_PRICE_PER_CALL in +// the off-chain facilitator's .env, defaults to exactly 1000 QU, which +// nets a seller 900 QU under this fee once routed through QPAY (1% of +// 1000 is 10, so the 100 QU floor applies). Anyone pricing per-call +// sales through QPAY should be aware the floor, not the percentage, +// dominates below 10,000 QU. +// +// TEST STATUS (append-only, keep accurate) +// Pay/Consume/GetReceipt/ComputeReceiptKey/GetInfo/END_EPOCH/ +// POST_INCOMING_TRANSFER: 11/11 GoogleTest unit + 19/19 live-chain +// integration checks passed on a real aio-qubic-dev-kit testnet - see +// contract/TEST-RESULTS.md. Nothing in this section of the contract +// changed as part of folding in the oracle-price feed below; the merge +// was verified as a strict, isolated addition (diffed line-for-line +// against the last-tested version before merging). +// GetQuUsdPrice/SubscribeToPriceFeed/NotifyQuUsdPriceReply (the oracle- +// price feed, search ORACLE PRICE FEED ADDITIONS below): syntax-checked +// only so far (0 errors, 0 prohibited tokens - contract/check.cpp). Not +// yet run through GoogleTest or a live chain - that is the next gate, +// same two-step bar every other piece of this contract already cleared. +// Pay's fee-floor clamp (QPAY_FEE_FLOOR_QU, search FEE FLOOR ADDITION +// below): syntax-checked against real qubic/core headers cross-compiled +// to x86_64 from this arm64 machine (0 errors - contract/check.cpp; the +// full GoogleTest harness itself still needs the real x86 toolchain per +// the note above, same constraint that blocked the oracle additions' +// test file from even a syntax check). New GoogleTest cases were added +// to contract/test/contract_qpay.cpp for floor-dominates, percent- +// dominates, and the exact-tie amount, modeled directly on the existing +// PayHappyPathFeeSplitAcrossAmounts test - reviewed by hand, not run. +// +// WHY THE ORACLE ADDITIONS EXIST +// src/priceOracle.js (the off-chain USD->QU pricing used by the POS/ +// checkout invoices) currently sources its rate from CoinGecko, because +// an off-chain Node process cannot reach Qubic Oracle Machines +// directly - interaction with them is managed via QPI, a smart-contract- +// only interface (see the README POS section for the full reasoning on why +// an undocumented raw-protocol integration was not attempted). But QPAY +// already IS a deployed contract sitting exactly at that boundary. If +// QPAY itself subscribes to the oracle and republishes the price through +// a normal read function, the facilitator can read it the exact same way +// it already reads GetReceipt - via querySmartContract, no new off-chain +// networking risk at all. The oracle interaction happens where it is +// actually supported: inside the contract, via QPI. +// +// DESIGN CHOICE WORTH FLAGGING: staying admin-free. +// QPAY whole identity is no admin, no seizure surface (see the +// the original file). Subscribing to an oracle could easily have +// been bolted on as an admin-only procedure, but that would reintroduce +// exactly the privileged-actor surface QPAY was built to avoid. Instead, +// SubscribeToPriceFeed takes NO caller-supplied query parameters at all - +// the oracle source and currency pair are fixed constants inside the +// procedure body, so ANYONE can permissionlessly call it (e.g. to renew +// an expired subscription) without being able to redirect the feed to a +// bogus source. Permissionless renewal, not permissioned control. +// +// VERIFIED BEFORE WRITING THIS: +// - The oracle interaction pattern (QUERY_ORACLE/SUBSCRIBE_ORACLE macros, +// the OracleNotificationInput callback shape, fee functions) is copied +// from src/contracts/QUtil.h and src/contracts/TestExampleC.h in the +// qubic/core checkout on this machine - both are real, already- +// compiling contracts using this exact interface, not a guess. +// - OI::Price (src/oracle_interfaces/Price.h) is a real, live oracle +// interface: OracleQuery{oracle, timestamp, currency1, currency2} -> +// OracleReply{numerator, denominator}, where +// currency1 = currency2 * numerator / denominator. +// - OI:: is auto-available to every contract via +// contract_core/contract_def.h including oracle_core/oracle_interfaces_def.h +// before any contract file - no include directive needed here, +// consistent with the no-include rule. +// - The id(...) five-letter constructor (src/platform/m256.h) takes five +// REQUIRED single-letter params - id(Q,U,B,I,C) is exactly QUBIC, no +// padding needed; id(U,S,D,T) for USDT leaves the unused 5th param at +// its default of 0. +// +// NOT YET VERIFIED (this file has only had the standalone syntax check +// run against it, same as Qpay.h originally did - GoogleTest coverage and +// the exact subscription renewal/lifetime semantics need the real test +// harness on x86, see oracle_testing.h in the core checkout): +// - Whether a subscription persists indefinitely or needs periodic +// re-subscription - designed conservatively so calling +// SubscribeToPriceFeed again when priceOracleSubscriptionId is already +// valid can serve as a manual renewal if needed. +// - The real numeric magnitude/precision of a live QUBIC/USDT reply +// (this was written for the numerator/denominator SHAPE Price.h +// documents, not against an observed live value for this specific +// pair - QUBIC actual listing symbol on Binance/MEXC may need +// confirming, e.g. whether it is paired directly against USDT or only +// via an intermediate). +// ============================================================================ + +constexpr uint64 QPAYHUB_RECEIPT_CAPACITY = 262144; // 2^18 (was 65536) -- ~9.4k/day mainnet headroom +constexpr sint64 QPAYHUB_MIN_PAYMENT = 100; // dust floor, QU - rejects the payment outright +constexpr uint64 QPAYHUB_FEE_PERMILLE = 10; // 10 per 1000 = 1 percent +// Floor on the fee itself, not a rejection threshold like MIN_PAYMENT. +constexpr sint64 QPAYHUB_FEE_FLOOR_QU = 100; +constexpr uint32 QPAYHUB_RECEIPT_RETENTION_EPOCHS = 2; +constexpr sint64 QPAYHUB_EXEC_RESERVE = 1000000; // never distributed, QU + +// Epoch fee pool splits: shares get this, QPAY-token holders the rest. +constexpr uint64 QPAYHUB_DIVIDEND_SHAREHOLDER_PERMILLE = 100; // 100 per 1000 = 10% +constexpr uint64 QPAYHUB_TOKEN_ASSETNAME = 1497452625ULL; // "QPAY" + +// ---- ORACLE PRICE FEED ADDITIONS: constants ---- +// A 16-minute renewal period sits in the efficient tier of the fee table +// (1,784 QU) - frequent enough for an invoice payment window, rare +// enough not to be worth querying per-invoice instead. notifyPreviousValue +// is on, so a fresh subscriber sees the last known reply immediately +// rather than waiting a full period for the first notification. +constexpr uint32 QPAYHUB_PRICE_SUBSCRIBE_PERIOD_MS = 16u * 60u * 1000u; +// bit has no constexpr constructor (see money-safety notes below), so this +// is passed as a literal 1 at the SUBSCRIBE_ORACLE call site instead of a +// named constant. +// A price older than this many ticks is reported stale rather than trusted +// silently - callers (GetQuUsdPrice) decide what to do with that, the +// contract just never hides it. +constexpr uint32 QPAYHUB_PRICE_STALE_TICKS = 4000; // roughly 20-25 min at current tick rates + +static_assert((QPAYHUB_RECEIPT_CAPACITY & (QPAYHUB_RECEIPT_CAPACITY - 1)) == 0); +static_assert(QPAYHUB_FEE_PERMILLE < 1000); +static_assert(QPAYHUB_DIVIDEND_SHAREHOLDER_PERMILLE <= 1000); + +// Return codes — append-only, never renumber. +constexpr uint64 QPAYHUB_OK = 0; +constexpr uint64 QPAYHUB_ERR_INVALID_SELLER = 1; +constexpr uint64 QPAYHUB_ERR_AMOUNT_TOO_LOW = 2; +constexpr uint64 QPAYHUB_ERR_DUPLICATE = 3; +constexpr uint64 QPAYHUB_ERR_CAPACITY = 4; +constexpr uint64 QPAYHUB_ERR_NOT_FOUND = 5; +constexpr uint64 QPAYHUB_ERR_ACCESS_DENIED = 6; +constexpr uint64 QPAYHUB_ERR_ALREADY_CONSUMED = 7; +constexpr uint64 QPAYHUB_ERR_ALREADY_SUBSCRIBED = 8; +constexpr uint64 QPAYHUB_ERR_SUBSCRIBE_FAILED = 9; + +struct QPAYHUB2 +{ +}; + +struct QPAYHUB : public ContractBase +{ + struct Receipt + { + id payer; + id seller; + id resourceId; + uint64 nonce; + sint64 amountPaid; + sint64 fee; + uint32 epochPaid; + uint32 tickPaid; + bit consumed; + }; + + // Hashed with K12 to derive the receipt key; deterministic, so buyers + // and facilitators can compute the key off-chain without a query. + struct ReceiptKeyMaterial + { + id payer; + id seller; + id resourceId; + uint64 nonce; + }; + + struct StateData + { + HashMap receipts; + sint64 feePool; + uint64 totalPayments; + uint64 totalVolume; + uint64 totalFeesCollected; + uint64 totalFeesDistributed; + uint64 totalConsumed; + uint64 totalPurged; + + // ---- ORACLE PRICE FEED ADDITIONS: state ---- + // 1 QUBIC = quUsdNumerator / quUsdDenominator USDT, as of + // quUsdUpdatedTick. denominator == 0 means no reply has ever + // landed yet (never trust a fresh contract price before this). + sint64 quUsdNumerator; + sint64 quUsdDenominator; + uint32 quUsdUpdatedTick; + sint32 priceOracleSubscriptionId; // -1 = not currently subscribed + + // Fixed in INITIALIZE; NULL_ID issuer leaves the 90% in feePool. + Asset dividendToken; + uint64 totalShareholderDividends; + uint64 totalTokenholderDividends; + }; + + // ------------------------------------------------------------------ + // Input, output and locals structs + // ------------------------------------------------------------------ + + struct Pay_input + { + id seller; + id resourceId; + uint64 nonce; + }; + struct Pay_output + { + uint64 returnCode; + id receiptKey; + sint64 net; + sint64 fee; + }; + struct Pay_locals + { + ReceiptKeyMaterial km; + Receipt r; + id key; + sint64 amount; + sint64 fee; + sint64 net; + }; + + struct Consume_input + { + id receiptKey; + }; + struct Consume_output + { + uint64 returnCode; + }; + struct Consume_locals + { + Receipt r; + }; + + struct GetReceipt_input + { + id receiptKey; + }; + struct GetReceipt_output + { + uint64 returnCode; + id payer; + id seller; + id resourceId; + uint64 nonce; + sint64 amountPaid; + sint64 fee; + uint32 epochPaid; + uint32 tickPaid; + bit consumed; + }; + struct GetReceipt_locals + { + Receipt r; + }; + + struct ComputeReceiptKey_input + { + id payer; + id seller; + id resourceId; + uint64 nonce; + }; + struct ComputeReceiptKey_output + { + id receiptKey; + }; + struct ComputeReceiptKey_locals + { + ReceiptKeyMaterial km; + }; + + struct GetInfo_input + { + }; + struct GetInfo_output + { + uint64 feePermille; + sint64 minPayment; + uint32 retentionEpochs; + uint32 padding0; + uint64 receiptCount; + sint64 feePool; + uint64 totalPayments; + uint64 totalVolume; + uint64 totalFeesCollected; + uint64 totalFeesDistributed; + uint64 totalConsumed; + uint64 totalPurged; + // ---- FEE FLOOR ADDITION ---- appended at the end rather than + // inserted by field order, so every existing offset in this struct + // (see qubicStructs.js's dumped-layout comment) stays unchanged. + sint64 feeFloorQu; + // Likewise appended to keep existing offsets stable. + uint64 shareholderPermille; + uint64 totalShareholderDividends; + uint64 totalTokenholderDividends; + }; + + // ---- ORACLE PRICE FEED ADDITIONS: I/O structs ---- + + struct GetQuUsdPrice_input + { + }; + struct GetQuUsdPrice_output + { + sint64 numerator; + sint64 denominator; // 0 = no price known yet + uint32 updatedTick; + bit stale; + }; + struct GetQuUsdPrice_locals + { + uint32 age; + }; + + struct SubscribeToPriceFeed_input + { + }; + struct SubscribeToPriceFeed_output + { + uint64 returnCode; + sint32 subscriptionId; + }; + struct SubscribeToPriceFeed_locals + { + OI::Price::OracleQuery query; + sint64 fee; + }; + + // The reference contracts this pattern is copied from use an older + // C-style alias keyword here; the modern alias-declaration syntax below + // has identical effect and is used instead, per the pre-submission + // checklist in the contract-builder guide. + using NotifyQuUsdPriceReply_input = OracleNotificationInput; + using NotifyQuUsdPriceReply_output = NoData; + struct NotifyQuUsdPriceReply_locals + { + OI::Price::OracleReply reply; + }; + + // ------------------------------------------------------------------ + // Functions (read-only) + // ------------------------------------------------------------------ + + PUBLIC_FUNCTION_WITH_LOCALS(GetReceipt) + { + if (!state.get().receipts.get(input.receiptKey, locals.r)) + { + output.returnCode = QPAYHUB_ERR_NOT_FOUND; + return; + } + output.returnCode = QPAYHUB_OK; + output.payer = locals.r.payer; + output.seller = locals.r.seller; + output.resourceId = locals.r.resourceId; + output.nonce = locals.r.nonce; + output.amountPaid = locals.r.amountPaid; + output.fee = locals.r.fee; + output.epochPaid = locals.r.epochPaid; + output.tickPaid = locals.r.tickPaid; + output.consumed = locals.r.consumed; + } + + PUBLIC_FUNCTION_WITH_LOCALS(ComputeReceiptKey) + { + locals.km.payer = input.payer; + locals.km.seller = input.seller; + locals.km.resourceId = input.resourceId; + locals.km.nonce = input.nonce; + output.receiptKey = qpi.K12(locals.km); + } + + PUBLIC_FUNCTION(GetInfo) + { + output.feePermille = QPAYHUB_FEE_PERMILLE; + output.minPayment = QPAYHUB_MIN_PAYMENT; + output.retentionEpochs = QPAYHUB_RECEIPT_RETENTION_EPOCHS; + output.padding0 = 0; + output.receiptCount = state.get().receipts.population(); + output.feePool = state.get().feePool; + output.totalPayments = state.get().totalPayments; + output.totalVolume = state.get().totalVolume; + output.totalFeesCollected = state.get().totalFeesCollected; + output.totalFeesDistributed = state.get().totalFeesDistributed; + output.totalConsumed = state.get().totalConsumed; + output.totalPurged = state.get().totalPurged; + output.feeFloorQu = QPAYHUB_FEE_FLOOR_QU; + output.shareholderPermille = QPAYHUB_DIVIDEND_SHAREHOLDER_PERMILLE; + output.totalShareholderDividends = state.get().totalShareholderDividends; + output.totalTokenholderDividends = state.get().totalTokenholderDividends; + } + + // ---- ORACLE PRICE FEED ADDITIONS: read function ---- + // A plain instant read - the oracle interaction already happened in + // the background via the subscription; this never itself talks to an + // oracle, so it costs nothing beyond a normal query. + PUBLIC_FUNCTION_WITH_LOCALS(GetQuUsdPrice) + { + output.numerator = state.get().quUsdNumerator; + output.denominator = state.get().quUsdDenominator; + output.updatedTick = state.get().quUsdUpdatedTick; + if (output.denominator == 0) + { + output.stale = 1; // never received a reply - trivially stale + return; + } + locals.age = (uint32)qpi.tick() - output.updatedTick; + output.stale = (locals.age > QPAYHUB_PRICE_STALE_TICKS) ? 1 : 0; + } + + // ------------------------------------------------------------------ + // Procedures + // ------------------------------------------------------------------ + + PUBLIC_PROCEDURE_WITH_LOCALS(Pay) + { + locals.amount = qpi.invocationReward(); + if (input.seller == NULL_ID || input.seller == SELF) + { + if (locals.amount > 0) + { + qpi.transfer(qpi.invocator(), locals.amount); + } + output.returnCode = QPAYHUB_ERR_INVALID_SELLER; + return; + } + if (locals.amount < QPAYHUB_MIN_PAYMENT) + { + if (locals.amount > 0) + { + qpi.transfer(qpi.invocator(), locals.amount); + } + output.returnCode = QPAYHUB_ERR_AMOUNT_TOO_LOW; + return; + } + + locals.km.payer = qpi.invocator(); + locals.km.seller = input.seller; + locals.km.resourceId = input.resourceId; + locals.km.nonce = input.nonce; + locals.key = qpi.K12(locals.km); + + // A duplicate key is a replayed payment attempt, not a new purchase. + if (state.get().receipts.contains(locals.key)) + { + qpi.transfer(qpi.invocator(), locals.amount); + output.returnCode = QPAYHUB_ERR_DUPLICATE; + return; + } + // Capacity is checked before any money moves so the receipt insert + // below can never fail after the seller has been paid. + if (state.get().receipts.population() >= QPAYHUB_RECEIPT_CAPACITY) + { + qpi.transfer(qpi.invocator(), locals.amount); + output.returnCode = QPAYHUB_ERR_CAPACITY; + return; + } + + // 1% or QPAYHUB_FEE_FLOOR_QU, whichever is greater. Floor and minimum + // are both 100, so a payment at the minimum nets the seller zero. + locals.fee = (sint64)div((uint64)locals.amount * QPAYHUB_FEE_PERMILLE, (uint64)1000); + if (locals.fee < QPAYHUB_FEE_FLOOR_QU) + { + locals.fee = QPAYHUB_FEE_FLOOR_QU; + } + if (locals.fee > locals.amount) + { + locals.fee = locals.amount; + } + locals.net = locals.amount - locals.fee; + + // Effects before the outbound interaction: record everything first, + // then pay the seller last. + locals.r.payer = qpi.invocator(); + locals.r.seller = input.seller; + locals.r.resourceId = input.resourceId; + locals.r.nonce = input.nonce; + locals.r.amountPaid = locals.amount; + locals.r.fee = locals.fee; + locals.r.epochPaid = (uint32)qpi.epoch(); + locals.r.tickPaid = (uint32)qpi.tick(); + locals.r.consumed = 0; + state.mut().receipts.set(locals.key, locals.r); + state.mut().feePool = sadd(state.get().feePool, locals.fee); + state.mut().totalPayments = sadd(state.get().totalPayments, (uint64)1); + state.mut().totalVolume = sadd(state.get().totalVolume, (uint64)locals.amount); + state.mut().totalFeesCollected = sadd(state.get().totalFeesCollected, (uint64)locals.fee); + + qpi.transfer(input.seller, locals.net); + + output.returnCode = QPAYHUB_OK; + output.receiptKey = locals.key; + output.net = locals.net; + output.fee = locals.fee; + } + + // Seller-only: mark a receipt consumed so it can never unlock the + // resource again, globally. Optional — a facilitator may instead keep + // single-use accounting off-chain. + PUBLIC_PROCEDURE_WITH_LOCALS(Consume) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + if (!state.get().receipts.get(input.receiptKey, locals.r)) + { + output.returnCode = QPAYHUB_ERR_NOT_FOUND; + return; + } + if (qpi.invocator() != locals.r.seller) + { + output.returnCode = QPAYHUB_ERR_ACCESS_DENIED; + return; + } + if (locals.r.consumed) + { + output.returnCode = QPAYHUB_ERR_ALREADY_CONSUMED; + return; + } + locals.r.consumed = 1; + state.mut().receipts.set(input.receiptKey, locals.r); + state.mut().totalConsumed = sadd(state.get().totalConsumed, (uint64)1); + output.returnCode = QPAYHUB_OK; + } + + // ---- ORACLE PRICE FEED ADDITIONS: subscribe procedure ---- + // Deliberately permissionless (see file header): the query is fixed - + // QUBIC/USDT via the combined Binance+MEXC oracle - so anyone can call + // this to establish or renew the subscription without being able to + // redirect it to an untrusted source. The subscription fee is paid + // from the caller own invocationReward, refunded in full on any + // rejection path. + PUBLIC_PROCEDURE_WITH_LOCALS(SubscribeToPriceFeed) + { + if (state.get().priceOracleSubscriptionId >= 0) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + output.returnCode = QPAYHUB_ERR_ALREADY_SUBSCRIBED; + output.subscriptionId = state.get().priceOracleSubscriptionId; + return; + } + + locals.query.oracle = OI::Price::getBinanceMexcOracleId(); + { + // Scoped so these single-letter names do not leak into the rest + // of the file - the same pattern TestExampleC.h uses. + using namespace Ch; + locals.query.currency1 = id(Q, U, B, I, C); + locals.query.currency2 = id(U, S, D, T); + } + locals.query.timestamp = qpi.now(); + + locals.fee = OI::Price::getSubscriptionFee(locals.query, QPAYHUB_PRICE_SUBSCRIBE_PERIOD_MS); + if (qpi.invocationReward() < locals.fee) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + output.returnCode = QPAYHUB_ERR_SUBSCRIBE_FAILED; + return; + } + // Refund the excess above the exact fee before the subscribe call, + // consistent with the rest of this contract fee handling. + if (qpi.invocationReward() > locals.fee) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward() - locals.fee); + } + + output.subscriptionId = SUBSCRIBE_ORACLE( + OI::Price, locals.query, NotifyQuUsdPriceReply, + QPAYHUB_PRICE_SUBSCRIBE_PERIOD_MS, 1); + if (output.subscriptionId < 0) + { + // Subscription failed to register - the fee already left the + // caller balance; the framework does not refund on this path + // (mirrors QUtil.h own handling of the same failure), so + // this is intentionally not retried automatically here. + output.returnCode = QPAYHUB_ERR_SUBSCRIBE_FAILED; + return; + } + state.mut().priceOracleSubscriptionId = output.subscriptionId; + output.returnCode = QPAYHUB_OK; + } + + // ---- ORACLE PRICE FEED ADDITIONS: notification callback ---- + // Fires whenever the subscription produces a new reply. Only a + // confirmed SUCCESS with a sane reply updates state - everything else + // (pending, unresolvable, timeout) leaves the last known good price in + // place rather than overwriting it with nothing. + PRIVATE_PROCEDURE_WITH_LOCALS(NotifyQuUsdPriceReply) + { + if (input.status != ORACLE_QUERY_STATUS_SUCCESS) + { + return; + } + if (!qpi.getOracleReply(input.queryId, locals.reply)) + { + return; + } + if (!OI::Price::replyIsValid(locals.reply)) + { + return; + } + state.mut().quUsdNumerator = locals.reply.numerator; + state.mut().quUsdDenominator = locals.reply.denominator; + state.mut().quUsdUpdatedTick = (uint32)qpi.tick(); + } + + REGISTER_USER_FUNCTIONS_AND_PROCEDURES() + { + REGISTER_USER_FUNCTION(GetReceipt, 1); + REGISTER_USER_FUNCTION(ComputeReceiptKey, 2); + REGISTER_USER_FUNCTION(GetInfo, 3); + REGISTER_USER_FUNCTION(GetQuUsdPrice, 4); + + REGISTER_USER_PROCEDURE(Pay, 1); + REGISTER_USER_PROCEDURE(Consume, 2); + REGISTER_USER_PROCEDURE(SubscribeToPriceFeed, 3); + + REGISTER_USER_PROCEDURE_NOTIFICATION(NotifyQuUsdPriceReply); + } + + INITIALIZE() + { + state.mut().priceOracleSubscriptionId = -1; + state.mut().quUsdDenominator = 0; // 0 = no price known yet, GetQuUsdPrice reports stale + + // DEVNET issuer - must be re-pointed before mainnet deployment. + state.mut().dividendToken.issuer = ID( + _Q, _D, _O, _G, _E, _E, _E, _S, + _K, _Y, _P, _A, _I, _C, _E, _C, + _H, _E, _A, _H, _O, _X, _P, _U, + _L, _E, _O, _A, _D, _T, _K, _G, + _E, _J, _H, _A, _V, _Y, _P, _F, + _K, _H, _L, _E, _W, _G, _X, _X, + _Z, _Q, _U, _G, _I, _G, _M, _B + ); + state.mut().dividendToken.assetName = QPAYHUB_TOKEN_ASSETNAME; + } + + POST_INCOMING_TRANSFER() + { + // Plain QU sent to the contract address (donations, misdirected + // transfers) accrues to the fee pool. Procedure-attached amounts are + // accounted inside Pay and must not be counted twice here. + if (input.type == TransferType::standardTransaction + || input.type == TransferType::qpiTransfer + || input.type == TransferType::qpiDistributeDividends + || input.type == TransferType::revenueDonation) + { + state.mut().feePool = sadd(state.get().feePool, input.amount); + } + } + + struct END_EPOCH_locals + { + sint64 idx; + Receipt r; + uint32 cur; + uint32 cutoff; + Entity ent; + sint64 balance; + sint64 distributable; + sint64 perShare; + sint64 distributed; + sint64 shareholderPart; + sint64 tokenPart; + sint64 paidShare; + sint64 totalHeld; + sint64 holderBal; + sint64 reward; + id holder; + AssetPossessionIterator it; + }; + END_EPOCH_WITH_LOCALS() + { + state.mut().receipts.cleanupIfNeeded(); + + // Purge receipts older than the retention window. A receipt paid in + // epoch E survives until END_EPOCH of E + retention, far beyond any + // payment freshness window a facilitator would accept. + locals.cur = (uint32)qpi.epoch(); + if (locals.cur > QPAYHUB_RECEIPT_RETENTION_EPOCHS) + { + locals.cutoff = locals.cur - QPAYHUB_RECEIPT_RETENTION_EPOCHS; + } + else + { + locals.cutoff = 0; + } + for (locals.idx = state.get().receipts.nextElementIndex(NULL_INDEX); + locals.idx != NULL_INDEX; + locals.idx = state.get().receipts.nextElementIndex(locals.idx)) + { + locals.r = state.get().receipts.value(locals.idx); + if (locals.r.epochPaid < locals.cutoff) + { + state.mut().receipts.removeByKey(state.get().receipts.key(locals.idx)); + state.mut().totalPurged = sadd(state.get().totalPurged, (uint64)1); + } + } + state.mut().receipts.cleanupIfNeeded(); + + // Distribute above the exec reserve: 10% shares, 90% token holders. + // Balance is re-read before spending; every payment is clamped to it. + qpi.getEntity(SELF, locals.ent); + locals.balance = locals.ent.incomingAmount - locals.ent.outgoingAmount; + locals.distributable = state.get().feePool - QPAYHUB_EXEC_RESERVE; + if (locals.distributable > locals.balance) + { + locals.distributable = locals.balance; + } + if (locals.distributable <= 0) return; + + locals.distributed = 0; + + locals.shareholderPart = (sint64)div( + (uint64)locals.distributable * QPAYHUB_DIVIDEND_SHAREHOLDER_PERMILLE, (uint64)1000); + // Token side takes the rounding remainder; nothing is stranded. + locals.tokenPart = locals.distributable - locals.shareholderPart; + if (locals.shareholderPart > 0) + { + locals.perShare = (sint64)div((uint64)locals.shareholderPart, (uint64)NUMBER_OF_COMPUTORS); + if (locals.perShare > 0 && qpi.distributeDividends(locals.perShare)) + { + locals.paidShare = locals.perShare * (sint64)NUMBER_OF_COMPUTORS; + locals.distributed = locals.distributed + locals.paidShare; + locals.balance = locals.balance - locals.paidShare; + state.mut().totalShareholderDividends = + sadd(state.get().totalShareholderDividends, (uint64)locals.paidShare); + } + } + + // No token or no holders: the 90% stays in feePool for a later epoch. + if (locals.tokenPart > 0 && state.get().dividendToken.issuer != NULL_ID) + { + locals.totalHeld = 0; + for (locals.it.begin(state.get().dividendToken); !locals.it.reachedEnd(); locals.it.next()) + { + // Issuer and contract are not recipients. + if (locals.it.possessor() == SELF) continue; + if (locals.it.possessor() == state.get().dividendToken.issuer) continue; + locals.totalHeld = sadd(locals.totalHeld, locals.it.numberOfPossessedShares()); + } + + if (locals.totalHeld > 0) + { + for (locals.it.begin(state.get().dividendToken); !locals.it.reachedEnd(); locals.it.next()) + { + if (locals.it.possessor() == SELF) continue; + if (locals.it.possessor() == state.get().dividendToken.issuer) continue; + locals.holderBal = locals.it.numberOfPossessedShares(); + if (locals.holderBal <= 0) continue; + // 128-bit intermediate: the product overflows 64 bits. + locals.reward = div( + (uint128)(uint64)locals.tokenPart * (uint128)(uint64)locals.holderBal, + (uint128)(uint64)locals.totalHeld).low; + if (locals.reward <= 0) continue; + if (locals.reward > locals.balance) locals.reward = locals.balance; + locals.holder = locals.it.possessor(); + qpi.transfer(locals.holder, locals.reward); + locals.balance = locals.balance - locals.reward; + locals.distributed = locals.distributed + locals.reward; + state.mut().totalTokenholderDividends = + sadd(state.get().totalTokenholderDividends, (uint64)locals.reward); + if (locals.balance <= 0) break; + } + } + } + + if (locals.distributed > 0) + { + state.mut().feePool = state.get().feePool - locals.distributed; + state.mut().totalFeesDistributed = + sadd(state.get().totalFeesDistributed, (uint64)locals.distributed); + } + } +}; From 9dd979125f4851f580b1d2e0971da914e77336b0 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Wed, 5 Aug 2026 21:47:58 +0100 Subject: [PATCH 2/8] feat: gtest for qpayhub --- src/contract_core/contract_def.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contract_core/contract_def.h b/src/contract_core/contract_def.h index 0406709a..2fffeb72 100644 --- a/src/contract_core/contract_def.h +++ b/src/contract_core/contract_def.h @@ -428,7 +428,7 @@ constexpr struct ContractDescription {"QUSINO", 208, 10000, sizeof(QUSINO::StateData)}, // proposal in epoch 206, IPO in 207, construction and first use in 208 {"ESCROW", 210, 10000, sizeof(ESCROW::StateData)}, // proposal in epoch 208, IPO in 209, construction and first use in 210 {"GGWP", 218, 10000, sizeof(WOLFPACK::StateData)}, // proposal in epoch 216, IPO in 217, construction and first use in 218 - {"QPAYHUB", 226, 10000, sizeof(IPO)}, // proposal in epoch 224, IPO in 225, construction and first use in 226 + {"QPAYHUB", 226, 10000, sizeof(QPAYHUB::StateData)}, // proposal in epoch 224, IPO in 225, construction and first use in 226 // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES {"TESTEXA", 138, 10000, sizeof(TESTEXA::StateData)}, From dd874dfe460b09071573bf794eb6e42c43c74d90 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Wed, 5 Aug 2026 21:48:29 +0100 Subject: [PATCH 3/8] fix: fee and def --- src/contracts/QPayhub.h | 6 +- test/contract_qpayhub.cpp | 802 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 805 insertions(+), 3 deletions(-) create mode 100644 test/contract_qpayhub.cpp diff --git a/src/contracts/QPayhub.h b/src/contracts/QPayhub.h index b4fb2228..a43239c7 100644 --- a/src/contracts/QPayhub.h +++ b/src/contracts/QPayhub.h @@ -133,7 +133,7 @@ using namespace QPI; constexpr uint64 QPAYHUB_RECEIPT_CAPACITY = 262144; // 2^18 (was 65536) -- ~9.4k/day mainnet headroom constexpr sint64 QPAYHUB_MIN_PAYMENT = 100; // dust floor, QU - rejects the payment outright -constexpr uint64 QPAYHUB_FEE_PERMILLE = 10; // 10 per 1000 = 1 percent +constexpr uint64 QPAYHUB_FEE_PERMILLE = 75; // 75 per 10000 = 0.75 percent // Floor on the fee itself, not a rejection threshold like MIN_PAYMENT. constexpr sint64 QPAYHUB_FEE_FLOOR_QU = 100; constexpr uint32 QPAYHUB_RECEIPT_RETENTION_EPOCHS = 2; @@ -159,7 +159,7 @@ constexpr uint32 QPAYHUB_PRICE_SUBSCRIBE_PERIOD_MS = 16u * 60u * 1000u; constexpr uint32 QPAYHUB_PRICE_STALE_TICKS = 4000; // roughly 20-25 min at current tick rates static_assert((QPAYHUB_RECEIPT_CAPACITY & (QPAYHUB_RECEIPT_CAPACITY - 1)) == 0); -static_assert(QPAYHUB_FEE_PERMILLE < 1000); +static_assert(QPAYHUB_FEE_PERMILLE < 10000); static_assert(QPAYHUB_DIVIDEND_SHAREHOLDER_PERMILLE <= 1000); // Return codes — append-only, never renumber. @@ -496,7 +496,7 @@ struct QPAYHUB : public ContractBase // 1% or QPAYHUB_FEE_FLOOR_QU, whichever is greater. Floor and minimum // are both 100, so a payment at the minimum nets the seller zero. - locals.fee = (sint64)div((uint64)locals.amount * QPAYHUB_FEE_PERMILLE, (uint64)1000); + locals.fee = (sint64)div((uint64)locals.amount * QPAYHUB_FEE_PERMILLE, (uint64)10000); if (locals.fee < QPAYHUB_FEE_FLOOR_QU) { locals.fee = QPAYHUB_FEE_FLOOR_QU; diff --git a/test/contract_qpayhub.cpp b/test/contract_qpayhub.cpp new file mode 100644 index 00000000..f9a2aba8 --- /dev/null +++ b/test/contract_qpayhub.cpp @@ -0,0 +1,802 @@ +#define NO_UEFI + +#include "contract_testing.h" +#include "oracle_testing.h" + +static const id SELLER1(1, 1, 1, 1); +static const id SELLER2(2, 2, 2, 2); +static const id BUYER1(11, 11, 11, 11); +static const id BUYER2(22, 22, 22, 22); +static const id RESOURCE1(101, 101, 101, 101); +static const id RESOURCE2(202, 202, 202, 202); +static const id SHAREHOLDER1(301, 301, 301, 301); +static const id TOKENHOLDER1(401, 401, 401, 401); + +static const id QPAYHUB_CONTRACT_ID(QPAYHUB_CONTRACT_INDEX, 0, 0, 0); + +// The exact issuer identity QPayhub.h's INITIALIZE() hardcodes for the QPAY +// dividend token (devnet placeholder). Copied verbatim so tests can issue the +// matching "QPAY" asset via QX and exercise the token-holder dividend path. +static const id QPAYHUB_DIVIDEND_TOKEN_ISSUER = ID( + _Q, _D, _O, _G, _E, _E, _E, _S, + _K, _Y, _P, _A, _I, _C, _E, _C, + _H, _E, _A, _H, _O, _X, _P, _U, + _L, _E, _O, _A, _D, _T, _K, _G, + _E, _J, _H, _A, _V, _Y, _P, _F, + _K, _H, _L, _E, _W, _G, _X, _X, + _Z, _Q, _U, _G, _I, _G, _M, _B +); + +// Exposes state fields directly (same pattern as StateCheckerTestExampleA in +// contract_testex.cpp: reinterpret the raw contract state buffer through a +// class that inherits both the contract and its StateData) plus the +// protected notification procedure id, needed to invoke the oracle-reply +// callback directly in tests (see invokeNotifyQuUsdPriceReply() below). +class QPayhubChecker : public QPAYHUB, public QPAYHUB::StateData +{ +public: + static unsigned int notifyQuUsdPriceReplyProcId() + { + return __id_NotifyQuUsdPriceReply; + } +}; + +class ContractTestingQPayhub : protected ContractTesting +{ +public: + QX::Fees_output qxFees; + + ContractTestingQPayhub() + { + initEmptySpectrum(); + initEmptyUniverse(); + + INIT_CONTRACT(QX); + callSystemProcedure(QX_CONTRACT_INDEX, INITIALIZE); + INIT_CONTRACT(QPAYHUB); + callSystemProcedure(QPAYHUB_CONTRACT_INDEX, INITIALIZE); + + system.epoch = 200; + system.tick = 123456783; + etalonTick.year = 25; + etalonTick.month = 12; + etalonTick.day = 15; + etalonTick.hour = 16; + etalonTick.minute = 51; + etalonTick.second = 12; + + // Needed for SubscribeToPriceFeed() / oracle-engine-backed tests only; + // harmless setup for the tests that don't touch the oracle at all. + for (unsigned int i = 0; i < NUMBER_OF_COMPUTORS; ++i) + { + broadcastedComputors.computors.publicKeys[i] = computorPublicKeys[i]; + } + EXPECT_TRUE(oracleEngine.init(computorPublicKeys)); + EXPECT_TRUE(OI::initOracleInterfaces()); + EXPECT_TRUE(ts.init()); + ts.beginEpoch((unsigned int)system.tick); + + checkContractExecCleanup(); + + callFunction(QX_CONTRACT_INDEX, 1, QX::Fees_input(), qxFees); + } + + ~ContractTestingQPayhub() + { + oracleEngine.deinit(); + ts.deinit(); + checkContractExecCleanup(); + } + + QPayhubChecker* state() + { + return (QPayhubChecker*)contractStates[QPAYHUB_CONTRACT_INDEX]; + } + + void endEpoch(bool expectSuccess = true) + { + callSystemProcedure(QPAYHUB_CONTRACT_INDEX, END_EPOCH, expectSuccess); + } + + QPAYHUB::Pay_output pay(const id& payer, const id& seller, const id& resourceId, uint64 nonce, sint64 amount) + { + QPAYHUB::Pay_input input{ seller, resourceId, nonce }; + QPAYHUB::Pay_output output; + invokeUserProcedure(QPAYHUB_CONTRACT_INDEX, 1, input, output, payer, amount); + return output; + } + + QPAYHUB::Consume_output consume(const id& invocator, const id& receiptKey, sint64 reward = 0) + { + QPAYHUB::Consume_input input{ receiptKey }; + QPAYHUB::Consume_output output; + invokeUserProcedure(QPAYHUB_CONTRACT_INDEX, 2, input, output, invocator, reward); + return output; + } + + QPAYHUB::GetReceipt_output getReceipt(const id& receiptKey) + { + QPAYHUB::GetReceipt_input input{ receiptKey }; + QPAYHUB::GetReceipt_output output; + callFunction(QPAYHUB_CONTRACT_INDEX, 1, input, output); + return output; + } + + id computeReceiptKey(const id& payer, const id& seller, const id& resourceId, uint64 nonce) + { + QPAYHUB::ComputeReceiptKey_input input{ payer, seller, resourceId, nonce }; + QPAYHUB::ComputeReceiptKey_output output; + callFunction(QPAYHUB_CONTRACT_INDEX, 2, input, output); + return output.receiptKey; + } + + QPAYHUB::GetInfo_output getInfo() + { + QPAYHUB::GetInfo_input input; + QPAYHUB::GetInfo_output output; + callFunction(QPAYHUB_CONTRACT_INDEX, 3, input, output); + return output; + } + + QPAYHUB::GetQuUsdPrice_output getQuUsdPrice() + { + QPAYHUB::GetQuUsdPrice_input input; + QPAYHUB::GetQuUsdPrice_output output; + callFunction(QPAYHUB_CONTRACT_INDEX, 4, input, output); + return output; + } + + QPAYHUB::SubscribeToPriceFeed_output subscribeToPriceFeed(const id& invocator, sint64 reward) + { + QPAYHUB::SubscribeToPriceFeed_input input; + QPAYHUB::SubscribeToPriceFeed_output output; + invokeUserProcedure(QPAYHUB_CONTRACT_INDEX, 3, input, output, invocator, reward); + return output; + } + + // Directly invokes the private oracle-reply notification callback exactly + // as qubic.cpp's contract processor does for USER_PROCEDURE_NOTIFICATION_CALL + // (see QpiContextUserProcedureNotificationCall usage at qubic.cpp:2357). + // This unit-tests NotifyQuUsdPriceReply's own guard-clause logic without + // requiring the full oracle notification-queue delivery pipeline, which is + // a separate concern owned by the oracle engine / qubic.cpp main loop. + void invokeNotifyQuUsdPriceReply(const QPAYHUB::NotifyQuUsdPriceReply_input& input) + { + const UserProcedureRegistry::UserProcedureData* procData = + userProcedureRegistry->get(QPayhubChecker::notifyQuUsdPriceReplyProcId()); + ASSERT_NE(procData, nullptr); + QpiContextUserProcedureNotificationCall qpiContext(*procData); + qpiContext.call(&input); + } + + // Drives one real oracle query all the way through the commit + reveal + // quorum pipeline so that oracleEngine.getOracleReply() has a genuine + // resolved value for it - mirrors test/oracle_engine.cpp's + // OracleEngine.ContractQuerySuccess test (the reference this was derived + // from), collapsed to a single engine instance instead of three + // simulated nodes, since here we only need one authoritative state. + // NotifyQuUsdPriceReply() itself calls qpi.getOracleReply() rather than + // trusting the notification input's reply field, so this is the only way + // to exercise its "reply is actually fetched and validated" branches. + sint64 startAndResolvePriceQuery(sint64 numerator, sint64 denominator) + { + OI::Price::OracleQuery query; + query.oracle = OI::Price::getBinanceMexcOracleId(); + { + using namespace Ch; + query.currency1 = id(Q, U, B, I, C); + query.currency2 = id(U, S, D, T); + } + query.timestamp = QPI::DateAndTime::now(); + + const uint32 notificationProcId = QPayhubChecker::notifyQuUsdPriceReplyProcId(); + const uint32 timeout = 60000; + sint64 queryId = oracleEngine.startContractQuery( + QPAYHUB_CONTRACT_INDEX, OI::Price::oracleInterfaceIndex, + &query, sizeof(query), timeout, notificationProcId); + EXPECT_GE(queryId, 0); + + // Simulate the oracle machine node reply landing. + struct + { + OracleMachineReply metadata; + OI::Price::OracleReply data; + } machineReply; + machineReply.metadata.oracleMachineErrorFlags = 0; + machineReply.metadata.oracleQueryId = queryId; + machineReply.data.numerator = numerator; + machineReply.data.denominator = denominator; + oracleEngine.processOracleMachineReply(&machineReply.metadata, sizeof(machineReply)); + + // Every computor commits to the identical reply digest, reaching quorum. + uint8_t txBuffer[MAX_TRANSACTION_SIZE]; + auto* commitTx = (OracleReplyCommitTransactionPrefix*)txBuffer; + system.tick += 3; + for (unsigned int i = 0; i < NUMBER_OF_COMPUTORS; ++i) + { + if (oracleEngine.getOracleQueryStatus(queryId) == ORACLE_QUERY_STATUS_COMMITTED) + break; + uint32_t rc = oracleEngine.getReplyCommitTransaction(txBuffer, i, system.tick + 3, 0); + if (rc == 0) + continue; + EXPECT_TRUE(oracleEngine.processOracleReplyCommitTransaction(commitTx)); + } + EXPECT_EQ(oracleEngine.getOracleQueryStatus(queryId), ORACLE_QUERY_STATUS_COMMITTED); + + // Reveal the committed reply so it becomes retrievable via getOracleReply(). + system.tick += 3; + uint32_t revealRc = oracleEngine.getReplyRevealTransaction(txBuffer, 0, system.tick + 3, 0); + EXPECT_NE(revealRc, 0u); + system.tick += 3; + auto* revealTx = (OracleReplyRevealTransactionPrefix*)txBuffer; + const unsigned int txIndex = 0; + addOracleTransactionToTickStorage(revealTx, txIndex); + oracleEngine.processOracleReplyRevealTransaction(revealTx, txIndex); + + EXPECT_EQ(oracleEngine.getOracleQueryStatus(queryId), ORACLE_QUERY_STATUS_SUCCESS); + return queryId; + } + + sint64 issueAsset(const id& issuer, uint64 assetName, sint64 numberOfShares) + { + QX::IssueAsset_input input; + input.assetName = assetName; + input.numberOfShares = numberOfShares; + input.unitOfMeasurement = 0; + input.numberOfDecimalPlaces = 0; + QX::IssueAsset_output output; + invokeUserProcedure(QX_CONTRACT_INDEX, 1, input, output, issuer, qxFees.assetIssuanceFee); + return output.issuedNumberOfShares; + } + + sint64 transferAsset(const id& from, const id& to, uint64 assetName, const id& issuer, sint64 numberOfShares) + { + QX::TransferShareOwnershipAndPossession_input input; + input.assetName = assetName; + input.issuer = issuer; + input.newOwnerAndPossessor = to; + input.numberOfShares = numberOfShares; + QX::TransferShareOwnershipAndPossession_output output; + invokeUserProcedure(QX_CONTRACT_INDEX, 2, input, output, from, qxFees.transferFee); + return output.transferredNumberOfShares; + } +}; + +TEST(ContractQPayhub, ComputeReceiptKeyIsDeterministicAndSensitiveToEachField) +{ + ContractTestingQPayhub qpayhub; + + const id k1 = qpayhub.computeReceiptKey(BUYER1, SELLER1, RESOURCE1, 1); + const id k1again = qpayhub.computeReceiptKey(BUYER1, SELLER1, RESOURCE1, 1); + EXPECT_EQ(k1, k1again); + + EXPECT_NE(k1, qpayhub.computeReceiptKey(BUYER2, SELLER1, RESOURCE1, 1)); + EXPECT_NE(k1, qpayhub.computeReceiptKey(BUYER1, SELLER2, RESOURCE1, 1)); + EXPECT_NE(k1, qpayhub.computeReceiptKey(BUYER1, SELLER1, RESOURCE2, 1)); + EXPECT_NE(k1, qpayhub.computeReceiptKey(BUYER1, SELLER1, RESOURCE1, 2)); +} + +TEST(ContractQPayhub, PayHappyPathRecordsReceiptAndPaysSellerNetOfFee) +{ + ContractTestingQPayhub qpayhub; + increaseEnergy(BUYER1, 10000000); + + const sint64 amount = 1000000; + const sint64 expectedFee = 10000; // 1% of 1,000,000 + const sint64 expectedNet = amount - expectedFee; + + const sint64 sellerBalanceBefore = getBalance(SELLER1); + const sint64 buyerBalanceBefore = getBalance(BUYER1); + + auto output = qpayhub.pay(BUYER1, SELLER1, RESOURCE1, 1, amount); + + EXPECT_EQ(output.returnCode, QPAYHUB_OK); + EXPECT_EQ(output.fee, expectedFee); + EXPECT_EQ(output.net, expectedNet); + EXPECT_EQ(output.receiptKey, qpayhub.computeReceiptKey(BUYER1, SELLER1, RESOURCE1, 1)); + + EXPECT_EQ(getBalance(SELLER1), sellerBalanceBefore + expectedNet); + EXPECT_EQ(getBalance(BUYER1), buyerBalanceBefore - amount); + + auto receipt = qpayhub.getReceipt(output.receiptKey); + EXPECT_EQ(receipt.returnCode, QPAYHUB_OK); + EXPECT_EQ(receipt.payer, BUYER1); + EXPECT_EQ(receipt.seller, SELLER1); + EXPECT_EQ(receipt.resourceId, RESOURCE1); + EXPECT_EQ(receipt.nonce, 1ULL); + EXPECT_EQ(receipt.amountPaid, amount); + EXPECT_EQ(receipt.fee, expectedFee); + EXPECT_EQ(receipt.epochPaid, (uint32)system.epoch); + EXPECT_EQ(receipt.tickPaid, (uint32)system.tick); + EXPECT_EQ(receipt.consumed, 0); + + auto info = qpayhub.getInfo(); + EXPECT_EQ(info.receiptCount, 1ULL); + EXPECT_EQ(info.feePool, expectedFee); + EXPECT_EQ(info.totalPayments, 1ULL); + EXPECT_EQ(info.totalVolume, (uint64)amount); + EXPECT_EQ(info.totalFeesCollected, (uint64)expectedFee); +} + +TEST(ContractQPayhub, PayInvalidSellerNullIdAndSelfRefundsAndRejects) +{ + ContractTestingQPayhub qpayhub; + increaseEnergy(BUYER1, 10000000); + + const sint64 amount = 5000; + const sint64 balanceBefore = getBalance(BUYER1); + + auto outNull = qpayhub.pay(BUYER1, NULL_ID, RESOURCE1, 1, amount); + EXPECT_EQ(outNull.returnCode, QPAYHUB_ERR_INVALID_SELLER); + EXPECT_EQ(getBalance(BUYER1), balanceBefore); + + auto outSelf = qpayhub.pay(BUYER1, QPAYHUB_CONTRACT_ID, RESOURCE1, 2, amount); + EXPECT_EQ(outSelf.returnCode, QPAYHUB_ERR_INVALID_SELLER); + EXPECT_EQ(getBalance(BUYER1), balanceBefore); + + EXPECT_EQ(qpayhub.getInfo().receiptCount, 0ULL); +} + +TEST(ContractQPayhub, PayAmountBelowMinimumRefundsAndRejects) +{ + ContractTestingQPayhub qpayhub; + increaseEnergy(BUYER1, 10000000); + + const sint64 balanceBefore = getBalance(BUYER1); + auto output = qpayhub.pay(BUYER1, SELLER1, RESOURCE1, 1, QPAYHUB_MIN_PAYMENT - 1); + + EXPECT_EQ(output.returnCode, QPAYHUB_ERR_AMOUNT_TOO_LOW); + EXPECT_EQ(getBalance(BUYER1), balanceBefore); + EXPECT_EQ(qpayhub.getInfo().receiptCount, 0ULL); +} + +TEST(ContractQPayhub, PayDuplicateKeyRefundsAndRejects) +{ + ContractTestingQPayhub qpayhub; + increaseEnergy(BUYER1, 10000000); + + auto first = qpayhub.pay(BUYER1, SELLER1, RESOURCE1, 1, 5000); + EXPECT_EQ(first.returnCode, QPAYHUB_OK); + + const sint64 balanceBeforeDup = getBalance(BUYER1); + auto dup = qpayhub.pay(BUYER1, SELLER1, RESOURCE1, 1, 5000); + + EXPECT_EQ(dup.returnCode, QPAYHUB_ERR_DUPLICATE); + EXPECT_EQ(getBalance(BUYER1), balanceBeforeDup); + EXPECT_EQ(qpayhub.getInfo().receiptCount, 1ULL); +} + +TEST(ContractQPayhub, PayFeeFloorDominatesBelowOnePercentThreshold) +{ + ContractTestingQPayhub qpayhub; + increaseEnergy(BUYER1, 10000000); + + // 1% of 5000 is 50, below the 100 QU floor, so the floor applies. + auto output = qpayhub.pay(BUYER1, SELLER1, RESOURCE1, 1, 5000); + EXPECT_EQ(output.returnCode, QPAYHUB_OK); + EXPECT_EQ(output.fee, QPAYHUB_FEE_FLOOR_QU); + EXPECT_EQ(output.net, 5000 - QPAYHUB_FEE_FLOOR_QU); +} + +TEST(ContractQPayhub, PayFeePercentDominatesAboveOnePercentThreshold) +{ + ContractTestingQPayhub qpayhub; + increaseEnergy(BUYER1, 10000000); + + // 1% of 20000 is 200, above the 100 QU floor, so the percentage applies. + auto output = qpayhub.pay(BUYER1, SELLER1, RESOURCE1, 1, 20000); + EXPECT_EQ(output.returnCode, QPAYHUB_OK); + EXPECT_EQ(output.fee, 200); + EXPECT_EQ(output.net, 20000 - 200); +} + +TEST(ContractQPayhub, PayFeeExactTieBetweenFloorAndPercent) +{ + ContractTestingQPayhub qpayhub; + increaseEnergy(BUYER1, 10000000); + + // 1% of 10000 is exactly 100, equal to the floor. + auto output = qpayhub.pay(BUYER1, SELLER1, RESOURCE1, 1, 10000); + EXPECT_EQ(output.returnCode, QPAYHUB_OK); + EXPECT_EQ(output.fee, 100); + EXPECT_EQ(output.net, 9900); +} + +TEST(ContractQPayhub, PayAtMinimumPaymentClampsFeeToAmountForZeroNet) +{ + ContractTestingQPayhub qpayhub; + increaseEnergy(BUYER1, 10000000); + + // At the minimum payment, the floor would exceed the amount and gets + // clamped: fee == amount, net == 0, never negative. + auto output = qpayhub.pay(BUYER1, SELLER1, RESOURCE1, 1, QPAYHUB_MIN_PAYMENT); + EXPECT_EQ(output.returnCode, QPAYHUB_OK); + EXPECT_EQ(output.fee, QPAYHUB_MIN_PAYMENT); + EXPECT_EQ(output.net, 0); +} + +TEST(ContractQPayhub, ConsumeHappyPathMarksConsumedBySeller) +{ + ContractTestingQPayhub qpayhub; + increaseEnergy(BUYER1, 10000000); + + auto payOut = qpayhub.pay(BUYER1, SELLER1, RESOURCE1, 1, 5000); + auto consumeOut = qpayhub.consume(SELLER1, payOut.receiptKey); + + EXPECT_EQ(consumeOut.returnCode, QPAYHUB_OK); + + auto receipt = qpayhub.getReceipt(payOut.receiptKey); + EXPECT_EQ(receipt.consumed, 1); + EXPECT_EQ(qpayhub.getInfo().totalConsumed, 1ULL); +} + +TEST(ContractQPayhub, ConsumeByNonSellerIsRejected) +{ + ContractTestingQPayhub qpayhub; + increaseEnergy(BUYER1, 10000000); + + auto payOut = qpayhub.pay(BUYER1, SELLER1, RESOURCE1, 1, 5000); + auto consumeOut = qpayhub.consume(BUYER1, payOut.receiptKey); + + EXPECT_EQ(consumeOut.returnCode, QPAYHUB_ERR_ACCESS_DENIED); + EXPECT_EQ(qpayhub.getReceipt(payOut.receiptKey).consumed, 0); +} + +TEST(ContractQPayhub, ConsumeAlreadyConsumedIsRejected) +{ + ContractTestingQPayhub qpayhub; + increaseEnergy(BUYER1, 10000000); + + auto payOut = qpayhub.pay(BUYER1, SELLER1, RESOURCE1, 1, 5000); + EXPECT_EQ(qpayhub.consume(SELLER1, payOut.receiptKey).returnCode, QPAYHUB_OK); + + auto second = qpayhub.consume(SELLER1, payOut.receiptKey); + EXPECT_EQ(second.returnCode, QPAYHUB_ERR_ALREADY_CONSUMED); + EXPECT_EQ(qpayhub.getInfo().totalConsumed, 1ULL); +} + +TEST(ContractQPayhub, ConsumeNotFoundIsRejected) +{ + ContractTestingQPayhub qpayhub; + increaseEnergy(SELLER1, 10000000); + + auto output = qpayhub.consume(SELLER1, id::randomValue()); + EXPECT_EQ(output.returnCode, QPAYHUB_ERR_NOT_FOUND); +} + +TEST(ContractQPayhub, ConsumeRefundsLeftoverInvocationReward) +{ + ContractTestingQPayhub qpayhub; + increaseEnergy(BUYER1, 10000000); + increaseEnergy(SELLER1, 10000000); + + auto payOut = qpayhub.pay(BUYER1, SELLER1, RESOURCE1, 1, 5000); + + const sint64 sellerBalanceBefore = getBalance(SELLER1); + auto consumeOut = qpayhub.consume(SELLER1, payOut.receiptKey, 777); + + EXPECT_EQ(consumeOut.returnCode, QPAYHUB_OK); + // The 777 QU attached to Consume is not a price; it must come straight back. + EXPECT_EQ(getBalance(SELLER1), sellerBalanceBefore); +} + +TEST(ContractQPayhub, GetReceiptNotFoundForUnknownKey) +{ + ContractTestingQPayhub qpayhub; + auto receipt = qpayhub.getReceipt(id::randomValue()); + EXPECT_EQ(receipt.returnCode, QPAYHUB_ERR_NOT_FOUND); +} + +TEST(ContractQPayhub, GetInfoReflectsRunningTotalsAndConstants) +{ + ContractTestingQPayhub qpayhub; + + auto emptyInfo = qpayhub.getInfo(); + EXPECT_EQ(emptyInfo.feePermille, QPAYHUB_FEE_PERMILLE); + EXPECT_EQ(emptyInfo.minPayment, QPAYHUB_MIN_PAYMENT); + EXPECT_EQ(emptyInfo.retentionEpochs, QPAYHUB_RECEIPT_RETENTION_EPOCHS); + EXPECT_EQ(emptyInfo.feeFloorQu, QPAYHUB_FEE_FLOOR_QU); + EXPECT_EQ(emptyInfo.shareholderPermille, QPAYHUB_DIVIDEND_SHAREHOLDER_PERMILLE); + EXPECT_EQ(emptyInfo.receiptCount, 0ULL); + EXPECT_EQ(emptyInfo.feePool, 0); + + increaseEnergy(BUYER1, 10000000); + increaseEnergy(BUYER2, 10000000); + qpayhub.pay(BUYER1, SELLER1, RESOURCE1, 1, 20000); + qpayhub.pay(BUYER2, SELLER2, RESOURCE2, 2, 30000); + auto payOut1 = qpayhub.pay(BUYER1, SELLER1, RESOURCE1, 3, 20000); + qpayhub.consume(SELLER1, payOut1.receiptKey); + + auto info = qpayhub.getInfo(); + EXPECT_EQ(info.receiptCount, 3ULL); + EXPECT_EQ(info.totalPayments, 3ULL); + EXPECT_EQ(info.totalVolume, 70000ULL); + EXPECT_EQ(info.totalFeesCollected, 700ULL); // 200 + 300 + 200 + EXPECT_EQ(info.feePool, 700); + EXPECT_EQ(info.totalConsumed, 1ULL); +} + +TEST(ContractQPayhub, PostIncomingTransferCountsDonationTypesButNotProcedureTransaction) +{ + ContractTestingQPayhub qpayhub; + + auto donate = [&](uint8 type, sint64 amount) + { + QpiContextSystemProcedureCall qpiContext(QPAYHUB_CONTRACT_INDEX, POST_INCOMING_TRANSFER); + QPI::PostIncomingTransfer_input input{ BUYER1, amount, type }; + qpiContext.call(input); + }; + + EXPECT_EQ(qpayhub.getInfo().feePool, 0); + + donate(QPI::TransferType::standardTransaction, 1000); + EXPECT_EQ(qpayhub.getInfo().feePool, 1000); + + donate(QPI::TransferType::qpiTransfer, 500); + EXPECT_EQ(qpayhub.getInfo().feePool, 1500); + + donate(QPI::TransferType::qpiDistributeDividends, 250); + EXPECT_EQ(qpayhub.getInfo().feePool, 1750); + + donate(QPI::TransferType::revenueDonation, 100); + EXPECT_EQ(qpayhub.getInfo().feePool, 1850); + + // Procedure-attached amounts are already accounted for inside Pay itself; + // counting them here too would double-count every Pay call. + donate(QPI::TransferType::procedureTransaction, 999999); + EXPECT_EQ(qpayhub.getInfo().feePool, 1850); +} + +TEST(ContractQPayhub, EndEpochPurgesReceiptsPastRetentionWindow) +{ + ContractTestingQPayhub qpayhub; + increaseEnergy(BUYER1, 10000000); + + system.epoch = 200; + auto oldReceipt = qpayhub.pay(BUYER1, SELLER1, RESOURCE1, 1, 5000); + + system.epoch = 202; + auto freshReceipt = qpayhub.pay(BUYER1, SELLER1, RESOURCE2, 2, 5000); + + // cutoff = 203 - QPAYHUB_RECEIPT_RETENTION_EPOCHS(2) = 201; epochPaid 200 < 201 purges, + // epochPaid 202 survives. + system.epoch = 203; + qpayhub.endEpoch(); + + EXPECT_EQ(qpayhub.getReceipt(oldReceipt.receiptKey).returnCode, QPAYHUB_ERR_NOT_FOUND); + EXPECT_EQ(qpayhub.getReceipt(freshReceipt.receiptKey).returnCode, QPAYHUB_OK); + EXPECT_EQ(qpayhub.getInfo().totalPurged, 1ULL); +} + +TEST(ContractQPayhub, EndEpochDistributesFeePoolAboveReserveToSharesAndTokenHolders) +{ + ContractTestingQPayhub qpayhub; + + // distributable = feePool - QPAYHUB_EXEC_RESERVE(1,000,000) = 676,000, + // chosen so shareholderPart (10%) divides evenly by NUMBER_OF_COMPUTORS. + const sint64 feePoolAmount = QPAYHUB_EXEC_RESERVE + 676000; + qpayhub.state()->feePool = feePoolAmount; + increaseEnergy(QPAYHUB_CONTRACT_ID, feePoolAmount + 1000000); + + std::vector> qpayhubShares{ + { SHAREHOLDER1, NUMBER_OF_COMPUTORS } + }; + issueContractShares(QPAYHUB_CONTRACT_INDEX, qpayhubShares); + + increaseEnergy(QPAYHUB_DIVIDEND_TOKEN_ISSUER, 10000000); + EXPECT_EQ(qpayhub.issueAsset(QPAYHUB_DIVIDEND_TOKEN_ISSUER, QPAYHUB_TOKEN_ASSETNAME, 1000000), 1000000); + EXPECT_EQ(qpayhub.transferAsset(QPAYHUB_DIVIDEND_TOKEN_ISSUER, TOKENHOLDER1, QPAYHUB_TOKEN_ASSETNAME, QPAYHUB_DIVIDEND_TOKEN_ISSUER, 1000000), 1000000); + + const sint64 shareholderBalanceBefore = getBalance(SHAREHOLDER1); + const sint64 tokenholderBalanceBefore = getBalance(TOKENHOLDER1); + + qpayhub.endEpoch(); + + const sint64 expectedShareholderTotal = 67600; // 10% of 676,000 + const sint64 expectedTokenholderTotal = 608400; // remaining 90% + + EXPECT_EQ(getBalance(SHAREHOLDER1), shareholderBalanceBefore + expectedShareholderTotal); + EXPECT_EQ(getBalance(TOKENHOLDER1), tokenholderBalanceBefore + expectedTokenholderTotal); + + auto info = qpayhub.getInfo(); + EXPECT_EQ(info.totalShareholderDividends, (uint64)expectedShareholderTotal); + EXPECT_EQ(info.totalTokenholderDividends, (uint64)expectedTokenholderTotal); + EXPECT_EQ(info.feePool, QPAYHUB_EXEC_RESERVE); +} + +TEST(ContractQPayhub, EndEpochDoesNotDistributeWhenFeePoolAtOrBelowReserve) +{ + ContractTestingQPayhub qpayhub; + + qpayhub.state()->feePool = QPAYHUB_EXEC_RESERVE - 1; + increaseEnergy(QPAYHUB_CONTRACT_ID, QPAYHUB_EXEC_RESERVE); + + std::vector> qpayhubShares{ + { SHAREHOLDER1, NUMBER_OF_COMPUTORS } + }; + issueContractShares(QPAYHUB_CONTRACT_INDEX, qpayhubShares); + + const sint64 shareholderBalanceBefore = getBalance(SHAREHOLDER1); + qpayhub.endEpoch(); + + EXPECT_EQ(getBalance(SHAREHOLDER1), shareholderBalanceBefore); + auto info = qpayhub.getInfo(); + EXPECT_EQ(info.totalShareholderDividends, 0ULL); + EXPECT_EQ(info.totalTokenholderDividends, 0ULL); + EXPECT_EQ(info.feePool, QPAYHUB_EXEC_RESERVE - 1); +} + +TEST(ContractQPayhub, SubscribeToPriceFeedFeeTooLowRefundsAndRejects) +{ + ContractTestingQPayhub qpayhub; + increaseEnergy(BUYER1, 10000000); + + const sint64 balanceBefore = getBalance(BUYER1); + auto output = qpayhub.subscribeToPriceFeed(BUYER1, 100); + + EXPECT_EQ(output.returnCode, QPAYHUB_ERR_SUBSCRIBE_FAILED); + EXPECT_EQ(getBalance(BUYER1), balanceBefore); + EXPECT_LT(qpayhub.state()->priceOracleSubscriptionId, 0); +} + +TEST(ContractQPayhub, SubscribeToPriceFeedSuccessStoresSubscriptionAndRefundsExcess) +{ + ContractTestingQPayhub qpayhub; + increaseEnergy(BUYER1, 10000000); + + OI::Price::OracleQuery dummyQuery; + const sint64 requiredFee = OI::Price::getSubscriptionFee(dummyQuery, QPAYHUB_PRICE_SUBSCRIBE_PERIOD_MS); + const sint64 excess = 500; + const sint64 balanceBefore = getBalance(BUYER1); + + auto output = qpayhub.subscribeToPriceFeed(BUYER1, requiredFee + excess); + + EXPECT_EQ(output.returnCode, QPAYHUB_OK); + EXPECT_GE(output.subscriptionId, 0); + EXPECT_EQ(qpayhub.state()->priceOracleSubscriptionId, output.subscriptionId); + EXPECT_NE(oracleEngine.getOracleSubscription(output.subscriptionId), nullptr); + + // Only the exact fee left the buyer's balance; the excess was refunded. + EXPECT_EQ(getBalance(BUYER1), balanceBefore - requiredFee); +} + +TEST(ContractQPayhub, SubscribeToPriceFeedAlreadySubscribedRefundsAndRejects) +{ + ContractTestingQPayhub qpayhub; + increaseEnergy(BUYER1, 10000000); + + OI::Price::OracleQuery dummyQuery; + const sint64 requiredFee = OI::Price::getSubscriptionFee(dummyQuery, QPAYHUB_PRICE_SUBSCRIBE_PERIOD_MS); + + auto first = qpayhub.subscribeToPriceFeed(BUYER1, requiredFee); + EXPECT_EQ(first.returnCode, QPAYHUB_OK); + + const sint64 balanceBefore = getBalance(BUYER1); + auto second = qpayhub.subscribeToPriceFeed(BUYER1, requiredFee + 1000); + + EXPECT_EQ(second.returnCode, QPAYHUB_ERR_ALREADY_SUBSCRIBED); + EXPECT_EQ(second.subscriptionId, first.subscriptionId); + // The whole attached reward is refunded; the already-subscribed check + // runs before any fee is even computed. + EXPECT_EQ(getBalance(BUYER1), balanceBefore); +} + +TEST(ContractQPayhub, GetQuUsdPriceFreshContractIsStale) +{ + ContractTestingQPayhub qpayhub; + auto price = qpayhub.getQuUsdPrice(); + EXPECT_EQ(price.denominator, 0); + EXPECT_TRUE(price.stale); +} + +TEST(ContractQPayhub, GetQuUsdPriceRecentUpdateIsNotStale) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->quUsdNumerator = 12345; + qpayhub.state()->quUsdDenominator = 100; + qpayhub.state()->quUsdUpdatedTick = (uint32)system.tick; + + auto price = qpayhub.getQuUsdPrice(); + EXPECT_EQ(price.numerator, 12345); + EXPECT_EQ(price.denominator, 100); + EXPECT_FALSE(price.stale); +} + +TEST(ContractQPayhub, GetQuUsdPriceOldUpdateIsStale) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->quUsdNumerator = 12345; + qpayhub.state()->quUsdDenominator = 100; + qpayhub.state()->quUsdUpdatedTick = (uint32)system.tick; + + system.tick += QPAYHUB_PRICE_STALE_TICKS + 1; + + auto price = qpayhub.getQuUsdPrice(); + EXPECT_TRUE(price.stale); +} + +TEST(ContractQPayhub, NotifyQuUsdPriceReplyIgnoresNonSuccessStatus) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->quUsdDenominator = 0; + + QPAYHUB::NotifyQuUsdPriceReply_input input{}; + input.queryId = -1; + input.subscriptionId = -1; + input.status = ORACLE_QUERY_STATUS_TIMEOUT; + input.reply.numerator = 999; + input.reply.denominator = 1; + + qpayhub.invokeNotifyQuUsdPriceReply(input); + + EXPECT_EQ(qpayhub.state()->quUsdDenominator, 0); + EXPECT_TRUE(qpayhub.getQuUsdPrice().stale); +} + +TEST(ContractQPayhub, NotifyQuUsdPriceReplyIgnoresUnresolvedQueryId) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->quUsdDenominator = 0; + + // status is SUCCESS, but this queryId was never actually queried/resolved + // in the oracle engine, so qpi.getOracleReply() must fail and the + // notification must be a no-op. + QPAYHUB::NotifyQuUsdPriceReply_input input{}; + input.queryId = 424242; + input.subscriptionId = -1; + input.status = ORACLE_QUERY_STATUS_SUCCESS; + input.reply.numerator = 999; + input.reply.denominator = 1; + + qpayhub.invokeNotifyQuUsdPriceReply(input); + + EXPECT_EQ(qpayhub.state()->quUsdDenominator, 0); + EXPECT_TRUE(qpayhub.getQuUsdPrice().stale); +} + +TEST(ContractQPayhub, NotifyQuUsdPriceReplyWithValidResolvedReplyUpdatesState) +{ + ContractTestingQPayhub qpayhub; + + const sint64 numerator = 1234; + const sint64 denominator = 1000; + sint64 queryId = qpayhub.startAndResolvePriceQuery(numerator, denominator); + + QPAYHUB::NotifyQuUsdPriceReply_input input{}; + input.queryId = queryId; + input.subscriptionId = -1; + input.status = ORACLE_QUERY_STATUS_SUCCESS; + // Deliberately left as garbage: NotifyQuUsdPriceReply must fetch the + // reply itself via qpi.getOracleReply(), not trust this field. + input.reply.numerator = -1; + input.reply.denominator = -1; + + qpayhub.invokeNotifyQuUsdPriceReply(input); + + EXPECT_EQ(qpayhub.state()->quUsdNumerator, numerator); + EXPECT_EQ(qpayhub.state()->quUsdDenominator, denominator); + EXPECT_EQ(qpayhub.state()->quUsdUpdatedTick, (uint32)system.tick); + + auto price = qpayhub.getQuUsdPrice(); + EXPECT_EQ(price.numerator, numerator); + EXPECT_EQ(price.denominator, denominator); + EXPECT_FALSE(price.stale); +} + +TEST(ContractQPayhub, NotifyQuUsdPriceReplyWithInvalidResolvedReplyIsIgnored) +{ + ContractTestingQPayhub qpayhub; + + // denominator == 0 fails OI::Price::replyIsValid(). + sint64 queryId = qpayhub.startAndResolvePriceQuery(1234, 0); + + QPAYHUB::NotifyQuUsdPriceReply_input input{}; + input.queryId = queryId; + input.subscriptionId = -1; + input.status = ORACLE_QUERY_STATUS_SUCCESS; + + qpayhub.invokeNotifyQuUsdPriceReply(input); + + EXPECT_EQ(qpayhub.state()->quUsdDenominator, 0); + EXPECT_TRUE(qpayhub.getQuUsdPrice().stale); +} From 4f6d1f57ff62009b19f5c92e287392f6dee08770 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Thu, 6 Aug 2026 02:35:13 +0100 Subject: [PATCH 4/8] fix: gtest, comment and some numerical --- src/contract_core/contract_def.h | 2 +- src/contracts/QPayhub.h | 12 +++++----- test/contract_qpayhub.cpp | 38 +++++++++++++++++++------------- 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/src/contract_core/contract_def.h b/src/contract_core/contract_def.h index 2fffeb72..ee94c8a5 100644 --- a/src/contract_core/contract_def.h +++ b/src/contract_core/contract_def.h @@ -428,7 +428,7 @@ constexpr struct ContractDescription {"QUSINO", 208, 10000, sizeof(QUSINO::StateData)}, // proposal in epoch 206, IPO in 207, construction and first use in 208 {"ESCROW", 210, 10000, sizeof(ESCROW::StateData)}, // proposal in epoch 208, IPO in 209, construction and first use in 210 {"GGWP", 218, 10000, sizeof(WOLFPACK::StateData)}, // proposal in epoch 216, IPO in 217, construction and first use in 218 - {"QPAYHUB", 226, 10000, sizeof(QPAYHUB::StateData)}, // proposal in epoch 224, IPO in 225, construction and first use in 226 + {"QPAYHUB", 228, 10000, sizeof(QPAYHUB::StateData)}, // proposal in epoch 226, IPO in 227, construction and first use in 228 // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES {"TESTEXA", 138, 10000, sizeof(TESTEXA::StateData)}, diff --git a/src/contracts/QPayhub.h b/src/contracts/QPayhub.h index a43239c7..1c512859 100644 --- a/src/contracts/QPayhub.h +++ b/src/contracts/QPayhub.h @@ -23,9 +23,9 @@ using namespace QPI; // payments with zero registration. // // FEES -// Protocol fee is QPAY_FEE_PERMILLE of each payment (1%) or +// Protocol fee is QPAY_FEE_PERMILLE of each payment (0.75%) or // QPAY_FEE_FLOOR_QU (100 QU), whichever is greater - modeled like a card -// processor's percentage-plus-minimum, so a sale small enough that 1% +// processor's percentage-plus-minimum, so a sale small enough that 0.75% // would round to near-nothing still pays a floor rather than the // protocol effectively processing it for free. The floor can never push // fee above the paid amount itself (see Pay_locals.fee clamp) - the @@ -43,8 +43,8 @@ using namespace QPI; // same as pricing near a card processor's minimum fee - not a bug, but // worth flagging: this project's OTHER default, QUBIC_PRICE_PER_CALL in // the off-chain facilitator's .env, defaults to exactly 1000 QU, which -// nets a seller 900 QU under this fee once routed through QPAY (1% of -// 1000 is 10, so the 100 QU floor applies). Anyone pricing per-call +// nets a seller 900 QU under this fee once routed through QPAY (0.75% of +// 1000 is 7, so the 100 QU floor applies). Anyone pricing per-call // sales through QPAY should be aware the floor, not the percentage, // dominates below 10,000 QU. // @@ -494,8 +494,8 @@ struct QPAYHUB : public ContractBase return; } - // 1% or QPAYHUB_FEE_FLOOR_QU, whichever is greater. Floor and minimum - // are both 100, so a payment at the minimum nets the seller zero. + // 0.75% or QPAYHUB_FEE_FLOOR_QU, whichever is greater. Floor and + // minimum are both 100, so a payment at the minimum nets the seller zero. locals.fee = (sint64)div((uint64)locals.amount * QPAYHUB_FEE_PERMILLE, (uint64)10000); if (locals.fee < QPAYHUB_FEE_FLOOR_QU) { diff --git a/test/contract_qpayhub.cpp b/test/contract_qpayhub.cpp index f9a2aba8..25d097ab 100644 --- a/test/contract_qpayhub.cpp +++ b/test/contract_qpayhub.cpp @@ -282,7 +282,7 @@ TEST(ContractQPayhub, PayHappyPathRecordsReceiptAndPaysSellerNetOfFee) increaseEnergy(BUYER1, 10000000); const sint64 amount = 1000000; - const sint64 expectedFee = 10000; // 1% of 1,000,000 + const sint64 expectedFee = 7500; // 0.75% of 1,000,000 const sint64 expectedNet = amount - expectedFee; const sint64 sellerBalanceBefore = getBalance(SELLER1); @@ -366,40 +366,48 @@ TEST(ContractQPayhub, PayDuplicateKeyRefundsAndRejects) EXPECT_EQ(qpayhub.getInfo().receiptCount, 1ULL); } -TEST(ContractQPayhub, PayFeeFloorDominatesBelowOnePercentThreshold) +TEST(ContractQPayhub, PayFeeFloorDominatesBelowPercentThreshold) { ContractTestingQPayhub qpayhub; increaseEnergy(BUYER1, 10000000); - // 1% of 5000 is 50, below the 100 QU floor, so the floor applies. + // 0.75% of 5000 is 37 (5000*75/10000), below the 100 QU floor, so the floor applies. auto output = qpayhub.pay(BUYER1, SELLER1, RESOURCE1, 1, 5000); EXPECT_EQ(output.returnCode, QPAYHUB_OK); EXPECT_EQ(output.fee, QPAYHUB_FEE_FLOOR_QU); EXPECT_EQ(output.net, 5000 - QPAYHUB_FEE_FLOOR_QU); } -TEST(ContractQPayhub, PayFeePercentDominatesAboveOnePercentThreshold) +TEST(ContractQPayhub, PayFeePercentDominatesAbovePercentThreshold) { ContractTestingQPayhub qpayhub; increaseEnergy(BUYER1, 10000000); - // 1% of 20000 is 200, above the 100 QU floor, so the percentage applies. + // 0.75% of 20000 is 150 (20000*75/10000), above the 100 QU floor, so the percentage applies. auto output = qpayhub.pay(BUYER1, SELLER1, RESOURCE1, 1, 20000); EXPECT_EQ(output.returnCode, QPAYHUB_OK); - EXPECT_EQ(output.fee, 200); - EXPECT_EQ(output.net, 20000 - 200); + EXPECT_EQ(output.fee, 150); + EXPECT_EQ(output.net, 20000 - 150); } -TEST(ContractQPayhub, PayFeeExactTieBetweenFloorAndPercent) +TEST(ContractQPayhub, PayFeeBoundaryJustBelowAndAboveFloor) { ContractTestingQPayhub qpayhub; increaseEnergy(BUYER1, 10000000); - // 1% of 10000 is exactly 100, equal to the floor. - auto output = qpayhub.pay(BUYER1, SELLER1, RESOURCE1, 1, 10000); - EXPECT_EQ(output.returnCode, QPAYHUB_OK); - EXPECT_EQ(output.fee, 100); - EXPECT_EQ(output.net, 9900); + // 0.75% of 13200 is exactly 99 (13200*75/10000), just below the 100 QU + // floor, so the floor applies. + auto below = qpayhub.pay(BUYER1, SELLER1, RESOURCE1, 1, 13200); + EXPECT_EQ(below.returnCode, QPAYHUB_OK); + EXPECT_EQ(below.fee, 100); + EXPECT_EQ(below.net, 13200 - 100); + + // 0.75% of 13600 is exactly 102 (13600*75/10000), just above the 100 QU + // floor, so the percentage applies. + auto above = qpayhub.pay(BUYER1, SELLER1, RESOURCE1, 2, 13600); + EXPECT_EQ(above.returnCode, QPAYHUB_OK); + EXPECT_EQ(above.fee, 102); + EXPECT_EQ(above.net, 13600 - 102); } TEST(ContractQPayhub, PayAtMinimumPaymentClampsFeeToAmountForZeroNet) @@ -511,8 +519,8 @@ TEST(ContractQPayhub, GetInfoReflectsRunningTotalsAndConstants) EXPECT_EQ(info.receiptCount, 3ULL); EXPECT_EQ(info.totalPayments, 3ULL); EXPECT_EQ(info.totalVolume, 70000ULL); - EXPECT_EQ(info.totalFeesCollected, 700ULL); // 200 + 300 + 200 - EXPECT_EQ(info.feePool, 700); + EXPECT_EQ(info.totalFeesCollected, 525ULL); // 150 + 225 + 150 + EXPECT_EQ(info.feePool, 525); EXPECT_EQ(info.totalConsumed, 1ULL); } From 7e7928f2c4e18848e040441ec91bf806a7e41811 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Mon, 10 Aug 2026 17:33:56 +0100 Subject: [PATCH 5/8] feat: promo rate admin capability for QPay Adds operator/recovery-gated promo pricing (SetPromoRate/RemovePromoRate/ChangeOperator/GetPromoRate), ported and adapted from profitphil/qubic-x402 PR #3 after a security review of the access-control and rate-bound logic. Discounted rates are clamped to [0.25%, 0.75%] and can never exceed the standard fee. Adds matching GoogleTest coverage for the new procedures. --- src/contracts/QPayhub.h | 265 ++++++++++++++++++++++++++++++++++-- test/contract_qpayhub.cpp | 273 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 517 insertions(+), 21 deletions(-) diff --git a/src/contracts/QPayhub.h b/src/contracts/QPayhub.h index 1c512859..f645686f 100644 --- a/src/contracts/QPayhub.h +++ b/src/contracts/QPayhub.h @@ -46,7 +46,15 @@ using namespace QPI; // nets a seller 900 QU under this fee once routed through QPAY (0.75% of // 1000 is 7, so the 100 QU floor applies). Anyone pricing per-call // sales through QPAY should be aware the floor, not the percentage, -// dominates below 10,000 QU. +// dominates below 13,334 QU. +// +// PROMO RATES ADDITION: a specific seller can be given a discounted rate +// below QPAYHUB_FEE_PERMILLE via SetPromoRate (search PROMO RATES ADDITION +// below). This is the one on-chain concession to the admin-free design +// described below - see that section for the operator/recovery model and +// why the discount is bounded rather than open-ended. The 100 QU floor is +// untouched by a promo rate; only the percentage is discounted, never the +// dust floor. // // TEST STATUS (append-only, keep accurate) // Pay/Consume/GetReceipt/ComputeReceiptKey/GetInfo/END_EPOCH/ @@ -70,6 +78,12 @@ using namespace QPI; // to contract/test/contract_qpay.cpp for floor-dominates, percent- // dominates, and the exact-tie amount, modeled directly on the existing // PayHappyPathFeeSplitAcrossAmounts test - reviewed by hand, not run. +// SetPromoRate/RemovePromoRate/ChangeOperator/GetPromoRate and Pay's +// promo-rate lookup (search PROMO RATES ADDITION below): ported from +// profitphil/qubic-x402 PR #3 (feature/qpay-promo-rates), reviewed for +// access-control/bounds correctness, and re-tested against this file's +// own GoogleTest suite (test/contract_qpayhub.cpp) - see that file's +// Promo/Operator/Recovery test cases. // // WHY THE ORACLE ADDITIONS EXIST // src/priceOracle.js (the off-chain USD->QU pricing used by the POS/ @@ -96,6 +110,23 @@ using namespace QPI; // an expired subscription) without being able to redirect the feed to a // bogus source. Permissionless renewal, not permissioned control. // +// UPDATE - PROMO RATES ADDITION: this is no longer fully accurate. A +// merchant promo-pricing feature (early-adopter discounted rates) needed +// a real privileged actor - there is no way to single out one seller for +// a better rate without someone authorized to say which seller and what +// rate. Rather than pretend that requirement doesn't exist, it is scoped +// as tightly as this file's other design choices are: two keys, not one - +// operatorId (day-to-day SetPromoRate/RemovePromoRate) and recoveryId +// (can only reassign operatorId via ChangeOperator, so a compromised +// operator key can be rotated out without redeploying the contract). +// Neither key can push a seller's rate below QPAYHUB_PROMO_FLOOR_PERMILLE +// - that floor is a hardcoded constexpr, not admin-settable, specifically +// so a compromised or malicious key's worst case is bounded and known in +// advance rather than "the operator can waive fees entirely." Every +// promo rate is publicly readable via GetPromoRate - discounted pricing +// is visible on-chain, not a private side deal. See PROMO RATES ADDITION +// below for the full mechanism. +// // VERIFIED BEFORE WRITING THIS: // - The oracle interaction pattern (QUERY_ORACLE/SUBSCRIBE_ORACLE macros, // the OracleNotificationInput callback shape, fee functions) is copied @@ -143,6 +174,13 @@ constexpr sint64 QPAYHUB_EXEC_RESERVE = 1000000; // never distributed, QU constexpr uint64 QPAYHUB_DIVIDEND_SHAREHOLDER_PERMILLE = 100; // 100 per 1000 = 10% constexpr uint64 QPAYHUB_TOKEN_ASSETNAME = 1497452625ULL; // "QPAY" +// ---- PROMO RATES ADDITION: constants ---- +// A promo rate can only ever discount, never below this floor and never +// above the standard rate - see the header's UPDATE - PROMO RATES ADDITION +// note above for why this is hardcoded rather than admin-settable. +constexpr uint64 QPAYHUB_PROMO_CAPACITY = 32; // concurrent promo sellers; grow via redeploy, not a live concern at launch scale +constexpr uint64 QPAYHUB_PROMO_FLOOR_PERMILLE = 25; // 25 per 10000 = 0.25 percent, the deepest discount any operator key can grant + // ---- ORACLE PRICE FEED ADDITIONS: constants ---- // A 16-minute renewal period sits in the efficient tier of the fee table // (1,784 QU) - frequent enough for an invoice payment window, rare @@ -161,6 +199,8 @@ constexpr uint32 QPAYHUB_PRICE_STALE_TICKS = 4000; // roughly 20-25 min at curre static_assert((QPAYHUB_RECEIPT_CAPACITY & (QPAYHUB_RECEIPT_CAPACITY - 1)) == 0); static_assert(QPAYHUB_FEE_PERMILLE < 10000); static_assert(QPAYHUB_DIVIDEND_SHAREHOLDER_PERMILLE <= 1000); +static_assert((QPAYHUB_PROMO_CAPACITY & (QPAYHUB_PROMO_CAPACITY - 1)) == 0); +static_assert(QPAYHUB_PROMO_FLOOR_PERMILLE <= QPAYHUB_FEE_PERMILLE); // Return codes — append-only, never renumber. constexpr uint64 QPAYHUB_OK = 0; @@ -173,6 +213,7 @@ constexpr uint64 QPAYHUB_ERR_ACCESS_DENIED = 6; constexpr uint64 QPAYHUB_ERR_ALREADY_CONSUMED = 7; constexpr uint64 QPAYHUB_ERR_ALREADY_SUBSCRIBED = 8; constexpr uint64 QPAYHUB_ERR_SUBSCRIBE_FAILED = 9; +constexpr uint64 QPAYHUB_ERR_INVALID_RATE = 10; // SetPromoRate's feePermille outside [QPAYHUB_PROMO_FLOOR_PERMILLE, QPAYHUB_FEE_PERMILLE] struct QPAYHUB2 { @@ -227,6 +268,14 @@ struct QPAYHUB : public ContractBase Asset dividendToken; uint64 totalShareholderDividends; uint64 totalTokenholderDividends; + + // ---- PROMO RATES ADDITION: state ---- + // operatorId: day-to-day SetPromoRate/RemovePromoRate caller. + // recoveryId: can ONLY call ChangeOperator - narrow on purpose, see + // header note above. Both fixed in INITIALIZE to real identities. + id operatorId; + id recoveryId; + HashMap promoFeePermille; }; // ------------------------------------------------------------------ @@ -254,6 +303,7 @@ struct QPAYHUB : public ContractBase sint64 amount; sint64 fee; sint64 net; + uint64 feePermille; // QPAYHUB_FEE_PERMILLE, or this seller's promo rate if one is set - PROMO RATES ADDITION }; struct Consume_input @@ -376,6 +426,50 @@ struct QPAYHUB : public ContractBase OI::Price::OracleReply reply; }; + // ---- PROMO RATES ADDITION: I/O structs ---- + + struct GetPromoRate_input + { + id seller; + }; + struct GetPromoRate_output + { + uint64 feePermille; // this seller's effective rate right now + bit isPromo; // 1 = feePermille came from promoFeePermille, 0 = it's the standard QPAYHUB_FEE_PERMILLE + }; + struct GetPromoRate_locals + { + uint64 rate; + }; + + struct SetPromoRate_input + { + id seller; + uint64 feePermille; + }; + struct SetPromoRate_output + { + uint64 returnCode; + }; + + struct RemovePromoRate_input + { + id seller; + }; + struct RemovePromoRate_output + { + uint64 returnCode; + }; + + struct ChangeOperator_input + { + id newOperator; + }; + struct ChangeOperator_output + { + uint64 returnCode; + }; + // ------------------------------------------------------------------ // Functions (read-only) // ------------------------------------------------------------------ @@ -446,6 +540,24 @@ struct QPAYHUB : public ContractBase output.stale = (locals.age > QPAYHUB_PRICE_STALE_TICKS) ? 1 : 0; } + // ---- PROMO RATES ADDITION: read function ---- + // Permissionless, like every other read in this file - a promo rate is + // a public fact about a seller, not a private arrangement between that + // seller and the operator. + PUBLIC_FUNCTION_WITH_LOCALS(GetPromoRate) + { + if (state.get().promoFeePermille.get(input.seller, locals.rate)) + { + output.feePermille = locals.rate; + output.isPromo = 1; + } + else + { + output.feePermille = QPAYHUB_FEE_PERMILLE; + output.isPromo = 0; + } + } + // ------------------------------------------------------------------ // Procedures // ------------------------------------------------------------------ @@ -494,9 +606,19 @@ struct QPAYHUB : public ContractBase return; } - // 0.75% or QPAYHUB_FEE_FLOOR_QU, whichever is greater. Floor and - // minimum are both 100, so a payment at the minimum nets the seller zero. - locals.fee = (sint64)div((uint64)locals.amount * QPAYHUB_FEE_PERMILLE, (uint64)10000); + // PROMO RATES ADDITION: a seller with an entry in promoFeePermille + // pays that rate instead of the standard QPAYHUB_FEE_PERMILLE. The + // 100 QU floor below applies either way - a promo rate only + // discounts the percentage, never the dust floor. + if (!state.get().promoFeePermille.get(input.seller, locals.feePermille)) + { + locals.feePermille = QPAYHUB_FEE_PERMILLE; + } + + // 0.75% (or the seller's promo rate) or QPAYHUB_FEE_FLOOR_QU, + // whichever is greater. Floor and minimum are both 100, so a + // payment at the minimum nets the seller zero. + locals.fee = (sint64)div((uint64)locals.amount * locals.feePermille, (uint64)10000); if (locals.fee < QPAYHUB_FEE_FLOOR_QU) { locals.fee = QPAYHUB_FEE_FLOOR_QU; @@ -625,6 +747,95 @@ struct QPAYHUB : public ContractBase output.returnCode = QPAYHUB_OK; } + // ---- PROMO RATES ADDITION: operator-gated procedures ---- + // operatorId-only. feePermille is clamped to + // [QPAYHUB_PROMO_FLOOR_PERMILLE, QPAYHUB_FEE_PERMILLE] - never below the + // hardcoded floor, and never above the standard rate (this is a discount + // mechanism, not a way to charge one seller more than everyone else). + PUBLIC_PROCEDURE(SetPromoRate) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + if (qpi.invocator() != state.get().operatorId) + { + output.returnCode = QPAYHUB_ERR_ACCESS_DENIED; + return; + } + if (input.seller == NULL_ID || input.seller == SELF) + { + output.returnCode = QPAYHUB_ERR_INVALID_SELLER; + return; + } + if (input.feePermille < QPAYHUB_PROMO_FLOOR_PERMILLE || input.feePermille > QPAYHUB_FEE_PERMILLE) + { + output.returnCode = QPAYHUB_ERR_INVALID_RATE; + return; + } + // Only a NEW seller consumes a capacity slot; updating an existing + // seller's rate is a plain overwrite and always allowed. + if (!state.get().promoFeePermille.contains(input.seller) + && state.get().promoFeePermille.population() >= QPAYHUB_PROMO_CAPACITY) + { + output.returnCode = QPAYHUB_ERR_CAPACITY; + return; + } + state.mut().promoFeePermille.set(input.seller, input.feePermille); + output.returnCode = QPAYHUB_OK; + } + + // operatorId-only. Reverting a seller to the standard rate is always + // free of the capacity check above - it can only shrink the table. + PUBLIC_PROCEDURE(RemovePromoRate) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + if (qpi.invocator() != state.get().operatorId) + { + output.returnCode = QPAYHUB_ERR_ACCESS_DENIED; + return; + } + if (!state.get().promoFeePermille.contains(input.seller)) + { + output.returnCode = QPAYHUB_ERR_NOT_FOUND; + return; + } + state.mut().promoFeePermille.removeByKey(input.seller); + output.returnCode = QPAYHUB_OK; + } + + // Callable by the CURRENT operator (planned rotation, e.g. moving to a + // new wallet) or by recoveryId (emergency override when the operator + // key is lost or compromised and cannot be trusted to rotate itself). + // recoveryId itself is fixed in INITIALIZE and has no procedure that + // can ever change it - rotating recoveryId requires a redeploy, same + // tier of rare as changing QPAYHUB_FEE_PERMILLE itself. See the + // header's UPDATE - PROMO RATES ADDITION note for why this is + // deliberately not a single self-reassigning key. + PUBLIC_PROCEDURE(ChangeOperator) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + if (qpi.invocator() != state.get().operatorId + && qpi.invocator() != state.get().recoveryId) + { + output.returnCode = QPAYHUB_ERR_ACCESS_DENIED; + return; + } + if (input.newOperator == NULL_ID) + { + output.returnCode = QPAYHUB_ERR_INVALID_SELLER; + return; + } + state.mut().operatorId = input.newOperator; + output.returnCode = QPAYHUB_OK; + } + // ---- ORACLE PRICE FEED ADDITIONS: notification callback ---- // Fires whenever the subscription produces a new reply. Only a // confirmed SUCCESS with a sane reply updates state - everything else @@ -655,10 +866,14 @@ struct QPAYHUB : public ContractBase REGISTER_USER_FUNCTION(ComputeReceiptKey, 2); REGISTER_USER_FUNCTION(GetInfo, 3); REGISTER_USER_FUNCTION(GetQuUsdPrice, 4); + REGISTER_USER_FUNCTION(GetPromoRate, 5); REGISTER_USER_PROCEDURE(Pay, 1); REGISTER_USER_PROCEDURE(Consume, 2); REGISTER_USER_PROCEDURE(SubscribeToPriceFeed, 3); + REGISTER_USER_PROCEDURE(SetPromoRate, 4); + REGISTER_USER_PROCEDURE(RemovePromoRate, 5); + REGISTER_USER_PROCEDURE(ChangeOperator, 6); REGISTER_USER_PROCEDURE_NOTIFICATION(NotifyQuUsdPriceReply); } @@ -668,17 +883,43 @@ struct QPAYHUB : public ContractBase state.mut().priceOracleSubscriptionId = -1; state.mut().quUsdDenominator = 0; // 0 = no price known yet, GetQuUsdPrice reports stale - // DEVNET issuer - must be re-pointed before mainnet deployment. + // QPAY token issuer: QPAYNOWSWZMGHFEAEVJXGZAVSHABAZDDBDIHTEBOPCOGHRGBCYCUZOHCVLXG state.mut().dividendToken.issuer = ID( - _Q, _D, _O, _G, _E, _E, _E, _S, - _K, _Y, _P, _A, _I, _C, _E, _C, - _H, _E, _A, _H, _O, _X, _P, _U, - _L, _E, _O, _A, _D, _T, _K, _G, - _E, _J, _H, _A, _V, _Y, _P, _F, - _K, _H, _L, _E, _W, _G, _X, _X, - _Z, _Q, _U, _G, _I, _G, _M, _B + _Q, _P, _A, _Y, _N, _O, _W, _S, + _W, _Z, _M, _G, _H, _F, _E, _A, + _E, _V, _J, _X, _G, _Z, _A, _V, + _S, _H, _A, _B, _A, _Z, _D, _D, + _B, _D, _I, _H, _T, _E, _B, _O, + _P, _C, _O, _G, _H, _R, _G, _B, + _C, _Y, _C, _U, _Z, _O, _H, _C ); state.mut().dividendToken.assetName = QPAYHUB_TOKEN_ASSETNAME; + + // ---- PROMO RATES ADDITION: operator/recovery init ---- + // Real identities ported from profitphil/qubic-x402 PR #3 - NOT + // independently verifiable against their intended 60-character + // source identities the way the QPAY issuer above was (that one + // was cross-checked against a string this session was given + // directly). Re-verify both before relying on them for a live + // deployment. + state.mut().operatorId = ID( + _I, _Q, _D, _Z, _L, _W, _S, _S, + _Q, _O, _G, _R, _F, _D, _W, _D, + _I, _N, _C, _E, _O, _Q, _W, _H, + _M, _F, _A, _A, _Z, _C, _G, _T, + _N, _E, _R, _H, _A, _Z, _K, _F, + _M, _A, _G, _L, _E, _J, _I, _Z, + _I, _K, _O, _E, _Z, _X, _Y, _G + ); + state.mut().recoveryId = ID( + _L, _A, _W, _L, _X, _E, _I, _B, + _Q, _H, _G, _W, _O, _G, _K, _U, + _F, _K, _X, _Q, _L, _B, _K, _Q, + _D, _M, _Z, _A, _I, _J, _Z, _L, + _V, _E, _U, _C, _T, _F, _B, _C, + _D, _A, _Z, _M, _N, _C, _S, _W, + _W, _J, _Q, _Y, _L, _J, _C, _B + ); } POST_INCOMING_TRANSFER() diff --git a/test/contract_qpayhub.cpp b/test/contract_qpayhub.cpp index 25d097ab..0d25e7d9 100644 --- a/test/contract_qpayhub.cpp +++ b/test/contract_qpayhub.cpp @@ -11,20 +11,25 @@ static const id RESOURCE1(101, 101, 101, 101); static const id RESOURCE2(202, 202, 202, 202); static const id SHAREHOLDER1(301, 301, 301, 301); static const id TOKENHOLDER1(401, 401, 401, 401); +static const id OPERATOR1(501, 501, 501, 501); +static const id RECOVERY1(601, 601, 601, 601); +static const id IMPOSTOR1(701, 701, 701, 701); +static const id NEWOPERATOR1(801, 801, 801, 801); static const id QPAYHUB_CONTRACT_ID(QPAYHUB_CONTRACT_INDEX, 0, 0, 0); // The exact issuer identity QPayhub.h's INITIALIZE() hardcodes for the QPAY -// dividend token (devnet placeholder). Copied verbatim so tests can issue the -// matching "QPAY" asset via QX and exercise the token-holder dividend path. +// dividend token: QPAYNOWSWZMGHFEAEVJXGZAVSHABAZDDBDIHTEBOPCOGHRGBCYCUZOHCVLXG. +// Copied verbatim so tests can issue the matching "QPAY" asset via QX and +// exercise the token-holder dividend path. static const id QPAYHUB_DIVIDEND_TOKEN_ISSUER = ID( - _Q, _D, _O, _G, _E, _E, _E, _S, - _K, _Y, _P, _A, _I, _C, _E, _C, - _H, _E, _A, _H, _O, _X, _P, _U, - _L, _E, _O, _A, _D, _T, _K, _G, - _E, _J, _H, _A, _V, _Y, _P, _F, - _K, _H, _L, _E, _W, _G, _X, _X, - _Z, _Q, _U, _G, _I, _G, _M, _B + _Q, _P, _A, _Y, _N, _O, _W, _S, + _W, _Z, _M, _G, _H, _F, _E, _A, + _E, _V, _J, _X, _G, _Z, _A, _V, + _S, _H, _A, _B, _A, _Z, _D, _D, + _B, _D, _I, _H, _T, _E, _B, _O, + _P, _C, _O, _G, _H, _R, _G, _B, + _C, _Y, _C, _U, _Z, _O, _H, _C ); // Exposes state fields directly (same pattern as StateCheckerTestExampleA in @@ -154,6 +159,38 @@ class ContractTestingQPayhub : protected ContractTesting return output; } + QPAYHUB::GetPromoRate_output getPromoRate(const id& seller) + { + QPAYHUB::GetPromoRate_input input{ seller }; + QPAYHUB::GetPromoRate_output output; + callFunction(QPAYHUB_CONTRACT_INDEX, 5, input, output); + return output; + } + + QPAYHUB::SetPromoRate_output setPromoRate(const id& invocator, const id& seller, uint64 feePermille, sint64 reward = 0) + { + QPAYHUB::SetPromoRate_input input{ seller, feePermille }; + QPAYHUB::SetPromoRate_output output; + invokeUserProcedure(QPAYHUB_CONTRACT_INDEX, 4, input, output, invocator, reward); + return output; + } + + QPAYHUB::RemovePromoRate_output removePromoRate(const id& invocator, const id& seller, sint64 reward = 0) + { + QPAYHUB::RemovePromoRate_input input{ seller }; + QPAYHUB::RemovePromoRate_output output; + invokeUserProcedure(QPAYHUB_CONTRACT_INDEX, 5, input, output, invocator, reward); + return output; + } + + QPAYHUB::ChangeOperator_output changeOperator(const id& invocator, const id& newOperator, sint64 reward = 0) + { + QPAYHUB::ChangeOperator_input input{ newOperator }; + QPAYHUB::ChangeOperator_output output; + invokeUserProcedure(QPAYHUB_CONTRACT_INDEX, 6, input, output, invocator, reward); + return output; + } + // Directly invokes the private oracle-reply notification callback exactly // as qubic.cpp's contract processor does for USER_PROCEDURE_NOTIFICATION_CALL // (see QpiContextUserProcedureNotificationCall usage at qubic.cpp:2357). @@ -808,3 +845,221 @@ TEST(ContractQPayhub, NotifyQuUsdPriceReplyWithInvalidResolvedReplyIsIgnored) EXPECT_EQ(qpayhub.state()->quUsdDenominator, 0); EXPECT_TRUE(qpayhub.getQuUsdPrice().stale); } + +TEST(ContractQPayhub, GetPromoRateReflectsStandardRateWhenUnset) +{ + ContractTestingQPayhub qpayhub; + + auto rate = qpayhub.getPromoRate(SELLER1); + EXPECT_EQ(rate.feePermille, QPAYHUB_FEE_PERMILLE); + EXPECT_EQ(rate.isPromo, 0); +} + +TEST(ContractQPayhub, SetPromoRateByOperatorAffectsPayAndGetPromoRate) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->operatorId = OPERATOR1; + increaseEnergy(OPERATOR1, 10000000); + increaseEnergy(BUYER1, 10000000); + + // 0.50%, between the 0.25% floor and the 0.75% standard rate. + auto setOut = qpayhub.setPromoRate(OPERATOR1, SELLER1, 50); + EXPECT_EQ(setOut.returnCode, QPAYHUB_OK); + + auto rate = qpayhub.getPromoRate(SELLER1); + EXPECT_EQ(rate.feePermille, 50ULL); + EXPECT_EQ(rate.isPromo, 1); + + // A different seller is unaffected - the override is per-seller, not global. + auto otherRate = qpayhub.getPromoRate(SELLER2); + EXPECT_EQ(otherRate.feePermille, QPAYHUB_FEE_PERMILLE); + EXPECT_EQ(otherRate.isPromo, 0); + + // amount=1,000,000: standard fee would be 7500 (0.75%); the promo rate + // must actually be applied by Pay(), not just stored. + auto payOut = qpayhub.pay(BUYER1, SELLER1, RESOURCE1, 1, 1000000); + EXPECT_EQ(payOut.returnCode, QPAYHUB_OK); + EXPECT_EQ(payOut.fee, 5000); // 0.50% of 1,000,000 + EXPECT_LT(payOut.fee, 7500); // strictly cheaper than the standard rate +} + +TEST(ContractQPayhub, SetPromoRateByNonOperatorRejectedAndRefunded) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->operatorId = OPERATOR1; + increaseEnergy(IMPOSTOR1, 10000000); + + const sint64 balanceBefore = getBalance(IMPOSTOR1); + auto out = qpayhub.setPromoRate(IMPOSTOR1, SELLER1, 50, 1000); + + EXPECT_EQ(out.returnCode, QPAYHUB_ERR_ACCESS_DENIED); + EXPECT_EQ(getBalance(IMPOSTOR1), balanceBefore); + EXPECT_EQ(qpayhub.getPromoRate(SELLER1).isPromo, 0); // rejected call never touched state +} + +TEST(ContractQPayhub, SetPromoRateInvalidSellerRejected) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->operatorId = OPERATOR1; + increaseEnergy(OPERATOR1, 10000000); + + EXPECT_EQ(qpayhub.setPromoRate(OPERATOR1, NULL_ID, 50).returnCode, QPAYHUB_ERR_INVALID_SELLER); + EXPECT_EQ(qpayhub.setPromoRate(OPERATOR1, QPAYHUB_CONTRACT_ID, 50).returnCode, QPAYHUB_ERR_INVALID_SELLER); +} + +TEST(ContractQPayhub, SetPromoRateBelowFloorRejected) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->operatorId = OPERATOR1; + increaseEnergy(OPERATOR1, 10000000); + + auto below = qpayhub.setPromoRate(OPERATOR1, SELLER1, QPAYHUB_PROMO_FLOOR_PERMILLE - 1); + EXPECT_EQ(below.returnCode, QPAYHUB_ERR_INVALID_RATE); + EXPECT_EQ(qpayhub.getPromoRate(SELLER1).isPromo, 0); + + auto atFloor = qpayhub.setPromoRate(OPERATOR1, SELLER1, QPAYHUB_PROMO_FLOOR_PERMILLE); + EXPECT_EQ(atFloor.returnCode, QPAYHUB_OK); + EXPECT_EQ(qpayhub.getPromoRate(SELLER1).feePermille, QPAYHUB_PROMO_FLOOR_PERMILLE); +} + +TEST(ContractQPayhub, SetPromoRateAboveStandardRejected) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->operatorId = OPERATOR1; + increaseEnergy(OPERATOR1, 10000000); + + // A promo rate can only ever discount - never charge a seller MORE + // than the standard rate everyone else pays. + auto out = qpayhub.setPromoRate(OPERATOR1, SELLER1, QPAYHUB_FEE_PERMILLE + 1); + EXPECT_EQ(out.returnCode, QPAYHUB_ERR_INVALID_RATE); + + // The standard rate itself is accepted (a no-op discount, but not an error). + auto atStandard = qpayhub.setPromoRate(OPERATOR1, SELLER1, QPAYHUB_FEE_PERMILLE); + EXPECT_EQ(atStandard.returnCode, QPAYHUB_OK); +} + +TEST(ContractQPayhub, RemovePromoRateRevertsToStandardFee) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->operatorId = OPERATOR1; + increaseEnergy(OPERATOR1, 10000000); + + ASSERT_EQ(qpayhub.setPromoRate(OPERATOR1, SELLER1, 50).returnCode, QPAYHUB_OK); + ASSERT_EQ(qpayhub.getPromoRate(SELLER1).isPromo, 1); + + auto removeOut = qpayhub.removePromoRate(OPERATOR1, SELLER1); + EXPECT_EQ(removeOut.returnCode, QPAYHUB_OK); + + auto rate = qpayhub.getPromoRate(SELLER1); + EXPECT_EQ(rate.feePermille, QPAYHUB_FEE_PERMILLE); + EXPECT_EQ(rate.isPromo, 0); + + // Removing an already-absent entry is a clean not-found, not a crash. + auto removeAgain = qpayhub.removePromoRate(OPERATOR1, SELLER1); + EXPECT_EQ(removeAgain.returnCode, QPAYHUB_ERR_NOT_FOUND); +} + +TEST(ContractQPayhub, RemovePromoRateByNonOperatorRejected) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->operatorId = OPERATOR1; + increaseEnergy(OPERATOR1, 10000000); + increaseEnergy(IMPOSTOR1, 10000000); + ASSERT_EQ(qpayhub.setPromoRate(OPERATOR1, SELLER1, 50).returnCode, QPAYHUB_OK); + + auto out = qpayhub.removePromoRate(IMPOSTOR1, SELLER1); + EXPECT_EQ(out.returnCode, QPAYHUB_ERR_ACCESS_DENIED); + EXPECT_EQ(qpayhub.getPromoRate(SELLER1).isPromo, 1); // untouched +} + +TEST(ContractQPayhub, PromoRateCapacityEnforcedButUpdatesAlwaysAllowed) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->operatorId = OPERATOR1; + increaseEnergy(OPERATOR1, 10000000); + + // Fill every promo slot with distinct sellers. + id sellers[QPAYHUB_PROMO_CAPACITY]; + for (uint64 i = 0; i < QPAYHUB_PROMO_CAPACITY; ++i) + { + sellers[i] = id::randomValue(); + auto out = qpayhub.setPromoRate(OPERATOR1, sellers[i], 50); + ASSERT_EQ(out.returnCode, QPAYHUB_OK); + } + EXPECT_EQ(qpayhub.state()->promoFeePermille.population(), QPAYHUB_PROMO_CAPACITY); + + // A brand-new seller is rejected once every slot is taken. + const id overflow = id::randomValue(); + auto overflowOut = qpayhub.setPromoRate(OPERATOR1, overflow, 50); + EXPECT_EQ(overflowOut.returnCode, QPAYHUB_ERR_CAPACITY); + EXPECT_EQ(qpayhub.getPromoRate(overflow).isPromo, 0); // rejected, never inserted + + // Updating an EXISTING seller's rate is a free overwrite - it must + // still succeed even while every slot is already taken. + auto updateOut = qpayhub.setPromoRate(OPERATOR1, sellers[0], 60); + EXPECT_EQ(updateOut.returnCode, QPAYHUB_OK); + EXPECT_EQ(qpayhub.getPromoRate(sellers[0]).feePermille, 60ULL); + EXPECT_EQ(qpayhub.state()->promoFeePermille.population(), QPAYHUB_PROMO_CAPACITY); // unchanged - no new slot consumed + + // Removing one entry frees a slot for a genuinely new seller. + ASSERT_EQ(qpayhub.removePromoRate(OPERATOR1, sellers[0]).returnCode, QPAYHUB_OK); + EXPECT_EQ(qpayhub.state()->promoFeePermille.population(), QPAYHUB_PROMO_CAPACITY - 1); + EXPECT_EQ(qpayhub.setPromoRate(OPERATOR1, overflow, 50).returnCode, QPAYHUB_OK); +} + +TEST(ContractQPayhub, ChangeOperatorByCurrentOperatorSucceeds) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->operatorId = OPERATOR1; + increaseEnergy(OPERATOR1, 10000000); + increaseEnergy(NEWOPERATOR1, 10000000); + + auto out = qpayhub.changeOperator(OPERATOR1, NEWOPERATOR1); + EXPECT_EQ(out.returnCode, QPAYHUB_OK); + + // The old operator can no longer administer promo rates; the new one can. + EXPECT_EQ(qpayhub.setPromoRate(OPERATOR1, SELLER1, 50).returnCode, QPAYHUB_ERR_ACCESS_DENIED); + EXPECT_EQ(qpayhub.setPromoRate(NEWOPERATOR1, SELLER1, 50).returnCode, QPAYHUB_OK); +} + +TEST(ContractQPayhub, ChangeOperatorByRecoveryOverridesCompromisedOperator) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->operatorId = OPERATOR1; + qpayhub.state()->recoveryId = RECOVERY1; + increaseEnergy(OPERATOR1, 10000000); + increaseEnergy(RECOVERY1, 10000000); + increaseEnergy(NEWOPERATOR1, 10000000); + + // recoveryId can reassign operatorId even without the current operator's + // cooperation - the emergency-override path this whole mechanism exists for. + auto out = qpayhub.changeOperator(RECOVERY1, NEWOPERATOR1); + EXPECT_EQ(out.returnCode, QPAYHUB_OK); + EXPECT_EQ(qpayhub.setPromoRate(NEWOPERATOR1, SELLER1, 50).returnCode, QPAYHUB_OK); + EXPECT_EQ(qpayhub.setPromoRate(OPERATOR1, SELLER2, 50).returnCode, QPAYHUB_ERR_ACCESS_DENIED); // old operator locked out +} + +TEST(ContractQPayhub, ChangeOperatorByThirdPartyRejected) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->operatorId = OPERATOR1; + qpayhub.state()->recoveryId = RECOVERY1; + increaseEnergy(OPERATOR1, 10000000); + increaseEnergy(IMPOSTOR1, 10000000); + + auto out = qpayhub.changeOperator(IMPOSTOR1, IMPOSTOR1); + EXPECT_EQ(out.returnCode, QPAYHUB_ERR_ACCESS_DENIED); + // Neither operator nor recovery changed. + EXPECT_EQ(qpayhub.setPromoRate(OPERATOR1, SELLER1, 50).returnCode, QPAYHUB_OK); +} + +TEST(ContractQPayhub, ChangeOperatorRejectsNullNewOperator) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->operatorId = OPERATOR1; + increaseEnergy(OPERATOR1, 10000000); + + auto out = qpayhub.changeOperator(OPERATOR1, NULL_ID); + EXPECT_EQ(out.returnCode, QPAYHUB_ERR_INVALID_SELLER); + EXPECT_EQ(qpayhub.setPromoRate(OPERATOR1, SELLER1, 50).returnCode, QPAYHUB_OK); // operator unchanged +} From e54ba5e701e964c35b12945ea3abba9d5fe9e8ed Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Wed, 12 Aug 2026 15:56:19 +0100 Subject: [PATCH 6/8] feat: affiliate referral commission for QPay Adds affiliateRegistrarId-gated referral links (SetAffiliate/RemoveAffiliate/ChangeAffiliateRegistrar/GetAffiliate), ported and adapted from profitphil/qubic-x402 PR #3 after a security review. A referrer earns 5% of a referred seller's fee for 52 epochs (~1 year), taken out of the fee (never the seller's net or buyer's payment). Self-referral is blocked, first-attribution-wins, and the link expires/purges the same way receipts do. affiliateRegistrarId is a separate admin role from operatorId so a compromised key only ever touches one surface; recoveryId can reassign either. Adds matching GoogleTest coverage. --- src/contracts/QPayhub.h | 258 +++++++++++++++++++++++++++++++++++++- test/contract_qpayhub.cpp | 206 ++++++++++++++++++++++++++++++ 2 files changed, 463 insertions(+), 1 deletion(-) diff --git a/src/contracts/QPayhub.h b/src/contracts/QPayhub.h index f645686f..6ecb98e6 100644 --- a/src/contracts/QPayhub.h +++ b/src/contracts/QPayhub.h @@ -56,6 +56,14 @@ using namespace QPI; // untouched by a promo rate; only the percentage is discounted, never the // dust floor. // +// AFFILIATE ADDITION: a seller can be attributed to a referring affiliate +// via SetAffiliate (search AFFILIATE ADDITION below), who then earns +// QPAYHUB_AFFILIATE_COMMISSION_PERMILLE of that seller's fee - never the +// seller's net, never the buyer's payment - for QPAYHUB_AFFILIATE_TERM_EPOCHS +// epochs from the referral. A growth incentive for whoever brings a +// merchant onto QPAY, bounded and time-limited the same way promo rates +// are bounded by a floor. +// // TEST STATUS (append-only, keep accurate) // Pay/Consume/GetReceipt/ComputeReceiptKey/GetInfo/END_EPOCH/ // POST_INCOMING_TRANSFER: 11/11 GoogleTest unit + 19/19 live-chain @@ -84,6 +92,14 @@ using namespace QPI; // access-control/bounds correctness, and re-tested against this file's // own GoogleTest suite (test/contract_qpayhub.cpp) - see that file's // Promo/Operator/Recovery test cases. +// SetAffiliate/RemoveAffiliate/ChangeAffiliateRegistrar/GetAffiliate and +// Pay's affiliate-cut split (search AFFILIATE ADDITION below): ported +// from the same PR #3 branch after it grew affiliate referrals, reviewed +// for the same access-control/bounds properties (self-referral blocked, +// cut comes out of the fee not the seller's net, first-attribution-wins, +// term-expiry enforced in both Pay() and END_EPOCH), and re-tested +// against this file's own GoogleTest suite - see that file's Affiliate +// test cases. // // WHY THE ORACLE ADDITIONS EXIST // src/priceOracle.js (the off-chain USD->QU pricing used by the POS/ @@ -127,6 +143,14 @@ using namespace QPI; // is visible on-chain, not a private side deal. See PROMO RATES ADDITION // below for the full mechanism. // +// UPDATE - AFFILIATE ADDITION: a second, separate admin role - +// affiliateRegistrarId - was added the same way, deliberately not folded +// into operatorId, so a compromised key on one surface (promo rates) +// can't touch the other (affiliate attribution), and vice versa. +// recoveryId can reassign either via ChangeOperator or +// ChangeAffiliateRegistrar. Every affiliate link is publicly readable via +// GetAffiliate, same transparency rationale as promo rates above. +// // VERIFIED BEFORE WRITING THIS: // - The oracle interaction pattern (QUERY_ORACLE/SUBSCRIBE_ORACLE macros, // the OracleNotificationInput callback shape, fee functions) is copied @@ -181,6 +205,13 @@ constexpr uint64 QPAYHUB_TOKEN_ASSETNAME = 1497452625ULL; // "QPAY" constexpr uint64 QPAYHUB_PROMO_CAPACITY = 32; // concurrent promo sellers; grow via redeploy, not a live concern at launch scale constexpr uint64 QPAYHUB_PROMO_FLOOR_PERMILLE = 25; // 25 per 10000 = 0.25 percent, the deepest discount any operator key can grant +// ---- AFFILIATE ADDITION: constants ---- +// A referrer earns a slice of a referred seller's fee for a bounded term, +// not an open-ended cut - see the header's UPDATE - AFFILIATE ADDITION note. +constexpr uint64 QPAYHUB_AFFILIATE_CAPACITY = 128; // concurrent active affiliate links; grow via redeploy +constexpr uint64 QPAYHUB_AFFILIATE_COMMISSION_PERMILLE = 500; // 500 per 10000 = 5% of the fee, not the seller's net +constexpr uint32 QPAYHUB_AFFILIATE_TERM_EPOCHS = 52; // ~12 months at ~1 epoch/week + // ---- ORACLE PRICE FEED ADDITIONS: constants ---- // A 16-minute renewal period sits in the efficient tier of the fee table // (1,784 QU) - frequent enough for an invoice payment window, rare @@ -201,6 +232,8 @@ static_assert(QPAYHUB_FEE_PERMILLE < 10000); static_assert(QPAYHUB_DIVIDEND_SHAREHOLDER_PERMILLE <= 1000); static_assert((QPAYHUB_PROMO_CAPACITY & (QPAYHUB_PROMO_CAPACITY - 1)) == 0); static_assert(QPAYHUB_PROMO_FLOOR_PERMILLE <= QPAYHUB_FEE_PERMILLE); +static_assert((QPAYHUB_AFFILIATE_CAPACITY & (QPAYHUB_AFFILIATE_CAPACITY - 1)) == 0); +static_assert(QPAYHUB_AFFILIATE_COMMISSION_PERMILLE <= 10000); // Return codes — append-only, never renumber. constexpr uint64 QPAYHUB_OK = 0; @@ -214,6 +247,7 @@ constexpr uint64 QPAYHUB_ERR_ALREADY_CONSUMED = 7; constexpr uint64 QPAYHUB_ERR_ALREADY_SUBSCRIBED = 8; constexpr uint64 QPAYHUB_ERR_SUBSCRIBE_FAILED = 9; constexpr uint64 QPAYHUB_ERR_INVALID_RATE = 10; // SetPromoRate's feePermille outside [QPAYHUB_PROMO_FLOOR_PERMILLE, QPAYHUB_FEE_PERMILLE] +constexpr uint64 QPAYHUB_ERR_ALREADY_HAS_AFFILIATE = 11; // SetAffiliate: seller already has an attributed affiliate - remove it first struct QPAYHUB2 { @@ -244,6 +278,13 @@ struct QPAYHUB : public ContractBase uint64 nonce; }; + // ---- AFFILIATE ADDITION: referral record ---- + struct Affiliate + { + id affiliate; + uint32 referredAtEpoch; + }; + struct StateData { HashMap receipts; @@ -276,6 +317,14 @@ struct QPAYHUB : public ContractBase id operatorId; id recoveryId; HashMap promoFeePermille; + + // ---- AFFILIATE ADDITION: state ---- + // affiliateRegistrarId: day-to-day SetAffiliate/RemoveAffiliate caller, + // a separate role from operatorId so a compromised key only ever + // touches one of the two admin surfaces. recoveryId (above) can + // reassign this one too, via ChangeAffiliateRegistrar. + id affiliateRegistrarId; + HashMap affiliateOf; }; // ------------------------------------------------------------------ @@ -304,6 +353,8 @@ struct QPAYHUB : public ContractBase sint64 fee; sint64 net; uint64 feePermille; // QPAYHUB_FEE_PERMILLE, or this seller's promo rate if one is set - PROMO RATES ADDITION + Affiliate aff; // AFFILIATE ADDITION + sint64 affiliateCut; }; struct Consume_input @@ -382,6 +433,10 @@ struct QPAYHUB : public ContractBase uint64 shareholderPermille; uint64 totalShareholderDividends; uint64 totalTokenholderDividends; + // Likewise appended - AFFILIATE ADDITION. + uint64 affiliateCommissionPermille; + uint32 affiliateTermEpochs; + uint32 padding1; }; // ---- ORACLE PRICE FEED ADDITIONS: I/O structs ---- @@ -470,6 +525,55 @@ struct QPAYHUB : public ContractBase uint64 returnCode; }; + // ---- AFFILIATE ADDITION: I/O structs ---- + + struct GetAffiliate_input + { + id seller; + }; + struct GetAffiliate_output + { + id affiliate; + uint32 referredAtEpoch; + bit active; // within QPAYHUB_AFFILIATE_TERM_EPOCHS of referredAtEpoch + }; + struct GetAffiliate_locals + { + Affiliate aff; + }; + + struct SetAffiliate_input + { + id seller; + id affiliate; + }; + struct SetAffiliate_output + { + uint64 returnCode; + }; + struct SetAffiliate_locals + { + Affiliate a; + }; + + struct RemoveAffiliate_input + { + id seller; + }; + struct RemoveAffiliate_output + { + uint64 returnCode; + }; + + struct ChangeAffiliateRegistrar_input + { + id newRegistrar; + }; + struct ChangeAffiliateRegistrar_output + { + uint64 returnCode; + }; + // ------------------------------------------------------------------ // Functions (read-only) // ------------------------------------------------------------------ @@ -520,6 +624,9 @@ struct QPAYHUB : public ContractBase output.shareholderPermille = QPAYHUB_DIVIDEND_SHAREHOLDER_PERMILLE; output.totalShareholderDividends = state.get().totalShareholderDividends; output.totalTokenholderDividends = state.get().totalTokenholderDividends; + output.affiliateCommissionPermille = QPAYHUB_AFFILIATE_COMMISSION_PERMILLE; + output.affiliateTermEpochs = QPAYHUB_AFFILIATE_TERM_EPOCHS; + output.padding1 = 0; } // ---- ORACLE PRICE FEED ADDITIONS: read function ---- @@ -558,6 +665,24 @@ struct QPAYHUB : public ContractBase } } + // ---- AFFILIATE ADDITION: read function ---- + // Permissionless, same rationale as GetPromoRate above. + PUBLIC_FUNCTION_WITH_LOCALS(GetAffiliate) + { + if (state.get().affiliateOf.get(input.seller, locals.aff)) + { + output.affiliate = locals.aff.affiliate; + output.referredAtEpoch = locals.aff.referredAtEpoch; + output.active = (uint32)qpi.epoch() < locals.aff.referredAtEpoch + QPAYHUB_AFFILIATE_TERM_EPOCHS ? 1 : 0; + } + else + { + output.affiliate = NULL_ID; + output.referredAtEpoch = 0; + output.active = 0; + } + } + // ------------------------------------------------------------------ // Procedures // ------------------------------------------------------------------ @@ -629,6 +754,16 @@ struct QPAYHUB : public ContractBase } locals.net = locals.amount - locals.fee; + // AFFILIATE ADDITION: if this seller was referred and is still + // within term, the affiliate's cut comes out of the fee, never out + // of the seller's net - locals.net above is already final. + locals.affiliateCut = 0; + if (state.get().affiliateOf.get(input.seller, locals.aff) + && (uint32)qpi.epoch() < locals.aff.referredAtEpoch + QPAYHUB_AFFILIATE_TERM_EPOCHS) + { + locals.affiliateCut = (sint64)div((uint64)locals.fee * QPAYHUB_AFFILIATE_COMMISSION_PERMILLE, (uint64)10000); + } + // Effects before the outbound interaction: record everything first, // then pay the seller last. locals.r.payer = qpi.invocator(); @@ -641,12 +776,16 @@ struct QPAYHUB : public ContractBase locals.r.tickPaid = (uint32)qpi.tick(); locals.r.consumed = 0; state.mut().receipts.set(locals.key, locals.r); - state.mut().feePool = sadd(state.get().feePool, locals.fee); + state.mut().feePool = sadd(state.get().feePool, locals.fee - locals.affiliateCut); state.mut().totalPayments = sadd(state.get().totalPayments, (uint64)1); state.mut().totalVolume = sadd(state.get().totalVolume, (uint64)locals.amount); state.mut().totalFeesCollected = sadd(state.get().totalFeesCollected, (uint64)locals.fee); qpi.transfer(input.seller, locals.net); + if (locals.affiliateCut > 0) + { + qpi.transfer(locals.aff.affiliate, locals.affiliateCut); + } output.returnCode = QPAYHUB_OK; output.receiptKey = locals.key; @@ -836,6 +975,90 @@ struct QPAYHUB : public ContractBase output.returnCode = QPAYHUB_OK; } + // ---- AFFILIATE ADDITION: registrar-gated procedures ---- + // affiliateRegistrarId-only. First-attribution-wins: an existing + // affiliate link can't be overwritten directly, only removed then + // re-set, so the registrar can't silently reassign credit after the + // fact. + PUBLIC_PROCEDURE_WITH_LOCALS(SetAffiliate) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + if (qpi.invocator() != state.get().affiliateRegistrarId) + { + output.returnCode = QPAYHUB_ERR_ACCESS_DENIED; + return; + } + if (input.seller == NULL_ID || input.affiliate == NULL_ID || input.affiliate == input.seller) + { + output.returnCode = QPAYHUB_ERR_INVALID_SELLER; + return; + } + if (state.get().affiliateOf.contains(input.seller)) + { + output.returnCode = QPAYHUB_ERR_ALREADY_HAS_AFFILIATE; + return; + } + if (state.get().affiliateOf.population() >= QPAYHUB_AFFILIATE_CAPACITY) + { + output.returnCode = QPAYHUB_ERR_CAPACITY; + return; + } + locals.a.affiliate = input.affiliate; + locals.a.referredAtEpoch = (uint32)qpi.epoch(); + state.mut().affiliateOf.set(input.seller, locals.a); + output.returnCode = QPAYHUB_OK; + } + + // affiliateRegistrarId OR recoveryId - a confirmed bad attribution needs + // a way out even if the registrar key is the one that's compromised. + PUBLIC_PROCEDURE(RemoveAffiliate) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + if (qpi.invocator() != state.get().affiliateRegistrarId + && qpi.invocator() != state.get().recoveryId) + { + output.returnCode = QPAYHUB_ERR_ACCESS_DENIED; + return; + } + if (!state.get().affiliateOf.contains(input.seller)) + { + output.returnCode = QPAYHUB_ERR_NOT_FOUND; + return; + } + state.mut().affiliateOf.removeByKey(input.seller); + output.returnCode = QPAYHUB_OK; + } + + // Callable by the CURRENT registrar (planned rotation) or by recoveryId + // (emergency override) - same self-rotation/recovery-override pattern + // as ChangeOperator above. + PUBLIC_PROCEDURE(ChangeAffiliateRegistrar) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + if (qpi.invocator() != state.get().affiliateRegistrarId + && qpi.invocator() != state.get().recoveryId) + { + output.returnCode = QPAYHUB_ERR_ACCESS_DENIED; + return; + } + if (input.newRegistrar == NULL_ID) + { + output.returnCode = QPAYHUB_ERR_INVALID_SELLER; + return; + } + state.mut().affiliateRegistrarId = input.newRegistrar; + output.returnCode = QPAYHUB_OK; + } + // ---- ORACLE PRICE FEED ADDITIONS: notification callback ---- // Fires whenever the subscription produces a new reply. Only a // confirmed SUCCESS with a sane reply updates state - everything else @@ -867,6 +1090,7 @@ struct QPAYHUB : public ContractBase REGISTER_USER_FUNCTION(GetInfo, 3); REGISTER_USER_FUNCTION(GetQuUsdPrice, 4); REGISTER_USER_FUNCTION(GetPromoRate, 5); + REGISTER_USER_FUNCTION(GetAffiliate, 6); REGISTER_USER_PROCEDURE(Pay, 1); REGISTER_USER_PROCEDURE(Consume, 2); @@ -874,6 +1098,9 @@ struct QPAYHUB : public ContractBase REGISTER_USER_PROCEDURE(SetPromoRate, 4); REGISTER_USER_PROCEDURE(RemovePromoRate, 5); REGISTER_USER_PROCEDURE(ChangeOperator, 6); + REGISTER_USER_PROCEDURE(SetAffiliate, 7); + REGISTER_USER_PROCEDURE(RemoveAffiliate, 8); + REGISTER_USER_PROCEDURE(ChangeAffiliateRegistrar, 9); REGISTER_USER_PROCEDURE_NOTIFICATION(NotifyQuUsdPriceReply); } @@ -920,6 +1147,18 @@ struct QPAYHUB : public ContractBase _D, _A, _Z, _M, _N, _C, _S, _W, _W, _J, _Q, _Y, _L, _J, _C, _B ); + + // ---- AFFILIATE ADDITION: registrar init ---- + // Real identity: LWKZMFLWSBGAIBGRHZTRANQQTVFDSBSEBJSLISUUYAVZUMLBWZCSTQJALJZE + state.mut().affiliateRegistrarId = ID( + _L, _W, _K, _Z, _M, _F, _L, _W, + _S, _B, _G, _A, _I, _B, _G, _R, + _H, _Z, _T, _R, _A, _N, _Q, _Q, + _T, _V, _F, _D, _S, _B, _S, _E, + _B, _J, _S, _L, _I, _S, _U, _U, + _Y, _A, _V, _Z, _U, _M, _L, _B, + _W, _Z, _C, _S, _T, _Q, _J, _A + ); } POST_INCOMING_TRANSFER() @@ -940,6 +1179,7 @@ struct QPAYHUB : public ContractBase { sint64 idx; Receipt r; + Affiliate aff; // AFFILIATE ADDITION uint32 cur; uint32 cutoff; Entity ent; @@ -985,6 +1225,22 @@ struct QPAYHUB : public ContractBase } state.mut().receipts.cleanupIfNeeded(); + // AFFILIATE ADDITION: purge referral links past their term, freeing + // the slot - same purge pattern as receipt retention above. Pay() + // already stops paying an expired affiliate on its own (it checks + // the term itself), this just reclaims the capacity slot. + for (locals.idx = state.get().affiliateOf.nextElementIndex(NULL_INDEX); + locals.idx != NULL_INDEX; + locals.idx = state.get().affiliateOf.nextElementIndex(locals.idx)) + { + locals.aff = state.get().affiliateOf.value(locals.idx); + if (locals.cur >= locals.aff.referredAtEpoch + QPAYHUB_AFFILIATE_TERM_EPOCHS) + { + state.mut().affiliateOf.removeByKey(state.get().affiliateOf.key(locals.idx)); + } + } + state.mut().affiliateOf.cleanupIfNeeded(); + // Distribute above the exec reserve: 10% shares, 90% token holders. // Balance is re-read before spending; every payment is clamped to it. qpi.getEntity(SELF, locals.ent); diff --git a/test/contract_qpayhub.cpp b/test/contract_qpayhub.cpp index 0d25e7d9..74395b88 100644 --- a/test/contract_qpayhub.cpp +++ b/test/contract_qpayhub.cpp @@ -15,6 +15,10 @@ static const id OPERATOR1(501, 501, 501, 501); static const id RECOVERY1(601, 601, 601, 601); static const id IMPOSTOR1(701, 701, 701, 701); static const id NEWOPERATOR1(801, 801, 801, 801); +static const id REGISTRAR1(901, 901, 901, 901); +static const id NEWREGISTRAR1(1001, 1001, 1001, 1001); +static const id AFFILIATE1(1101, 1101, 1101, 1101); +static const id AFFILIATE2(1201, 1201, 1201, 1201); static const id QPAYHUB_CONTRACT_ID(QPAYHUB_CONTRACT_INDEX, 0, 0, 0); @@ -191,6 +195,38 @@ class ContractTestingQPayhub : protected ContractTesting return output; } + QPAYHUB::GetAffiliate_output getAffiliate(const id& seller) + { + QPAYHUB::GetAffiliate_input input{ seller }; + QPAYHUB::GetAffiliate_output output; + callFunction(QPAYHUB_CONTRACT_INDEX, 6, input, output); + return output; + } + + QPAYHUB::SetAffiliate_output setAffiliate(const id& invocator, const id& seller, const id& affiliate, sint64 reward = 0) + { + QPAYHUB::SetAffiliate_input input{ seller, affiliate }; + QPAYHUB::SetAffiliate_output output; + invokeUserProcedure(QPAYHUB_CONTRACT_INDEX, 7, input, output, invocator, reward); + return output; + } + + QPAYHUB::RemoveAffiliate_output removeAffiliate(const id& invocator, const id& seller, sint64 reward = 0) + { + QPAYHUB::RemoveAffiliate_input input{ seller }; + QPAYHUB::RemoveAffiliate_output output; + invokeUserProcedure(QPAYHUB_CONTRACT_INDEX, 8, input, output, invocator, reward); + return output; + } + + QPAYHUB::ChangeAffiliateRegistrar_output changeAffiliateRegistrar(const id& invocator, const id& newRegistrar, sint64 reward = 0) + { + QPAYHUB::ChangeAffiliateRegistrar_input input{ newRegistrar }; + QPAYHUB::ChangeAffiliateRegistrar_output output; + invokeUserProcedure(QPAYHUB_CONTRACT_INDEX, 9, input, output, invocator, reward); + return output; + } + // Directly invokes the private oracle-reply notification callback exactly // as qubic.cpp's contract processor does for USER_PROCEDURE_NOTIFICATION_CALL // (see QpiContextUserProcedureNotificationCall usage at qubic.cpp:2357). @@ -1063,3 +1099,173 @@ TEST(ContractQPayhub, ChangeOperatorRejectsNullNewOperator) EXPECT_EQ(out.returnCode, QPAYHUB_ERR_INVALID_SELLER); EXPECT_EQ(qpayhub.setPromoRate(OPERATOR1, SELLER1, 50).returnCode, QPAYHUB_OK); // operator unchanged } + +TEST(ContractQPayhub, SetAffiliateByRegistrarThenPaySplitsFeeToAffiliate) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->affiliateRegistrarId = REGISTRAR1; + increaseEnergy(REGISTRAR1, 10000000); + increaseEnergy(BUYER1, 10000000); + + ASSERT_EQ(qpayhub.setAffiliate(REGISTRAR1, SELLER1, AFFILIATE1).returnCode, QPAYHUB_OK); + auto info = qpayhub.getAffiliate(SELLER1); + EXPECT_EQ(info.affiliate, AFFILIATE1); + EXPECT_EQ(info.active, 1); + + const sint64 amount = 100000; + const sint64 affBefore = getBalance(AFFILIATE1); + const sint64 sellerBefore = getBalance(SELLER1); + + auto payOut = qpayhub.pay(BUYER1, SELLER1, RESOURCE1, 1, amount); + ASSERT_EQ(payOut.returnCode, QPAYHUB_OK); + + // fee = 0.75% of 100,000 = 750; affiliate cut = 5% of 750 = 37. + EXPECT_EQ(getBalance(AFFILIATE1), affBefore + 37); + // Seller's net is unchanged by the split - the cut comes out of the fee. + EXPECT_EQ(getBalance(SELLER1), sellerBefore + payOut.net); +} + +TEST(ContractQPayhub, SetAffiliateRejectsSelfReferral) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->affiliateRegistrarId = REGISTRAR1; + increaseEnergy(REGISTRAR1, 10000000); + + auto out = qpayhub.setAffiliate(REGISTRAR1, SELLER1, SELLER1); + EXPECT_EQ(out.returnCode, QPAYHUB_ERR_INVALID_SELLER); + EXPECT_EQ(qpayhub.getAffiliate(SELLER1).active, 0); +} + +TEST(ContractQPayhub, SetAffiliateFirstAttributionWins) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->affiliateRegistrarId = REGISTRAR1; + increaseEnergy(REGISTRAR1, 10000000); + + ASSERT_EQ(qpayhub.setAffiliate(REGISTRAR1, SELLER1, AFFILIATE1).returnCode, QPAYHUB_OK); + auto out = qpayhub.setAffiliate(REGISTRAR1, SELLER1, AFFILIATE2); + EXPECT_EQ(out.returnCode, QPAYHUB_ERR_ALREADY_HAS_AFFILIATE); + EXPECT_EQ(qpayhub.getAffiliate(SELLER1).affiliate, AFFILIATE1); +} + +TEST(ContractQPayhub, SetAffiliateByThirdPartyRejected) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->affiliateRegistrarId = REGISTRAR1; + increaseEnergy(IMPOSTOR1, 10000000); + + auto out = qpayhub.setAffiliate(IMPOSTOR1, SELLER1, AFFILIATE1, 1000); + EXPECT_EQ(out.returnCode, QPAYHUB_ERR_ACCESS_DENIED); + EXPECT_EQ(qpayhub.getAffiliate(SELLER1).active, 0); +} + +TEST(ContractQPayhub, RemoveAffiliateFreesTheSellerForReattribution) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->affiliateRegistrarId = REGISTRAR1; + increaseEnergy(REGISTRAR1, 10000000); + + ASSERT_EQ(qpayhub.setAffiliate(REGISTRAR1, SELLER1, AFFILIATE1).returnCode, QPAYHUB_OK); + ASSERT_EQ(qpayhub.removeAffiliate(REGISTRAR1, SELLER1).returnCode, QPAYHUB_OK); + EXPECT_EQ(qpayhub.getAffiliate(SELLER1).active, 0); + + // A fraudulent or mistaken attribution can be corrected, not just deleted. + EXPECT_EQ(qpayhub.setAffiliate(REGISTRAR1, SELLER1, AFFILIATE2).returnCode, QPAYHUB_OK); + EXPECT_EQ(qpayhub.getAffiliate(SELLER1).affiliate, AFFILIATE2); +} + +TEST(ContractQPayhub, RemoveAffiliateByRecoveryAlsoAllowed) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->affiliateRegistrarId = REGISTRAR1; + qpayhub.state()->recoveryId = RECOVERY1; + increaseEnergy(REGISTRAR1, 10000000); + increaseEnergy(RECOVERY1, 10000000); + + ASSERT_EQ(qpayhub.setAffiliate(REGISTRAR1, SELLER1, AFFILIATE1).returnCode, QPAYHUB_OK); + EXPECT_EQ(qpayhub.removeAffiliate(RECOVERY1, SELLER1).returnCode, QPAYHUB_OK); +} + +// An affiliate link stops paying out after QPAYHUB_AFFILIATE_TERM_EPOCHS, +// and END_EPOCH actively purges the entry once expired, same purge pattern +// as receipt retention. +TEST(ContractQPayhub, AffiliateCutStopsAndEntryIsPurgedAfterTerm) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->affiliateRegistrarId = REGISTRAR1; + increaseEnergy(REGISTRAR1, 10000000); + increaseEnergy(BUYER1, 10000000); + + system.epoch = 10; + ASSERT_EQ(qpayhub.setAffiliate(REGISTRAR1, SELLER1, AFFILIATE1).returnCode, QPAYHUB_OK); + EXPECT_EQ(qpayhub.getAffiliate(SELLER1).active, 1); + + // Still within term: one epoch short of expiry. + system.epoch = 10 + QPAYHUB_AFFILIATE_TERM_EPOCHS - 1; + EXPECT_EQ(qpayhub.getAffiliate(SELLER1).active, 1); + const sint64 affBefore = getBalance(AFFILIATE1); + qpayhub.pay(BUYER1, SELLER1, RESOURCE1, 1, 100000); + EXPECT_GT(getBalance(AFFILIATE1), affBefore); + + // Past the term: Pay no longer pays the affiliate, even though the + // entry has not been purged yet. + system.epoch = 10 + QPAYHUB_AFFILIATE_TERM_EPOCHS; + EXPECT_EQ(qpayhub.getAffiliate(SELLER1).active, 0); + const sint64 affBefore2 = getBalance(AFFILIATE1); + qpayhub.pay(BUYER1, SELLER1, RESOURCE2, 2, 100000); + EXPECT_EQ(getBalance(AFFILIATE1), affBefore2); + + // END_EPOCH at or past expiry purges the entry and frees the slot. + qpayhub.endEpoch(); + EXPECT_EQ(qpayhub.state()->affiliateOf.population(), 0ULL); +} + +TEST(ContractQPayhub, AffiliateCapacityEnforced) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->affiliateRegistrarId = REGISTRAR1; + increaseEnergy(REGISTRAR1, 10000000); + + id sellers[QPAYHUB_AFFILIATE_CAPACITY]; + for (uint64 i = 0; i < QPAYHUB_AFFILIATE_CAPACITY; ++i) + { + sellers[i] = id::randomValue(); + auto out = qpayhub.setAffiliate(REGISTRAR1, sellers[i], id::randomValue()); + ASSERT_EQ(out.returnCode, QPAYHUB_OK); + } + + const id overflow = id::randomValue(); + auto overflowOut = qpayhub.setAffiliate(REGISTRAR1, overflow, id::randomValue()); + EXPECT_EQ(overflowOut.returnCode, QPAYHUB_ERR_CAPACITY); + EXPECT_EQ(qpayhub.getAffiliate(overflow).active, 0); + + ASSERT_EQ(qpayhub.removeAffiliate(REGISTRAR1, sellers[0]).returnCode, QPAYHUB_OK); + EXPECT_EQ(qpayhub.setAffiliate(REGISTRAR1, overflow, id::randomValue()).returnCode, QPAYHUB_OK); +} + +TEST(ContractQPayhub, ChangeAffiliateRegistrarByCurrentRegistrarSucceeds) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->affiliateRegistrarId = REGISTRAR1; + increaseEnergy(REGISTRAR1, 10000000); + increaseEnergy(NEWREGISTRAR1, 10000000); + + EXPECT_EQ(qpayhub.changeAffiliateRegistrar(REGISTRAR1, NEWREGISTRAR1).returnCode, QPAYHUB_OK); + EXPECT_EQ(qpayhub.setAffiliate(REGISTRAR1, SELLER1, AFFILIATE1).returnCode, QPAYHUB_ERR_ACCESS_DENIED); + EXPECT_EQ(qpayhub.setAffiliate(NEWREGISTRAR1, SELLER1, AFFILIATE1).returnCode, QPAYHUB_OK); +} + +TEST(ContractQPayhub, ChangeAffiliateRegistrarByRecoveryOverridesCompromisedRegistrar) +{ + ContractTestingQPayhub qpayhub; + qpayhub.state()->affiliateRegistrarId = REGISTRAR1; + qpayhub.state()->recoveryId = RECOVERY1; + increaseEnergy(REGISTRAR1, 10000000); + increaseEnergy(RECOVERY1, 10000000); + increaseEnergy(NEWREGISTRAR1, 10000000); + + auto out = qpayhub.changeAffiliateRegistrar(RECOVERY1, NEWREGISTRAR1); + EXPECT_EQ(out.returnCode, QPAYHUB_OK); + EXPECT_EQ(qpayhub.setAffiliate(NEWREGISTRAR1, SELLER1, AFFILIATE1).returnCode, QPAYHUB_OK); + EXPECT_EQ(qpayhub.setAffiliate(REGISTRAR1, SELLER2, AFFILIATE2).returnCode, QPAYHUB_ERR_ACCESS_DENIED); // old registrar locked out +} From dd83c141e7f1b2f88c201a5e18044c8e0b93bc9a Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Fri, 14 Aug 2026 10:53:27 +0100 Subject: [PATCH 7/8] fix:add header and test cpp file to VisulStudio files --- src/Qubic.vcxproj | 1 + src/Qubic.vcxproj.filters | 3 +++ test/CMakeLists.txt | 1 + test/test.vcxproj | 1 + test/test.vcxproj.filters | 1 + 5 files changed, 7 insertions(+) diff --git a/src/Qubic.vcxproj b/src/Qubic.vcxproj index 942c974b..116b5f56 100644 --- a/src/Qubic.vcxproj +++ b/src/Qubic.vcxproj @@ -44,6 +44,7 @@ + diff --git a/src/Qubic.vcxproj.filters b/src/Qubic.vcxproj.filters index 160c5365..75cce9be 100644 --- a/src/Qubic.vcxproj.filters +++ b/src/Qubic.vcxproj.filters @@ -123,6 +123,9 @@ contracts + + contracts + contracts diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 39aa01b9..32da1f73 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -33,6 +33,7 @@ add_executable( # contract_qearn.cpp # contract_qvault.cpp # contract_qx.cpp + contract_qpayhub.cpp contract_qraffle.cpp contract_random.cpp contract_vottunbridge.cpp diff --git a/test/test.vcxproj b/test/test.vcxproj index fe0736f6..3fe6fd51 100644 --- a/test/test.vcxproj +++ b/test/test.vcxproj @@ -158,6 +158,7 @@ + diff --git a/test/test.vcxproj.filters b/test/test.vcxproj.filters index 6f776a78..cbd7aba2 100644 --- a/test/test.vcxproj.filters +++ b/test/test.vcxproj.filters @@ -34,6 +34,7 @@ + From 70ec8ecbe6e051bb5b159c4b8b5005cb726a0321 Mon Sep 17 00:00:00 2001 From: double-k-3033 Date: Tue, 18 Aug 2026 14:43:29 +0100 Subject: [PATCH 8/8] fix: QPayhub test harness failures in dividend and oracle tests Three ContractQPayhub tests failed due to fixture bugs, not contract bugs; src/contracts/QPayhub.h is unchanged. EndEpochDistributesFeePoolAboveReserveToSharesAndTokenHolders funded the QPAY issuer with 10,000,000 QU while QX charges 1,000,000,000 QU to issue an asset, so IssueAsset refunded and issued nothing, leaving no token holders for the 90% split. Fund from qxFees instead of a hardcoded amount. NotifyQuUsdPriceReply{Valid,Invalid}ResolvedReply seeded broadcastedComputors from computorPublicKeys, which holds only one entry (sized by computorSeeds). Indices 1..675 read out of bounds, so nearly every computor mapped to the same computorIndex(), all 676 commits collapsed onto one slot, quorum (451) was never reached and the query stayed PENDING with no reply to fetch. Use distinct synthetic keys and init the engine from them, matching OracleEngineTest. All 53 ContractQPayhub tests pass. --- test/contract_qpayhub.cpp | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/test/contract_qpayhub.cpp b/test/contract_qpayhub.cpp index 74395b88..9df06549 100644 --- a/test/contract_qpayhub.cpp +++ b/test/contract_qpayhub.cpp @@ -76,11 +76,16 @@ class ContractTestingQPayhub : protected ContractTesting // Needed for SubscribeToPriceFeed() / oracle-engine-backed tests only; // harmless setup for the tests that don't touch the oracle at all. + // computorPublicKeys holds one entry per computorSeeds entry (currently + // one), so it cannot supply NUMBER_OF_COMPUTORS distinct keys. Commit + // quorum needs every computor to map to its own index via + // computorIndex(), so use synthetic distinct keys - same approach as + // OracleEngineTest in test/oracle_engine.cpp. for (unsigned int i = 0; i < NUMBER_OF_COMPUTORS; ++i) { - broadcastedComputors.computors.publicKeys[i] = computorPublicKeys[i]; + broadcastedComputors.computors.publicKeys[i] = m256i(i * 2, 42, 13, 1337); } - EXPECT_TRUE(oracleEngine.init(computorPublicKeys)); + EXPECT_TRUE(oracleEngine.init(broadcastedComputors.computors.publicKeys)); EXPECT_TRUE(OI::initOracleInterfaces()); EXPECT_TRUE(ts.init()); ts.beginEpoch((unsigned int)system.tick); @@ -664,7 +669,9 @@ TEST(ContractQPayhub, EndEpochDistributesFeePoolAboveReserveToSharesAndTokenHold }; issueContractShares(QPAYHUB_CONTRACT_INDEX, qpayhubShares); - increaseEnergy(QPAYHUB_DIVIDEND_TOKEN_ISSUER, 10000000); + // QX charges 1,000,000,000 QU to issue an asset - fund the issuer for the + // real fee, otherwise IssueAsset refunds and issues nothing. + increaseEnergy(QPAYHUB_DIVIDEND_TOKEN_ISSUER, qpayhub.qxFees.assetIssuanceFee + qpayhub.qxFees.transferFee + 10000000); EXPECT_EQ(qpayhub.issueAsset(QPAYHUB_DIVIDEND_TOKEN_ISSUER, QPAYHUB_TOKEN_ASSETNAME, 1000000), 1000000); EXPECT_EQ(qpayhub.transferAsset(QPAYHUB_DIVIDEND_TOKEN_ISSUER, TOKENHOLDER1, QPAYHUB_TOKEN_ASSETNAME, QPAYHUB_DIVIDEND_TOKEN_ISSUER, 1000000), 1000000);