Skip to content

Add sbor skill: benchmark lending rate for Stacks - #422

Open
vidardigital wants to merge 17 commits into
aibtcdev:mainfrom
sborxyz:main
Open

vidardigital wants to merge 17 commits into
aibtcdev:mainfrom
sborxyz:main

Conversation

@vidardigital

Copy link
Copy Markdown

Adds a read-only skill for SBOR, the benchmark lending rate for Stacks. sbor.xyz

Several skills here make rate decisions with no reference rate to judge against: sbtc-yield-maximizer compares Zest against Bitflow HODLMM, zest-auto-repay guards LTV, bitflow-zest-sbtc-leverage-cycle runs the loop, yield-hunter routes idle sBTC. This gives them a market rate.

The main command is compare: give it a rate you have been offered and it returns whether it is above or below the market, by how many basis points, and which venue is cheapest with its utilisation.

No wallet, no keys, no funds, no rate limit. It reads a public endpoint and returns JSON. Tagged read-only, mainnet-only, defi, l2.

AGENT.md carries the guardrails: never substitute an estimate if SBOR is unreachable, never treat a missing index as zero, never compare the PoX staking yield with a lending rate.

SBOR takes no payment from any venue it measures and does not trade on its own rate.

@vidardigital

Copy link
Copy Markdown
Author

Some context on why I built this.

I use lending on Stacks and kept opening three apps to work out whether a rate was any good. There was no neutral place that would just tell me, so I built SBOR and published it as a public good. It has been fixing daily since 1 September and reads rates from Zest and Granite contract state rather than from either app.

What made me think it belonged here rather than just on a website: several skills already in this repo are making rate decisions with nothing to judge against. Reading the descriptions in your README, sbtc-yield-maximizer, zest-auto-repay, bitflow-zest-sbtc-leverage-cycle and yield-hunter are each implicitly answering "is this rate good," and there was no benchmark to answer it with.

Happy to change anything about the shape of it. I followed the SKILL.md frontmatter spec and the AGENT.md convention from the README, and the script is Commander with JSON to stdout like the others, but tell me if I have got a convention wrong.

One thing worth flagging: SBOR takes no payment from any venue it measures and does not trade on its own rate. That is written into the skill docs and into the methodology at sbor.xyz. A benchmark that trades on itself is the LIBOR failure and I would rather rule it out in writing.

@biwasxyz biwasxyz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. A neutral reference rate is useful for the lending skills here. I ran every subcommand against the live endpoint and they all work. A few things need to change before it can land, mostly because compare can currently return a confident verdict from bad input or bad data, and AGENT.md tells agents to rely on it before lending actions.

Blocking

  1. Repo checks. bun run validate fails. sbor/SKILL.md is missing metadata.requires (use "") and metadata.author-agent. sbor/AGENT.md has no frontmatter (name, skill, description). The PR also needs a row in the README Skills table and a regenerated skills.json (bun run manifest). See CLAUDE.md, "Adding a New Skill".
  2. compare input (sbor.ts ~141-162). --rate 0.042 (4.2% entered as a fraction) returns "better than market, 230 bps below". --rate -5, --rate 420 and an empty --rate are also accepted with exit 0. Please enforce percent units with bounds, e.g. 0 < rate ≤ 100, and reject empty input.
  3. compare with missing data. If ix.borrow / ix.supply is absent, diff is NaN and the verdict falls through to "better than market". That breaks the AGENT.md rule "never treat a missing index as zero". Fail with exit 1 instead.
  4. Staleness is only a flag. compare still returns a verdict on stale data. A missing or malformed fixing gives staleHours: NaNstale: false. compare should exit 1 when data is stale or the timestamp can't be parsed.

Should fix

  • Add a fetch timeout (AbortSignal.timeout) on both fetches.
  • compare on an unpublished index (SBOR-BTC today) returns published: false with exit 0. Exit 1 is safer for agents that only check exit codes.
  • Docs vs code: the SKILL.md example shows "verdict": "above market", but the code emits "worse than market". The documented rate --date 2026-09-01 returns 404 because the archive starts 2026-09-03.
  • history averages across methodology versions 1.0.0–1.9.0. Consider averaging only within the current version, or returning per-version stats.
  • AGENT.md's "a weight above 0.9 means one venue" misses SBOR-STX, which is single-venue with a max weight of 0.56. Key it on venue count instead.
  • The skill is tagged mainnet-only but ignores NETWORK. Consider erroring when NETWORK !== "mainnet".

For maintainers to weigh (not a code change): this makes a single, young (series from 2026-09-01), unsigned third-party feed a pre-trade guardrail, and the methodology changed seven times in about two weeks. Some staleness and methodology-version reporting in the output would help agents judge how much to trust a given fixing.

@vidardigital

Copy link
Copy Markdown
Author

Thank you for running every subcommand. Three of these were real safety bugs and I would not have found them.

Blocking, all done.

  1. Repo checks. SKILL.md now has metadata.requires and metadata.author-agent, AGENT.md has frontmatter, README row added, skills.json regenerated. bun run validate 204/204, bun run typecheck clean.

  2. compare input. Bounds enforced at 0 < rate <= 100, empty rejected. -5 and 420 exit 1.

  3. Missing data. compare exits 1 when the index is unpublished, when the side has no published rate, or when the difference is not finite. It no longer falls through to a verdict.

  4. Staleness. compare exits 1 on data older than 48h or on a timestamp that cannot be parsed. staleHours is now number | null, so an unparseable timestamp reports stale: true rather than false via NaN.

One I did not do as asked, and I want to be explicit about it.

--rate 0.042 is still answered rather than rejected. Bounds of 0 < rate <= 100 do not catch it, and I could not find a rule that catches it without breaking real queries: stSTX supply on Zest is 0.09% in the current fixing, and sBTC has been reading 0.01% at the contract for a fortnight, which is why SBOR-BTC is excluded rather than published. Rejecting everything below half a percent would refuse legitimate questions about the actual market.

So it is accepted and flagged. Below 0.5 the output carries a unitsWarning, and the plain sentence opens with "Check units first: this was read as 0.042%, not 4.2%." An agent reading either field cannot miss it. If you would rather it hard fail, say so and I will change it, but I did not want to make the tool unable to describe its own data.

Should fix, all done.

  • AbortSignal.timeout(10_000) on both fetches.
  • compare on an unpublished index exits 1.
  • Docs: verdict example corrected to "worse than market", --date example corrected to 2026-09-03, and the archive start date is documented with a pointer to archive-index.json.
  • history no longer averages across methodology versions. It returns byMethodologyVersion and withholds a single meanBorrow when the window spans more than one, with a note saying why.
  • AGENT.md keys concentration on venueCount rather than weight, and calls out SBOR-STX as the case that breaks the weight heuristic. compare returns venueCount.
  • NETWORK is checked. Anything other than mainnet exits 1 before any fetch.

On the maintainer question, which I think is the right one to ask.

You are correct that this is a young, unsigned, single feed whose methodology has changed repeatedly. The series began 2026-09-01 and I have revised it every time I found something wrong, most recently when the venue confirmed its contract returns nominal rather than compounded rates.

One correction to my own record while you are looking at it: the first two fixings, 1 and 2 September, carry no methodologyVersion at all, because the field did not exist yet. Every fixing from 3 September does. I am not backfilling those two rows, because adding a field to a published fixing is still editing the record, and I would rather the gap show. history reports them as version unknown and excludes them from any grouped mean.

Every change since is published as a documented step rather than smoothed over, but that is a reason to read the version, not a reason to trust the number more.

What I would offer in mitigation is the direction of the guardrail. AGENT.md uses SBOR to stop an agent, never to start one: if an offer is more than 50 bps above the benchmark, pause and ask a human. A wrong SBOR reading under that pattern causes an unnecessary pause, not a bad trade. I have made that explicit, and added a "How much to trust this" section to SKILL.md saying plainly that it is one feed maintained by one person, that it should be treated as a sanity check rather than a settlement price, and that compare refuses rather than guesses.

Every response now carries methodologyVersion, staleHours, stale and, where relevant, a staleNote and a note on the methodology's age.

If you would rather this not be a pre-trade guardrail at all until the record is longer, I would understand that and would rather you say so than merge it on my framing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants