Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}