How a visit becomes a token.
Two parties have to agree before anything moves. The presence server has to agree that a phone was at the door. The contract has to agree that the pool owes that wallet something today. Neither can do the other's job.
The server checks the body.
Distance to the door under 40 m. GPS accuracy under 65 m, because a coarser fix is an IP guess. Speed since your last accepted fix under 9 m/s, because faster than a sprint is a spoof. Then caps that a fresh wallet does not reset: 6 visits a day per device, 2 wallets per device, 8 visits per network, 12 per 11 m square. A rejected visit writes nothing.
The contract checks the money.
The voucher says who, which pool, a nonce and a deadline. Nothing else. The contract looks up the pool, refuses a second claim from the same wallet on the same UTC day, works out the amount from the pool's own fields, clamps it to what is left today and what is left at all, and sends it. Then it advances the wallet's streak and, if this was a first visit at a door in a campaign, its challenge progress.
What the brand controls.
Everything about its own pool: budget, per-visit amount, first-visit bonus, daily cap, dates, boost window, whether streaks apply, whether the door is a mystery. It can top up, extend, boost, and close at any time. Closing returns every unspent token. No brand can touch another brand's pool, and nobody but the brand can touch its own.
What the signer cannot do.
It cannot set an amount, cannot pay the same wallet twice in a day, cannot pay from a closed or empty pool, and cannot pay past the daily cap. A stolen key hands out visits that did not happen at the rate the brand already agreed to, until the cap, and the brand can close the pool the moment it notices.
The pool, field by field.
The amount.
amount = perVisit
if inside boost window: amount *= boostBps / 10000
if streaks: amount *= streakBps(days) / 10000
(1x, 1.25x from day 3, 1.5x from 7, 2x from 14)
if first visit here: amount += firstVisitBonus
amount = min(amount, dailyCap - paidToday) when dailyCap > 0
amount = min(amount, remaining)The voucher.
Voucher(address to, uint256 poolId, uint256 nonce, uint256 deadline) EIP-712 domain: name "Vyran", version "1", chainId 4663 signed by the presence key, redeemable once, by anyone, paid only to `to`
Vyran