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/src/contract_core/contract_def.h b/src/contract_core/contract_def.h index 2aa6e074..ee94c8a5 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", 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)}, @@ -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..6ecb98e6 --- /dev/null +++ b/src/contracts/QPayhub.h @@ -0,0 +1,1318 @@ +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 (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 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 +// 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 (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 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. +// +// 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 +// 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. +// 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. +// 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/ +// 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. +// +// 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. +// +// 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 +// 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 = 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; +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" + +// ---- 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 + +// ---- 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 +// 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 < 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; +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; +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 +{ +}; + +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; + }; + + // ---- AFFILIATE ADDITION: referral record ---- + struct Affiliate + { + id affiliate; + uint32 referredAtEpoch; + }; + + 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; + + // ---- 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; + + // ---- 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; + }; + + // ------------------------------------------------------------------ + // 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; + 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 + { + 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; + // Likewise appended - AFFILIATE ADDITION. + uint64 affiliateCommissionPermille; + uint32 affiliateTermEpochs; + uint32 padding1; + }; + + // ---- 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; + }; + + // ---- 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; + }; + + // ---- 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) + // ------------------------------------------------------------------ + + 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; + output.affiliateCommissionPermille = QPAYHUB_AFFILIATE_COMMISSION_PERMILLE; + output.affiliateTermEpochs = QPAYHUB_AFFILIATE_TERM_EPOCHS; + output.padding1 = 0; + } + + // ---- 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; + } + + // ---- 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; + } + } + + // ---- 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 + // ------------------------------------------------------------------ + + 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; + } + + // 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; + } + if (locals.fee > locals.amount) + { + locals.fee = locals.amount; + } + 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(); + 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 - 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; + 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; + } + + // ---- 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; + } + + // ---- 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 + // (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_FUNCTION(GetPromoRate, 5); + REGISTER_USER_FUNCTION(GetAffiliate, 6); + + 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(SetAffiliate, 7); + REGISTER_USER_PROCEDURE(RemoveAffiliate, 8); + REGISTER_USER_PROCEDURE(ChangeAffiliateRegistrar, 9); + + REGISTER_USER_PROCEDURE_NOTIFICATION(NotifyQuUsdPriceReply); + } + + INITIALIZE() + { + state.mut().priceOracleSubscriptionId = -1; + state.mut().quUsdDenominator = 0; // 0 = no price known yet, GetQuUsdPrice reports stale + + // QPAY token issuer: QPAYNOWSWZMGHFEAEVJXGZAVSHABAZDDBDIHTEBOPCOGHRGBCYCUZOHCVLXG + state.mut().dividendToken.issuer = ID( + _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 + ); + + // ---- 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() + { + // 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; + Affiliate aff; // AFFILIATE ADDITION + 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(); + + // 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); + 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); + } + } +}; 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/contract_qpayhub.cpp b/test/contract_qpayhub.cpp new file mode 100644 index 00000000..9df06549 --- /dev/null +++ b/test/contract_qpayhub.cpp @@ -0,0 +1,1278 @@ +#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 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); + +// The exact issuer identity QPayhub.h's INITIALIZE() hardcodes for the QPAY +// 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, _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 +// 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. + // 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] = m256i(i * 2, 42, 13, 1337); + } + EXPECT_TRUE(oracleEngine.init(broadcastedComputors.computors.publicKeys)); + 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; + } + + 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; + } + + 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). + // 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 = 7500; // 0.75% 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, PayFeeFloorDominatesBelowPercentThreshold) +{ + ContractTestingQPayhub qpayhub; + increaseEnergy(BUYER1, 10000000); + + // 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, PayFeePercentDominatesAbovePercentThreshold) +{ + ContractTestingQPayhub qpayhub; + increaseEnergy(BUYER1, 10000000); + + // 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, 150); + EXPECT_EQ(output.net, 20000 - 150); +} + +TEST(ContractQPayhub, PayFeeBoundaryJustBelowAndAboveFloor) +{ + ContractTestingQPayhub qpayhub; + increaseEnergy(BUYER1, 10000000); + + // 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) +{ + 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, 525ULL); // 150 + 225 + 150 + EXPECT_EQ(info.feePool, 525); + 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); + + // 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); + + 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); +} + +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 +} + +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 +} 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 @@ +