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 @@
+