Automated recurring payments on Solana using token delegation. Web2 subscription UX with Web3 transparency — non-custodial, permissionless, and composable.
- Overview
- Key Features
- Tech Stack
- Prerequisites
- Getting Started
- Architecture
- Workspaces Reference
- SDK Usage
- CLI Manager
- Environment Variables
- Available Scripts
- Testing
- Deployment
- Troubleshooting
- Security
- Contributing
- License
- Community & Support
Tributary is a single Solana program that enables automated, pull-based recurring payments using SPL token delegation. Users approve a delegate once; authorized gateways then pull payments on schedule without further user intervention — and without ever taking custody of the funds.
The protocol exposes two families of pull-payment policies that share the same scheduling model but differ in execution semantics:
- PaymentPolicy — direct pull payments (subscriptions, milestones, pay-as-you-go).
- ComposablePolicy — programmable pull payments with optional validation (Lighthouse on-chain assertions) and token forwarding (Meteora DLMM swaps) inserted between the pull and the settlement.
Both reuse the same PolicyType enum, the same UserPayment account, the same
PaymentGateway, and the same fee-distribution logic.
Program ID: TRibg8W8zmPHQqWtyAD1rEBRXEdyU13Mu6qX1Sg42tJ
- Non-custodial — funds stay in the user's wallet; the program only pulls via SPL delegation.
- Permissionless execution — any authorized gateway signer can trigger a due payment.
- Three policy models — Subscription, Milestone (escrow + release conditions), and Pay-as-you-go (usage-based with period caps).
- Composable policies — opt-in validation (Lighthouse assertions) and token-transform forwards (Meteora DLMM) during execution.
- Fee architecture — configurable protocol + gateway fees, net/gross modes, per-gateway custom fees, and a 3-tier referral reward pool.
- Referral program — 6-character codes scoped per gateway with a 3-level chain split.
- Emergency pause — global kill switch on the
ProgramConfigsingleton. - x402 / HTTP 402 middleware for deferred micropayments.
- Action Codes — one-time wallet-less payment codes.
- Multi-app monorepo — SDK, React SDK, payments client, oclif CLI, Express API, scheduler, checkout, and marketing site.
| Layer | Technology |
|---|---|
| Smart Contract | Rust, Anchor 1.2.0 (sBPFv3 — ADR-0035), anchor-spl (Token / Associated Token) |
| Blockchain | Solana / Agave 3.1.10, program ID TRibg8W8zmPHQqWtyAD1rEBRXEdyU13Mu6qX1Sg42tJ |
| SDKs | TypeScript, @coral-xyz/anchor, @solana/web3.js, @solana/spl-token |
| Frontend | React 19, Vite 7, Tailwind 4, HeroUI, Wallet Adapter, TanStack Query, Jotai |
| API Server | Express 4, Drizzle ORM, PostgreSQL (postgres), Redis, Socket.io, KafkaJS |
| Scheduler | Node.js, node-cron, commander |
| CLI | oclif 4 |
| Validation CPI | Lighthouse (L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95) |
| Forward CPI | Meteora DLMM (LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo) |
| Docs | MkDocs Material (uv / Python) |
| Package Manager | pnpm workspaces (root 10.28.2) |
| CI/CD | GitHub Actions, semantic-release (per-package), ghcr.io Docker |
| Testing | Jest, Anchor tests, Surfpool mainnet-fork |
- Node.js 20.19+ or 22.12+
- pnpm 9.6.0+ (root uses
10.28.2viacorepack) - Rust stable toolchain
- Anchor
1.2.0(install viaavm) - Solana CLI
3.1.10(Agave) - Docker (optional, for API / scheduler images and local DBs)
- Surfpool (required for integration tests)
[!TIP] >
make preprunsavm install 1.2.0 && avm use 1.2.0to install and pin the Anchor version. Run it once before building or testing the program.
git clone https://github.com/tributary-so/tributary
cd tributaryThe repo is a pnpm workspace (apps/*, packages/*, tests, programs/tributary).
corepack enable
pnpm installBuild the smart contract and all publishable packages:
# Build everything (contract + all packages + all apps + docs)
make build
# Or build individually:
anchor build # Rust program -> target/deploy/tributary.so (sBPFv3)
pnpm --filter @tributary-so/sdk build # Core SDK (tsup)
pnpm --filter @tributary-so/sdk-react build # React SDK
pnpm --filter @tributary-so/sdk-x402 build # x402 middleware
pnpm --filter @tributary-so/payments build # Payments client
pnpm --filter @tributary-so/cli build # oclif CLIThere is no root .env. Each app has its own configuration:
| App | Env file | Notes |
|---|---|---|
apps/api |
apps/api/.env.example |
DB, Redis, RPC, Kafka |
apps/scheduler |
(env vars / CLI flags) | RPC, gateway keypair |
apps/cli |
~/.config/solana/id.json |
Uses Solana CLI config |
apps/app |
apps/app/.env.example |
Vite frontend vars (VITE_*) |
apps/checkout |
apps/checkout/.env.example |
Checkout page frontend vars |
Copy the relevant example and fill it in:
cp apps/api/.env.example apps/api/.envAll tests (Rust unit tests + every jest integration suite) run against a
Surfpool mainnet-fork. The program is deployed on mainnet, so the fork
carries real config and token state (USDC, USDT, Meteora pools, …) that the
tests seed via Surfpool cheatcodes — there is no separate deploy step (Surfpool
auto-deploys the program from target/deploy/ on start).
Start Surfpool in one terminal, then run the whole suite with a single command:
# Terminal 1 — start the fork
surfpool start --legacy-anchor-compatibility --no-tui
# (or: make run_surfpool)
# Terminal 2 — run everything (Rust + every jest suite); first failure aborts
anchor run surfpool
# (or: make test_surfpool)[!WARNING] >
anchor testno longer runs the suite. It builds the program, prints a redirect message, and exits non-zero. Useanchor run surfpoolagainst a running Surfpool instance instead. Plaincargo teststill works for the Rust unit tests only.
See Testing for the per-suite matrix.
# Main app (React + Vite) — http://localhost:5173
pnpm --filter @tributary-so/app dev
# Landing page — http://localhost:5174
pnpm --filter ./apps/landing dev
# Checkout page
pnpm --filter ./apps/checkout dev
# API server (Express) — http://localhost:3000
pnpm --filter @tributary-so/api dev
# Scheduler (cron worker)
pnpm --filter @tributary-so/scheduler devcd apps/docs
make serve # or: uv run mkdocs serveOpen http://localhost:8000.
├── programs/tributary/ # Solana program (Rust / Anchor 1.2.0, sBPFv3)
│ └── src/
│ ├── lib.rs # 21 instruction entrypoints + security.txt
│ ├── constants.rs # PDA seeds, allowlisted CPI programs
│ ├── error.rs # TributaryError enum
│ ├── state/ # Account structs (ProgramConfig, PaymentGateway,
│ │ # UserPayment, PaymentPolicy, ComposablePolicy,
│ │ # ValidationPda, ReferralAccount, ...)
│ ├── instructions/ # composable/, gateway/, payment/, referral/, user/
│ ├── policies/ # Strategy trait + Subscription/Milestone/PayAsYouGo
│ ├── shared/ # fees, delegation, mint, validation, referral, schedule
│ └── utils.rs
├── packages/
│ ├── sdk/ # @tributary-so/sdk — core TS SDK (tsup)
│ │ └── src/
│ │ ├── sdk.ts # Tributary class — all instruction builders
│ │ ├── lighthouse.ts # Fluent assertion facade (Lighthouse wrapper)
│ │ ├── pda.ts # PDA derivation helpers
│ │ ├── token.ts # SPL token utilities
│ │ ├── constants.ts # PROTOCOL_FEE_BPS, SEEDS, GATEWAY_FEATURES
│ │ ├── types.ts # Shared TS types
│ │ └── utils.ts
│ ├── sdk-react/ # @tributary-so/sdk-react — hooks + UI components
│ ├── sdk-x402/ # @tributary-so/sdk-x402 — HTTP 402 Express middleware
│ ├── payments/ # @tributary-so/payments — high-level payments client (JWT)
│ └── lighthouse/ # lighthouse-sdk-legacy (vendored, private) — assertion client
├── apps/
│ ├── app/ # @tributary-so/app — React 19 dashboard
│ ├── checkout/ # Checkout page (pay-page.tsx)
│ ├── landing/ # Marketing site (React + Vite)
│ ├── lando/ # Lando page
│ ├── api/ # @tributary-so/api — Express + Drizzle + Postgres
│ │ └── src/{routes,services,middleware,db,types}
│ ├── scheduler/ # @tributary-so/scheduler — node-cron executor
│ ├── cli/ # @tributary-so/cli — oclif CLI (wallet/gateway/subscription/...)
│ ├── showcase-payments/ # Integration showcase
│ ├── showcase-topup-sol/ # Auto top-up (composable policy) showcase
│ ├── showcase-payment-policies/ # Owner-direct policy creation showcase
│ └── docs/ # MkDocs Material site (uv / Python)
│ └── adr/ # Architecture Decision Records (0001-0013)
├── tests/ # Jest integration suite (Surfpool-backed)
├── specs/ # Feature specifications
├── branding/ # Brand assets
├── reports/ # Audit / security finding write-ups
├── CONTEXT.md # Domain glossary / ubiquitous language
└── .github/workflows/ # CI pipelines
Orientation for new contributors:
CONTEXT.mddefines the ubiquitous language (read it first).apps/docs/adr/captures the why behind every locked-in architectural decision — 0001-0006 are v1 PaymentPolicy era, 0007-0013 are v2 ComposablePolicy era. Code is the authority on current state; ADRs are the authority on rationale.
Tributary exposes two policy namespaces that share the scheduling engine:
┌──────────────────────────────────────┐
│ UserPayment │
│ ["user_payment", owner, mint] │
│ created_policies_count ──┐ │
│ created_composable_count ┤ │
└────────────────────────────┼─────────┘
│
┌───────────────────────────────────┴───────────────────────────────────┐
│ │
PaymentPolicy PDA ComposablePolicy PDA
["payment_policy", user_payment, id] ["composable_policy", user_payment, id]
id = created_policies_count id = created_composable_count
│ │
execute_payment execute_composable
(single CPI: user → recipient) ┌─────────────────────┴─────────────────────┐
│ │
Phase 1: PULL Phase 2/3 (optional)
user_token ──► intermediate_input_ata VALIDATE + FORWARD
(Lighthouse) (Meteora DLMM)
│
settle: recipient + fees
[!IMPORTANT] >
PaymentPolicyIDs andComposablePolicyIDs come from independent counters onUserPayment(created_policies_countvscreated_composable_count). A regular policy #1 and a composable policy #1 can coexist on the sameUserPayment.
| PDA | Seeds | Purpose |
|---|---|---|
ProgramConfig |
["config"] |
Singleton — protocol admin, protocol fee, emergency pause |
PaymentGateway |
["gateway", authority] |
Per-authority gateway settings (fees, signer, flags, referral config) |
UserPayment |
["user_payment", owner, mint] |
Per user+mint; the delegate for token pulls; holds both counters |
PaymentPolicy |
["payment_policy", user_payment, policy_id] |
Regular pull-payment policy (Subscription / Milestone / PayAsYouGo) |
ComposablePolicy |
["composable_policy", user_payment, policy_id] |
Programmable pull-payment policy (validation + forward hooks) |
ValidationPda |
["composable_validation", composable_policy] |
Stores Lighthouse assertion data (≤ 1024 bytes) |
ReferralAccount |
["referral", gateway, referral_code] |
6-char referral code + chain relationships (gateway-scoped) |
PaymentsDelegate |
["payments"] |
Legacy global delegate (deprecated — UserPayment PDA is the delegate) |
All three variants are part of a single PolicyType enum and are exactly 128
bytes each (fixed-size for account stability). Both PaymentPolicy and
ComposablePolicy reuse this enum.
Fixed recurring payments at regular intervals.
PolicyType::Subscription {
amount: u64, // payment per cycle
auto_renew: bool, // continue past max_renewals?
max_renewals: Option<u32>, // ceiling (None = indefinite)
payment_frequency: PaymentFrequency, // Daily/Weekly/Monthly/Quarterly/.../Custom(secs)
next_payment_due: i64, // gates execution
padding: [u8; 97],
}PaymentFrequency supports Daily, Weekly, Monthly, Quarterly,
SemiAnnually, Annually, and Custom(u64) (arbitrary seconds).
Project-based compensation with up to 4 escrowed milestones released via a release-condition bitmap.
PolicyType::Milestone {
milestone_amounts: [u64; 4],
milestone_timestamps: [i64; 4],
current_milestone: u8,
release_condition: u8, // bit0=due-date, bit1=gateway, bit2=owner, bit3=recipient
total_milestones: u8, // 1..=4
escrow_amount: u64,
padding: [u8; 53],
}Release bits (bits 1–3 are mutually exclusive):
| Bit | Mask | Condition |
|---|---|---|
| 0 | 0b0001 |
Milestone due-date reached |
| 1 | 0b0010 |
Gateway authority must sign |
| 2 | 0b0100 |
Policy owner must sign |
| 3 | 0b1000 |
Recipient must sign |
Usage-based billing with per-period caps and per-call chunk limits.
PolicyType::PayAsYouGo {
max_amount_per_period: u64,
max_chunk_amount: u64,
period_length_seconds: u64,
current_period_start: i64,
current_period_total: u64, // auto-resets when period elapses
padding: [u8; 88],
}Fees are computed in programs/tributary/src/shared/fees.rs:
- Total fee — one
gateway_fee_bps(0..=10000), gateway-authority-set, stored per-gateway. There is no separate protocol fee (ADR-0018). - Protocol cut —
total_fee × protocol_share_bps / 10000. Global default2000(20% of the fee) onProgramConfig; per-gateway admin-granted override viaFEATURE_CUSTOM_PROTOCOL_FEE(bit 2 offeature_flags, may be zero). - Scheduler cut —
total_fee × scheduler_share_bps / 10000(per-gateway) — pays the execute-tx signer. - Referral pool —
total_fee × referral_allocation_bps / 10000when referral is enabled (see below). - Gateway residual — the remainder →
gateway.fee_recipient(the balancing item; truncation dust lands here). - Share guard —
protocol_share + scheduler_share + referral_allocation ≤ 10000enforced at every gateway-config write site (not at execute time).
Two amount modes (PaymentGateway.feature_flags bit 1):
- Gross (default) — recipient =
payment_amount − total_fee. - Net — recipient receives exactly
payment_amount; the total fee is added on top and pulled from the user.
Math: (amount * bps) / 10000 (rounds down; dust goes to the gateway residual).
Gateways opt into a 3-tier referral reward pool (bit 0 of feature_flags):
referral_allocation_bps— fraction of the gateway fee carved into the pool (0..=2500, i.e. up to 25%).referral_tiers_bps— 3-element split of the pool across[direct, level2, level3], must sum to 10000.
Referral codes are 6-byte arrays, scoped per gateway via the ReferralAccount
PDA (["referral", gateway, referral_code]). The chain is traversed at payment
time to distribute rewards.
A ComposablePolicy runs two optional hooks during execution, between the
pull and settlement. Both are opt-in via sentinels.
execute_composable:
┌─ Phase 1: PULL ─────────────────────────────────────────────────┐
│ UserPayment PDA signs: │
│ user_token_account ──► intermediate_input_ata │
│ (intermediate ATAs owned by ComposablePolicy PDA — NOT the │
│ UserPayment PDA, decoupling intermediate authority from the │
│ user-source delegate) │
└─────────────────────────────────────────────────────────────────┘
┌─ Phase 2: VALIDATE (optional) ──────────────────────────────────┐
│ CPI into validation_program (Lighthouse) with stored │
│ validation_data + declared read-accounts. Veto on assertion │
│ failure. Read-only — cannot move funds. │
└─────────────────────────────────────────────────────────────────┘
┌─ Phase 3: FORWARD (optional) + SETTLE ──────────────────────────┐
│ If forward enabled: CPI into target_program (Meteora DLMM) to │
│ swap intermediate_input ──► intermediate_output. │
│ ByteRangeCheck pins the forward instruction selector. │
│ Sweep intermediate_output ──► recipient + protocol + gateway. │
│ min_output_amount enforced on NET (post-fee) amount. │
│ If forward disabled (sentinel): sweep input directly to │
│ recipient + fees (same-mint topup pattern). │
└─────────────────────────────────────────────────────────────────┘
struct ForwardConfig {
target_program: Pubkey, // Pubkey::default() = disabled (sentinel)
input_mint: Pubkey, // == user_payment.token_mint
output_mint: Pubkey, // recipient delivery mint
min_output_amount: Option<u64>, // NET (post-fee) minimum (DeFi convention)
forward_flags: u8,
num_data_checks: u8,
data_checks: [ByteRangeCheck; 4], // pin forward instruction discriminator
}target_programmust be inALLOWED_FORWARD_PROGRAMS(Meteora DLMM).Pubkey::default()disables the forward.- When enabled, ≥1
ByteRangeCheckmust pin the discriminator at offset 0. min_output_amountis checked against the net amount (after fees).
struct ValidationConfig {
validation_program: Pubkey, // SystemProgram = disabled (sentinel)
num_validation_accounts: u8, // ≤ 10 read-accounts for the assertion
}Assertion data (≤ 1024 bytes) lives in a separate ValidationPda
(["composable_validation", composable_policy]). At execute, the program CPIs
into the validation program passing this data + declared read-accounts as
remaining_accounts.
validation_programmust be inALLOWED_VALIDATION_PROGRAMS(Lighthouse).SystemProgramdisables validation.
| List | Programs |
|---|---|
ALLOWED_FORWARD_PROGRAMS |
Meteora DLMM LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo |
ALLOWED_VALIDATION_PROGRAMS |
Lighthouse L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95 |
The SDK ships a fluent lighthouse facade over the vendored
packages/lighthouse client. Never hand-roll the serialization.
import { lighthouse, LIGHTHOUSE_PROGRAM_ID } from "@tributary-so/sdk";
// Assert hotWallet USDC balance < 50 USDC before topping up
const guard = lighthouse
.tokenAccount(hotWalletUsdcAta)
.amount(50_000_000, "<")
.build();
// guard.data → Buffer (stored in ValidationPda)
// guard.numAccounts → 1 (numValidationAccounts)
// guard.accounts → [hotWalletUsdcAta] (Lighthouse read-accounts)Covers tokenAccount, mintAccount, accountInfo, accountData,
accountDelta, sysvarClock, stakeAccount, merkleTree, plus operator
sugar ("<", ">=", "!=", "in", …).
Note
The facade owns only the Lighthouse target accounts. The caller assembles
Tributary's full remaining_accounts list ([ValidationPda, ...guard.accounts]).
PaymentGateway.feature_flags is a bit-vector (see constants.rs):
| Bit | Mask | Flag | Effect |
|---|---|---|---|
| 0 | 0x01 | FEATURE_REFERRAL |
Enables the referral reward pool |
| 1 | 0x02 | FEATURE_NET_AMOUNT |
Recipient receives exactly payment_amount; fees added on top |
| 2 | 0x04 | FEATURE_CUSTOM_PROTOCOL_FEE |
Overrides default protocol fee with custom_protocol_fee_bps |
| Package | Path | Version | Purpose |
|---|---|---|---|
@tributary-so/contract |
programs/tributary |
1.6.1 | Rust Anchor program (publishes IDL) |
@tributary-so/sdk |
packages/sdk |
1.11.1 | Core TypeScript SDK — instruction builders, PDAs |
@tributary-so/sdk-react |
packages/sdk-react |
1.6.0 | React hooks + UI components (SubscriptionButton) |
@tributary-so/sdk-x402 |
packages/sdk-x402 |
1.5.0 | Express 5 HTTP 402 middleware + metering |
@tributary-so/payments |
packages/payments |
1.9.1 | High-level payments client with JWT verification |
lighthouse-sdk-legacy |
packages/lighthouse |
2.0.1 | Vendored Lighthouse assertion client (private) |
@tributary-so/cli |
apps/cli |
1.8.0 | oclif CLI (tributary binary) |
@tributary-so/api |
apps/api |
1.9.0 | Express + Drizzle + Postgres + Redis API server |
@tributary-so/scheduler |
apps/scheduler |
1.5.2 | node-cron payment executor |
@tributary-so/app |
apps/app |
1.14.0 | React 19 dashboard |
| (unpublished) | apps/checkout |
— | Checkout pay-page |
| (unpublished) | apps/landing |
— | Marketing site |
| (unpublished) | apps/lando |
— | Lando page |
| (unpublished) | apps/docs |
— | MkDocs documentation |
pnpm add @tributary-so/sdk
# optional companions:
pnpm add @tributary-so/sdk-react # React hooks + buttons
pnpm add @tributary-so/sdk-x402 # HTTP 402 middleware
pnpm add @tributary-so/payments # high-level payments clientimport { Tributary, getPaymentFrequency, encodeMemo } from "@tributary-so/sdk";
import { Connection, PublicKey } from "@solana/web3.js";
const connection = new Connection("https://api.mainnet-beta.solana.com");
const PROGRAM_ID = new PublicKey("TRibg8W8zmPHQqWtyAD1rEBRXEdyU13Mu6qX1Sg42tJ");
const sdk = new Tributary(PROGRAM_ID, connection);
// Full setup: ATA + user payment + policy + delegate approval
const ixs = await sdk.createSubscription(
tokenMint, // e.g. USDC mint
recipient, // recipient wallet
gateway, // gateway PDA authority
new BN("1000000"), // amount (6 decimals = 1 USDC)
true, // auto-renew
12, // max renewals
getPaymentFrequency("monthly"),
encodeMemo("Pro plan")
);const execIx = await sdk.executePayment(paymentPolicyPda);import { lighthouse, LIGHTHOUSE_PROGRAM_ID } from "@tributary-so/sdk";
const guard = lighthouse
.tokenAccount(hotWalletUsdcAta)
.amount(50_000_000, "<")
.build();
const ix = await sdk.getCreateComposablePolicyInstruction(
tokenMint,
recipient,
gateway,
policyType, // same PolicyType enum
"Auto topup guard",
forwardConfig, // targetProgram = PublicKey.default for no-swap topup
LIGHTHOUSE_PROGRAM_ID, // SystemProgram = no validation
guard.numAccounts,
guard.data
);
const execIx = await sdk.executeComposable(
composablePolicyPda,
instructionData, // forward ix data (empty if disabled)
forwardAmount ?? null,
remainingAccounts // [ValidationPda, ...lighthouseTargets, ...forwardAccts]
);import { SubscriptionButton, PaymentInterval } from "@tributary-so/sdk-react";
<SubscriptionButton
amount={new BN("10000000")} // 10 USDC
token={USDC_MINT}
recipient={recipientWallet}
gateway={gatewayAddress}
interval={PaymentInterval.Monthly}
maxRenewals={12}
memo="Monthly donation"
label="Subscribe for $10/month"
/>;import { createX402Middleware } from "@tributary-so/sdk-x402";
const middleware = createX402Middleware({
scheme: "deferred",
network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
amount: 100,
recipient: process.env.RECIPIENT_WALLET!,
gateway: process.env.GATEWAY!,
tokenMint: process.env.TOKEN_MINT!,
paymentFrequency: "monthly",
jwtSecret: process.env.JWT_SECRET!,
sdk,
connection,
});
app.use("/api/premium", middleware);The oclif CLI (apps/cli, binary tributary) exposes topics for every program
operation:
pnpm --filter @tributary-so/cli build
# Topics:
tributary wallet # keypair create / import / balance
tributary program # protocol init + config queries
tributary user # UserPayment create / list / inspect
tributary gateway # gateway create / configure / inspect
tributary subscription # policy create / list / pause / resume / delete
tributary payments # trigger execution
tributary referral # referral accounts + chain queries
tributary pda # derive any PDATip
The SDK also ships a low-level manager: pnpm --filter @tributary-so/sdk manager.
| Variable | Description | Example |
|---|---|---|
SOLANA_RPC_URL |
Solana RPC endpoint | https://api.mainnet-beta.solana.com |
PROGRAM_ID |
Tributary program ID | TRibg8W8zmPHQqWtyAD1rEBRXEdyU13Mu6qX1Sg42tJ |
| Variable | Description | Default |
|---|---|---|
ANCHOR_WALLET |
Path to Solana wallet keypair | ~/.config/solana/id.json |
ANCHOR_PROVIDER_URL |
Anchor provider URL | localnet |
SOLANA_API |
Override Makefile RPC (deploy scripts) | https://api.mainnet-beta.solana.com |
BUFFER |
Buffer path for buffer-based deploys | — |
Each app documents its own variables in its .env.example. See
Getting Started §4.
Important
Never commit .env files. The repo only ships .env.example templates.
| Command | Description |
|---|---|
pnpm install |
Install all workspace dependencies |
pnpm run lint |
Lint all workspaces |
pnpm run lint:fix |
Auto-fix lint issues |
make prep |
Install + pin Anchor 1.2.0 (avm install && avm use) |
make verify-sbf |
Assert every built ELF is sBPFv3 (readelf e_flags 0x3) — SIMD-0500 guard |
make build |
Build contract + all packages + all apps + docs |
make run_surfpool |
Start Surfpool mainnet-fork (--legacy-anchor-compatibility) |
make test_surfpool |
Run the full suite against Surfpool (anchor run surfpool) |
make test |
anchor test — prints a redirect message (use make test_surfpool instead) |
anchor run surfpool |
Full suite (Rust + every jest suite) against a running Surfpool instance |
| Command | Description |
|---|---|
anchor build |
Build .so + IDL → target/deploy/ |
anchor test |
No-op — prints a redirect to anchor run surfpool |
cargo test |
Rust unit tests only |
| Command | Description |
|---|---|
make devnet_build |
Build for devnet |
make devnet_deploy |
Deploy to devnet (--program-keypair) |
make devnet_deploy_buffer |
Buffer-based devnet deploy (BUFFER=...) |
make mainnet_build |
Build with --features mainnet |
make mainnet_deploy |
Direct mainnet deploy (--upgradeable) |
make mainnet_deploy_buffer |
Buffer-based mainnet deploy (recommended for upgrades) |
make mainnet_expand |
Extend program account size by 20480 bytes |
make publish_idl |
Upgrade on-chain IDL |
make verifiable_build |
solana-verify build + hash + deploy + verify |
make submit-verifable_build |
Remote verifiable build via solana-verify |
| Command | Description |
|---|---|
pnpm --filter @tributary-so/sdk build |
Build core SDK (tsup) |
pnpm --filter @tributary-so/sdk-react build |
Build React SDK (tsup) |
pnpm --filter @tributary-so/sdk-x402 build |
Build x402 middleware |
pnpm --filter @tributary-so/payments build |
Build payments client |
pnpm --filter @tributary-so/cli build |
Build oclif CLI |
pnpm --filter @tributary-so/sdk manager |
Run SDK manager REPL |
| Command | Description |
|---|---|
pnpm --filter @tributary-so/app dev |
Vite dev server (dashboard) |
pnpm --filter ./apps/landing dev |
Vite dev server (marketing) |
pnpm --filter ./apps/checkout dev |
Vite dev server (checkout) |
pnpm --filter @tributary-so/api dev |
Express dev server (tsx watch) |
pnpm --filter @tributary-so/scheduler dev |
Scheduler worker |
Every suite runs against a Surfpool mainnet-fork (the program is deployed on mainnet, so the fork supplies real config / token / pool state). Rust unit tests are cluster-agnostic.
| Suite | Command | Runner | Requires |
|---|---|---|---|
| Rust unit tests | anchor run test-cargo |
cargo | Rust |
| Integration (regular policies) | anchor run test-integration |
jest | Surfpool |
| Composable policies | anchor run test-composable |
jest | Surfpool |
| Surfpool harness | anchor run test-surfpool |
jest | Surfpool |
| Surfpool topup (no swap) | anchor run test-topup |
jest | Surfpool |
| Surfpool topup (with swap) | anchor run test-topup-swap |
jest | Surfpool |
| Surfpool topup (SOL) | anchor run test-topup-sol |
jest | Surfpool |
| Everything | anchor run surfpool |
all | Surfpool |
# Terminal 1 — start the Surfpool mainnet-fork
make run_surfpool
# Terminal 2 — run the whole suite (first failure aborts)
anchor run surfpoolanchor run surfpool is wired in Anchor.toml to chain every suite in order:
test-cargo → cargo test
test-integration → jest ./tests/tributary.test.ts
test-composable → jest ./tests/composable.test.ts
test-surfpool → jest ./tests/surfpool.test.ts
test-topup → jest ./tests/topup-balance.test.ts
test-topup-swap → jest ./tests/topup-balance-swap.test.ts
test-topup-sol → jest ./tests/topup-balance-sol.test.ts
[!IMPORTANT] >
anchor testis intentionally a no-op that prints a redirect message and exits non-zero. The suite must be driven viaanchor run surfpool(ormake test_surfpool) against a running Surfpool instance, because Surfpool auto-deploys the program fromtarget/deploy/and the tests rely on the forked mainnet state (USDC/USDT mints, the existingProgramConfig, …).
tests/
├── tributary.test.ts # Full PaymentPolicy flow (init → execute)
├── composable.test.ts # ComposablePolicy: validation + forward
├── topup-balance.test.ts # Same-mint topup (no forward)
├── topup-balance-swap.test.ts # Topup with Meteora DLMM swap
├── surfpool.test.ts # Surfpool harness
├── constants.ts # Shared test pubkeys (METEORA_DLMM, LIGHTHOUSE)
├── surfpool-helpers.ts
└── helpers/
Mirror the source structure with a .test.ts suffix. Integration tests use the
Surfpool-backed Anchor provider. Prefer accountsStrict() over accounts()
for type safety.
The program deploys upgradeable (Anchor.toml → upgradeable = true).
Use the Makefile targets which bake in the correct keypairs and RPC.
make prep
make devnet_build
make devnet_deploy # direct
# or buffer-based (recommended for upgrades):
BUFFER=<path> make devnet_deploy_buffermake prep
make mainnet_build # builds with --features mainnet
make mainnet_deploy_buffer # buffer + program-id deploy (recommended)
# or direct:
make mainnet_deployDeterministic Docker builds verified on-chain via solana-verify:
make verifiable_build # build + hash + deploy + verify
# or submit a remote verification job:
make submit-verifable_buildSee DEPLOYMENT.md for the manual verifiable-build runbook.
make mainnet_expand # +20480 bytes
# devnet:
make devnet_expandmake publish_idlBoth apps/api and apps/scheduler ship multi-stage Dockerfiles built in CI
and pushed to ghcr.io/tributary-so/....
# API
docker build -t tributary-api -f apps/api/Dockerfile .
# Scheduler
docker build -t tributary-scheduler -f apps/scheduler/Dockerfile .Run locally:
docker run --rm \
-e DATABASE_URL=postgresql://... \
-e SOLANA_RPC_URL=https://api.mainnet-beta.solana.com \
-p 3000:3000 \
tributary-api
docker run --rm \
-e SOLANA_RPC_URL=https://api.mainnet-beta.solana.com \
-e ANCHOR_WALLET=/keys/gateway.json \
-v ~/.config/solana:/keys:ro \
tributary-schedulerAll publishable packages use semantic-release (gitmoji-flavored) per-package
via semantic-release-monorepo. Releases trigger automatically on pushes to
main (see .github/workflows/semantic-release.yml).
# Manual dry-run for a single package
pnpm --filter @tributary-so/sdk release --dry-runThe app, landing, checkout, and lando pages deploy automatically via GitHub
Actions (.github/workflows/app-prod.yaml, landing-page.yaml,
checkout-page.yaml, lando-page.yaml).
The main pipeline (.github/workflows/main.yaml) is change-detected:
- Detect changed packages — diff against the previous tag.
- Test SDKs — runs per changed package path.
- Release — semantic-release per package.
- Deploy — app, landing, checkout, lando, docs, typedocs (conditional).
- Docker — build & push API + scheduler images to
ghcr.io(conditional).
Trigger a full post-release rebuild without cutting a release:
gh workflow run main.yaml -f post_release=true
Warning
All jest suites require a running Surfpool mainnet-fork. anchor test
no longer runs them — it prints a redirect message and exits. Use
anchor run surfpool instead.
# Start Surfpool first (separate terminal)
make run_surfpool
# Then run the whole suite (Rust + every jest suite)
anchor run surfpool
# If Surfpool is stuck, restart it:
surfpool start --legacy-anchor-compatibility --no-tuiTip
Surfpool persists fork overrides between runs. If a test fails because an
account (e.g. ProgramConfig) is in an unexpected state from a prior run,
restart Surfpool to get back to a pristine mainnet fork, then re-run
anchor run surfpool.
| Symptom | Likely cause | Fix |
|---|---|---|
MissingDelegate / insufficient delegate |
User has not approved the UserPayment PDA as delegate |
Approve delegate on the user token account with sufficient amount |
PaymentNotDue |
next_payment_due is in the future (Subscription) |
Wait for the due timestamp, or warp time in tests |
EmergencyPauseActive |
ProgramConfig.emergency_pause == true |
Admin must clear the flag |
PolicyPaused |
Policy status != Active |
change_payment_policy_status → Active |
CombinedFeeBpsExceedsMax |
gateway_fee_bps + protocol_fee_bps >= 10000 |
Lower the gateway fee |
# Pin the toolchain
make prep
# Clear caches
rm -rf ~/.anchor target
anchor buildrm -rf node_modules pnpm-lock.yaml
pnpm install
pnpm --filter @tributary-so/sdk buildsolana balance # ensure SOL for rent + fees
solana config get # verify cluster + keypair
# If "account already in use" on program-id:
BUFFER=/tmp/buffer.json make mainnet_deploy_buffertarget_programmust be inALLOWED_FORWARD_PROGRAMS(Meteora DLMM) orPubkey::default()to disable.- ≥1
ByteRangeCheckmust pin the instruction discriminator at offset 0. min_output_amountis checked against the net (post-fee) output.- Intermediate ATAs are owned by the ComposablePolicy PDA, not the UserPayment PDA.
Caution
The validation CPI dispatcher (shared/validation.rs) is currently a no-op
stub (Ok(())). It is wired but does not invoke Lighthouse at runtime yet.
The Lighthouse SDK facade, ValidationPda storage, and account splitting are
all implemented and tested; the on-chain dispatch is tracked as a follow-up.
Treat validation as "configured, not enforced" until the dispatch lands.
pnpm run lint
pnpm run lint:fix- Non-custodial — funds remain in user wallets; only SPL delegation is used.
- Emergency pause —
ProgramConfig.emergency_pauseblocks all execution. - Access control — authority verification on every mutating instruction.
- CPI allowlists — forward (Meteora DLMM) and validation (Lighthouse) target programs are hard-coded.
- CPI signer sanitization — validation & forward builders do not forward
is_signerfromremaining_accounts(closes a privilege pass-through vector). - Intermediate ATA ownership — owned by the ComposablePolicy PDA, isolating transient balances from user source funds.
security.txt— embedded on-chain viasolana-security-txt.- Formal verification — pull-amount bounds and fee-conservation logic are
formally specified (
.qedspec) and model-checked. The core pure functions (calculate_fees,validate_policy_execution,advance_policy,ByteRangeCheck::validate,validate_byte_ranges) are verified by Kani bounded model checking for all symbolic inputs and by property-based testing (23 properties). Compile-time drift gates (#[qed(verified)]) bind the spec to the source oncreate_payment_policyandtransfer. Seeformal_verification/README.mdfor the full layered architecture, what is proven vs. integration-tested, and current proof status. - Audits — see
AUDITS.mdandSECURITY.md. Findings are documented inreports/.
Report vulnerabilities to [email protected] per the policy in
SECURITY.md.
- Fork the repository.
- Create a feature branch:
git checkout -b feature/your-feature. - Write tests first (TDD) — mirror source structure with
.test.ts. - Run the suite:
make run_surfpool(start the fork), thenmake test_surfpool(oranchor run surfpool). - Lint:
pnpm run lint(must be clean). - Commit with conventional commits (gitmoji — releases are automated).
- Open a pull request.
Important
This repo uses beans for issue tracking. Reference relevant bean IDs in commit messages.
- Rust: snake_case files,
Result<()>error handling,#[account]fixed sizes with padding. - TypeScript: strict types,
PublicKeyfor addresses,anchor.BNfor big numbers,accountsStrict(). - Imports: Solana first, then Anchor, then local modules.
- PDAs: always derive via
packages/sdk/src/pda.tshelpers.
MIT — see LICENSE.
- Website: tributary.so
- Documentation: docs.tributary.so
- GitHub Issues: github.com/tributary-so/tributary/issues
- Twitter: @tributaryso