Soft staking for the XRP Ledger — powered by Discord. Snapshot-based NFT, token, and MPT staking with flexible reward pools, multi-wallet support, and a zero-friction self-hosted setup.
EasyStake is a Discord bot that implements soft staking on the XRP Ledger. Rather than locking assets in a smart contract, it takes periodic on-chain snapshots to determine what users hold, then distributes rewards accordingly. No assets ever leave the user's wallet.
Rewards accumulate against a user's Discord ID and can be claimed at any time, triggering a real on-ledger payment from the project's distributor wallet.
For the full architectural overview, see EASYSTAKE_V2.md.
For the line-by-line build spec, see CURSOR_INSTRUCTIONS.md.
- Features
- Build status
- Requirements
- Quick start
- Configuration
- Signing providers
- Running in production
- Slash command reference
- Project structure
- Database
- Migrating from v1
- Troubleshooting
- Security
- License
- Three stake types — NFT collections (by taxon + issuer, or issuer-only / blank taxon for any NFT from that issuer), fungible tokens (currency + issuer), and MPTs (Multi-Purpose Tokens).
- Flexible rewards — each pool independently emits a token, an MPT, or XRP. Mix and match freely across pools.
- Multiple pools per server — every pool has its own schedule, supply cap, and reward rate.
- Per-pool cron schedules — hourly, every 6h, every 12h, daily at midnight UTC, or daily at a custom hour.
- Unlimited wallets per user — users can link as many wallets as they like, labelled, with one designated to receive claim payments.
- Wallet verification via two signing providers — XRPL Request (recommended, wallet-agnostic) or Xaman.
- Dry-run snapshots — preview a snapshot without crediting any rewards.
- Configurable log channel — all snapshot, wallet, and claim events can be mirrored to a Discord channel.
- One-time v1 migration script — imports users, wallets, and balances from a MongoDB-backed v1 deployment.
- Zero external services — SQLite for storage, XRPL RPC for on-chain data. Self-hosted, no managed database, no third-party persistence.
EasyStake v2 is being built phase by phase. This section is updated with every merge so you can see at a glance what is and isn't deployable.
| Phase | Scope | Status |
|---|---|---|
| 1 | Project skeleton — package.json, .env.example, .gitignore |
✅ |
| 2 | Config loader and signing-provider selection (config.js) |
✅ |
| 3 | Database layer — migrations and database/db.js |
✅ |
| 4 | Utilities — utils/format.js, utils/embeds.js, utils/logger.js |
✅ |
| 5 | Signing providers — services/signing/{index,xrplrequest,xaman}.js |
✅ |
| 6 | XRPL service — services/xrpl.js |
✅ |
| 7 | Snapshot engine — services/snapshot.js |
✅ |
| 8 | Distributor — services/distributor.js |
✅ |
| 9 | Slash commands — commands/{config,wallet,pool,rewards,supply,snapshot}.js |
✅ |
| 10 | Entry point — stakebot.js, deploy-commands.js |
✅ |
| 11 | v1 migration script — migrate-from-v1.js |
✅ |
| 12 | Hardening — deprecation cleanup, address validation, unit tests | ✅ |
| 13 | Docker — Dockerfile, .dockerignore, docker-compose.yml, DB_PATH env var |
✅ |
| 14 | Pool list pagination + concurrent snapshot guard | ✅ |
| 15 | /export admin data export (JSON file attachment, guild-scoped) |
✅ |
| 16 | Long token currency codes — auto-encode to 40-hex on save, decode for display | ✅ |
Today: the bot is feature-complete end-to-end. All planned phases plus a hardening pass and a Docker delivery option have landed and were smoke-tested as they were built. The deployment instructions below describe the live, ready-to-deploy product. The next iteration will focus on real-environment validation (a staging Discord server pointed at the XRPL testnet) and any polish that surfaces from it.
- Node.js 18 or later (tested on 18, 20, and 22).
- A Discord bot application with a bot token. Create one at https://discord.com/developers/applications.
- An XRPL wallet for distributing rewards. Fund it with the reward token (and
enough XRP to cover transaction fees and any trust line reserves). The seed
for this wallet goes in
.envand must not be your main treasury wallet. - A signing-provider account — either:
- An API key from XRPL Request (recommended), or
- A Xaman developer API key + secret from https://apps.xaman.dev.
git clone https://github.com/LedgerCraft/EasyStake-v2.git
cd EasyStake-v2
npm install
cp .env.example .env
# Open .env in your editor and fill in the required values
# (see "Configuration" below).
node deploy-commands.js # one-time: register slash commands with Discord
node stakebot.js # start the botThat's it. The SQLite database file (stake.db) is created automatically on
first run — no database setup required.
If everything is configured correctly you should see:
[db] Applied migration: 001_initial.sql
[db] Applied migration: 002_guild_settings.sql
EasyStake v2 is running as YourBotName#1234
Invite the bot to your server with the OAuth2 URL generator in the Discord
Developer Portal, scopes bot + applications.commands, and the permissions
your deployment needs (typically Send Messages and Embed Links at minimum).
All configuration lives in .env. Copy .env.example to .env and fill in
the values below.
| Variable | Description |
|---|---|
TOKEN |
Discord bot token from the Developer Portal. |
CLIENT_ID |
Discord application client ID (used to register slash commands). |
NETWORK |
XRPL WebSocket endpoint, e.g. wss://s1.ripple.com/ for mainnet or wss://s.altnet.rippletest.net:51233/ for testnet. |
SEED |
Family seed of the distributor wallet. Keep only the reward token and a small XRP balance here. Never use your main project wallet seed. The bot derives keys using SEED_ALGORITHM / DISTRIBUTOR_ADDRESS when set, otherwise sEd… seeds use Ed25519 and other family seeds use secp256k1 (see Optional). |
Option A — XRPL Request (recommended). Supports Xaman, Crossmark, GEM Wallet, WalletConnect, Ledger, Xyra, and Otsu out of the box.
| Variable | Description |
|---|---|
XRPL_REQUEST_API_KEY |
xrplr_live_... key from https://xrplre.quest/dashboard. |
Option B — Xaman (fallback). Used automatically when XRPL_REQUEST_API_KEY
is not set. Both keys are required.
| Variable | Description |
|---|---|
XUMM_KEY |
Xaman API key. |
XUMM_SECRET |
Xaman API secret. |
If neither option is configured, the bot exits at startup with a clear error message listing the missing variables. You will never get a half-configured bot running silently.
| Variable | Default | Description |
|---|---|---|
DB_PATH |
./stake.db |
Override where the SQLite database file lives. Useful for Docker volumes, NFS mounts, or running multiple instances side by side. The parent directory is created automatically. |
SEED_ALGORITHM |
(auto) | ed25519 or secp256k1 — forces how SEED is interpreted for claim payouts. Use when the auto heuristic does not match your funded distributor account. |
DISTRIBUTOR_ADDRESS |
(unset) | Classic r… address of the distributor account for this SEED. When set, the bot picks the algorithm whose derived address matches (overrides the prefix heuristic). |
The active provider is chosen once at startup and used for all wallet verification flows. Users see a "Verify Wallet" button in their ephemeral reply and complete signing in their preferred wallet.
User runs /wallet add
→ Bot creates a `connect` payload via the XRPL Request API
→ Bot replies ephemerally with an embed + "Verify Wallet" button
→ User opens the link, picks any supported wallet, approves
→ Bot polls for the result (up to 5 minutes)
→ On success: signer address is stored, reply updated
Same flow, but the signing URL deep-links into the Xaman app and the user must have Xaman installed.
Both providers conform to an identical internal interface, so the rest of the bot is unaware of which is active:
createConnectPayload(opts) // → { uuid, signingUrl, expiresAt }
pollPayload(uuid, timeout) // → { status, signerAddress }
cancelPayload(uuid)Run the bot under a process manager so it restarts on crashes and survives reboots.
npm install -g pm2
pm2 start stakebot.js --name easystake
pm2 save
pm2 startupUseful PM2 commands:
pm2 logs easystake # tail the bot's stdout/stderr
pm2 restart easystake # rolling restart after an update
pm2 stop easystake # stop the bot
pm2 delete easystake # remove from PM2's registryCreate /etc/systemd/system/easystake.service:
[Unit]
Description=EasyStake v2 Discord bot
After=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/easystake-v2
ExecStart=/usr/bin/node stakebot.js
Restart=on-failure
RestartSec=5
User=easystake
EnvironmentFile=/opt/easystake-v2/.env
[Install]
WantedBy=multi-user.targetThen enable:
sudo systemctl daemon-reload
sudo systemctl enable --now easystake
sudo journalctl -u easystake -fA production-ready Dockerfile, .dockerignore, and docker-compose.yml
ship in the repository root.
Image highlights:
- Multi-stage build on
node:20-bookworm-slim. The builder stage installspython3/make/g++only whenbetter-sqlite3's prebuilt binary isn't available; the runtime stage is slim and contains no compilers. - Runs as a non-root user (
easystake, uid 1001) with anologinshell. tiniis the PID 1 entrypoint sodocker stopdeliversSIGTERMcleanly and the bot's graceful shutdown path runs.DB_PATHdefaults to/app/data/stake.db; the/app/datadirectory is declared as a volume so the database survives container replacement even without an explicit-vflag..dockerignoreexcludes.env, the local SQLite files,.git, andnode_modulesso secrets and local state never leak into the image.
cp .env.example .env
# Edit .env with your values, then:
docker compose up -d --build
docker compose logs -f easystakeThe included docker-compose.yml reads .env, mounts a named volume
(easystake-data) at /app/data for the database, and configures a
restart: unless-stopped policy plus a lightweight health check.
To stop the bot:
docker compose down # keeps the volume (DB preserved)
docker compose down -v # also removes the volume (deletes DB)To update after a git pull:
docker compose up -d --builddocker build -t easystake-v2:latest .
docker run -d \
--name easystake \
--restart unless-stopped \
--env-file .env \
-v easystake-data:/app/data \
easystake-v2:latestThe deploy-commands.js script needs to run once per Discord application
(and again whenever command definitions change). With Compose:
docker compose run --rm easystake node deploy-commands.jsOr with plain docker:
docker run --rm --env-file .env easystake-v2:latest node deploy-commands.jsdocker compose run --rm \
-v "$(pwd)/legacy:/legacy" \
easystake \
node migrate-from-v1.js \
--mongouri="mongodb+srv://..." \
--sqlite=/app/data/stake.dbRun this once before starting the bot for the first time. The migration writes into the same volume the bot will read from.
EasyStake ships with a unit-test suite for its pure-logic modules (format helpers, snapshot supply-cap and identifier parsing, slash command input validators). It uses Node's built-in test runner — no extra dependency.
npm testThe test script provides dummy env vars so config.js accepts the load. All
tests run in well under a second and are safe to wire into CI.
cd /path/to/easystake-v2
git pull
npm install
node deploy-commands.js # only if slash command definitions changed
pm2 restart easystake # or: sudo systemctl restart easystakeDatabase migrations apply automatically on every startup — there is no manual migration step.
| Command | Description |
|---|---|
/wallet add [label] |
Link a new XRPL wallet via signing verification. |
/wallet remove |
Unlink one of your registered wallets (select + confirm). |
/wallet list |
View all your linked wallets. |
/wallet primary |
Pick which wallet receives claim payments. |
/rewards view |
View your unclaimed reward balances across all pools. |
/rewards claim pool <pool> |
Claim rewards for a specific pool. |
/rewards claim all |
Claim all available rewards in one go. |
Requires the Administrator Discord permission.
| Command | Description |
|---|---|
/pool create <name> <stake_type> <stake_identifier> <reward_type> <reward_per_unit> <schedule> [reward_identifier] [schedule_hour] [min_balance] [supply] |
Create a new staking pool. Posts a preview embed and waits for the admin to press Create pool. |
/pool edit <pool> [name] [reward_per_unit] [min_balance] [schedule] [schedule_hour] [active] [supply] |
Apply partial updates to an existing pool. Re-registers its cron job. |
/pool list |
View all pools in this server with status and supply stats. Paginated with ◀ Prev / Next ▶ / Close buttons when there are more than five pools. |
/pool delete <pool> |
Delete a pool (danger-button confirmation, stops the cron job). |
/blacklist add <address> [reason] |
Blacklist a classic XRPL address (r...). Holdings at that address no longer accrue new rewards from snapshots in this server (see note below). |
/blacklist remove <address> |
Remove an address from the blacklist. |
/blacklist view |
List blacklisted addresses (with optional reasons). |
/supply view [pool] |
View remaining reward supply per pool (paginated when listing all pools). |
/supply add <pool> <amount> |
Top up a pool's reward supply. |
/snapshot run [pool] |
Manually trigger a snapshot (all active pools in this server, or one). A pool already running a snapshot (e.g. from a concurrent cron tick) is skipped with a clear status — no double-credits. |
/snapshot dryrun [pool] |
Preview a snapshot without crediting rewards. Same concurrency guard applies. |
/snapshot history [pool] [limit] |
View the most recent snapshot results (default 10, max 25). |
/config logs set <channel> |
Set the bot's logs channel. |
/config logs clear |
Remove the bot's logs channel. |
/config logs view |
View the current logs channel setting. |
/export <type> [snapshots_limit] |
Download an admin data export as a JSON file (see Data exports below). |
Wallet blacklist: Entries apply only within the current Discord server. Blacklisted on-chain addresses are skipped when matching XRPL holders to linked wallets during pool snapshots, so those addresses contribute no new accrual for that tick. The bot does not unlink wallets, claw back user_rewards, or block /rewards claim solely because of the blacklist. After pulling bot updates that add or change slash commands, run node deploy-commands.js once so Discord registers /blacklist (same as other command changes).
When creating a pool, stake_identifier and reward_identifier use the
following formats (the bot validates them before saving):
| Type | Format | Example |
|---|---|---|
| NFT collection (stake) | <taxon>|<issuer> — leave taxon empty for any NFT from that issuer (|r…), or paste the issuer address alone |
5|rIssuer…, |rIssuer…, rIssuer… |
| Fungible token (stake or reward) | <currency>|<issuer> — see currency code rules below |
MYTOKEN|rIssuer123… |
| MPT (stake or reward) | <mpt_issuance_id> (no separator) |
0000012ABCD… |
| XRP (reward only) | leave reward_identifier blank |
— |
You can type whichever form is easiest:
- A standard 3-character code like
USD,BTC, or123— stored as-is. - A longer ASCII name up to 20 characters like
MYTOKENorPuppyToken— EasyStake automatically encodes it as the 40-character uppercase hex form XRPL stores on the ledger. Everywhere the code is displayed (pool list, rewards view, snapshot embeds, exports) it's decoded back to the readable name — admins and users never see the hex unless they ask for the raw JSON. - A pre-encoded 40-character hex code (e.g. pasted from an explorer) — uppercased and stored as-is.
XRP (any case) is not allowed as an issued currency.
JSON exports include a stake_identifier_display / reward_identifier_display
field next to the raw on-ledger identifier when the human-readable form
differs (i.e. for auto-encoded names). The raw value is preserved so
downstream tooling can still call XRPL RPCs with it.
For schedule, choose one of Every hour / Every 6 hours / Every 12 hours /
Daily at midnight UTC / Daily at custom hour. If you pick the custom-hour
option, also pass schedule_hour (0–23, UTC).
/export lets a server administrator download a JSON snapshot of any subset of
the bot's data. The file is attached to the admin's ephemeral reply — nobody
else in the channel sees it. The data is always scoped to the calling
guild: an admin cannot use this command to harvest data about pools,
balances, or wallet addresses belonging to a different deployment.
type |
Contents |
|---|---|
users |
Every Discord user who participates in this guild, with their linked wallet addresses and current unclaimed rewards for this guild's pools. |
wallets |
Flat list of wallet rows belonging to participating users. |
pools |
Pool configuration and supply tracking for this guild. |
rewards |
Current unclaimed reward balances (per user per pool) for this guild's pools. |
claims |
Full claim history (with on-ledger tx hashes) for this guild's pools. |
snapshots |
Recent snapshot audit log for this guild's pools (snapshots_limit controls the cap, default 1000, max 10000). |
all |
Everything above in one bundle. |
Users are considered to "participate in this guild" only if they have at least
one reward row or one claim record tied to a pool in the guild. A user who
happens to share the Discord server but has only ever interacted with a
different EasyStake deployment will not appear in users or wallets exports.
Every export is a JSON document with the same envelope:
For type: all, data is an object keyed by section
(users, pools, rewards, claims, snapshots). SQLite 0/1 boolean
columns are coerced to actual true / false in the JSON output so
downstream tooling doesn't have to guess.
Discord allows attachments up to 25 MB on unboosted servers. If the
generated export exceeds that, the command surfaces a clear error rather than
silently failing. In that case, pick a narrower type (e.g. pools instead
of all) or pass a smaller snapshots_limit.
A best-effort log entry is sent to the configured logs channel noting which admin generated the export, what type, and the record count — the export file itself is never attached to the log channel message.
/
├── stakebot.js # Entry point: Discord client, cron, command loader
├── deploy-commands.js # Slash command registration (run once)
├── config.js # Env validation and config export
├── migrate-from-v1.js # One-time MongoDB → SQLite migrator
│
├── database/
│ ├── db.js # SQLite init, auto-migration runner
│ └── migrations/
│ ├── 001_initial.sql
│ └── 002_guild_settings.sql
│
├── commands/
│ ├── wallet.js
│ ├── pool.js
│ ├── rewards.js
│ ├── supply.js
│ ├── snapshot.js
│ ├── config.js
│ └── export.js # /export — admin data export
│
├── services/
│ ├── signing/
│ │ ├── index.js # Provider factory (env-selected)
│ │ ├── xrplrequest.js
│ │ └── xaman.js
│ ├── xrpl.js # XRPL client + holder queries + payments
│ ├── snapshot.js # Snapshot engine (pool- and dry-run-aware)
│ ├── distributor.js # Claim flow and payment dispatch
│ └── export.js # Data-export builders (guild-scoped JSON)
│
└── utils/
├── logger.js # logToChannel helper
├── embeds.js # Discord embed builders
└── format.js # String/number formatting
EasyStake uses SQLite via better-sqlite3. The database file stake.db
lives in the project root and is created automatically on first run.
- Migrations in
database/migrations/are applied in filename order on every startup. A_migrationsledger tracks what has already been applied, so re-running the bot is always safe. - WAL mode is enabled for better concurrency.
- Foreign keys are enabled — referential integrity is enforced.
- All timestamps are stored as ISO 8601 text (
new Date().toISOString()). - All wallet addresses are globally unique: one address can only ever belong to one Discord user across the entire deployment.
The database is a single file. Back it up by copying stake.db while the bot
is running (SQLite's WAL mode makes this safe):
sqlite3 stake.db ".backup '/path/to/backup/stake-$(date +%Y%m%d).db'"Or simply stop the bot and copy the file. Restoring is the reverse — put the file back and start the bot.
EasyStake v2 ships with a standalone migration script that imports existing data from a v1 MongoDB database into the new SQLite format.
node migrate-from-v1.js \
--mongouri="mongodb+srv://user:[email protected]/easystake" \
--sqlite="./stake.db"What it migrates:
- Users — Discord IDs →
userstable. - Wallets — linked wallet addresses →
user_walletstable. - Reward balances — existing unclaimed rewards →
user_rewardsunder a sentinelLegacy (v1 Import)pool, claimable from the new bot. - Collections config — best-effort import as
staking_poolsrows. Any rows that couldn't be confidently mapped are printed at the end so you can fix them manually with/pool edit.
The script is idempotent within reason: it uses INSERT OR IGNORE for users
and wallets, so re-running it won't duplicate data. Always back up stake.db
before running it if there's anything already there you don't want to lose.
Run the migration before starting the bot for the first time, then start the bot normally.
config.js validates the environment at startup and refuses to run with a
partial configuration. Add the listed variables to .env and restart.
You haven't set XRPL_REQUEST_API_KEY and you're also missing one or both of
XUMM_KEY / XUMM_SECRET. Pick one provider and supply its credentials.
Run node deploy-commands.js once after the first install and again whenever
slash command definitions change. Discord can take up to an hour to propagate
global commands; restarting the Discord client usually shortcuts this.
The address you tried to link is already linked to a different Discord user.
This is by design: one wallet address can only belong to one Discord user.
The other user must /wallet remove it first.
- Make sure users have actually verified their wallets with
/wallet add. - Check that the pool's
stake_identifiermatches the on-chain identifier exactly (NFT: taxon + issuer, or any taxon via a leading pipe + issuer, or issuer-only; for tokens use currency code + issuer — token currency codes may be hex, leave them as stored). - Try
/snapshot dryrun <pool>to see the holder count without crediting.
NETWORK must be a WebSocket endpoint (wss://…), not HTTP. Public mainnet
endpoints are typically rate-limited; for production, consider a dedicated node
or a paid provider like QuickNode or RippleX's Clio.
It shouldn't have — the logger swallows all errors silently. If you genuinely
see a log-channel error in console.error, it's a bug; please open an issue.
- Never commit your
.envfile. It's gitignored by default — leave it that way. - The
SEEDwallet is the only place a private key touches this codebase, and it's only loaded from the environment, never persisted to the database. If payouts fail with path errors or wrongAccount, verifySEEDmatches your funded distributor address (useDISTRIBUTOR_ADDRESSorSEED_ALGORITHMas needed). - Treat the SQLite file as sensitive: it contains the mapping between Discord IDs and XRPL addresses.
- All admin commands check
interaction.memberPermissions.has('Administrator')before running. - All signing flows reply ephemerally so signing URLs are only ever visible to the requesting user.
EasyStake is open source. Anyone is free to use and modify this code as they wish. No credits required, but always appreciated.
{ "export": { "version": 1, "type": "pools", "guild_id": "123456789012345678", "generated_at": "2026-05-13T10:52:00.000Z", "record_count": 5 }, "data": [ /* type-specific payload */ ] }