Dev.to · 11 min read

Your tipster's record is a screenshot. I built one that can't be edited.

Your tipster's record is a screenshot. I built one that can't be edited.

Everyone who posts football picks has a great record. That is not because they are all good at it. It is because the record is a screenshot, and screenshots are taken after the fact. The move is always the same. Post the wins, delete the losses, quietly nudge the confidence number up in the image. Nobody can prove the pick did not say 0.78 at the time. The format has no memory of its own, so the tipster is the memory. I wanted to see what happens when the record lives somewhere the tipster does not control. This post is about the build: an agent that seals its picks to Walrus Memory before kickoff, seals the grade after full time, and cannot move the hash in between. It is called Receipts. The one rule that makes it work Everything in the project comes from a single constraint: A pick is written before the result exists, and the write is content addressed. Content addressing means the identifier of a blob is derived from its bytes. If the text says confidence 0.450, the hash is one value. Change it to confidence 0.780 and the hash is a different value. There is no update operation that keeps the address, so "I edited my pick" becomes "I published a second, contradicting blob", which is visible to anyone who looks. The second half of the constraint is timing. A pick contains no result field. It cannot, because it was sealed before the match was played. So even if the write timestamp is unhelpful, the content itself proves the ordering: you cannot have written this string after seeing a result that is not in it, unless you wrote a pick so vague it means nothing. That is the demo beat. Two screens, the same blob hash at T minus two hours and after full time. Why memory and not a database The obvious objection: a Postgres table with an append-only trigger does the same thing. It does, as long as you trust the person running the database. A tipster with root on the box can rewrite history and nobody is the wiser. The whole point is to move the record out of the tipster's blast radius. Walrus Memory gives three things a table does not: Content addressed blobs. The id is the hash. Verification is a re-read plus a recompute, not a query against a mirror. Owner signed writes. The delegate key belongs to the memory space, so a record has a provenance that is not "some server said so". Semantic recall. The agent can ask its own memory "what did I do on this market with this team" and get blobs back without me maintaining an index. The tradeoff is that recall is approximate. I will get to how the project handles that, because it is the most interesting engineering problem in the build. Architecture browser (vanilla JS) | | GET /api/board, /api/receipts | POST /api/board/pick, /api/verify v Express (server/index.ts, port 4310) | +-- agent.ts sealPick / gradePick / humanPick / gradeFinished +-- engine.ts slateMarket -> assess -> recall + shrinkage +-- ledger.ts pickText / gradeText / parseRecord / gradeAgainst +-- mem.ts seal / recall / health (+ disk backed test double) +-- football.ts TheSportsDB live fixtures, openfootball WC2026 replay | +--> Walrus Memory relayer +--> data/ledger.json rememberAndWait(text, key) blob id + sha256 mirror recall(query, limit) used by verify, never trusted | +--> Sui mainnet (x402 optional) Two writes, one read: sealPick() builds one line of text, hashes it, calls rememberAndWait() with idempotency key pick:, and stores the returned blob id. gradePick() builds a second line, key grade:, and stores that blob id too. assess() calls recall() with a text query, parses what comes back, and counts only records that are grades for that exact market and side. Everything else is presentation. The front end is hand written HTML, CSS and vanilla JS with no build step, deliberately styled like a newspaper page, because hashes and timestamps should read as evidence and not as app chrome. The record format I used pipe delimited text instead of JSON. It hashes predictably, it survives a copy paste into a DM, and a human can read it out loud on video. export function pickText(p: Omit): string { return [ `PICK ${p.id}`, `comp ${clean(p.competition)}`, `home ${clean(p.home)}`, `away ${clean(p.away)}`, `kickoff ${p.kickoff}`, `market ${p.market}`, `side ${clean(p.side)}`, `base ${p.base.toFixed(3)}`, `confidence ${p.confidence.toFixed(3)}`, `verdict ${p.verdict}`, `reasoning ${clean(p.reasoning)}`, `sealed ${p.sealedAt}`, `owner ${clean(p.owner ?? "agent")}`, `kind ${p.kind ?? "agent"}`, ].join(" | "); } A real sealed pick from the ledger: PICK pk-687ce77b90 | comp World Cup 2026 | home Mexico | away South Africa | kickoff 2026-06-11T12:00:00Z | market under_2_5 | side - | base 0.450 | confidence 0.450 | verdict play | reasoning Prior 45.0% on "Under 2.5 goals". My sealed record on this market: 0-0 across 0 graded receipts, shrunk to 45.0%. That moves the number +0.000 to 45.0%. Above the 45.0% floor, so I publish. | sealed 2026-11-08T15:15:30.887Z | owner agent | kind agent And the grade, sealed later, as a separate blob: GRADE pk-687ce77b90 | comp World Cup 2026 | home Mexico | away South Africa | market under_2_5 | side - | final 2-0 | ht 1-0 | result won | graded 2026-11-08T15:15:30.928Z Two records, one pick id. The grade does not modify the pick. That separation is what makes the audit trail work: you can read the pick and know nothing about the result was available, then read the grade and check it against the scoreline yourself. Note the owner and kind fields. They are there so a human can be sealed through the identical function and be indistinguishable, structurally, from the agent. The number is the interesting part A handicapper that always says "70%" is not making a claim, it is making a noise. I wanted the confidence to be a function of memory, so that remembering a bad run visibly changes the pick. The engine starts from a market prior and shrinks it toward the agent's own graded record: const PRIOR_WEIGHT = 3; const FLOOR = 0.45; const n = won + lost; const shrunk = (won + PRIOR_WEIGHT * base) / (n + PRIOR_WEIGHT); const delta = shrunk - base; const confidence = Number(clamp(shrunk, 0.05, 0.95).toFixed(3)); const verdict = n >= 5 && shrunk < 0.35 ? "pass" : confidence >= FLOOR ? "play" : "pass"; PRIOR_WEIGHT = 3 is three pseudo observations at the prior. It does two jobs: Early on, the prior dominates, so the agent does not swing wildly on one result. Later, the record dominates, so the agent has to live with its own history. The worked example that ships in the demo is a 0.70 market where the agent remembers going 1 for 6: (1 + 3 * 0.70) / (6 + 3) = 0.344 That is a 0.70 turning into a 0.34 because of what the agent remembers about itself. The delta bar on the board is that subtraction, drawn. On the current ledger the comeback market (team trails at half, wins) is 0-9, which is why the number printed there collapses to the floor and the pick gets benched instead of published. The reasoning string is built from the same variables as the number: Prior 16.0% on "USA trails at half, wins". My sealed record on this market: 0-0 across 0 graded receipts, shrunk to 16.0%. That moves the number +0.000 to 16.0%. Below my 45.0% publishing floor, so I pass. No separate generation step means the sentence cannot disagree with the figure. If there is an LLM in the loop at all, it writes prose around a number that already exists, and the server runs fine without it. Recall is fuzzy, the count is not This is where most "agent with memory" demos quietly cheat. Semantic recall returns approximations. If you count those approximations, your hit rate is fiction. The fix is to use recall for candidate generation and exact parsing for the count: const query = `GRADE ${label} ${side} graded result won lost`; const hits = await recall(query, 30); for (const h of hits) { const fields = parseRecord(h.text); const counted = kindOf(h.text) === "GRADE" && fields.market === market && fields.side === side; if (counted) fields.result === "won" ? won++ : lost++; } Recall decides what to look at. The record decides what to count. A bad recall returns fewer receipts, which makes the agent more cautious, never more confident. That failure mode is the one I want: if memory breaks, the agent gets quieter, not louder. The ledger that hides nothing The replayed ledger, over real World Cup 2026 fixtures with real full time and half time scores: 50 receipts, 99 seals, 49 graded Overall 22-26, hit rate 0.458 Played picks: 38 at 21-17, 0.553 Benched picks: 10 at 1-9 That last line is the one I would put on a billboard. A publishing floor is worthless if the floor hides its own record. Every pick the agent passed on is in the table with its grade, so the board shows both the thing it got right and the tournament it sat out. It also happens to be the honest answer to "is this profitable". At 0.553 on played picks it is a decent tipster and not an oracle, and now the claim is checkable. The two sided board The second half of the idea: humans join with their own memory space and a delegate key, and get graded by the same rules. export async function humanPick(name, fixture, market, side, confidence) { const id = `hu-${hashOf(`${name}:${fixture.id}:${market}`).slice(0, 10)}`; const recordText = pickText({ ...base, owner: name, kind: "human" }); const written = await seal(recordText, `pick:${id}`); ... } Same pickText(), same seal(), same grading function. Agent picks get pk- ids, human picks get hu-. The standings strip shows the agent and each human side by side with the play record and the pass record separated. The point is not a leaderboard. The point is that a human playing the same game cannot retro-edit either, so the comparison means something. A leaderboard where only the agent is audited is a marketing page. Payments, and the honesty problem in reverse GET /api/receipt/:id/verified returns the full receipt with evidence and a hash check for 0.10 SUI, using x402. Unpaid, it returns 402 with an accepts array. Paid, the server verifies the digest, checks the balance change went to payTo, and guards against replay with a seen set. The part worth writing down is the mock. The verification path can run with X402_MOCK=1, which returns mode: "demo" and makes the UI say so on screen. I could have left the mock silent and let a screenshot imply chain settlement. That would have been the same dishonesty the project exists to argue against, so the mock is labelled everywhere it can be seen: in the response body, in the UI, in the boot banner. Same for memory. With a real delegate key the server writes to the Walrus relayer. Without one it can run against a disk backed double, and the boot log says: memory: TEST DOUBLE (data/fake-memory.json) - not a submission run A verification demo is only worth the parts a sceptic can check. What broke, and what I would change Confidence math, first attempt. I shrank toward 0.5 with a damped delta, and could not reach 0.34 no matter what the record said. Shrinking toward the market prior with a fixed pseudo count fixed it and made the arithmetic explainable in one sentence. Timestamps lie in a replay. Seeding 48 historic fixtures in one evening gives every write a timestamp of tonight. The ordering claim therefore rests on the content, not the clock, and the UI says that in plain words rather than hoping nobody notices. The live path (npm run seal:upcoming) exists to make the argument literal for the next real fixture. Idempotency is not optional. rememberAndWait(text, "pick:") means a retry cannot double-seal. Without the key, a flaky network turns one pick into three blobs and the record gets muddy. Mobile tables. Two six column tables, no framework, and horizontal overflow on a phone. Wrapping them in an overflow container with a min-width under 760px was the fix, and it was the least glamorous hour of the build. Next. A calibration curve is the most convincing thing missing. Bucketing graded picks by published confidence and plotting predicted against realised would show whether the shrink logic is actually calibrated, instead of only showing that it wins more than it loses. Try it git clone receipts cd receipts npm install cp .env.example .env MEMWAL_FAKE=1 npm run seed MEMWAL_FAKE=1 npm start Open http://localhost:4310. With a real delegate key in .env, the same commands without MEMWAL_FAKE write to mainnet Walrus Memory: npm run mem:check, then npm run seed, npm run seal:upcoming, npm run grade, npm run verify. If you build the calibration curve, or a public verifier page per receipt, send me the blob id. Code & more: https://www.dailybuild.xyz/project/308-receipts

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More AI & Machine Learning News