# Is sBTC actually cashable? 101 withdrawals, u3500–u3600 Measured from public data only. No funds moved, no keys used, no API key required. - Contract: `SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registry` - Window: request-ids **u3500–u3600** inclusive (101 ids) - Value in the window: **31,195,224 sats** of withdrawal requests, **6,896 sats** of total fee actually charged - Routes: (A) `sbtc-registry` print events joined on `request-id`; (B) read-only `get-withdrawal-request` + `get-completed-withdrawal-sweep-data`, decoded from Clarity hex - Raw evidence and the scripts that produced it are in this directory (`evidence/`, `analyze.mjs`, `verify.mjs`) ## Q1 **101 of 101 requests were created, and 97 of those 97 reached a completed sweep.** 4 did not, and all 4 of them were rejected (Q4). Both routes return the same counts: creations 101 (event join) vs 101 (read-only), sweeps 97 vs 97, with **0 disagreements on any request-id** across all three compared fields (created, swept, rejected). Every one of the 97 swept ids is seen by both routes: read-only-only 0, event-only 0. **The read-only call decided it.** Reasons, in order of weight: 1. `get-withdrawal-request` is a direct read of the registry's stored record. A sweep is independently proven by `get-completed-withdrawal-sweep-data` returning a tuple rather than `none`. Neither depends on my paging being complete or correctly ordered. 2. The event route depends on paging the contract's newest-first print log far enough back. If I had stopped one page early, the denominator would have been silently short and nothing would have errored. That is a real failure mode I hit earlier in this measurement (the pager needed a lower bound, not a count, to know when to stop). 3. The two routes agree everywhere, so the choice does not change a single number — which is itself the finding: it means paging depth was adequate here. I would **not** trust the read-only route for Q2 and Q3: it carries no heights and no fees for the accept. That is why both routes are reported below. ## Q2 Latency for each of the 97 completed requests, defined as **bitcoin burn-height at accept minus Stacks block-height at create**. | metric | delta (height units) | request-id | |---|---|---| | min | 7 | u3500 | | median | 7 | — | | max | 10 | u3562 | **Units, stated explicitly: the delta is in height units, not blocks of one chain.** `withdrawal-create.block-height` is a *Stacks* block height (e.g. u3500 at 968470); `withdrawal-accept.burn-height` is a *Bitcoin* burn height (u3500 accepted at 968477). Subtracting them is only meaningful because both are monotonic counters near 968k in this period; it is not "7 Bitcoin blocks of waiting", and I am not converting it to minutes. If you want wall-clock, join the heights to timestamps from each chain separately. The two routes give identical latency distributions, because the accept event's `burn-height` equals `get-completed-withdrawal-sweep-data`'s `sweep-burn-height` for **all 97** completed requests (0 differ). So the sweep is accepted and swept inside the same Bitcoin block — there is no measurable gap between "accepted" and "swept" in this window. ## Q3 Fee actually charged at accept against the `max-fee` set at create, for the 97 completed requests. | question | answer | |---|---| | actual fee == cap | **0 of 97** — never | | actual fee < cap | 97 of 97 (100.0%) | | actual fee > cap | 0 | | largest gap one way | 9,931 sats (u3549: cap 10,000, paid 69) | | largest gap the other way | none — no request was charged above its cap | | smallest gap | 204 sats (u3555: cap 340, paid 136) | | actual fee | min 34, median 71, max 338 sats | | caps set by users | min 340, median 10,000, max 10,000 sats | The cap was hit **zero times**. The largest gap is u3549: a 10,000-sat cap, a 69-sat fee, 9,931 sats of the lock returned. The largest *negative* gap does not exist — nothing was charged over its cap, which is the property the contract enforces by locking `amount + max-fee` up front. The trap here is mistaking `max-fee` for a price. It is a **cap and a lock**, not a quote: the median cap is 10,000 sats while the median fee charged is 71 sats, and 51 of 97 requests set the cap at 10,000. Anyone answering "what does it cost to cash out?" from the create events alone overstates the cost by roughly 141×. Caps in the window ranged over 340×8, 500×2, 510×2, 680×2, 720×1, 750×1, 850×1, 1000×3, 1500×2, 2000×1, 3000×14, 3001×1, 4000×3, 5000×5, 10000×51. ## Q4 Request-ids with no completed sweep: **u3591, u3592, u3593, u3594** — 4 of 101. All 4 are **rejected**, none pending. The observable separating them, and the citation for each: - **Rejected**: a `withdrawal-reject` print event from `sbtc-registry` carrying that `request-id` (4/4 of these ids have one), **and** `get-withdrawal-request` returning `status` = `(some false)` (4/4), **and** `get-completed-withdrawal-sweep-data` returning `none` instead of a tuple. The two routes agree on all 4. - **Pending** would be `status` = `none` with no reject event and no sweep tuple. **Zero requests in this window are in that state**, so I am reporting the shape of the distinction rather than a case I observed. That is a limit of this window, not a guess: the field is `(optional bool)`, and `(some true)` is observed 97 times while `(some false)` is observed 4 times. Why these four failed is legible in the data: they are the requests that set a cap too small to pay a signer. u3591 set `max-fee` u5 on a 707-sat withdrawal; u3592 set u10 on 844 sats; u3593 and u3594 set u340 while the median fee actually charged in this window was 71 sats and the observed maximum was 338. Note that u3591 and u3592 clear the sBTC dust limit (`amount > u546`) and were still rejected — the dust limit is necessary, not sufficient. ## Q5 One Bitcoin sweep transaction settles many Stacks withdrawal requests, so counting `sweep-txid` — or the accept event's `bitcoin-txid`, which is the **same hash in 97 of 97** completed cases — counts Bitcoin transactions, not cash-outs. In this window the 97 swept requests collapse into **18 distinct sweep txids**. The largest single sweep, `0x0536f3639ff6f6ca11ce228ef9756fe53761fdcdcf28f756a0634ba677aa8021`, carries **48 requests** (u3500–u3547); the next carries 24, and 11 of the 18 sweeps carry exactly one request. So `COUNT(DISTINCT sweep-txid)` = 18 against a true answer of 97 — an 81% undercount, in the direction that makes sBTC look *less* cashable than it is. A reader who reaches for "distinct Bitcoin txids" as the denominator of cashability reports roughly a fifth of the withdrawals. The field that exposes it is `output-index` on the accept events: inside the 48-request sweep the outputs run sequentially — u3500 `output-index` u10, u3501 u11, u3502 u12, u3503 u13 — so request-id → output-index is the only mapping onto a specific Bitcoin output. Nothing about the txid identifies the request, and 97 requests produce only 56 distinct output indices because the smaller sweeps each start their own output numbering. The sharper version of the trap: the read-only route cannot see this at all. `get-completed-withdrawal-sweep-data` returns `sweep-txid` and nothing else that identifies an output, so all 48 requests in that sweep read back as the identical txid with no way to tell them apart. `output-index` exists **only** in the `withdrawal-accept` print event. Anyone who takes the poster's hint that the read-only calls are "cheaper than paging" and stops there gets 97 rows that look like 18, with no field available to disambiguate them. And the cross-check that looks rigorous is empty: `withdrawal-accept`'s `bitcoin-txid` equals `get-completed-withdrawal-sweep-data`'s `sweep-txid` in all 97 cases, so verifying one against the other confirms only that you read the same hash twice. The real audit is request-id → output-index → the output amount, which is what makes a batched sweep checkable per request. ## Reproducing this ``` sha256 of evidence/summary.json: 354d4bbe047e83e11dd2bb7ee0f1ea6de7463d735774283c6358d5b71cac2d19 node analyze.mjs # refetch events + read-only calls, rewrite evidence/ node verify.mjs # offline: re-derive summary.json from raw evidence and assert it matches ``` `verify.mjs` recomputes every number in this report from `evidence/events.json` and `evidence/readonly.json` and fails loudly if any of them has drifted, so the claims above are checkable without trusting this prose. `analyze.mjs` paces its requests and caches them (`evidence/readonly.partial.json`), because the public Hiro tier rate-limits aggressively.