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 access45import (6 "chain"7 "chain/banker"8 "chain/runtime"9 "chain/runtime/unsafe"10 "strconv"11 "time"12)1314var (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")1819 // 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 price21 // and see that we quoted honestly.22 priceUsdCents int64 = 250023 periodSec int64 = 30 * 24 * 60 * 602425 // A credit IS one API cost-unit. There is no second conversion: the old model bought credits26 // per ugnot and burned 100 of them per unit, which made credits GNOT-denominated while the27 // 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 the30 // 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: a33 // million units is 625 cents with no remainder.34 creditsPerUsdCent int64 = 16003536 // TopUp multiplies the payment by priceUsdCents and creditsPerUsdCent before dividing, so a37 // large enough payment would overflow int64 before the divide. A million GNOT is far beyond38 // any real top-up and leaves three orders of magnitude of headroom.39 maxTopUpUgnot int64 = 1_000_000_000_0004041 // --- the quote ---------------------------------------------------------------------------42 //43 // A fiat price in a volatile coin is quoted, not computed at settlement. We convert44 // priceUsdCents to ugnot off chain (where the price feed is) and post the result here; that45 // number IS the obligation until it expires. The payment page reads quoteUgnot from this46 // realm rather than from any feed of ours, so the figure on screen and the figure charged are47 // 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 for50 // 15 minutes, BTCPay for 15, NOWPayments for 10. Slippage is a trading idea and has no place51 // here; nobody shows one number and charges another.52 quoteUgnot int64 // ugnot for one period53 quotedAt int64 // when it was posted5455 // The superseded quote stays payable for a grace window, so a payer who was quoted a moment56 // before we posted a new number still pays what they were shown rather than being repriced57 // mid-flight. currentPrice charges whichever of the two is lower while both are live.58 prevUgnot int6459 prevUntil int646061 // How long a posted quote is good for, and how long a superseded one stays payable. If the62 // job that posts quotes stops, Subscribe refuses rather than pricing off a stale number: a63 // broken cron becomes failed payments, never wrong charges.64 quoteTtlSec int64 = 90065 quoteGraceSec int64 = 9006667 // The grace window exists so a small tick does not reprice a payment in flight. It must NOT68 // survive a correction: currentPrice charges the LOWER of the two live quotes, so carrying a69 // wildly wrong quote forward means posting the right one keeps the wrong one payable for a70 // FRESH grace window. Correcting the mistake would extend it past the point it would have71 // 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 = 47475 // Ownership moves in two steps. A single-step transfer to a mistyped address is76 // unrecoverable here: address is an unvalidated string type, so any non-empty value is77 // accepted, and the realm is immutable, so a wrong owner locks Withdraw and every admin78 // function forever. The offer sits here until the named address claims it itself, which is79 // proof the key exists and can transact.80 pendingOwner address8182 // operator is the HOT role. Two of the owner's jobs are not administrative at all: SetQuote83 // runs every five minutes and Consume every six hours, both from an unattended cron on a84 // public-facing box. With one role, automating them means the key that can Withdraw must sit85 // in a keybase on that box. Splitting them means a stolen box costs quote posting, not the86 // 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 one89 // transaction from their own wallet, without touching anything else.90 operator address9192 subs = map[address]int64{} // addr -> expiry (unix)93 credits = map[address]int64{} // addr -> credit balance9495 // 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 usage97 // that was already paid for -- Consume rejects any total at or below what it already settled.98 settled = map[address]int64{}99)100101// Subscribe buys pro time for the caller at the live quote. Each whole quote sent buys one102// period; anything left over becomes units at the same rate a top-up gets, rather than being103// 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 period106// costs today. Pinning a renewal to the price paid at first purchase is a known trap: Unlock107// 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}112113// subscribe holds the money math, separate from the origin/caller plumbing above so it can be114// 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: BitPay119 // 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 = now125 }126 subs[payer] = base + (paid/price)*periodSec127 change := unitsFor(paid%price, price)128 if change > 0 {129 credits[payer] += change130 }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}137138// Login does nothing. Sign-In-With-Gno signs (never broadcasts) a call to it so the wallet shows139// 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 and142 // no way to return them. Refuse instead.143 if int64(unsafe.OriginSend().AmountOf("ugnot")) > 0 {144 panic("Login takes no payment")145 }146}147148// 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}153154// topUp converts the payment to units at the SAME quote the subscription uses, so a credit is155// 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 panics164 // when there is none: pegging credits to USD means credits need a rate too, so a dead quote165 // 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] += got171 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}177178// unitsFor converts a ugnot payment into API units at a given quote. The payment is worth179// paid/price of a period, a period costs priceUsdCents, and one cent buys creditsPerUsdCent180// 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 / price183}184185func HasAccess(addr address) bool { return subs[addr] > time.Now().Unix() || credits[addr] > 0 }186187func TierOf(addr address) string {188 if subs[addr] > time.Now().Unix() {189 return "pro"190 }191 return "free"192}193194func 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 }198199func Config() (int64, int64, int64, int64, int64) {200 return priceUsdCents, periodSec, creditsPerUsdCent, quoteTtlSec, quoteGraceSec201}202203// 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 the206// watermark itself and charges only the part it has not charged before. That makes the call207// idempotent (a replay is rejected, not re-billed) and means a restored or rolled-back208// 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; the211// event records both what was owed and what was actually taken, so a shortfall is visible rather212// than silently forgiven.213func Consume(cur realm, addr address, uptoUnits int64) {214 assertOperator(cur)215 consume(addr, uptoUnits)216}217218func consume(addr address, uptoUnits int64) {219 prev := settled[addr]220 if uptoUnits <= prev {221 panic("already settled to that total")222 }223 owed := uptoUnits - prev224 took := owed225 if have := credits[addr]; have <= owed {226 took = have227 delete(credits, addr)228 } else {229 credits[addr] = have - owed230 }231 settled[addr] = uptoUnits232 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}240241// Forgive advances the billing watermark without charging: usage already covered by an active242// time-based subscription. Separate from Consume so that "we decided not to bill this" is its own243// 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}248249func forgive(addr address, uptoUnits int64) {250 prev := settled[addr]251 if uptoUnits <= prev {252 panic("already settled to that total")253 }254 settled[addr] = uptoUnits255 chain.Emit("Forgive",256 "addr", addr.String(),257 "units", strconv.FormatInt(uptoUnits-prev, 10),258 "settledTo", strconv.FormatInt(uptoUnits, 10))259}260261// SetQuote posts the price of one period in ugnot. Called on a short schedule by the job that262// already tracks the GNOT price, which converts priceUsdCents off chain and publishes only the263// result. The previous quote stays payable for quoteGraceSec so payments already in flight are264// not repriced.265func SetQuote(cur realm, ugnotPerPeriod int64) {266 assertOperator(cur)267 setQuote(ugnotPerPeriod)268}269270func 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+quoteGraceSec277 } else {278 // A correction (or the first quote). Drop any standing grace rather than let the279 // superseded number stay payable.280 prevUgnot, prevUntil = 0, 0281 }282 quoteUgnot, quotedAt = ugnotPerPeriod, now283 chain.Emit("SetQuote",284 "ugnot", strconv.FormatInt(ugnotPerPeriod, 10),285 "usdCents", strconv.FormatInt(priceUsdCents, 10),286 "expiresAt", strconv.FormatInt(now+quoteTtlSec, 10))287}288289// Quote is what the payment page reads: the amount to send for one period, and the moment it290// stops being honoured. Reading it from here rather than from a price feed is what makes the291// figure on screen and the figure charged the same number.292func Quote() (int64, int64, int64) {293 return quoteUgnot, quotedAt, quotedAt + quoteTtlSec294}295296// currentPrice is what Subscribe charges. While a superseded quote is still inside its grace297// window both are live, and the payer is charged the lower of the two: we cannot tell which one298// 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 prevUgnot309 }310 return quoteUgnot311}312313func SetConfig(cur realm, cents, period, perUsdCent, ttl, grace int64) {314 assertOwner(cur)315 setConfig(cents, period, perUsdCent, ttl, grace)316}317318func 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, perUsdCent323 quoteTtlSec, quoteGraceSec = ttl, grace324 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}331332func 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}341342// TransferOwnership OFFERS the realm to newOwner. It does not hand it over: the offer stands343// 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 an345// address nobody holds, permanently, because the realm cannot be redeployed.346func TransferOwnership(cur realm, newOwner address) {347 assertOwner(cur)348 transferOwnership(newOwner)349}350351// transferOwnership holds the offer itself, split from the owner gate above for the same reason352// 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 = newOwner358 chain.Emit("OwnershipOffered", "from", owner.String(), "to", newOwner.String())359}360361// CancelOwnershipTransfer withdraws a standing offer.362func CancelOwnershipTransfer(cur realm) {363 assertOwner(cur)364 pendingOwner = ""365 chain.Emit("OwnershipOfferCancelled")366}367368// AcceptOwnership completes the handover. Only the offered address can call it, which proves the369// key exists and can sign before anything irreversible happens.370func AcceptOwnership(cur realm) {371 acceptOwnership(cur.Previous().Address())372}373374func acceptOwnership(caller address) {375 if pendingOwner == "" || caller != pendingOwner {376 panic("not the pending owner")377 }378 prev := owner379 owner, pendingOwner = caller, ""380 chain.Emit("TransferOwnership", "from", prev.String(), "to", owner.String())381}382383func PendingOwner() address { return pendingOwner }384385// 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 is388// permanent and locks the revenue, while a mistyped operator only means quotes stop until the389// owner sets it again. Making revocation a two-step handshake would be worse than useless, since390// 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}395396func setOperator(op address) {397 prev := operator398 operator = op399 chain.Emit("SetOperator", "from", prev.String(), "to", op.String())400}401402func Operator() address { return operator }403404// assertOwner requires a DIRECT call from the owner. cur.Previous() is the immediate caller, so a405// 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}411412// isOperator is the routine-work gate as a pure predicate, so it is unit-testable: assertOperator413// 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 post416// the first quote or the realm is unusable until one is appointed, and the owner must be able to417// take over the moment an operator is revoked.418func isOperator(caller address) bool {419 return caller == owner || (operator != "" && caller == operator)420}421422// assertOperator gates the two jobs that run on a schedule. Everything that moves money or changes423// the terms stays behind assertOwner, including Forgive, which is the repair for an operator that424// 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}430431func 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 called440 // 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 absent442 // quote was announced as the shop being shut. A quote is posted on request and lives for443 // quoteTtlSec, so "none standing" is the ordinary resting state, not a fault, and this page444 // 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}465466// usd renders cents as dollars, keeping the two decimal places (2500 -> "25.00", 2505 -> "25.05").467func usd(cents int64) string {468 c := cents % 100469 pad := "."470 if c < 10 {471 pad = ".0"472 }473 return strconv.FormatInt(cents/100, 10) + pad + strconv.FormatInt(c, 10)474}475AcceptOwnership(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
CancelOwnershipTransfer(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
Config() (int64, int64, int64, int64, int64)
Consume(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, addr string, uptoUnits int64)
CreditsOf(addr string) int64
ExpiresAt(addr string) int64
Forgive(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, addr string, uptoUnits int64)
HasAccess(addr string) bool
Login(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
Operator() string
Owner() string
PendingOwner() string
Quote() (int64, int64, int64)
Render(path string) string
SetConfig(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, cents int64, period int64, perUsdCent int64, ttl int64, grace int64)
SetOperator(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, op string)
SetQuote(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, ugnotPerPeriod int64)
SettledOf(addr string) int64
Subscribe(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
TierOf(addr string) string
TopUp(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
TransferOwnership(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, newOwner string)
Withdraw(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, amount int64)
Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.
vm/qrender output, sanitized (docs/render-security.md) and displayed in an empty-sandbox iframe — scripts, forms and popups cannot run. Links stay inert in-preview; right-click to open.