SPDR home
StakingTokenomicsWhitepaperDocs

docs

read the silk.

SPDR staking on Solana, the short version.

contents

  1. what it is
  2. stake
  3. withdraw and claim
  4. fee
  5. on-chain
  6. api
  7. widget

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

  1. Open staking and connect your wallet.
  2. Enter an amount of SPDR.
  3. 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.

base
https://spdr.live/spdr-api/v1
main routes
routereturns
GET https://spdr.live/spdr-api/v1/pool/statspool totals, tiers, vault balances
GET https://spdr.live/spdr-api/v1/pool/tierslock tiers and multipliers
GET https://spdr.live/spdr-api/v1/pool/statuspause 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}/historypast staking actions
POST https://spdr.live/spdr-api/v1/tx/depositunsigned flexible stake
POST https://spdr.live/spdr-api/v1/tx/deposit-lockedunsigned locked stake
POST https://spdr.live/spdr-api/v1/tx/withdrawunsigned flexible withdraw
POST https://spdr.live/spdr-api/v1/tx/withdraw-lockedunsigned withdraw of a matured lock
POST https://spdr.live/spdr-api/v1/tx/claimunsigned 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.

openapi
https://spdr.live/spdr-api/v1/openapi.json
agent skill
https://spdr.live/spdr-api/v1/skill.md

widget

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.

  1. quick start
  2. embed
  3. attributes
  4. js api
  5. events
  6. theming
  7. rpc
  8. wallet
  9. csp
  10. versions
  11. live demo

quick start

embed
<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 the spdr:open detail; the widget itself always opens on stake.
<spdr-stake>
the custom element, defined as soon as the script loads. It reads data-* and the boolean open attribute once, when it connects: open shows it from the start, and later attribute changes do nothing (call open() and close()). From code, set its config and wallet properties.
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 null container (an id getElementById did not find) puts the widget on <body> and ignores config. 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, version and the React component SpdrStake. It does not read data-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>: open opens and closes it when it changes, so set it back to false in onClose or a later open={true} will not reopen it. onClose() runs on spdr:close with no argument, and onTx(detail) gets the spdr:tx detail { kind, sig, status }. wallet is a passthrough signer. Every other prop, className and style included, is config, applied again on each render.
element
<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>
element from code
// 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();
into a container
const container = document.getElementById("stake-slot");
const w = SpdrStake.mount(container, { rpcUrl: "YOUR_BROWSER_RPC_URL" });
w.el; // the <spdr-stake> inside container
es module
// 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" });
react
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 /rpc on your own origin. Without it the widget only reads. default none
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 unstake there is no stake / unstake switch (Withdraw under Your positions stays); without claim there is no claim row. Stake always shows. default stake,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 value false or 0, turns it off. default true
data-telemetrytelemetry
when on, the widget beacons {apiBase}/telemetry with the visitor's wallet address and user agent: tx_error with the error text when a transaction cannot be built, signed or sent (a cancelled request included), tx_clamp with both amounts when clamp lowers an amount, and refresh_error with the error text when the position refresh after a sent transaction fails. false, or the attribute value false or 0, keeps it off; any other value turns it on, a bare data-telemetry included. It is default off: nothing is sent unless you turn it on. default false
data-themetheme
the widget ships one dark skin; light and auto change nothing yet. Restyle it with the theme tokens. default dark
copyV2
the message when the chain rejects an unstake, withdraw or claim with an insufficient-funds error (the text insufficient funds or custom program error: 0x1) the widget does not read as a vault pause: true reads "That amount exceeded your live balance; try again.", false reads "The vault is short right now; try again shortly." Code only; there is no data-* attribute. default true
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, or tokens: { 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; opts becomes the spdr:open detail.
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 rpcUrl or apiBase while 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; null gives back its own picker (see wallet).
w.on(event, cb)
listens to spdr:<event>; cb gets 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.

mount
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 page

events

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 the data-spdr-auto script (with or without data-action) or the React open prop. The open attribute shows it at mount without firing it. Detail: the opts given to open(), { action } from a data-action trigger, 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; sig is set.
status "unconfirmed"
sent, but it did not confirm (timed out or failed on chain); sig is set.
status "error"
failed or cancelled; no sig.
listen
// 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.

theme
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;
}
theme as attributes
<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).

rpc (drop-in)
<script src="https://spdr.live/widget/v2/spdr.js" defer data-spdr-auto data-rpc="YOUR_BROWSER_RPC_URL"></script>
rpc (from code)
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.

passthrough
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 prop

signTransaction 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:

csp
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

url
https://spdr.live/widget/v2/spdr.js
sri (sha384)
sha384-f3gvJBPvtZBt/B5FzTGl5tls30J3ymH+zXKdZqy8TTPOtSZRsTqgpEZZXoxWoYxu
sha256
7a27970c11e36c22664e7d2a63c75f3e1cd696d4bbf78682be32c7a89210c545

v2 · current · ES module · 59355 bytes

url
https://spdr.live/widget/v2/spdr.esm.js
sri (sha384)
sha384-ZW3wremooxMWEV3kO6ApJrvT0DUdw/5I2XSi5brPeCv2/tOOnlDzaxreRhlX3WIc
sha256
a29281a9434bfe7967c1f4e6885b40fc610d5e94dc9727562b508e24ef7ccf9f

v1 · frozen · drop-in · 611977 bytes

url
https://spdr.live/widget/v1/spdr.js
sri (sha384)
sha384-Ut5+INH6/426Sdj/YVOFzgr0XUZ0ZjLtZohdOPhycusd+T73TRS6OWT6v6Y4GkJL
sha256
9bdaf8c5f2983a4e52247fd3984097405ca9909ef40ce3b4ff2ccf89467ff09b

v1 · frozen · ES module · 59373 bytes

url
https://spdr.live/widget/v1/spdr.esm.js
sri (sha384)
sha384-j0Vny9cb0E1r7leG8MwfDP1p2Zc8JYl0YDMPqAgmWW3R1v6ull7GnqS/nB0gsGpz
sha256
3ae5571a22311fdeaf0778cfbf1d30c2ee235ffbb1148cda1f12aa71a17b70cf
sri pin (v1)
<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.