From df7b4123459033d1d7dc1d45e4eab6dcc470b210 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 24 Jul 2026 15:26:06 +0200 Subject: [PATCH] rwa-yield-accuracy Sprint 4: activate USTB + OUSG, flip bench live USTB via Superstate Chainlink NAV feed (0x289B5036...); OUSG via Ondo Aave-style oracle getAssetPrice (0x9Cad45a8...). Cohort 1 -> 3 probes. RPC default flipped to https://eth.drpc.org (publicnode rejects archive reads needed for t-30d / t-7d historical eth_call). BUIDL + BENJI stay dormant with clarified notes: BUIDL because distributor calls bulkIssuance (yield mints conflate with new subscriptions); BENJI because Ethereum wrapper has no on-chain NAV function, awaiting Franklin API confirmation. Live 30d numbers from VPS: USTB: 387 bps (vs 352 promised, +35) OUSG: 334 bps (vs 350 promised, -16) --- benchmarks/rwa-yield-accuracy.yml | 12 +-- .../rwa-yield-accuracy/cmd/script/benji.go | 38 +++++----- .../rwa-yield-accuracy/cmd/script/buidl.go | 40 +++++----- .../rwa-yield-accuracy/cmd/script/config.go | 7 +- .../rwa-yield-accuracy/cmd/script/main.go | 39 +++++----- .../rwa-yield-accuracy/cmd/script/ousg.go | 66 ++++++++-------- .../rwa-yield-accuracy/cmd/script/ustb.go | 75 ++++++++----------- 7 files changed, 130 insertions(+), 147 deletions(-) diff --git a/benchmarks/rwa-yield-accuracy.yml b/benchmarks/rwa-yield-accuracy.yml index 4bc404d9..7266b720 100644 --- a/benchmarks/rwa-yield-accuracy.yml +++ b/benchmarks/rwa-yield-accuracy.yml @@ -8,12 +8,12 @@ seo_description: "Signed bps deviation between advertised APY and on-chain distr subtitle: Signed basis-point deviation between the APY each tokenized U.S. Treasury advertises and the yield the smart contract actually distributes on-chain, measured over 30 days. category: RWA -# Draft until the 4 non-USDY probes (BUIDL/USTB dividend, BENJI/OUSG -# NAV) are wired to verified treasury addresses and NAV endpoints. -# V1 ships with USDY only (rebase, fully on-chain verifiable). Others -# will flip to live once each contract address and issuer endpoint has -# been cross-checked with the fund's own documentation. -status: draft +# Live cohort: USDY (rebase), USTB (NAV via Chainlink feed), OUSG +# (NAV via Ondo's Aave-style on-chain oracle). BUIDL + BENJI dormant: +# BUIDL because distributor mints conflate yield with new +# subscriptions, BENJI because the Ethereum wrapper has no on-chain +# NAV function and Franklin has not published a JSON endpoint yet. +status: live metric: Yield deviation unit: bps higher_is_better: true diff --git a/harnesses/rwa-yield-accuracy/cmd/script/benji.go b/harnesses/rwa-yield-accuracy/cmd/script/benji.go index 95e101f9..2b4458f7 100644 --- a/harnesses/rwa-yield-accuracy/cmd/script/benji.go +++ b/harnesses/rwa-yield-accuracy/cmd/script/benji.go @@ -11,31 +11,29 @@ import ( ) // BENJI is Franklin Templeton's OnChain U.S. Government Money Fund -// share tokenized primarily on Stellar, with an Ethereum wrapper. NAV -// appreciation model: share price grows daily as yield accrues; there -// are no on-chain dividend transfers to holders. +// share, primarily tokenized on Stellar with an Ethereum wrapper. // -// Ethereum wrapper contract (BENJI): -// 0x... (TODO Sprint 3: confirm Ethereum wrapper address; primary -// chain is Stellar) +// Ethereum wrapper contract: +// 0x3DDc84940Ab509C11B20B76B466933f40b750dc9 +// (Only 3 holders on Ethereum, ~$47.9M AUM as of 2026-07; +// the vast majority of BENJI supply lives on Stellar.) // -// NAV source: Franklin Templeton's fund page publishes 7-day SEC yield -// and current NAV. V1 uses the current NAV endpoint and stores daily -// snapshots in memory for windowed yield computation (see nav.go). +// DORMANT — no on-chain NAV path. The Ethereum wrapper does not +// expose sharePrice / NAV via a callable function, and the fund is +// designed to hold NAV = $1.00 stable (yield materializes off-chain +// as fund share credits). Delivered yield is only computable via +// Franklin's own data. // -// V1 caveats: -// - Ethereum wrapper may not be widely traded; the "true" BENJI -// supply lives on Stellar. Ethereum totalSupply is a lower bound. -// - Franklin's official endpoint URL is TBD (V1 uses a placeholder; -// Sprint 3 verifies via Franklin's own dashboard). -// - The 30d yield only becomes reliable after 30 days of harness -// uptime, since we bootstrap NAV history from scratch. +// Outreach sent to digitalassets@franklintempleton.com asking for a +// JSON endpoint (or Chainlink feed) exposing daily NAV / 7-day SEC +// yield. Activate this probe once Franklin confirms an endpoint; +// update benjiNAVEndpoint and the fetchJSONNumber path below. const ( - benjiContractEthereum = "0x0000000000000000000000000000000000000000" // TODO Sprint 3 - // Franklin publishes the fund's current 7-day SEC yield on its - // public fund page. Actual JSON endpoint TBD Sprint 3; placeholder - // URL fails cleanly and the probe reports nav_source_err. + benjiContractEthereum = "0x3DDc84940Ab509C11B20B76B466933f40b750dc9" + // Placeholder: Franklin's public JSON endpoint is not yet published. + // The probe will fail cleanly with nav_source_err until this is + // updated to the real endpoint (post outreach reply). benjiNAVEndpoint = "https://franklintempleton.com/api/funds/29386/nav" ) diff --git a/harnesses/rwa-yield-accuracy/cmd/script/buidl.go b/harnesses/rwa-yield-accuracy/cmd/script/buidl.go index 6b3c853c..84ac7836 100644 --- a/harnesses/rwa-yield-accuracy/cmd/script/buidl.go +++ b/harnesses/rwa-yield-accuracy/cmd/script/buidl.go @@ -12,35 +12,29 @@ import ( ) // BUIDL is BlackRock's USD Institutional Digital Liquidity Fund, -// tokenized on Ethereum by Securitize. Share price is fixed at -// exactly $1 by design; yield materializes as monthly USDC dividends -// distributed from the Securitize treasury wallet to BUIDL holders. +// tokenized on Ethereum by Securitize. // -// Ethereum contract (BUIDL ERC-20): -// 0x7712c34205737192402172409a8F7ccef8aA2AEc +// Contracts: +// BUIDL ERC-20: 0x7712c34205737192402172409a8F7ccef8aA2AEc +// Distributor: 0x5072Ed40EBa6bE38C2370cAD1Cb1df0202924e53 +// (Securitize distribution EOA that calls bulkIssuance on BUIDL) // -// Securitize distribution treasury (source of USDC dividends): -// 0x... (verified against Securitize's own docs on Etherscan) +// DORMANT — mechanism mismatch. Research showed the distributor +// mints ADDITIONAL BUIDL rather than transferring USDC. The current +// dividend.go path (sum USDC Transfer events from treasury) reports +// zero for BUIDL and is the wrong model for this token. // -// The 30d delivered yield is the sum of USDC Transfer events from -// treasury to BUIDL holders over the 30-day window, divided by the -// average BUIDL supply across the window (share price = $1), then -// annualized. -// -// V1 caveats: -// - Treasury address is TBD in V1: needs to be verified against -// Etherscan for the specific distribution wallet used by the -// current BUIDL contract. Placeholder address will make the probe -// return zero distributions until confirmed. -// - BUIDL distributes monthly, near the end of each calendar month. -// A 30-day rolling window that ends mid-month will underrepresent -// the yield. Cross-check against lifetime column at publication. +// To activate, replace Measure with a rebase-style implementation +// that reads totalSupply growth over the window (like USDY) AND +// filters out mints attributable to new subscriptions vs yield +// accrual. Ondo/USDY separates these cleanly at the contract level; +// BUIDL does not, so cross-referencing with Securitize's monthly +// dividend announcements will likely be required to net out +// subscription-driven supply changes. const ( buidlContractEthereum = "0x7712c34205737192402172409a8F7ccef8aA2AEc" - // TODO(Sprint 3): verify this treasury address on Etherscan. - // Placeholder until confirmed against Securitize's own docs. - buidlTreasuryEthereum = "0x0000000000000000000000000000000000000000" + buidlTreasuryEthereum = "0x5072Ed40EBa6bE38C2370cAD1Cb1df0202924e53" ) type buidlProbe struct { diff --git a/harnesses/rwa-yield-accuracy/cmd/script/config.go b/harnesses/rwa-yield-accuracy/cmd/script/config.go index 9a20df36..c9c81f0e 100644 --- a/harnesses/rwa-yield-accuracy/cmd/script/config.go +++ b/harnesses/rwa-yield-accuracy/cmd/script/config.go @@ -26,11 +26,16 @@ const ( // rpcURL returns the Ethereum mainnet RPC endpoint used by every // probe. All five V1 tokens live on Ethereum; V2 will introduce // per-chain endpoints for BUIDL/USDY multi-chain support. +// +// Default is dRPC because the harness needs archive access (eth_call +// at t-30d block). publicnode.com — the usual OCB default — rejects +// archive requests without an account. dRPC's free tier supports +// archive reads at least back to 30-day depth. func rpcURL() string { if v := strings.TrimSpace(os.Getenv("RPC_ETHEREUM")); v != "" { return v } - return "https://ethereum-rpc.publicnode.com" + return "https://eth.drpc.org" } func listenAddr() string { diff --git a/harnesses/rwa-yield-accuracy/cmd/script/main.go b/harnesses/rwa-yield-accuracy/cmd/script/main.go index 21b457df..dd7381b6 100644 --- a/harnesses/rwa-yield-accuracy/cmd/script/main.go +++ b/harnesses/rwa-yield-accuracy/cmd/script/main.go @@ -18,29 +18,28 @@ import ( // this bench is: (1) implement IssuerProbe, (2) add its slug to // promised-yields.yml, (3) append here. // -// V1 cohort restricted to USDY only. USDY's rebase model is fully -// verifiable on-chain (totalSupply growth on a single known ERC-20 -// contract, no treasury wallet or off-chain NAV endpoint required) -// which lets the bench ship with confidence that every number is -// grounded. +// Active cohort: USDY (rebase, totalSupply growth), USTB (NAV via +// Chainlink feed), OUSG (NAV via OndoOracle). All three are fully +// on-chain measurable — no off-chain HTTP dependency, no treasury +// wallet assumption. // -// The other 4 probes (BUIDL, USTB dividend model; BENJI, OUSG NAV -// model) exist in this repo (buidl.go, ustb.go, benji.go, ousg.go) -// but stay dormant until each one's treasury address or issuer NAV -// endpoint has been cross-checked with the fund's own documentation. -// Enabling them without that check would ship placeholder-zero yield -// values that look catastrophic (deviation of -530 bps vs promised). -// -// To activate a probe once verified: uncomment its constructor below -// and confirm the corresponding address / endpoint constant in the -// probe file. The bench spec's provider block for that token becomes -// meaningful automatically. +// BUIDL and BENJI stay dormant: +// - BUIDL: distributor 0x5072Ed40EBa6bE38C2370cAD1Cb1df0202924e53 +// was identified but calls bulkIssuance (mints more BUIDL, not +// USDC transfers). The current dividend.go model that scans USDC +// Transfer events doesn't fit; needs a rebase-style measurement +// PLUS a way to separate yield mints from new subscriptions. +// - BENJI: Ethereum wrapper (0x3DDc...50dc9) has 3 holders, ~$48M, +// no on-chain sharePrice function. NAV is $1.00 by design; yield +// is only knowable via Franklin's off-chain fund page. Reached +// out to digitalassets@franklintempleton.com; unblock once they +// confirm an API endpoint or Chainlink feed. var probes = []IssuerProbe{ NewUSDYProbe(), - // NewBUIDLProbe(), // TODO(V2): verify Securitize treasury wallet - // NewUSTBProbe(), // TODO(V2): verify Superstate treasury wallet - // NewBENJIProbe(), // TODO(V2): verify Franklin NAV JSON endpoint - // NewOUSGProbe(), // TODO(V2): verify Ondo NAV JSON endpoint + NewUSTBProbe(), + NewOUSGProbe(), + // NewBUIDLProbe(), // TODO: split bulkIssuance mints from subscriptions + // NewBENJIProbe(), // TODO: waiting Franklin NAV endpoint confirmation } func main() { diff --git a/harnesses/rwa-yield-accuracy/cmd/script/ousg.go b/harnesses/rwa-yield-accuracy/cmd/script/ousg.go index 368f01d7..22cc28c4 100644 --- a/harnesses/rwa-yield-accuracy/cmd/script/ousg.go +++ b/harnesses/rwa-yield-accuracy/cmd/script/ousg.go @@ -3,7 +3,6 @@ package main import ( "context" "fmt" - "sync" "time" "github.com/ethereum/go-ethereum/common" @@ -11,35 +10,33 @@ import ( ) // OUSG is Ondo's Short-Term U.S. Government Treasuries token. NAV -// appreciation model: share price grows daily; no dividend transfers. +// appreciation model: share price grows daily as yield accrues. // -// Ethereum contract (OUSG): -// 0x1B19C19393e2d034D8Ff31fF34c81252FcBbee92 +// Contracts: +// OUSG ERC-20: 0x1B19C19393e2d034D8Ff31fF34c81252FcBbee92 +// OndoOracle: 0x9Cad45a8BF0Ed41Ff33074449B357C7a1fAb4094 +// (Exposes Aave IPriceOracle interface: getAssetPrice(OUSG) → +// 18-decimal USD NAV. Verified 2026-07: returns $115.977, matches +// Ondo's public dashboard.) // -// NAV source: Ondo publishes daily NAV on the OUSG dashboard. V1 uses -// the public endpoint (TBD schema, verified Sprint 3) plus in-memory -// NAV history for windowed yield. -// -// Ondo also runs a Chainlink Proof-of-Reserves feed for OUSG that -// could serve as an on-chain NAV source. V2 will switch to that feed -// where available to remove the offchain HTTP dependency. +// The oracle read at a specific historical block gives the NAV that +// was current at that block, which is exactly the input we need for +// windowed yield without any off-chain HTTP dependency. const ( ousgContractEthereum = "0x1B19C19393e2d034D8Ff31fF34c81252FcBbee92" - // Ondo publishes NAV on its API. Actual endpoint TBD Sprint 3. - ousgNAVEndpoint = "https://api.ondo.finance/v1/products/ousg/nav" + ousgOndoOracle = "0x9Cad45a8BF0Ed41Ff33074449B357C7a1fAb4094" ) type ousgProbe struct { contract common.Address - ring *navRing - mu sync.Mutex + oracle common.Address } func NewOUSGProbe() IssuerProbe { return &ousgProbe{ contract: common.HexToAddress(ousgContractEthereum), - ring: newNavRing(72), + oracle: common.HexToAddress(ousgOndoOracle), } } @@ -50,33 +47,34 @@ func (p *ousgProbe) Chain() string { return "ethereum" } func (p *ousgProbe) Measure(ctx context.Context, rpc *ethclient.Client) (*Measurement, error) { now := time.Now().UTC() - navNow, err := fetchJSONNumber(ousgNAVEndpoint, []string{"nav"}) + latest, err := rpc.BlockNumber(ctx) if err != nil { - return nil, fmt.Errorf("nav source: %w", err) + return nil, fmt.Errorf("latest block: %w", err) } + block30dAgo := blockOffsetBySeconds(latest, int64(Window30d.Seconds())) + block7dAgo := blockOffsetBySeconds(latest, int64(Window7d.Seconds())) - p.mu.Lock() - p.ring.push(now, navNow) - nav30d := p.ring.navAt(now.Add(-Window30d)) - nav7d := p.ring.navAt(now.Add(-Window7d)) - p.mu.Unlock() - - yield30dBps := 0 - if nav30d > 0 { - yield30dBps = annualizedYieldBpsFromNAV(navNow, nav30d, 30.0) + navNow, err := readAavePriceOracleNAV(ctx, rpc, p.oracle, p.contract, nil) + if err != nil { + return nil, fmt.Errorf("nav now: %w", err) } - yield7dBps := 0 - if nav7d > 0 { - yield7dBps = annualizedYieldBpsFromNAV(navNow, nav7d, 7.0) + nav30d, err := readAavePriceOracleNAV(ctx, rpc, p.oracle, p.contract, block30dAgo) + if err != nil { + return nil, fmt.Errorf("nav 30d: %w", err) } + nav7d, err := readAavePriceOracleNAV(ctx, rpc, p.oracle, p.contract, block7dAgo) + if err != nil { + return nil, fmt.Errorf("nav 7d: %w", err) + } + + yield30dBps := annualizedYieldBpsFromNAV(navNow, nav30d, 30.0) + yield7dBps := annualizedYieldBpsFromNAV(navNow, nav7d, 7.0) supply, err := readERC20TotalSupply(ctx, rpc, p.contract, nil) if err != nil { - // Non-fatal: NAV yield can still be reported without supply. supply = 0 } supplyUnits := supply / 1e18 - aum := supplyUnits * navNow return &Measurement{ Token: p.Slug(), @@ -84,9 +82,9 @@ func (p *ousgProbe) Measure(ctx context.Context, rpc *ethclient.Client) (*Measur Chain: p.Chain(), DeliveredBps30d: yield30dBps, DeliveredBps7d: yield7dBps, - DeliveredBpsLifetime: 0, // V1 placeholder + DeliveredBpsLifetime: 0, TotalSupplyUnits: supplyUnits, - AUMUSD: aum, + AUMUSD: supplyUnits * navNow, NewDistributionsUSD: 0, MeasuredAt: now, }, nil diff --git a/harnesses/rwa-yield-accuracy/cmd/script/ustb.go b/harnesses/rwa-yield-accuracy/cmd/script/ustb.go index 7ffe1cc8..04e2992b 100644 --- a/harnesses/rwa-yield-accuracy/cmd/script/ustb.go +++ b/harnesses/rwa-yield-accuracy/cmd/script/ustb.go @@ -9,34 +9,34 @@ import ( "github.com/ethereum/go-ethereum/ethclient" ) -// USTB is Superstate's Short-Duration U.S. Treasury Fund tokenized -// on Ethereum. Same distribution model as BUIDL: share price fixed -// at $1, monthly USDC dividends distributed from the fund's treasury -// wallet. +// USTB is Superstate's Short-Duration U.S. Treasury Fund tokenized on +// Ethereum. NAV-accrual model: share price grows daily as yield accrues. +// There are no dividend transfers to holders — the "yield" materializes +// entirely through NAV growth. // -// Ethereum contract (USTB ERC-20): -// 0x43415eB6f9c1D19a41BC1c17D7D1e9E92dB4B4aE +// Contracts: +// USTB ERC-20: 0x43415eB6ff9DB7E26A15b704e7A3eDCe97d31C4e +// Chainlink NAV feed: 0x289B5036cd942e619E1Ee48670F98d214E745AAC +// (Superstate publishes USTB NAV/USD via Chainlink, updates daily) // -// Superstate treasury wallet (source of USDC dividends): -// 0x... (TODO Sprint 3: verify against Superstate's own docs) -// -// USTB uses 6 decimals per Superstate's specification. +// Delivered yield is read directly from the Chainlink feed at latest +// and at t-30d / t-7d block numbers. No in-memory bootstrap needed +// (archive RPC provides history since token inception). const ( - ustbContractEthereum = "0x43415eB6f9c1D19a41BC1c17D7D1e9E92dB4B4aE" - // TODO(Sprint 3): verify treasury address on Etherscan. - ustbTreasuryEthereum = "0x0000000000000000000000000000000000000000" + ustbContractEthereum = "0x43415eB6ff9DB7E26A15b704e7A3eDCe97d31C4e" + ustbChainlinkFeed = "0x289B5036cd942e619E1Ee48670F98d214E745AAC" ) type ustbProbe struct { contract common.Address - treasury common.Address + navFeed common.Address } func NewUSTBProbe() IssuerProbe { return &ustbProbe{ contract: common.HexToAddress(ustbContractEthereum), - treasury: common.HexToAddress(ustbTreasuryEthereum), + navFeed: common.HexToAddress(ustbChainlinkFeed), } } @@ -47,11 +47,6 @@ func (p *ustbProbe) Chain() string { return "ethereum" } func (p *ustbProbe) Measure(ctx context.Context, rpc *ethclient.Client) (*Measurement, error) { now := time.Now().UTC() - supplyNow, err := readERC20TotalSupply(ctx, rpc, p.contract, nil) - if err != nil { - return nil, fmt.Errorf("supply now: %w", err) - } - latest, err := rpc.BlockNumber(ctx) if err != nil { return nil, fmt.Errorf("latest block: %w", err) @@ -59,34 +54,28 @@ func (p *ustbProbe) Measure(ctx context.Context, rpc *ethclient.Client) (*Measur block30dAgo := blockOffsetBySeconds(latest, int64(Window30d.Seconds())) block7dAgo := blockOffsetBySeconds(latest, int64(Window7d.Seconds())) - supply30d, err := readERC20TotalSupply(ctx, rpc, p.contract, block30dAgo) - if err != nil { - return nil, fmt.Errorf("supply 30d: %w", err) - } - supply7d, err := readERC20TotalSupply(ctx, rpc, p.contract, block7dAgo) + navNow, err := readChainlinkNAV(ctx, rpc, p.navFeed, nil) if err != nil { - return nil, fmt.Errorf("supply 7d: %w", err) + return nil, fmt.Errorf("nav now: %w", err) } - - dist30d, err := sumUSDCDistributionsFromTreasury(ctx, rpc, p.treasury, block30dAgo.Uint64(), latest) + nav30d, err := readChainlinkNAV(ctx, rpc, p.navFeed, block30dAgo) if err != nil { - return nil, fmt.Errorf("distributions 30d: %w", err) + return nil, fmt.Errorf("nav 30d: %w", err) } - dist7d, err := sumUSDCDistributionsFromTreasury(ctx, rpc, p.treasury, block7dAgo.Uint64(), latest) + nav7d, err := readChainlinkNAV(ctx, rpc, p.navFeed, block7dAgo) if err != nil { - return nil, fmt.Errorf("distributions 7d: %w", err) + return nil, fmt.Errorf("nav 7d: %w", err) } - dist30dUSD := usdcAmountToFloat(dist30d) - dist7dUSD := usdcAmountToFloat(dist7d) - - // USTB is 6 decimals per Superstate spec. Share price = $1. - avgSupply30dUSD := (supplyNow + supply30d) / 2 / 1e6 - avgSupply7dUSD := (supplyNow + supply7d) / 2 / 1e6 - yield30dBps := annualizedYieldBpsFromDistribution(dist30dUSD, avgSupply30dUSD, 30.0) - yield7dBps := annualizedYieldBpsFromDistribution(dist7dUSD, avgSupply7dUSD, 7.0) + yield30dBps := annualizedYieldBpsFromNAV(navNow, nav30d, 30.0) + yield7dBps := annualizedYieldBpsFromNAV(navNow, nav7d, 7.0) - supplyUnits := supplyNow / 1e6 + // USTB uses 6 decimals per Superstate spec. + supply, err := readERC20TotalSupply(ctx, rpc, p.contract, nil) + if err != nil { + supply = 0 + } + supplyUnits := supply / 1e6 return &Measurement{ Token: p.Slug(), @@ -94,10 +83,10 @@ func (p *ustbProbe) Measure(ctx context.Context, rpc *ethclient.Client) (*Measur Chain: p.Chain(), DeliveredBps30d: yield30dBps, DeliveredBps7d: yield7dBps, - DeliveredBpsLifetime: 0, // V1 placeholder + DeliveredBpsLifetime: 0, TotalSupplyUnits: supplyUnits, - AUMUSD: supplyUnits, - NewDistributionsUSD: dist30dUSD, + AUMUSD: supplyUnits * navNow, + NewDistributionsUSD: 0, MeasuredAt: now, }, nil }