What is a DETF?

A member-owned crypto asset pool: deposit for a token representing your share, redeemable for that share anytime.

DETF stands for Decentralised Exchange Traded Fund. Redeem the token for its share of everything in the pool — its NAV. Smart contracts hold the value; members decide the group's actions.

The four pillars

Tiers define roles, weighted seats split votes, exit at NAV is guaranteed, and tokens can cross chains.

Four ideas address failures of tokens and DAOs:

01 · TBVS

Tier-Based Voting Structure

Members are organised in tiers, each with its own role and voting weight. Tier names, count and weights are set per project, so a group can be as centralised or as open as it wants.

Example

Founders
10%
Developers
20%
Marketing
30%
Token holders
40%
Power spread beyond the top seat60%

Drag a bar to re-weight that tier — the others rebalance. Token holders stay at the bottom; every tier you add stacks above them and splits the vote further. This board carries over to the next card.

02 · PBVR

Percentage-Based Voting Rights

Each tier holds a fixed % of the total votes, and no seat can pass anything alone. Buying tokens only buys a share of the token tier, and starting proposals can require a higher tier.

Example

20%
30%
40%
Founders10 members · 1.0% each
Developers20 members · 1.0% each
Marketing30 members · 1.0% each
Token holders40 members · 1.0% each

Distributed — it takes 50 of 100 members to control half the vote. A 50%+ seat held by a wide tier isn't centralization; that's power spread across many hands.

Drag the white dividers to move weight between seats — the members stay the same. Drag the ⇕ handle next to a member count up or down (or drag on a block) to grow or shrink that tier: widen token holders and even a majority seat is spread thin.

03 · GER

Guaranteed Exit Rights

Your deposit is a share of the treasury. Burn your tokens and reclaim your share of the NAV at any time. Even after a proposal has been approved you have 7 days to withdraw your funds before it's enacted.

Example

Group treasury10.0 ETH
your share 5.0 ETHother members
Your tokens500 (half the supply)

Your 5 ETH is in — the teal half of the pot is yours, never locked. Then a proposal opens: spend 3 ETH. Cast your vote…

04 · CCTS

Cross-Chain Token Swap

An abandoned project on one chain can restart on another. Over Chainlink CCIP, a migration moves the token itself between Solana and Ethereum — with a new name and a funded treasury behind it.

Example

1 · Deposit deadline

open

2 · Transfer date

pending

3 · CCIP delivery

pending

Ethereum

You
Anadeposited ✓
Bodeposited ✓
Cydeposited ✓

900 OLD sold → 4.5 ETH · yours to go

Solana

You
Ana
Bo
Cy

waiting for transfer day

ETH pool4.5 ETH
CCIP

DETF vs ETF vs DAO

An ETF's redeemable share and a DAO's open books, without a fund manager or the biggest wallet deciding for you.

A DETF compared with a traditional ETF and typical DAO:

DETFTraditional ETFTypical DAO
Your moneyYours — a share of the shared treasury, redeemable at NAV any timeHeld by a fund company; out through a brokerA treasury insiders unlock
Who decidesWeighted seats splitting 100% — buying tokens only buys the token seatThe fund managerThe biggest wallet
What it costs you1% of the supply once, at launch — no management or exit feeA management fee, every year you holdWhatever the team pays itself
If it goes wrongAny member can put the kill switch to a vote — withdrawals stay open foreverThe issuer decides when to wind it downThe token goes to zero; the treasury stays with insiders
Who can start oneAnyone — one transaction deploys the whole setA licensed issuerAnyone — then improvise the governance

Every proposal, vote, salary, treasury movement, member profile and group transaction ledger is public: check who does what and at what cost, without relying on a report.

Tiers & entry rules

A tier defines rights, entry and voting weight, preventing anyone from buying control outright.

A DETF starts with four tiers: commonly founders, Developers building the product, Moderators running the community, and token holders at the bottom.

The default four tiers, renamed to fit (rename, re-weight, add or remove tiers at creation — or later, by vote)

Founders1 founderthe board

voted-in · every founder votes directly at their own weight · treasury keys

Developers3 membersshared seat

voted-in here — admission is itself a vote; deposit or hold-an-asset gates work too

Moderators2 membersshared seat

hold 1,000 tokens — a stake-to-moderate rule; any tier can gate on votes, deposits, or holdings

Token Holderseveryone holding the tokentoken seat

automatic — hold the token or click Join DETF; the seat votes by balance

  • Rights. Tiers set proposal permissions and board seats, and may pay each member a monthly ETH salary, public on the member list and paid like other spending. Changes require a Tiers proposal showing old and new values side by side.
  • Entry rule. The bottom tier admits anyone; each other tier requires a vote, a deposit amount or holding a specified token.
  • Moves are votes. Moving or removing members requires a public Vote In / Out proposal. Tier seats and token holders can together vote out founders (weighted voting).
  • One founder is a valid board of one; numbers adjust as founders join.

Weighted voting

Votes count by weight, not headcount, with the pass mark above the heaviest seat so none decides alone.

Each founder has an individual weight; each tier can hold one shared weighted seat; token holders vote by balance through their seat. Percentage weights are set at creation, changed only by public Tiers vote, and may be split any way, including token holders outweighing founders.

Example: founder 30%, Developers 25%, Moderators 15%, token holders 30%; on-chain pass mark 60%. The founder needs token holders (30 + 30 = 60) or both work tiers (30 + 25 + 15 = 70). The other three seats total 70%, enough to outvote the founder.

Try it — a live board

The same board with five founders instead of one. Drag the dividers to re-weight the blocs, drag the white pass mark, flip the seat votes — and note the founders are five individual voters, not one seat.

50%
15%
15%
20%

pass mark 60%

Founders4/5· 40%

Approving weight is 60% against a pass mark of 60% — the proposal passes and moves to execution.

This is the on-chain board, which is why there is a pass mark at all. The purple slices are five founders voting individually at their own weight — which is why a founders majority above the pass mark is legal, and why a brand-new one-founder DETF works from day one. Each tier seat casts its whole weight by the majority of its members; the token-holder seat does the same, weighted by balance. Buying the entire token supply only wins the amber segment, not the board. Off-chain groups have no pass mark and share each seat's weight out among the members who vote instead — see the weighted board.

On-chain, approving seats must reach the pass mark: just above half the total weight and always above the largest seat. Off-chain, the heavier side wins without a pass mark. Buying most tokens gains only the token seat's share. A separate on-chain token-holder vote starts at 50% approval and 10% turnout, changeable only by Tiers proposal.

The weighted board

Each tier can hold one seat, sharing its weight among actual voters off-chain and voting as one bloc on-chain.

The board's seats approve group actions: contracts vote on-chain; off-chain, the board must publicly follow the count.

A board split 30 / 25 / 15 / 30 — on-chain it passes at the 60% mark; off-chain the heavier side wins

30%
25%
15%
30%
FounderDevelopers seat (3 members)Moderators seat (2 members)Token-holder seat (everyone holding the token)pass mark (on-chain)

The founder (30%) can't outvote the rest — they need the token holders (60%) or both worker seats (70%), and the Developers, Moderators and token holders together reach 70% without them. On-chain, any seat combination reaching the 60% mark executes; off-chain, whichever side carries more of the weight that actually voted wins.

  • Off-chain, weight is shared among actual voters. A 30% seat stays 30% whether ten members vote or two; a five-to-three split yields 18.75% against 11.25%. Non-voters neither dilute votes nor block results. Founders share one seat the same way.
  • On-chain, a seat contract votes as one bloc. Tier members vote by click or free signature using non-transferable badges; a yes majority commits the seat's full weight. Each founder has an individual full-weight seat. Badge holders are snapshotted when voting opens; empty tiers don't vote. Once the result cannot change, anyone can push it on-chain; relayers pay gas and cannot fake or block votes.
  • The token holders' seat counts balances, not heads: proportional split off-chain, full weight to the heavier side on-chain.
  • No seat decides alone. All sit below the pass mark; voting windows must allow tiers enough time to vote. Money movements and leadership changes wait in a public queue where board members can cancel them.

Set up seats at creation. The create wizard can enable a seat for any tier and shows each percentage. Off-chain weights apply immediately; on-chain seats need two extra transactions after launch, from Queued actions.

Off-chain vs on-chain

Both modes share tiers, weights and proposal types; only who executes results differs — people or contracts.

  • Off-chain (default). Free voting: proposers choose simple (one click, membership check) or signed (wallet proof of voter and choice, nothing on-chain). The app counts seat weights; the board executes voluntarily. Paying against a vote, spending without one or ignoring a passed vote for two weeks triggers a public warning on the group's page.
  • On-chain. Spending, sales, tier and membership changes are voted on-chain and executed by contracts. Discussion remains off-chain and free in both modes.
  • Switching modes requires a Governance vote: 14 days voting, then 14 before activation, allowing dissenters to withdraw first.

Proposals & lifecycle

Actions start as typed proposals; except for plain discussion, titles are generated from what they do.

Types share a lifecycle but request different details. Except for General, titles are generated from those details and uneditable, preventing misleading promises. Entry to a closed tier requires Vote In / Out: the vote is the invite.

TypeWhat it doesWhen it passes
GeneralRecord a decision or ask the group; never moves money.Saved as the group's position.
TreasurySend treasury money. The app generates the title from your details (“Send X to 0x1234…”), preventing a misleading title.The board runs it on-chain after the queue.
Sell TokensOpen, reprice, cap or close the token sale; buyers pay ETH into the treasury for new tokens.The board sets the price or cap on-chain.
NAV SaleSell group-held tokens at current worth plus a markup, or close the sale.The board lists it; buyers' ETH enters the treasury.
Sell AssetAuction a treasury token amount or NFT, optionally with a minimum price; never the group's own token.The board starts one public falling-price auction; the buyer's ETH enters the treasury.
Vote In / OutMove a member between tiers or remove them, letting lower tiers hold leaders accountable.Membership changes; Tier-1 changes also update the board.
TiersChange tiers, salaries or token-holder voting percentages, showing actual before and after values.The board applies the settings on-chain.
HiringA job or bounty specifying work and pay: “Hiring: redesign the logo — 0.5 ETH”.The job opens on the group's Jobs board.
AdvertiseFund a watch-to-earn ad campaign from the treasury.Admins fund the campaign and upload the ad in Ad Manager.
ExecuteOn-chain actions not covered elsewhere: swaps, staking, listing treasury tokens or voting the group's tokens in another project. Titles start “Execute:”.The call runs on-chain through the board and queue.
GovernanceSwitch between off-chain and on-chain voting.14 days to vote, then 14 before it applies, allowing dissenters to leave first.
KillShut down with withdrawals open forever. Fixed title, one kill vote at a time, lasting the group's queue delay (7 days on mainnet).Either bar suffices (see the kill switch); runs immediately because a kill takes nothing.
AI seatSeat a platform AI agent in a tier or dismiss one. The title names both agent and tier, identifying the AI voter without opening the body.The platform creates or ends the seat. Seated agents vote and comment; each proposal reviewed costs the group's credit pool. Already-seated agents get no ballot on this vote.
AI policyChanges either or both AI rules the founders picked in the create wizard: seating by any admin at any time, or only after an “AI seat” vote; ballots with full control (except their own seat), half control (also neutral on membership and tier votes), or none (reviews and comments only).Rules change; existing seats remain unchanged. Runs at least as long as a Tiers vote; seated agents get no ballot on it. At any level, proposers can toggle agent ballots per proposal; agents vote only if the group's credit pool can pay for review.

How a proposal runs

Proposers choose a window from a few minutes on testnet to 30 days. Eligible voters may vote or change votes until close; each vote records their tier at voting time. Seats use weighted majority, no seats use simple majority, and on-chain contracts count. Passed money actions enter the public queue, where any board member can cancel before execution.

“Hiring: build the mobile app — 0.5 ETH”: a 7-day vote passes with Developers, Moderators and token holders backing it — 70% of board weight. The job opens on the Jobs board; the treasury pays when work is done.

One lifecycle for every proposal type

ACTIVE
voting open · window you chose
PASSED
simple or weighted majority
QUEUED
on-chain actions · public timelock
EXECUTED
on-chain / enacted
↳ majority no (or on-chain thresholds unmet) → REJECTED↳ a board member vetoes in the queue → VETOED↳ members open a kill vote in the queue → it resolves before the payout
VOTE — weigh in or change your mind
QUEUE — veto or exit
EXEC

The white line tracks a proposal over time. While it's in the amber zone, the queued action sits in public — board members can veto it, and anyone who disagrees can still withdraw their treasury share. Funds move only at the end.

Treasury & exit

Tokens represent your treasury share, withdrawable anytime — even after a spend you opposed passes.

The treasury holds ETH and voted-in tokens, owned proportionally: 2% of tokens means 2% of every listed asset. Spending reduces each share's worth; exit protects dissenters:

  • You can always withdraw. Return tokens for your share of every listed asset, anytime, without notice or permission. Group-held tokens are excluded, so unsold stock never dilutes shares. Solana pays out the same way in one transaction.
  • Spending is slow and public. Payments require a vote; on-chain they queue publicly with board cancellation rights, while off-chain payments against votes flag the group's page. Pending payments stop deposits. Withdrawers leave with their full share; remaining holders bear the spend. Enough exits kill the payment and group: the kill switch.

Try it — the exit maths

A 100 ETH treasury. Set your share, then a spend you'd vote against.

Exit before it executes

10.0 ETH

your full share at NAV — the spend then falls only on those who stayed

Stay while it executes

6.0 ETH

your share of what's left — you funded 4.0 ETH of the spend

The vote and the timelock give you time to react; the burn-for-share exit lets you leave. No permission needed — the contract pays out whoever burns their tokens.

Oppose a 30 ETH spend? Withdraw while it waits, as above. It either loses funding to exits or falls on supporters who stay. The contract, not a person, holds the money, so nobody can remove your exit.

How money comes in

Token sale: anyone pays ETH straight into the treasury for new tokens at the voted price. Sales may be capped; over-cap purchases receive remaining tokens and a refund in one transaction. No open sale means no buy-in. NAV sale: group-held tokens sell at current worth plus a non-negative markup, only from an ETH-only treasury and never alongside a fixed-price sale, preventing dilution. Migration proceeds arrive when deposits close (Deposit & Sell). Assets enter the shared pot by vote; NFTs and unlisted tokens become ETH through auction.

Execute covers other on-chain calls — swap, stake, add liquidity, list or delist tokens — through vote, queue, cancellation window and execution. Only voting with another project's token skips the queue, since it moves no money; approving that project and delegating power still wait.

Assets labelled “shown, not withdrawable” on the Assets tab are outside the shared pot and excluded from withdrawals. If a broken token blocks payouts, the withdraw window offers everything else, preventing it from trapping ETH.

The kill switch

Five routes let members shut a group down without touching funds; withdrawals stay open forever.

A kill ends the group but never its money: spending stops for good, withdrawals stay open forever. Five routes:

  • A Kill vote, started by any member with proposal rights: fixed title and duration, one at a time, retry wait, and only after someone beyond the creator joins.
  • Blocking a passed proposal. After any non-General proposal passes, any member can start a kill vote from its page; payouts pause until that vote ends.
  • Mass withdrawals. Queued payments close deposits and snapshot token supply. Each withdrawal counts toward killing; at half the snapshot, anyone can shut down without a vote or wait.
  • Protest withdrawals. Withdrawals during a pending payment can be marked as protests, measured against treasury holdings when queued: 10% alerts the platform; 50% lets anyone start a kill vote immediately.
  • Platform shutdown for theft or takeover returns members' money. No contract anywhere moves treasury funds except to pay members their own shares; kills cannot take or redirect funds.

A kill vote passes at either threshold: yes voters hold half the tokens or half the group's total voting weight. Withdrawals during voting count as yes. Passed kills execute immediately because they take nothing.

What a kill does

Irreversibly stops deposits and token sales, blocks queued and new actions, destroys treasury-held group tokens so outside holders own everything remaining, and closes any running migration's quote book with buyer refunds. Withdrawals stay open forever under “shut down — withdrawals only”. Irreversibility prevents hostage-taking; full withdrawal remains possible, so a mistaken kill requires only redeployment.

After shutdown, NFTs and other tokens are sold for ETH; withdrawing before sales finish forfeits your share of them.

The create wizard

Choose everything on one page before spending gas; one transaction deploys and connects the complete set.

Set tier names, board weights and entry rules in the create wizard before spending gas. Almost every field has a ? explanation linking here. First, bring an existing token or create one.

1 · Find your token, or make a new one

Two starting points:

  • Bring an existing token. For a community without a treasury or decision-making rules, paste its token address. The wizard detects Ethereum or Solana and fills the name, symbol and supply. NFT collections are rejected.
  • Make a new token. Skip search and choose Ethereum or Solana. On Solana, your wallet pays the small set-up cost, receives the tokens and becomes the founder.

2 · Who gets the tokens

Name the new token; migrations reuse the old ticker unless changed. For new tokens, set total supply, your allocation (blank takes the remainder) and the treasury allocation. The 1% platform fee comes from these amounts, preserving your specified total. It funds DETF's own group, governed by the same rules. This card also lets you:

  • Reserve community tokens for Telegram, Discord or Twitter: one pool each, split equally or at a fixed amount per person, claimed later through the bots.
  • Send tokens to people at launch. Enter addresses, select friends or paste a CSV.
  • Open a public sale (Ethereum launches). Anyone pays ETH into the treasury for new tokens at your price. A later Sell Tokens vote can reprice, cap or close it.

Migrations offer these extras and extra tokens for you or the treasury beyond the one-for-one swap. Both paths show a running allocation breakdown.

3 · Your group's details

Add a description, logo, category, social links and website, then a profile: an @handle for your web address (availability checked as you type), location, goal and up to three topics for discovery through Tune your feed. Choose public or private, and whether to appear in the group list.

4 · Timing (migrations only)

Set the deadline to hand in old tokens: 100 days by default. Afterwards, anyone can close the migration, automatically moving proceeds to the treasury.

5 · Extra settings

  • How you vote off-chain or on-chain. Off-chain is free and recommended; the group can vote to switch later.
  • Unsold old tokens (migrations) — return them (default) or destroy them, fixed at launch. See Deposit & Sell for leftover allocation.
  • Blocked accounts — hide addresses and people from group chat, posts and lists on the website only; never restrict holding or moving tokens.

6 · Planning your tiers

Rename the four tiers or add more. Set each entry rule (vote, deposit or token holding; open “Join DETF” belongs to the bottom tier), whether it can start proposals, and its board seat and weight, if any. A bar shows each seat's percentage as you edit.

Add friends as members upfront and plan your own tier: you launch as the top-tier founder but can immediately step down on the group page. The saved plan costs nothing extra except on-chain seats, which require two post-launch transactions.

7 · Launch

Before spending gas, the wizard checks for missing names, overallocated tokens, unfinished airdrops and taken handles. Unfinished work is saved for your return. One transaction then sets up everything and opens your new group page.

Deposit & Sell migrations

Deposit old tokens for instant sale into the new treasury and receive new tokens one for one.

From old tokens to new:

Deposit & Sell

The only migration path: sell deposited old tokens immediately at the best offered price to fund the group treasury, receiving new tokens one for one. An unbacked token gains a treasury and governance.

Deposit
old token
Quote book sells
instant, same tx
Treasury funded
ETH / SOL
New token
to depositor

Deposit during the 100-day window; afterwards, anyone can finish the migration and automatically move proceeds to the treasury. Holders retain relative holdings, now backed by a treasury and board. Each claim also mints a 1% platform fee on top for DETF's own group.

Each deposit sells in its own transaction, first to the quote book, then to the old token's trading pool on a DETF-selected exchange. A failed or underpaying pool trade is undone, leaving those tokens unsold. Set a minimum ETH amount when depositing: if the sale falls short, the whole transaction fails, preventing an unaccepted price.

The launch-fixed leftover rule defaults to give it back: unsold tokens are allocated proportionally to deposits and claimable on the group page after migration. If nobody claims within 30 days, anyone can destroy the remainder. Alternatively, destroy it suits an abandoned token. Deposit order never matters; everyone gets all their new tokens. An empty quote book cannot stop migration.

Deposits are final sales: no undo or refund. Before depositing, check buyers and expected proceeds; afterwards, withdraw your treasury share any time. Transfer-blocking old tokens fail safely; a scam token cannot take a buyer's money.

The standing quote book

Buyers pre-fund ETH bids, paying each deposit in the same transaction at a price visible beforehand.

Migration starts with a quote book, not a dump into a small trading pool. Buyers pre-fund ETH and quote old-token prices; the best offer pays in the same transaction. No auction, waiting or later pricing: you get the displayed price.

Example: with a pool price around 0.0010 ETH and book offers around 0.00097, depositing 10,000 tokens receives about 9.7 ETH from the best buyer in the same transaction. The buyer takes tokens to resell; a month of such deposits funds the treasury.

A bot updates quotes, keeping the book rarely empty. Unbought tokens go to their own pool in the same transaction, then to the leftover rule. Buyers first, open market second; you receive all your new tokens either way.

Earning as a market maker

Anyone can bid below market, receive automatic fills, then collect and resell after deposits close.

Anyone can quote on an open migration without appointment: buy below market, resell at market and keep the difference.

Escrow ETH
quote market − margin
Deposit fills you
same tx, best price first
Old tokens land
straight to your wallet
Resell at market
spread = your profit

Worked example: the old token trades at 0.0010 ETH. You quote 0.00097 with 1 ETH escrowed. Holders deposit 1,000 tokens against your quote → 0.97 ETH of your escrow pays them, the 1,000 tokens land in your wallet instantly, and you resell them at market for ~1.0 ETH → ≈3% gross margin, minus gas and price movement.

Steps:

  • 1. Find migrations. The public list (GET /api/migrations) includes every open migration's address, old token and chain. Read live bids and deposit status directly from the chain.
  • 2. Quote a price. Call placeQuote(price) and lock at least 0.001 ETH. One offer per wallet; up to 32 buyers at once. Wizard-created groups set no minimum price: you compete with other buyers and the token's pool.
  • 3. Get filled automatically. Deposits sell to the best book price in the same transaction. Your ETH pays; purchased tokens accumulate in your record throughout the window without further action.
  • 4. Collect and resell. After deposits close, call claimPurchased() and resell wherever the tokens trade. Buying at 0.97 and selling at 1.00 gives about 3% per fill. Cancel anytime for unspent ETH; cancel at close because no further purchases remain.

Why it pays: compensation for holding risk. Collection waits until deposits close, also preventing self-sales for free new tokens. Price and liquidity may deteriorate; price that risk in. Best-price-first fills make buyers undercut each other, while leftovers reach the token's pool. Offer below the pool and you won't trade.

Our open-source bot quotes market − your margin, updates with the market, then cancels, collects and resells at close. Run it with your key and margin, or beat its price for its trades. Similar opportunities exist on the Solana side of cross-chain migrations and at asset auctions.

You can lose money: the old token may fall or become hard to sell before resale. ETH stays locked while your offer stands. Offer only what you can afford to hold.

Asset auctions

Indivisible treasury assets sell through public falling-price auctions ending with a 24-hour challenge period.

Non-ETH treasury assets — NFTs, other projects' tokens, unsolicited airdrops — cannot be split on withdrawal and must be sold. Public auctions let anyone buy. Two triggers use the same auction house:

  • A voted sale. A Sell Asset vote passes, waits in the queue and lists one token amount or NFT, with an optional floor price. Never the group's own token; use the NAV sale. Started sales cannot be cancelled because buyers await their price. Unsold items return to the treasury.
  • A shut-down group's assets. After a kill, anyone can start sales of assets that cannot be paid out directly; no board approval remains. Large token holdings split into up to ten equal, staggered lots, letting early sales establish price expectations. Proceeds fund remaining holders' withdrawals over about 30 days.

Nobody picks the price

Prices start deliberately high at 100,000 ETH, halve about every six hours and reach zero after a week, passing every possible valuation. Buyers wait for an acceptable price. Shut-down sales reach zero, allowing purchase for gas alone; voted sales stop at the group's floor and return unsold items after their week.

The first purchase stops the falling price, holds the payment as a bid and opens a 24-hour challenge period. Anyone can outbid by any amount, with no minimum increment or extension. Closing is exactly 24 hours after the first bid; outbid buyers can reclaim ETH anytime.

The open market gets one last go

After 24 hours, anyone can close. Token sales first attempt a market sale for at least 1% above the winning bid. Success sends market proceeds to the treasury and fully refunds the bidder; otherwise, the bidder receives the tokens at their price. The treasury never receives less than the winning bid. This is a real trade, not a manipulable price check. The closer earns 5% of extra proceeds, incentivising timely closure without a request.

Active sales appear on the platform-wide auctions page, including shut-down groups removed from discovery, and on each group's Assets tab: falling prices and controls to bid, outbid, close and reclaim refunds.

Auction purchases are assets, not group shares redeemable against a treasury. Bids lock ETH for 24 hours. A market-beaten token bid may return only your money. Auctions currently run on test networks.

Cross-chain (EVM ↔ Solana)

Chainlink CCIP connects Ethereum and Solana, moving real tokens rather than wrapped claims.

Migrations can span a community's chains.

EthereumEVM · Sepolia

CCIP delivery → mints the new SPL token natively

← Solana deposits relay back as EVM mints

Solanadevnet

For Solana-only holders, Ethereum sends a migration message that creates the real new token in their Solana wallets without requiring Ethereum use. The same Solana program handles airdrops and a quote book, letting Solana communities deposit there for new tokens on Ethereum. Solana treasuries hold SOL and other tokens under Ethereum's same withdraw-your-share rule.

Ethereum and Solana are supported; an Ethereum L2 is being evaluated next.

Joining & membership

Join as a person, not a wallet, under the tier's rule: open entry, deposit, holding or vote.

  • Join DETF — open groups admit you immediately to the bottom tier in one click; on-chain groups can also record membership on-chain.
  • Deposit or hold tokens — tiers state and check requirements at joining; meet them for permission-free entry.
  • Ask or get invited — closed-group requests go to admins; members can invite friends, followers or any wallet address. Admin approval starts a vote, not membership: the group decides.

At creation, groups can blacklist wallet addresses from joining or requesting access. Direct top-tier invitations override the list. It controls app membership only, never on-chain holding or trading.

One person, multiple wallets. Link unlimited wallets across both chains, choose a main wallet per chain and switch acting wallets in one click. You remain one member; balances combine for the best tier any wallet qualifies for. Leaving takes one click and preserves tokens; withdraw money through the treasury.

Holdings, buying & sending

The Assets tab shows treasury holdings and your share, with buying, bidding, sending and cash-out controls.

  • Treasury holdings — ETH, listed tokens, total supply, your balance and percentage, and largest holders. Displayed assets excluded from withdrawals are marked “shown, not withdrawable”.
  • Buy tokens — use an open sale at its voted fixed price or NAV price calculated at purchase. Buying automatically pauses while a payment or kill vote waits.
  • Auctions — see current and target falling prices and controls to bid, outbid or close. See asset auctions.
  • Withdraw your share — return tokens for treasury assets here or from a profile holding: two Ethereum transactions, one on Solana. Balances combine all your wallets; the window identifies the signing wallet. After shutdown, withdrawal remains available forever, warning if assets are still being sold: early exit forfeits your share of those sales.
  • Send tokens — to any member or address on either chain. Solana creates the recipient's token account if needed. Selecting someone on the other chain looks up their address on the correct chain.
  • Transactions — wallet and group-contract history, explorer links and colour-coded inflows/outflows. Your transactions appear on confirmation, before the explorer catches up.

Chat, jobs & messages

Chat, discussions, jobs and admin tools support the group's work between votes.

  • Chat and discussions — each group has public chat with a site-wide pop-out window, plus a discussions tab for longer conversations.
  • Message the group — private member–moderator conversations, with moderators replying as the group. Personal messages share the side panel and /messages.
  • Jobs — passed Hiring proposals open jobs on the group's Jobs board. Members apply, are accepted and do the work; records of contributions inform later tier votes.
  • Admin tools — the Admin tab holds the group's inbox, board approvals, queue, seats, payouts, airdrops, ad campaigns, job applications, join requests and moderation.

The feed & tuning it

Posts and open votes share a feed with inline voting and tuning by topic and proposal type.

Filter by everything, groups, people or votes. On the Tune page, prioritise topics, hide vote types (for example, hiring but not money), or restrict votes to your groups. Browser-saved settings apply everywhere immediately.

Bots, notifications & airdrops

Link Telegram, Discord or email for vote notifications and community airdrop claims there.

Link Telegram, Discord or email in settings; Twitter/X is identity-only. The app opens the bot, such as @DETFcomBOT on Telegram, to link your account and wallet. Receive proposal, money and mention notifications in chats and the site bell. Each channel can be toggled and tested.

Bots distribute community airdrops: groups reserve Telegram or Discord tokens, admins open with /airdrop, and people use /claim. The bot prompts unlinked claimants to link a wallet. Pools split equally among claimants or pay a fixed amount each until exhausted. Migration airdrops need no setup: waiting tokens in a connected wallet automatically show a group-page claim banner, with no eligibility-check button.

Watch-to-earn advertising

Advertisers pay per verified view, viewers earn for watching, and groups must vote to run an ad inside them.

DETF funds itself through paid-to-watch ads. Advertisers fund campaigns with at least 0.1 ETH and set payment per view. Viewers choose a picture, timed video, text card or promoted post to earn that amount. Funding comes from a personal wallet, a treasury Advertise vote, or a member reimbursed by vote. Groups must approve ads shown inside them: the community votes and gets paid.

Track accumulated earnings as you watch. Verified views pay in batches directly to your wallet, without gas or signatures. DETF's 1% outgoing-payment fee funds the same DETF group as launch and migration fees.

Sell on your site

A group can sell from any web page, with card payments going directly to its shared credit balance.

Sell from any page — the group's, a member's or a sponsor's — with card payments funding Group credits, the shared balance for agent reviews, drafts, builds and Auto-mode. No shop operation, buyer account or payout: the group spends its earnings under these current rules.

How it works

  • A group admin opens AdminSell on your site and lists an item's name, US-dollar price and description.
  • Paste the card's HTML snippet wherever scripts run; the widget builder also generates it. Each item has a pay link, /pay/<group>/<item>, for READMEs, newsletters and script-free pages.
  • Buy opens the processor's checkout on this site's account; neither seller nor seller page handles card details. The receipt page here links back and returns automatically after ten seconds unless the buyer chooses to stay. Admins see buyer email, item, quantity and amount under Orders. All members see sale items and prices in Activity under Group credits on Stats, alongside deposits and spending.

Where the money goes

  • The full payment goes into the group's balance. Revenue stays for group spending, never personal balances or cash payouts. Buying on a member's site contributes to the group, not the member.
  • The platform takes 5% + 30¢ per paid order, regardless of item count. The group receives the rest; item cards and pay pages show the net ($9.20 on $10.00). Like all platform fees, this funds DETF's own group.
  • Stored prices control checkout. The widget sends only an item id. Server-side pricing prevents edited, copied or tampered pages selling a $40 item for a cent.
  • No double counting. Processor confirmation and receipt-page verification race: the first credits the group; the second is ignored.

The rules

  • Admins list; listed groups only. Members and agents can distribute widgets, but only admins change items or prices. Private, unlisted or closed groups respond to widgets and pay links as nonexistent, preventing checkout disclosure.
  • Up to 50 items; names up to 80 characters, descriptions up to 300, fixed US-dollar prices of at least $1.00. No “pay what you want”, other currencies or subscriptions. Widget and pay-link quantity boxes allow 1 to 100 per item per checkout. Up to 20 short-word options (sizes or colours) appear in a selector; checkout rejects unlisted choices, and orders show selections. Pausing or removing an item preserves its order history.
  • One cart, one checkout. Up to 20 distinct items share one payment, order, receipt and fee. Widget rows have Add; the basket shows quantities and a running total, with one Checkout. Single-item shops keep a Buy button. Browser storage preserves the basket through checkout and return; custom carts can submit in one call below. Group-enabled promotion codes are entered at the processor; orders record discounts and the pool receives the amount paid.
  • Three storefronts, one catalogue. The group's embedded widget; hosted /pay/<group> with all items and the same cart for groups without sites; and the group's automatic Shop tab once items are listed. Price changes reach all three together.
  • Shipping is optional. Admins selling physical goods enable “collect a shipping address”. The processor collects worldwide addresses on its page; only admins see them under Orders. The platform has no address form and never shows addresses on receipts, public routes or buyers' purchases lists.
  • Groups are responsible for what they sell: describe it truthfully and fulfil it. DETF hosts checkout, not delivery or purchase arbitration.
  • Checkout email and name are visible only to group admins for fulfilment, never on public group pages.
  • No refund button. Sales credit immediately on settlement and may already be spent. DETF handles refunds and card disputes case by case through Contact. Groups unable to honour sales lose selling access.
  • Credits are not cash. Sales and card top-ups create prepaid DETF spending balances: credits never expire, are never refunded to a card, paid out or converted to money or tokens, and are forfeited when the group closes.
  • Checkouts open at most ten times a minute per address; item lists use a short cache. When card payments are disabled, listings remain and Buy opens a pay page explaining this.

For developers

Two keyless endpoints power the restylable HTML widget. GET /api/v1/store/items?group=<id> returns the group, items (id, name, description, price in cents and formatted) and pay links, cached one minute; hidden and unknown groups return 404.POST /api/v1/store/checkout accepts { group, item, quantity?, variant?, returnUrl? } or a cart, { group, lines: [{ item, quantity?, variant? }], returnUrl? }, and returns { url } for browser navigation. Items with options require a listed variant. Custom product pages and carts can use the widget's window.groupStoreCheckout(lines, { returnUrl }). For a custom count using its cart, read window.groupStoreCart.lines() and listen for group-store:cart on document. Any origin, no cookie or key; ten calls per minute per address. Send item ids, never prices: the server does not take one.503 means payments are disabled; fall back to the item's payUrl for a buyer-facing explanation. HTTP(S)-only returnUrl supplies the receipt's “Back to” link and ten-second redirect after payment confirmation. Only the paid session's copy redirects; the URL copy remains a link.

Restyle without forking. Colours, radii and fonts use CSS custom properties with original-value fallbacks; every element has a gs- class (gs-row, gs-buy, gs-cart, gs-checkout, gs-total…). Set properties on #group-store or an ancestor — #group-store { --gs-accent: #111; --gs-radius: 0; --gs-font: 15px/1.5 Inter, sans-serif } — or style the classes. Supported properties: --gs-accent, --gs-on-accent, --gs-muted, --gs-faint, --gs-line, --gs-field, --gs-bad, --gs-good, --gs-radius, --gs-button-radius, --gs-font, --gs-font-small and --gs-font-tiny. Direct block edits work but are lost when copying a later version.

The handshake — how your site knows a payment happened

One keyless check; no server required:

  • On the page. The receipt returns paid buyers with ?d8a_order=<id>. The widget verifies through GET /api/v1/store/orders/<id>?group=<group>, never the URL alone. A { paid: true, order } response fires group-store:paid on document with the order, sets window.groupStorePaid, and shows a one-line receipt above the list. Release products in that handler: document.addEventListener("group-store:paid", (e) => unlock(e.detail.itemId)). window.groupStoreVerify(id) also checks manually entered ids. Unguessable ids exist only for paid orders; possession is the receipt. Public verification reveals purchases, never buyers.
  • On a server or for an agent. GET /api/v1/store/orders/<id>?group=<group> supports the same public, keyless check anywhere without a browser. Buyer identity stays in Admin's Orders list.
  • Every path requires the buyer to return with the id. Delivery is only as safe as the page's check: verify against the platform, not the URL.

The contract set

Byte-for-byte Aragon OSx runs the DAO and voting; our own code is deliberately small.

Every DETF uses the same contract set, deployed and connected in one transaction:

Aragon OSx core

The DAO and permissions; an exact Aragon OSx copy, checked byte for byte.

TokenVoting

Token-holder voting: initially 50% approval and 10% turnout, changeable by a Tiers vote.

DETFGovernanceERC20

The group token: carries votes, records past balances and burns to redeem your share.

DETFTreasuryPlugin

Holds funds, sells tokens for ETH, pays exit shares, runs NAV and auction sales, and holds the kill switch.

DETFBoardPlugin

Board membership, seat weights and the vote's pass mark.

DETFTimelockPlugin

Public queue for approved actions; board members can cancel them before execution.

DETFTierBadge + DETFSeats

Tier-membership badges and shared seats for tiers and token holders.

DETFSeatFactory

Creates badges and seats for the group's plan.

DETFGroupFactory

Deploys and connects the whole set in one transaction.

DETFMigrationV2

Deposit & Sell: quote book (up to 32 makers), DEX fallback and leftovers.

DETFAuctionModule

Platform-wide auction house: falling-price sales of voted assets and shut-down groups' leftovers.

DETFAirdrop

List-based airdrop claims by transfer or minting.

DETFCCIPSender + DistributorV2

Ethereum-side token transfers to Solana over Chainlink CCIP.

detf-distributor (Solana)

Solana program: CCIP messages, airdrop claims, quote book and withdrawable group treasuries.

DAO and voting code copies Aragon OSx word for word, verified byte for byte, retaining its existing scrutiny. DETF's deliberately small custom code — treasury, queue, board and seats, factory, migration and auctions — is the audit scope, covered by 110 tests.

Rebuilt in July 2026, our contracts close every internal-review finding. An independent professional audit will precede real-money use. Until then, queue delays, voting windows and auction price changes are set to run in minutes rather than days for testing; rules remain unchanged.

Networks & status

Test networks only — Ethereum Sepolia and Solana devnet — never send real money to a DETF contract.

Ethereum

Running on Sepolia testnet

Groups, voting, board and seats, treasury, kill switch, quote-book migrations and auctions

Solana

Running on devnet

Ethereum token arrivals, airdrop claims, quote book and withdrawable group treasuries

Ethereum L2

Being evaluated

Next network considered; not running yet.

DETF runs on test networks only. Never send real money to a DETF contract.

The .d8a file

A short repository-root file identifies its governing group, run instructions and sales.

Governed repositories carry .d8a at the root. Its five comment lines and link here keep it short because the platform does not own the repository. Two halves share one file:

# .d8a — who governs this repository, how to run it, and how it earns.
# Code lands on this branch only by the named group's passed vote; delete the
# file to withdraw consent. The three lines below, tools: and payments: are
# generated, never hand-edited. Everything else: https://detf.com/docs?group=your-group#d8a-file
version: 1
group: web:your-group
proof: <signed by the platform>

# tools: — the group's own doors, for a person or an agent working here.
tools:
  group    = https://detf.com/g/your-group
  admin    = https://detf.com/g/your-group?section=admin
  items    = https://detf.com/api/v1/store/items?group=your-group
  checkout = https://detf.com/api/v1/store/checkout
  verify   = https://detf.com/api/v1/store/orders/ORDER?group=your-group

# payments: — the group's shop. Paste this block where it should appear on the
# site; card payments land in the group's shared credit balance. Items and
# prices are set on the group's Admin tab, never here. A paid buyer comes back
# with ?d8a_order=<id>; the block verifies it (tools: verify) and fires the
# "group-store:paid" event — release the product there.
payments:
  <!-- Sell on your site: your items, a Buy button each. -->
  <div id="group-store"></div>
  <script>  …the widget, printed in full below…  </script>

# url: https://example.com   — where it runs live; shown as a globe once set.
# run: — how to run it. Yours to edit; VS Code's ▶ (the VSD8A extension) launches each entry.
run:
  {
    "// dev": "npm run dev",
    "// api": { "cmd": "npm start", "cwd": "packages/api", "port": 4000 }
  }

Put the shop on the site — the code to paste

Three steps for a person or agent working from .d8a; the third implements the shop on this repository's site:

  1. No platform configuration. Governed repositories define items and prices in items: within .d8a, one line each: Everyday Tee = $38.00 — soft combed cotton; up to 50 items, minimum $1.00. Passed Code proposals change them; Admin displays them read-only. The platform reads every merge, requiring no site redeploy. Groups without repositories list items in Admin's Sell on your site.
  2. Check the catalogue with GET https://detf.com/api/v1/store/items?group=<id>: group, items and pay links. Empty items means no listings; the widget renders nothing until an admin lists something.
  3. Paste the block below where the shop belongs, or copy the same group-filled block from .d8a under payments:. Dependency-free HTML and script work on any page or framework: load items, show Buy buttons, open card checkout. Restyle freely but retain data-item and the checkout call; never put prices in the page, since the server sets them. A size, colour or note travels as one line via data-variant on the buy link or variant in checkout. It appears at card checkout, on the receipt and in Admin's order. Higher-priced variants need separate items. On paid return, the block verifies with the platform and fires group-store:paid; release the product there (see the handshake).

Replace YOUR-GROUP-ID with the id after the colon in your .d8a file's group: line, or open its docs link to fill it automatically.

<!-- Sell on your site: your items, a selector where one comes in sizes or
     colours, and a CART — Add on every row, then one Checkout for the lot. The
     basket rides in a pill stuck to the bottom of THIS box, never to your page:
     its right half IS the Checkout, its left half opens an optional review
     panel. A single-item shop keeps its single Buy button and no basket at all.
     Paste ONE of these per page.
     STYLING: every colour, radius and font is a CSS custom property with the old
     literal as its fallback, and every node carries a gs-* class. Restyle it from
     your own stylesheet — #group-store { --gs-accent:#111; --gs-radius:0 } — or
     beat the block's own rules from an id: #group-store .gs-row { … }. On a DARK
     site set the one thing a sticky control cannot guess:
     #group-store { --gs-panel:#111827; --gs-hover:#1f2937 }. --gs-dock-bottom
     lifts the bar off the viewport bottom; :auto makes it a plain bar again.
     After a payment the buyer
     returns here with ?d8a_order=<id>; the widget verifies it with the platform and
     fires "group-store:paid" (window.groupStorePaid) — release the product there.
     The order id alone is a link anyone could forward, so an order also proves
     WHO paid: set window.groupStoreBuyerEmail (ask the returning buyer, or read
     your own session) before this block, or call
     window.groupStoreVerify(id, email) yourself. The email is hashed here and
     never sent.
     YOUR OWN CART: window.groupStoreCheckout([{ item, quantity, variant }, …])
     opens ONE checkout for everything a cart holds — item ids from the list this
     block loads (or tools: items), one line per item and choice. Keep your own
     product pages; this is the only call they need. To draw your own basket
     count off THIS cart instead, read window.groupStoreCart.lines() and listen
     for "group-store:cart" on document. A multi-item shop draws no per-row
     quantity box — the basket owns quantity — so a page that scripted one calls
     window.groupStoreCart.add({ item, quantity }) instead. -->
<div id="group-store"></div>
<noscript><p><a href="https://detf.com/pay/YOUR-GROUP-ID">Shop</a></p></noscript>
<style>#group-store *{box-sizing:border-box}#group-store :focus-visible{outline:2px solid var(--gs-accent,#7c5cff);outline-offset:2px}#group-store{font:var(--gs-font,14px system-ui,sans-serif)}.gs-t{font:var(--gs-font-tiny,11px system-ui,sans-serif);color:var(--gs-faint,#9ca3af)}.gs-m{color:var(--gs-muted,#6b7280)}.gs-b{color:var(--gs-bad,#dc2626)}.gs-n{font-variant-numeric:tabular-nums}.gs-a{color:var(--gs-accent,#7c5cff)}.gs-r{margin-left:auto}.gs-row{display:flex;flex-wrap:wrap;align-items:center;gap:10px;padding:10px 0}.gs-row[data-sold]{opacity:.6}.gs-main{flex:1 1 11rem;min-width:0}.gs-name{font-weight:600}.gs-box{font:inherit;padding:5px 8px;width:76px;border-radius:var(--gs-radius,8px)}.gs-select{width:auto}.gs-opts{display:flex;flex-wrap:wrap;gap:6px}.gs-chip{font:var(--gs-font-small,13px system-ui,sans-serif);line-height:1;padding:7px 11px;cursor:pointer}.gs-chip[aria-pressed=true]{background:var(--gs-accent,#7c5cff);border-color:var(--gs-accent,#7c5cff);color:var(--gs-on-accent,#ffffff)}.gs-opts[data-need] .gs-chip{border-color:var(--gs-bad,#dc2626)}.gs-buy,.gs-checkout{font:inherit;cursor:pointer;text-decoration:none;white-space:nowrap;border:0;padding:9px 18px;background:var(--gs-accent,#7c5cff);color:var(--gs-on-accent,#ffffff)}.gs-buy{display:inline-block;padding:7px 16px}.gs-buy[hidden]{display:none}.gs-checkout{flex:none;min-width:8.5rem;font-weight:600}.gs-bar .gs-checkout{border-radius:0 var(--gs-button-radius,999px) var(--gs-button-radius,999px) 0}.gs-checkout[disabled]{opacity:.6;cursor:default}.gs-stepper{display:inline-flex;align-items:center;padding:2px}.gs-rowstep:empty{display:none}.gs-step{font:inherit;line-height:1;min-width:30px;padding:5px 0;cursor:pointer;border:0}.gs-step[disabled]{opacity:.35;cursor:default}.gs-qty{width:3ch;text-align:center;font:inherit;font-variant-numeric:tabular-nums;border:0}.gs-dock{position:sticky;bottom:var(--gs-dock-bottom,0px);z-index:1;display:flex;flex-direction:column-reverse;align-items:flex-end;margin-top:12px}.gs-bar,.gs-panel{width:min(100%,26rem);background:var(--gs-panel,#ffffff);border:1px solid var(--gs-line,#e5e7eb)}.gs-bar{display:flex;align-items:stretch;margin:8px 0}.gs-toggle{flex:1;display:flex;align-items:center;gap:8px;min-width:0;font:inherit;text-align:left;border:0;padding:10px 14px;cursor:pointer;border-radius:var(--gs-button-radius,999px) 0 0 var(--gs-button-radius,999px)}.gs-toggle:hover{background:var(--gs-hover,#f3f4f6)}.gs-count{flex:none;min-width:22px;padding:0 6px;text-align:center;line-height:20px;font:var(--gs-font-tiny,11px system-ui,sans-serif);background:var(--gs-accent,#7c5cff);color:var(--gs-on-accent,#ffffff)}.gs-count[data-bad]{background:var(--gs-bad,#dc2626)}.gs-sum{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gs-caret{margin-left:auto;transition:transform .15s}[aria-expanded=true] .gs-caret{transform:rotate(180deg)}.gs-panel[hidden]{display:none}.gs-panel{max-height:min(70vh,520px);overflow:auto;border-radius:var(--gs-radius,8px);font:var(--gs-font-small,13px system-ui,sans-serif)}.gs-head,.gs-seller,.gs-cart-row,.gs-tot,.gs-foot{display:flex;align-items:center;gap:8px}.gs-head{padding:10px 12px 4px;font-weight:600}.gs-seller,.gs-why{padding:0 12px 9px}.gs-lines{max-height:min(32vh,220px);overflow:auto;padding:0 12px}.gs-cart-row{flex-wrap:wrap;padding:8px 0}.gs-cart-name{flex:1 1 7rem;min-width:0}.gs-line-total{min-width:68px;text-align:right}.gs-remove{font:inherit;border:0;padding:2px 4px;cursor:pointer;color:var(--gs-faint,#9ca3af)}.gs-sub{flex:1 0 100%}.gs-link{font:inherit;color:var(--gs-accent,#7c5cff);border:0;padding:0 0 0 4px;cursor:pointer;text-decoration:underline}.gs-sums{padding:9px 12px}.gs-tot{padding:2px 0}.gs-tot b{margin-left:auto;font-weight:inherit}.gs-big{font-weight:700}.gs-foot{padding:9px 12px 10px}.gs-foot .gs-checkout{flex:1;min-width:0}.gs-status{margin:6px 0 0;min-height:1.2em;font:var(--gs-font-small,13px system-ui,sans-serif);color:var(--gs-faint,#9ca3af)}.gs-status[data-bad]{color:var(--gs-bad,#dc2626)}[data-option-for][data-need]{border-color:var(--gs-bad,#dc2626)}.gs-row,.gs-lines,.gs-cart-row+.gs-cart-row,.gs-sums,.gs-foot{border-top:1px solid var(--gs-line,#e5e7eb)}.gs-buy,.gs-chip,.gs-bar,.gs-count,.gs-stepper,.gs-foot .gs-checkout{border-radius:var(--gs-button-radius,999px)}.gs-box,.gs-chip,.gs-stepper{border:1px solid var(--gs-field,#d1d5db)}.gs-box,.gs-chip,.gs-toggle,.gs-step,.gs-qty{background:none;color:inherit}.gs-remove,.gs-link{background:none}@media (max-width:520px){.gs-dock{align-items:stretch}.gs-main{flex-basis:100%}.gs-buy,.gs-stepper,.gs-rowstep{margin-left:auto}}@media (prefers-reduced-motion:reduce){.gs-caret{transition:none}}</style>
<script>
(function () {
  var BASE = "https://detf.com";
  var GROUP = "YOUR-GROUP-ID";
  var esc = function (s) {
    return String(s).replace(/[&<>"']/g, function (c) { return "&#" + c.charCodeAt(0) + ";"; });
  };
  var el = document.getElementById("group-store");
  // Hashed here, so the address never leaves the page. The split name is ON PURPOSE.
  var sha256Hex = function (s) {
    try {
      var g = window, k = "cr" + "ypto";
      var c = g[k] && g[k].subtle;
      if (!c) return Promise.resolve("");
      return c.digest("SHA-256", new TextEncoder().encode(String(s).trim().toLowerCase())).then(function (b) {
        return Array.prototype.map.call(new Uint8Array(b), function (x) { return ("0" + x.toString(16)).slice(-2); }).join("");
      });
    } catch (e) { return Promise.resolve(""); }
  };
  var bearer = function () {
    try { var l = window.d8aLogin; return l && l.bearer ? { Authorization: "Bearer " + String(l.bearer) } : {}; } catch (e) { return {}; }
  };
  var verify = function (id, email) {
    return (email ? sha256Hex(email) : Promise.resolve("")).then(function (h) {
      return fetch(BASE + "/api/v1/store/orders/" + encodeURIComponent(id) + "?group=" + encodeURIComponent(GROUP) + (h ? "&buyer=" + h : ""), { headers: bearer() })
        .then(function (r) { return r.ok ? r.json() : null; })
        .then(function (d) { return d && d.paid ? d.order : null; })
        .catch(function () { return null; });
    });
  };
  window.groupStoreVerify = verify;
  var here = function () { return location.href.replace(/([?&])d8a_order=[^&#]*&?/, "$1").replace(/[?&](#|$)/, "$1"); };
  var checkout = function (lines, opts) {
    opts = opts || {};
    var headers = bearer(); headers["Content-Type"] = "application/json";
    return fetch(BASE + "/api/v1/store/checkout", { method: "POST", headers: headers,
      body: JSON.stringify({ group: GROUP, lines: lines, returnUrl: opts.returnUrl || here() }) })
      .then(function (r) { return r.json().then(function (d) { if (!r.ok || !d.url) throw new Error(d.error || "Checkout failed"); return d; }); })
      .then(function (d) { if (opts.navigate !== false) location.href = d.url; return d; });
  };
  window.groupStoreCheckout = checkout;
  var CART_KEY = "d8a-cart:" + GROUP;
  var cart = [];
  try {
    var saved = JSON.parse(window.localStorage.getItem(CART_KEY) || "[]");
    if (Object.prototype.toString.call(saved) === "[object Array]") {
      cart = saved.filter(function (l) { return l && typeof l.item === "string" && l.quantity > 0; });
    }
  } catch (e) { cart = []; }
  var cartChanged = function () {
    try { window.localStorage.setItem(CART_KEY, JSON.stringify(cart)); } catch (e) { /* memory only */ }
    try { document.dispatchEvent(new CustomEvent("group-store:cart", { detail: { lines: cart.slice(0) } })); } catch (e) {}
    if (window.groupStoreRenderCart) window.groupStoreRenderCart();
  };
  var clearCart = function () {
    cart = [];
    try { window.localStorage.removeItem(CART_KEY); } catch (e) {}
    cartChanged();
  };
  // One line per item AND choice; a second Add of the same pair adds quantity.
  var addToCart = function (line) {
    for (var i = 0; i < cart.length; i++) {
      if (cart[i].item === line.item && (cart[i].variant || "") === (line.variant || "")) {
        cart[i].quantity = Math.min(100, cart[i].quantity + line.quantity);
        if (line.amountCents) cart[i].amountCents = line.amountCents;
        cartChanged();
        return true;
      }
    }
    if (cart.length >= 20) return false;   // STORE_MAX_LINES: the checkout refuses more
    cart.push(line);
    cartChanged();
    return true;
  };
  window.groupStoreCart = {
    lines: function () { return cart.slice(0); },
    add: function (line) { return addToCart({ item: String(line.item), quantity: Math.max(1, Math.min(100, Math.round(line.quantity || 1))), variant: line.variant || "", amountCents: line.amountCents || undefined }); },
    clear: clearCart,
    checkout: function (opts) { return checkout(cart.slice(0), opts); }
  };
  var back = (location.search.match(/[?&]d8a_order=([A-Za-z0-9_-]+)/) || [])[1];
  if (back) verify(back, window.groupStoreBuyerEmail).then(function (o) {
    if (!o) return;
    window.groupStorePaid = o;
    clearCart();   // it is bought: the basket that opened this checkout is spent
    if (el && el.parentNode) {
      var p = document.createElement("p");
      p.setAttribute("data-paid", o.id);
      p.style.cssText = "font:var(--gs-font-small,13px system-ui,sans-serif);color:var(--gs-good,#059669)";
      // One line for the whole order — a cart reads "Everyday Tee (M) + 2 more".
      p.innerHTML = "Paid: " + esc(o.summary || (o.itemName + (o.quantity > 1 ? " \u00d7" + o.quantity : ""))) + " \u2014 order " + esc(o.id);
      el.parentNode.insertBefore(p, el);
    }
    document.dispatchEvent(new CustomEvent("group-store:paid", { detail: o }));
  });
  if (!el) return;
  var store = null;   // what is on sale, so the handler below sees the latest load
  var amending = -1;   // which line has its named amount open for editing
  var money = function (c) { return "$" + (Math.max(0, Math.round(c)) / 100).toFixed(2); };
  var itemById = function (id) {
    var xs = (store && store.items) || [];
    for (var i = 0; i < xs.length; i++) { if (xs[i].id === id) return xs[i]; }
    return null;
  };
  // What one line may hold: the seller's stock, else the cap the route enforces.
  var capOf = function (it) { return (it && typeof it.stock === "number" && it.stock > 0) ? Math.min(100, it.stock) : 100; };
  var status = function (msg, bad) {
    var p = el.querySelector("[data-cart-status]");
    if (!p) return;
    p.textContent = msg || "";
    if (bad) p.setAttribute("data-bad", "1"); else p.removeAttribute("data-bad");
    if (bad && msg && p.scrollIntoView) p.scrollIntoView({ block: "nearest" });
  };
  // Written once: a host page may paste this inside a form of its own, where a
  // button with no type submits it. Every button below opens with these bytes.
  var BTN = '<button type="button" class="gs-';
  var loadFailed = function () {
    el.innerHTML = '<p role="status" class="gs-error gs-t">The shop could not be reached. ' + BTN + 'retry gs-link" data-store-retry>Try again</button></p>';
    var retry = el.querySelector("[data-store-retry]");
    if (retry) retry.addEventListener("click", function () { load(); });
  };
  var openPanel = function (want) {
    var tog = el.querySelector("[data-cart-toggle]"), pan = el.querySelector("[data-cart-panel]");
    if (!tog || !pan) return;
    tog.setAttribute("aria-expanded", want ? "true" : "false");
    if (want) pan.removeAttribute("hidden"); else pan.setAttribute("hidden", "hidden");
  };
  // One shape, twice: panel (typable) and row. Both carry the cart INDEX.
  var stepper = function (at, qty, cap, typable, name) {
    return '<span class="gs-stepper">' + BTN + 'step" data-dec="' + at + '" aria-label="One fewer">\u2212</button>' +
      (typable ? '<input class="gs-qty" data-cart-qty="' + at + '" type="text" inputmode="numeric" value="' + qty + '" aria-label="Quantity of ' + name + '">' : '<span class="gs-qty">' + qty + '</span>') +
      BTN + 'step" data-inc="' + at + '"' + (qty >= cap ? ' disabled title="Only ' + cap + ' left"' : '') + ' aria-label="One more">+</button></span>';
  };
  // A rewritten or hidden control cannot keep focus: name it, stand there again.
  var FK = ["data-inc", "data-dec", "data-del", "data-cart-qty"];
  var keyOf = function (f) {
    for (var i = 0; i < 4; i++) { var v = f && f.getAttribute && f.getAttribute(FK[i]); if (v != null) return "[" + FK[i] + '="' + v + '"]'; }
    return "";
  };
  var refocus = function (r, k) { var f = k && r.querySelector(k); if (f && f.disabled) f = f.parentNode.querySelector("[data-dec]"); if (f) f.focus(); };
  // The Add, and the amount box beside it, are hidden for that line's stepper.
  var renderRowState = function () {
    var as = el.querySelectorAll("a[data-item]");
    for (var i = 0; i < as.length; i++) {
      var a = as[i], id = a.getAttribute("data-item");
      var slot = el.querySelector('[data-rowstep-for="' + id + '"]');
      if (!slot) continue;
      var ab = el.querySelector('[data-amount-for="' + id + '"]');
      var sel = el.querySelector('[data-option-for="' + id + '"]');
      var v = sel ? sel.value : (a.getAttribute("data-variant") || "");
      var at = -1;
      for (var j = 0; j < cart.length; j++) { if (cart[j].item === id && (cart[j].variant || "") === v) { at = j; break; } }
      var act = document.activeElement, held = slot.contains(act), h = at < 0 ? "removeAttribute" : "setAttribute";
      a[h]("hidden", "hidden");
      if (ab) ab[h]("hidden", "hidden");
      if (at < 0) { slot.innerHTML = ""; if (held) a.focus(); continue; }
      var k = act === a ? '[data-inc="' + at + '"]' : (held ? keyOf(act) : "");
      slot.innerHTML = stepper(at, cart[at].quantity, capOf(itemById(id)), false, "");
      refocus(slot, k);
    }
  };
  // Redrawn whole, so the button indices are the ones on screen.
  var renderCart = function () {
    var box = el.querySelector("[data-cart]");
    if (!box) return;
    var s = store;
    // A one-item shop draws no dock; its stored lines are unseen, NOT deleted.
    if (!s || !s.items || s.items.length < 2 || !cart.length) { box.innerHTML = ""; renderRowState(); return; }
    var prev = box.querySelector("[data-cart-toggle]");
    var wasOpen = !!(prev && prev.getAttribute("aria-expanded") === "true");
    var ae = document.activeElement, fk = box.contains(ae) ? keyOf(ae) : "";
    var total = 0, units = 0, needsFix = false;
    var rows = cart.map(function (l, i) {
      var it = itemById(l.item);
      var unit = l.amountCents || (it ? it.priceCents : 0);
      // Inherited from an earlier visit: over the stock, or under the floor.
      var over = (it && typeof it.stock === "number" && it.stock > 0 && l.quantity > it.stock) ? it.stock : 0;
      var under = it && it.payWhatYouWant && unit < it.priceCents;
      if (over || under) needsFix = true;
      total += unit * l.quantity;
      units += l.quantity;
      var nm = esc(it ? it.name : l.item), sub = "";
      if (over) sub = '<span class="gs-sub gs-t gs-b">Only ' + over + ' left \u2014 you asked for ' + l.quantity + ' \u00b7 ' + BTN + 'link" data-clamp="' + i + '">use ' + over + '</button></span>';
      else if (it && it.payWhatYouWant) sub = amending === i
        ? '<span class="gs-sub gs-t"><input class="gs-box gs-n" data-amend-for="' + i + '" type="text" inputmode="decimal" value="' + (Math.max(0, Math.round(unit)) / 100).toFixed(2) + '" aria-label="Amount for ' + nm + '"></span>'
        : '<span class="gs-sub gs-t' + (under ? ' gs-b' : '') + '">You named ' + money(unit) + (under ? ' \u2014' : ' \u00b7') + ' minimum ' + money(it.priceCents) + ' \u00b7 ' + BTN + 'link" data-' + (under ? 'floor' : 'amend') + '="' + i + '">' + (under ? 'use ' + money(it.priceCents) : 'change') + '</button></span>';
      else if (l.quantity > 1) sub = '<span class="gs-sub gs-t">' + money(unit) + ' each</span>';
      return '<div class="gs-cart-row"><span class="gs-cart-name">' + nm + (l.variant ? ' <span class="gs-m">(' + esc(l.variant) + ')</span>' : '') + '</span>' +
        stepper(i, l.quantity, capOf(it), true, nm) +
        '<span class="gs-line-total gs-n">' + money(unit * l.quantity) + '</span>' +
        BTN + 'remove" data-del="' + i + '" aria-label="Remove">\u00d7</button>' + sub + '</div>';
    }).join("");
    var ck = s.checkout || {}, off = ck.enabled === false, ship = !!ck.shipping, promo = !!ck.promoCodes;
    var g = s.group || {};
    var gn = '<b>' + esc(g.name || "") + '</b>';
    var seller = 'Sold by ' + (g.url ? '<a class="gs-a" href="' + esc(g.url) + '">' + gn + '</a>' : gn);
    // The second sentence is only honest because checkout() sends a returnUrl.
    var signedIn = false;
    try { signedIn = !!bearer().Authorization; } catch (e) {}
    var why = (signedIn ? "Paying as your signed-in account \u2014 this order shows under your purchases."
      : "You&#39;ll enter your email at the card checkout \u2014 it&#39;s what proves the receipt is yours.") +
      " After paying you come back here and this basket empties.";
    // The resting button IS the checkout, until the line it would send is doomed.
    var barBtn = BTN + 'checkout" data-checkout' + (needsFix ? ' data-fix' : (off ? ' disabled' : '')) + '>' + (needsFix ? "Fix" : (off ? "Payments not set up" : "Checkout")) + '</button>';
    var footBtn = BTN + 'checkout" data-checkout' + (needsFix || off ? ' disabled' : '') + '>' + (needsFix ? "Fix the line above" : (off ? "Payments not set up" : "Checkout \u00b7 " + money(total))) + '</button>';
    box.innerHTML = '<div class="gs-bar">' + BTN + 'toggle" data-cart-toggle aria-expanded="false">' +
      '<span class="gs-count"' + (needsFix ? ' data-bad' : '') + '>' + units + '</span>' +
      '<span class="gs-sum">Basket \u00b7 ' + money(total) + '</span>' +
      '<span class="gs-caret" aria-hidden="true">\u25b4</span></button>' + barBtn + '</div>' +
      '<div class="gs-panel" role="region" aria-label="Basket" data-cart-panel hidden>' +
      '<div class="gs-head">Basket<span class="gs-r gs-t">' + cart.length + ' of 20 things</span></div>' +
      '<div class="gs-seller gs-m"><span>' + seller + '</span><span class="gs-r gs-t">Paid by card</span></div>' +
      '<div class="gs-lines">' + rows + '</div><div class="gs-sums">' +
      (ship ? '<div class="gs-tot"><span>Subtotal</span><b class="gs-n">' + money(total) + '</b></div><div class="gs-tot gs-m"><span>Shipping</span><b>Address collected at checkout</b></div>' : '') +
      (promo ? '<div class="gs-tot gs-m"><span>Promo code</span><b>Enter it at the card checkout</b></div>' : '') +
      '<div class="gs-tot gs-big"><span>Total today</span><b class="gs-n">' + money(total) + (ship ? ' + shipping' : '') + '</b></div></div>' +
      '<div class="gs-why gs-t">' + why + '</div>' +
      '<div class="gs-foot">' + footBtn + BTN + 'clear gs-link gs-t" data-clear>Empty basket</button></div></div>';
    if (wasOpen) openPanel(true);   // an open panel survives a step or a remove
    renderRowState();
    refocus(box, fk);   // the + you just pressed is a new node; stand there again
  };
  window.groupStoreRenderCart = renderCart;
  var load = function () {
    el.innerHTML = '<p role="status" class="gs-loading gs-t">Loading what&#39;s on sale\u2026</p>';
    // A request that never answers never rejects; without this it loads for ever.
    var done = false, timedOut = false;
    var ctrl = window.AbortController ? new AbortController() : null;
    var timer = setTimeout(function () {
      if (done) return;
      timedOut = true;
      if (ctrl) { try { ctrl.abort(); } catch (err) { loadFailed(); } }  // rejects -> .catch below
      else { loadFailed(); }                                             // no AbortController: draw it here
    }, 12000);
    fetch(BASE + "/api/v1/store/items?group=" + encodeURIComponent(GROUP), ctrl ? { signal: ctrl.signal } : undefined)
      .then(function (r) {
        if (timedOut) return null;              // late answer: the failure line stands
        done = true;
        clearTimeout(timer);                    // a slow but good render is never killed
        if (!r.ok) throw new Error("HTTP " + r.status);
        return r.json();
      })
      .then(function (s) {
        if (!s) return;
        if (!s.items) throw new Error("bad payload");
        store = s;
        var ck = s.checkout || {};
        if (!s.items.length) { el.innerHTML = '<p class="gs-empty gs-t">Nothing for sale right now.</p>'; return; }
        // More than one thing to buy = a basket is worth it; one thing is not.
        var multi = s.items.length > 1;
        el.innerHTML = s.items.map(function (it) {
          // Sold out gets no Buy; a cap prints "3 left"; PWYW prefills its box.
          var sold = it.soldOut === true, iid = esc(it.id), nm = esc(it.name);
          var left = (typeof it.stock === "number" && it.stock > 0) ? '<span class="gs-left gs-t gs-m">' + it.stock + ' left</span>' : '';
          var priceText = esc(it.price) + (it.payWhatYouWant ? '+' : '');
          var amountBox = it.payWhatYouWant && !sold
            ? '<input type="number" class="gs-box gs-n" data-amount-for="' + iid + '" min="' + (it.priceCents / 100) + '" step="0.01" value="' + (it.priceCents / 100).toFixed(2) + '" onfocus="this.select()" aria-label="Amount for ' + nm + '">'
            : '';
          var options = '';
          if (Array.isArray(it.options) && it.options.length && !sold) {
            options = (it.options.length <= 6 && it.options.join("").length <= 40)
              ? '<span class="gs-opts" role="group" aria-label="Choose one for ' + nm + '" data-opts-for="' + iid + '">' + it.options.map(function (o) { return BTN + 'chip" data-chip="' + iid + '" data-value="' + esc(o) + '" aria-pressed="false">' + esc(o) + '</button>'; }).join("") + '</span>'
              : '<select data-option-for="' + iid + '" aria-label="Choose one for ' + nm + '" class="gs-box gs-select"><option value="">Choose\u2026</option>' + it.options.map(function (o) { return '<option value="' + esc(o) + '">' + esc(o) + '</option>'; }).join("") + '</select>';
          }
          var qty = (sold || multi) ? '' : '<input type="number" class="gs-box gs-n" data-qty-for="' + iid + '" min="1" max="' + capOf(it) + '" value="1" aria-label="Quantity of ' + nm + '">';
          // The same <a> in both modes: no-script href, data-variant hook, and
          // data-add is what routes it into the basket.
          var action = sold
            ? '<span class="gs-soldout gs-t">Sold out</span>'
            : '<a href="' + esc(it.payUrl) + '" data-item="' + iid + '"' + (multi ? ' data-add="1"' : '') + ' class="gs-buy">' + (multi ? 'Add' : 'Buy') + '</a>' +
              (multi ? '<span class="gs-rowstep" data-rowstep-for="' + iid + '"></span>' : '');
          return '<div class="gs-row"' + (sold ? ' data-sold' : '') + '>' +
            '<div class="gs-main"><b class="gs-name">' + nm + '</b>' +
            (it.description ? '<div class="gs-desc gs-t">' + esc(it.description) + '</div>' : '') + '</div>' +
            left + options + qty + amountBox +
            '<span class="gs-price">' + priceText + '</span>' + action + '</div>';
        }).join("") +
          '<div class="gs-dock" data-cart></div>' +
          '<p class="gs-status" data-cart-status role="status"></p>' +
          '<p class="gs-note gs-t">Sold by <a class="gs-a" href="' + esc(s.group.url) + '">' + esc(s.group.name) + '</a>' +
          (!multi && ck.shipping ? ' \u00b7 Shipping address collected at checkout' : '') +
          (!multi && ck.promoCodes ? ' \u00b7 Have a promo code? Enter it at checkout' : '') + '</p>';
        // A stored basket can name an item the shop has withdrawn: drop, say so.
        var before = cart.length, gone = [];
        cart = cart.filter(function (l) {
          var it = itemById(l.item);
          if (!it || it.soldOut === true) { gone.push(it ? it.name : l.item); return false; }
          return true;
        });
        if (gone.length) status("Sold out and removed: " + gone.join(", ") + ".", true);
        if (cart.length !== before) cartChanged(); else renderCart();
      })
      .catch(function () {
        clearTimeout(timer);
        loadFailed();
      });
  };
  el.addEventListener("click", function (e) {
    var t = e.target;
    var near = function (q) { return t && t.closest ? t.closest(q) : null; };
    var tog = near("[data-cart-toggle]");
    if (tog) { openPanel(tog.getAttribute("aria-expanded") !== "true"); return; }
    var btn = near("button[data-inc],button[data-dec],button[data-del],button[data-checkout]");
    if (btn) {
      var inc = btn.getAttribute("data-inc"), dec = btn.getAttribute("data-dec"), del = btn.getAttribute("data-del");
      if (del !== null) { cart.splice(Number(del), 1); cartChanged(); return; }
      if (inc !== null || dec !== null) {
        var at = Number(inc !== null ? inc : dec);
        var line = cart[at];
        if (!line) return;
        // One fewer than one is no line at all — the same thing the \u00d7 does.
        if (inc === null && line.quantity <= 1) { cart.splice(at, 1); cartChanged(); return; }
        var cap = capOf(itemById(line.item));
        line.quantity = Math.max(1, Math.min(cap, line.quantity + (inc !== null ? 1 : -1)));
        cartChanged();
        return;
      }
      if (btn.getAttribute("data-fix") !== null) { openPanel(true); return; }
      status("");
      var all = el.querySelectorAll("[data-checkout]");
      for (var k = 0; k < all.length; k++) all[k].disabled = true;
      btn.textContent = "Opening\u2026";
      checkout(cart.slice(0)).catch(function (err) {
        renderCart();
        // The platform's own words, so the buyer is told what to change.
        status((err && err.message) || "Checkout failed", true);
      });
      return;
    }
    // A chip only selects.
    var chip = near(".gs-chip");
    if (chip) {
      var grp = chip.parentNode, cs = grp.querySelectorAll(".gs-chip");
      for (var ci = 0; ci < cs.length; ci++) cs[ci].setAttribute("aria-pressed", cs[ci] === chip ? "true" : "false");
      grp.removeAttribute("data-need");
      var link = el.querySelector('a[data-item="' + chip.getAttribute("data-chip") + '"]');
      if (link) link.setAttribute("data-variant", chip.getAttribute("data-value"));
      renderRowState();
      return;
    }
    var edit = near("[data-clamp],[data-floor],[data-amend],[data-clear]");
    if (edit) {
      if (edit.getAttribute("data-clear") !== null) { clearCart(); status("Basket emptied."); return; }
      var cl = edit.getAttribute("data-clamp"), fl = edit.getAttribute("data-floor");
      var ln = cart[Number(cl !== null ? cl : (fl !== null ? fl : edit.getAttribute("data-amend")))];
      if (!ln) return;
      if (cl !== null) { ln.quantity = Math.max(1, capOf(itemById(ln.item))); cartChanged(); return; }
      if (fl !== null) { var itf = itemById(ln.item); if (itf) ln.amountCents = itf.priceCents; cartChanged(); return; }
      amending = Number(edit.getAttribute("data-amend"));
      renderCart();
      var open = el.querySelector("[data-amend-for]");
      if (open) open.focus();
      return;
    }
    var a = near("a[data-item]");
    var s = store;
    if (!a || !s || !s.checkout.enabled) return;
    e.preventDefault();
    var itemId = a.getAttribute("data-item");
    // WHICH ONE: the selector, else the pressed chip or your own data-variant.
    var sel = el.querySelector('[data-option-for="' + itemId + '"]');
    var variant = sel ? sel.value : (a.getAttribute("data-variant") || "");
    if (sel && !variant) { sel.focus(); sel.setAttribute("data-need", "1"); setTimeout(function () { sel.removeAttribute("data-need"); }, 1200); return; }
    var opts = el.querySelector('[data-opts-for="' + itemId + '"]');
    if (opts && !variant) {
      opts.setAttribute("data-need", "1");
      var first = opts.querySelector(".gs-chip");
      if (first) first.focus();
      setTimeout(function () { opts.removeAttribute("data-need"); }, 1200);
      return;
    }
    var qtyBox = el.querySelector('[data-qty-for="' + itemId + '"]');
    var quantity = qtyBox ? Math.max(1, Math.min(100, Math.round(parseFloat(qtyBox.value) || 1))) : 1;
    // The amount the buyer named, in cents; only sent when there is a box. It
    // is NOT raised to the minimum here — the basket says so instead.
    var box = el.querySelector('[data-amount-for="' + itemId + '"]');
    var amountCents = box && box.value ? Math.round(parseFloat(box.value) * 100) : null;
    // ADD: one unit, the page stays put, the row's Add becomes its stepper.
    if (a.getAttribute("data-add")) {
      var added = addToCart({ item: itemId, quantity: 1, variant: variant, amountCents: (amountCents && amountCents > 0) ? amountCents : undefined });
      var itn = itemById(itemId);
      status(added ? ((itn ? itn.name : itemId) + (variant ? " (" + variant + ")" : "") + " added.")
        : "That is as many different things as one order can hold.", !added);
      return;
    }
    a.textContent = "Opening\u2026";
    var headers = bearer(); headers["Content-Type"] = "application/json";
    fetch(s.checkout.url, { method: "POST", headers: headers,
      body: JSON.stringify({ group: GROUP, item: itemId, quantity: quantity, variant: variant, returnUrl: here(),
        amountCents: (amountCents && amountCents > 0) ? amountCents : undefined }) })
      .then(function (r) { return r.json(); })
      .then(function (d) { if (d.url) { location.href = d.url; } else { a.textContent = "Buy"; location.href = a.href; } })
      .catch(function () { location.href = a.href; });
  });
  // Bound to the widget's box, NEVER document, and only when the panel is open.
  el.addEventListener("keydown", function (e) {
    var t = e.target, k = e.key;
    if (k === "Enter" && t && t.getAttribute && (t.getAttribute("data-cart-qty") !== null || t.getAttribute("data-amend-for") !== null)) { e.preventDefault(); t.blur(); return; }
    if (k !== "Escape") return;
    var tog = el.querySelector("[data-cart-toggle]");
    if (!tog || tog.getAttribute("aria-expanded") !== "true") return;
    openPanel(false);
    tog.focus();
  });
  // The prefilled minimum is selected on touch, even where onfocus is blocked.
  el.addEventListener("focusin", function (e) {
    var t = e.target;
    if (t && t.getAttribute && t.getAttribute("data-amount-for") !== null && t.select) { try { t.select(); } catch (err) {} }
  });
  // The ONE typable field, and a named amount: raw text, clamped on blur/Enter.
  el.addEventListener("change", function (e) {
    var t = e.target;
    if (!t || !t.getAttribute) return;
    // A different choice is a different line, so the row goes back to Add.
    if (t.getAttribute("data-option-for") !== null) { renderRowState(); return; }
    var q = t.getAttribute("data-cart-qty");
    if (q !== null) {
      var lq = cart[Number(q)];
      if (!lq) return;
      // The commit rule, written to match the React cart case for case: the two
      // share ONE basket (the same storage key), so a rule that differs between
      // them is a real divergence, not a cosmetic one. Blank or non-numeric
      // leaves the stored quantity alone and puts it back in the box - Number,
      // not parseFloat, so "12abc" reads as a typo rather than as twelve. Below
      // one is not a line at all, which is what the minus at one already does.
      var raw = String(t.value).trim();
      var n = raw ? Math.round(Number(raw)) : NaN;
      if (!isFinite(n)) { t.value = lq.quantity; return; }
      if (n < 1) { cart.splice(Number(q), 1); cartChanged(); return; }
      lq.quantity = Math.min(capOf(itemById(lq.item)), n);
      cartChanged();
      return;
    }
    var m = t.getAttribute("data-amend-for");
    if (m !== null) {
      var lm = cart[Number(m)];
      if (!lm) return;
      var itm = itemById(lm.item);
      var c = Math.round(parseFloat(t.value) * 100);
      lm.amountCents = c > 0 ? c : (itm ? itm.priceCents : 1);
      amending = -1;
      cartChanged();
    }
  });
  load();
})();
</script>

The governance half — written by the platform

  • Left-margin version, group and proof come from Connect with GitHub (bot-committed) or Manage group. group binds the default-branch merge bot to one group's passed Code votes; it refuses others. Platform-signed proof prevents rebinding by text edits.
  • Connecting rewrites only generated text: header, these three lines, tools: links and group-filled payments: widget. Everything from the url: note down survives byte for byte. Three writers — both products, the github bot and VSD8A extension — produce identical bytes, preventing no-op commits on reconnection.
  • Delete the file to withdraw consent. Absence withdraws the binding; the bot never recreates it independently.

The run half — yours

  • url: https://… — optional live URL, HTTP(S) only; VSD8A shows a footer globe while present. Commented # url: is ignored, as in the shipped sample.
  • On rented servers, this line defines the site. Set your domain and point DNS at the box. Server has no domain or start-command field: the platform generates web configuration, host names and certificate requests from this line, forwarding to the port inrun:'s web entry or the first port-declaring entry. Commit a URL change to move the site; remove it to take the site down.
  • Above Extra Large, rentals require a term and vote. Ordinary web sizes are monthly and admin-approved. Graphics cards and dedicated boxes offer an hour, four hours, twelve hours, a day, week or month on their tile. A “Server” proposal fixes size, term and price but charges nothing. After passage, an admin has seven days to rent from Server. Group credits pay the whole block; it ends automatically unless extended beforehand. Only monthly terms renew.
  • run: starts an indented block: one terminal per entry, launched together only by VS Code's VSD8A ▶ button. Accepted forms: NAME[:PORT] [@ SUBFOLDER] = COMMAND lines or JSON { "name": "command" }, with entries also accepting { cmd, cwd, port }.
  • name labels the terminal tab and addresses the entry. cwd is relative to the file, defaulting to repository root. The port is freed before launch, even from servers started outside the editor, then monitored; green means it answers.
  • Keys starting // and lines starting # are ignored notes or disabled entries. Fresh examples are disabled; committing launches nothing until you remove //.
  • Rented servers also use this block. Boxes rented from us run run:; Server deliberately has no start-command field. Commit startup changes here to keep local and server commands aligned. Boxes run only commands declared here or in the project's package.json scripts.
  • The block ends at the first left-margin line. Indentation distinguishes entries from governance keys, allowing both halves in one file.

What the platform keeps current — server: and keys:

  • server: names the rented box's host; keys: lists setting names, never values, which live only on the box and are set in Server. Both blocks are generated. Since 2026-09-02 the platform commits changes automatically on renting, cancelling or losing a server and adding or removing setting names, without re-saving. Failed commits produce stale-file warnings in Manage group and Server; Connect with GitHub re-stamps the file.
  • keys: flows both ways. Code can add names in the same pull request as features needing them. After merge, Server shows each as declared by the code, no value yet for an admin to fill. File imports only add names; removal requires Server, preventing credentials from remaining active after their names silently disappear.
  • Reviewers enforce declarations. AI Code reviewers compare settings read by changes with keys: at the proposed commit and are instructed to reject undeclared reads: unset server values would fail at first use. Tests, examples and docs are exempt; adding the name to keys: in the change satisfies the check.

How it earns

The header's third sentence covers sales from any page, including this repository's site, into shared credits. items: defines one item per line: name, dollar-and-cent price and optional text after a dash. Every merge updates listings exactly; unreadable lines are reported in Admin and skipped, while removed items are paused, not deleted. Paste the widget on the url: site; see Sell on your site for rules.