access.gno
19.49 Kb · 474 lines
1// GnoPulse access: on-chain subscriptions + prepaid credits that gate the GnoPulse API.
2// Users pay from their own wallet; the realm holds only what they pay it.
3package access
4
5import (
6 "chain"
7 "chain/banker"
8 "chain/runtime"
9 "chain/runtime/unsafe"
10 "strconv"
11 "time"
12)
13
14var (
15 // The GnoPulse owner wallet. Cold by design: it signs the deploy and SetOperator,
16 // and nothing on a schedule. Every recurring job runs as the operator instead.
17 owner = address("g138mdf0gqzue3ra2r335upclngn8t8r0n72cn3h")
18
19 // What a period costs, stated in USD. This is the price; the GNOT figure is derived from it.
20 // It is recorded on chain so anyone can check a posted quote against a published GNOT price
21 // and see that we quoted honestly.
22 priceUsdCents int64 = 2500
23 periodSec int64 = 30 * 24 * 60 * 60
24
25 // A credit IS one API cost-unit. There is no second conversion: the old model bought credits
26 // per ugnot and burned 100 of them per unit, which made credits GNOT-denominated while the
27 // subscription was USD-denominated, so the two drifted apart whenever GNOT moved.
28 //
29 // Now both are priced the same way. One cent buys creditsPerUsdCent units, converted from the
30 // payment at the live quote, so topping up N GNOT buys whatever N GNOT is worth in dollars.
31 // 1600 units per cent is $6.25 per million, three times the rate a subscription includes,
32 // which is the overage premium this pricing was set to. It is exact in integer math: a
33 // million units is 625 cents with no remainder.
34 creditsPerUsdCent int64 = 1600
35
36 // TopUp multiplies the payment by priceUsdCents and creditsPerUsdCent before dividing, so a
37 // large enough payment would overflow int64 before the divide. A million GNOT is far beyond
38 // any real top-up and leaves three orders of magnitude of headroom.
39 maxTopUpUgnot int64 = 1_000_000_000_000
40
41 // --- the quote ---------------------------------------------------------------------------
42 //
43 // A fiat price in a volatile coin is quoted, not computed at settlement. We convert
44 // priceUsdCents to ugnot off chain (where the price feed is) and post the result here; that
45 // number IS the obligation until it expires. The payment page reads quoteUgnot from this
46 // realm rather than from any feed of ours, so the figure on screen and the figure charged are
47 // the same number by construction, never merely close.
48 //
49 // This is what payment processors do, and expiry is the rate lock: BitPay locks a quote for
50 // 15 minutes, BTCPay for 15, NOWPayments for 10. Slippage is a trading idea and has no place
51 // here; nobody shows one number and charges another.
52 quoteUgnot int64 // ugnot for one period
53 quotedAt int64 // when it was posted
54
55 // The superseded quote stays payable for a grace window, so a payer who was quoted a moment
56 // before we posted a new number still pays what they were shown rather than being repriced
57 // mid-flight. currentPrice charges whichever of the two is lower while both are live.
58 prevUgnot int64
59 prevUntil int64
60
61 // How long a posted quote is good for, and how long a superseded one stays payable. If the
62 // job that posts quotes stops, Subscribe refuses rather than pricing off a stale number: a
63 // broken cron becomes failed payments, never wrong charges.
64 quoteTtlSec int64 = 900
65 quoteGraceSec int64 = 900
66
67 // The grace window exists so a small tick does not reprice a payment in flight. It must NOT
68 // survive a correction: currentPrice charges the LOWER of the two live quotes, so carrying a
69 // wildly wrong quote forward means posting the right one keeps the wrong one payable for a
70 // FRESH grace window. Correcting the mistake would extend it past the point it would have
71 // lapsed on its own. A move of this factor or more is treated as a correction, not a tick,
72 // and the old quote is dropped instead of carried.
73 quoteGraceBand int64 = 4
74
75 // Ownership moves in two steps. A single-step transfer to a mistyped address is
76 // unrecoverable here: address is an unvalidated string type, so any non-empty value is
77 // accepted, and the realm is immutable, so a wrong owner locks Withdraw and every admin
78 // function forever. The offer sits here until the named address claims it itself, which is
79 // proof the key exists and can transact.
80 pendingOwner address
81
82 // operator is the HOT role. Two of the owner's jobs are not administrative at all: SetQuote
83 // runs every five minutes and Consume every six hours, both from an unattended cron on a
84 // public-facing box. With one role, automating them means the key that can Withdraw must sit
85 // in a keybase on that box. Splitting them means a stolen box costs quote posting, not the
86 // revenue: an operator can price and settle, and can do nothing else.
87 //
88 // Empty disables it, and that is the kill switch. The owner revokes a suspect operator in one
89 // transaction from their own wallet, without touching anything else.
90 operator address
91
92 subs = map[address]int64{} // addr -> expiry (unix)
93 credits = map[address]int64{} // addr -> credit balance
94
95 // settled is the billing watermark: cumulative API cost-units already charged to an address.
96 // It lives on chain so that losing or restoring our off-chain database cannot re-bill usage
97 // that was already paid for -- Consume rejects any total at or below what it already settled.
98 settled = map[address]int64{}
99)
100
101// Subscribe buys pro time for the caller at the live quote. Each whole quote sent buys one
102// period; anything left over becomes units at the same rate a top-up gets, rather than being
103// kept, so the payer keeps the value of every ugnot they sent.
104//
105// Every call is priced at the quote in force when it executes, so a renewal costs what a period
106// costs today. Pinning a renewal to the price paid at first purchase is a known trap: Unlock
107// Protocol does it and a renewal simply reverts once the price has moved.
108func Subscribe(cur realm) {
109 runtime.AssertOriginCall()
110 subscribe(cur.Previous().Address(), int64(unsafe.OriginSend().AmountOf("ugnot")))
111}
112
113// subscribe holds the money math, separate from the origin/caller plumbing above so it can be
114// exercised directly (AssertOriginCall cannot be satisfied from a test frame).
115func subscribe(payer address, paid int64) {
116 price := currentPrice()
117 if paid < price {
118 // Underpayment is refused outright, which is what every processor surveyed does: BitPay
119 // voids the whole payment, BTCPay defaults its tolerance to zero.
120 panic("payment below the quoted price")
121 }
122 base := subs[payer]
123 if now := time.Now().Unix(); base < now {
124 base = now
125 }
126 subs[payer] = base + (paid/price)*periodSec
127 change := unitsFor(paid%price, price)
128 if change > 0 {
129 credits[payer] += change
130 }
131 chain.Emit("Subscribe",
132 "addr", payer.String(),
133 "paid", strconv.FormatInt(paid, 10),
134 "expiresAt", strconv.FormatInt(subs[payer], 10),
135 "changeUnits", strconv.FormatInt(change, 10))
136}
137
138// Login does nothing. Sign-In-With-Gno signs (never broadcasts) a call to it so the wallet shows
139// a "Login" to GnoPulse rather than a token transfer; the challenge nonce rides in the tx memo.
140func Login(cur realm) {
141 // Login credits nothing, so coins sent with it would be kept by the realm with no record and
142 // no way to return them. Refuse instead.
143 if int64(unsafe.OriginSend().AmountOf("ugnot")) > 0 {
144 panic("Login takes no payment")
145 }
146}
147
148// TopUp buys prepaid credits for the caller.
149func TopUp(cur realm) {
150 runtime.AssertOriginCall()
151 topUp(cur.Previous().Address(), int64(unsafe.OriginSend().AmountOf("ugnot")))
152}
153
154// topUp converts the payment to units at the SAME quote the subscription uses, so a credit is
155// worth the same in dollars whatever GNOT is doing. Paying in GNOT, priced in USD.
156func topUp(payer address, paid int64) {
157 if paid <= 0 {
158 panic("no payment")
159 }
160 if paid > maxTopUpUgnot {
161 panic("payment too large")
162 }
163 // currentPrice is the live quote (with the same grace window Subscribe gets), and it panics
164 // when there is none: pegging credits to USD means credits need a rate too, so a dead quote
165 // job closes top-ups as well as subscriptions rather than selling at a stale rate.
166 got := unitsFor(paid, currentPrice())
167 if got <= 0 {
168 panic("payment too small to buy a unit")
169 }
170 credits[payer] += got
171 chain.Emit("TopUp",
172 "addr", payer.String(),
173 "paid", strconv.FormatInt(paid, 10),
174 "units", strconv.FormatInt(got, 10),
175 "balance", strconv.FormatInt(credits[payer], 10))
176}
177
178// unitsFor converts a ugnot payment into API units at a given quote. The payment is worth
179// paid/price of a period, a period costs priceUsdCents, and one cent buys creditsPerUsdCent
180// units. One helper so a top-up and the change from a subscription can never diverge.
181func unitsFor(paid, price int64) int64 {
182 return paid * priceUsdCents * creditsPerUsdCent / price
183}
184
185func HasAccess(addr address) bool { return subs[addr] > time.Now().Unix() || credits[addr] > 0 }
186
187func TierOf(addr address) string {
188 if subs[addr] > time.Now().Unix() {
189 return "pro"
190 }
191 return "free"
192}
193
194func CreditsOf(addr address) int64 { return credits[addr] }
195func ExpiresAt(addr address) int64 { return subs[addr] }
196func SettledOf(addr address) int64 { return settled[addr] }
197func Owner() address { return owner }
198
199func Config() (int64, int64, int64, int64, int64) {
200 return priceUsdCents, periodSec, creditsPerUsdCent, quoteTtlSec, quoteGraceSec
201}
202
203// Consume charges an address for metered API usage, up to a cumulative cost-unit total.
204//
205// The owner passes the caller's running lifetime usage, not a delta: the realm keeps the
206// watermark itself and charges only the part it has not charged before. That makes the call
207// idempotent (a replay is rejected, not re-billed) and means a restored or rolled-back
208// off-chain database cannot double-charge anyone.
209//
210// A caller with too few credits is charged what they have and the watermark still advances; the
211// event records both what was owed and what was actually taken, so a shortfall is visible rather
212// than silently forgiven.
213func Consume(cur realm, addr address, uptoUnits int64) {
214 assertOperator(cur)
215 consume(addr, uptoUnits)
216}
217
218func consume(addr address, uptoUnits int64) {
219 prev := settled[addr]
220 if uptoUnits <= prev {
221 panic("already settled to that total")
222 }
223 owed := uptoUnits - prev
224 took := owed
225 if have := credits[addr]; have <= owed {
226 took = have
227 delete(credits, addr)
228 } else {
229 credits[addr] = have - owed
230 }
231 settled[addr] = uptoUnits
232 chain.Emit("Consume",
233 "addr", addr.String(),
234 "units", strconv.FormatInt(uptoUnits-prev, 10),
235 "settledTo", strconv.FormatInt(uptoUnits, 10),
236 "owed", strconv.FormatInt(owed, 10),
237 "debited", strconv.FormatInt(took, 10),
238 "remaining", strconv.FormatInt(credits[addr], 10))
239}
240
241// Forgive advances the billing watermark without charging: usage already covered by an active
242// time-based subscription. Separate from Consume so that "we decided not to bill this" is its own
243// auditable on-chain act rather than an invisible gap in the watermark.
244func Forgive(cur realm, addr address, uptoUnits int64) {
245 assertOwner(cur)
246 forgive(addr, uptoUnits)
247}
248
249func forgive(addr address, uptoUnits int64) {
250 prev := settled[addr]
251 if uptoUnits <= prev {
252 panic("already settled to that total")
253 }
254 settled[addr] = uptoUnits
255 chain.Emit("Forgive",
256 "addr", addr.String(),
257 "units", strconv.FormatInt(uptoUnits-prev, 10),
258 "settledTo", strconv.FormatInt(uptoUnits, 10))
259}
260
261// SetQuote posts the price of one period in ugnot. Called on a short schedule by the job that
262// already tracks the GNOT price, which converts priceUsdCents off chain and publishes only the
263// result. The previous quote stays payable for quoteGraceSec so payments already in flight are
264// not repriced.
265func SetQuote(cur realm, ugnotPerPeriod int64) {
266 assertOperator(cur)
267 setQuote(ugnotPerPeriod)
268}
269
270func setQuote(ugnotPerPeriod int64) {
271 if ugnotPerPeriod <= 0 {
272 panic("quote must be positive")
273 }
274 now := time.Now().Unix()
275 if quoteUgnot > 0 && ugnotPerPeriod/quoteUgnot < quoteGraceBand && quoteUgnot/ugnotPerPeriod < quoteGraceBand {
276 prevUgnot, prevUntil = quoteUgnot, now+quoteGraceSec
277 } else {
278 // A correction (or the first quote). Drop any standing grace rather than let the
279 // superseded number stay payable.
280 prevUgnot, prevUntil = 0, 0
281 }
282 quoteUgnot, quotedAt = ugnotPerPeriod, now
283 chain.Emit("SetQuote",
284 "ugnot", strconv.FormatInt(ugnotPerPeriod, 10),
285 "usdCents", strconv.FormatInt(priceUsdCents, 10),
286 "expiresAt", strconv.FormatInt(now+quoteTtlSec, 10))
287}
288
289// Quote is what the payment page reads: the amount to send for one period, and the moment it
290// stops being honoured. Reading it from here rather than from a price feed is what makes the
291// figure on screen and the figure charged the same number.
292func Quote() (int64, int64, int64) {
293 return quoteUgnot, quotedAt, quotedAt + quoteTtlSec
294}
295
296// currentPrice is what Subscribe charges. While a superseded quote is still inside its grace
297// window both are live, and the payer is charged the lower of the two: we cannot tell which one
298// they were shown, and charging the lower one guarantees it is never more than they saw.
299func currentPrice() int64 {
300 now := time.Now().Unix()
301 if quoteUgnot <= 0 {
302 panic("no quote has been posted")
303 }
304 if now > quotedAt+quoteTtlSec {
305 panic("the quote has expired")
306 }
307 if prevUgnot > 0 && now <= prevUntil && prevUgnot < quoteUgnot {
308 return prevUgnot
309 }
310 return quoteUgnot
311}
312
313func SetConfig(cur realm, cents, period, perUsdCent, ttl, grace int64) {
314 assertOwner(cur)
315 setConfig(cents, period, perUsdCent, ttl, grace)
316}
317
318func setConfig(cents, period, perUsdCent, ttl, grace int64) {
319 if cents <= 0 || period <= 0 || perUsdCent <= 0 || ttl <= 0 || grace < 0 {
320 panic("must be positive")
321 }
322 priceUsdCents, periodSec, creditsPerUsdCent = cents, period, perUsdCent
323 quoteTtlSec, quoteGraceSec = ttl, grace
324 chain.Emit("SetConfig",
325 "usdCents", strconv.FormatInt(cents, 10),
326 "period", strconv.FormatInt(period, 10),
327 "creditsPerUsdCent", strconv.FormatInt(perUsdCent, 10),
328 "quoteTtl", strconv.FormatInt(ttl, 10),
329 "quoteGrace", strconv.FormatInt(grace, 10))
330}
331
332func Withdraw(cur realm, amount int64) {
333 assertOwner(cur)
334 if amount <= 0 {
335 panic("must be positive")
336 }
337 banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(
338 cur.Address(), owner, chain.Coins{{"ugnot", amount}})
339 chain.Emit("Withdraw", "amount", strconv.FormatInt(amount, 10))
340}
341
342// TransferOwnership OFFERS the realm to newOwner. It does not hand it over: the offer stands
343// until that address claims it with AcceptOwnership. A mistyped address therefore costs nothing,
344// where a single-step transfer would have locked every admin function and the revenue behind an
345// address nobody holds, permanently, because the realm cannot be redeployed.
346func TransferOwnership(cur realm, newOwner address) {
347 assertOwner(cur)
348 transferOwnership(newOwner)
349}
350
351// transferOwnership holds the offer itself, split from the owner gate above for the same reason
352// subscribe is split from Subscribe: assertOwner cannot be satisfied from a test frame.
353func transferOwnership(newOwner address) {
354 if newOwner == "" {
355 panic("empty address")
356 }
357 pendingOwner = newOwner
358 chain.Emit("OwnershipOffered", "from", owner.String(), "to", newOwner.String())
359}
360
361// CancelOwnershipTransfer withdraws a standing offer.
362func CancelOwnershipTransfer(cur realm) {
363 assertOwner(cur)
364 pendingOwner = ""
365 chain.Emit("OwnershipOfferCancelled")
366}
367
368// AcceptOwnership completes the handover. Only the offered address can call it, which proves the
369// key exists and can sign before anything irreversible happens.
370func AcceptOwnership(cur realm) {
371 acceptOwnership(cur.Previous().Address())
372}
373
374func acceptOwnership(caller address) {
375 if pendingOwner == "" || caller != pendingOwner {
376 panic("not the pending owner")
377 }
378 prev := owner
379 owner, pendingOwner = caller, ""
380 chain.Emit("TransferOwnership", "from", prev.String(), "to", owner.String())
381}
382
383func PendingOwner() address { return pendingOwner }
384
385// SetOperator appoints, rotates or (with an empty address) revokes the hot role.
386//
387// One step, unlike TransferOwnership, and the asymmetry is deliberate: a mistyped owner is
388// permanent and locks the revenue, while a mistyped operator only means quotes stop until the
389// owner sets it again. Making revocation a two-step handshake would be worse than useless, since
390// the address being revoked is the one you no longer trust to act.
391func SetOperator(cur realm, op address) {
392 assertOwner(cur)
393 setOperator(op)
394}
395
396func setOperator(op address) {
397 prev := operator
398 operator = op
399 chain.Emit("SetOperator", "from", prev.String(), "to", op.String())
400}
401
402func Operator() address { return operator }
403
404// assertOwner requires a DIRECT call from the owner. cur.Previous() is the immediate caller, so a
405// relaying realm (owner -> X -> here) would fail this, no separate origin assert needed.
406func assertOwner(cur realm) {
407 if cur.Previous().Address() != owner {
408 panic("owner only")
409 }
410}
411
412// isOperator is the routine-work gate as a pure predicate, so it is unit-testable: assertOperator
413// below cannot be satisfied from a test frame, exactly as assertOwner cannot.
414//
415// The owner always passes. A fresh deploy has no operator yet, so the owner must be able to post
416// the first quote or the realm is unusable until one is appointed, and the owner must be able to
417// take over the moment an operator is revoked.
418func isOperator(caller address) bool {
419 return caller == owner || (operator != "" && caller == operator)
420}
421
422// assertOperator gates the two jobs that run on a schedule. Everything that moves money or changes
423// the terms stays behind assertOwner, including Forgive, which is the repair for an operator that
424// over-charges and therefore must never be reachable by one.
425func assertOperator(cur realm) {
426 if !isOperator(cur.Previous().Address()) {
427 panic("owner or operator only")
428 }
429}
430
431func Render(path string) string {
432 if len(path) > 5 && path[:5] == "addr/" {
433 a := address(path[5:])
434 return "# " + a.String() + "\n\n- tier: " + TierOf(a) +
435 "\n- expires: " + strconv.FormatInt(subs[a], 10) +
436 "\n- credits: " + strconv.FormatInt(credits[a], 10) +
437 "\n- billed through: " + strconv.FormatInt(settled[a], 10) + " cost-units\n"
438 }
439 // Three states, not two. The old text had one branch for "quoteUgnot > 0" and called
440 // everything else "subscriptions are closed", which is wrong twice over on an immutable page:
441 // a LAPSED quote still rendered as "good until <a timestamp in the past>", and a merely absent
442 // quote was announced as the shop being shut. A quote is posted on request and lives for
443 // quoteTtlSec, so "none standing" is the ordinary resting state, not a fault, and this page
444 // cannot tell a quiet moment from an outage. It says what it knows and no more.
445 quoteLine := "- quote: none standing. One is posted on request and is good for " +
446 strconv.FormatInt(quoteTtlSec, 10) + "s once posted.\n"
447 if quoteUgnot > 0 {
448 if time.Now().Unix() <= quotedAt+quoteTtlSec {
449 quoteLine = "- quote: " + strconv.FormatInt(quoteUgnot, 10) + " ugnot / " +
450 strconv.FormatInt(periodSec, 10) + "s, good until " +
451 strconv.FormatInt(quotedAt+quoteTtlSec, 10) + "\n"
452 } else {
453 quoteLine = "- quote: the last one lapsed at " +
454 strconv.FormatInt(quotedAt+quoteTtlSec, 10) +
455 ". A fresh quote is posted on request.\n"
456 }
457 }
458 return "# GnoPulse Access\n\nSubscribe() or TopUp() from your wallet. Send exactly the quote " +
459 "below; it is what you will be charged.\n\n" +
460 "- price: $" + usd(priceUsdCents) + " per " + strconv.FormatInt(periodSec, 10) + "s\n" +
461 quoteLine +
462 "- credits: " + strconv.FormatInt(creditsPerUsdCent, 10) +
463 " API units per US cent, bought at the quote above\n"
464}
465
466// usd renders cents as dollars, keeping the two decimal places (2500 -> "25.00", 2505 -> "25.05").
467func usd(cents int64) string {
468 c := cents % 100
469 pad := "."
470 if c < 10 {
471 pad = ".0"
472 }
473 return strconv.FormatInt(cents/100, 10) + pad + strconv.FormatInt(c, 10)
474}