# El Salvador legions (v2): five answers from the deployed source Contracts read (mainnet, live): - market `SP5Y3W3F78NKFH4HYFNDQMJC484VZWKDH35ZR2M9.elsalvador-stakes-btc-v2` - yes legion `SP5Y3W3F78NKFH4HYFNDQMJC484VZWKDH35ZR2M9.elsalvador-yes-legion-v2` - no legion `SP5Y3W3F78NKFH4HYFNDQMJC484VZWKDH35ZR2M9.elsalvador-no-legion-v2` The no legion is generated from the yes source (`no-legion.clar` header: "produced from yes-legion.clar by scripts/gen.mjs"), so the two differ only in `SIDE`/`WIN_STATUS`/`SIDE_LABEL` and in which field of `positions` counts as voting weight (`bonded` vs `idle`, L223 vs L232). Line numbers below are the yes legion's unless stated. All live values were read at burn height 967,033 (CLOSE_HEIGHT 994,699). --- ## 1. How the vault is funded, and why `get-vault` can disagree with a UI share count **Function: `transfer-shares` on the market contract, `(side uint) (amount uint) (to principal)`** (`elsalvador-stakes-btc-v2.clar` L371-L390). The legion has no funding function of its own — its public surface is `propose` (L458), `vote` (L529), `conclude` (L636), `redeem-vault` (L746), `claim-credit` (L788). That is deliberate, and the source says so (L18-L22): *"It is endowed by transfer, from any wallet, at any time, and needs no function on this side: the market writes positions[to] directly."* `transfer-shares` is called by the **sender** (`from contract-caller`, L373), so only a seat that already holds shares can endow. It asserts `amount > 0` (u106), `to ≠ from` (u110), `side ∈ {0,1}` (u109) and that the market is tradeable (u102/u103), then needs `idle[from] ≥ amount` or `bonded[from] ≥ amount` (u107) and writes `map-set positions to` (L383-L388). No sBTC moves; no counter anywhere else changes. How the two vaults were actually funded (the three txs in the listing): | burn | tx | call | | --- | --- | --- | | 966961 | `0xad79cc…9033c` | `mint-complete-set u1000000` — mints 1,000,000 of **both** sides to the caller (L339-L352), cost 1,000,000 sats | | 966962 | `0xff173d…321f` | `transfer-shares u1 u300000 '…elsalvador-yes-legion-v2` | | 966962 | `0x8c10c0…96ea` | `transfer-shares u0 u300000 '…elsalvador-no-legion-v2` | That is the "3,000 → 303,000": each vault already held 3,000 from an earlier endowment and took +300,000. It has been refilled since — `get-vault` now returns **u992000 on both legions**, i.e. 330 `PAYOUT`s. **Why `get-vault` can disagree with a share count shown in a UI.** `get-vault` (L228-L230) is `(get-weight current-contract)`, and `get-weight` (L222-L226) is one field of the market's live position for *this contract's own principal*: `bonded` for the yes legion, `idle` for the no legion. Three independent ways a UI ends up with a different number: 1. **A name collision inside the market itself.** The market's `get-market` exposes a field literally named `vault` (L286) — `(var-get vault)` (declared L116). It is the market's **complete-set collateral counter in sats**: raised by `mint-complete-set` (L346) and by `match-bids` (L533), lowered by `merge-complete-set` (L362) and `redeem` (L624). `transfer-shares` **never touches it** (L371-L390 has no `var-set`). Live right now: market `vault` = **1,729,600**, yes-legion `get-vault` = **992,000**. Both are printed as "vault", and endowing a vault — the very act that funds it — moves the second and not the first, so the gap grows by design. 2. **Wrong field / wrong legion.** `get-vault` returns exactly one side. The yes legion's position is `{idle u0, bonded u992000}`; the no legion's is `{idle u992000, bonded u0}`. A UI that renders `bonded` for both legions, or sums both fields, disagrees with one of them. 3. **Shares are not an FT.** They live in a plain `define-map positions` (L118), not a SIP-010 token. A wallet, explorer or `/extended/v1/address/…/balances` view of the legion principal therefore reports **zero** shares; `get-vault` reports 992,000. Any UI that takes its "share count" from token balances rather than from `get-position` is reading a source that structurally cannot see them. --- ## 2. Every reason string `conclude` writes, and the one it never writes `conclude` (L636) reaches a reason through two routes: `settle-failed` (L589-L634) for failures, and a direct `map-set Proposals` for passes. The complete set written by conclude is **six** strings: | reason | written at | exact condition | | --- | --- | --- | | `"no-voters"` | L668 | `yesVoterCount < MIN_VOTERS` (u2) — a **headcount** of distinct yes voters, L658/L666 | | `"voted-down"` | L670 | `yesWeight*100 / cast < VOTING_THRESHOLD` (u66), or `cast = 0` since `thresholdMet` also requires `cast > 0` (L661-L664) | | `"not-holding"` | L672 | `(get-weight proposer) < MIN_POSITION` (u1000) re-read at conclude (L653) | | `"pot-short"` | L674 | `(get-vault) < TotalCredits + PAYOUT` (L673) | | `"paid-shares"` | L680 (print L694) | every gate above passed **and** `(is-market-tradeable)` is true | | `"credited"` | L707 (print L717) | every gate above passed **and** `(is-market-tradeable)` is false | (`propose` writes the initial `reason: ""`, L507. `settle-failed` writes its `reason` argument verbatim, L611, so no other string can appear from that path.) **The string that appears on proposals but is never written by conclude is `"not-concluded"`.** It is manufactured by the *read-only* `get-proposal` (L327-L337): when `is-lapsed` (L317-L326) is true — `status == STATUS_OPEN` **and** `burn-block-height ≥ voteEnd + CONCLUDE_WINDOW` — `get-proposal` returns `(merge p { status: STATUS_EXPIRED, reason: "not-concluded" })` on the **copy it hands back**. There is no `map-set`; the chain state is untouched. Mechanism in one line: the read-only reports a status and a reason that no transaction ever stored. Proof, from the live chain (yes legion, proposal 1): - raw map entry `Proposals[1]` — `reason` = `0d 00000000` (empty string) and `status` = `u0` → `…06726561736f6e0d000000000673746174757301000000000000000000000000000000…` - `get-proposal u1` on the same proposal → `status u3`, `reason "not-concluded"`, `phase "expired"` `get-phase` (L344-L366) does compute the string `"expired"`, but that is a phase label, not a proposal reason, and it is likewise never written to the map. --- ## 3. The two passing paths, and what the second one means for the proposer Both passing paths run after identical gates (`votersMet`, `thresholdMet`, `stillHolding`, pot check, L658-L675); they differ only in the last branch (L676-L725). 1. **`"paid-shares"` — `(is-market-tradeable)` is true.** The vault pays immediately, in shares: conclude calls the market's `transfer-shares SIDE PAYOUT proposer` (L690-L698) and stamps `paidInShares: true`. The proposer's value is delivered in the same transaction that concludes. 2. **`"credited"` — `(is-market-tradeable)` is false.** Shares cannot move (the market's own `transfer-shares` asserts tradeability, L379), so conclude instead writes `Credits[proposer] += PAYOUT` and `TotalCredits += PAYOUT` (L707-L712) and stamps `paidInShares: false`. **What decides between them is `(is-market-tradeable)` (L238-L245): market `status == MARKET_OPEN` (u0) and `burn-block-height ≤ close-height` (994,699).** It is evaluated at conclude time, not at propose time, which is the whole point of the design note at L630-L635: a proposal that is fully approved can be flipped onto the credit path by a stranger landing `resolve-bonded` mid-vote, so an approval is never stranded. **What the second path means for when the proposer actually receives value: the credit is a claim, not money, and it is deferred, conditional and first-come-first-served.** - `Credits` (L143) is not sBTC and cannot be spent by itself. The sats only exist after the vault's position is converted, by `redeem-vault` (L746-L786), which is permissionless, runs **once** (`Redeemed`, L147), and requires: the market is no longer open (u444), `TotalCredits > 0` (u446), and `burn-block-height ≥ get-settle-height` (u447) so no proposal can still conclude. - `get-settle-height` (L443-L448) is `LastProposeAt + VOTE_DELAY + VOTE_WINDOW + CONCLUDE_WINDOW`, not the market's close height; the credit path therefore unlocks only after the last proposal's window. - `redeem-vault` calls the market's `redeem` **only if this side won** (`won = status == WIN_STATUS`, L759). If the legion argued the losing side, it sets `Redeemed` true with `RedeemedSats = 0` and prints `sats: u0` (L769-L779) — every credit on that side settles to nothing. - `claim-credit` (L788-L816) then pays `min(credit, RedeemedSats - PaidSats)` in sBTC. Because the running `PaidSats` is global, claims are paid in arrival order, and if `TotalCredits` exceeds the redeemed sats — which is exactly what the `"paid-shares"` branch causes, since every share paid out early shrinks the position that would have been redeemed later — later claimants can be paid less than the credit says. A proposer on this path is paid after resolution, only if the side wins, and only while the pot still has sats. --- ## 4. The burn-block timing parameters, and what a lapsed win costs The contract defines **five** burn-block constants; the four that time a single proposal are these, and the fifth paces the legion as a whole: | constant | value | line | role | | --- | --- | --- | --- | | `VOTE_DELAY` | `u2` | L47 | wait after `createdAt` before any vote counts (`vote` asserts it, L542; phase `"pending"`) | | `VOTE_WINDOW` | `u30` | L48 | voting closes at `voteEnd = createdAt + VOTE_DELAY + VOTE_WINDOW` (L467) | | `CONCLUDE_WINDOW` | `u12` | L49 | window after `voteEnd` in which `conclude` may run (L660-L663); `lapseAt = voteEnd + CONCLUDE_WINDOW` (L468) | | `PROPOSER_COOLDOWN` | `u144` | L63 | per-proposer spacing, one Bitcoin day, checked against `LastProposeBy` (L508-L520, L485-L489) | | `GLOBAL_PROPOSE_INTERVAL` | `u6` | L57 | legion-wide spacing between any two proposals (`LastProposeAt`, L521) | The three lifecycle constants sum to the "44-block lifecycle" the source names at L64, and they are also what `get-settle-height` adds up (L448). If the question means the four proposal-timing dials, they are `VOTE_DELAY` 2, `VOTE_WINDOW` 30, `CONCLUDE_WINDOW` 12 and `PROPOSER_COOLDOWN` 144; `GLOBAL_PROPOSE_INTERVAL` 6 is the fifth and applies to the roster rather than to one proposal. **A proposal that wins its vote but is never concluded in time is lost, permanently, and silently.** `conclude` asserts `burn-block-height < voteEnd + CONCLUDE_WINDOW` (L660-L663, `ERR_CONCLUDE_WINDOW_PASSED` u435), so once that height passes no transaction can ever settle it. Nothing else happens: the map entry keeps `status: STATUS_OPEN` and `reason: ""`, the proposer receives neither shares nor a credit, and the pot is not touched. Only the read-onlys reveal it — `get-proposal` returns `status u3` / `reason "not-concluded"`, `get-phase` returns `"expired"`. The proposer's slot does still free, because `has-live-proposal` (L294-L296) compares the height against `LiveUntil` (= `lapseAt`), not against the map, so the stale `LiveProposal` entry (L156) is never read again. **A real proposal where this already happened: yes legion, proposal id `1`.** - `createdAt u966528`, `voteEnd u966560`, so the last concludable height was 966571 (now 461+ blocks gone). - It won the vote: `yesWeight u5000`, `noWeight u0`, `yesVoterCount u5`, `voterCount u5` — that clears `MIN_VOTERS` (5 ≥ 2) and `VOTING_THRESHOLD` (100% ≥ 66%). - It is still raw `status u0` / `reason ""` in the `Proposals` map, and `get-proposal` reports `status u3` / `reason "not-concluded"` / `phase "expired"`. Proposer was `SP5Y3W3F78NKFH4HYFNDQMJC484VZWKDH35ZR2M9` (which still holds `bonded u1200`, so `MIN_POSITION` was not the obstacle). Its meta: title *"v2 terms verified against pox-5: deadline 994,699 is exactly one block below period 2's L1 unlock"*, link `github.com/aibtcdev/legions/commit/9068816`. The contrast is on the same legion minutes later: proposal `2` (proposer `SP20GPDS…FQJE1`, `createdAt u966964`, `voteEnd u966996`, 6 yes voters / 6200 yes weight) was concluded inside its window and reads `status u1` / `reason "paid-shares"`. The difference between the two is one `conclude` call. --- ## 5. The two conditions that stop one holder passing their own proposal **Condition one: the proposer may not vote at all.** `vote` asserts `(not (is-eq tx-sender (get proposer p)))` (L543, `ERR_SELF_VOTE` u423 declared L118). Own weight and own headcount can never be contributed by the proposer, whatever the size of the position. **Condition two: `MIN_VOTERS` is a headcount.** `conclude` requires `yesVoterCount ≥ MIN_VOTERS` (`u2`, L85; checked L658/L666), where `yesVoterCount` counts distinct principals that voted yes — incremented once per `vote` call (L578-L584) and de-duplicated by the `Votes {proposalId, voter}` one-vote rule (L545-L552). The source is explicit that there is deliberately **no turnout quorum and no floor on yes weight** (L88-L104), so the only thing weight buys is `VOTING_THRESHOLD`, which a single wallet can satisfy on its own. A headcount cannot be bought: one principal is one voter, and with condition one removing the proposer, passing your own proposal always requires at least two *other* distinct principals on the yes side. Both are needed — weight alone fails condition two, and headcount alone fails nothing — which is why the source calls `MIN_VOTERS` "the dial that raises the price, one wallet at a time" (L104) and openly notes that `MIN_POSITION` in three wallets is enough (L99-L103). "A single holder" is exactly one principal. --- ## Appendix: reproduce every number Read-only calls (Hiro `POST /v2/contracts/call-read/{contract}/{fn}`), at burn 967,033: - `…elsalvador-yes-legion-v2/get-vault` → `u992000` - `…elsalvador-no-legion-v2/get-vault` → `u992000` - `…elsalvador-yes-legion-v2/get-wins-left` → `u330` - `…elsalvador-yes-legion-v2/get-votable` → `u737600` - `…elsalvador-stakes-btc-v2/get-market` → `vault u1729600`, `idle-circ u1729600`, `bonded-circ u1729600`, `status u0`, `created-at u966390`, `close-height u994699` - `…elsalvador-stakes-btc-v2/get-position '…elsalvador-yes-legion-v2` → `(bonded u992000) (idle u0)` - `…elsalvador-stakes-btc-v2/get-position '…elsalvador-no-legion-v2` → `(bonded u0) (idle u992000)` - `…elsalvador-yes-legion-v2/get-proposal u1` → `status u3`, `reason "not-concluded"`, `paidInShares false` - raw `Proposals[1]` via `POST /v2/map_entry/…/elsalvador-yes-legion-v2/Proposals` with key `u1` → `reason` empty, `status u0` (state untouched; the read-only synthesises the lapse) - `…elsalvador-yes-legion-v2/get-proposal u2` → `status u1`, `reason "paid-shares"`, `paidInShares true` Funding txs: `0xad79cca26cf44f066057fb5bdf5aa0db2f418006a5e6bd7974eb1576d9e9033c` (mint, burn 966961), `0xff173df7d65daebfac9f42b66cf9214c66be37d494710824f3bf00596f46321f` (yes, burn 966962), `0x8c10c05ace725c8809899aa293f4303ef30e179b1f1323117984e8e6b2fe96ea` (no, burn 966962). Everything above is read from the deployed source and live mainnet state; no fork or mock was used.