// ========================================================================================= // Faucet — a fixed drip of native STR, one per caller per cooldown, with a declared sweep. // // WHAT IT IS FOR // Handing out small amounts of STR to whoever asks: a testnet tap, an onboarding allowance, // a demo wallet top-up. It is also the catalogue's worked example of PER-CALLER RATE // LIMITING, which is a pattern every contract that gives something away needs and which no // other template here shows. // // THE RATE LIMIT, AND WHAT IT IS AND IS NOT WORTH // The limit is per ADDRESS: one drip per `cooldown()` milliseconds, from the sealed block's // timestamp. **It is not a limit per person, and cannot be.** Addresses are free to create, // so anyone willing to make n addresses can draw n drips — and the only reason this faucet is // not drained in one block is a fact about the CHAIN and not about this contract: a contract // call requires a proved STR.domain, which costs 1000 STR locked. That is the sybil cost, it // is enforced by `contractCallerGate` before this bytecode runs, and it is what makes the // per-address limit mean something. If that gate ever loosens, this faucet's protection // loosens with it. // // The second limit is a lifetime cap per address, which bounds the total one address can ever // take however long it waits. Set it to 0 only if you mean "forever, without limit". // // WHAT IT GUARANTEES // * A caller cannot take twice inside the cooldown. The timestamp is written BEFORE the // transfer, from the block being executed, so no caller argument and no node clock is // involved anywhere. // * A caller cannot take more than the lifetime cap in total, across any number of calls. // * Nobody can take another caller's drip or make a call on their behalf: `claim()` has no // arguments and pays `caller`. // * The drip amount and the cooldown are fixed at deployment. There is no setter for either; // a faucet whose owner can change the terms between two calls is not a rate limit, it is // a preference. // * A revert leaves the books and the balance byte-identical. Storage and value commit // together, only on success. // // THE POWER IT DELIBERATELY HAS, DECLARED // **`sweep(to, amount)` lets the owner take the remaining balance back.** A faucet is funded // as a gift and usually needs winding up, so the power is here — but it is stated rather than // hidden, it is in the ABI, and every use of it emits `Swept`. What it CANNOT do is take back // a drip somebody has already claimed; it moves only what is still in this contract. // // An operator who wants a faucet with no such power deletes `sweep` before deploying. The // address changes with it, which is correct, and `owner()` then names a key whose only // remaining power is the pause. // // HOW IT IS TYPICALLY MISUSED // * Deploying it on a network where the sybil cost is low and calling the per-address limit // protection. See above — it is the domain gate doing that work, not this contract. // * Funding it before deploying. A contract address cannot receive until the contract // exists: the receive gate answers from the deployed set. Deploy, then fund. // * Setting the drip near the balance. There is no queue and no fairness rule: the fastest // caller takes the last drip, and every later call reverts with `faucet is dry` until // somebody refills it. // * Expecting the faucet to know who funded it. It cannot: a call record carries no value // and there is no deposit hook, so any number of parties may pay it and none of them is // recorded. The sweep pays whoever the OWNER names, not whoever put the money in. // * Under-declaring the gas limit on the call record. Post-mining covers only // min(gasUsed, gasLimit / 10), so the network silently absorbs the rest, forever, on every // call. MEASURED, on the v2 gas table, with 53-character addresses: claim 4,326 — of which // 2,100 is the `TRANSFER` and 400 the balance read — sweep 2,703, setPaused 363, // proposeOwner 412, acceptOwnership 612. Declare a gas limit of at least 44,000. // // TO USE IT // Edit the three constants below, deploy, fund the address by an ordinary transfer, and tell // people to call `claim()`. // ========================================================================================= contract Faucet { // ---- edit these three ---------------------------------------------------------------- // One drip, in MINOR units. At 13 decimals, 10000000000000 is one whole STR. state dripAmount: uint = 10000000000000; // The wait between two claims by the same address, in milliseconds. 86400000 is 24 hours. state cooldownMs: uint = 86400000; // The most one address may ever take in total, in minor units. 0 means no lifetime limit, // which is a decision and not a default — say it deliberately. state lifetimeCapMinor: uint = 100000000000000; // ------------------------------------------------------------------------------------- // `caller` inside a state initializer is the DEPLOYER: Sindri runs the compiler-generated // initializer with the authenticated deployer as caller, so there is no claim race. state contractOwner: address = caller; state pendingOwner: address = ""; state frozen: bool = false; state paidTotal: uint = 0; state claimCount: uint = 0; // 0 means this address has never claimed. The cooldown arithmetic below relies on that: // `0 + cooldown` is smaller than any real block time, so a first claim is never blocked. state lastClaimAt: map; state claimedByOf: map; event Claimed(who: address, amount: uint, atMs: uint); event Swept(to: address, amount: uint, by: address); event PausedChanged(paused: bool, by: address); event OwnerProposed(current: address, pending: address); event OwnerChanged(previous: address, next: address); // ---- views ---------------------------------------------------------------------------- view fn drip() -> uint { return dripAmount; } view fn cooldown() -> uint { return cooldownMs; } view fn lifetimeCap() -> uint { return lifetimeCapMinor; } // What the faucet holds right now, read from the native ledger rather than from a mirror in // state: a cached balance is a second copy of the truth and diverges the moment anybody pays // this contract without calling it. Divide by `drip()` for how many claims are left. view fn held() -> uint { return balance(self); } view fn totalPaid() -> uint { return paidTotal; } view fn totalClaims() -> uint { return claimCount; } view fn claimedBy(account: address) -> uint { return claimedByOf[account]; } // The earliest block time at which this address may claim again, or 0 if it never has. view fn nextClaimAt(account: address) -> uint { if (lastClaimAt[account] == 0) { return 0; } return lastClaimAt[account] + cooldownMs; } // Everything `claim()` checks, as one answer, so a wallet can grey out a button instead of // spending gas on a call that reverts. view fn claimable(account: address) -> bool { if (frozen) { return false; } if (timestamp < lastClaimAt[account] + cooldownMs) { return false; } if (lifetimeCapMinor > 0 && claimedByOf[account] + dripAmount > lifetimeCapMinor) { return false; } return balance(self) >= dripAmount; } view fn owner() -> address { return contractOwner; } view fn paused() -> bool { return frozen; } view fn pendingOwnerIs() -> address { return pendingOwner; } // ---- the drip --------------------------------------------------------------------------- // Pays the CALLER one drip. No arguments, because an argument naming the recipient would // make one address able to farm the faucet on behalf of many. fn claim() -> uint { require(frozen == false, "paused"); // `lastClaimAt` is 0 for an address that has never claimed, and `0 + cooldown` is below any // real block time, so this permits a first claim without a special case for it. require(timestamp >= lastClaimAt[caller] + cooldownMs, "cooldown"); if (lifetimeCapMinor > 0) { require(claimedByOf[caller] + dripAmount <= lifetimeCapMinor, "lifetime cap"); } require(balance(self) >= dripAmount, "faucet is dry"); // Effects before the interaction. The cooldown is stamped, the totals are written, and only // then does value move — so on a chain where a recipient could call back in (this one // cannot: there is no call opcode) the second claim would already be blocked. lastClaimAt[caller] = timestamp; claimedByOf[caller] = claimedByOf[caller] + dripAmount; paidTotal = paidTotal + dripAmount; claimCount = claimCount + 1; emit Claimed(caller, dripAmount, timestamp); transfer(caller, dripAmount); return dripAmount; } // ---- the owner's powers, both of them declared ------------------------------------------- // Takes unclaimed balance back out. It cannot reach a drip already claimed — that is in the // claimant's own account — and every use of it is on the record as `Swept`. fn sweep(to: address, amount: uint) -> uint { require(caller == contractOwner, "not owner"); require(len(to) > 0, "empty recipient"); require(amount > 0, "amount must be positive"); require(to != self, "self is not payable"); require(balance(self) >= amount, "insufficient balance"); emit Swept(to, amount, caller); transfer(to, amount); return amount; } // Stops `claim` and no view, so a stopped faucet stays readable. It does not stop `sweep`: // winding a faucet up is precisely the case where both are wanted at once. fn setPaused(next: bool) { require(caller == contractOwner, "not owner"); frozen = next; emit PausedChanged(next, caller); } // Two steps, because a one-step handover to a mistyped address is permanent: there is no // administrator anywhere in SourceLess who can reach into a contract's slots. fn proposeOwner(next: address) { require(caller == contractOwner, "not owner"); pendingOwner = next; emit OwnerProposed(contractOwner, next); } fn acceptOwnership() { require(caller == pendingOwner, "not the pending owner"); emit OwnerChanged(contractOwner, caller); contractOwner = caller; pendingOwner = ""; } }