Transaction

AE33E8FBFFDF47…3431D870386F

Block 323,529 · index 0 · indexed

Summary

Hash
AE33E8FBFFDF4719A59A989499C3E4318FD129BB5757BD9556F93431D870386F
Block
323,529
Size
31075 bytes
Gas used
41,547,113 / 200,000,000
Fee
200000 ugnot
Status
success

Messages

Attached funds
10000000 ugnot

Arguments · 9

  1. #1duebook
  2. #2duebook.gno
  3. #3// Package duebook is a pure scheduling primitive for realms that need to // authorize an action now and perform it later. It is to *time* what // feeledger is to *money*: it holds no coins, performs no effects, imports // no chain APIs, and owns no package-level state. The importing realm owns // a *Book, supplies the clock, and performs its own effects. // // # The one idea // // A realm does not ask duebook to execute anything — Gno has no autonomous // execution, so "scheduled" always means "someone sends a transaction // later". What a realm actually needs is for that later transaction to be // authorized exactly once. Claim is that step: // // d, err := book.Claim(id, now) // if err != nil { // panic(err) // not due, expired, cancelled, or already claimed // } // // ... the realm performs its own effect here, under its own authority // // Claim checks due / not-expired / still-open and CONSUMES the deferral in // the same call, before returning. The realm then acts. Replay is not // guarded against, it is structurally impossible: // // - IDs are allocated monotonically from an internal counter and are // NEVER reused, for the lifetime of the Book; // - a successful Claim removes the deferral before returning; // // so at most one Claim per ID can ever succeed, across all transactions, // forever. A second Claim of the same ID returns ErrNotFound whether the // first one happened in this transaction or a year ago. // // Because the realm performs the effect itself, no closure, callback, or // capability ever crosses a realm boundary. duebook cannot be handed code // to run, so it cannot be tricked into running the wrong code. // // # Lifecycle // // A deferral is open from Schedule until exactly one of Claim, Cancel, or // Expire consumes it. There is no other transition and no way back. // // Schedule ──> open ──┬── Claim (now >= DueAt, before ExpiresAt) // ├── Cancel (owner only, any time while open) // └── Expire (anyone, once now >= ExpiresAt) // // Claimability is the half-open interval [DueAt, ExpiresAt): due at DueAt, // no longer claimable at ExpiresAt. A deferral scheduled with ttl == 0 // never expires and has ExpiresAt == 0. // // # State growth // // Consumed deferrals are removed, not archived — the ID counter, not a // tombstone, is what prevents replay, so there is nothing to keep. Open // deferrals are capped per Book at construction. Storage is therefore // bounded by maxOpen regardless of how many deferrals have ever existed. // Audit history belongs in the consuming realm's events. // // # Consumer contract (the parts the package cannot enforce) // // 1. SUPPLY A REAL CLOCK. duebook cannot verify that `now` came from // runtime.ChainHeight() or time.Now().Unix(). A realm that lets a caller // choose `now` has no delay at all. Pass the chain's clock, never a // transaction parameter. This is the single most important obligation // and the one most commonly got wrong. // 2. USE ONE CLOCK CONSISTENTLY. Heights and seconds must not be mixed // within a Book; delay, ttl and now are all in the caller's chosen // unit. // 3. DO NOT EXPORT THE BOOK. A *Book is a mutable handle. Returning one // across a realm boundary hands out the right to schedule, cancel and // claim. Expose your own crossing functions instead; this package // returns Deferral values, never pointers into its state. // 4. AUTHORIZE THE ACTOR. duebook authenticates nothing but ownership on // Cancel. Who may Schedule, and who may Claim, are the realm's policy — // derive the caller from cur.Previous().Address(), not from an argument. // 5. ACT AFTER A SUCCESSFUL CLAIM, IN THE SAME TRANSACTION. Claim's // return value is the authorization. Storing it to act on later // reintroduces the replay window this package exists to close. // // All failures are returned as errors and leave the Book COMPLETELY // UNCHANGED. Must* wrappers are the only functions here that panic. // // The Book is address-agnostic: owners are non-empty strings. Realms // normally use address.String(). package duebook import ( "errors" "strconv" "gno.land/p/nt/avl/v0" ) // MaxPayloadLen bounds a single deferral's payload. The payload is opaque // to duebook — it exists so a realm can recover what it scheduled without // keeping a parallel table. const MaxPayloadLen = 4096 // MaxOpenLimit is the largest maxOpen a Book may be constructed with. It // bounds the worst-case cost of Due and IterateOpen, which scan the open // set. const MaxOpenLimit = 10000 // Errors returned by Book operations. var ( ErrEmptyOwner = errors.New("duebook: empty owner key") ErrPayloadTooBig = errors.New("duebook: payload exceeds MaxPayloadLen") ErrInvalidNow = errors.New("duebook: now must be non-negative") ErrInvalidDelay = errors.New("duebook: delay outside [minDelay, maxDelay]") ErrInvalidTTL = errors.New("duebook: ttl must be non-negative") ErrInvalidConfig = errors.New("duebook: invalid book configuration") ErrBookFull = errors.New("duebook: open deferral cap reached") ErrNotFound = errors.New("duebook: no such open deferral") ErrNotDue = errors.New("duebook: not due yet") ErrExpired = errors.New("duebook: deferral has expired") ErrNotExpired = errors.New("duebook: deferral has not expired") ErrNotOwner = errors.New("duebook: caller does not own this deferral") ErrOverflow = errors.New("duebook: int64 overflow") ErrIDExhausted = errors.New("duebook: identifier space exhausted") ) // Deferral is a scheduled action. It is returned BY VALUE: holders cannot // reach into a Book through it. Payload is opaque to this package. // // ExpiresAt == 0 means the deferral never expires. Otherwise the deferral // is claimable exactly on [DueAt, ExpiresAt). type Deferral struct { ID uint64 Owner string Payload string CreatedAt int64 DueAt int64 ExpiresAt int64 } // IsDue reports whether the deferral has reached its due time at now. func (d Deferral) IsDue(now int64) bool { return now >= d.DueAt } // IsExpired reports whether the deferral is past its expiry at now. // A deferral with no expiry is never expired. func (d Deferral) IsExpired(now int64) bool { return d.ExpiresAt != 0 && now >= d.ExpiresAt } // IsClaimable reports whether Claim would succeed at now, assuming the // deferral is still open. func (d Deferral) IsClaimable(now int64) bool { return d.IsDue(now) && !d.IsExpired(now) } // Book holds the open deferrals of one consuming realm. The zero value is // not usable; construct with New. type Book struct { minDelay int64 maxDelay int64 maxOpen int nextID uint64 // never decreases; IDs are never reused open *avl.Tree // padded id -> Deferral } // New returns an empty Book. // // minDelay is the floor on how far ahead a deferral may be scheduled; 0 // permits same-instant scheduling. maxDelay is the ceiling, and doubles as // the overflow guard on now+delay. maxOpen caps simultaneously open // deferrals and must be in [1, MaxOpenLimit]. // // Units are the caller's choice — block heights or seconds — but must be // used consistently for the life of the Book. func New(minDelay, maxDelay int64, maxOpen int) (*Book, error) { if minDelay < 0 || maxDelay < minDelay { return nil, ErrInvalidConfig } if maxOpen < 1 || maxOpen > MaxOpenLimit { return nil, ErrInvalidConfig } return &Book{ minDelay: minDelay, maxDelay: maxDelay, maxOpen: maxOpen, nextID: 1, open: avl.NewTree(), }, nil } // MustNew is New but panics on error. func MustNew(minDelay, maxDelay int64, maxOpen int) *Book { b, err := New(minDelay, maxDelay, maxOpen) if err != nil { panic(err) } return b } // MinDelay returns the Book's scheduling floor. func (b *Book) MinDelay() int64 { return b.minDelay } // MaxDelay returns the Book's scheduling ceiling. func (b *Book) MaxDelay() int64 { return b.maxDelay } // MaxOpen returns the Book's cap on simultaneously open deferrals. func (b *Book) MaxOpen() int { return b.maxOpen } // OpenCount returns how many deferrals are currently open. func (b *Book) OpenCount() int { return b.open.Size() } // NextID returns the identifier the next Schedule will allocate. It only // ever increases, which is what makes a consumed ID unreusable. func (b *Book) NextID() uint64 { return b.nextID } // Schedule opens a deferral owned by owner, due at now+delay, expiring // ttl after that (ttl == 0 means it never expires). It returns the new // deferral's ID. // // Fails with ErrEmptyOwner, ErrPayloadTooBig, ErrInvalidNow (now < 0), // ErrInvalidDelay (delay outside [MinDelay, MaxDelay]), ErrInvalidTTL // (ttl < 0), ErrBookFull, ErrOverflow, or ErrIDExhausted. On error // nothing is modified. func (b *Book) Schedule(owner, payload string, now, delay, ttl int64) (uint64, error) { if owner == "" { return 0, ErrEmptyOwner } if len(payload) > MaxPayloadLen { return 0, ErrPayloadTooBig } if now < 0 { return 0, ErrInvalidNow } if delay < b.minDelay || delay > b.maxDelay { return 0, ErrInvalidDelay } if ttl < 0 { return 0, ErrInvalidTTL } if b.open.Size() >= b.maxOpen { return 0, ErrBookFull } if b.nextID == 0 { return 0, ErrIDExhausted } dueAt, ok := checkedAdd(now, delay) if !ok { return 0, ErrOverflow } expiresAt := int64(0) if ttl > 0 { expiresAt, ok = checkedAdd(dueAt, ttl) if !ok { return 0, ErrOverflow } } id := b.nextID b.nextID++ b.open.Set(idKey(id), Deferral{ ID: id, Owner: owner, Payload: payload, CreatedAt: now, DueAt: dueAt, ExpiresAt: expiresAt, }) return id, nil } // MustSchedule is Schedule but panics on error. func (b *Book) MustSchedule(owner, payload string, now, delay, ttl int64) uint64 { id, err := b.Schedule(owner, payload, now, delay, ttl) if err != nil { panic(err) } return id } // Claim consumes the deferral and returns it, authorizing the caller to // perform the deferred action NOW, in this transaction. // // It succeeds only while the deferral is open and now is in // [DueAt, ExpiresAt). The deferral is removed BEFORE Claim returns, so a // re-entrant or later Claim of the same ID finds nothing; combined with // non-reused IDs, at most one Claim per ID ever succeeds. // // Fails with ErrInvalidNow, ErrNotFound (never existed, or already // consumed by Claim/Cancel/Expire), ErrNotDue, or ErrExpired. On error // nothing is modified. // // duebook does NOT check who is claiming: whether a deferral is // permissionlessly claimable or restricted to its owner is the consuming // realm's policy, applied before calling Claim. func (b *Book) Claim(id uint64, now int64) (Deferral, error) { if now < 0 { return Deferral{}, ErrInvalidNow } d, ok := b.get(id) if !ok { return Deferral{}, ErrNotFound } if !d.IsDue(now) { return Deferral{}, ErrNotDue } if d.IsExpired(now) { return Deferral{}, ErrExpired } // Consume before returning: the caller acts only after this point, so // the deferral is already gone when the effect runs. b.open.Remove(idKey(id)) return d, nil } // MustClaim is Claim but panics on error. func (b *Book) MustClaim(id uint64, now int64) Deferral { d, err := b.Claim(id, now) if err != nil { panic(err) } return d } // Cancel consumes an open deferral without performing it. Only its owner // may cancel, and cancellation is permitted at any time while the deferral // is open — including after it became due but before anyone claimed it. // // Fails with ErrEmptyOwner, ErrNotFound, or ErrNotOwner. On error nothing // is modified. func (b *Book) Cancel(id uint64, owner string) (Deferral, error) { if owner == "" { return Deferral{}, ErrEmptyOwner } d, ok := b.get(id) if !ok { return Deferral{}, ErrNotFound } if d.Owner != owner { return Deferral{}, ErrNotOwner } b.open.Remove(idKey(id)) return d, nil } // MustCancel is Cancel but panics on error. func (b *Book) MustCancel(id uint64, owner string) Deferral { d, err := b.Cancel(id, owner) if err != nil { panic(err) } return d } // Expire consumes a deferral that is past its expiry, reclaiming its // storage. It is deliberately permissionless: an expired deferral can // never be claimed again, so letting anyone clear it keeps a Book from // silting up with dead entries that block Schedule against maxOpen. // // Fails with ErrInvalidNow, ErrNotFound, or ErrNotExpired (including for // deferrals with no expiry, which never expire). On error nothing is // modified. func (b *Book) Expire(id uint64, now int64) (Deferral, error) { if now < 0 { return Deferral{}, ErrInvalidNow } d, ok := b.get(id) if !ok { return Deferral{}, ErrNotFound } if !d.IsExpired(now) { return Deferral{}, ErrNotExpired } b.open.Remove(idKey(id)) return d, nil } // MustExpire is Expire but panics on error. func (b *Book) MustExpire(id uint64, now int64) Deferral { d, err := b.Expire(id, now) if err != nil { panic(err) } return d } // Get returns an open deferral by ID. The second result is false if the // deferral never existed or has already been consumed — Get cannot tell // those apart, by design: consumed deferrals leave no tombstone. // // Get is a read-only preview and never authorizes anything. Only Claim's // return value authorizes an action. func (b *Book) Get(id uint64) (Deferral, bool) { return b.get(id) } // Due returns up to limit open deferrals that are claimable at now, in // ascending ID order (oldest first). A limit <= 0 returns nothing. // // It scans the open set, so its cost is bounded by MaxOpen. func (b *Book) Due(now int64, limit int) []Deferral { out := []Deferral{} if limit <= 0 || now < 0 { return out } b.open.Iterate("", "", func(_ string, value any) bool { d := value.(Deferral) if d.IsClaimable(now) { out = append(out, d) } return len(out) >= limit }) return out } // Expirable returns up to limit open deferrals that Expire would accept at // now, in ascending ID order. A limit <= 0 returns nothing. func (b *Book) Expirable(now int64, limit int) []Deferral { out := []Deferral{} if limit <= 0 || now < 0 { return out } b.open.Iterate("", "", func(_ string, value any) bool { d := value.(Deferral) if d.IsExpired(now) { out = append(out, d) } return len(out) >= limit }) return out } // IterateOpen calls fn for every open deferral in ascending ID order. // Iteration stops early when fn returns true. // // fn receives a COPY: Deferral is passed by value and holds only scalars // and strings, so fn gets no pointer into the Book and cannot reach past // it — Book's fields are all unexported. // // Two rules for fn, and the second is the one that is easy to miss. // // - Do not Schedule, Claim, Cancel or Expire from inside fn: mutating // the tree while iterating it is undefined. Collect IDs first, then // act after IterateOpen returns. // // - fn runs under the CALLING REALM'S STORAGE AUTHORITY. This method's // receiver is stamped with the importing realm's PkgID, so the borrow // rules leave the realm context set to that realm for the whole // callback, and a top-level fn has no receiver and no declaring realm // to anchor it elsewhere. A callback that re-enters the calling // realm's own mutators therefore does so with that realm's authority. // Never pass a caller-supplied function here from inside a // permission-gated path; pass only a closure this package's consumer // wrote itself. func (b *Book) IterateOpen(fn func(Deferral) bool) { b.open.Iterate("", "", func(_ string, value any) bool { return fn(value.(Deferral)) }) } // get looks up an open deferral without touching the tree otherwise. func (b *Book) get(id uint64) (Deferral, bool) { v := b.open.Get(idKey(id)) if v == nil { return Deferral{}, false } return v.(Deferral), true } // idKey encodes an ID so that avl's lexical key order matches numeric ID // order. math.MaxUint64 is 20 digits, so a fixed 20-wide zero-padded // decimal is both sufficient and unambiguous. func idKey(id uint64) string { s := strconv.FormatUint(id, 10) const width = 20 if len(s) >= width { return s } return zeros[:width-len(s)] + s } const zeros = "00000000000000000000" // checkedAdd returns a+b and reports whether the addition did not // overflow int64. func checkedAdd(a, b int64) (int64, bool) { sum := a + b if (b > 0 && sum < a) || (b < 0 && sum > a) { return 0, false } return sum, true }
  4. #4duebook_test.gno
  5. #5package duebook import "testing" const ( alice = "g1alice" bob = "g1bob" ) // newBook is the default fixture: delay in [10, 1000], 8 open slots. func newBook(t *testing.T) *Book { t.Helper() b, err := New(10, 1000, 8) if err != nil { t.Fatalf("New: %v", err) } return b } // --- construction --------------------------------------------------------- func TestNewRejectsBadConfig(t *testing.T) { cases := []struct { name string min, max int64 maxOpen int }{ {"negative min", -1, 100, 4}, {"max below min", 100, 99, 4}, {"zero maxOpen", 0, 100, 0}, {"negative maxOpen", 0, 100, -1}, {"maxOpen over limit", 0, 100, MaxOpenLimit + 1}, } for _, c := range cases { if _, err := New(c.min, c.max, c.maxOpen); err != ErrInvalidConfig { t.Errorf("%s: got %v, want ErrInvalidConfig", c.name, err) } } } func TestNewAcceptsZeroMinDelay(t *testing.T) { b, err := New(0, 0, 1) if err != nil { t.Fatalf("New(0,0,1): %v", err) } id, err := b.Schedule(alice, "", 100, 0, 0) if err != nil { t.Fatalf("Schedule: %v", err) } // min == max == 0 means due immediately. if _, err := b.Claim(id, 100); err != nil { t.Fatalf("Claim at due instant: %v", err) } } // --- scheduling ----------------------------------------------------------- func TestScheduleComputesWindow(t *testing.T) { b := newBook(t) id, err := b.Schedule(alice, "payload", 1000, 50, 200) if err != nil { t.Fatalf("Schedule: %v", err) } d, ok := b.Get(id) if !ok { t.Fatal("Get: not found") } if d.CreatedAt != 1000 || d.DueAt != 1050 || d.ExpiresAt != 1250 { t.Errorf("window = (%d,%d,%d), want (1000,1050,1250)", d.CreatedAt, d.DueAt, d.ExpiresAt) } if d.Owner != alice || d.Payload != "payload" { t.Errorf("owner/payload = %q/%q", d.Owner, d.Payload) } } func TestScheduleZeroTTLNeverExpires(t *testing.T) { b := newBook(t) id := b.MustSchedule(alice, "", 0, 10, 0) d, _ := b.Get(id) if d.ExpiresAt != 0 { t.Fatalf("ExpiresAt = %d, want 0", d.ExpiresAt) } if d.IsExpired(1 << 62) { t.Error("ttl==0 deferral reported expired") } // Still claimable in the far future. if _, err := b.Claim(id, 1<<62); err != nil { t.Fatalf("Claim far future: %v", err) } } func TestScheduleValidation(t *testing.T) { b := newBook(t) big := make([]byte, MaxPayloadLen+1) cases := []struct { name string owner string payload string now int64 delay int64 ttl int64 want error }{ {"empty owner", "", "", 0, 10, 0, ErrEmptyOwner}, {"payload too big", alice, string(big), 0, 10, 0, ErrPayloadTooBig}, {"negative now", alice, "", -1, 10, 0, ErrInvalidNow}, {"delay below min", alice, "", 0, 9, 0, ErrInvalidDelay}, {"delay above max", alice, "", 0, 1001, 0, ErrInvalidDelay}, {"negative delay", alice, "", 0, -5, 0, ErrInvalidDelay}, {"negative ttl", alice, "", 0, 10, -1, ErrInvalidTTL}, } for _, c := range cases { if _, err := b.Schedule(c.owner, c.payload, c.now, c.delay, c.ttl); err != c.want { t.Errorf("%s: got %v, want %v", c.name, err, c.want) } } if b.OpenCount() != 0 { t.Errorf("failed Schedules mutated the book: OpenCount = %d", b.OpenCount()) } if b.NextID() != 1 { t.Errorf("failed Schedules consumed IDs: NextID = %d", b.NextID()) } } func TestScheduleOverflowGuard(t *testing.T) { b, err := New(0, 1<<62, 4) if err != nil { t.Fatalf("New: %v", err) } const huge = int64(1<<63 - 1) // now + delay overflows. if _, err := b.Schedule(alice, "", huge-10, 1<<62, 0); err != ErrOverflow { t.Errorf("dueAt overflow: got %v, want ErrOverflow", err) } // dueAt + ttl overflows. if _, err := b.Schedule(alice, "", huge-1<<62, 1<<62, huge); err != ErrOverflow { t.Errorf("expiresAt overflow: got %v, want ErrOverflow", err) } if b.OpenCount() != 0 { t.Errorf("overflow left state behind: OpenCount = %d", b.OpenCount()) } } func TestScheduleRespectsCap(t *testing.T) { b, _ := New(0, 100, 2) b.MustSchedule(alice, "", 0, 10, 0) b.MustSchedule(alice, "", 0, 10, 0) if _, err := b.Schedule(alice, "", 0, 10, 0); err != ErrBookFull { t.Fatalf("third Schedule: got %v, want ErrBookFull", err) } // Consuming one frees exactly one slot. b.MustClaim(1, 10) if _, err := b.Schedule(alice, "", 0, 10, 0); err != nil { t.Fatalf("Schedule after Claim: %v", err) } if _, err := b.Schedule(alice, "", 0, 10, 0); err != ErrBookFull { t.Fatalf("cap not re-applied: got %v, want ErrBookFull", err) } } // --- the central claim: exactly-once --------------------------------------- func TestClaimSucceedsExactlyOnce(t *testing.T) { b := newBook(t) id := b.MustSchedule(alice, "do-the-thing", 0, 10, 0) d, err := b.Claim(id, 10) if err != nil { t.Fatalf("first Claim: %v", err) } if d.Payload != "do-the-thing" { t.Errorf("payload = %q", d.Payload) } for i := 0; i < 5; i++ { if _, err := b.Claim(id, 10); err != ErrNotFound { t.Fatalf("replay %d: got %v, want ErrNotFound", i, err) } } } func TestIDsAreNeverReused(t *testing.T) { b := newBook(t) id1 := b.MustSchedule(alice, "", 0, 10, 0) b.MustClaim(id1, 10) if b.OpenCount() != 0 { t.Fatalf("OpenCount = %d after claim", b.OpenCount()) } // The book is empty again, but the next ID must still move forward. id2 := b.MustSchedule(alice, "", 0, 10, 0) if id2 == id1 { t.Fatalf("ID %d reused after the book emptied", id2) } // So the old ID stays permanently unclaimable. if _, err := b.Claim(id1, 10); err != ErrNotFound { t.Errorf("old ID claimable again: %v", err) } } func TestConsumedByAnyPathStaysConsumed(t *testing.T) { // Every terminal path must close the ID to all three transitions. paths := []struct { name string consume func(b *Book, id uint64) }{ {"claim", func(b *Book, id uint64) { b.MustClaim(id, 10) }}, {"cancel", func(b *Book, id uint64) { b.MustCancel(id, alice) }}, {"expire", func(b *Book, id uint64) { b.MustExpire(id, 100) }}, } for _, p := range paths { b := newBook(t) id := b.MustSchedule(alice, "", 0, 10, 50) // due 10, expires 60 p.consume(b, id) if _, err := b.Claim(id, 10); err != ErrNotFound { t.Errorf("%s then Claim: got %v, want ErrNotFound", p.name, err) } if _, err := b.Cancel(id, alice); err != ErrNotFound { t.Errorf("%s then Cancel: got %v, want ErrNotFound", p.name, err) } if _, err := b.Expire(id, 100); err != ErrNotFound { t.Errorf("%s then Expire: got %v, want ErrNotFound", p.name, err) } if _, ok := b.Get(id); ok { t.Errorf("%s: deferral still visible", p.name) } } } // --- the claim window ----------------------------------------------------- func TestClaimWindowIsHalfOpen(t *testing.T) { // Schedule at 0, delay 10, ttl 5 => claimable on [10, 15). cases := []struct { now int64 want error }{ {0, ErrNotDue}, {9, ErrNotDue}, {10, nil}, // due instant is claimable {14, nil}, {15, ErrExpired}, // expiry instant is NOT claimable {16, ErrExpired}, } for _, c := range cases { b := newBook(t) id := b.MustSchedule(alice, "", 0, 10, 5) _, err := b.Claim(id, c.now) if err != c.want { t.Errorf("Claim at now=%d: got %v, want %v", c.now, err, c.want) } // A refused Claim must not consume the deferral. if c.want != nil { if _, ok := b.Get(id); !ok { t.Errorf("refused Claim at now=%d consumed the deferral", c.now) } } } } func TestClaimRejectsNegativeNow(t *testing.T) { b := newBook(t) id := b.MustSchedule(alice, "", 0, 10, 0) if _, err := b.Claim(id, -1); err != ErrInvalidNow { t.Fatalf("got %v, want ErrInvalidNow", err) } if _, ok := b.Get(id); !ok { t.Error("negative now consumed the deferral") } } func TestClaimUnknownID(t *testing.T) { b := newBook(t) if _, err := b.Claim(999, 10); err != ErrNotFound { t.Fatalf("got %v, want ErrNotFound", err) } } // --- cancellation --------------------------------------------------------- func TestCancelOwnerOnly(t *testing.T) { b := newBook(t) id := b.MustSchedule(alice, "", 0, 10, 0) if _, err := b.Cancel(id, bob); err != ErrNotOwner { t.Fatalf("bob cancelling alice's deferral: got %v, want ErrNotOwner", err) } if _, ok := b.Get(id); !ok { t.Fatal("refused Cancel consumed the deferral") } if _, err := b.Cancel(id, ""); err != ErrEmptyOwner { t.Errorf("empty owner: got %v, want ErrEmptyOwner", err) } if _, err := b.Cancel(id, alice); err != nil { t.Fatalf("owner cancel: %v", err) } } func TestCancelWorksAfterDue(t *testing.T) { // Cancellation is allowed while OPEN, not only while pending — an owner // can still pull a deferral back after it became claimable, as long as // nobody claimed it first. b := newBook(t) id := b.MustSchedule(alice, "", 0, 10, 0) if _, err := b.Cancel(id, alice); err != nil { t.Fatalf("cancel after due: %v", err) } } func TestCancelLosesRaceToClaim(t *testing.T) { b := newBook(t) id := b.MustSchedule(alice, "", 0, 10, 0) b.MustClaim(id, 10) if _, err := b.Cancel(id, alice); err != ErrNotFound { t.Fatalf("cancel after claim: got %v, want ErrNotFound", err) } } // --- expiry --------------------------------------------------------------- func TestExpireRequiresExpiry(t *testing.T) { b := newBook(t) // Not yet expired. id := b.MustSchedule(alice, "", 0, 10, 5) // expires 15 if _, err := b.Expire(id, 14); err != ErrNotExpired { t.Errorf("at 14: got %v, want ErrNotExpired", err) } if _, err := b.Expire(id, 15); err != nil { t.Errorf("at 15: %v", err) } // ttl == 0 can never be expired away. never := b.MustSchedule(alice, "", 0, 10, 0) if _, err := b.Expire(never, 1<<62); err != ErrNotExpired { t.Errorf("no-expiry deferral: got %v, want ErrNotExpired", err) } } func TestExpireIsPermissionless(t *testing.T) { b := newBook(t) id := b.MustSchedule(alice, "", 0, 10, 5) // No owner argument at all — anyone may clear a dead entry. if _, err := b.Expire(id, 20); err != nil { t.Fatalf("Expire: %v", err) } } func TestExpiredDeferralCannotBeClaimedOrGriefTheCap(t *testing.T) { b, _ := New(0, 100, 1) id := b.MustSchedule(alice, "", 0, 10, 5) // expires 15 // Cap is full and the entry is dead. if _, err := b.Schedule(bob, "", 20, 10, 0); err != ErrBookFull { t.Fatalf("got %v, want ErrBookFull", err) } if _, err := b.Claim(id, 20); err != ErrExpired { t.Fatalf("got %v, want ErrExpired", err) } // Anyone can unstick it. b.MustExpire(id, 20) if _, err := b.Schedule(bob, "", 20, 10, 0); err != nil { t.Fatalf("Schedule after Expire: %v", err) } } // --- queries -------------------------------------------------------------- func TestDueReturnsOnlyClaimableInIDOrder(t *testing.T) { b := newBook(t) b.MustSchedule(alice, "a", 0, 10, 0) // 1: due 10 b.MustSchedule(alice, "b", 0, 500, 0) // 2: due 500, not yet b.MustSchedule(alice, "c", 0, 10, 5) // 3: due 10, expired at 15 b.MustSchedule(alice, "d", 0, 20, 0) // 4: due 20 got := b.Due(20, 10) if len(got) != 2 { t.Fatalf("Due returned %d entries, want 2", len(got)) } if got[0].ID != 1 || got[1].ID != 4 { t.Errorf("IDs = %d,%d; want 1,4", got[0].ID, got[1].ID) } } func TestDueRespectsLimit(t *testing.T) { b := newBook(t) for i := 0; i < 5; i++ { b.MustSchedule(alice, "", 0, 10, 0) } if got := b.Due(10, 2); len(got) != 2 { t.Errorf("limit 2 returned %d", len(got)) } if got := b.Due(10, 0); len(got) != 0 { t.Errorf("limit 0 returned %d", len(got)) } if got := b.Due(10, -1); len(got) != 0 { t.Errorf("limit -1 returned %d", len(got)) } } func TestExpirable(t *testing.T) { b := newBook(t) b.MustSchedule(alice, "", 0, 10, 5) // 1: expires 15 b.MustSchedule(alice, "", 0, 10, 0) // 2: never expires b.MustSchedule(alice, "", 0, 10, 5) // 3: expires 15 got := b.Expirable(15, 10) if len(got) != 2 || got[0].ID != 1 || got[1].ID != 3 { t.Fatalf("Expirable = %v", got) } if len(b.Expirable(14, 10)) != 0 { t.Error("Expirable before expiry returned entries") } } func TestIterateOpenIsIDOrdered(t *testing.T) { b, _ := New(0, 100, 32) // Schedule enough to cross the 1->2 digit boundary, where naive string // keys would order "10" before "9". for i := 0; i < 12; i++ { b.MustSchedule(alice, "", 0, 1, 0) } prev := uint64(0) n := 0 b.IterateOpen(func(d Deferral) bool { if d.ID <= prev { t.Fatalf("out of order: %d after %d", d.ID, prev) } prev = d.ID n++ return false }) if n != 12 { t.Errorf("iterated %d, want 12", n) } } func TestGetReturnsACopy(t *testing.T) { b := newBook(t) id := b.MustSchedule(alice, "original", 0, 10, 0) d, _ := b.Get(id) d.Payload = "tampered" d.Owner = bob again, _ := b.Get(id) if again.Payload != "original" || again.Owner != alice { t.Fatalf("mutating a returned Deferral changed the book: %+v", again) } } // --- helpers -------------------------------------------------------------- func TestIDKeyOrdering(t *testing.T) { if idKey(9) >= idKey(10) { t.Errorf("idKey(9)=%q not < idKey(10)=%q", idKey(9), idKey(10)) } if len(idKey(1)) != 20 { t.Errorf("idKey(1) width = %d, want 20", len(idKey(1))) } // Largest uint64 is exactly 20 digits and must not be truncated. const maxU64 = ^uint64(0) if got := idKey(maxU64); got != "18446744073709551615" { t.Errorf("idKey(max) = %q", got) } if idKey(maxU64-1) >= idKey(maxU64) { t.Error("ordering breaks at the top of the range") } } func TestCheckedAdd(t *testing.T) { const max = int64(1<<63 - 1) const min = int64(-1 << 63) if _, ok := checkedAdd(max, 1); ok { t.Error("max+1 reported ok") } if _, ok := checkedAdd(min, -1); ok { t.Error("min-1 reported ok") } if v, ok := checkedAdd(5, 7); !ok || v != 12 { t.Errorf("5+7 = %d,%v", v, ok) } } func TestDeferralPredicates(t *testing.T) { d := Deferral{DueAt: 10, ExpiresAt: 20} if d.IsDue(9) || !d.IsDue(10) { t.Error("IsDue boundary") } if d.IsExpired(19) || !d.IsExpired(20) { t.Error("IsExpired boundary") } if d.IsClaimable(9) || !d.IsClaimable(10) || !d.IsClaimable(19) || d.IsClaimable(20) { t.Error("IsClaimable window") } }
  6. #6gnomod.toml
  7. #7module = "gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/duebook" gno = "0.9"
  8. #8/gno.MemPackageType
  9. #9 MPUserAll

Result log

msg:0,success:true,log:,events:[]