Bringing Web2's subscription simplicity to Web3. Tributary enables any Solana-based business to offer truly automated recurring payments
Tributary
Automated recurring payments on Solana using token delegation. Web2 subscription UX with Web3 transparency โ non-custodial, permissionless, and composable.
Table of Contents
- Monorepo Layout - The Two Policy Families - PDAs (Program Derived Addresses) - Payment Types - Fee Model - Referral Program - Composable Policies (Validation + Forward) - Gateway Feature Flags- Workspaces Reference
- SDK Usage
- CLI Manager
- Environment Variables
- Available Scripts
- Testing
- Deployment
- Troubleshooting
- Security
- Contributing
- License
- Community & Support
Overview
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,
- ComposablePolicy โ programmable pull payments with optional validation
Both reuse the same PolicyType enum, the same UserPayment account, the same PaymentGateway, and the same fee-distribution logic.
Program ID: TRibg8W8zmPHQqWtyAD1rEBRXEdyU13Mu6qX1Sg42tJ
Key Features
- 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.
Tech Stack
| Layer | Technology | | --------------- | ----------------------------------------------------------------------------------- | | Smart Contract | Rust, Anchor 0.31.1, anchor-spl (Token / Associated Token) | | Blockchain | Solana (cluster 2.2.11), 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 |
Prerequisites
- Node.js 20.19+ or 22.12+
- pnpm 9.6.0+ (root uses
10.28.2viacorepack) - Rust stable toolchain
- Anchor
0.31.0(install viaavm) - Solana CLI
2.2.11 - Docker (optional, for API / scheduler images and local DBs)
- Surfpool (required for integration tests)
[!TIP] >make preprunsavm use 0.31.0to pin the Anchor version. Run it once before
building or testing the program.
Getting Started
1. Clone the Repository
git clone https://github.com/tributary-so/tributary
cd tributary
2. Install Dependencies
The repo is a pnpm workspace (apps/, packages/, tests, programs/tributary).
corepack enable
pnpm install
3. Build the Workspace
Build 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
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 CLI
4. Environment Setup
There 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/.env
5. Run Tests
All 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 test no longer runs the suite. It builds the program, prints a
redirect message, and exits non-zero. Use anchor run surfpool against a
running Surfpool instance instead. Plain cargo test still works for the
Rust unit tests only.
See Testing for the per-suite matrix.
6. Start a Frontend Dev Server
# 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 dev
7. Run the Docs Locally
cd apps/docs
make serve # or: uv run mkdocs serve
Open http://localhost:8000.
Architecture
Monorepo Layout
โโโ programs/tributary/ # Solana program (Rust / Anchor 0.31.1)
โ โโโ 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 # PROTOCOLFEEBPS, 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.md defines
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.
The Two Policy Families
Tributary exposes two policy namespaces that share the scheduling engine:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ UserPayment โ
โ ["user_payment", owner, mint] โ
โ createdpoliciescount โโโ โ
โ createdcomposablecount โค โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ
PaymentPolicy PDA ComposablePolicy PDA
["paymentpolicy", userpayment, id] ["composablepolicy", userpayment, id]
id = createdpoliciescount id = createdcomposablecount
โ โ
executepayment executecomposable
(single CPI: user โ recipient) โโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโ
โ โ
Phase 1: PULL Phase 2/3 (optional)
usertoken โโโบ intermediateinput_ata VALIDATE + FORWARD
(Lighthouse) (Meteora DLMM)
โ
settle: recipient + fees
[!IMPORTANT] >PaymentPolicyIDs andComposablePolicyIDs come from independent
counters onUserPayment(createdpoliciescountvs
createdcomposablecount). A regular policy #1 and a composable policy #1
can coexist on the same UserPayment.
PDAs (Program Derived Addresses)
| 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 | ["paymentpolicy", userpayment, policy_id] | Regular pull-payment policy (Subscription / Milestone / PayAsYouGo) | | ComposablePolicy | ["composablepolicy", userpayment, policy_id] | Programmable pull-payment policy (validation + forward hooks) | | ValidationPda | ["composablevalidation", composablepolicy] | 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) |
Payment Types
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.
Subscription
Fixed recurring payments at regular intervals.
PolicyType::Subscription {
amount: u64, // payment per cycle
autorenew: bool, // continue past maxrenewals?
max_renewals: Option<u32>, // ceiling (None = indefinite)
payment_frequency: PaymentFrequency, // Daily/Weekly/Monthly/Quarterly/.../Custom(secs)
nextpaymentdue: i64, // gates execution
padding: [u8; 97],
}
PaymentFrequency supports Daily, Weekly, Monthly, Quarterly, SemiAnnually, Annually, and Custom(u64) (arbitrary seconds).
Milestone
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 |
Pay-as-you-go
Usage-based billing with per-period caps and per-call chunk limits.
PolicyType::PayAsYouGo {
maxamountper_period: u64,
maxchunkamount: u64,
periodlengthseconds: u64,
currentperiodstart: i64,
currentperiodtotal: u64, // auto-resets when period elapses
padding: [u8; 88],
}
Fee Model
Fees are computed in programs/tributary/src/shared/fees.rs:
- Protocol fee โ default
100 bps(1%), stored onProgramConfig.protocolfeebps. - Gateway fee โ configurable
gatewayfeebps(0..10000), stored per-gateway. - Custom protocol fee โ per-gateway override (bit 2 of
feature_flags). - Combined guard โ
gatewayfeebps + protocolfeebps < 10000is enforced (recipient must receive > 0).
PaymentGateway.feature_flags bit 1):
- Gross (default) โ recipient =
paymentamount โ gatewayfee โ protocol_fee. - Net โ recipient receives exactly
payment_amount; fees are added on top and pulled from the user.
(amount * bps) / 10000 (rounds down; dust goes to protocol).
Referral Program
Gateways opt into a 3-tier referral reward pool (bit 0 of feature_flags):
referralallocationbpsโ fraction of the gateway fee carved into the pool (0..=2500, i.e. up to 25%).referraltiersbpsโ 3-element split of the pool across[direct, level2, level3], must sum to 10000.
ReferralAccount
PDA (["referral", gateway, referral_code]). The chain is traversed at payment
time to distribute rewards.
Composable Policies (Validation + Forward)
A ComposablePolicy runs two optional hooks during execution, between the pull and settlement. Both are opt-in via sentinels.
Execution flow (3 phases)
execute_composable:
โโ Phase 1: PULL โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ UserPayment PDA signs: โ
โ usertokenaccount โโโบ intermediateinputata โ
โ (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 intermediateinput โโโบ intermediateoutput. โ
โ ByteRangeCheck pins the forward instruction selector. โ
โ Sweep intermediate_output โโโบ recipient + protocol + gateway. โ
โ minoutputamount enforced on NET (post-fee) amount. โ
โ If forward disabled (sentinel): sweep input directly to โ
โ recipient + fees (same-mint topup pattern). โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
ForwardConfig
struct ForwardConfig {
target_program: Pubkey, // Pubkey::default() = disabled (sentinel)
inputmint: Pubkey, // == userpayment.token_mint
output_mint: Pubkey, // recipient delivery mint
minoutputamount: Option<u64>, // NET (post-fee) minimum (DeFi convention)
forward_flags: u8,
numdatachecks: u8,
data_checks: [ByteRangeCheck; 4], // pin forward instruction discriminator
}
targetprogrammust be inALLOWEDFORWARD_PROGRAMS(Meteora DLMM).Pubkey::default()disables the forward.- When enabled, โฅ1
ByteRangeCheckmust pin the discriminator at offset 0. minoutputamountis checked against the net amount (after fees).
ValidationConfig
struct ValidationConfig {
validation_program: Pubkey, // SystemProgram = disabled (sentinel)
numvalidationaccounts: u8, // โค 10 read-accounts for the assertion
}
Assertion data (โค 1024 bytes) lives in a separate ValidationPda (["composablevalidation", composablepolicy]). At execute, the program CPIs into the validation program passing this data + declared read-accounts as remaining_accounts.
validationprogrammust be inALLOWEDVALIDATION_PROGRAMS(Lighthouse).SystemProgramdisables validation.
Allowlists (hard-coded in constants.rs)
| List | Programs | | ----------------------------- | ---------------------------------------------------------- | | ALLOWEDFORWARDPROGRAMS | Meteora DLMM LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo | | ALLOWEDVALIDATIONPROGRAMS | Lighthouse L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95 |
Building Lighthouse assertions (SDK facade)
The SDK ships a fluent lighthouse facade over the vendored packages/lighthouse client. Never hand-roll the serialization.
import { lighthouse, LIGHTHOUSEPROGRAMID } from "@tributary-so/sdk";
// Assert hotWallet USDC balance < 50 USDC before topping up const guard = lighthouse .tokenAccount(hotWalletUsdcAta) .amount(50000000, "<") .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 fullremaining_accountslist ([ValidationPda, ...guard.accounts]).
Gateway Feature Flags
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 | FEATURENETAMOUNT | Recipient receives exactly payment_amount; fees added on top | | 2 | 0x04 | FEATURECUSTOMPROTOCOLFEE | Overrides default protocol fee with customprotocolfeebps |
Workspaces Reference
| 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 |
SDK Usage
Install
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 client
Create a subscription
import { 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") );
Execute a payment (permissionless โ called by gateway signer)
const execIx = await sdk.executePayment(paymentPolicyPda);
Create a composable policy with a Lighthouse guard
import { lighthouse, LIGHTHOUSEPROGRAMID } from "@tributary-so/sdk";
const guard = lighthouse .tokenAccount(hotWalletUsdcAta) .amount(50000000, "<") .build();
const ix = await sdk.getCreateComposablePolicyInstruction( tokenMint, recipient, gateway, policyType, // same PolicyType enum "Auto topup guard", forwardConfig, // targetProgram = PublicKey.default for no-swap topup LIGHTHOUSEPROGRAMID, // 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] );
React โ SubscriptionButton
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" />;
x402 HTTP 402 middleware
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);
CLI Manager
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 PDA
[!TIP]
The SDK also ships a low-level manager: pnpm --filter @tributary-so/sdk manager.
Environment Variables
Required (program interaction)
| Variable | Description | Example | | ---------------- | -------------------- | --------------------------------------------- | | SOLANARPCURL | Solana RPC endpoint | https://api.mainnet-beta.solana.com | | PROGRAM_ID | Tributary program ID | TRibg8W8zmPHQqWtyAD1rEBRXEdyU13Mu6qX1Sg42tJ |
Optional (Anchor / deployment)
| Variable | Description | Default | | --------------------- | -------------------------------------- | ------------------------------------- | | ANCHOR_WALLET | Path to Solana wallet keypair | ~/.config/solana/id.json | | ANCHORPROVIDERURL | Anchor provider URL | localnet | | SOLANA_API | Override Makefile RPC (deploy scripts) | https://api.mainnet-beta.solana.com | | BUFFER | Buffer path for buffer-based deploys | โ |
App-specific
Each app documents its own variables in its .env.example. See Getting Started ยง4.
[!IMPORTANT]
Never commit.envfiles. The repo only ships.env.exampletemplates.
Available Scripts
Root (package.json + Makefile)
| Command | Description | | --------------------- | ---------------------------------------------------------------------------- | | pnpm install | Install all workspace dependencies | | pnpm run lint | Lint all workspaces | | pnpm run lint:fix | Auto-fix lint issues | | make prep | Pin Anchor (avm use 0.31.0) | | 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 |
Program (programs/tributary)
| 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 |
Deployment (Makefile)
| Command | Description | | ----------------------------- | ------------------------------------------------------ | | make devnet_build | Build for devnet | | make devnet_deploy | Deploy to devnet (--program-keypair) | | make devnetdeploybuffer | Buffer-based devnet deploy (BUFFER=...) | | make mainnet_build | Build with --features mainnet | | make mainnet_deploy | Direct mainnet deploy (--upgradeable) | | make mainnetdeploybuffer | 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 |
Packages
| 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 |
Apps
| 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 |
Testing
Test matrix
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 |
Running the full suite
# Terminal 1 โ start the Surfpool mainnet-fork
make run_surfpool
Terminal 2 โ run the whole suite (first failure aborts)
anchor run surfpool
anchor 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 test is intentionally a no-op that prints a redirect message and
exits non-zero. The suite must be driven via anchor run surfpool (or
make test_surfpool) against a running Surfpool instance, because Surfpool
auto-deploys the program from target/deploy/ and the tests rely on the
forked mainnet state (USDC/USDT mints, the existing ProgramConfig, โฆ).
Test files
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/
Writing tests
Mirror the source structure with a .test.ts suffix. Integration tests use the Surfpool-backed Anchor provider. Prefer accountsStrict() over accounts() for type safety.
Deployment
Smart Contract
The program deploys upgradeable (Anchor.toml โ upgradeable = true). Use the Makefile targets which bake in the correct keypairs and RPC.
Devnet
make prep
make devnet_build
make devnet_deploy # direct
or buffer-based (recommended for upgrades):
BUFFER=<path> make devnetdeploybuffer
Mainnet
make prep
make mainnet_build # builds with --features mainnet
make mainnetdeploybuffer # buffer + program-id deploy (recommended)
or direct:
make mainnet_deploy
Verifiable builds (recommended)
Deterministic Docker builds verified on-chain via solana-verify:
make verifiable_build # build + hash + deploy + verify
or submit a remote verification job:
make submit-verifable_build
See DEPLOYMENT.md for the manual verifiable-build runbook.
Extending program size
make mainnet_expand # +20480 bytes
devnet:
make devnet_expand
Publishing the IDL
make publish_idl
Docker (API + Scheduler)
Both 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 SOLANARPCURL=https://api.mainnet-beta.solana.com \
-p 3000:3000 \
tributary-api
docker run --rm \ -e SOLANARPCURL=https://api.mainnet-beta.solana.com \ -e ANCHOR_WALLET=/keys/gateway.json \ -v ~/.config/solana:/keys:ro \ tributary-scheduler
SDK Packages
All 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-run
Frontend Apps
The 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).
CI/CD
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).
gh workflow run main.yaml -f post_release=true
Troubleshooting
Surfpool / integration tests fail
[!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-tui
[!TIP]
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.
executepayment / executecomposable fails
| 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 | nextpaymentdue 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 | changepaymentpolicy_status โ Active | | CombinedFeeBpsExceedsMax | gatewayfeebps + protocolfeebps >= 10000 | Lower the gateway fee |
Anchor build fails
# Pin the toolchain
make prep
Clear caches
rm -rf ~/.anchor target
anchor build
SDK build fails
rm -rf node_modules pnpm-lock.yaml
pnpm install
pnpm --filter @tributary-so/sdk build
Program deployment fails
solana 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 mainnetdeploybuffer
Composable: forward CPI rejected
targetprogrammust be inALLOWEDFORWARD_PROGRAMS(Meteora DLMM) orPubkey::default()to disable.- โฅ1
ByteRangeCheckmust pin the instruction discriminator at offset 0. minoutputamountis checked against the net (post-fee) output.- Intermediate ATAs are owned by the ComposablePolicy PDA, not the UserPayment PDA.
Composable: validation CPI
[!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.
TypeScript type errors
pnpm run lint
pnpm run lint:fix
Security
- 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
issignerfromremainingaccounts(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.- Audits โ see
AUDITS.mdandSECURITY.md. Findings are documented inreports/.
SECURITY.md.
Contributing
- 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 runsurfpool(start the fork), thenmake testsurfpool(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.
Conventions
- 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.
License
MIT โ see LICENSE.
Community & Support
- Website: tributary.so
- Documentation: docs.tributary.so
- GitHub Issues: github.com/tributary-so/tributary/issues
- Twitter: @tributaryso