what it is
Stake SPDR, earn SPDR. Single-sided and self-custodial: your wallet signs every action, with no custodian and no co-signer.
Staked principal and rewards sit in separate on-chain vaults. A claim pays only from the reward reserve, so it can never draw down a deposit. Pool state is public on-chain for anyone to verify.
stake
- Open staking and connect your wallet.
- Enter an amount of SPDR.
- Choose flexible or a lock tier, then sign.
Flexible stake counts at 1x pool weight and withdraws any time. A locked stake counts at its amount times the tier multiplier. The pool sets the tiers, their terms and multipliers; read them at GET /pool/tiers.
Your first stake opens your on-chain position, a one-time rent you pay.
withdraw and claim
- Flexible stake withdraws any time.
- Each lock matures on its own at the end of its term, then withdraws like flexible. A new lock never extends an old one.
- Claim sends earned SPDR to your wallet any time, paid up to the reward reserve on hand.
fee
A flat 0.002 SOL per stake and per claim. Withdrawals carry no protocol fee. The Solana network fee applies to every transaction. The live fee is served at GET /pool/status.
on-chain
Solana mainnet. These addresses are fixed.
- staking program view
CarbeVCzypPEbtPJsdCboEkqxYxqT75nmhNUNBtyNPiz- pool view
2GVrq8NGkZxDrtWXdiKs1Qsy3n1gUmUZsPy4E85QsBQ3- principal vault view
CarbVAKABGGN1uUJU5XnuGaHGza6JkyqovB1qbGg31Cp- reward vault view
CarbwrU6vrfuZvnLGG4WUtupHezvQnJbsCPRkB5YaXFo- SPDR mint (9 decimals) view
AT79ReYU9XtHUTF5vM6Q4oa9K8w7918Fp5SU7G1MDMQY
api
Public, no key.
https://spdr.live/spdr-api/v1| route | returns |
|---|---|
GET https://spdr.live/spdr-api/v1/pool/stats | pool totals, tiers, vault balances |
GET https://spdr.live/spdr-api/v1/pool/tiers | lock tiers and multipliers |
GET https://spdr.live/spdr-api/v1/pool/status | pause flags, live fee, reward reserve |
GET https://spdr.live/spdr-api/v1/position/{wallet} | principal, locks, pending and claimable rewards |
GET https://spdr.live/spdr-api/v1/position/{wallet}/history | past staking actions |
POST https://spdr.live/spdr-api/v1/tx/deposit | unsigned flexible stake |
POST https://spdr.live/spdr-api/v1/tx/deposit-locked | unsigned locked stake |
POST https://spdr.live/spdr-api/v1/tx/withdraw | unsigned flexible withdraw |
POST https://spdr.live/spdr-api/v1/tx/withdraw-locked | unsigned withdraw of a matured lock |
POST https://spdr.live/spdr-api/v1/tx/claim | unsigned reward claim |
A response is { success: true, data } or { error: { message, code } } with a matching HTTP status. Token amounts come as { raw, ui } with 9 decimals. Limits are per IP per minute; over the limit returns 429.
The POST routes return an unsigned transaction (base64) for your wallet and a recent blockhash you supply. Only your wallet signs it; the API holds no keys. amount is a decimal string; the locked withdraw takes amount_raw in base units.
https://spdr.live/spdr-api/v1/openapi.jsonhttps://spdr.live/spdr-api/v1/skill.mdwidget
One script tag adds stake, withdraw and claim to any site. It is self-custodial: visitors sign in their own wallet and keys never touch the widget. It renders inside a Shadow DOM, so your styles and its styles never collide, and it uses the same read API and on-chain program as this app. No build step, no framework, no React version to match.
Visitors see their staked, pending and claimable SPDR; a stake / unstake switch with Half and Max; term chips for flexible (1x) and each lock tier; a claim row with re-web, which claims straight into a lock; and a Your positions drawer to withdraw flexible stake and matured locks.
quick start
<script src="https://spdr.live/widget/v2/spdr.js" defer
data-spdr-auto
data-api-base="https://spdr.live/spdr-api/v1"
data-rpc="YOUR_BROWSER_RPC_URL"></script>
<button data-spdr-stake>stake SPDR</button>data-spdr-auto wires every [data-spdr-stake] element, including ones your app renders later. data-rpc is the one thing you provide: a Solana mainnet RPC URL that accepts browser (CORS) requests. Without an RPC the widget only reads: a connected visitor sees their staked, pending and claimable SPDR and Your positions, but their wallet's SPDR balance shows a dash, re-web is hidden, Claim and Withdraw are disabled, and the main button is disabled and reads Staking needs an RPC (or Insufficient balance on the unstake tab when the amount is above the flexible stake).
embed
The first five forms load the drop-in spdr.js. The ES module and React forms are in-house: they need @spdr/client and @czlabs/wallet-adapter from the SPDR monorepo. Any other site, a React site included, uses the drop-in script plus <spdr-stake>.
<script data-spdr-auto>- reads its own
data-*as the config and wires every[data-spdr-stake]element to one shared widget, including ones your app renders later. The quick start above. [data-spdr-stake]- any element: a click opens the shared widget. An optional
data-action="claim"arrives as{ action }in thespdr:opendetail; the widget itself always opens on stake. <spdr-stake>- the custom element, defined as soon as the script loads. It reads
data-*and the booleanopenattribute once, when it connects:openshows it from the start, and later attribute changes do nothing (callopen()andclose()). From code, set itsconfigandwalletproperties. SpdrStake.mount(config)- a new
<spdr-stake>per call, added to<body>, each with its own config. Returns a handle (js api). SpdrStake.mount(container, config)- the same, added inside your container, which must be an HTMLElement: a
nullcontainer (an idgetElementByIddid not find) puts the widget on<body>and ignoresconfig. The card still opens as a full-page overlay. import { mount } from spdr.esm.js- in-house, as the SPDR monorepo package
@spdr/widget. The ES module:mount,defineElement,TAG,versionand the React componentSpdrStake. It does not readdata-spdr-auto, and importing it does not define<spdr-stake>:mount(),defineElement()or a rendered<SpdrStake>does. Your bundler resolves its bare imports:react,react-dom/client,react/jsx-runtime,@solana/web3.js,buffer,@spdr/client,@czlabs/wallet-adapter. <SpdrStake> (React)- in-house, from the ES module. It renders
<spdr-stake>:openopens and closes it when it changes, so set it back to false inonCloseor a lateropen={true}will not reopen it.onClose()runs onspdr:closewith no argument, andonTx(detail)gets thespdr:txdetail{ kind, sig, status }.walletis a passthrough signer. Every other prop,classNameandstyleincluded, is config, applied again on each render.
<script src="https://spdr.live/widget/v2/spdr.js" defer></script>
<spdr-stake open data-rpc="YOUR_BROWSER_RPC_URL" data-brand="#22c55e"></spdr-stake>// after the script has loaded
const el = document.createElement(SpdrStake.TAG); // "spdr-stake"
el.config = { rpcUrl: "YOUR_BROWSER_RPC_URL", tokens: { radius: "4px" } };
el.wallet = mySigner; // optional, see wallet
document.body.append(el);
el.open();const container = document.getElementById("stake-slot");
const w = SpdrStake.mount(container, { rpcUrl: "YOUR_BROWSER_RPC_URL" });
w.el; // the <spdr-stake> inside container// in-house: @spdr/widget is the SPDR monorepo package built as /widget/v2/spdr.esm.js;
// your bundler resolves it and its bare imports from the monorepo:
// react, react-dom/client, react/jsx-runtime, @solana/web3.js,
// buffer, @spdr/client, @czlabs/wallet-adapter
import { mount, SpdrStake, defineElement, TAG, version } from "@spdr/widget";
const w = mount({ rpcUrl: "YOUR_BROWSER_RPC_URL" });import { useState } from "react";
import { SpdrStake } from "@spdr/widget";
export function Stake({ signer }) {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>stake SPDR</button>
<SpdrStake open={open} onClose={() => setOpen(false)}
onTx={({ kind, sig, status }) => console.log(kind, sig, status)}
wallet={signer} rpcUrl="YOUR_BROWSER_RPC_URL" tokens={{ brand: "#22c55e" }} />
</>
);
}attributes
Set an option as a data-* attribute on the auto script or on <spdr-stake>, or by its option name in mount(config), setConfig() or el.config. Trigger buttons read only data-action.
data-rpcrpcUrl- the Solana mainnet RPC for stake, withdraw and claim (see rpc). Absolute, or relative to your page such as
/rpcon your own origin. Without it the widget only reads. defaultnone data-api-baseapiBase- the SPDR read API. It must serve the official pool (see the pin check under rpc). default
https://spdr.live/spdr-api/v1 data-actionsactions- a comma list or an array. Without
unstakethere is no stake / unstake switch (Withdraw under Your positions stays); withoutclaimthere is no claim row. Stake always shows. defaultstake,unstake,claim data-clampclamp- before an unstake or withdraw, re-reads the account on your RPC and lowers an over-request to the live amount.
false, or the attribute valuefalseor0, turns it off. defaulttrue data-telemetrytelemetry- when on, the widget beacons
{apiBase}/telemetrywith the visitor's wallet address and user agent:tx_errorwith the error text when a transaction cannot be built, signed or sent (a cancelled request included),tx_clampwith both amounts when clamp lowers an amount, andrefresh_errorwith the error text when the position refresh after a sent transaction fails.false, or the attribute valuefalseor0, keeps it off; any other value turns it on, a baredata-telemetryincluded. It is default off: nothing is sent unless you turn it on. defaultfalse data-themetheme- the widget ships one dark skin;
lightandautochange nothing yet. Restyle it with the theme tokens. defaultdark copyV2- the message when the chain rejects an unstake, withdraw or claim with an insufficient-funds error (the text
insufficient fundsorcustom program error: 0x1) the widget does not read as a vault pause:truereads "That amount exceeded your live balance; try again.",falsereads "The vault is short right now; try again shortly." Code only; there is nodata-*attribute. defaulttrue chain- the Wallet Standard chain id the widget's own picker sends with each signing request (unused when you hand over your wallet). The widget only runs against the mainnet pool, so leave it. default
solana:mainnet data-brand, data-radius-sm, …tokens- one
data-<token>per theme token, ortokens: { brand, "radius-sm" }(see theming). default{}
js api
The drop-in sets two globals: window.SpdrStake, with mount, defineElement, TAG and version, and window.Buffer, only when the page has none (a Buffer your page already defines is kept). The ES module sets the same Buffer fallback.
SpdrStake.mount(config)- a new widget, closed. Returns the handle
w. w.el- its
<spdr-stake>element. w.open(opts)- opens it;
optsbecomes thespdr:opendetail. w.close()- closes it, as its X and its backdrop do.
w.setTheme(tokens)- merges theme tokens, live.
w.setConfig(patch)- merges any option. Change
rpcUrlorapiBasewhile it is closed: an open widget keeps the RPC and the API pin check it opened with. w.setWallet(signer)- hands it your connected wallet;
nullgives back its own picker (see wallet). w.on(event, cb)- listens to
spdr:<event>;cbgets the detail. Returns an unsubscribe function. w.destroy()- removes the element and unmounts it.
SpdrStake.defineElement()- registers
<spdr-stake>if needed and returns its tag; safe to call again. SpdrStake.TAG"spdr-stake"SpdrStake.version"0.1.0"in v1 and v2.
The element carries the same open(opts), close(), setConfig(patch) and setWallet(signer), plus write-only config and wallet properties. Call the methods once the element is in the page. Before that, set config and wallet: they apply when it connects.
const w = SpdrStake.mount({ rpcUrl: "YOUR_BROWSER_RPC_URL" });
myButton.addEventListener("click", () => w.open());
const unsubscribe = w.on("tx", ({ kind, sig, status }) => {
// kind: "deposit" | "principal" | "reward"
// status: "confirmed" | "unconfirmed" | "error"
});
w.setTheme({ brand: "#22c55e" }); // restyle live, any time
w.setConfig({ actions: "stake,claim" }); // any option, merged
unsubscribe(); // stop listening
w.destroy(); // take it off the pageevents
DOM CustomEvents dispatched on <spdr-stake> itself. They bubble to document, and they are composed, so they also leave a shadow root your page puts <spdr-stake> in.
spdr:open- on each
open()call:w.open(),el.open(), a[data-spdr-stake]click on a page with thedata-spdr-autoscript (with or withoutdata-action) or the Reactopenprop. Theopenattribute shows it at mount without firing it. Detail: theoptsgiven toopen(),{ action }from adata-actiontrigger, or{}. spdr:close- on close: its X, its backdrop or
close(). No detail. spdr:tx- after each transaction the visitor submits. Detail:
{ kind, sig, status }. kind "deposit"- a stake, or a re-web (claim straight into a lock).
kind "principal"- an unstake, or a withdraw under Your positions.
kind "reward"- a claim.
status "confirmed"- confirmed on chain;
sigis set. status "unconfirmed"- sent, but it did not confirm (timed out or failed on chain);
sigis set. status "error"- failed or cancelled; no
sig.
// CustomEvents that bubble and are composed: listen on the element or on document
document.addEventListener("spdr:tx", (e) => {
const { kind, sig, status } = e.detail;
if (status === "confirmed") refreshMyPage(kind, sig);
});theming
Colors, corners, the font, the card width and the z-index are --spdr-* CSS variables, apart from some fixed colors and corners (the main ones are listed below). Set one with a data-* attribute such as data-brand or data-radius-sm (read once, when the element connects), with mount({ tokens }), setTheme() or setConfig({ tokens }) (live), or with plain CSS on spdr-stake. Order, lowest first: the defaults below, then tokens you pass, then your own CSS on spdr-stake. Your site's CSS wins.
--spdr-brand- the primary button, the active term chip's border, multipliers and the lock pills, Pending, and the hover border of the wallet pill, small buttons and wallet list rows. default
#f4c43c --spdr-accent- Half and Max. default
#ba39f7 --spdr-success- the connected dot, Claimable, the FLEX pill. default
#7ee497 --spdr-danger- error messages. default
#e0544d --spdr-info- the re-web button. default
#00c0ff --spdr-bg- declared, no effect yet. default
#0e0e0e --spdr-modal- the card background. default
#121212 --spdr-surface- the small buttons (Claim, Withdraw), the wallet pill, the selected stake / unstake tab and the re-web popover. default
#181818 --spdr-surface-2- stat tiles, rows and the amount field. default
#131313 --spdr-line- borders. default
#2b2b2b --spdr-text- text. default
#f6f6f7 --spdr-muted- labels. default
#c2c2ca --spdr-faint- notes and the footer. default
#9a9aa2 --spdr-radius- card corner rounding. default
12px --spdr-radius-sm- stat tiles, rows, the amount field, the stake / unstake switch, the main button, the re-web popover and the wallet list (the selected stake / unstake tab keeps a fixed 6px corner; small buttons, term chips, Half / Max, the pills and the wallet pill keep fixed corners). default
8px --spdr-font- all text. The widget loads no font file: load Space Grotesk on your page or name your own. default
"Space Grotesk", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif --spdr-width- the card's max width. default
440px --spdr-z- the overlay's z-index. default
2147483000
Fixed, not tokens: the overlay dim behind the card, the shadows, the primary button's dark text (#17130a, so keep --spdr-brand light enough to read it), the amount placeholder, and the tints behind Half and Max, the active term chip and the pills, which stay SPDR yellow, purple and green.
spdr-stake {
--spdr-brand: #22c55e; /* primary button, Pending, multipliers */
--spdr-accent: #38bdf8; /* Half and Max */
--spdr-modal: #0b1220; /* card background */
--spdr-radius: 18px; /* corner rounding */
--spdr-font: "Inter", system-ui;
}<spdr-stake data-brand="#22c55e" data-surface-2="#0c110f" data-radius-sm="4px"></spdr-stake>rpc
Reads (pool, tiers, positions, pending and claimable rewards) work with zero configuration: they come from the SPDR read API. Stake, withdraw and claim need data-rpc or rpcUrl: a Solana mainnet RPC that answers CORS requests from your site's origin. Any provider that permits browser-origin requests works. Without an RPC the widget only reads: a connected visitor sees their staked, pending and claimable SPDR and Your positions, but their wallet's SPDR balance shows a dash, re-web is hidden, Claim and Withdraw are disabled, and the main button is disabled and reads Staking needs an RPC (or Insufficient balance on the unstake tab when the amount is above the flexible stake).
<script src="https://spdr.live/widget/v2/spdr.js" defer data-spdr-auto data-rpc="YOUR_BROWSER_RPC_URL"></script>SpdrStake.mount({ rpcUrl: "YOUR_BROWSER_RPC_URL" });https://spdr.live/rpc is this site's own relay: it sends no CORS headers and takes transactions from this site only, so your site needs its own RPC.
RPC methods the widget calls: getLatestBlockhash, sendTransaction, getSignatureStatuses, getBlockHeight, getAccountInfo, getTokenAccountBalance. It confirms by polling over HTTP, with no websocket, at commitment confirmed.
Each transaction sets a 200,000 compute-unit limit and a compute-unit price of 10,000 microlamports: a priority fee of 2,000 lamports (0.000002 SOL) on top of the base fee. The visitor's wallet signs and pays the fee; the widget sends the transaction.
Read API routes the widget calls on apiBase (the API answers CORS from any origin):
GET /config- once per open: the program, pool and mint for the pin check
GET /pool/stats- on open, then every 30 s while open
GET /pool/tiers- on open, then every 30 s while open
GET /position/{wallet}- with a wallet connected: when the widget opens or the wallet connects, every 30 s while open, and 0.6 s and 2 s after each transaction the widget sends (those two still run if the widget closes in between)
POST /position/refresh- after each sent transaction
POST /telemetry- only with telemetry on
Pin check: on open the widget reads /config and refuses to run unless the staking program, pool and mint match the addresses built into it (see on-chain). It then shows SPDR: this embed points at an API that does not serve the official SPDR pool. A mirror API works only if it serves the official pool.
wallet
By default the widget finds the visitor's wallets through Wallet Standard and shows its own picker inside its Shadow DOM. It remembers the last wallet in your origin's localStorage under @czlabs/wallet-adapter:wallet and clears nothing else.
If your site already has a connected wallet, hand it over instead. The widget then runs no wallet picker or wallet connection of its own: it shows your wallet as connected, and its address button calls your disconnect. The widget does not watch your wallet: when it disconnects or switches account, call setWallet(null) or setWallet with the new signer, or the widget keeps showing the old address. Without signTransaction and an address or publicKey it falls back to its own picker.
w.setWallet({
address: "VISITOR_ADDRESS", // or publicKey
signTransaction: (tx) => wallet.signTransaction(tx),
disconnect: () => wallet.disconnect(), // optional
});
w.setWallet(null); // back to its own picker
// the same: el.wallet = signer, or the React wallet propsignTransaction receives a @solana/web3.js (v1) legacy Transaction and must resolve to the signed Transaction: the widget calls its serialize() and sends it.
Keys never enter the widget: it builds the transaction, the wallet signs it, the widget sends it.
csp
Behind a content security policy (CSP), allow https://spdr.live and your RPC origin, plus data: images for the logo and wallet icons:
script-src https://spdr.live;
connect-src https://spdr.live YOUR_RPC_ORIGIN;
img-src data:;style-src: the widget styles its Shadow DOM with constructable stylesheets, which style-src does not govern. A browser without them gets <style> elements instead, which need style-src 'unsafe-inline'. No font-src is needed: the widget loads no font. The in-house ES module needs no extra origin: your bundler puts it and its bare imports (react, react-dom/client, react/jsx-runtime, @solana/web3.js, buffer, @spdr/client, @czlabs/wallet-adapter) into your own scripts.
The files are served with Access-Control-Allow-Origin: *, which an SRI pin (crossorigin) needs.
versions
v2 is current. The drop-in is one self-contained file (it bundles React 19 and @solana/web3.js); the in-house ES module leaves its bare imports to your bundler: react, react-dom/client, react/jsx-runtime, @solana/web3.js, buffer, @spdr/client, @czlabs/wallet-adapter. A fix ships as new v2 bytes, so its hashes change with it: pin v2 only if you update the hash when it changes.
v1 is frozen: its bytes never change, so an SRI pin on it stays valid. It is the same widget (version 0.1.0 in both) with an older built-in API base, so set data-api-base when you use it.
v2 · current · drop-in · 611959 bytes
https://spdr.live/widget/v2/spdr.jssha384-f3gvJBPvtZBt/B5FzTGl5tls30J3ymH+zXKdZqy8TTPOtSZRsTqgpEZZXoxWoYxu7a27970c11e36c22664e7d2a63c75f3e1cd696d4bbf78682be32c7a89210c545v2 · current · ES module · 59355 bytes
https://spdr.live/widget/v2/spdr.esm.jssha384-ZW3wremooxMWEV3kO6ApJrvT0DUdw/5I2XSi5brPeCv2/tOOnlDzaxreRhlX3WIca29281a9434bfe7967c1f4e6885b40fc610d5e94dc9727562b508e24ef7ccf9fv1 · frozen · drop-in · 611977 bytes
https://spdr.live/widget/v1/spdr.jssha384-Ut5+INH6/426Sdj/YVOFzgr0XUZ0ZjLtZohdOPhycusd+T73TRS6OWT6v6Y4GkJL9bdaf8c5f2983a4e52247fd3984097405ca9909ef40ce3b4ff2ccf89467ff09bv1 · frozen · ES module · 59373 bytes
https://spdr.live/widget/v1/spdr.esm.jssha384-j0Vny9cb0E1r7leG8MwfDP1p2Zc8JYl0YDMPqAgmWW3R1v6ull7GnqS/nB0gsGpz3ae5571a22311fdeaf0778cfbf1d30c2ee235ffbb1148cda1f12aa71a17b70cf<script src="https://spdr.live/widget/v1/spdr.js" defer
integrity="sha384-Ut5+INH6/426Sdj/YVOFzgr0XUZ0ZjLtZohdOPhycusd+T73TRS6OWT6v6Y4GkJL"
crossorigin="anonymous"
data-spdr-auto
data-api-base="https://spdr.live/spdr-api/v1"
data-rpc="YOUR_BROWSER_RPC_URL"></script>live demo
Open the real v2 widget, running against this site's API and RPC relay, and restyle it live with setTheme(). It loads on the first click (about 600 KB), not with this page. Opening it needs no wallet.