1// Package bubblerumble2 is the second Bubble Rumble realm: the same last-bidder-2// wins clock as v1, with the money rearranged so that players want company.3//4// Of every bid: 55% goes to the pot, 30% is paid at once to the earlier bidders5// of that pool pro-rata to what they have paid in, 10% goes to whoever first6// invited the bidder (forever, on every bid they make), 5% to the pool's creator.7// When the clock runs out, 80% of the pot goes to the last bidder and 20% is8// shared among everyone who played under the winner's flag — or all of it to the9// winner if nobody else flew that flag.10//11// So the player on top wants more bids (each one pays them), everyone wants12// their invitees to bid (10% of everything), and countries are teams.13package bubblerumble21415import (16 "chain"17 "chain/banker"18 "chain/runtime/unsafe"19 "math"20 "strconv"21 "strings"22 "time"23)2425const (26 MinWindow int64 = 10 // seconds27 MaxWindow int64 = 30 * 24 * 3600 // 30 days28 MinBasePrice int64 = 1_000 // ugnot, 0.001 GNOT29 MaxBasePrice int64 = 1_000_000_000_000 // ugnot, 1,000,000 GNOT30 MaxNameLen = 4031 HistoryLen = 50 // bids kept per pool for the feed32 RosterMax = 200 // distinct bidders a pool pays dividends and team shares to33 MaxBoost int64 = 10 // the shortest clock a bid can buy is window/MaxBoost3435 // where every bid goes, in percent36 PotShare int64 = 5537 DivShare int64 = 30 // to earlier bidders of the pool, pro-rata by paid-in38 RefShare int64 = 10 // to the bidder's inviter39 CreatorShare int64 = 540 // of the pot at the close: the rest goes to the winner41 TeamShare int64 = 2042)4344// pkgPath must match gnomod.toml: the realm's own address derives from it.45const pkgPath = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble2"4647type Entry struct {48 N int6449 Bidder address50 Flag string51 Price int64 // what was paid; at least the price at the time52 Clock int64 // seconds this bid had to stand53 Pot int64 // pot right after this bid54 At time.Time55}5657// Member is a distinct bidder of a pool: the roster dividends and team shares go to.58type Member struct {59 Addr address60 Flag string61 Paid int64 // gross paid into this pool; the dividend weight62 Earned int64 // dividends received from this pool63}6465type Pool struct {66 ID int6467 Name string68 Creator address69 Window int64 // seconds the last bid must stand to win70 BasePrice int64 // ugnot; the Nth bid costs BasePrice·√N71 Seed int6472 Pot int6473 Bids int6474 LastBidder address75 LastBidAt time.Time76 Clock int64 // seconds the current top bid has to stand77 CreatedAt time.Time78 Paid bool // the pot went out, or the seed back to the creator79 Cancelled bool80 DivPaid int64 // dividends paid out of this pool so far81 RefPaid int6482 CreatorPaid int6483 WinnerFlag string84 TeamPaid int64 // what the winner's team received at the close85 History []Entry86 Roster []*Member87}8889// Player is what the realm remembers about an address across pools.90type Player struct {91 Referrer address // set by the first bid, permanent92 Recruits int6493 Flag string94 Bids int6495 Paid int6496 DivEarned int6497 RefEarned int6498 Won int64 // pots and team shares received99}100101var (102 pools []*Pool103 players = map[address]*Player{}104 teamPaid = map[string]int64{} // flag -> gross bid volume under it105 teamWon = map[string]int64{} // flag -> pots closed under it106 teamSize = map[string]int64{} // flag -> players flying it107 realmAddr = chain.PackageAddress(pkgPath)108)109110// Price of the next bid in a pool with the given base price and n bids so far.111func Price(base, n int64) int64 {112 return int64(math.Ceil(float64(base) * math.Sqrt(float64(n+1))))113}114115func (p *Pool) Deadline() time.Time { return p.LastBidAt.Add(time.Duration(p.Clock) * time.Second) }116func (p *Pool) Over() bool { return p.Bids > 0 && !time.Now().Before(p.Deadline()) }117func (p *Pool) NextPrice() int64 { return Price(p.BasePrice, p.Bids) }118119// ClockFor is the clock a bid buys by paying sent against a price: the window120// divided by the multiple paid, never below window/MaxBoost.121func ClockFor(window, price, sent int64) int64 {122 c := int64(float64(window) * float64(price) / float64(sent))123 if c < window/MaxBoost {124 c = window / MaxBoost125 }126 if c < 1 {127 c = 1128 }129 return c130}131132// CreatePool opens a pool. Any ugnot sent with the call seeds its pot. The133// creator earns CreatorShare of every bid.134func CreatePool(cur realm, name string, window int64, basePrice int64) int64 {135 creator := userCaller(cur)136 name = cleanName(name)137 if window < MinWindow || window > MaxWindow {138 panic("window must be between 10s and 30d")139 }140 if basePrice < MinBasePrice || basePrice > MaxBasePrice {141 panic("base price must be between 0.001 and 1,000,000 GNOT")142 }143 seed := ugnotSent()144 p := &Pool{145 ID: int64(len(pools)) + 1, Name: name, Creator: creator, Window: window,146 BasePrice: basePrice, Seed: seed, Pot: seed, CreatedAt: time.Now(),147 }148 pools = append(pools, p)149 chain.Emit("PoolCreated", "id", itoa(p.ID), "creator", creator.String(), "seed", itoa(seed))150 return p.ID151}152153// Bid pays at least the current price and resets the clock. ref is the address154// that invited the bidder ("" for none; only the first bid ever sets it), flag155// a two-letter country to play under ("" keeps the last one).156func Bid(cur realm, id int64, ref string, flag string) {157 bidder := userCaller(cur)158 p := pool(id)159 if p.Cancelled {160 panic("pool was cancelled")161 }162 if p.Over() {163 panic("pool is closed")164 }165 if p.LastBidder == bidder {166 panic("you are already the last bidder")167 }168 price := p.NextPrice()169 sent := ugnotSent()170 if sent < price {171 panic("send at least " + itoa(price) + "ugnot, got " + itoa(sent) + "ugnot")172 }173 pl := player(bidder)174 if f := cleanFlag(flag); f != "" && f != pl.Flag {175 if pl.Flag != "" {176 teamSize[pl.Flag]--177 }178 pl.Flag = f179 teamSize[f]++180 }181 if pl.Referrer == "" && ref != "" {182 r := address(ref)183 if r.IsValid() && r != bidder {184 pl.Referrer = r185 player(r).Recruits++186 }187 }188189 // where the money goes190 div, refCut, creatorCut := sent*DivShare/100, sent*RefShare/100, sent*CreatorShare/100191 toPot := sent - div - refCut - creatorCut192 if creatorCut > 0 {193 send(cur, p.Creator, creatorCut)194 p.CreatorPaid += creatorCut195 }196 if pl.Referrer != "" && refCut > 0 {197 send(cur, pl.Referrer, refCut)198 p.RefPaid += refCut199 player(pl.Referrer).RefEarned += refCut200 } else {201 toPot += refCut202 }203 toPot += p.payDividends(cur, bidder, div)204 p.Pot += toPot205206 m := p.member(bidder)207 m.Paid += sent208 m.Flag = pl.Flag209 pl.Bids++210 pl.Paid += sent211 if pl.Flag != "" {212 teamPaid[pl.Flag] += sent213 }214 p.Bids++215 p.LastBidder = bidder216 p.LastBidAt = time.Now()217 p.Clock = ClockFor(p.Window, price, sent)218 p.History = append(p.History, Entry{N: p.Bids, Bidder: bidder, Flag: pl.Flag, Price: sent, Clock: p.Clock, Pot: p.Pot, At: p.LastBidAt})219 if len(p.History) > HistoryLen {220 p.History = p.History[len(p.History)-HistoryLen:]221 }222 chain.Emit("Bid", "id", itoa(id), "n", itoa(p.Bids), "bidder", bidder.String(), "paid", itoa(sent), "pot", itoa(p.Pot), "div", itoa(div-(toPot-(sent-div-refCut-creatorCut))))223}224225// payDividends splits div among the pool's earlier bidders, pro-rata to what226// each has paid in, skipping the bidder; whatever cannot be placed (no earlier227// bidders, rounding) is returned to go into the pot.228func (p *Pool) payDividends(cur realm, bidder address, div int64) int64 {229 var total int64230 for _, m := range p.Roster {231 if m.Addr != bidder {232 total += m.Paid233 }234 }235 if total == 0 || div == 0 {236 return div237 }238 var paid int64239 for _, m := range p.Roster {240 if m.Addr == bidder {241 continue242 }243 amt := div * m.Paid / total244 if amt > 0 {245 send(cur, m.Addr, amt)246 m.Earned += amt247 player(m.Addr).DivEarned += amt248 paid += amt249 }250 }251 p.DivPaid += paid252 return div - paid253}254255// member finds or adds a bidder on the roster; a full roster drops its oldest.256func (p *Pool) member(a address) *Member {257 for _, m := range p.Roster {258 if m.Addr == a {259 return m260 }261 }262 if len(p.Roster) >= RosterMax {263 p.Roster = p.Roster[1:]264 }265 m := &Member{Addr: a}266 p.Roster = append(p.Roster, m)267 return m268}269270// Claim pays out a finished pool: 80% to the winner, 20% shared among the271// roster members flying the winner's flag (pro-rata by paid-in, the winner272// included) — or everything to the winner when nobody else flew it. Anyone may call it.273func Claim(cur realm, id int64) {274 p := pool(id)275 if p.Cancelled {276 panic("pool was cancelled")277 }278 if !p.Over() {279 panic("clock still running")280 }281 if p.Paid {282 panic("already paid out")283 }284 p.Paid = true285 winner := p.LastBidder286 flag := ""287 if m := p.find(winner); m != nil {288 flag = m.Flag289 }290 p.WinnerFlag = flag291 var team int64292 var mates int293 if flag != "" {294 for _, m := range p.Roster {295 if m.Flag == flag {296 team += m.Paid297 mates++298 }299 }300 }301 rest := p.Pot302 if mates > 1 && team > 0 {303 cut := p.Pot * TeamShare / 100304 for _, m := range p.Roster {305 if m.Flag != flag {306 continue307 }308 amt := cut * m.Paid / team309 if amt > 0 {310 send(cur, m.Addr, amt)311 player(m.Addr).Won += amt312 rest -= amt313 p.TeamPaid += amt314 }315 }316 }317 send(cur, winner, rest)318 player(winner).Won += rest319 if flag != "" {320 teamWon[flag] += p.Pot321 }322 chain.Emit("Paid", "id", itoa(id), "winner", winner.String(), "pot", itoa(p.Pot), "team", itoa(p.TeamPaid))323}324325// Cancel lets the creator take the seed back from a pool nobody has bid on.326func Cancel(cur realm, id int64) {327 caller := userCaller(cur)328 p := pool(id)329 if p.Creator != caller {330 panic("only the creator can cancel")331 }332 if p.Bids > 0 {333 panic("pool already has bids")334 }335 if p.Cancelled {336 panic("already cancelled")337 }338 p.Cancelled = true339 p.Paid = true340 if p.Pot > 0 {341 send(cur, caller, p.Pot)342 }343 chain.Emit("Cancelled", "id", itoa(id))344}345346func (p *Pool) find(a address) *Member {347 for _, m := range p.Roster {348 if m.Addr == a {349 return m350 }351 }352 return nil353}354355func player(a address) *Player {356 pl := players[a]357 if pl == nil {358 pl = &Player{}359 players[a] = pl360 }361 return pl362}363364func send(cur realm, to address, ugnot int64) {365 banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(realmAddr, to, chain.Coins{{"ugnot", ugnot}})366}367368func pool(id int64) *Pool {369 if id < 1 || id > int64(len(pools)) {370 panic("no such pool")371 }372 return pools[id-1]373}374375// userCaller is the signer of the transaction, refusing calls relayed through376// another realm so nobody can bid on someone else's behalf.377func userCaller(cur realm) address {378 prev := cur.Previous()379 if !prev.IsUserCall() {380 panic("must be a direct user call")381 }382 return prev.Address()383}384385func ugnotSent() int64 {386 sent := unsafe.OriginSend()387 for _, c := range sent {388 if c.Denom != "ugnot" {389 panic("only ugnot is accepted")390 }391 }392 return sent.AmountOf("ugnot")393}394395func cleanName(s string) string {396 s = strings.TrimSpace(s)397 if s == "" || len(s) > MaxNameLen {398 panic("name must be 1-40 characters")399 }400 for _, c := range s {401 if c < 0x20 || c == 0x7f {402 panic("name has control characters")403 }404 }405 return s406}407408// cleanFlag accepts a two-letter country code, upper-cased; anything else is "".409func cleanFlag(s string) string {410 s = strings.ToUpper(strings.TrimSpace(s))411 if len(s) != 2 || s[0] < 'A' || s[0] > 'Z' || s[1] < 'A' || s[1] > 'Z' {412 return ""413 }414 return s415}416417func itoa(n int64) string { return strconv.FormatInt(n, 10) }418419func parseID(s string) int64 {420 n, err := strconv.ParseInt(s, 10, 64)421 if err != nil {422 panic("bad pool id")423 }424 return n425}426427// GNOT formats ugnot as GNOT with the trailing zeros trimmed.428func GNOT(ugnot int64) string {429 whole, frac := ugnot/1_000_000, ugnot%1_000_000430 if frac == 0 {431 return itoa(whole) + " GNOT"432 }433 f := strconv.FormatInt(1_000_000+frac, 10)[1:]434 return itoa(whole) + "." + strings.TrimRight(f, "0") + " GNOT"435}436437func short(a address) string {438 s := a.String()439 if len(s) < 12 {440 return s441 }442 return s[:8] + "…" + s[len(s)-4:]443}444445func dur(sec int64) string {446 if sec <= 0 {447 return "0s"448 }449 d, h, m, s := sec/86400, sec%86400/3600, sec%3600/60, sec%60450 out := ""451 if d > 0 {452 out += itoa(d) + "d "453 }454 if d > 0 || h > 0 {455 out += itoa(h) + "h "456 }457 if d > 0 || h > 0 || m > 0 {458 out += itoa(m) + "m "459 }460 return out + itoa(s) + "s"461}462463// --- rendering ---------------------------------------------------------------464465// Render serves gnoweb ("" and "<id>") and the page ("json", "json/<id>",466// "me/<address>", "teams").467func Render(path string) string {468 path = strings.Trim(path, "/")469 switch {470 case path == "json":471 return jsonAll()472 case strings.HasPrefix(path, "json/"):473 return pool(parseID(path[5:])).json(true)474 case strings.HasPrefix(path, "me/"):475 return jsonMe(address(path[3:]))476 case path == "teams":477 return jsonTeams()478 case path == "":479 return markdownAll()480 default:481 return pool(parseID(path)).markdown()482 }483}484485func unix(t time.Time) string {486 if t.IsZero() {487 return "0"488 }489 return itoa(t.Unix())490}491492func jbool(b bool) string {493 if b {494 return "true"495 }496 return "false"497}498499func (p *Pool) json(detail bool) string {500 var b strings.Builder501 b.WriteString("{\"id\":" + itoa(p.ID))502 b.WriteString(",\"name\":" + strconv.Quote(p.Name))503 b.WriteString(",\"creator\":\"" + p.Creator.String() + "\"")504 b.WriteString(",\"window\":" + itoa(p.Window))505 b.WriteString(",\"basePrice\":" + itoa(p.BasePrice))506 b.WriteString(",\"seed\":" + itoa(p.Seed))507 b.WriteString(",\"pot\":" + itoa(p.Pot))508 b.WriteString(",\"bids\":" + itoa(p.Bids))509 b.WriteString(",\"nextPrice\":" + itoa(p.NextPrice()))510 b.WriteString(",\"lastBidder\":\"" + p.LastBidder.String() + "\"")511 b.WriteString(",\"lastBidAt\":" + unix(p.LastBidAt))512 b.WriteString(",\"clock\":" + itoa(p.Clock))513 b.WriteString(",\"createdAt\":" + unix(p.CreatedAt))514 b.WriteString(",\"over\":" + jbool(p.Over()))515 b.WriteString(",\"paid\":" + jbool(p.Paid))516 b.WriteString(",\"cancelled\":" + jbool(p.Cancelled))517 b.WriteString(",\"bidders\":" + itoa(int64(len(p.Roster))))518 b.WriteString(",\"divPaid\":" + itoa(p.DivPaid))519 b.WriteString(",\"refPaid\":" + itoa(p.RefPaid))520 b.WriteString(",\"creatorPaid\":" + itoa(p.CreatorPaid))521 b.WriteString(",\"teamPaid\":" + itoa(p.TeamPaid))522 flag := p.WinnerFlag523 if m := p.find(p.LastBidder); m != nil && flag == "" {524 flag = m.Flag525 }526 b.WriteString(",\"flag\":\"" + flag + "\"")527 // what each flag has paid into this pool528 b.WriteString(",\"teams\":{")529 seen := map[string]int64{}530 order := []string{}531 for _, m := range p.Roster {532 if m.Flag == "" {533 continue534 }535 if _, ok := seen[m.Flag]; !ok {536 order = append(order, m.Flag)537 }538 seen[m.Flag] += m.Paid539 }540 for i, f := range order {541 if i > 0 {542 b.WriteString(",")543 }544 b.WriteString("\"" + f + "\":" + itoa(seen[f]))545 }546 b.WriteString("}")547 if detail {548 b.WriteString(",\"history\":[")549 for i := len(p.History) - 1; i >= 0; i-- { // newest first550 h := p.History[i]551 if i < len(p.History)-1 {552 b.WriteString(",")553 }554 b.WriteString("{\"n\":" + itoa(h.N) + ",\"bidder\":\"" + h.Bidder.String() + "\",\"flag\":\"" + h.Flag + "\",\"price\":" + itoa(h.Price) + ",\"clock\":" + itoa(h.Clock) + ",\"pot\":" + itoa(h.Pot) + ",\"at\":" + unix(h.At) + "}")555 }556 b.WriteString("],\"roster\":[")557 for i, m := range p.Roster {558 if i > 0 {559 b.WriteString(",")560 }561 b.WriteString("{\"addr\":\"" + m.Addr.String() + "\",\"flag\":\"" + m.Flag + "\",\"paid\":" + itoa(m.Paid) + ",\"earned\":" + itoa(m.Earned) + "}")562 }563 b.WriteString("]")564 }565 b.WriteString("}")566 return b.String()567}568569func jsonAll() string {570 var b strings.Builder571 b.WriteString("{\"now\":" + itoa(time.Now().Unix()) + ",\"realm\":\"" + pkgPath + "\"")572 b.WriteString(",\"split\":{\"pot\":" + itoa(PotShare) + ",\"div\":" + itoa(DivShare) + ",\"ref\":" + itoa(RefShare) + ",\"creator\":" + itoa(CreatorShare) + ",\"team\":" + itoa(TeamShare) + ",\"roster\":" + itoa(int64(RosterMax)) + "}")573 b.WriteString(",\"pools\":[")574 for i, p := range pools {575 if i > 0 {576 b.WriteString(",")577 }578 b.WriteString(p.json(false))579 }580 b.WriteString("],\"teams\":" + jsonTeams() + "}")581 return b.String()582}583584// jsonTeams is the country leaderboard: paid in, won, and players per flag.585func jsonTeams() string {586 var b strings.Builder587 b.WriteString("{")588 i := 0589 for f, paid := range teamPaid {590 if i > 0 {591 b.WriteString(",")592 }593 i++594 b.WriteString("\"" + f + "\":{\"paid\":" + itoa(paid) + ",\"won\":" + itoa(teamWon[f]) + ",\"players\":" + itoa(teamSize[f]) + "}")595 }596 b.WriteString("}")597 return b.String()598}599600// jsonMe is what the page shows a connected wallet about itself.601func jsonMe(a address) string {602 pl := players[a]603 if pl == nil {604 pl = &Player{}605 }606 return "{\"addr\":\"" + a.String() + "\",\"referrer\":\"" + pl.Referrer.String() + "\",\"recruits\":" + itoa(pl.Recruits) +607 ",\"flag\":\"" + pl.Flag + "\",\"bids\":" + itoa(pl.Bids) + ",\"paid\":" + itoa(pl.Paid) +608 ",\"divEarned\":" + itoa(pl.DivEarned) + ",\"refEarned\":" + itoa(pl.RefEarned) + ",\"won\":" + itoa(pl.Won) + "}"609}610611func (p *Pool) status() string {612 switch {613 case p.Cancelled:614 return "cancelled"615 case p.Over():616 s := "won by " + short(p.LastBidder)617 if !p.Paid {618 s += " (unclaimed)"619 }620 return s621 case p.Bids == 0:622 return "no bids yet · " + dur(p.Window) + " window · next " + GNOT(p.NextPrice())623 default:624 left := p.Deadline().Unix() - time.Now().Unix()625 s := dur(left) + " left"626 if p.Clock < p.Window {627 s += " of a " + dur(p.Clock) + " clock"628 }629 return s + " · " + short(p.LastBidder) + " on top · next " + GNOT(p.NextPrice())630 }631}632633func markdownAll() string {634 var b strings.Builder635 b.WriteString("# Bubble Rumble\n\n")636 b.WriteString("Every bid goes into a pool's pot and resets its clock; the Nth bid costs base·√N. ")637 b.WriteString("Of every bid, 55% is pot, 30% is paid at once to the pool's earlier bidders, 10% to whoever invited you, 5% to the pool's creator. ")638 b.WriteString("When the clock runs out the last bidder takes 80% and the players under the winner's flag share 20%. Play at [bubblerumble.net](https://bubblerumble.net).\n\n")639 if len(pools) == 0 {640 b.WriteString("_No pools yet._\n")641 return b.String()642 }643 b.WriteString("## Pools\n\n")644 for i := len(pools) - 1; i >= 0; i-- {645 p := pools[i]646 b.WriteString("- [" + p.Name + "](?" + itoa(p.ID) + ") — **" + GNOT(p.Pot) + "** · " + itoa(p.Bids) + " bids · " + p.status() + "\n")647 }648 return b.String()649}650651func (p *Pool) markdown() string {652 var b strings.Builder653 b.WriteString("# " + p.Name + "\n\n")654 b.WriteString("**" + GNOT(p.Pot) + "** in the pot · " + p.status() + "\n\n")655 b.WriteString("Opened by " + short(p.Creator) + " · " + dur(p.Window) + " window · base " + GNOT(p.BasePrice) + " · seed " + GNOT(p.Seed) + " · " + GNOT(p.DivPaid) + " paid to earlier bidders so far\n\n")656 if len(p.History) > 0 {657 b.WriteString("## Bids\n\n")658 for i := len(p.History) - 1; i >= 0; i-- {659 h := p.History[i]660 b.WriteString("- #" + itoa(h.N) + " " + short(h.Bidder) + " " + h.Flag + " paid " + GNOT(h.Price) + " → pot " + GNOT(h.Pot) + "\n")661 }662 }663 return b.String()664}665Bid(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}, id int64, ref string, flag string)
Cancel(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}, id int64)
Claim(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}, id int64)
ClockFor(window int64, price int64, sent int64) int64
CreatePool(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}, name string, window int64, basePrice int64) int64
GNOT(ugnot int64) string
Price(base int64, n int64) int64
Render(path string) string
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.