Skip to content

LedgerCraft/EasyStake-v2

Repository files navigation

EasyStake v2

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.


Table of contents


Features

  • 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.

Build status

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.


Requirements

  • 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 .env and must not be your main treasury wallet.
  • A signing-provider account — either:

Quick start

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 bot

That'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).


Configuration

All configuration lives in .env. Copy .env.example to .env and fill in the values below.

Required

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).

Signing provider — set exactly one option

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.

Optional

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).

Signing providers

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.

XRPL Request

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

Xaman

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)

Running in production

Run the bot under a process manager so it restarts on crashes and survives reboots.

PM2 (recommended)

npm install -g pm2
pm2 start stakebot.js --name easystake
pm2 save
pm2 startup

Useful 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 registry

systemd

Create /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.target

Then enable:

sudo systemctl daemon-reload
sudo systemctl enable --now easystake
sudo journalctl -u easystake -f

Docker

A 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 installs python3 / make / g++ only when better-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 a nologin shell.
  • tini is the PID 1 entrypoint so docker stop delivers SIGTERM cleanly and the bot's graceful shutdown path runs.
  • DB_PATH defaults to /app/data/stake.db; the /app/data directory is declared as a volume so the database survives container replacement even without an explicit -v flag.
  • .dockerignore excludes .env, the local SQLite files, .git, and node_modules so secrets and local state never leak into the image.

Quick start with Docker Compose (recommended)

cp .env.example .env
# Edit .env with your values, then:
docker compose up -d --build
docker compose logs -f easystake

The 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 --build

Running with plain docker (no Compose)

docker build -t easystake-v2:latest .
docker run -d \
  --name easystake \
  --restart unless-stopped \
  --env-file .env \
  -v easystake-data:/app/data \
  easystake-v2:latest

Registering slash commands from inside the container

The 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.js

Or with plain docker:

docker run --rm --env-file .env easystake-v2:latest node deploy-commands.js

Running the v1 migration inside the container

docker compose run --rm \
  -v "$(pwd)/legacy:/legacy" \
  easystake \
  node migrate-from-v1.js \
    --mongouri="mongodb+srv://..." \
    --sqlite=/app/data/stake.db

Run this once before starting the bot for the first time. The migration writes into the same volume the bot will read from.

Running the test suite

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 test

The 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.

Updating the bot

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 easystake

Database migrations apply automatically on every startup — there is no manual migration step.


Slash command reference

User commands

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.

Admin commands

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).

Pool identifier formats

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

Token currency codes — the short version

You can type whichever form is easiest:

  • A standard 3-character code like USD, BTC, or 123 — stored as-is.
  • A longer ASCII name up to 20 characters like MYTOKEN or PuppyToken — 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).


Data exports

/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.

Available types

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.

Privacy contract

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.

File format

Every export is a JSON document with the same envelope:

{
  "export": {
    "version": 1,
    "type": "pools",
    "guild_id": "123456789012345678",
    "generated_at": "2026-05-13T10:52:00.000Z",
    "record_count": 5
  },
  "data": [ /* type-specific payload */ ]
}

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.

Limits

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.


Project structure

/
├── 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

Database

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 _migrations ledger 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.

Backing up

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.


Migrating from v1

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 → users table.
  • Wallets — linked wallet addresses → user_wallets table.
  • Reward balances — existing unclaimed rewards → user_rewards under a sentinel Legacy (v1 Import) pool, claimable from the new bot.
  • Collections config — best-effort import as staking_pools rows. 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.


Troubleshooting

"Missing required environment variable(s): …"

config.js validates the environment at startup and refuses to run with a partial configuration. Add the listed variables to .env and restart.

"No signing provider configured."

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.

Slash commands don't appear in Discord

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.

"This wallet is already registered to an account."

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.

A snapshot is showing zero matched holders

  • Make sure users have actually verified their wallets with /wallet add.
  • Check that the pool's stake_identifier matches 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.

XRPL connection errors

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.

The bot logged a logToChannel error

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.


Security

  • Never commit your .env file. It's gitignored by default — leave it that way.
  • The SEED wallet 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 wrong Account, verify SEED matches your funded distributor address (use DISTRIBUTOR_ADDRESS or SEED_ALGORITHM as 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.

License

EasyStake is open source. Anyone is free to use and modify this code as they wish. No credits required, but always appreciated.

About

Overhaul and reimagining of Puppy's XRPL EasyStake system

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors