# Tributary — Money should move itself > The rule-based money-moving primitive on Solana. Delegate once; money flows within rules you set. Pull, don't push. # Usage documentation # Tributary > **Money should move itself.** The rule-based money-moving primitive on Solana. You delegate spending authority once — *set the riverbed once* — and money then flows within rules you defined: a trigger condition (**WHEN**), a value to pull (**PULL**), a destination to route to (**ROUTE**). One signature, not a thousand. Pull, don't push. ______________________________________________________________________ ## Why this exists Every payment rail before Tributary is **push**-based: you hold a balance, you sign a transfer, the balance drops. Every move needs your hand on the keypad. The signature is the tax; the wallet is a wheelbarrow. Crypto spent fifteen years winning the **balance** and silently inherited the **push** worldview — money that sits until a human shoves it. Tributary is, architecturally, a **pull**-payment primitive: you delegate a puller once, the puller draws on rules. The antagonist (push) and the architecture (pull) are the same word. Recurring payments is the smallest thing this primitive does — the minimal live configuration, already running (4,000+ pulls on mainnet). Turn the other knobs and the same primitive composes into autonomous capital. > **Stop pushing your bags. Let them flow.** ______________________________________________________________________ ## How it works Solana-native **token delegation**. You grant a puller permission for specific amounts on a specific schedule; the protocol pulls exactly what you approved, when you approved it — nothing more. Your wallet is the aquifer: untouched until a rule fires. - **No deposits.** Tokens stay in your wallet until a pull is due. - **No middleman.** Funds route directly from your wallet to the recipient. - **No surprises.** Pause, resume, or revoke anytime. - **No borders.** Anyone, anywhere can pay or accept. Payments settle in under a second with fees measured in fractions of a cent — that's Solana, not magic. | | Tributary | Traditional | | --------------- | ------------------- | ----------------------------- | | **Setup** | Seconds | Days (KYC, bank verification) | | **Fees** | starting at 1% | 2.9% + 30¢ per transaction | | **Settlement** | Instant (400ms) | 2–7 business days | | **Chargebacks** | No | Yes | | **Custody** | Your wallet, always | Held by a third party | | **Geography** | No restrictions | Country-limited | ______________________________________________________________________ ## What you get Five claim shapes. One primitive. *If This Then Money* — pick the configuration that fits your flow. ### Subscriptions The familiar model. Fixed amount, regular interval. - Pay the same every week, month, or year - Auto-renew or cap the number of renewals - Cancel or pause with one click **Great for:** SaaS tools, memberships, streaming, recurring donations [Learn more](https://docs.tributary.so/protocol-reference/payment-policy/subscription/index.md) ### Milestone Payments Pay for deliverables, not time. Split a project into up to 4 milestones. - Different amounts per milestone - Release on schedule, on approval, or automatically - Funds committed upfront but released only as work completes **Great for:** Freelance projects, consulting, software development, content series [Learn more](https://docs.tributary.so/protocol-reference/payment-policy/milestone/index.md) ### Pay-as-you-go Use first, pay later. Providers claim what you owe within limits you set. - Set a spending cap per billing period - Providers claim incrementally as you consume - Periods reset automatically — hard limits enforced on-chain **Great for:** AI APIs, cloud computing, utility services, anything metered [Learn more](https://docs.tributary.so/protocol-reference/payment-policy/payasyougo/index.md) ______________________________________________________________________ ## Who is it for? ### End users One signature, total transparency, full control. Every pull is on-chain. Your tokens never leave your wallet until a rule fires. Pause or revoke anytime. ### Developers Drop in a [React component or hook](https://docs.tributary.so/integration-guide/pull-payments/sdk-react/index.md) and you're done. Need more control? Use the [TypeScript SDK](https://docs.tributary.so/integration-guide/pull-payments/sdk/index.md). Going no-code? Generate [checkout links](https://docs.tributary.so/integration-guide/pull-payments/checkout/index.md) in seconds. Everything is [open-source](https://github.com/tributary-so/tributary). ### Businesses Accept recurring payments globally without the KYC bottleneck. Pay ~1% instead of 3%+. Settle instantly. Give your customers a familiar experience on pull-based rails. ### Payment providers Build your own payment service on top of the primitive. Earn fees by running a [Payment Gateway](https://docs.tributary.so/operate/providers/index.md) — keep the watershed. Focus on UX; the protocol handles the complexity. ______________________________________________________________________ ## What Can You Build? | Idea | Payment Type | How It Works | | --------------------- | ------------- | ----------------------------------- | | Streaming service | Subscription | $10/month, auto-renew | | Freelance platform | Milestones | Pay per deliverable phase | | AI API gateway | Pay-as-you-go | Bill per token, capped daily | | Newsletter | Subscription | $5/month, cancel anytime | | Consulting engagement | Milestones | 3-phase project with approval gates | | Cloud compute | Pay-as-you-go | Metered usage with weekly caps | More ideas in [Use Cases](https://docs.tributary.so/use-cases/index.md). ______________________________________________________________________ ## Get Started 1. **Pick your integration** — [React SDK](https://docs.tributary.so/integration-guide/pull-payments/sdk-react/index.md), [TypeScript SDK](https://docs.tributary.so/integration-guide/pull-payments/sdk/index.md), or [Checkout Links](https://docs.tributary.so/integration-guide/pull-payments/checkout/index.md) 1. **Choose a payment type** — [Subscription](https://docs.tributary.so/protocol-reference/payment-policy/subscription/index.md), [Milestone](https://docs.tributary.so/protocol-reference/payment-policy/milestone/index.md), [Pay-as-you-go](https://docs.tributary.so/protocol-reference/payment-policy/payasyougo/index.md), [OneTime](https://docs.tributary.so/protocol-reference/payment-policy/onetime/index.md), or [UpTo](https://docs.tributary.so/protocol-reference/payment-policy/upto/index.md) 1. **Go live** — deploy on Solana mainnet in minutes Questions? Check the [FAQ](https://docs.tributary.so/faq/index.md) or read the [Protocol Overview](https://docs.tributary.so/protocol-reference/overview/index.md). ## Developer Tools - **[TypeScript SDK](https://docs.tributary.so/integration-guide/pull-payments/sdk/#typescript-sdk-tributary-sosdk)** - Complete protocol interaction - **[React SDK](https://docs.tributary.so/integration-guide/pull-payments/sdk-react/index.md)** - Pre-built payment components and React hooks - **[Payments SDK](https://docs.tributary.so/integration-guide/pull-payments/sdk/#payments-sdk-tributary-sopayments)** - Simple Payments API with hosted checkout page (zero API keys) - **[x402 SDK](https://docs.tributary.so/integration-guide/pull-payments/sdk/#x402-sdk-tributary-sox402)** - HTTP 402 middleware for API monetization - **[CLI](https://docs.tributary.so/integration-guide/pull-payments/sdk/#cli-tributary-socli)** - Protocol management tools - **[REST API](https://docs.tributary.so/api/rest-api/index.md)** - Query subscriptions, events, manage webhooks ______________________________________________________________________ Tributary is one primitive. *If This Then Money.* You route the rest. # Frequently Asked Questions (FAQ) ## Security and Delegation Model ### Is delegation secure? What are the risks? Delegation in Tributary is designed to be non-custodial, meaning your funds remain in your wallet at all times. When you delegate payment authority, you're only approving the protocol to withdraw a specific amount for a defined subscription period (e.g., $120 for a $10/month subscription over 12 months). This is much safer than traditional Web3 alternatives that approve the entirety of your balance or require hot private keys in AI agents. The protocol is open-source, will undergo professional security audits, and is secured by multisig governance. Plans include decentralizing ownership via DAO governance to prevent any single entity from updating the contract. Unlike custodial solutions, you can revoke delegation at any time, and the contract enforces strict limits on what can be withdrawn. ### Is "funds stay in your wallet" misleading? No, it's technically accurate but requires context. In Tributary's non-custodial model, funds remain in your wallet— the protocol only gets delegated authority to pull specific amounts within approved parameters. This differs from actual self-custody where you're the only one who can move funds, but it's a significant improvement over fund lock-up in smart contracts or repeated manual approvals. The delegation is limited to the exact subscription amount and duration you approve, making it far safer than giving unlimited access or storing funds in a third-party contract. ### How does Tributary compare to hot private keys in other systems? Systems like x402 require clients to sign transactions with internet-connected private keys, creating "hot" wallets that are inherently risky. Tributary replaces this with a public contract that states clear limits on withdrawals. A single, well-reviewed delegation signature is more secure than repeatedly approving transactions from potentially malicious websites. The contract's transparency and revocability provide better protection than trusting hot keys. Unlike protocols where private keys must be accessible to AI agents or bots—granting access to your entire wallet and all assets—Tributary's delegation is limited to a specific token (e.g., USDC) and a predefined amount. This significantly reduces the impact radius of any potential compromise, allowing private keys to remain cold and secure. ## Adoption and Use Cases ### Why recurring payments? Don't users prefer one-time payments in DeFi? While one-time payments are common in DeFi, recurring subscriptions have low adoption due to poor UX and security concerns. Tributary addresses this by offering "sign once, pay forever" automation without custody risk. Traditional SaaS subscriptions converting to crypto often add friction, but Tributary's model works best for Web3-native businesses needing predictable revenue. ### What's Tributary's advantage over Stripe or other payment processors? Tributary is built for Solana: non-custodial, supports SPL tokens natively, and leverages Solana's speed and low costs. Unlike other providers, which often require fiat settlement and centralized processing, Tributary enables native crypto subscriptions with full on-chain transparency. Businesses get predictable revenue without the high fees and custody risks of traditional processors. For developers, Tributary provides SDKs and React components for easy integration — the simplicity of a hosted checkout, without surrendering self-custody. ## Technical and Operational ### Is multi-token support a differentiator? Multi-token support (including SPL tokens) is table stakes for Solana payment protocols. Tributary supports this as standard, along with advanced scheduling features. ### Has Tributary been audited? What about the MVP timeline? The protocol is open-source and will be audited by professional firms as soon as possible. The 3-week MVP demonstrates rapid execution but includes comprehensive testing and security measures. Recurring payments handle real money, so we've prioritized security from day one including plans for governance decentralization of the contract upgrade authority itself. ### What makes Tributary unique on Solana? Tributary is the non-custodial money-moving primitive on Solana: you delegate pull authority once — *set the riverbed once* — and money moves itself within rules you set. Pull, don't push. That single delegation replaces a thousand signatures and is what creates network effects through the provider ecosystem. ## Getting Started ### How do I integrate Tributary? Check our [Integration Guide](https://docs.tributary.so/integration-guide/index.md) for SDK integration. The React components make it easy to add subscription buttons in minutes. # Migration: v1 → v2 Tributary v2 adds the ComposablePolicy layer (validation + forward hooks) to the existing PaymentPolicy layer. **Existing v1 integrations continue to work unchanged** — v2 is a program upgrade, not a breaking change. This guide covers what changed, what didn't, and when you might want to opt in. ## What changed | Area | v1 | v2 | | ------------ | --------------------------------------------------------- | ---------------------------------------------------------------------------- | | Policy types | `PaymentPolicy` (subscription, milestone, pay-as-you-go) | + `ComposablePolicy` (same types + validation + forward hooks) | | New variants | — | + `OneTime`, `UpTo` | | Delegate | `UserPayment` PDA or legacy global `PaymentsDelegate` PDA | Same — dual-delegate fully backward-compatible | | Fees | `FEATURE_NET_AMOUNT` flag controls gross/net pull | Composable always input-side (ADR-0026); PaymentPolicy still honors the flag | | SDK | `createSubscription`, `createPayAsYouGo`, etc. | + `createComposable`, `executeComposable`, `lighthouse` facade | ## What did NOT change - **Program ID** — same: `TRibg8W8zmPHQqWtyAD1rEBRXEdyU13Mu6qX1Sg42tJ` - **PDA seeds for v1 accounts** — `PaymentPolicy`, `UserPayment`, `PaymentGateway`, `ProgramConfig` all unchanged - **v1 instruction signatures** — `create_payment_policy`, `execute_payment`, etc. work exactly as before - **Your existing delegate approvals** — if you approved the `UserPayment` PDA or the legacy `PaymentsDelegate` PDA, those still work for both v1 and v2 policies - **Fee model** — the unified gateway fee model (ADR-0018) applies to both ## The dual-delegate model Tributary accepts two delegate authorities on a user's token account: 1. **`UserPayment` PDA** (recommended) — `["user_payment", owner, mint]`. This is the default for new SDK integrations. Per-user, per-mint. 1. **`PaymentsDelegate` PDA** (legacy) — `["payments"]`. A single global delegate. Still accepted for backward compatibility. At execution time, `resolve_delegate` checks both paths. If either matches the actual delegate on the token account, execution proceeds. ### Do I need to migrate? **No.** If your v1 integration uses the legacy global delegate, it keeps working. New composable policies created via the SDK default to the `UserPayment` PDA, but the program accepts either. ### When to switch to UserPayment PDA - **New integrations** — start with `UserPayment`. It's per-user, per-mint, so revoking one user's delegation doesn't affect others. - **Security upgrades** — the `UserPayment` PDA provides finer-grained control. You can revoke delegation for a specific user+mint pair without touching others. - **Never required** — the legacy delegate is not deprecated. It's just less granular. ### How to switch (optional) ```typescript // Old: legacy global delegate approved on token account // (nothing to do — it still works) // New: re-approve to the UserPayment PDA const { address: userPaymentPda } = sdk.getUserPaymentPda(owner, mint); const approveIx = createApproveInstruction( ownerTokenAccount, userPaymentPda, // delegate owner, amount ); ``` After re-approval, both v1 `PaymentPolicy` and v2 `ComposablePolicy` execute against the `UserPayment` PDA. ## Upgrading your SDK ```bash pnpm add @tributary-so/sdk@latest ``` The SDK is backward-compatible. All v1 methods (`createSubscription`, `createPayAsYouGo`, `executePayment`, etc.) work exactly as before. New v2 methods (`createComposable`, `executeComposable`) are additive. ## Adopting composable policies (optional) You don't have to. But if you want validation hooks or forward swaps: 1. Read the [composable overview](https://docs.tributary.so) 1. Try a [quickstart](https://docs.tributary.so) (\<10 min) 1. The same `UserPayment` PDA serves both `PaymentPolicy` and `ComposablePolicy` — no new delegate approval needed if you're already on `UserPayment` ### Counter independence `PaymentPolicy` IDs come from `user_payment.created_policies_count`. `ComposablePolicy` IDs come from `user_payment.created_composable_count`. These are **independent** — a v1 policy #1 and a v2 policy #1 can coexist on the same `UserPayment` with different PDA addresses. ## Questions? - [FAQ](https://docs.tributary.so) - [Existing team migration notice](https://docs.tributary.so) - [Full ADR list](https://docs.tributary.so) # Use Cases Tributary's three payment types support diverse business models. Each type is optimized for specific scenarios. ## Payment Type Overview | Type | Best For | Predictability | Flexibility | | ------------- | ---------------- | -------------- | ----------- | | Subscriptions | Regular services | High | Low | | Milestones | Project work | Medium | Medium | | Pay-as-you-go | Variable usage | Low | High | ## Subscriptions Fixed recurring payments at regular intervals. ### SaaS & Software - **Monthly software licenses** - $29/month for productivity tools - **Annual enterprise plans** - $999/year with discounts - **Developer tools** - API access with tiered pricing - **Cloud services** - Compute, storage, bandwidth ### Content & Media - **Newsletters** - Premium content for $10/month - **Streaming platforms** - Video, audio, podcasts - **Research reports** - Weekly/monthly analysis - **Course access** - Continuous learning subscriptions ### Memberships & Communities - **Professional associations** - Annual dues - **Creator communities** - Discord server access - **DAO participation** - Voting rights, exclusive channels - **Club memberships** - Gym, co-working, social clubs ### Donations & Support - **Open source funding** - Monthly GitHub sponsors - **Creator support** - Patreon-style recurring donations - **Nonprofit donations** - Charitable giving automation ______________________________________________________________________ ## Milestone Payments Project-based compensation with up to 4 configurable deliverables. ### Freelance & Consulting ```text Website Development - $1,500 total ├── Milestone 1: Design mockups - $300 (Week 1) ├── Milestone 2: Core development - $700 (Week 3) ├── Milestone 3: Testing & launch - $500 (Week 5) ``` ### Software Development - **Feature development** - Payment per feature delivered - **Bug fixes** - Bounties with milestone verification - **Code reviews** - Payment upon completion - **Documentation** - Writing projects with chapter milestones ### Content Creation - **Video series** - Payment per episode - **Article packages** - Payment per article delivered - **Design work** - Concept, draft, final milestones - **Translation** - Milestone per document section ### Construction & Physical Work - **Home renovations** - Foundation, framing, finishing - **Event planning** - Booking, preparation, execution - **Manufacturing** - Design, prototype, production ### Release Conditions | Condition | Description | | ---------- | ------------------------------- | | Time-based | Automatic release at timestamp | | Manual | Requires recipient approval | | Automatic | Instant on milestone completion | ______________________________________________________________________ ## Pay-as-you-go Usage-based billing with period limits and chunk controls. ### AI & LLM Services ```typescript // AI API billing PayAsYouGo { maxAmountPerPeriod: 100_000_000, // $100/month maxChunkAmount: 10_000_000, // $10 max per call periodLengthSeconds: 2_592_000, // 30 days } ``` - **LLM token usage** - Per-token billing - **Image generation** - Pay per image - **Embedding services** - Per-request billing - **Model inference** - Compute time billing ### API Services - **REST APIs** - Per-request billing - **GraphQL** - Query complexity billing - **Webhooks** - Event delivery charges - **Rate limiting** - Premium tier access ### Cloud Resources - **Compute** - Per-hour billing - **Storage** - Per-GB billing - **Bandwidth** - Per-TB billing - **Database** - Query-based pricing ### Data Services - **Data feeds** - Real-time market data - **Analytics** - Query-based billing - **Search** - Per-search billing - **Monitoring** - Metric ingestion ______________________________________________________________________ ## Integration Patterns ### AI Agent Monetization (Lando) Service agents generate subscription URLs for customer agents: ```typescript // Service agent generates payment URL const session = await payments.checkout.sessions.create({ mode: "subscription", line_items: [{ description: "AI Service Pro", unitPrice: 29, quantity: 1 }], paymentFrequency: "monthly", tributaryConfig: { gateway: "CwNybLVQ3sVmcZ3Q1veS6x99gUZcAF2duNDe3qbcEMGr", recipient: "SERVICE_AGENT_WALLET", trackingId: "ai-service-pro", }, }); // Customer agent visits URL, subscribes // Payments execute automatically ``` ### API Monetization (x402) HTTP 402 middleware for API access: ```typescript import { createX402Middleware } from "@tributary-so/x402"; app.use( "/api/premium", createX402Middleware({ scheme: "x402://payg", amount: 0.01, // $0.01 per request maxAmountPerPeriod: 100, periodLengthSeconds: 86400, }) ); ``` ### Checkout Links Zero-code payment collection: ```typescript // Generate link const session = await payments.checkout.sessions.create({ mode: "subscription", line_items: [{ description: "Newsletter Pro", unitPrice: 10, quantity: 1 }], paymentFrequency: "monthly", tributaryConfig: { gateway, recipient, trackingId }, }); // Share via email, SMS, chat sendEmail(email, `Subscribe: ${session.url}`); ``` ______________________________________________________________________ ## Hybrid Models Combine payment types for complex scenarios: ### Freemium + Subscription - Free tier with basic access - $10/month for premium features - $50/month for enterprise ### Project + Maintenance - Milestone payments for initial build - Monthly subscription for ongoing support ### Usage + Minimum - $50/month minimum (subscription) - Pay-as-you-go for usage above threshold ______________________________________________________________________ ## Industry Applications ### Creator Economy | Creator Type | Payment Type | Model | | ----------------- | ------------- | ---------------------- | | Newsletter writer | Subscription | $10/month premium | | YouTuber | Subscription | $5/month membership | | Consultant | Milestone | $500 per project phase | | AI bot builder | Pay-as-you-go | $0.01 per API call | ### DeFi & Web3 | Protocol Type | Payment Type | Model | | --------------- | ------------- | ---------------------- | | Staking service | Subscription | Monthly management fee | | Bridge protocol | Pay-as-you-go | Per-transaction fee | | DAO tooling | Milestone | Feature bounties | | Analytics | Subscription | $99/month premium | ### Professional Services | Service Type | Payment Type | Model | | -------------- | ------------- | ------------ | | Legal retainer | Subscription | $2000/month | | Development | Milestone | $500/feature | | Consulting | Pay-as-you-go | $150/hour | | Accounting | Subscription | $300/month | ______________________________________________________________________ ## Next Steps - [Subscription Payments](https://docs.tributary.so/protocol-reference/payment-policy/subscription/index.md) - Detailed subscription docs - [Milestone Payments](https://docs.tributary.so/protocol-reference/payment-policy/milestone/index.md) - Milestone implementation - [Pay-as-you-go](https://docs.tributary.so/protocol-reference/payment-policy/payasyougo/index.md) - Usage-based billing - [Integration Options](https://docs.tributary.so/integration-guide/index.md) - Get started # REST API The Tributary API server exposes a REST interface for querying subscriptions, payment events, managing webhooks, and issuing JWT tokens. The full endpoint reference below is generated at build time from our live OpenAPI 3.0 spec. **Base URL**: `https://api.tributary.so` ______________________________________________________________________ ## Endpoint Reference The reference is generated from the API server's JSDoc annotations and served live at `https://api.tributary.so/openapi.yaml`. If the spec is unreachable the section below will be empty — the API server may be starting up or the domain is not yet deployed. # Tributary API 1.9.0 Modular Express API for subscription and payment services on Solana. Provides health checks, subscription status lookups, on-chain event queries, webhook management, JWT issuance, JWKS publishing, and admin key rotation. ______________________________________________________________________ **License:** MIT ## Servers | Description | URL | | ----------------- | -------------------------- | | Production | | | Local development | | ## Webhooks ______________________________________________________________________ ### POST /v1/webhooks Register a webhook Description Creates a new webhook subscription for a gateway. **Request body** ```json { "gateway_pubkey": "string", "endpoint_url": "string", "active": true } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the request body ```json { "type": "object", "required": [ "gateway_pubkey", "endpoint_url" ], "properties": { "gateway_pubkey": { "type": "string", "minLength": 32, "maxLength": 44, "description": "Gateway authority public key." }, "endpoint_url": { "type": "string", "format": "uri", "description": "HTTPS (or HTTP) URL Tributary will POST events to." }, "active": { "type": "boolean", "default": true, "description": "Whether the webhook is active immediately." } } } ``` **Responses** ```json { "id": 1, "gateway_pubkey": "string", "endpoint_url": "string", "active": true, "created_at": "2022-04-13T15:42:05.901Z", "updated_at": "2022-04-13T15:42:05.901Z" } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "id", "gateway_pubkey", "endpoint_url", "active" ], "properties": { "id": { "type": "integer", "example": 1 }, "gateway_pubkey": { "type": "string", "minLength": 32, "maxLength": 44, "description": "Gateway authority public key." }, "endpoint_url": { "type": "string", "format": "uri", "description": "Webhook target URL." }, "active": { "type": "boolean", "example": true }, "created_at": { "type": "string", "format": "date-time" }, "updated_at": { "type": "string", "format": "date-time" } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ______________________________________________________________________ ### GET /v1/webhooks List webhooks Description Returns all webhooks, optionally filtered to active only and paginated. **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | ------------- | ----- | ------- | ------- | -------- | ----------------------------------------- | | `active_only` | query | boolean | False | No | When `true`, return only active webhooks. | | `limit` | query | integer | | No | | | `offset` | query | integer | | No | | **Responses** ```json [ { "id": 1, "gateway_pubkey": "string", "endpoint_url": "string", "active": true, "created_at": "2022-04-13T15:42:05.901Z", "updated_at": "2022-04-13T15:42:05.901Z" } ] ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "array", "items": { "$ref": "#/components/schemas/Webhook" } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ______________________________________________________________________ ### GET /v1/webhooks/gateway/{gatewayPubkey} List webhooks for a gateway Description Returns all webhooks registered for the given gateway. **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | --------------- | ----- | ------- | ------- | -------- | ----------- | | `active_only` | query | boolean | False | No | | | `gatewayPubkey` | path | string | | No | | **Responses** ```json [ { "id": 1, "gateway_pubkey": "string", "endpoint_url": "string", "active": true, "created_at": "2022-04-13T15:42:05.901Z", "updated_at": "2022-04-13T15:42:05.901Z" } ] ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "array", "items": { "$ref": "#/components/schemas/Webhook" } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ______________________________________________________________________ ### DELETE /v1/webhooks/gateway/{gatewayPubkey} Delete all webhooks for a gateway **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | --------------- | ---- | ------ | ------- | -------- | ----------- | | `gatewayPubkey` | path | string | | No | | **Responses** ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ______________________________________________________________________ ### GET /v1/webhooks/{id} Get a webhook by ID **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | --------- | ---- | ------- | ------- | -------- | ----------- | | `id` | path | integer | | No | | **Responses** ```json { "id": 1, "gateway_pubkey": "string", "endpoint_url": "string", "active": true, "created_at": "2022-04-13T15:42:05.901Z", "updated_at": "2022-04-13T15:42:05.901Z" } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "id", "gateway_pubkey", "endpoint_url", "active" ], "properties": { "id": { "type": "integer", "example": 1 }, "gateway_pubkey": { "type": "string", "minLength": 32, "maxLength": 44, "description": "Gateway authority public key." }, "endpoint_url": { "type": "string", "format": "uri", "description": "Webhook target URL." }, "active": { "type": "boolean", "example": true }, "created_at": { "type": "string", "format": "date-time" }, "updated_at": { "type": "string", "format": "date-time" } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ______________________________________________________________________ ### PUT /v1/webhooks/{id} Toggle a webhook's active flag **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | --------- | ---- | ------- | ------- | -------- | ----------- | | `id` | path | integer | | No | | **Request body** ```json { "active": true } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the request body ```json { "type": "object", "required": [ "active" ], "properties": { "active": { "type": "boolean" } } } ``` **Responses** ```json { "id": 1, "gateway_pubkey": "string", "endpoint_url": "string", "active": true, "created_at": "2022-04-13T15:42:05.901Z", "updated_at": "2022-04-13T15:42:05.901Z" } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "id", "gateway_pubkey", "endpoint_url", "active" ], "properties": { "id": { "type": "integer", "example": 1 }, "gateway_pubkey": { "type": "string", "minLength": 32, "maxLength": 44, "description": "Gateway authority public key." }, "endpoint_url": { "type": "string", "format": "uri", "description": "Webhook target URL." }, "active": { "type": "boolean", "example": true }, "created_at": { "type": "string", "format": "date-time" }, "updated_at": { "type": "string", "format": "date-time" } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ______________________________________________________________________ ### DELETE /v1/webhooks/{id} Delete a webhook **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | --------- | ---- | ------- | ------- | -------- | ----------- | | `id` | path | integer | | No | | **Responses** ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ## Tokens ______________________________________________________________________ ### POST /v1/tokens/issue Issue a short-lived JWT Description Validates that the caller has an active payment policy (any of the 5 PolicyType variants: Subscription, Milestone, PayAsYouGo, OneTime, UpTo) OR a recent payment transaction signature, and issues a JWT bound to the caller's wallet. The token carries a `policies[]` array of discriminated `PolicyClaim` objects (authorization proof) and a `lastPayments[]` array of recent `PaymentRecord` objects (payment proof). Consumers decide which aspect to require. Rate-limited to 200 requests per minute per wallet. **Request body** ```json { "walletPublicKey": "string", "tokenMint": "string", "policyAddress": "string", "recipient": "string", "transactionSignature": "string", "trackingId": "string" } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the request body ```json { "type": "object", "properties": { "walletPublicKey": { "type": "string", "minLength": 32, "maxLength": 44, "description": "Caller wallet public key (required unless `transactionSignature` is supplied)." }, "tokenMint": { "type": "string", "minLength": 32, "maxLength": 44, "description": "SPL token mint of the subscription." }, "policyAddress": { "type": "string", "minLength": 32, "maxLength": 44, "description": "Specific payment policy address." }, "recipient": { "type": "string", "minLength": 32, "maxLength": 44, "description": "Recipient wallet public key." }, "transactionSignature": { "type": "string", "pattern": "^[1-9A-HJ-NP-Za-km-z]{87,88}$", "description": "Base58 transaction signature of a recent payment." }, "trackingId": { "type": "string", "description": "Checkout tracking ID." } } } ``` **Responses** Schema of the response body ```json { "type": "object", "description": "Token bundle (shape defined by the token issuer service)." } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ## Subscriptions ______________________________________________________________________ ### GET /v1/subscriptions Look up subscription details Description Returns matching subscription policy records. Provide at least one filter (up to three combined). If `walletPublicKey` is given, `tokenMint` is also required. **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | ------------------ | ----- | ------ | ------- | -------- | ---------------------------------------------- | | `gatewayPublicKey` | query | string | | No | Gateway authority public key. | | `recipient` | query | string | | No | Recipient wallet public key. | | `tokenMint` | query | string | | No | SPL token mint. Defaults to USDC when omitted. | | `trackingId` | query | string | | No | Tracking ID assigned at checkout. | | `userPublicKey` | query | string | | No | User (owner) wallet public key. | | `walletPublicKey` | query | string | | No | User wallet public key (requires `tokenMint`). | **Responses** ```json { "success": true, "data": [ {} ], "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "success", "data", "timestamp" ], "properties": { "success": { "type": "boolean", "example": true }, "data": { "type": "array", "items": { "type": "object", "description": "Subscription policy record (gateway, recipient, schedule, status)." } }, "timestamp": { "type": "integer", "description": "Unix epoch ms." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ## Skill ______________________________________________________________________ ### GET /v1/skill/{encoded} Generate Lando skill markdown Description Decodes a base64-encoded checkout session, fetches mint decimals on-chain, converts the human-readable amount to its integer representation, and renders a `text/markdown` skill document the Lando agent uses to drive the subscription checkout. **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | --------- | ---- | ------ | ------- | -------- | ---------------------------------------------------------------------------- | | `encoded` | path | string | | No | Base64-encoded subscription parameters produced by `CheckoutSessionManager`. | **Responses** ```json "string" ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "string" } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ## Pools ______________________________________________________________________ ### GET /v1/pools/search Free-text search for liquidity pools Description Searches the cached pool index (Raydium, Whirlpool, and the Meteora live proxy) by token symbol/name/mint. Returns ranked REAL pools (stars DESC, tvl DESC) with token identity, fees, and tier-1 trust flags. Used by the Mill to resolve a template's `lane` to a concrete pool address. On error, returns `200` with `results: []` (ADR-0028 D3: empty, not 500). Redis-cached per (q, venue, limit). Rate-limited to 120 requests/min/IP. **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | --------- | ----- | ------- | ------- | -------- | ------------------------------------------------- | | `limit` | query | integer | 20 | No | Max results. | | `q` | query | string | | No | Free-text query (symbol, name, or mint). | | `venue` | query | string | | No | Restrict to one venue. Omit to search all venues. | **Responses** ```json { "success": true, "data": { "query": "string", "venue": "string", "results": [ { "address": "string", "venue": "meteora", "tokenX": { "mint": "string", "symbol": "string", "decimals": 0, "logoUri": "string", "tier": "string" }, "tokenY": { "mint": "string", "symbol": "string", "decimals": 0, "logoUri": "string", "tier": "string" }, "tvl": 10.12, "feeRate": 10.12, "stars": 0, "tier1": true, "extras": {} } ] }, "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "properties": { "success": { "type": "boolean", "example": true }, "data": { "type": "object", "properties": { "query": { "type": "string" }, "venue": { "type": "string", "nullable": true }, "results": { "type": "array", "items": { "type": "object", "properties": { "address": { "type": "string", "description": "Pool base58 address." }, "venue": { "type": "string", "enum": [ "meteora", "raydium", "whirlpool" ] }, "tokenX": { "type": "object", "properties": { "mint": { "type": "string" }, "symbol": { "type": "string", "nullable": true }, "decimals": { "type": "integer", "nullable": true }, "logoUri": { "type": "string", "format": "uri", "nullable": true }, "tier": { "type": "string", "nullable": true, "description": "tokens.xyz trust tier." } } }, "tokenY": { "type": "object", "properties": { "mint": { "type": "string" }, "symbol": { "type": "string", "nullable": true }, "decimals": { "type": "integer", "nullable": true }, "logoUri": { "type": "string", "format": "uri", "nullable": true }, "tier": { "type": "string", "nullable": true } } }, "tvl": { "type": "number", "nullable": true, "description": "USD TVL." }, "feeRate": { "type": "number", "nullable": true }, "stars": { "type": "integer", "description": "Trust ranking (0-5)." }, "tier1": { "type": "boolean", "description": "tokens.xyz tier-1 flag." }, "extras": { "type": "object", "description": "Venue-specific payload." } } } } } }, "timestamp": { "type": "integer" } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ## PaymentPolicies ______________________________________________________________________ ### GET /v1/payment-policies List payment policies Description Returns matching PaymentPolicy records. Provide at least one filter (up to three combined). If `walletPublicKey` is given, `tokenMint` is also required. Response shape mirrors `/v1/subscriptions`. **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | ------------------ | ----- | ------ | ------- | -------- | ---------------------------------------------- | | `gatewayPublicKey` | query | string | | No | Gateway authority public key. | | `recipient` | query | string | | No | Recipient wallet public key. | | `tokenMint` | query | string | | No | SPL token mint. Defaults to USDC when omitted. | | `trackingId` | query | string | | No | Tracking ID assigned at checkout (memo). | | `userPublicKey` | query | string | | No | User payment PDA public key. | | `walletPublicKey` | query | string | | No | User wallet public key (requires `tokenMint`). | **Responses** ```json { "success": true, "data": [ {} ], "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "success", "data", "timestamp" ], "properties": { "success": { "type": "boolean", "example": true }, "data": { "type": "array", "items": { "type": "object", "description": "Payment policy record (variant, schedule, gateway, recipient, status)." } }, "timestamp": { "type": "integer", "description": "Unix epoch ms." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ______________________________________________________________________ ### GET /v1/payment-policies/{address} Get a single payment policy Description Fetches a PaymentPolicy account directly from RPC by its on-chain address (PDA). **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | --------- | ---- | ------ | ------- | -------- | ------------------------------------ | | `address` | path | string | | No | PaymentPolicy account address (PDA). | **Responses** ```json { "success": true, "data": {}, "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "success", "data", "timestamp" ], "properties": { "success": { "type": "boolean", "example": true }, "data": { "type": "object", "description": "Normalized payment policy record." }, "timestamp": { "type": "integer", "description": "Unix epoch ms." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ______________________________________________________________________ ### GET /v1/payment-policies/{address}/executions Payment execution history for a policy Description Returns PaymentRecord events emitted for the given PaymentPolicy, newest first. Paginated via `limit` (default 100) and `offset` (default 0). **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | --------- | ----- | ------- | ------- | -------- | ------------------------------------ | | `address` | path | string | | No | PaymentPolicy account address (PDA). | | `limit` | query | integer | 100 | No | Maximum records to return. | | `offset` | query | integer | 0 | No | Number of records to skip. | **Responses** ```json { "success": true, "data": [ {} ], "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "success", "data", "timestamp" ], "properties": { "success": { "type": "boolean", "example": true }, "data": { "type": "array", "items": { "type": "object", "description": "A PaymentRecord event (signature, slot, timestamp, amount, gateway)." } }, "timestamp": { "type": "integer", "description": "Unix epoch ms." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ## OneTime ______________________________________________________________________ ### GET /v1/onetime/{trackingId} Look up one-time payment Description Returns one-time payment records for a tracking ID. Optionally filter by recipient and paginate with `limit` / `offset`. **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | ------------ | ----- | ------- | ------- | -------- | -------------------------------------- | | `limit` | query | integer | 100 | No | Maximum records to return. | | `offset` | query | integer | 0 | No | Number of records to skip. | | `recipient` | query | string | | No | Filter by recipient wallet public key. | | `trackingId` | path | string | | No | Tracking ID assigned at checkout. | **Responses** ```json { "success": true, "data": null, "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "success", "data", "timestamp" ], "properties": { "success": { "type": "boolean", "example": true }, "data": { "oneOf": [ { "type": "object", "description": "Single record (when exactly one matches)." }, { "type": "array", "items": { "type": "object" }, "description": "Multiple records." } ] }, "timestamp": { "type": "integer", "description": "Unix epoch ms." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ## JWKS ______________________________________________________________________ ### GET /v1/jwks JWKS (mounted under /.well-known too) Description Returns the JSON Web Key Set used to verify JWTs issued by `/v1/tokens/issue`. Cached publicly for 1 hour. Also served at `/.well-known/jwks.json` for OIDC-style discovery. **Responses** ```json { "keys": [ {} ] } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "keys" ], "properties": { "keys": { "type": "array", "items": { "type": "object", "description": "JWK (RFC 7517) — kid, kty, alg, use, crv, x, y." } } } } ``` **Response headers** | Name | Description | Schema | | --------------- | ----------- | ------ | | `Cache-Control` | | string | ## Health ______________________________________________________________________ ### GET /v1/health Health check Description Returns service liveness, name, and version. **Responses** ```json { "success": true, "data": { "status": "ok", "service": "tributary-api", "version": "1.9.0" }, "timestamp": 1719300000000 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "success", "data", "timestamp" ], "properties": { "success": { "type": "boolean", "example": true }, "data": { "type": "object", "required": [ "status", "service", "version" ], "properties": { "status": { "type": "string", "example": "ok" }, "service": { "type": "string", "example": "tributary-api" }, "version": { "type": "string", "example": "1.9.0" } } }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds.", "example": 1719300000000 } } } ``` ## GatewayAuth ______________________________________________________________________ ### POST /v1/gateway/{gateway}/auth/challenge Request a sign-in challenge **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | --------- | ---- | ------ | ------- | -------- | ----------- | | `gateway` | path | string | | No | | **Responses** ```json { "nonce": "string", "gateway": "string", "expiresAt": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "nonce", "gateway", "expiresAt" ], "properties": { "nonce": { "type": "string" }, "gateway": { "type": "string" }, "expiresAt": { "type": "integer" } } } ``` ______________________________________________________________________ ### POST /v1/gateway/{gateway}/auth/verify Verify wallet signature and issue a gateway JWT **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | --------- | ---- | ------ | ------- | -------- | ----------- | | `gateway` | path | string | | No | | **Request body** ```json { "signer": "string", "signature": [ 0 ] } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the request body ```json { "type": "object", "required": [ "signer", "signature" ], "properties": { "signer": { "type": "string", "description": "Base58 wallet pubkey" }, "signature": { "type": "array", "items": { "type": "integer" }, "description": "64-byte ed25519 signature over the nonce bytes" } } } ``` **Responses** ```json { "token": "string", "expiresIn": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "properties": { "token": { "type": "string" }, "expiresIn": { "type": "integer" } } } ``` ## GatewayMerchant ______________________________________________________________________ ### GET /v1/gateway/{gateway}/merchant/policies List policies under a gateway **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | ------------ | ------ | ------- | ------- | -------- | ----------- | | `bearerAuth` | header | string | N/A | No | | | `gateway` | path | string | | No | | | `limit` | query | integer | 100 | No | | | `offset` | query | integer | 0 | No | | **Responses** ```json { "items": [ {} ], "total": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "properties": { "items": { "type": "array", "items": { "type": "object" } }, "total": { "type": "integer" } } } ``` ## Events ______________________________________________________________________ ### GET /v1/events Query on-chain events Description Polymorphic event lookup. Exactly one filter mode is applied per request, evaluated in this priority order: `signature` → `slot` → `trackingId` → `eventName` → `startTime`/`endTime`. If none match, a generic `searchEvents` is run. **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | ------------ | ----- | ------- | ------- | -------- | ------------------------------------------------------------- | | `endTime` | query | string | | No | | | `eventName` | query | string | | No | Event name filter. | | `limit` | query | integer | 100 | No | | | `maxSlot` | query | integer | | No | | | `minSlot` | query | integer | | No | | | `offset` | query | integer | 0 | No | | | `signature` | query | string | | No | Transaction signature (returns a single event or 404). | | `slot` | query | integer | | No | Solana slot number. | | `startTime` | query | string | | No | | | `trackingId` | query | string | | No | Encoded memo tracking ID (matched via 64-byte memo encoding). | **Responses** Schema of the response body ```json { "oneOf": [ { "type": "object", "description": "Single event (when `signature` is supplied)." }, { "type": "array", "items": { "type": "object" }, "description": "Event list." } ] } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ______________________________________________________________________ ### GET /v1/events/count Count events Description Returns a count of events optionally filtered by name and/or time range. **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | ----------- | ----- | ------ | ------- | -------- | ----------- | | `endTime` | query | string | | No | | | `eventName` | query | string | | No | | | `startTime` | query | string | | No | | **Responses** ```json { "count": 42 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "count" ], "properties": { "count": { "type": "integer", "example": 42 } } } ``` ______________________________________________________________________ ### GET /v1/events/names All known event names Description Returns the distinct set of event names indexed in the database. **Responses** ```json [ "string" ] ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "array", "items": { "type": "string" } } ``` ______________________________________________________________________ ### GET /v1/events/names/tributary Tributary event names Description Returns the canonical set of event names emitted by the Tributary program. **Responses** ```json [ "string" ] ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "array", "items": { "type": "string" } } ``` ______________________________________________________________________ ### GET /v1/events/payments Payment records Description Returns `PaymentRecord` events optionally filtered by gateway and/or policy. **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | --------------- | ----- | ------- | ------- | -------- | ----------- | | `gateway` | query | string | | No | | | `limit` | query | integer | 100 | No | | | `offset` | query | integer | 0 | No | | | `paymentPolicy` | query | string | | No | | **Responses** ```json [ {} ] ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "array", "items": { "type": "object" } } ``` ______________________________________________________________________ ### GET /v1/events/payments/stats Payment statistics Description Aggregated payment statistics (count, volume) optionally filtered by gateway and/or time range. **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | ----------- | ----- | ------ | ------- | -------- | ----------- | | `endTime` | query | string | | No | | | `gateway` | query | string | | No | | | `startTime` | query | string | | No | | **Responses** Schema of the response body ```json { "type": "object" } ``` ______________________________________________________________________ ### GET /v1/events/policies/created PolicyCreated events Description Returns `PaymentPolicyCreated` events. **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | ------------- | ----- | ------- | ------- | -------- | ----------- | | `gateway` | query | string | | No | | | `limit` | query | integer | 100 | No | | | `offset` | query | integer | 0 | No | | | `recipient` | query | string | | No | | | `userPayment` | query | string | | No | | **Responses** ```json [ {} ] ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "array", "items": { "type": "object" } } ``` ______________________________________________________________________ ### GET /v1/events/policies/deleted PolicyDeleted events **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | --------------- | ----- | ------- | ------- | -------- | ----------- | | `limit` | query | integer | 100 | No | | | `offset` | query | integer | 0 | No | | | `owner` | query | string | | No | | | `paymentPolicy` | query | string | | No | | **Responses** ```json [ {} ] ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "array", "items": { "type": "object" } } ``` ______________________________________________________________________ ### GET /v1/events/policies/status-changed PolicyStatusChanged events **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | --------------- | ----- | ------- | ------- | -------- | ----------- | | `limit` | query | integer | 100 | No | | | `offset` | query | integer | 0 | No | | | `paymentPolicy` | query | string | | No | | **Responses** ```json [ {} ] ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "array", "items": { "type": "object" } } ``` ______________________________________________________________________ ### GET /v1/events/gateways/created GatewayCreated events **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | ----------- | ----- | ------- | ------- | -------- | ----------- | | `authority` | query | string | | No | | | `limit` | query | integer | 100 | No | | | `offset` | query | integer | 0 | No | | **Responses** ```json [ {} ] ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "array", "items": { "type": "object" } } ``` ______________________________________________________________________ ### GET /v1/events/gateways/deleted GatewayDeleted events **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | ----------- | ----- | ------- | ------- | -------- | ----------- | | `authority` | query | string | | No | | | `gateway` | query | string | | No | | | `limit` | query | integer | 100 | No | | | `offset` | query | integer | 0 | No | | **Responses** ```json [ {} ] ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "array", "items": { "type": "object" } } ``` ______________________________________________________________________ ### GET /v1/events/gateways/fee-bps-changed GatewayFeeBpsChanged events **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | --------- | ----- | ------- | ------- | -------- | ----------- | | `gateway` | query | string | | No | | | `limit` | query | integer | 100 | No | | | `offset` | query | integer | 0 | No | | **Responses** ```json [ {} ] ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "array", "items": { "type": "object" } } ``` ______________________________________________________________________ ### GET /v1/events/gateways/fee-recipient-changed GatewayFeeRecipientChanged events **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | --------- | ----- | ------- | ------- | -------- | ----------- | | `gateway` | query | string | | No | | | `limit` | query | integer | 100 | No | | | `offset` | query | integer | 0 | No | | **Responses** ```json [ {} ] ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "array", "items": { "type": "object" } } ``` ______________________________________________________________________ ### GET /v1/events/gateways/signer-changed GatewaySignerChanged events **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | --------- | ----- | ------- | ------- | -------- | ----------- | | `gateway` | query | string | | No | | | `limit` | query | integer | 100 | No | | | `offset` | query | integer | 0 | No | | **Responses** ```json [ {} ] ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "array", "items": { "type": "object" } } ``` ______________________________________________________________________ ### GET /v1/events/referrals/rewards ReferralRewardDistributed events **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | --------------- | ----- | ------- | ------- | -------- | ----------- | | `gateway` | query | string | | No | | | `limit` | query | integer | 100 | No | | | `offset` | query | integer | 0 | No | | | `paymentPolicy` | query | string | | No | | **Responses** ```json [ {} ] ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "array", "items": { "type": "object" } } ``` ______________________________________________________________________ ### GET /v1/events/user-payments/created UserPaymentCreated events **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | ----------- | ----- | ------- | ------- | -------- | ----------- | | `limit` | query | integer | 100 | No | | | `offset` | query | integer | 0 | No | | | `owner` | query | string | | No | | | `tokenMint` | query | string | | No | | **Responses** ```json [ {} ] ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "array", "items": { "type": "object" } } ``` ______________________________________________________________________ ### GET /v1/events/program/config-created ProgramConfigCreated events **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | --------- | ----- | ------- | ------- | -------- | ----------- | | `admin` | query | string | | No | | | `limit` | query | integer | 100 | No | | | `offset` | query | integer | 0 | No | | **Responses** ```json [ {} ] ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "array", "items": { "type": "object" } } ``` ______________________________________________________________________ ### GET /v1/events/typed/{eventName} Typed event lookup Description Returns strongly-typed events for the given Tributary event name. **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | ----------- | ----- | ------- | ------- | -------- | -------------------------------------------------------- | | `eventName` | path | string | | No | Tributary event name (see `/v1/events/names/tributary`). | | `limit` | query | integer | 100 | No | | | `offset` | query | integer | 0 | No | | **Responses** ```json [ {} ] ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "array", "items": { "type": "object" } } ``` ## ComposablePolicies ______________________________________________________________________ ### GET /v1/composable-policies List composable policies Description Returns matching ComposablePolicy records. Provide at least one filter (up to three combined). If `walletPublicKey` is given, `tokenMint` is also required. **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | ------------------ | ----- | ------ | ------- | -------- | ---------------------------------------------- | | `gatewayPublicKey` | query | string | | No | Gateway authority public key. | | `recipient` | query | string | | No | Recipient wallet public key. | | `tokenMint` | query | string | | No | SPL token mint. Defaults to USDC when omitted. | | `trackingId` | query | string | | No | Tracking ID (32-byte memo). | | `userPublicKey` | query | string | | No | User payment PDA public key. | | `walletPublicKey` | query | string | | No | User wallet public key (requires `tokenMint`). | **Responses** ```json { "success": true, "data": [ {} ], "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "success", "data", "timestamp" ], "properties": { "success": { "type": "boolean", "example": true }, "data": { "type": "array", "items": { "type": "object", "description": "Normalized composable policy record." } }, "timestamp": { "type": "integer", "description": "Unix epoch ms." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ______________________________________________________________________ ### GET /v1/composable-policies/{address} Get a single composable policy Description Fetches a ComposablePolicy account directly from RPC by its on-chain address (PDA). **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | --------- | ---- | ------ | ------- | -------- | --------------------------------------- | | `address` | path | string | | No | ComposablePolicy account address (PDA). | **Responses** ```json { "success": true, "data": {}, "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "success", "data", "timestamp" ], "properties": { "success": { "type": "boolean", "example": true }, "data": { "type": "object", "description": "Normalized composable policy record." }, "timestamp": { "type": "integer", "description": "Unix epoch ms." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ______________________________________________________________________ ### GET /v1/composable-policies/{address}/executions Composable execution history for a policy Description Returns ComposableExecuted events emitted for the given ComposablePolicy, newest first. Paginated via `limit` (default 100) and `offset` (default 0). **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | --------- | ----- | ------- | ------- | -------- | --------------------------------------- | | `address` | path | string | | No | ComposablePolicy account address (PDA). | | `limit` | query | integer | 100 | No | Maximum records to return. | | `offset` | query | integer | 0 | No | Number of records to skip. | **Responses** ```json { "success": true, "data": [ {} ], "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "success", "data", "timestamp" ], "properties": { "success": { "type": "boolean", "example": true }, "data": { "type": "array", "items": { "type": "object", "description": "A ComposableExecuted event (signature, slot, timestamp, input/output amounts, fees)." } }, "timestamp": { "type": "integer", "description": "Unix epoch ms." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ## Assets ______________________________________________________________________ ### GET /v1/assets/search Search the tokenized-asset catalog Description Server-side proxy to tokens.xyz `/assets/search`. Injects the upstream `x-api-key`; the browser never sees it. Returns a slim projection filtered to assets that carry a usable Solana SPL mint (no mint = no token account = no Tributary payment). On upstream error, returns `200` with `results: []` (empty state, not error state). Redis-cached per-query for 60s. Rate-limited to 120 requests/min/IP. **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | --------- | ----- | ------- | ------- | -------- | ----------------------------------------- | | `limit` | query | integer | 20 | No | Max results. | | `q` | query | string | | No | Search query (symbol, name, or asset id). | **Responses** ```json { "success": true, "data": { "query": "string", "results": [ null ] }, "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "properties": { "success": { "type": "boolean", "example": true }, "data": { "type": "object", "properties": { "query": { "type": "string" }, "results": { "type": "array", "items": { "$ref": "#/components/schemas/AssetSearchResult" } } } }, "timestamp": { "type": "integer" } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ______________________________________________________________________ ### GET /v1/assets/resolve Resolve a mint to asset metadata Description Server-side proxy to tokens.xyz `/assets/resolve`. Injects the upstream `x-api-key`. On upstream failure, falls back to the baked-in `MINT_OVERRIDES` map (USDC, SOL, USDT, mSOL, devnet USDC) so account balances never render as truncated mints. Redis-cached per-mint for 10min. Rate-limited to 120/min/IP. **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | --------- | ----- | ------ | ------- | -------- | ------------------- | | `mint` | query | string | | No | Solana base58 mint. | **Responses** ```json { "success": true, "data": { "mint": "string", "assetId": "string", "symbol": "string", "name": "string", "decimals": 0, "imageUrl": "string", "category": "string", "tier": "string" }, "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "properties": { "success": { "type": "boolean", "example": true }, "data": { "type": "object", "required": [ "mint", "assetId", "symbol", "name", "decimals", "imageUrl", "category" ], "properties": { "mint": { "type": "string", "description": "Solana base58 mint." }, "assetId": { "type": "string", "nullable": true }, "symbol": { "type": "string", "nullable": true, "description": "Token symbol, or null when no source (tokens.xyz/venue/on-chain) provides one; clients render a fallback." }, "name": { "type": "string", "nullable": true }, "decimals": { "type": "integer", "nullable": true }, "imageUrl": { "type": "string", "format": "uri", "nullable": true }, "category": { "type": "string", "nullable": true }, "tier": { "type": "string", "nullable": true, "description": "tokens.xyz trust tier." } } }, "timestamp": { "type": "integer" } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ## Admin ______________________________________________________________________ ### POST /v1/admin/keys/rotate Rotate the JWT signing key Description Generates a new signing key, promotes it as active, and keeps the previous key in the JWKS for a grace period so in-flight tokens continue to validate. Requires the `x-admin-key` header. **Input parameters** | Parameter | In | Type | Default | Nullable | Description | | ------------- | ------ | ------ | ------- | -------- | ---------------------------------------------------- | | `AdminApiKey` | header | string | N/A | No | Admin API key (ADMIN_API_KEY env var on the server). | **Responses** ```json { "message": "Key rotated successfully", "newKid": "string", "oldKid": "string", "gracePeriodEndsAt": "2022-04-13T15:42:05.901Z" } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "message", "newKid", "oldKid", "gracePeriodEndsAt" ], "properties": { "message": { "type": "string", "example": "Key rotated successfully" }, "newKid": { "type": "string", "description": "New active key ID." }, "oldKid": { "type": "string", "description": "Previous key ID (still in JWKS during grace)." }, "gracePeriodEndsAt": { "type": "string", "format": "date-time", "description": "ISO 8601 timestamp after which the old key is retired." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ```json { "success": false, "error": "string", "timestamp": 0 } ``` ⚠️ *This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information.* Schema of the response body ```json { "type": "object", "required": [ "error" ], "properties": { "success": { "type": "boolean", "example": false }, "error": { "type": "string" }, "timestamp": { "type": "integer", "description": "Unix epoch milliseconds." } } } ``` ______________________________________________________________________ ## Schemas ### Error | Name | Type | Description | | ----------- | ------- | ------------------------ | | `error` | string | | | `success` | boolean | | | `timestamp` | integer | Unix epoch milliseconds. | ### Webhook | Name | Type | Description | | ---------------- | ----------------- | ----------------------------- | | `active` | boolean | | | `created_at` | string(date-time) | | | `endpoint_url` | string(uri) | Webhook target URL. | | `gateway_pubkey` | string | Gateway authority public key. | | `id` | integer | | | `updated_at` | string(date-time) | | ## Security schemes | Name | Type | Scheme | Description | | ----------- | ------ | ------ | ---------------------------------------------------- | | AdminApiKey | apiKey | | Admin API key (ADMIN_API_KEY env var on the server). | ## Tags | Name | Description | | ------------- | ----------------------------------------------------------------------------------------------- | | Health | Service health probes | | Skill | Lando skill markdown generation | | Subscriptions | Recurring subscription lookups | | OneTime | One-time payment lookups | | Events | On-chain event queries | | Webhooks | Webhook endpoint management | | Tokens | JWT issuance for active payment policies (all 5 PolicyType variants) and direct payments | | JWKS | JWT key set publishing | | Admin | Administrative key rotation | | Gateway | Gateway merchant layer (auth + analytics) | | Assets | Tokenized-asset catalog proxy (tokens.xyz). Type-ahead search + mint resolver. | | Pools | Free-text liquidity-pool search over the cached index (Raydium, Whirlpool, Meteora live proxy). | ______________________________________________________________________ ## Authentication The API exposes a mix of public read endpoints and gateway-scoped write endpoints: - **Public reads** (e.g. `GET /subscriptions`, `GET /events/*`) require no authentication. - **Gateway writes** (e.g. webhook management) are authorized via the gateway signer key. - **JWT tokens** for checkout sessions are issued via the `/tokens` family — see the SDK's `jwt-auth` docs for client-side use. ______________________________________________________________________ ## Asset Catalog (`/v1/assets/*`) A public, IP-rate-limited (120/min) proxy to the tokens.xyz asset catalog. The upstream `x-api-key` is injected server-side — never shipped to the browser. Used by the type-ahead token picker in both `apps/app` and `apps/showcase-payment-policies` (ADR-0028). Responses are wrapped in the standard `ApiResponse` envelope. ### `GET /v1/assets/search?q=&limit=` Search the catalog for assets (stablecoins, LSTs, tokenized equities, …). Server filters out any result whose primary variant is missing or whose mint isn't a valid Solana base58 mint. | Param | Required | Default | Notes | | ------- | -------- | ------- | -------------------------- | | `q` | yes | — | Symbol, name, or asset id. | | `limit` | no | `20` | Clamped to `[1, 50]`. | **Failure stance:** on upstream error, returns `200` with `results: []` (empty state, not error state). Redis-cached per query for 60s. ```bash curl 'https://api.tributary.so/v1/assets/search?q=usdc&limit=5' ``` ```json { "success": true, "data": { "query": "usdc", "results": [ { "assetId": "usd", "symbol": "USDC", "name": "USD Coin", "category": "stablecoin", "imageUrl": null, "primaryVariant": { "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "decimals": 6, "kind": "native", "trustTier": "tier1" } } ] }, "timestamp": 1783070812696 } ``` ### `GET /v1/assets/resolve?mint=` Resolve a single Solana mint to its asset metadata. | Param | Required | Notes | | ------ | -------- | -------------------------------- | | `mint` | yes | Solana base58 mint, 32-44 chars. | **Failure stance:** on upstream error, falls back to the baked-in `MINT_OVERRIDES` map (USDC, SOL, USDT, mSOL, devnet USDC) so account balances never render as truncated mints. Returns `404` only when the mint is unknown to upstream **and** absent from the fallback map. Redis-cached per mint for 10min. ```bash curl 'https://api.tributary.so/v1/assets/resolve?mint=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' ``` ```json { "success": true, "data": { "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "assetId": "usd", "symbol": "USDC", "name": "USD Coin", "decimals": 6, "imageUrl": null, "category": "stablecoin" }, "timestamp": 1783070812696 } ``` ### Client package Browser consumers should use the shared `@tributary-so/tokens-client` package (pure fetch client + react-query hooks) rather than hand-rolling the envelope handling. See ADR-0028 for the design and `packages/tokens-client/src/` for the API. ______________________________________________________________________ ## SDK Integration For programmatic access, use the `@tributary-so/sdk` or `@tributary-so/payments` packages instead of raw HTTP calls where possible. See: - [TypeScript SDK](https://docs.tributary.so/integration-guide/pull-payments/sdk/index.md) - [Checkout Links](https://docs.tributary.so/integration-guide/pull-payments/checkout/index.md) - [JWT Auth](https://docs.tributary.so/integration-guide/pull-payments/jwt-auth/index.md) # Tributary — How We Test a Payment Protocol on Solana **194 test functions. 61 formal proofs. 21 property tests. 9 integration suites. 3 differential proptests against chrono. One model checker that found a bug no human would have caught.** This is the full inventory. ## The numbers | Layer | Count | What it exercises | How long | | ------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------ | | Rust unit tests (`#[test]` in `src/`) | 119 | Pure-function math: schedule logic, fee decomposition, referral topology, mint validation, policy-variant invariants | seconds | | Differential proptests (vs chrono) | 3 | Calendar-month arithmetic correctness against the industry-standard Rust date library | seconds | | Property-based tests (proptest) | 21 | Same properties as formal verification, but random sampling on the real code | 0.03s | | Kani BMC (impl-targeted) | 16 | Bounded model checking of the real `calculate_fees`, `validate_policy_execution`, `advance_policy`, `ByteRangeCheck::validate`, `validate_byte_ranges` for ALL symbolic inputs | 3s per linear proof, 10+ min per nonlinear | | Kani BMC (spec-model) | 61 active / 71 disabled | Model checking of the spec's effect formulas on a parallel state machine | 3-10s per harness | | Lean 4 theorems | scaffolded | Universal-quantified preservation proofs | blocked (codegen bug) | | Integration tests (Surfpool) | ~170 tests across 9 files | Full handler lifecycle: create, execute, delegate, pause, delete, referral chains, composable swaps, native-SOL unwrap, all 5 policy variants | minutes | | API tests (Jest, mocked) | 11 files | REST endpoints: token issuance, subscription filtering, OneTime details, rate limiting, JWKS, health | seconds | | SDK package tests | 8 files | x402 middleware, payment verification, checkout sessions | seconds | Total: **~460 individual test cases** across **7 verification layers**. ______________________________________________________________________ ## Layer 1 — Rust unit tests (119 tests, 12 files) The foundation. Every pure function in the protocol has inline tests next to its definition. ### Schedule math (`shared/schedule.rs` — 52 tests + 3 proptests) The largest test surface. Covers: - **Subscription**: calendar-month advancement (Jan 31 + 1 month = Feb 28/29, not Mar 3). Leap-year aware. Day-clamping at every step. `max_renewals` decrement and completion. `auto_renew` indefinite continuation. - **Milestone**: all 4 release-condition bitmap permutations (bit0 due-date, bit1 gateway signer, bit2 owner, bit3 recipient). Mutual exclusivity of bits 1-3. Wrong-caller rejection for each signer variant. - **PayAsYouGo**: chunk > 0 enforcement (L-01 regression). Chunk > `max_chunk_amount` rejection. Period-cap breach rejection. Accumulate vs reset semantics. - **OneTime**: immediate execution (`due_date <= 0`). Future due-date gating. Expiry enforcement. `provided_amount` ignored (fixed amount). - **UpTo**: zero settle permitted (x402 "no usage, no charge"). Settle above max rejected. `valid_after` gating. Strict `< deadline` (not `<=`). Always completes after one settlement. ### Fee math (`shared/fees.rs` — 6 tests) Gross/net mode split. Referral-disabled zeroing. Zero-shares-to-gateway. Residual-as-balancing-item. Overflow detection on `u64::MAX` input. ### Referral topology (`shared/referral.rs` — 9 tests) Depth-3 chain construction. Broken-link handling. Self-reference rejection. Empty/over-depth rejection. Payer binding. ### Token-2022 blocklist (`shared/mint.rs` — 8 tests) Rejects: permanent delegate, transfer hook, confidential transfer, transfer fee config, non-transferable, mint-close authority. This is the defense against Token-2022 extensions that could bypass the delegate model. ### Composable helpers (`instructions/composable/` — 13 tests across 2 files) ForwardConfig validation: disabled-forward requires same mint, NATIVE_OUTPUT requires WSOL output, data-check bounds. Byte-range validation logic. ### Policy variant validators (`policies/` — 17 tests across 4 files) Create-time invariants for each PolicyType variant. Zero-amount/deadline/interval rejection. Ordering constraints (expiry > due, deadline > valid_after). ______________________________________________________________________ ## Layer 2 — Differential proptests (3 properties) Located in `shared/schedule.rs:1485`. The calendar math is hand-rolled (no `chrono` dependency in the on-chain program — it's `no_std` incompatible). These proptests verify our implementation against `chrono`. 1. **`add_months` matches chrono exactly** — for all timestamps [epoch, year 2400] and n in [1,12]. Our manual year/month/day decomposition with day-clamping equals `chrono::checked_add_months`. 1. **`calculate_next_payment_due` is monotonic AND chrono-accurate** — result is strictly > now AND lands on the exact date chrono produces via iterative month-adding with per-step clamping. 1. **Iteration cap enforced** — `skip_months` bails at 1200 iterations with `ArithmeticOverflow`. ______________________________________________________________________ ## Layer 3 — Property-based tests (21 properties, 0.03s) `programs/tributary/tests/proptest_pure_fns.rs` Fast random-sampling counterpart to Kani. Same properties, non-exhaustive. 10,000 cases per property in under a second. | Category | Properties | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `calculate_fees` | Fee conservation (carve-outs sum to total_fee). Residual nonnegative. BPS decomposition. Gross mode identity. Net mode identity. Referral-disabled zeros pool. Overflow returns Err. | | `validate_policy_execution` | Rejects zero chunk. Rejects oversize chunk. Accepts valid chunk (returns it unchanged). | | `advance_policy` | PAYG never auto-completes. OneTime always completes. UpTo always completes. | | `ByteRangeCheck::validate` | Length > 8 rejects. In-bounds never panics. Matches correctly when expected == data. | | `validate_byte_ranges` | num_checks > checks.len() returns Err. | | `validate_forward_config` | Disabled requires same mint. Disabled + same mint + 0 checks is Ok. NATIVE_OUTPUT requires WSOL. | | Referral pool | Tier conservation: sum(tier_rewards) \<= referral_pool. | ______________________________________________________________________ ## Layer 4 — Kani bounded model checking (impl-targeted, 16 harnesses) `programs/tributary/tests/kani_pure_fns.rs` These call the real Rust functions directly. Not a spec model, not a mock. If someone edits `shared/fees.rs` and the math changes, these proofs break. ### What Kani does differently from proptest Proptest generates random concrete inputs. Kani generates **symbolic** inputs — it explores ALL possible values simultaneously. If a property holds for all u64 values, Kani proves it in one run. Proptest can only sample. The tradeoff: Kani is exhaustive but slow. A single nonlinear proof (fee multiplication) takes 10+ minutes. A linear proof (chunk bound check) takes 3 seconds. ### Passing proofs (9/16, all linear arithmetic) | Proof | Function | Property | | ------------------------------------------------------- | --------------------------- | -------------------------------------------------- | | `verify_payg_rejects_zero_chunk` | `validate_policy_execution` | Some(0) rejected for all inputs | | `verify_payg_pull_bounded` | `validate_policy_execution` | returned amount \<= max_chunk_amount | | `verify_payg_rejects_period_breach` | `validate_policy_execution` | chunk that breaches period cap rejected | | `verify_payg_advance_preserves_cap` | `advance_policy` | A2: period_total \<= max after reset or accumulate | | `verify_onetime_advance_completes` | `advance_policy` | returns true for all inputs | | `verify_upto_advance_completes` | `advance_policy` | returns true for all inputs | | `verify_calculate_fees_max_input_no_panic` | `calculate_fees` | u64::MAX input: no panic, no UB | | `verify_byte_range_check_rejects_length_above_eight` | `ByteRangeCheck::validate` | H-06 regression: length > 8 returns false | | `verify_validate_byte_ranges_rejects_excess_num_checks` | `validate_byte_ranges` | H-04 regression: num_checks > len returns Err | ### Slow proofs (7/16, nonlinear fee arithmetic) These exercise `bps_mul` which does `amount * bps / 10000` via `checked_mul`/`checked_div`. Kani must explore the full branch tree of the checked arithmetic. Each takes 10+ minutes. ### The bug Kani found ```text schedule.rs:359: if current_time >= *current_period_start + *period_length_seconds as i64 { ``` The bare `+` overflows `i64` when `period_length_seconds` is near `u64::MAX`. In debug mode: panic. In release mode: silent wraparound. The period-reset comparison then evaluates against garbage. Kani found this because it explores ALL `u64` values for `period_length_seconds`, not just "reasonable" ones. No unit test or proptest would have caught it — the probability of randomly sampling `period_length_seconds > i64::MAX` is effectively zero. Fix: `saturating_add`. ______________________________________________________________________ ## Layer 5 — Kani spec-model (61 active / 71 disabled) `formal_verification/kani.rs` Generated by QEDGen from `tributary.qedspec`. Tests the spec's effect formulas on a parallel State struct. Does NOT call the real Anchor code. ### Why a separate layer? The spec-model layer catches a different class of bug: **spec-internal inconsistency**. If the spec's effect formula for `total_fee` doesn't preserve `fee_conservation`, that's a spec bug — the formula is wrong before any code is written. The impl-targeted layer (Layer 4) can't catch this because it doesn't know what the spec says. ### The 71 disabled harnesses Fee multiplication in the spec model uses `mul_div_floor_u128` — a u128 operation. CBMC (Kani's solver) encodes 128-bit multiplication as ~16K boolean gates. The SAT reduction is O(n^2) in bit-width. It does not terminate. Not slow — never finishes. The fee-conservation property holds by construction (`gateway_residual = total_fee - cuts`, so the sum is algebraically `total_fee`). The disabled harnesses would confirm this symbolically. The same guarantee is available via Layer 3 (proptest) and Layer 4 (impl Kani on the real `calculate_fees`). ### Drift gates Two handlers (`create_payment_policy`, `transfer`) carry `#[qed(verified, spec_hash=..., hash=...)]` attributes. The `qedgen-macros` proc macro hashes the spec's handler block and the real Rust fn body at compile time. Any drift without re-running `qedgen adapt` produces `compile_error!`. ______________________________________________________________________ ## Layer 6 — Integration tests (9 files, ~9,700 lines) `tests/*.test.ts` All run against Surfpool (Solana mainnet-fork simulator). These test the handler/account/CPI surface that formal verification cannot reach — PDA derivation, Anchor account constraints, token transfers, Lighthouse validation CPI, Meteora DLMM swap CPI. ### `tributary.test.ts` (4,296 lines, ~90 tests) The monster suite. Covers: - Program initialization (admin setup, frontrun protection) - User payment creation + delegate approval (UserPayment PDA + legacy global delegate) - Gateway lifecycle: create, change signer, change fee recipient, change fee bps, update protocol fee, update feature flags, update referral settings - Subscription: create, execute (with delegate), pause, resume, delete, max_renewals completion, auto_renew indefinite - Milestone: all 4 release-condition bitmap permutations, all signer combinations, due-date gating, multi-milestone progression - PayAsYouGo: chunk claiming, period-cap exhaustion, period reset, multi-period sequences - Referral program: L1/L2/L3 chains, disabled referral, broken chain handling - Transfer instruction (standalone, ADR-0004) - Delegate migration (global PDA to UserPayment PDA) - Full account cleanup (close user payment, close gateway) ### `composable.test.ts` (1,885 lines, ~20 tests) ComposablePolicy lifecycle: create with/without validation, forward/validation program allowlist, ByteRangeCheck bounds, status changes, delete (with ValidationPDA close), execute byte-range failure, C-1 regression (Subscription rejects forward_amount), B2/B3 regressions. ### Topup test suite (3 files, ~2,100 lines) | File | What it tests | | ---------------------------------------- | --------------------------------------------------------------------------------------------- | | `topup-balance.test.ts` (661 lines) | Same-mint topup (forward disabled). Lighthouse balance guard. Sentinel-disabled forward path. | | `topup-balance-swap.test.ts` (728 lines) | USDC to WSOL via Meteora DLMM swap. Forward CPI with instruction data. Period-cap exhaustion. | | `topup-balance-sol.test.ts` (709 lines) | USDC to WSOL to native SOL via `NATIVE_OUTPUT` flag. `closeAccount` unwrap sweep. | ### Policy-variant suites (2 files, ~1,200 lines) | File | What it tests | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `one-time-payment.test.ts` (623 lines) | OneTime (ADR-0019): direct PaymentPolicy (create, execute, Completed transition, re-exec blocked, due/expiry gating) + composable with Lighthouse guard | | `up-to-policy.test.ts` (568 lines) | UpTo (ADR-0020): settle below max, settle at max, settle above max fails, settle zero, valid_after/deadline gating, recipient-triggerable, re-settle blocked | ### Scheduler evaluator (1 file, 334 lines) Pure TypeScript unit tests for the composable scheduler's assertion evaluator: `parseAssertionFamily`, `applyIntegerOperator` (12 parametric cases), `evaluateAssertion` (accountInfo.lamports, tokenAccount.amount), `isScheduleReady` (all policy types). ### Surfpool smoke test (1 file, 166 lines) Mainnet-fork harness shakedown: fund keypairs, mint USDC, create gateway + user payment. ______________________________________________________________________ ## Layer 7 — Package-level tests (19 files) ### `packages/sdk-x402/` (3 files, 988 lines) x402 HTTP-402 payment integration: Payment-Required header construction, `upto` scheme ceiling enforcement, `settleUpTo` delegation, middleware request flow, OpenAI token metering, usage tracking. ### `packages/payments/` (5 files, 1,441 lines) Payment verification: JWT payload verification, subscription/payment claims, `TributaryVerifier` with real `jose` keypairs, JWKS fetch over HTTP, full verify round-trip. Checkout session URL encode/decode. `PaymentsClient` constructor and checkout.session.create. ### `apps/api/` (11 files) REST API: token issuance validation, subscription filtering (3-filter limit, special chars), OneTime details with pagination, rate limiting (per-wallet, window reset), JWKS endpoint, health check. ______________________________________________________________________ ## The gap map What each layer covers and what it doesn't: | Concern | Covered by | Gap | | ----------------------------------------------- | -------------------------------------------------- | ----------------------------------------------------- | | Pure-function math (fees, schedule, validation) | Unit tests + proptest + Kani + differential chrono | None | | Spec-internal consistency | Spec-model Kani (Layer 5) | Lean proofs blocked on codegen bug | | Real-code correctness | Impl Kani (Layer 4) + proptest (Layer 3) | 5 nonlinear fee proofs slow (10+ min each) | | Handler/account wiring | Surfpool integration tests | Not formally verified (Anchor Context wall) | | CPI boundary (Lighthouse, Meteora) | Integration tests (swap, topup, native-SOL) | Not formally verified (callee program responsibility) | | Signer sanitization (ADR-0008) | Integration tests + manual review | Not formally verified | | Spec to code drift | Drift gates on 2 handlers | 4 handlers unmapped (match-arm name mismatch) | ______________________________________________________________________ ## Conclusion The testing pyramid for Tributary, bottom to top: - **119 unit tests** pin every pure function - **3 differential proptests** pin the calendar math against chrono - **21 property tests** sample the real code in milliseconds - **16 Kani proofs** exhaustively verify the real code for all inputs - **61 spec-model proofs** verify the spec is self-consistent - **~170 integration tests** exercise the full handler/CPI surface - **2 drift gates** make spec-code drift a compile error The bug Kani found — an `i64` overflow in a period guard — would not have been caught by any other layer. The 71 disabled harnesses (u128 SAT wall) are a hard limit of formal verification. The solver literally cannot finish. The workaround is layering: proptest for speed, Kani for depth, integration tests for coverage. No single layer is sufficient. Together, they are. # Common Errors Build- and runtime-error troubleshooting for integrators using the Tributary SDK and on-chain program. ## Overview This page collects the error messages an integrator is most likely to hit — Anchor compile errors, delegate-approval failures, `next_payment_due` gating, and composable CPI reverts — and pairs each with a root-cause explanation and the smallest fix. Use it as the first stop before the full [Error Codes](https://docs.tributary.so/protocol-reference/error-codes/index.md) reference. # Environments Devnet and mainnet-beta program IDs, committed RPC endpoints, and cluster feature flags. ## Overview This page lists every Tributary deployment the integrator can target. It pairs each cluster with its program ID, a recommended RPC endpoint, and any behavioural differences (e.g. fee caps, paused instructions) so that integrators can pin the right configuration per environment. # Quickstart A five-minute walkthrough that creates a devnet subscription and executes a payment end-to-end. ## Overview This page will guide a new integrator from zero to a working pull-payment on devnet. It is the fastest path to verify the Tributary SDK against the live program and is the recommended entry point after reading the home page. # Integration Options Tributary offers multiple ways to integrate automated payments. Choose the method that fits your use case. ## Integration Methods ### 1. Payments SDK 🛒 Best for: Quick checkout links with zero API keys - Use `@tributary-so/payments` for simplified payments via hosted checkout page - Generate shareable payment URLs - Track subscription and one-time payment status - Zero configuration required ```typescript import { PaymentsClient } from "@tributary-so/payments"; const payments = new PaymentsClient(connection, tributary); const session = await payments.checkout.sessions.create({ mode: "subscription", line_items: [{ description: "Pro Plan", unitPrice: 10, quantity: 1 }], paymentFrequency: "monthly", tributaryConfig: { gateway, recipient, trackingId }, }); ``` 👉 **Get Started:** [Checkout](https://docs.tributary.so/integration-guide/pull-payments/checkout/index.md) ______________________________________________________________________ ### 2. Direct SDK Integration 💻 Best for: Full programmatic control and custom flows - Use `@tributary-so/sdk` for complete protocol interaction - Build custom payment UI and logic - Full control over transaction construction - Support for all payment types (subscriptions, milestones, pay-as-you-go) ```typescript import { Tributary } from "@tributary-so/sdk"; const tributary = new Tributary(connection, wallet); const instructions = await tributary.createSubscriptionInstruction(/*...*/); ``` 👉 **Get Started:** [SDK Reference](https://docs.tributary.so/integration-guide/pull-payments/sdk/index.md) ______________________________________________________________________ ### 3. React SDK ⚛️ Best for: Fast integration in React applications - Use `@tributary-so/sdk-react` for pre-built components and hooks - Drop-in payment buttons (subscription, milestone, pay-as-you-go) - React hooks for full control over the payment flow - Built-in wallet integration, transaction handling, and error states - Ideal for web apps and dashboards ```tsx import { SubscriptionButton, PaymentInterval } from "@tributary-so/sdk-react"; ; ``` Or use hooks for custom UI: ```tsx import { useCreateSubscription } from "@tributary-so/sdk-react"; const { createSubscription, loading, error } = useCreateSubscription(); const result = await createSubscription({ amount, recipient, gateway, interval, ... }); ``` 👉 **Get Started:** [React SDK](https://docs.tributary.so/integration-guide/pull-payments/sdk-react/index.md) ______________________________________________________________________ ### 4. REST API 📡 Best for: Backend integration and real-time notifications - Query subscription status and payment events - No SDK required - pure HTTP ```bash # Get subscription status curl "https://api.tributary.so/v1/subscriptions?trackingId=my-sub" ``` 👉 **Get Started:** [API Overview](https://docs.tributary.so/api/rest-api/index.md) ______________________________________________________________________ ### 5. x402 HTTP Payments 🌐 Best for: API monetization and micropayments - Express.js middleware for HTTP 402 payments - Subscription or pay-as-you-go billing - JWT-based authenticated access - Standards-compliant HTTP payment protocol ```typescript import { createX402Middleware } from "@tributary-so/x402"; app.use( "/api/premium", createX402Middleware({ scheme: "deferred", amount: 100, recipient: process.env.RECIPIENT!, }) ); ``` 👉 **Get Started:** [x402 Overview](https://docs.tributary.so/integration-guide/pull-payments/x402/overview/index.md) ______________________________________________________________________ ## Choosing the Right Integration | Use Case | Method | | ------------------------ | ------------ | | Share payment links | Payments SDK | | AI agent monetization | Payments SDK | | Custom payment UI | Direct SDK | | Complex payment logic | Direct SDK | | React web app | React SDK | | Backend-only integration | REST API | | API monetization | x402 | | Real-time notifications | REST API | ## SDK Packages | Package | Purpose | | ------------------------- | -------------------------- | | `@tributary-so/sdk` | Core protocol interaction | | `@tributary-so/payments` | Simplified payments SDK | | `@tributary-so/sdk-react` | React components and hooks | | `@tributary-so/x402` | HTTP 402 middleware | | `@tributary-so/cli` | Command-line tools | ## Next Steps 1. **Learn the Protocol:** [Tributary Overview](https://docs.tributary.so/index.md) 1. **Choose Your Integration:** Review quickstart guides above 1. **JWT Authentication:** [Verify subscriptions after checkout](https://docs.tributary.so/integration-guide/pull-payments/jwt-auth/index.md) 1. **Explore Payment Types:** [Subscriptions](https://docs.tributary.so/protocol-reference/payment-policy/subscription/index.md), [Milestones](https://docs.tributary.so/protocol-reference/payment-policy/milestone/index.md), [Pay-as-you-go](https://docs.tributary.so/protocol-reference/payment-policy/payasyougo/index.md), [OneTime](https://docs.tributary.so/protocol-reference/payment-policy/onetime/index.md), [UpTo](https://docs.tributary.so/protocol-reference/payment-policy/upto/index.md) 1. **Build:** Check [use cases](https://docs.tributary.so/use-cases/index.md) for inspiration ## Need Help? - 📖 [SDK Reference](https://docs.tributary.so/integration-guide/pull-payments/sdk/index.md) - 📖 [API Reference](https://docs.tributary.so/api/rest-api/index.md) - ❓ [FAQ](https://docs.tributary.so/faq/index.md) - 💬 [Discord](https://discord.gg/tributary) # Guide: Forward CPI Integration A composable policy can transform the pulled token before delivering it to the recipient — pull USDC, swap to SOL via Meteora DLMM, deliver SOL. This guide shows how to wire any allowlisted forward program into a composable policy and pin it so only the exact instruction you approve can run. ## Why constraints exist The forward step is a CPI — Tributary calls an external program (`invoke_signed`) with caller-supplied instruction data and accounts. Without constraints, a malicious gateway could substitute any instruction on the allowlisted program (e.g. a Meteora "withdraw" instead of "swap"). Tributary solves this with `InstructionConstraint`: you pin the instruction **selector** (first 8 bytes) and optionally pin specific **account positions** to concrete pubkeys. At execute time, the program validates both before the CPI fires. ## The three pieces ```text ForwardConfig ├── instructionConstraint: InstructionConstraint │ ├── programId: PublicKey ← must be in ALLOWED_FORWARD_PROGRAMS │ ├── dataChecks: ByteRangeCheck[4] ← pin the instruction selector │ └── pinnedAccounts: PinnedAccount[2] ← pin specific account slots ├── inputMint: PublicKey ← the user's token (what's pulled) ├── outputMint: PublicKey ← what the recipient receives └── forwardFlags: u8 ← bit0 = native SOL unwrap ``` Currently `ALLOWED_FORWARD_PROGRAMS` contains **Meteora DLMM**, **Raydium CPMM**, **Raydium CLMM**, and **Orca Whirlpool** (see ADR-0032 and `programs/tributary/src/constants.rs`). ## Step 1: Extract the discriminator Every Anchor/SPL instruction starts with an 8-byte discriminator (the first 8 bytes of `instruction.data`). Pin those bytes at offset 0 to lock the instruction type. ```typescript import DLMM from "@meteora-ag/dlmm"; const METEORA_DLMM_PUBKEY = new PublicKey( "LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo" ); // Build the swap ix once with a dummy user — you only need the data layout const dummyIx = await buildSwapIx(PublicKey.default); // The first 8 bytes are the Anchor instruction discriminator const discriminator = dummyIx.data.slice(0, 8); ``` ## Step 2: Build the InstructionConstraint At least one `ByteRangeCheck` must pin offset 0. The `dataChecks` array is fixed-size (4 entries); pad unused slots with zero-length checks. ```typescript const forwardConfig = { instructionConstraint: { programId: METEORA_DLMM_PUBKEY, numDataChecks: 1, dataChecks: [ { offset: 0, length: 8, expected: Buffer.from(discriminator) }, { offset: 0, length: 0, expected: Buffer.alloc(8) }, // unused { offset: 0, length: 0, expected: Buffer.alloc(8) }, // unused { offset: 0, length: 0, expected: Buffer.alloc(8) }, // unused ], numPinnedAccounts: 0, pinnedAccounts: [ { index: 0, pubkey: PublicKey.default }, { index: 0, pubkey: PublicKey.default }, ], // fixed-size [PinnedAccount; 2] — must have 2 entries even when unused }, inputMint: USDC_MINT, outputMint: NATIVE_MINT, forwardFlags: 0, }; ``` At least one pin required when forward is enabled A forward-enabled constraint with zero effective pins is rejected at create (`DegenerateForwardPins`). If you leave `numPinnedAccounts: 0`, you must also disable forward (`programId: PublicKey.default`). To enable forward without pinning a specific account, pin any required account (typically the pool) at its index — see below. ### PinnedAccounts (required when forward is enabled) Pin specific pubkeys to specific slots in the forward-account slice. At least one pin must be active when `programId != PublicKey.default`: ```typescript // Suppose the DLMM pool is at index 2 in the swap instruction's accounts pinnedAccounts: [ { index: 2, pubkey: DLMM_POOL }, { index: 0, pubkey: PublicKey.default }, // pad to [PinnedAccount; 2] ], numPinnedAccounts: 1, ``` At execute time, Tributary checks `remaining_accounts[fwd_base + pin.index].pubkey == pin.pubkey` for each active pin. No duplicate indices allowed. ### Cold-relayer safety net (ADR-0016) If the execute caller is not the gateway signer, the user, or the recipient (a "cold relayer" / pure scheduler), Tributary additionally requires **either** `post_validation` is `ProgramCall` **or** the forward constraint has at least one active pin (a "route pin"). This blocks the obvious "scheduler drains arbitrary output to its own ATA" attack. A bare PayAsYouGo policy with no validation and no pins is only executable by the trusted three. ## Step 3: The three settlement shapes `outputMint` controls what happens after the forward CPI: | `outputMint` | Forward | Shape | Behaviour | | ----------------------------- | -------------------------------- | ------------------------ | ---------------------------------------------------------------------------------- | | `== inputMint` | disabled (`programId = default`) | **deliver-no-transform** | sweep input directly to recipient | | `!= inputMint`, concrete mint | enabled | **deliver-transform** | swap → sweep output to recipient | | `PublicKey.default()` | enabled | **act mode** | no output ATA, no sweep — forward acts on the input (e.g. deposit to a subaccount) | ## Step 4: Execute — the forward accounts At execute time, the caller supplies the live forward instruction data and the forward accounts. The forward accounts come from the swap instruction's `keys` — map them to `AccountMeta`: ```typescript const swapIx = await buildSwapIx(composablePolicyPDA); // real PDA as user const forwardAccounts = swapIx.keys.map((k) => ({ pubkey: k.pubkey, isSigner: false, // Tributary strips all is_signer from forward accounts isWritable: true, // mark all writable — safe, avoids stale-IDL mismatches })); // remaining_accounts = [validation targets..., forward accounts...] const remainingAccounts = [ ...guard.accounts, // Lighthouse targets (empty if no validation) ...forwardAccounts, // DLMM swap accounts ]; const execIxs = await sdk.executeComposable( composablePolicyPDA, Buffer.from(swapIx.data), // the raw instruction data (selector must match) new anchor.BN(amount), // pull amount (null for subscription) remainingAccounts ); ``` Signer sanitization Tributary forces `isSigner: false` on ALL forward (and validation) accounts. This prevents the fee payer — a Signer — from granting signer authority to the forward program via remaining_accounts. Do not attempt to pass signer flags; they are stripped. ## Common patterns ### Meteora DLMM swap See the [Swap & Deliver example](https://docs.tributary.so/integration-guide/programmable-pull-payments/examples/swap-and-deliver/index.md) and the [Auto-DCA quickstart](https://docs.tributary.so/integration-guide/programmable-pull-payments/quickstarts/auto-dca/index.md) for complete working code, including the `hostFeeIn` fix (rewrite the SystemProgram placeholder → DLMM program id). ### Native SOL delivery (WSOL unwrap) Set `forwardFlags = 1` (`FORWARD_FLAG_NATIVE_OUTPUT`) to unwrap WSOL to native SOL via `closeAccount` after the swap. The recipient gets native SOL in their system account — no WSOL ATA needed. See the [Native SOL topup example](https://docs.tributary.so/integration-guide/programmable-pull-payments/examples/native-sol-topup/index.md). ### Same-mint topup (no swap) When `inputMint === outputMint` and forward is disabled (`programId = PublicKey.default()`), the policy is **deliver-no-transform**: the pull goes straight to the recipient with no forward CPI. See the [AI agent budget quickstart](https://docs.tributary.so/integration-guide/programmable-pull-payments/quickstarts/ai-agent-budget/index.md). ## Building forward instructions (ForwardBuilder) The manual `swapIx.keys.map(...)` block above works, but it duplicates logic that the scheduler and CLI also need (validation-target resolution, account assembly, PayAsYouGo face→gross math). To kill that duplication, `@tributary-so/sdk` exports the shared primitives and a `ForwardBuilder` interface; concrete implementations (Meteora DLMM first) live in the opt-in `@tributary-so/forward-builders` package. ```typescript import { createMeteoraDlmmForward } from "@tributary-so/forward-builders"; import { isForwardEnabled, resolveValidationTargets, assembleComposableRemainingAccounts, resolveDefaultForwardAmount, } from "@tributary-so/sdk"; // 1. Resolve the face amount (handles PayAsYouGo gross→face; null for fixed-amount variants) const face = resolveDefaultForwardAmount(policy, gateway); // 2. Build the forward instruction (or skip if forward is disabled) const fwd = isForwardEnabled(policy) ? await createMeteoraDlmmForward({ pool, slippageBps: 100 }).build({ connection, policy, composablePolicyPda: composablePolicyPDA, face, }) : { instructionData: Buffer.alloc(0), forwardAccounts: [] }; // 3. Assemble remaining_accounts in ADR-0016 order: [pre, forward, post] const remaining = assembleComposableRemainingAccounts({ preTargets: await resolveValidationTargets( connection, composablePolicyPDA, policy.preValidation, validationProgramId, "pre" ), forwardAccounts: fwd.forwardAccounts, postTargets: await resolveValidationTargets( connection, composablePolicyPDA, policy.postValidation, validationProgramId, "post" ), }); const execIxs = await sdk.executeComposable( composablePolicyPDA, fwd.instructionData, face, remaining ); ``` ### Why the builder returns `{ pubkey, isWritable }[]` The builder does **not** return `isSigner`. The assembler (`assembleComposableRemainingAccounts`) stamps `isSigner: false` on every account. This is the ADR-0008 privilege boundary enforced at the type level: a builder cannot leak signer authority because the `ForwardAccountMeta` type has no field to carry it. Per-account `isWritable` comes from the forward program's own account list (e.g. DLMM's `swapIx.keys`), not a blanket `true`. See [ADR-0030](https://docs.tributary.so/adr/0030-composable-execution-primitives.md) for the full rationale (primitives-not-orchestrator, sibling-package structure, assembler owns ADR-0008). ## Checklist before you ship - [ ] `programId` is in `ALLOWED_FORWARD_PROGRAMS` - [ ] At least one `ByteRangeCheck` pins offset 0 (the discriminator) - [ ] `outputMint` matches your intended settlement shape - [ ] Forward accounts at execute time use per-account `isWritable` from the forward program (or use a `ForwardBuilder` from `@tributary-so/forward-builders`, which handles this for you) - [ ] Swap-level slippage (`minOutAmount` in the swap ix) is set — or use [post-validation](https://docs.tributary.so/integration-guide/programmable-pull-payments/lighthouse-facade/index.md) as an output floor ## Related - [Forward Hook reference](https://docs.tributary.so/protocol-reference/composable-policy/forward-hook/index.md) — on-chain mechanics - [Allowlists & Sentinels](https://docs.tributary.so/protocol-reference/composable-policy/allowlists-and-sentinels/index.md) — disabled forward - [SDK reference](https://docs.tributary.so/integration-guide/programmable-pull-payments/sdk/index.md) — `ForwardConfig` type # The Lighthouse Facade Lighthouse is a read-only on-chain assertion program. Tributary stores a serialized Lighthouse assertion in a `ValidationPda` and replays it via CPI at `execute_composable` time — the transaction reverts if the assertion doesn't hold. The SDK ships a fluent builder that wraps the vendored official `lighthouse-sdk-legacy` client so you never have to touch umi types. Import: ```typescript import { lighthouse, LIGHTHOUSE_PROGRAM_ID } from "@tributary-so/sdk"; ``` Program ID `LIGHTHOUSE_PROGRAM_ID = new PublicKey("L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95")` is the only entry in Tributary's `ALLOWED_VALIDATION_PROGRAMS`. ## The fluent shape ```typescript const guard = lighthouse .tokenAccount(hotWalletUsdcAta) // pick the assertion family .amount(50_000_000, "<") // chain field assertions .build(); // serialize ``` `build()` returns: ```typescript interface LighthouseAssertion { data: Buffer; // → passed as validationData to getCreateComposablePolicyInstruction numAccounts: number; // → passed as numValidationAccounts accounts: AccountMeta[]; // → the validation slice of executeComposable's remaining_accounts } ``` The facade owns **only** the Lighthouse `target_account(s)`. The caller assembles Tributary's full `remaining_accounts`: ```typescript // Caller assembles remaining_accounts = validation targets + forward accounts: const remainingAccounts = [...guard.accounts, ...forwardAccounts]; ``` ## Operators Every numeric field accepts either a string alias or the matching enum. Strings are preferred for readability. ```typescript import { IntegerOperator, EquatableOperator } from "@tributary-so/sdk"; ``` | String alias | `IntegerOperator` | Meaning | | ----------------------- | -------------------- | ---------------- | | `"=="` / `"==="` | `Equal` | equal | | `"!="` / `"!=="` | `NotEqual` | not equal | | `">"` | `GreaterThan` | greater than | | `"<"` | `LessThan` | less than | | `">="` | `GreaterThanOrEqual` | greater-or-equal | | `"<="` | `LessThanOrEqual` | less-or-equal | | `"in"` / `"contains"` | `Contains` | membership | | `"!in"` / `"!contains"` | `DoesNotContain` | non-membership | `EquatableOperator` accepts the same `==` / `===` / `!=` / `!==` strings. ## Assertion families | Family | Method | Target accounts | Common fields | | -------------------------------- | --------------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | **SPL token account** | `lighthouse.tokenAccount(ata)` | 1 | `amount`, `mint`, `owner`, `delegate`, `state`, `isNative`, `delegatedAmount`, `closeAuthority`, `ownerIsDerived` | | **SPL mint account** | `lighthouse.mintAccount(mint)` | 1 | `mintAuthority`, `supply`, `decimals`, `isInitialized`, `freezeAuthority` | | **Account info** | `lighthouse.accountInfo(pubkey)` | 1 | `lamports`, `dataLength`, `owner`, `rentEpoch`, `isSigner`, `isWritable`, `executable` | | **Account data (raw bytes)** | `lighthouse.accountData(pubkey)` | 1 | `.at(offset, type, value, op)` — typed value at a byte offset (`Bool`, `U8`, `I8`, `U16`/`I16`, `U32`/`I32`, `U64`/`I64`, `U128`/`I128`) | | **Account delta (two accounts)** | `lighthouse.accountDelta(a, b)` | 2 | `.accountInfo(aOffset, value, op)` — no multi variant | | **Sysvar clock** | `lighthouse.sysvarClock()` | 0 | `.field(field, value, op)` — `Slot`, `EpochStartTimestamp`, `Epoch`, `LeaderScheduleEpoch`, `UnixTimestamp` | | **Stake account** | `lighthouse.stakeAccount(pubkey)` | 1 | `state`, `stakeFlags` | | **Merkle tree account** | `lighthouse.merkleTree(pubkey)` | 1 | `.verifyLeaf(leafIndex, leafHash)` — no multi variant | ### Multi-assertions (single target) For `tokenAccount`, `mintAccount`, `accountInfo`, `accountData`, and `stakeAccount`, chaining multiple field assertions produces the compact `*Multi` instruction (saves space + compute vs. multiple single CPIs): ```typescript import { lighthouse } from "@tributary-so/sdk"; const guard = lighthouse .tokenAccount(hotWalletUsdcAta) .amount(50_000_000, "<") .state(2, "!=") // not frozen (1 = initialized, 2 = frozen) .build(); // numAccounts = 1, single CPI to AssertTokenAccountMulti ``` ### Raw account-data assertion For arbitrary on-chain accounts whose layout isn't covered by a typed family: ```typescript const guard = lighthouse .accountData(someAccount) .at(64, "U64", 1_000_000n, ">=") // bigint for 64/128-bit .build(); ``` ### Two-account delta ```typescript const guard = lighthouse .accountDelta(treasuryA, treasuryB) .accountInfo(0, 1_000_000_000n, ">=") // lamports delta .build(); // numAccounts = 2 ``` ### Sysvar clock (no target accounts) ```typescript const guard = lighthouse .sysvarClock() .field("UnixTimestamp", 1_700_000_000n, ">") .build(); // numAccounts = 0, accounts = [] ``` Validation data is capped at 512 bytes The `ValidationPda` is allocated to fit `MAX_VALIDATION_DATA_SIZE`. Complex multi-assertions that exceed 512 bytes will be rejected at policy creation. Prefer a single targeted assertion (e.g. balance `< threshold`) over exhaustive checks. ## Putting it together ```typescript import { Tributary, lighthouse, LIGHTHOUSE_PROGRAM_ID, } from "@tributary-so/sdk"; // 1. Build the assertion const guard = lighthouse .tokenAccount(hotWalletUsdcAta) .amount(50_000_000, "<") .build(); // 2. Create the policy — pre-validation enabled via program-call, forward disabled const createIx = await sdk.getCreateComposablePolicyInstruction( USDC_MINT, recipient, gatewayPDA, policyType, "Balance guard", forwardConfig, { programCall: { programId: LIGHTHOUSE_PROGRAM_ID } }, // preValidation guard.accounts, // prePinnedAccounts guard.data // preValidationData // postValidation defaults to disabled ); // 3. Execute — forward is disabled, so remaining_accounts is just guard.accounts const [execIx] = await sdk.executeComposable( composablePolicyPDA, Buffer.alloc(0), new BN(50_000_000), guard.accounts ); ``` ## Related - [SDK surface](https://docs.tributary.so/integration-guide/programmable-pull-payments/sdk/index.md) — full `getCreateComposablePolicyInstruction` / `executeComposable` signatures. - Example: [Auto-topup guard](https://docs.tributary.so/integration-guide/programmable-pull-payments/examples/auto-topup-guard/index.md). - Lighthouse source → `packages/sdk/src/lighthouse.ts`. # Programmable Pull Payments A **ComposablePolicy** is a pull payment that runs two optional hooks **between** the pull and the settlement: 1. **Validation** — a read-only on-chain assertion (via Lighthouse) that can veto the transaction if a condition isn't met. 1. **Forward** — a token-transform step (via Meteora DLMM, Raydium CPMM/CLMM, or Orca Whirlpool) that swaps the pulled input token into a different output token before delivery. Both hooks are **opt-in via sentinel values**. A composable policy with both disabled behaves like a `PaymentPolicy` but lives in its own PDA namespace and routes through an intermediate ATA hop. Both families reuse the same `PolicyType` enum (`Subscription` / `Milestone` / `PayAsYouGo`), the same `UserPayment` account, and the same fee-distribution logic. ## The lifecycle: pull → skim → pre-validate → forward → post-validate → settle ``` graph TD Pull["1. PULL
UserPayment PDA signs:
user_token_account → intermediate_input_ata"] Skim["2. SKIM (input-side)
Protocol + gateway + scheduler fees
deducted from intermediate_input_ata.
Remaining = face amount for forward."] PreVal["3. PRE-VALIDATE (optional)
CPI into Lighthouse with stored
assertion data + read-accounts.
Fails the tx if assertion doesn't hold."] Fwd["4. FORWARD (optional)
CPI into Meteora DLMM:
swap intermediate_input_ata → intermediate_output_ata.
ByteRangeChecks pin the swap selector."] PostVal["5. POST-VALIDATE (optional)
CPI into Lighthouse with stored
assertion data + read-accounts.
Guards minimum output after forward."] Settle["6. SETTLE
Sweep intermediate_output → recipient.
Fees already skimmed in phase 2 —
settle moves only principal."] Pull --> Skim --> PreVal --> Fwd --> PostVal --> Settle classDef phase fill:#e3f2fd,stroke:#1565c0,stroke-width:2px class Pull,Skim,PreVal,Fwd,PostVal,Settle phase ``` Key invariants enforced on-chain: - **Intermediate ATAs are owned by the `ComposablePolicy` PDA**, not the `UserPayment` PDA. This decouples the intermediate signing authority from the user-source delegate — a forward program can only ever move transient intermediate balances, never the user's source funds. - **Signer sanitization**: validation and forward CPI builders do NOT forward `is_signer` from `remaining_accounts`. The fee payer (a Signer) cannot be re-passed to grant Lighthouse / DLMM unintended signer authority. - **Allowlists** (`programs/tributary/src/constants.rs`): - `ALLOWED_FORWARD_PROGRAMS` → Meteora DLMM, Raydium CPMM, Raydium CLMM, Orca Whirlpool (4 entries) - `ALLOWED_VALIDATION_PROGRAMS` → Lighthouse (`L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95`) - **Emergency pause** (`ProgramConfig.emergency_pause`) blocks `execute_composable` just like `execute_payment`. ## Settlement shapes — `output_mint` controls delivery | Shape | `output_mint` | Forward | Behaviour | | ------------------------ | ------------------------ | -------- | ------------------------------------------------------------------- | | **deliver-no-transform** | `== input_mint` | disabled | Same-mint topup. Sweep `intermediate_input` → recipient. | | **deliver-transform** | concrete mint `!= input` | enabled | Swap input → output, sweep output → recipient (`>0` guard kept). | | **act mode** | `Pubkey::default()` | enabled | Forward consumes input for non-fungible settlement (no output ATA). | See [forward-cpi-guide.md](https://docs.tributary.so/integration-guide/programmable-pull-payments/forward-cpi-guide/index.md) and ADR-0026 for details. ## The sentinel convention | Hook you want to disable | Sentinel value | | ------------------------ | -------------------------------------------------------------------------------------------------- | | No validation | `ValidationSpec::Disabled` (SDK: `{ disabled: {} }`) | | No forward | `forward_config.instruction_constraint.program_id == PublicKey.default` (static getter, no parens) | The SDK accepts `{ disabled: {} }` as the `ValidationSpec` for both `preValidation` and `postValidation`. When forward is disabled, `instruction_constraint.data_checks` should be empty and `input_mint` must equal `output_mint` (no conversion step — it's a same-mint pull → sweep). ## When to choose ComposablePolicy vs PaymentPolicy | Use case | Choose | Reason | | ---------------------------------------------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | Plain subscription, milestone, or pay-as-you-go | **PaymentPolicy** | One CPI: `transfer user → recipient + fees`. Cheapest, simplest. | | Auto-topup a hot wallet when its balance drops below a threshold | **ComposablePolicy** | Needs a Lighthouse assertion to veto the pull when the balance is fine. | | Pull USDC from user, deliver WSOL to recipient | **ComposablePolicy** | Needs a Meteora DLMM forward between the pull and the settle. | | Pull tokens only if an oracle / on-chain state condition holds | **ComposablePolicy** | Lighthouse can assert on any readable account (token account, mint, sysvar clock, raw account data). | | "Pull X, deliver native SOL" (unwrap WSOL automatically) | **ComposablePolicy** | Forward to WSOL + `FORWARD_FLAG_NATIVE_OUTPUT` bit → `closeAccount` ships SOL to the recipient's system wallet. | | Same-mint topup, no swap, no guard | **PaymentPolicy** | A composable with both hooks disabled is functionally equivalent but pays the PDA-hop overhead. Use `PaymentPolicy` unless you need the extension points. | ## Where to next - [SDK surface](https://docs.tributary.so/integration-guide/programmable-pull-payments/sdk/index.md) — `getCreateComposablePolicyInstruction`, `executeComposable`, `changeComposableStatus`, `ForwardConfig`, `ValidationSpec`. - [Lighthouse facade](https://docs.tributary.so/integration-guide/programmable-pull-payments/lighthouse-facade/index.md) — build assertions with `lighthouse.tokenAccount(ata).amount(threshold, "<").build()`. - Examples: [Auto-topup guard](https://docs.tributary.so/integration-guide/programmable-pull-payments/examples/auto-topup-guard/index.md) · [Swap & deliver](https://docs.tributary.so/integration-guide/programmable-pull-payments/examples/swap-and-deliver/index.md) · [Native SOL topup](https://docs.tributary.so/integration-guide/programmable-pull-payments/examples/native-sol-topup/index.md). - Deep technical reference → [Protocol Reference → Composable Policy](https://docs.tributary.so/protocol-reference/composable-policy/overview/index.md). # Composable Policy SDK Surface The TypeScript SDK exposes a low-level instruction builder and an executor for composable policies. Both live on the main `Tributary` class from `@tributary-so/sdk`. ```bash pnpm install @tributary-so/sdk @solana/web3.js @solana/spl-token @coral-xyz/anchor ``` ```typescript import { Tributary, lighthouse, LIGHTHOUSE_PROGRAM_ID, } from "@tributary-so/sdk"; import { BN } from "@coral-xyz/anchor"; import { PublicKey, SystemProgram, Transaction } from "@solana/web3.js"; const sdk = new Tributary(connection, wallet.payer); ``` ## `getCreateComposablePolicyInstruction()` Creates a `ComposablePolicy` PDA and (optionally) a `ValidationPda` that stores the Lighthouse assertion data. ```typescript async getCreateComposablePolicyInstruction( tokenMint: PublicKey, recipient: PublicKey, gateway: PublicKey, policyType: PolicyType, // { subscription | milestone | payAsYouGo } memo: string, // free-form, max 32 bytes (SDK encodes it) forwardConfig: ForwardConfig, // instructionConstraint.programId = Pubkey.default() disables forward preValidation: ValidationSpec = { disabled: {} }, prePinnedAccounts: PublicKey[] = [], preValidationData: Buffer = Buffer.alloc(0), // from lighthouse.<...>.build().data postValidation: ValidationSpec = { disabled: {} }, postPinnedAccounts: PublicKey[] = [], postValidationData: Buffer = Buffer.alloc(0), feePayer?: PublicKey // defaults to provider wallet ): Promise ``` Counter separation `ComposablePolicy` IDs come from `user_payment.created_composable_count` — **independent** from `PaymentPolicy` IDs (`created_policies_count`). A regular policy `#1` and a composable policy `#1` can coexist on the same `UserPayment`. ### Minimal example — same-mint topup, validation enabled ```typescript import { Tributary, lighthouse, LIGHTHOUSE_PROGRAM_ID, } from "@tributary-so/sdk"; import { PublicKey } from "@solana/web3.js"; import { BN } from "@coral-xyz/anchor"; const guard = lighthouse .tokenAccount(hotWalletUsdcAta) .amount(50_000_000, "<") // 50 USDC threshold .build(); const policyType = { payAsYouGo: { maxAmountPerPeriod: new BN(100_000_000), // 100 USDC / month maxChunkAmount: new BN(50_000_000), // 50 USDC / call periodLengthSeconds: new BN(30 * 24 * 3600), currentPeriodStart: new BN(Math.floor(Date.now() / 1000)), currentPeriodTotal: new BN(0), padding: new Array(88).fill(0), }, }; // Forward disabled: sentinel programId = PublicKey.default(). // num_data_checks MUST be 0 (no forward instruction to byte-range validate). // dataChecks must still be a full [4]-array of zeroed entries (fixed-size). const forwardConfig = { instructionConstraint: { programId: PublicKey.default(), numDataChecks: 0, dataChecks: [ { offset: 0, length: 0, expected: [0, 0, 0, 0, 0, 0, 0, 0] }, { offset: 0, length: 0, expected: [0, 0, 0, 0, 0, 0, 0, 0] }, { offset: 0, length: 0, expected: [0, 0, 0, 0, 0, 0, 0, 0] }, { offset: 0, length: 0, expected: [0, 0, 0, 0, 0, 0, 0, 0] }, ], numPinnedAccounts: 0, pinnedAccounts: [ { index: 0, pubkey: PublicKey.default }, { index: 0, pubkey: PublicKey.default }, ], }, inputMint: USDC_MINT, outputMint: USDC_MINT, // must equal inputMint when forward disabled forwardFlags: 0, }; const ix = await sdk.getCreateComposablePolicyInstruction( USDC_MINT, hotWallet.publicKey, // recipient gatewayPDA, // gateway policyType, "Auto topup guard", forwardConfig, { programCall: { programId: LIGHTHOUSE_PROGRAM_ID } }, // preValidation [hotWalletUsdcAta], // prePinnedAccounts guard.data // preValidationData // postValidation defaults to { disabled: {} } // postPinnedAccounts defaults to [] // postValidationData defaults to Buffer.alloc(0) ); ``` ## `executeComposable()` Permissionless — any gateway signer (or the user, or the recipient) can call it. The caller supplies the forward instruction data and the full `remaining_accounts` list. ```typescript async executeComposable( composablePolicy: PublicKey, instructionData: Buffer, // forward program ix data (empty Buffer if forward disabled) forwardAmount?: BN | null, // amount to pull through the forward step remainingAccounts?: AccountMeta[] ): Promise ``` ### `remaining_accounts` layout ```text remaining_accounts = [ ...guard.accounts // Lighthouse read-accounts (pre + post validation targets) , ...forwardAccounts // forward program accounts (empty if forward disabled) ] ``` ValidationPda is in `accountsStrict`, not `remaining_accounts` The `preValidationPda` and `postValidationPda` are already declared in the `accountsStrict` map on the execute instruction. Pass only the Lighthouse read-accounts (from `guard.accounts`) and the forward program accounts. ### Minimal example — validation only (no forward) ```typescript import { Tributary } from "@tributary-so/sdk"; import { Buffer } from "buffer"; import { BN } from "@coral-xyz/anchor"; // Forward disabled → instruction_data is unused by the program. Pass empty. const instructionData = Buffer.alloc(0); // remaining_accounts = guard.accounts (Lighthouse read-accounts; ValidationPda is in accountsStrict). const remainingAccounts = guard.accounts; // [{ pubkey: hotWalletUsdcAta, isSigner: false, isWritable: false }] const [ix] = await sdk.executeComposable( composablePolicyPDA, instructionData, new BN(50_000_000), // forward amount (the pull size) remainingAccounts ); ``` ### With forward enabled — caller supplies swap ix data + pool accounts ```typescript // Build the Meteora DLMM swap ix (user = ComposablePolicy PDA, which owns both // intermediates). Keep ONLY the swap instruction. const swapIx = await buildSwapIx(composablePolicyPDA); const forwardAccounts = swapIx.keys.map((k) => ({ pubkey: k.pubkey, isSigner: false, isWritable: true, // see swap-and-deliver.md for the writability rationale })); const remainingAccounts = [ ...guard.accounts, // validation read-accounts (empty array if no validation) ...forwardAccounts, ]; const [ix] = await sdk.executeComposable( composablePolicyPDA, Buffer.from(swapIx.data), // the forward program instruction data new BN(SWAP_INPUT_AMOUNT), remainingAccounts ); ``` ## Read methods The SDK exposes four read-only helpers for querying `ComposablePolicy` accounts. They mirror the `PaymentPolicy` read pattern but use the `composablePolicy` account type with adjusted memcmp offsets. ```typescript // Fetch a single composable policy by its address (null if not found) const policy: ComposablePolicy | null = await sdk.getComposablePolicy( policyAddress ); // All composable policies for a given UserPayment PDA const policies = await sdk.getComposablePoliciesByUserPayment(userPaymentPda); // All composable policies for a given gateway const policies = await sdk.getComposablePoliciesByGateway(gatewayPda); // All composable policies on-chain (unfiltered) const all = await sdk.getAllComposablePolicies(); ``` ### memcmp offsets `ComposablePolicy` has a different field order from `PaymentPolicy` — the `bump: u8` sits right after the discriminator, shifting every subsequent field by 1 byte: | Field | Offset | Size | | -------------- | ------ | ---- | | `user_payment` | 9 | 32 | | `gateway` | 41 | 32 | Recipient filtering is not supported via memcmp — `recipient` sits deep in the struct after variable-size enums (`ValidationSpec`, `ForwardConfig`). ### Field differences from `PaymentPolicy` | `PaymentPolicy` | `ComposablePolicy` | Notes | | --------------- | ------------------ | ------------------------------------ | | `total_paid` | `total_input` | Gross tokens pulled from the user | | — | `total_output` | Tokens delivered to recipient | | — | `rent_payer` | Account that paid rent | | — | `forward_config` | Forward hook config (program, mints) | | — | `pre_validation` | Pre-forward validation spec | | — | `post_validation` | Post-forward validation spec | ## Type reference ### `ForwardConfig` ```typescript type ForwardConfig = { instructionConstraint: InstructionConstraint; inputMint: PublicKey; // must == user_payment.token_mint outputMint: PublicKey; // recipient delivery mint; Pubkey.default() = act mode forwardFlags: number; // bit 0 = FORWARD_FLAG_NATIVE_OUTPUT }; ``` ### `InstructionConstraint` ```typescript type InstructionConstraint = { programId: PublicKey; // Pubkey.default() = forward disabled (sentinel) numDataChecks: number; // 0 if forward disabled, else 1..4 dataChecks: ByteRangeCheck[]; // length 4 (fixed-size); pin selector at offset 0 numPinnedAccounts: number; pinnedAccounts: PinnedAccount[]; // length 2 (fixed-size); indexed forward-account pins }; ``` ### `PinnedAccount` ```typescript type PinnedAccount = { index: number; pubkey: PublicKey; }; ``` ### `ByteRangeCheck` ```typescript type ByteRangeCheck = { offset: number; // byte offset into the forward instruction data length: number; // 0..=8 (expected is a [u8; 8]) expected: number[]; // length-8 array; only the first `length` bytes are checked }; ``` ### `ValidationSpec` ```typescript type ValidationSpec = | { disabled: {} } | { programCall: { programId: PublicKey } } | { inline: { reserved: number } }; ``` Rules enforced on-chain (`validate_forward_config`): - `programId == Pubkey.default()` → `num_data_checks` MUST be `0`, `pinned_accounts` MUST be empty, and `input_mint` MUST equal `output_mint`. - Otherwise `programId` MUST be in `ALLOWED_FORWARD_PROGRAMS`, and at least one `ByteRangeCheck` MUST pin bytes at `offset: 0, length: > 0` (discriminator coverage). - `FORWARD_FLAG_NATIVE_OUTPUT` (bit 0) → `output_mint` MUST be `NATIVE_MINT`. The assertion **data** (≤512 bytes) is NOT stored inline — it lives in separate `ValidationPda` accounts (`["composable_validation_pre", composable_policy]` and `["composable_validation_post", composable_policy]`) that the create handler initializes via `invoke_signed`. ## Disabling the hooks | Hook | Disabling recipe | | ---------- | ---------------------------------------------------------------------------------------------------------------------- | | Forward | `forwardConfig.instructionConstraint.programId = PublicKey.default()`, `numDataChecks = 0`, `inputMint === outputMint` | | Validation | `ValidationSpec = { disabled: {} }` (the default for both `preValidation` and `postValidation`) | Why `Pubkey::default()` instead of the Token program? The same-mint topup (no swap) used to be modelled by setting the forward target to the SPL Token program. That opened a drain vector: the forward `AccountMeta` list's `to` account is not validated, so a gateway could redirect the sweep. The sentinel pattern makes "no forward step" unambiguous and safe. ## Related - [Overview](https://docs.tributary.so/integration-guide/programmable-pull-payments/overview/index.md) — concept and lifecycle. - [Lighthouse facade](https://docs.tributary.so/integration-guide/programmable-pull-payments/lighthouse-facade/index.md) — building the assertion buffer. - [Protocol Reference → Composable Policy](https://docs.tributary.so/protocol-reference/composable-policy/overview/index.md) for on-chain constraints, error codes, and the full account layout. # Example: Auto-Topup Guard **Use case.** A hot wallet (the recipient) must stay above a USDC threshold so its operator can keep paying gas, refilling positions, etc. A cold wallet (the user) funds the topup. The topup should only fire when the hot wallet's USDC balance drops **below** the threshold — not on a fixed schedule. This is a composable policy with: - **Validation enabled** — Lighthouse asserts `hotWalletUsdcAta.amount < threshold`. - **Forward disabled** — same-mint (USDC → USDC) pull → sweep. No swap needed. ``` graph LR Cold["coldWallet
(user, funding source)"] -->|"pull USDC
(UserPayment PDA signs)"| Inter["intermediate USDC ATA
(owned by ComposablePolicy PDA)"] Val["Lighthouse CPI:
hotWalletUsdcAta.amount < 50 USDC?"] -.->|"assertion holds → continue
fails → tx reverts"| Inter Inter -->|"sweep USDC"| Hot["hotWallet
(recipient)
+ protocol fee + gateway fee"] classDef user fill:#e8f5e8,stroke:#1b5e20 classDef pda fill:#e3f2fd,stroke:#1565c0 classDef val fill:#fff3e0,stroke:#e65100 class Cold,Hot user class Inter pda class Val val ``` ## Prerequisites - A `UserPayment` PDA for `coldWallet` + `USDC_MINT`. The cold wallet must approve the `UserPayment` PDA as delegate on its USDC ATA with sufficient `delegated_amount`. - A `PaymentGateway` with a signer (the executor). - An SPL token account for `hotWallet` in USDC — Lighthouse will read it. ```typescript import * as anchor from "@coral-xyz/anchor"; import { PublicKey, SystemProgram, Transaction, sendAndConfirmTransaction, } from "@solana/web3.js"; import { getAssociatedTokenAddressSync } from "@solana/spl-token"; import { Tributary, lighthouse, LIGHTHOUSE_PROGRAM_ID, } from "@tributary-so/sdk"; import { Buffer } from "buffer"; const THRESHOLD = 50_000_000; // 50 USDC (6 decimals) const TOPUP_CHUNK = 50_000_000; // 50 USDC per execute call const USDC_MINT = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); const hotWalletUsdcAta = getAssociatedTokenAddressSync( USDC_MINT, hotWallet.publicKey ); ``` ## 1. Build the Lighthouse assertion ```typescript const guard = lighthouse .tokenAccount(hotWalletUsdcAta) .amount(THRESHOLD, "<") .build(); // guard.data → Buffer (the serialized Lighthouse instruction data) // guard.numAccounts → 1 // guard.accounts → [{ pubkey: hotWalletUsdcAta, isSigner: false, isWritable: false }] ``` ## 2. Create the composable policy Forward disabled: `instructionConstraint.programId = PublicKey.default()`. Because there's no swap step, `num_data_checks` must be `0` and `input_mint === output_mint`. ```typescript const policyType = { payAsYouGo: { maxAmountPerPeriod: new anchor.BN(100_000_000), // 100 USDC / month cap maxChunkAmount: new anchor.BN(TOPUP_CHUNK), // 50 USDC / call periodLengthSeconds: new anchor.BN(30 * 24 * 3600), currentPeriodStart: new anchor.BN(Math.floor(Date.now() / 1000)), currentPeriodTotal: new BN(0), expiryDate: null, padding: new Array(79).fill(0), }, }; const forwardConfig = { instructionConstraint: { programId: PublicKey.default(), numDataChecks: 0, dataChecks: [ { offset: 0, length: 0, expected: [0, 0, 0, 0, 0, 0, 0, 0] }, { offset: 0, length: 0, expected: [0, 0, 0, 0, 0, 0, 0, 0] }, { offset: 0, length: 0, expected: [0, 0, 0, 0, 0, 0, 0, 0] }, { offset: 0, length: 0, expected: [0, 0, 0, 0, 0, 0, 0, 0] }, ], numPinnedAccounts: 0, pinnedAccounts: [], }, inputMint: USDC_MINT, outputMint: USDC_MINT, forwardFlags: 0, }; const createIx = await sdk.getCreateComposablePolicyInstruction( USDC_MINT, hotWallet.publicKey, gatewayPDA, policyType, "Auto topup guard", forwardConfig, { programCall: { programId: LIGHTHOUSE_PROGRAM_ID } }, [hotWalletUsdcAta], guard.data ); await sendAndConfirmTransaction( connection, new Transaction().add(createIx), [hotWallet, coldWallet], // recipient is the fee_payer, user must sign too { commitment: "processed" } ); ``` Why PayAsYouGo? A `PayAsYouGo` policy caps both per-call (`max_chunk_amount`) and per-period (`max_amount_per_period`) spend. For topup logic you typically want the topup to fire only when needed (Lighthouse gate) and to stop once the period budget is exhausted — exactly the PayAsYouGo semantics. ## 3. Execute (permissionless) The executor is any gateway signer (or the user / recipient). Forward disabled → `instructionData` is unused; pass an empty buffer. `remaining_accounts` = the Lighthouse read-accounts (`guard.accounts`). The ValidationPdas are resolved from the composable policy account — no need to pass them. ```typescript const { address: composablePolicyPDA } = sdk.getComposablePolicyPda( userPaymentPDA, composablePolicyId ); const execIxs = await sdk.executeComposable( composablePolicyPDA, Buffer.alloc(0), new anchor.BN(TOPUP_CHUNK), guard.accounts ); await sendAndConfirmTransaction( connection, new Transaction().add(...execIxs), [coldWallet], { commitment: "processed" } ); ``` ## What happens on-chain 1. **Pull** — `UserPayment` PDA signs a `transfer` from `coldWalletUsdcAta` into the `intermediate_input_ata` (owned by the `ComposablePolicy` PDA). The pull is gross: `face + fees`. 1. **Skim fees** — Protocol fee + gateway fee are routed from the `intermediate_input_ata` to their respective fee accounts. After skimming, the intermediate holds exactly the `face` amount. 1. **Pre-validate** — Tributary CPIs Lighthouse with `guard.data` + `[hotWalletUsdcAta]` as read-accounts. Lighthouse asserts `hotWalletUsdcAta.amount < 50 USDC`. If the hot wallet is at or above the threshold, the assertion fails and the **whole transaction reverts** — no funds move. 1. **Settle (deliver-no-transform)** — Because forward is disabled, the program sweeps the remaining USDC from the intermediate to `hotWalletUsdcAta`. ## Failure modes | Condition | Outcome | | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------- | | `hotWalletUsdcAta.amount >= threshold` | Lighthouse assertion fails → tx reverts, nothing moves. | | PayAsYouGo period cap exhausted (`current_period_total + chunk > max_amount_per_period`) | `validate_policy_execution` rejects before the Lighthouse CPI. | | Insufficient delegate amount on `coldWalletUsdcAta` | Pull fails with `InsufficientDelegatedAmount`. | | `ProgramConfig.emergency_pause == true` | `execute_composable` fails with `ProgramPaused`. | ## Reference - Working test: `tests/topup-balance.test.ts` (runs against Surfpool). - [Lighthouse facade](https://docs.tributary.so/integration-guide/programmable-pull-payments/lighthouse-facade/index.md) — every assertion family. - [SDK surface](https://docs.tributary.so/integration-guide/programmable-pull-payments/sdk/index.md) — `getCreateComposablePolicyInstruction` / `executeComposable` signatures. # Example: Native SOL Topup (WSOL → SOL unwrap) **Use case.** Same as [Swap & Deliver](https://docs.tributary.so/integration-guide/programmable-pull-payments/examples/swap-and-deliver/index.md), but the recipient wants to be paid in **native SOL**, not wrapped SOL. Most recipient wallets (exchanges, custody, treasuries) don't want to deal with unwrapping WSOL themselves. This is the swap-and-deliver flow plus one flag bit: `ForwardConfig.forwardFlags & FORWARD_FLAG_NATIVE_OUTPUT`. When set, the post-swap sweep does **not** `transfer_checked` into the recipient's WSOL ATA — it `closeAccount`s the intermediate WSOL ATA directly into the recipient's **system wallet**, shipping the WSOL value as native SOL. ``` graph LR Cold["coldWallet
(user, USDC)"] -->|"pull USDC (gross)"| In["intermediate USDC ATA"] In -->|"skim fees (USDC, input-side)"| Fees["fee ATAs
(protocol + gateway)"] In -->|"Meteora DLMM swap"| Out["intermediate WSOL ATA
(owned by ComposablePolicy PDA)"] Out -->|"closeAccount
(FORWARD_FLAG_NATIVE_OUTPUT)"| Hot["hotWallet
(recipient, SYSTEM wallet)
receives native SOL"] classDef user fill:#e8f5e8,stroke:#1b5e20 classDef pda fill:#e3f2fd,stroke:#1565c0 classDef native fill:#fce4ec,stroke:#880e4f class Cold,Hot user class In,Out,Fees pda ``` ## The `FORWARD_FLAG_NATIVE_OUTPUT` bit ```rust // programs/tributary/src/constants.rs pub const FORWARD_FLAG_NATIVE_OUTPUT: u8 = 1; // bit 0 ``` Constraints enforced on-chain (`validate_forward_config`): - The flag REQUIRES `output_mint == NATIVE_MINT` (`So111…111`). Setting the flag with any other output mint is rejected at policy creation (`NativeOutputRequiresWsol`). - The recipient's `recipient_token_account` passed at execute time MUST equal `composable_policy.recipient` (the system wallet), NOT a token account. The handler validates this explicitly because Anchor constraints can't be conditional. The sweep implementation (`process_output_and_sweep`): ```text normal mode: transfer_checked(intermediate_output → recipient_ata, sweep_amount, decimals) NATIVE_OUTPUT mode: closeAccount(intermediate_output → recipient_system_wallet) ``` `closeAccount` ships the entire remaining WSOL value (= `sweep_amount`) as native SOL, plus the rent lamports of the closed ATA (a side-effect bonus to the recipient). The `destination` is constrained on-chain to equal `composable_policy.recipient`, so there is no drain vector. ## Full code example ```typescript import * as anchor from "@coral-xyz/anchor"; import { PublicKey, SystemProgram, Transaction, TransactionInstruction, sendAndConfirmTransaction, } from "@solana/web3.js"; import { NATIVE_MINT, getAssociatedTokenAddressSync } from "@solana/spl-token"; import DLMM from "@meteora-ag/dlmm"; import { Tributary, lighthouse, getPreValidationPda, getPostValidationPda, LIGHTHOUSE_PROGRAM_ID, } from "@tributary-so/sdk"; const SWAP_INPUT_AMOUNT = 50_000_000; // 50 USDC const USDC_MINT = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); const METEORA_DLMM_PUBKEY = new PublicKey( "LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo" ); const METEORA_DLMM_SOL_USDC_POOL = new PublicKey(""); // bit 0 const FORWARD_FLAG_NATIVE_OUTPUT = 1; ``` ### 1. Build the swap ix (same as swap-and-deliver) ```typescript const dlmmPool = await DLMM.create(connection, METEORA_DLMM_SOL_USDC_POOL, { cluster: "mainnet-beta", skipSolWrappingOperation: true, }); const swapForY = USDC_MINT.equals(dlmmPool.tokenX.publicKey); const binArrays = await dlmmPool.getBinArrayForSwap(swapForY); const quote = dlmmPool.swapQuote( new anchor.BN(SWAP_INPUT_AMOUNT), swapForY, new anchor.BN(100), binArrays ); async function buildSwapIx(user: PublicKey): Promise { const swapTx = await dlmmPool.swap({ lbPair: METEORA_DLMM_SOL_USDC_POOL, inToken: USDC_MINT, outToken: NATIVE_MINT, inAmount: new anchor.BN(SWAP_INPUT_AMOUNT), minOutAmount: quote.minOutAmount, user, binArraysPubkey: quote.binArraysPubkey as PublicKey[], }); const found = swapTx.instructions.find((i) => i.programId.equals(METEORA_DLMM_PUBKEY) )!; const keys = found.keys.map((k) => k.pubkey.equals(SystemProgram.programId) ? { pubkey: METEORA_DLMM_PUBKEY, isSigner: k.isSigner, isWritable: k.isWritable, } : k ); return new TransactionInstruction({ keys, programId: found.programId, data: found.data, }); } const discriminator = Array.from( (await buildSwapIx(PublicKey.default)).data.slice(0, 8) ); ``` ### 2. Create the policy with `FORWARD_FLAG_NATIVE_OUTPUT` ```typescript const forwardConfig = { inputMint: USDC_MINT, outputMint: NATIVE_MINT, // REQUIRED — NATIVE_OUTPUT requires WSOL output forwardFlags: FORWARD_FLAG_NATIVE_OUTPUT, // ← bit 0 set instructionConstraint: { programId: METEORA_DLMM_PUBKEY, numDataChecks: 1, dataChecks: [ { offset: 0, length: 8, expected: discriminator }, { offset: 0, length: 0, expected: [0, 0, 0, 0, 0, 0, 0, 0] }, { offset: 0, length: 0, expected: [0, 0, 0, 0, 0, 0, 0, 0] }, { offset: 0, length: 0, expected: [0, 0, 0, 0, 0, 0, 0, 0] }, ], numPinnedAccounts: 0, pinnedAccounts: [ { index: 0, pubkey: PublicKey.default }, { index: 0, pubkey: PublicKey.default }, ], }, }; const createIx = await sdk.getCreateComposablePolicyInstruction( USDC_MINT, hotWallet.publicKey, // recipient — the SYSTEM wallet that will receive SOL gatewayPDA, policyType, "Native SOL topup", forwardConfig, { programCall: { programId: LIGHTHOUSE_PROGRAM_ID } }, // preValidation [hotWallet.publicKey], // prePinnedAccounts guard.data // preValidationData // postValidation: disabled (default) ); ``` ### 3. Execute — `recipient_token_account` is the system wallet This is the key difference from the WSOL-delivery variant: the `recipient_token_account` field in the `executeComposable` accounts struct is the recipient's **system wallet** (`hotWallet.publicKey`), not a WSOL ATA. The handler checks `recipient_token_account == composable_policy.recipient` when `NATIVE_OUTPUT` is set. Bypass the SDK's recipient-ATA derivation The SDK's `executeComposable()` auto-derives `recipientTokenAccount` as `getAssociatedTokenAddressSync(outputMint, recipient)`. For `NATIVE_OUTPUT` that derivation is wrong — it would hand `closeAccount` a WSOL ATA instead of the system wallet. Two options: - Use the low-level `program.methods.executeComposable(...)` path and pass `recipientTokenAccount: hotWallet.publicKey` directly (see the test suite for the exact accounts struct). - Wrap `executeComposable()` and override the resolved account after the fact. Low-level path (matches `tests/` pattern): ```typescript const swapIx = await buildSwapIx(composablePolicyPDA); const forwardAccounts = swapIx.keys.map((k) => ({ pubkey: k.pubkey, isSigner: false, isWritable: true, })); // ValidationPda is a named account post-ADR-0016; remaining_accounts is // the bare [target, ...forward] slice (no leading ValidationPda entry). const { address: preValidationPda } = getPreValidationPda( composablePolicyPDA, program.programId ); const { address: postValidationPda } = getPostValidationPda( composablePolicyPDA, program.programId ); const remainingAccounts = [...guard.accounts, ...forwardAccounts]; const ix = await program.methods .executeComposable(Buffer.from(swapIx.data), new anchor.BN(SWAP_INPUT_AMOUNT)) .accountsStrict({ feePayer: coldWallet.publicKey, paymentsDelegate: paymentsDelegatePDA, composablePolicy: composablePolicyPDA, userPayment: userPaymentPDA, gateway: gatewayPDA, config: configPDA, preValidationProgram: LIGHTHOUSE_PROGRAM_ID, postValidationProgram: SystemProgram.programId, preValidationPda, postValidationPda, userTokenAccount: coldWalletUsdcAta, mint: USDC_MINT, outputMint: NATIVE_MINT, intermediateInputTokenAccount: getAssociatedTokenAddressSync( USDC_MINT, composablePolicyPDA, true ), intermediateOutputTokenAccount: getAssociatedTokenAddressSync( NATIVE_MINT, composablePolicyPDA, true ), recipientTokenAccount: hotWallet.publicKey, // ← SYSTEM WALLET, not ATA gatewayFeeAccount: feeRecipientUsdcAta, // input-side (USDC, ADR-0026) protocolFeeAccount: adminUsdcAta, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, systemProgram: SystemProgram.programId, }) .remainingAccounts(remainingAccounts) .instruction(); ``` Fees are input-side (USDC, ADR-0026) Composable fees are always skimmed from the intermediate input (USDC) before the forward swap runs, not from the output. `gatewayFeeAccount` and `protocolFeeAccount` are USDC ATAs, not WSOL. ## When to use native SOL vs WSOL delivery | Recipient wants | Use | `forwardFlags` | | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | | WSOL (will unwrap themselves, or is a Solana-native DeFi wallet) | [Swap & Deliver](https://docs.tributary.so/integration-guide/programmable-pull-payments/examples/swap-and-deliver/index.md) | `0` | | Native SOL (exchange, custody, EOA-style wallet) | **Native SOL topup** | `FORWARD_FLAG_NATIVE_OUTPUT` (1) | Native SOL delivery costs one extra `closeAccount` CPI and skips the recipient ATA requirement. WSOL delivery leaves the recipient with a wrapped balance they must unwrap later (or keep for DeFi use). ## Failure modes | Condition | Outcome | | ----------------------------------------------------------------- | ------------------------------------------------------------- | | `outputMint != NATIVE_MINT` while flag set | Rejected at creation (`NATIVE_OUTPUTRequiresWsolOutputMint`). | | `recipientTokenAccount != composable_policy.recipient` at execute | `Unauthorized`. | | Swap selector doesn't match the pinned `ByteRangeCheck` | Tx reverts. | | Lighthouse assertion fails | Tx reverts before the swap. | ## Reference - `FORWARD_FLAG_NATIVE_OUTPUT` → `programs/tributary/src/constants.rs:24` - Sweep logic → `process_output_and_sweep` in `programs/tributary/src/instructions/composable/execute_composable.rs` - [Swap & Deliver](https://docs.tributary.so/integration-guide/programmable-pull-payments/examples/swap-and-deliver/index.md) — the WSOL-delivery variant. - [SDK surface](https://docs.tributary.so/integration-guide/programmable-pull-payments/sdk/index.md) — `executeComposable` and the `recipient_token_account` override caveat. # Example: Swap & Deliver (USDC → WSOL via Meteora DLMM) **Use case.** A service charges in USDC (the input mint the user holds), but the recipient wants to be paid in WSOL. The composable policy pulls USDC from the user, swaps it through a Meteora DLMM pool into WSOL, and delivers WSOL (minus fees) to the recipient's WSOL ATA. This is a composable policy with: - **Forward enabled** — `programId = METEORA_DLMM_PUBKEY` on the `instructionConstraint`, with a `ByteRangeCheck` pinning the swap instruction discriminator at offset 0. - **Optional validation** — same pattern as the topup guard (e.g. only swap when the recipient's WSOL balance is below a threshold). - **Input-side fees** — protocol and gateway fees are skimmed from the gross USDC pull in Phase 1b (ADR-0026), before the forward runs. The swap and deliver phases move only the net principal. ``` graph LR Cold["coldWallet
(user, USDC)"] -->|"pull USDC (gross)
(UserPayment PDA signs)"| In["intermediate USDC ATA"] In -->|"skim fees
(input-side)"| Fee["protocol fee + gateway fee
(USDC)"] PreVal["Lighthouse CPI
(pre‑validation)"] -.->|"assertion → continue"| In In -->|"swap via DLMM
(ComposablePolicy PDA signs)"| Out["intermediate WSOL ATA"] PostVal["Lighthouse CPI
(post‑validation)"] -.->|"assertion → continue"| Out Out -->|"sweep WSOL"| Hot["hotWallet
(recipient, WSOL ATA)"] classDef user fill:#e8f5e8,stroke:#1b5e20 classDef pda fill:#e3f2fd,stroke:#1565c0 classDef val fill:#fff3e0,stroke:#e65100 class Cold,Hot user class In,Out pda class PreVal,PostVal val class Fee user ``` ## Constants ```typescript import DLMM from "@meteora-ag/dlmm"; import { NATIVE_MINT } from "@solana/spl-token"; // The only program currently in ALLOWED_FORWARD_PROGRAMS. const METEORA_DLMM_PUBKEY = new PublicKey( "LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo" ); const METEORA_DLMM_SOL_USDC_POOL = new PublicKey(""); const USDC_MINT = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); const SWAP_INPUT_AMOUNT = 50_000_000; // 50 USDC ``` Pool choice matters The pool you pass to `pool.swap()` must have `USDC_MINT` as one leg and `NATIVE_MINT` as the other. Tributary does not validate the pool itself — only that the forward program id is allowlisted and the instruction selector matches the pinned `ByteRangeCheck`. ## 1. Build the DLMM swap instruction The swap `user` MUST be the `ComposablePolicy` PDA — it owns both intermediate ATAs, and Tributary's `run_forward_cpi` promotes it to signer via `invoke_signed`. ```typescript // Load the pool with skipSolWrappingOperation — pool.swap() otherwise appends // a WSOL wrap/unwrap post-instruction; Tributary manages the intermediates // itself. const dlmmPool = await DLMM.create(connection, METEORA_DLMM_SOL_USDC_POOL, { cluster: "mainnet-beta", skipSolWrappingOperation: true, }); // swapForY = true ⟹ in-token is X (we sell USDC, buy WSOL/Y). const swapForY = USDC_MINT.equals(dlmmPool.tokenX.publicKey); const binArrays = await dlmmPool.getBinArrayForSwap(swapForY); const quote = dlmmPool.swapQuote( new anchor.BN(SWAP_INPUT_AMOUNT), swapForY, new anchor.BN(100), // 1% slippage binArrays ); ``` The ComposablePolicy PDA isn't known until after creation, so build the swap ix in two places: once at creation (to extract its discriminator) and again at execute (with the real `user`). Wrap it in a helper: ```typescript async function buildSwapIx(user: PublicKey): Promise { const swapTx = await dlmmPool.swap({ lbPair: METEORA_DLMM_SOL_USDC_POOL, inToken: USDC_MINT, outToken: NATIVE_MINT, inAmount: new anchor.BN(SWAP_INPUT_AMOUNT), minOutAmount: quote.minOutAmount, // slippage protection at the swap layer user, binArraysPubkey: quote.binArraysPubkey as PublicKey[], }); // pool.swap() returns [CU-estimation ix, idempotent ATA-create ix, swap ix]. // Keep ONLY the instruction whose programId == DLMM. const found = swapTx.instructions.find((i) => i.programId.equals(METEORA_DLMM_PUBKEY) ); if (!found) throw new Error("DLMM swap instruction not found"); // hostFeeIn fix: the SDK passes hostFeeIn: null → Anchor serializes that as // the System Program id. The DLMM program rejects a System-Program-owned // host_fee_in. Rewrite that one account meta to the DLMM program id itself // (Meteora's own CLI/tests use that as the "no host fee" placeholder). const keys = found.keys.map((k) => k.pubkey.equals(SystemProgram.programId) ? { pubkey: METEORA_DLMM_PUBKEY, isSigner: k.isSigner, isWritable: k.isWritable, } : k ); return new TransactionInstruction({ keys, programId: found.programId, data: found.data, }); } // Build once just to extract the discriminator for the ByteRangeCheck. const discriminator = (await buildSwapIx(PublicKey.default)).data.slice(0, 8); ``` ## 2. Create the composable policy Forward enabled: `programId = METEORA_DLMM_PUBKEY` on the `instructionConstraint`, at least one `ByteRangeCheck` pins the discriminator at offset 0. ```typescript const forwardConfig = { instructionConstraint: { programId: METEORA_DLMM_PUBKEY, numDataChecks: 1, dataChecks: [ { offset: 0, length: 8, expected: Buffer.from(discriminator) }, { offset: 0, length: 0, expected: Buffer.alloc(8) }, { offset: 0, length: 0, expected: Buffer.alloc(8) }, { offset: 0, length: 0, expected: Buffer.alloc(8) }, ], numPinnedAccounts: 0, pinnedAccounts: [], }, inputMint: USDC_MINT, outputMint: NATIVE_MINT, forwardFlags: 0, // WSOL ATA delivery — see native-sol-topup.md for the unwrap variant }; // Optional Lighthouse guard: only swap when recipient WSOL is below 1 WSOL. const guard = lighthouse .tokenAccount(hotWalletWsolAta) .amount(1_000_000_000, "<") .build(); const createIx = await sdk.getCreateComposablePolicyInstruction( USDC_MINT, hotWallet.publicKey, // recipient gatewayPDA, policyType, "Topup WSOL swap", forwardConfig, { programCall: { programId: LIGHTHOUSE_PROGRAM_ID } }, // preValidation guard.accounts, // prePinnedAccounts — owner-declared Lighthouse target accounts guard.data // preValidationData ); ``` Why pin the discriminator? Without a `ByteRangeCheck` at offset 0, a malicious gateway could substitute any instruction data at execute time — the program ID is allowlisted, but DLMM exposes other instructions besides `swap`. Pinning the first 8 bytes locks the swap selector and refuses any other instruction shape. ## 3. Execute (permissionless) The caller supplies: - `instructionData` = the raw DLMM swap ix data (the same bytes whose first 8 must match the pinned `ByteRangeCheck`). - `forwardAmount` = the USDC pull size (required for PayAsYouGo; pass `null` for subscription/onetime). - `remaining_accounts` = `[...guard.accounts, ...forwardAccounts]` — the `ValidationPda` is in the `accountsStrict` map; the remaining accounts slice is just the Lighthouse target accounts + forward accounts. ```typescript // Two distinct intermediates (input_mint != output_mint), both owned by the // ComposablePolicy PDA. The swap draws USDC from the input ATA and sends // WSOL to the output ATA; fees + sweep then move WSOL to the recipient. const swapIx = await buildSwapIx(composablePolicyPDA); const forwardAccounts = swapIx.keys.map((k) => ({ pubkey: k.pubkey, isSigner: false, // Mark ALL forward accounts writable. The DLMM program mutates several // accounts that dlmm-sdk@0.7.7's IDL marks read-only (e.g. // bin_array_bitmap_extension, oracle). The runtime permits marking an // account writable even if the callee never writes it, so this is safe // and sidesteps the stale-IDL mutability mismatch. isWritable: true, })); const remainingAccounts = [ ...guard.accounts, // Lighthouse target accounts (no leading ValidationPda) ...forwardAccounts, // DLMM swap accounts (includes the self-listed DLMM program) ]; const [execIx] = await sdk.executeComposable( composablePolicyPDA, Buffer.from(swapIx.data), new anchor.BN(SWAP_INPUT_AMOUNT), remainingAccounts ); // Returns [executeInstruction] — also ATA ensures for recipient + fee recipients ``` ## `minOutputAmount` (removed) `ForwardConfig.minOutputAmount` was removed in v2.1. The `ByteRangeCheck` pins the swap instruction selector, so swap-level slippage (`minOutAmount` on `pool.swapQuote`) is the caller's responsibility — it's encoded inside the forward instruction data that the CPI executes verbatim. For net-level guarantees (output after fees), use the **post-validation** hook: a Lighthouse assertion against the intermediate output ATA balance after the swap but before the sweep. This replaces the old inline `minOutputAmount` field. ```typescript const netGuard = lighthouse .tokenAccount(intermediateOutputAta) .amount(desiredMinOut, ">=") .build(); const createIx = await sdk.getCreateComposablePolicyInstruction( USDC_MINT, hotWallet.publicKey, gatewayPDA, policyType, "Topup WSOL swap", forwardConfig, preValidationSpec, prePinnedAccounts, preValidationData, { programCall: { programId: LIGHTHOUSE_PROGRAM_ID } }, // postValidation netGuard.accounts, // postPinnedAccounts netGuard.data // postValidationData ); ``` Slippage protection The DLMM `minOutAmount` inside the swap ix is the primary price-slippage defence. The post-validation hook is an optional backstop on the delivered amount; leave it disabled if swap-level slippage already covers your requirements. ## Failure modes | Condition | Outcome | | -------------------------------------------------------------- | ---------------------------------------------------------------------------- | | Swap selector doesn't match the pinned `ByteRangeCheck` | `DiscriminatorCheckRequired` / `ByteRangeCheckFailed` → tx reverts. | | PayAsYouGo period cap exhausted | Rejected before the swap CPI runs. | | Lighthouse assertion fails (recipient already has enough WSOL) | Tx reverts before the swap. | | Insufficient delegate amount on `userTokenAccount` | `InsufficientDelegatedAmount`. | | Pool moves adversarially between quote and execute | `minOutAmount` in the swap ix (or a post‑validation assertion) protects you. | ## Reference - Working test: `tests/topup-balance-swap.test.ts` (runs against Surfpool with a mainnet-forked DLMM pool). - [SDK surface](https://docs.tributary.so/integration-guide/programmable-pull-payments/sdk/index.md) — `executeComposable` and `remaining_accounts` layout. - Next: [Native SOL topup](https://docs.tributary.so/integration-guide/programmable-pull-payments/examples/native-sol-topup/index.md) — same flow but unwraps WSOL to native SOL via the `FORWARD_FLAG_NATIVE_OUTPUT` bit. # Quickstart: AI Agent Budget Give an AI agent a capped USDC allowance. It can pull funds on-demand, but only up to a per-period ceiling — and only when its hot wallet actually needs it. No forward, no swap: pure conditional pull. **You'll build:** a PayAsYouGo composable policy with a Lighthouse balance guard. The agent pulls USDC only when its wallet dips below a threshold. **Time to first value:** \<10 min if you have a funded devnet wallet. ## Prerequisites - Node 18+, `@solana/web3.js`, `@coral-xyz/anchor`, `@tributary-so/sdk` - A funded Solana wallet (devnet or mainnet) - A `PaymentGateway` — create one via the SDK manager CLI or `sdk.createPaymentGateway()` - USDC in your wallet (the funder) ## Setup ```typescript import * as anchor from "@coral-xyz/anchor"; import { PublicKey, Connection, Keypair, Transaction, sendAndConfirmTransaction } from "@solana/web3.js"; import { getAssociatedTokenAddressSync } from "@solana/spl-token"; import { Tributary, lighthouse, LIGHTHOUSE_PROGRAM_ID } from "@tributary-so/sdk"; const USDC_MINT = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); const connection = new Connection("https://api.devnet.solana.com"); // Your wallet (funder) and the agent's wallet (recipient) const funder = Keypair.generate(); // load from your wallet provider const agentWallet = Keypair.generate(); const sdk = new Tributary(connection, { publicKey: funder.publicKey, signTransaction: /* your signer */, } as any); const gatewayPDA = new PublicKey(""); // The agent's USDC ATA — Lighthouse will read its balance const agentUsdcAta = getAssociatedTokenAddressSync(USDC_MINT, agentWallet.publicKey); ``` ## Step 1: Build the guard Only allow pulls when the agent's USDC balance is below 50 USDC. This prevents the agent from draining funds it doesn't need. ```typescript const THRESHOLD = 50_000_000; // 50 USDC (6 decimals) const guard = lighthouse .tokenAccount(agentUsdcAta) .amount(THRESHOLD, "<") .build(); ``` ## Step 2: Create the policy One call handles ATA creation, UserPayment, the composable policy, and delegate approval. ```typescript const CAP_MONTHLY = 1_000_000_000; // 1000 USDC / month const CAP_PER_CALL = 50_000_000; // 50 USDC / call const THIRTY_DAYS_S = 30 * 24 * 3600; const policyType = { payAsYouGo: { maxAmountPerPeriod: new anchor.BN(CAP_MONTHLY), maxChunkAmount: new anchor.BN(CAP_PER_CALL), periodLengthSeconds: new anchor.BN(THIRTY_DAYS_S), currentPeriodStart: new anchor.BN(Math.floor(Date.now() / 1000)), currentPeriodTotal: new anchor.BN(0), expiryDate: null, padding: new Array(79).fill(0), }, }; // Forward disabled — same-mint (USDC → USDC), no swap const forwardConfig = { instructionConstraint: { programId: PublicKey.default, // sentinel: forward disabled (static getter, no parens) numDataChecks: 0, dataChecks: Array(4).fill({ offset: 0, length: 0, expected: Buffer.alloc(8), }), numPinnedAccounts: 0, pinnedAccounts: [ { index: 0, pubkey: PublicKey.default }, { index: 0, pubkey: PublicKey.default }, ], // fixed-size [PinnedAccount; 2] — must have 2 entries even when disabled }, inputMint: USDC_MINT, outputMint: USDC_MINT, // == inputMint ⇒ deliver-no-transform settlement forwardFlags: 0, }; const ixs = await sdk.createComposable( USDC_MINT, agentWallet.publicKey, // recipient = the AI agent gatewayPDA, policyType, "AI agent budget", forwardConfig, { programCall: { programId: LIGHTHOUSE_PROGRAM_ID } }, // preValidation [agentUsdcAta], // prePinnedAccounts guard.data, // preValidationData { disabled: {} }, // postValidation (disabled — pre-validation is enough here) [], // postPinnedAccounts Buffer.alloc(0), // postValidationData undefined, // feePayer (defaults to provider) new anchor.BN(CAP_MONTHLY) // approvalAmount (delegate approval cap) ); // Send the transaction await sendAndConfirmTransaction(connection, new Transaction().add(...ixs), [ funder, ]); ``` ## Step 3: Execute (permissionless) Anyone can execute — the agent itself, a scheduler, or your backend. The guard fires first: if the agent's balance is ≥ threshold, the tx reverts. ```typescript const { address: composablePolicyPDA } = sdk.getComposablePolicyPda( sdk.getUserPaymentPda(funder.publicKey, USDC_MINT).address, 1 // first composable policy ); const execIxs = await sdk.executeComposable( composablePolicyPDA, Buffer.alloc(0), // no forward instruction data new anchor.BN(CAP_PER_CALL), // pull 50 USDC guard.accounts // Lighthouse read-accounts ); await sendAndConfirmTransaction(connection, new Transaction().add(...execIxs), [ funder, ]); ``` ## What happened 1. **Pull** — 50 USDC + fees pulled from your wallet to the intermediate ATA. 1. **Skim** — Protocol + gateway fees skimmed (input-side, USDC). 1. **Pre-validate** — Lighthouse checks `agentUsdcAta.amount < 50 USDC`. Passes only when the agent is low on funds. 1. **Settle** — USDC swept to the agent's ATA. If the agent already has ≥ 50 USDC, the assertion fails and nothing moves. ## Next steps - [Auto-topup quickstart](https://docs.tributary.so/integration-guide/programmable-pull-payments/quickstarts/auto-topup/index.md) — same guard but with a USDC→SOL swap - [Auto-DCA quickstart](https://docs.tributary.so/integration-guide/programmable-pull-payments/quickstarts/auto-dca/index.md) — recurring subscription with a forward swap - [Lighthouse facade guide](https://docs.tributary.so/integration-guide/programmable-pull-payments/lighthouse-facade/index.md) — every assertion family - [SDK reference](https://docs.tributary.so/integration-guide/programmable-pull-payments/sdk/index.md) — full composable API # Quickstart: Auto-DCA Automatically dollar-cost-average into SOL every week. A subscription composable policy pulls USDC on a schedule, swaps it to SOL via Meteora DLMM, and delivers SOL to your wallet — all on-chain, no custodian. **You'll build:** a Subscription composable policy with a Meteora forward. USDC is pulled weekly, swapped to SOL, and delivered. **Time to first value:** \<15 min (requires a DLMM pool address). ## Prerequisites - Node 18+, `@solana/web3.js`, `@coral-xyz/anchor`, `@tributary-so/sdk` - `@meteora-ag/dlmm` installed - A funded Solana wallet - A `PaymentGateway` - USDC in your wallet - A Meteora DLMM pool address (USDC/SOL pair) — find one on [Meteora](https://app.meteora.ag/) or use the SDK to discover pools ## Setup ```typescript import * as anchor from "@coral-xyz/anchor"; import { PublicKey, Connection, Transaction, sendAndConfirmTransaction, SystemProgram, TransactionInstruction, } from "@solana/web3.js"; import { getAssociatedTokenAddressSync, NATIVE_MINT } from "@solana/spl-token"; import { Tributary, lighthouse, LIGHTHOUSE_PROGRAM_ID, } from "@tributary-so/sdk"; import DLMM from "@meteora-ag/dlmm"; const USDC_MINT = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); const METEORA_DLMM_PUBKEY = new PublicKey( "LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo" ); const DLMM_POOL = new PublicKey(""); const connection = new Connection("https://api.devnet.solana.com"); // ... initialise sdk, wallet, gatewayPDA as usual ``` ## Step 1: Build the DLMM swap helper The swap `user` must be the ComposablePolicy PDA (it owns the intermediate ATAs). Build a helper that can produce the swap ix for any `user` — you'll call it twice: once to extract the discriminator (at create time), once with the real PDA (at execute time). ```typescript const DCA_AMOUNT = 100_000_000; // 100 USDC per swap const dlmmPool = await DLMM.create(connection, DLMM_POOL, { cluster: "mainnet-beta", skipSolWrappingOperation: true, // Tributary manages intermediates }); async function buildSwapIx(user: PublicKey) { const swapForY = USDC_MINT.equals(dlmmPool.tokenX.publicKey); const binArrays = await dlmmPool.getBinArrayForSwap(swapForY); const quote = dlmmPool.swapQuote( new anchor.BN(DCA_AMOUNT), swapForY, new anchor.BN(100), // 1% slippage binArrays ); const swapTx = await dlmmPool.swap({ lbPair: DLMM_POOL, inToken: USDC_MINT, outToken: NATIVE_MINT, inAmount: new anchor.BN(DCA_AMOUNT), minOutAmount: quote.minOutAmount, user, binArraysPubkey: quote.binArraysPubkey as PublicKey[], }); // Keep only the DLMM instruction (pool.swap returns extras) const found = swapTx.instructions.find((i) => i.programId.equals(METEORA_DLMM_PUBKEY) ); if (!found) throw new Error("DLMM swap instruction not found"); // hostFeeIn fix: rewrite SystemProgram placeholder → DLMM program id const keys = found.keys.map((k) => k.pubkey.equals(PublicKey.default) || k.pubkey.equals(SystemProgram.programId) ? { ...k, pubkey: METEORA_DLMM_PUBKEY } : k ); return new TransactionInstruction({ keys, programId: found.programId, data: found.data, }); } // Extract the discriminator for the ByteRangeCheck const discriminator = (await buildSwapIx(PublicKey.default)).data.slice(0, 8); ``` ## Step 2: Create the policy Subscription: 100 USDC every 7 days, swapped to SOL, delivered to you. ```typescript const WEEK_S = 7 * 24 * 3600; const policyType = { subscription: { amount: new anchor.BN(DCA_AMOUNT), // 100 USDC paymentFrequency: { custom: new anchor.BN(WEEK_S) }, // weekly via custom seconds nextPaymentDue: new anchor.BN(Math.floor(Date.now() / 1000) + WEEK_S), maxRenewals: 52, // 1 year of weekly DCA autoRenew: true, padding: new Array(97).fill(0), // [u8; 97] per IDL }, }; const forwardConfig = { instructionConstraint: { programId: METEORA_DLMM_PUBKEY, numDataChecks: 1, dataChecks: [ { offset: 0, length: 8, expected: Buffer.from(discriminator) }, { offset: 0, length: 0, expected: Buffer.alloc(8) }, { offset: 0, length: 0, expected: Buffer.alloc(8) }, { offset: 0, length: 0, expected: Buffer.alloc(8) }, ], numPinnedAccounts: 0, pinnedAccounts: [ { index: 0, pubkey: PublicKey.default }, { index: 0, pubkey: PublicKey.default }, ], // fixed-size [PinnedAccount; 2] — must have 2 entries }, inputMint: USDC_MINT, outputMint: NATIVE_MINT, // WSOL delivery forwardFlags: 0, }; const ixs = await sdk.createComposable( USDC_MINT, wallet.publicKey, // you are the recipient gatewayPDA, policyType, "Weekly DCA", forwardConfig, { disabled: {} }, // preValidation (no pre-guard; post-validation checks output) [], // prePinnedAccounts Buffer.alloc(0), // preValidationData { programCall: { programId: LIGHTHOUSE_PROGRAM_ID } }, // postValidation (price guard) [outputSolAta], // postPinnedAccounts (read-accounts for post-validation) postGuard.data, // postValidationData undefined, // feePayer undefined // approvalAmount (optional — subscription amount suffices) ); await sendAndConfirmTransaction(connection, new Transaction().add(...ixs), [ wallet, ]); ``` Post-validation price guard This DCA adds a **post-validation** guard: after the swap but before settle, Lighthouse asserts the recipient's WSOL ATA received at least `MIN_OUTPUT_LAMPORTS`. This is the "floor on output" pattern from ADR-0031 — the on-chain `>0` guard stays, and `post_validation` generalizes it as the owner's enforced minimum. Build the guard the same way as a pre-validation guard, but pass it through `postValidation` / `postPinnedAccounts` / `postValidationData` (the 10th–12th `createComposable` args above): ```typescript const MIN_OUTPUT_LAMPORTS = new anchor.BN(0); // floor: any non-zero amount const postGuard = lighthouse .tokenAccount(outputSolAta) // WSOL ATA after the swap .amount(MIN_OUTPUT_LAMPORTS, ">=") .build(); ``` If you don't want a price guard, set `postValidation: { disabled: {} }` and skip the `postPinnedAccounts` / `postValidationData` args — the DCA will run unconditionally on schedule. ## Step 3: Execute (permissionless) A scheduler (or anyone) calls execute when `next_payment_due` has passed. The caller supplies the live swap instruction data. ```typescript const { address: composablePolicyPDA } = sdk.getComposablePolicyPda( sdk.getUserPaymentPda(wallet.publicKey, USDC_MINT).address, 1 ); // Build the swap ix with the REAL composable policy PDA as user const swapIx = await buildSwapIx(composablePolicyPDA); // Map DLMM accounts → AccountMeta (all writable, all non-signer) const forwardAccounts = swapIx.keys.map((k) => ({ pubkey: k.pubkey, isSigner: false, isWritable: true, })); const execIxs = await sdk.executeComposable( composablePolicyPDA, Buffer.from(swapIx.data), // the raw swap instruction data null, // null for subscription (fixed amount) forwardAccounts // DLMM swap accounts ); await sendAndConfirmTransaction(connection, new Transaction().add(...execIxs), [ wallet, ]); ``` ## What happened 1. **Pull** — 100 USDC + fees pulled from your wallet to the intermediate ATA. 1. **Skim** — Protocol + gateway fees skimmed (input-side, USDC). 1. **Forward** — DLMM swap: USDC → WSOL via the intermediate ATAs. 1. **Settle (deliver-transform)** — WSOL swept to your wallet's WSOL ATA. 1. **Schedule advance** — `next_payment_due` += 7 days. ## Next steps - [AI agent budget](https://docs.tributary.so/integration-guide/programmable-pull-payments/quickstarts/ai-agent-budget/index.md) — PayAsYouGo with validation, no swap - [Auto-topup](https://docs.tributary.so/integration-guide/programmable-pull-payments/quickstarts/auto-topup/index.md) — balance guard + swap - [Swap & Deliver example](https://docs.tributary.so/integration-guide/programmable-pull-payments/examples/swap-and-deliver/index.md) — deeper dive with post-validation - [Forward CPI guide](https://docs.tributary.so/integration-guide/programmable-pull-payments/forward-cpi-guide/index.md) — how to pin any forward instruction # Quickstart: Auto-Topup Keep a hot wallet topped up with SOL. When the hot wallet's USDC balance drops below a threshold, a composable policy pulls USDC from your cold wallet, swaps it to SOL via Meteora DLMM, and delivers it. Conditional + transformative in one primitive. **You'll build:** a PayAsYouGo composable with a Lighthouse balance guard and a Meteora DLMM forward swap. **Time to first value:** \<15 min (requires a DLMM pool address). ## Prerequisites - Node 18+, `@solana/web3.js`, `@coral-xyz/anchor`, `@tributary-so/sdk` - `@meteora-ag/dlmm` installed - Two wallets: cold (funder) and hot (recipient) - A `PaymentGateway` - USDC in the cold wallet - A Meteora DLMM pool address (USDC/SOL pair) ## Setup ```typescript import * as anchor from "@coral-xyz/anchor"; import { PublicKey, Connection, Transaction, sendAndConfirmTransaction, SystemProgram, TransactionInstruction, } from "@solana/web3.js"; import { getAssociatedTokenAddressSync, NATIVE_MINT } from "@solana/spl-token"; import { Tributary, lighthouse, LIGHTHOUSE_PROGRAM_ID, } from "@tributary-so/sdk"; import DLMM from "@meteora-ag/dlmm"; const USDC_MINT = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); const METEORA_DLMM_PUBKEY = new PublicKey( "LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo" ); const DLMM_POOL = new PublicKey(""); const connection = new Connection("https://api.devnet.solana.com"); // ... initialise sdk with coldWallet as provider, gatewayPDA const hotWalletUsdcAta = getAssociatedTokenAddressSync( USDC_MINT, hotWallet.publicKey ); ``` ## Step 1: Build the guard + swap helper ```typescript const THRESHOLD = 50_000_000; // 50 USDC — trigger when below this const TOPUP_CHUNK = 50_000_000; // 50 USDC per execute call // Guard: only top up when hot wallet is below threshold const guard = lighthouse .tokenAccount(hotWalletUsdcAta) .amount(THRESHOLD, "<") .build(); // Swap helper (same pattern as auto-DCA quickstart) const dlmmPool = await DLMM.create(connection, DLMM_POOL, { cluster: "mainnet-beta", skipSolWrappingOperation: true, }); async function buildSwapIx(user: PublicKey) { const swapForY = USDC_MINT.equals(dlmmPool.tokenX.publicKey); const binArrays = await dlmmPool.getBinArrayForSwap(swapForY); const quote = dlmmPool.swapQuote( new anchor.BN(TOPUP_CHUNK), swapForY, new anchor.BN(100), // 1% slippage binArrays ); const swapTx = await dlmmPool.swap({ lbPair: DLMM_POOL, inToken: USDC_MINT, outToken: NATIVE_MINT, inAmount: new anchor.BN(TOPUP_CHUNK), minOutAmount: quote.minOutAmount, user, binArraysPubkey: quote.binArraysPubkey as PublicKey[], }); const found = swapTx.instructions.find((i) => i.programId.equals(METEORA_DLMM_PUBKEY) ); if (!found) throw new Error("DLMM swap instruction not found"); const keys = found.keys.map((k) => k.pubkey.equals(PublicKey.default) || k.pubkey.equals(SystemProgram.programId) ? { ...k, pubkey: METEORA_DLMM_PUBKEY } : k ); return new TransactionInstruction({ keys, programId: found.programId, data: found.data, }); } const discriminator = (await buildSwapIx(PublicKey.default)).data.slice(0, 8); ``` ## Step 2: Create the policy PayAsYouGo: up to 200 USDC/month, 50 USDC per call, with the balance guard and the SOL swap forward. ```typescript const policyType = { payAsYouGo: { maxAmountPerPeriod: new anchor.BN(200_000_000), // 200 USDC / month maxChunkAmount: new anchor.BN(TOPUP_CHUNK), // 50 USDC / call periodLengthSeconds: new anchor.BN(30 * 24 * 3600), currentPeriodStart: new anchor.BN(Math.floor(Date.now() / 1000)), currentPeriodTotal: new anchor.BN(0), expiryDate: null, padding: new Array(79).fill(0), }, }; const forwardConfig = { instructionConstraint: { programId: METEORA_DLMM_PUBKEY, numDataChecks: 1, dataChecks: [ { offset: 0, length: 8, expected: Buffer.from(discriminator) }, { offset: 0, length: 0, expected: Buffer.alloc(8) }, { offset: 0, length: 0, expected: Buffer.alloc(8) }, { offset: 0, length: 0, expected: Buffer.alloc(8) }, ], numPinnedAccounts: 0, pinnedAccounts: [ { index: 0, pubkey: PublicKey.default }, { index: 0, pubkey: PublicKey.default }, ], // fixed-size [PinnedAccount; 2] — must have 2 entries }, inputMint: USDC_MINT, outputMint: NATIVE_MINT, // WSOL delivery forwardFlags: 0, }; const ixs = await sdk.createComposable( USDC_MINT, hotWallet.publicKey, // recipient = the hot wallet gatewayPDA, policyType, "Auto SOL topup", forwardConfig, { programCall: { programId: LIGHTHOUSE_PROGRAM_ID } }, // preValidation [hotWalletUsdcAta], // prePinnedAccounts guard.data, // preValidationData { disabled: {} }, // postValidation (disabled — pre-validation gates the pull) [], // postPinnedAccounts Buffer.alloc(0), // postValidationData undefined, // feePayer new anchor.BN(200_000_000) // approvalAmount ); await sendAndConfirmTransaction(connection, new Transaction().add(...ixs), [ coldWallet, ]); ``` ## Step 3: Execute (permissionless) A scheduler polls the hot wallet balance and fires this when the guard would pass. The guard reverts the tx if the balance is still above threshold. ```typescript const { address: composablePolicyPDA } = sdk.getComposablePolicyPda( sdk.getUserPaymentPda(coldWallet.publicKey, USDC_MINT).address, 1 ); const swapIx = await buildSwapIx(composablePolicyPDA); const forwardAccounts = swapIx.keys.map((k) => ({ pubkey: k.pubkey, isSigner: false, isWritable: true, })); const remainingAccounts = [ ...guard.accounts, // Lighthouse target accounts ...forwardAccounts, // DLMM swap accounts ]; const execIxs = await sdk.executeComposable( composablePolicyPDA, Buffer.from(swapIx.data), // swap instruction data new anchor.BN(TOPUP_CHUNK), // pull amount remainingAccounts ); await sendAndConfirmTransaction(connection, new Transaction().add(...execIxs), [ coldWallet, ]); ``` ## What happened 1. **Pull** — 50 USDC + fees pulled from cold wallet to intermediate ATA. 1. **Skim** — Protocol + gateway fees skimmed (input-side, USDC). 1. **Pre-validate** — Lighthouse checks `hotWalletUsdcAta.amount < 50 USDC`. Reverts if the hot wallet doesn't need a topup. 1. **Forward** — DLMM swap: USDC → WSOL. 1. **Settle (deliver-transform)** — WSOL swept to the hot wallet's WSOL ATA. ## Next steps - [AI agent budget](https://docs.tributary.so/integration-guide/programmable-pull-payments/quickstarts/ai-agent-budget/index.md) — same guard, no swap (simplest) - [Auto-DCA](https://docs.tributary.so/integration-guide/programmable-pull-payments/quickstarts/auto-dca/index.md) — recurring schedule with swap - [Native SOL topup example](https://docs.tributary.so/integration-guide/programmable-pull-payments/examples/native-sol-topup/index.md) — unwrap WSOL to native SOL with `FORWARD_FLAG_NATIVE_OUTPUT` - [Forward CPI guide](https://docs.tributary.so/integration-guide/programmable-pull-payments/forward-cpi-guide/index.md) — how to pin forward instructions # Checkout Page Quickstart Generate payment URLs for Tributary subscriptions without any frontend code. Perfect for: - **AI agent monetization** (service agents accepting payments from customer agents) - **Payment links** shared via messaging, email, or chat - **Zero UI integration** - Tributary handles the checkout experience - **Lando** - The "Stripe for AI Agents" platform ## Prerequisites - Node.js (v16 or higher) - pnpm package manager - A recipient wallet address (to receive payments) ## Step 1: Installation ```bash pnpm install @tributary-so/payments @tributary-so/sdk @solana/web3.js ``` ## Step 2: Generate Checkout URL Use the PaymentsClient to create checkout sessions: ```typescript import { PaymentsClient } from "@tributary-so/payments"; import { Connection } from "@solana/web3.js"; import { Tributary } from "@tributary-so/sdk"; const connection = new Connection("https://api.mainnet-beta.solana.com"); const tributary = new Tributary(connection, wallet); const payments = new PaymentsClient(connection, tributary); const session = await payments.checkout.sessions.create({ payment_method_types: ["tributary"], line_items: [ { description: "Premium subscription", unitPrice: 10.0, // $10 quantity: 1, }, ], paymentFrequency: "monthly", mode: "subscription", success_url: "https://yourapp.com/success", cancel_url: "https://yourapp.com/cancel", tributaryConfig: { gateway: "CwNybLVQ3sVmcZ3Q1veS6x99gUZcAF2duNDe3qbcEMGr", recipient: "YOUR_RECIPIENT_WALLET_ADDRESS", trackingId: "unique-tracking-id", autoRenew: true, }, }); console.log("Checkout URL:", session.url); // https://checkout.tributary.so/#/subscribe/eyJ0bSI6IkVQakZXZGQ1Q... ``` ## Step 3: Share the Payment URL Once generated, share via any channel: ```typescript // Email function sendPaymentEmail(email: string, url: string) { sendEmail({ to: email, subject: "Complete your subscription", body: `Click here: ${url}`, }); } // SMS/Messaging function sendPaymentSMS(phone: string, url: string) { sendSMS(phone, `Subscribe: ${url}`); } // AI Agent (Lando) const skillContent = ` Subscribe to this service: ${url} $10 USDC monthly subscription. `; ``` ## Step 4: Monitor Payments Track subscription status using the SDK: ```typescript // Check subscription status const status = await payments.subscriptions.checkStatus({ trackingId: "unique-tracking-id", userPublicKey: "USER_PUBLIC_KEY", tokenMint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", }); console.log("Status:", status.status); // "pending" | "created" | "active" console.log("Payments:", status.paymentCount); console.log("Next due:", status.nextPaymentDue); ``` ## One-Time Payments For single, non-recurring payments: ```typescript const session = await payments.checkout.sessions.create({ payment_method_types: ["tributary"], line_items: [ { description: "Premium feature access", unitPrice: 50.0, quantity: 1, }, ], mode: "payment", // One-time payment success_url: "https://yourapp.com/success", cancel_url: "https://yourapp.com/cancel", tributaryConfig: { recipient: "YOUR_RECIPIENT_WALLET_ADDRESS", trackingId: "order-12345", }, }); ``` Check one-time payment status: ```typescript const status = await payments.payments.oneTime.checkStatus("order-12345"); if (status.status === "paid") { console.log("Paid!", { transaction: status.transaction?.signature, amount: status.amount, paidAt: status.paidAt, }); } ``` ## Custom Line Items Add detailed breakdowns: ```typescript const session = await payments.checkout.sessions.create({ // ... line_items: [ { description: "Basic plan", unitPrice: 20, quantity: 1 }, { description: "Add-on feature", unitPrice: 5, quantity: 1 }, { description: "Priority support", unitPrice: 10, quantity: 1 }, ], // Total: $35 }); ``` ## URL Encoding Checkout URLs contain Base64URL-encoded data: **Subscription format:** ```text https://checkout.tributary.so/#/subscribe/{base64url} ``` **One-time payment format:** ```text https://checkout.tributary.so/#/pay/{base64url} ``` **Encoded fields:** | Field | Description | | ----- | --------------------------------------- | | `m` | Mode ("subscription" or "payment") | | `tm` | Token mint address | | `r` | Recipient public key | | `a` | Total amount | | `tid` | Tracking ID | | `g` | Gateway public key (subscriptions only) | | `ar` | Auto-renew flag (subscriptions only) | | `pf` | Payment frequency (subscriptions only) | | `li` | Line items JSON (optional) | ## TributaryConfig Reference ### Subscription Mode ```typescript tributaryConfig: { gateway: string; // Required: Gateway public key recipient: string; // Required: Recipient wallet address trackingId: string; // Required: Your unique identifier autoRenew?: boolean; // Optional: Enable auto-renewal (default: false) memo?: string; // Optional: Payment memo } ``` ### One-Time Payment Mode ```typescript tributaryConfig: { recipient: string; // Required: Recipient wallet address trackingId: string; // Required: Your unique identifier memo?: string; // Optional: Payment memo } ``` ## Use Cases ### AI Agent Monetization (Lando) ```typescript // Service agent generates subscription URL const session = await payments.checkout.sessions.create({ payment_method_types: ["tributary"], line_items: [{ description: "AI Service Pro", unitPrice: 29, quantity: 1 }], paymentFrequency: "monthly", mode: "subscription", tributaryConfig: { gateway: "CwNybLVQ3sVmcZ3Q1veS6x99gUZcAF2duNDe3qbcEMGr", recipient: "SERVICE_AGENT_WALLET", trackingId: "agent-service-pro", }, }); // Customer agent visits URL, subscribes // Service agent receives recurring USDC automatically ``` ### Payment Links for Services ```typescript // Generate and share via email/SMS/chat const session = await payments.checkout.sessions.create({ payment_method_types: ["tributary"], line_items: [ { description: "Consulting Session", unitPrice: 99, quantity: 1 }, ], mode: "payment", tributaryConfig: { recipient: "YOUR_WALLET", trackingId: "consulting-session-001", }, }); console.log(`Pay here: ${session.url}`); ``` ## Testing Use devnet for testing: ```typescript const connection = new Connection("https://api.devnet.solana.com"); // Use devnet-fund wallet // Small test amounts recommended ``` ## Important Notes - **Gateway**: Use `CwNybLVQ3sVmcZ3Q1veS6x99gUZcAF2duNDe3qbcEMGr` (default Tributary gateway) - **Token Decimals**: Amounts in token units (USDC uses 6 decimals) - **Wallet Required**: Customers need a Solana wallet - **Non-Custodial**: Funds stay in user wallets ## Next Steps - [SDK Reference](https://docs.tributary.so/integration-guide/pull-payments/sdk/index.md) - Full SDK documentation - [Payment Types](https://docs.tributary.so/protocol-reference/payment-policy/subscription/index.md) - Subscriptions, milestones, pay-as-you-go - [Lando](https://lando.tributary.so) - AI-to-AI payments platform # Developer Tools Tributary provides a complete CLI for protocol management, along with testing utilities and deployment scripts. ## CLI (`@tributary-so/cli`) A command-line interface for managing Tributary recurring payments on Solana. ### Installation ```bash # From source cd apps/cli pnpm install pnpm run build # Global installation npm link ``` ### Global Options All commands require these options: | Option | Description | Example | | ---------------------------- | ------------------------- | ------------------------------------- | | `-c, --connection-url ` | Solana RPC connection URL | `https://api.mainnet-beta.solana.com` | | `-k, --keypath ` | Path to keypair JSON file | `~/.config/solana/id.json` | ### Program Administration #### Initialize Program ```bash tributary-cli -c https://api.devnet.solana.com -k ~/.config/solana/id.json \ initialize -a [ADMIN_PUBKEY] ``` ### Payment Gateway Management #### Create Gateway ```bash tributary-cli -c https://api.devnet.solana.com -k ~/.config/solana/id.json \ create-gateway \ -a [AUTHORITY_PUBKEY] \ -f 500 \ -r [FEE_RECIPIENT_PUBKEY] \ -n "My Gateway" \ -u "https://mygateway.com" ``` | Option | Description | | ------ | ---------------------------- | | `-a` | Gateway authority public key | | `-f` | Gateway fee in basis points | | `-r` | Fee recipient public key | | `-n` | Gateway name | | `-u` | Gateway URL | #### Delete Gateway ```bash tributary-cli delete-gateway -a [AUTHORITY_PUBKEY] ``` #### Update Gateway Settings ```bash # Change signer tributary-cli change-gateway-signer -a [AUTHORITY_PUBKEY] -s [NEW_SIGNER_PUBKEY] # Change fee recipient tributary-cli change-gateway-fee-recipient -a [AUTHORITY_PUBKEY] -r [NEW_FEE_RECIPIENT_PUBKEY] # Change fee basis points tributary-cli change-gateway-fee-bps -a [AUTHORITY_PUBKEY] -f 750 ``` #### Referral Settings ```bash tributary-cli update-gateway-referral-settings \ -a [AUTHORITY_PUBKEY] \ -f 1 \ -l 500 \ -t "100,50,25" ``` | Option | Description | | ------ | ---------------------------------------- | | `-f` | Feature flags (bit 0 = referral enabled) | | `-l` | Referral allocation in basis points | | `-t` | Comma-separated tier BPS (L1,L2,L3) | ### Subscription Management #### Create User Payment Account ```bash tributary-cli create-user-payment -t [TOKEN_MINT_PUBKEY] ``` #### Create Subscription ```bash tributary-cli create-subscription \ -t [TOKEN_MINT_PUBKEY] \ -r [RECIPIENT_PUBKEY] \ -g [GATEWAY_PUBKEY] \ -a 1000000 \ -m "Monthly subscription" \ --auto-renew \ --max-renewals 12 \ -f monthly \ --execute-immediately ``` | Option | Description | | ----------------------- | ---------------------------------------------------------------------------- | | `-t, --token-mint` | Token mint public key (required) | | `-r, --recipient` | Payment recipient public key (required) | | `-g, --gateway` | Payment gateway public key (required) | | `-a, --amount` | Payment amount in token base units (required) | | `-m, --memo` | Payment memo (default: "") | | `--auto-renew` | Enable auto-renewal (default: true) | | `--max-renewals` | Maximum number of renewals | | `-f, --frequency` | Payment frequency: daily, weekly, monthly, quarterly, semiAnnually, annually | | `--start-time` | Start time as Unix timestamp | | `--execute-immediately` | Execute first payment immediately | ### Payment Execution ```bash tributary-cli execute-payment -u [USER_PAYMENT_PDA] ``` ### Inspection Commands ```bash # List all gateways tributary-cli list-gateways # List all user payments tributary-cli list-user-payments # List policies by owner tributary-cli list-policies-by-owner -o [OWNER_PUBKEY] # List all payment policies tributary-cli list-payment-policies ``` ### PDA Utilities ```bash # Get program config PDA tributary-cli get-config-pda # Get gateway PDA tributary-cli get-gateway-pda -a [AUTHORITY_PUBKEY] # Get user payment PDA tributary-cli get-user-payment-pda -u [USER_PUBKEY] -t [TOKEN_MINT_PUBKEY] # Get payment policy PDA tributary-cli get-payment-policy-pda -u [USER_PAYMENT_PUBKEY] -p 1 # Get payments delegate PDA tributary-cli get-payments-delegate-pda ``` ## Environment Variables ```bash # Solana CLI configuration export SOLANA_RPC_URL="https://api.devnet.solana.com" # Default keypair path export SOLANA_KEYPAIR_PATH="~/.config/solana/id.json" ``` ## Troubleshooting ### Error: "Error reading keypair" - Ensure keypair file exists at specified path - Verify JSON format (array of numbers) ### Error: "Transaction simulation failed" - Check wallet has sufficient SOL for fees - Verify account ownership and permissions - Ensure program is properly initialized ### Error: "Account not found" - Confirm PDAs are correct using PDA utility commands - Check prerequisite accounts exist # JWT Authentication & JWKS After a user completes checkout, the merchant needs to know: *Did the payment actually happen? Is a subscription active?* Tributary answers with **cryptographically signed JWTs** that carry verifiable on-chain payment claims — no backend, no Solana SDK, no blockchain integration required. ## Why JWTs? The core problem: a checkout page redirects back to the merchant's site, but the merchant has no way to trust that redirect. They could query the blockchain directly, but that requires Solana RPC knowledge, PDA derivation, and account deserialization — a heavy lift for a simple "did this user pay?" check. JWTs solve this by packaging on-chain payment state into a **signed, verifiable token** that any web developer can validate with standard libraries. The merchant never talks to Solana — they just verify a JWT. ### Benefits of This Approach | Benefit | Explanation | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **No backend required** | Merchants can validate tokens client-side using the `jose` library. A static success page with JavaScript is enough. | | **No Solana code** | JWT verification is supported in every language. No Solana SDK, no RPC calls, no PDA derivation, no account deserialization. | | **Stateless** | Tokens are issued from on-chain data — no server-side sessions, no database lookups, no state to manage. | | **Self-contained** | All payment claims (amount, status, transaction signatures, next payment due) are inside the token. No extra API calls needed. | | **Automatic expiry** | Token TTL is tied to the next payment due date. A token for a monthly subscription expires just before the next payment — naturally prompting a refresh that fetches fresh on-chain state. | | **Key rotation built in** | JWKS with automatic 30-day rotation. Merchants fetch public keys from a well-known endpoint. Old keys stay valid for 24 hours during rotation — zero downtime. | | **Self-hostable** | Every component — checkout page, API server, indexer, signing keys — can run on your own infrastructure. Start hosted, migrate when ready. | | **Two payment models** | Same JWT flow handles both recurring subscriptions and one-time payments. The verification code is identical. | | **SDK provided** | `TributaryVerifier` from `@tributary-so/payments` handles JWKS fetching, signature verification, and payment matching in one call. | ### How It Works ```text ┌──────────┐ sign tx ┌──────────┐ POST /v1/tokens/issue ┌──────────┐ │ Checkout │─────────────►│ Solana │──────────────────────────►│ Tributary │ │ Page │ │ Blockchain│ │ API │ └──────────┘ └──────────┘ └──────────┘ │ │ │ │ tx confirmed signs JWT │ │ │ │ ◄──── redirect with JWT ──┘ │ │ │ │ ▼ ▼ │ ┌──────────┐ verify via JWKS ┌──────────────────────────┐ │ │ Merchant │─────────────────────►│ GET /.well-known/jwks.json│◄────────┘ │ Success │ └──────────────────────────┘ │ Page │ ◄── payment confirmed (recipient, wallet, memo matched) └──────────┘ ``` 1. User completes checkout → on-chain subscription created **or** one-time payment executed 1. Checkout app requests a JWT from the Tributary API 1. API reads on-chain state, builds payment claims, signs with ES256 key 1. User is redirected to the merchant's `successUrl` with `?token=` 1. Merchant calls `TributaryVerifier.verifyPayment()` or `verifySubscription()` 1. Verifier fetches JWKS, validates signature, matches payment claims — one call ## Two Token Types Tributary issues JWTs for two payment models. Both share the same JWKS infrastructure, signing keys, and verification flow. The difference is what's inside: | Token Type | When Issued | Claims | | -------------------- | ------------------------------ | ------------------------------------------------------------ | | **Subscription** | After on-chain policy creation | `subscriptions[]` (on-chain policy state) + `lastPayments[]` | | **One-Time Payment** | After a single USDC transfer | `lastPayments[]` only (no recurring policy) | ### Subscription Token Issued after a user creates a recurring subscription on-chain. Contains the policy state and recent payment history: ```json { "sub": "7xKpV2BZQ3HfeRZFMfWVBpDCmCN8eYwGmCjL7m3mVqR", "iss": "https://api.tributary.so", "aud": "tributary-checkout", "iat": 1743465600, "exp": 1743469200, "kid": "trib-2026-03-31-a", "subscriptions": [ { "policyAddress": "DxL...3kP", "policyId": 1, "recipient": "BxKp...9mVq", "gateway": "6ntm5rWqDFefET8RFyZV73FcdqxPMbc7Tso3pCMWk4w4", "amount": "100000", "paymentFrequency": "monthly", "totalPayments": 3, "nextPaymentDue": 1746057600, "status": "paid", "autoRenew": true, "maxRenewals": null, "createdAt": 1740787200 } ], "lastPayments": [ { "signature": "5UfK2hZ8rN3mQ9pL7wX1vB4cY6dA0eT2gR8nJ5sF3oH9kM7uP", "slot": 245123456, "timestamp": 1743465590, "policyAddress": "DxL...3kP", "amount": "100000", "tokenMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "payer": "7xKpV2BZQ3HfeRZFMfWVBpDCmCN8eYwGmCjL7m3mVqR", "recipient": "BxKp...9mVq", "gateway": "6ntm5rWqDFefET8RFyZV73FcdqxPMdc7Tso3pCMWk4w4", "memo": "user_123_monthly_premium", "recordId": 3 } ] } ``` ### One-Time Payment Token Issued after a single USDC transfer. Contains only the verified payment event — no recurring policy data: ```json { "sub": "7xKpV2BZQ3HfeRZFMfWVBpDCmCN8eYwGmCjL7m3mVqR", "iss": "https://api.tributary.so", "aud": "tributary-checkout", "iat": 1743465600, "exp": 1743469200, "kid": "trib-2026-03-31-a", "subscriptions": [], "lastPayments": [ { "signature": "5UfK2hZ8rN3mQ9pL7wX1vB4cY6dA0eT2gR8nJ5sF3oH9kM7uP", "slot": 245123456, "timestamp": 1743465590, "policyAddress": "11111111111111111111111111111111", "amount": "499900", "tokenMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "payer": "7xKpV2BZQ3HfeRZFMfWVBpDCmCN8eYwGmCjL7m3mVqR", "recipient": "BxKpT3mZQ5HgeRZFMfWVBpDCmCN8eYwGmCjL7m9mVq", "gateway": "6ntm5rWqDFefET8RFyZV73FcdqxPMbc7Tso3pCMWk4w4", "memo": "order_12345", "recordId": 0 } ] } ``` A one-time payment token has an empty `subscriptions` array and a single entry in `lastPayments`. The `policyAddress` is the system program address (all ones) since there's no recurring policy. ## Verifying Payments (Recommended) Use `TributaryVerifier` from `@tributary-so/payments` to verify JWTs. It handles JWKS fetching, signature verification, and payment matching in one call. No need to understand JWT claims, JWKS endpoints, or on-chain data structures. ### Install ```bash pnpm add @tributary-so/payments ``` No Solana libraries required. `jose` is included as a dependency. ### Initialize ```typescript import { TributaryVerifier } from "@tributary-so/payments"; // Uses TRIBUTARY_BASE_URL env var, defaults to https://api.tributary.so const verifier = new TributaryVerifier(); // Or configure explicitly (for self-hosted instances) const verifier = new TributaryVerifier({ baseUrl: "https://your-api.example.com", issuer: "https://your-api.example.com", audience: "tributary-checkout", }); ``` ### Verify a Subscription After a user completes a subscription checkout and lands on your success page: ```typescript // On your success page — extract token from URL const urlParams = new URLSearchParams(window.location.search); const token = urlParams.get("token"); try { const subscription = await verifier.verifySubscription(token, { recipient: "BxKp...9mVq", // your wallet (the payment recipient) wallet: "7xKp...3mVq", // user's wallet pubkey memo: "user_123_monthly", // the trackingId from your checkout session }); // If we get here, the subscription is confirmed: // - JWT signature is valid (signed by Tributary's JWKS key) // - Wallet matches the token's subject // - A subscription with status "paid" exists for your recipient // - A payment with matching memo exists in lastPayments console.log("Subscription confirmed!"); console.log(`Amount: ${subscription.amount}`); console.log(`Frequency: ${subscription.paymentFrequency}`); console.log( `Next payment due: ${new Date(subscription.nextPaymentDue * 1000)}` ); // Grant access grantUserAccess(subscription); // Store token for refreshes localStorage.setItem("tributary_token", token); } catch (e) { if (e instanceof SubscriptionVerificationError) { // Specific errors with details: // "Wallet mismatch: token issued for X, expected Y" // "Subscription found but not paid (status: overdue)" // "No active subscription found for recipient=..., wallet=..." // "Subscription is paid but no payment found with memo=..." console.error("Subscription verification failed:", e.message); showAccessDenied(e.message); } } // Clean URL window.history.replaceState({}, "", window.location.pathname); ``` ### Verify a One-Time Payment ```typescript try { const payment = await verifier.verifyPayment(token, { recipient: "BxKp...9mVq", // your wallet wallet: "7xKp...3mVq", // user's wallet memo: "order_12345", // tracking ID for this purchase }); // Payment confirmed — deliver the product console.log(`Payment confirmed: ${payment.amount} lamports`); console.log(`Transaction: ${payment.signature}`); console.log(`Paid at: ${new Date(payment.timestamp * 1000)}`); deliverProduct(payment); } catch (e) { if (e instanceof PaymentVerificationError) { // "Wallet mismatch: ..." // "No payment found matching recipient=..., wallet=..., memo=..." console.error("Payment verification failed:", e.message); showPaymentFailed(e.message); } } ``` ### Verify Either (Unified) If your product supports both models and you want one code path: ```typescript import { TributaryVerifier, PaymentVerificationError, SubscriptionVerificationError, } from "@tributary-so/payments"; const verifier = new TributaryVerifier(); async function confirmAnyPayment( token: string, opts: { recipient: string; wallet: string; memo: string; } ) { // Try subscription first try { const sub = await verifier.verifySubscription(token, opts); return { type: "subscription" as const, data: sub }; } catch {} // Fall back to one-time payment try { const payment = await verifier.verifyPayment(token, opts); return { type: "one_time" as const, data: payment }; } catch {} return null; } const result = await confirmAnyPayment(token, { recipient: YOUR_WALLET, wallet: userWallet, memo: trackingId, }); if (result?.type === "subscription") { grantSubscriptionAccess(result.data); } else if (result?.type === "one_time") { deliverProduct(result.data); } else { showAccessDenied(); } ``` ### Token Refresh When a token expires, refresh it to get updated on-chain state: ```typescript const stored = localStorage.getItem("tributary_token"); if (stored) { try { // Try verifying the existing token first await verifier.verify(stored); } catch (e) { if (e.code === "ERR_JWT_EXPIRED") { const response = await fetch(`${verifier.baseUrl}/v1/tokens/refresh`, { method: "POST", headers: { Authorization: `Bearer ${stored}` }, }); const { token: newToken } = await response.json(); localStorage.setItem("tributary_token", newToken); } } } ``` Refresh is **stateless** — the API re-queries the blockchain every time. The expired JWT serves as proof of identity (valid signature required), and the API builds a fresh token with current on-chain state. Expired tokens can be refreshed for up to 7 days; after that, a new token must be issued via `/v1/tokens/issue`. ### Error Handling All verification errors inherit from `VerificationError` and include descriptive messages: ```typescript import { VerificationError, PaymentVerificationError, SubscriptionVerificationError, } from "@tributary-so/payments"; try { await verifier.verifySubscription(token, opts); } catch (e) { if (e instanceof SubscriptionVerificationError) { // Subscription-specific failures: // - Wallet mismatch // - No subscription for recipient // - Subscription exists but not paid (includes actual status) // - Paid but memo doesn't match any payment } else if (e instanceof PaymentVerificationError) { // Payment-specific failures: // - Wallet mismatch // - No payment matching recipient + wallet + memo } else if (e instanceof VerificationError) { // JWT signature failures, expired tokens, wrong issuer/audience } } ``` ```text VerificationError (base) ├── PaymentVerificationError — verifyPayment failures └── SubscriptionVerificationError — verifySubscription failures ``` ### Self-Hosted Verification When running your own Tributary API instance, configure the verifier to point to your server: ```typescript const verifier = new TributaryVerifier({ baseUrl: "https://payments.yourcompany.com", issuer: "https://payments.yourcompany.com", }); ``` Or via environment variables: ```bash TRIBUTARY_BASE_URL=https://payments.yourcompany.com TRIBUTARY_ISSUER=https://payments.yourcompany.com ``` The verification code is identical regardless of whether you use the hosted or self-hosted API — only the base URL changes. ## Verifying Payments (Low-Level) If you're not using TypeScript/JavaScript, or need direct access to JWT claims, you can verify tokens manually using any JWT library that supports JWKS. ### Manual Verification with `jose` ```typescript import { jwtVerify, createRemoteJWKSet } from "jose"; const JWKS_URL = new URL("https://api.tributary.so/.well-known/jwks.json"); const jwks = createRemoteJWKSet(JWKS_URL); async function validateToken(token: string) { const { payload } = await jwtVerify(token, jwks, { issuer: "https://api.tributary.so", audience: "tributary-checkout", }); return payload; } ``` ### Manual Subscription Check ```typescript const payload = await validateToken(token); const subs = payload.subscriptions as Array; if (subs.length === 0) { return { active: false, reason: "no_subscriptions" }; } const sub = subs.find((s) => s.recipient === YOUR_WALLET); if (!sub) { return { active: false, reason: "not_your_subscription" }; } if (sub.status !== "paid") { return { active: false, reason: `subscription_${sub.status}` }; } // Verify payment exists with your tracking ID const payments = payload.lastPayments as Array; const matchingPayment = payments.find( (p) => p.recipient === YOUR_WALLET && p.payer === userWallet && (p.memo === trackingId || p.memo.includes(trackingId)) ); if (!matchingPayment) { return { active: false, reason: "no_matching_payment" }; } return { active: true, subscription: sub, payment: matchingPayment }; ``` ### Manual One-Time Payment Check ```typescript const payload = await validateToken(token); const subs = payload.subscriptions as Array; if (subs.length > 0) { return { valid: false, reason: "expected_one_time_got_subscription" }; } const payments = payload.lastPayments as Array; const payment = payments.find( (p) => p.recipient === YOUR_WALLET && p.payer === userWallet && (p.memo === trackingId || p.memo.includes(trackingId)) ); if (!payment) { return { valid: false, reason: "no_matching_payment" }; } return { valid: true, payment }; ``` ### Non-JavaScript Environments Any JWT library with JWKS support works. The verification parameters are: | Parameter | Value | | --------- | ------------------------------------------------ | | Algorithm | ES256 | | Issuer | `https://api.tributary.so` | | Audience | `tributary-checkout` | | JWKS URL | `https://api.tributary.so/.well-known/jwks.json` | After verifying the signature, inspect `payload.subscriptions` and `payload.lastPayments` to match against your expected recipient, wallet, and memo. ## Claim Reference ### Header ```json { "alg": "ES256", "kid": "trib-2026-03-31-a", "typ": "JWT" } ``` ### Subscription Claim Fields | Field | Type | Description | | ------------------ | ----------- | ------------------------------------------------- | | `policyAddress` | string | On-chain PaymentPolicy PDA address | | `policyId` | number | Policy ID within the UserPayment account | | `recipient` | string | Payment recipient's Solana pubkey | | `gateway` | string | PaymentGateway that processes this subscription | | `amount` | string | Payment amount in smallest token units (lamports) | | `paymentFrequency` | string | `"daily"`, `"weekly"`, `"monthly"`, etc. | | `totalPayments` | number | Total payments executed so far | | `nextPaymentDue` | number/null | Timestamp of next scheduled payment | | `status` | string | `"paid"`, `"overdue"`, or `"completed"` | | `autoRenew` | boolean | Whether subscription auto-renews | | `maxRenewals` | number/null | Max renewal count, null = unlimited | | `createdAt` | number | Timestamp of policy creation | ### Payment Record Fields (lastPayments) | Field | Type | Description | | --------------- | ------ | ------------------------------------------------- | | `signature` | string | Solana transaction signature | | `slot` | number | Solana slot number | | `timestamp` | number | Unix timestamp of the payment | | `policyAddress` | string | PaymentPolicy PDA, or system program for one-time | | `amount` | string | Payment amount in smallest token units | | `tokenMint` | string | SPL token mint address (e.g. USDC) | | `payer` | string | Payer's Solana pubkey | | `recipient` | string | Recipient's Solana pubkey | | `gateway` | string | PaymentGateway PDA address | | `memo` | string | Memo/tracking ID attached to the payment | | `recordId` | number | Payment record ID (0 for one-time payments) | ### Status Derivation (Subscriptions) | Condition | Status | | ------------------------------------------------------- | -------------------------- | | Policy cancelled/paused on-chain | Excluded from JWT entirely | | `totalPayments >= maxRenewals` (and maxRenewals is set) | `"completed"` | | `nextPaymentDue < now()` | `"overdue"` | | Otherwise | `"paid"` | ## Token Expiration Token TTL is tied to the payment schedule: ```text expiration = min( earliest nextPaymentDue + 10 minutes, now + 30 days ) ``` - **Monthly subscription** → JWT lives ~30 days (expires just before next payment) - **Weekly subscription** → JWT lives ~7 days - **Yearly subscription** → JWT capped at 30 days - **One-time payment** → Fixed TTL (1 hour default) Refreshing the token after payment fetches fresh state. Even yearly subscriptions are capped at 30-day TTL to limit stale data. ## API Endpoints ### Issue Token ```text POST /v1/tokens/issue Content-Type: application/json { "walletPublicKey": "7xKp...3mVq", "tokenMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "recipient": "BxKp...9mVq", "transactionSignature": "5Uf...9kT2" } ``` | Field | Required | Description | | ---------------------- | -------- | ---------------------------------------------- | | `walletPublicKey` | Yes | Payer's Solana wallet address | | `tokenMint` | No | SPL token mint (defaults to USDC) | | `recipient` | No | Filter claims to a specific recipient | | `transactionSignature` | No | Verify a specific one-time payment transaction | Unauthenticated — the JWT contains only public on-chain data. Any party can request a JWT for any wallet. The value comes from the cryptographic guarantee that claims match on-chain state, signed by Tributary's key. **Rate limit:** 10 requests/minute per wallet. ### Refresh Token ```text POST /v1/tokens/refresh Authorization: Bearer ``` Accepts expired JWTs (valid signature required, within 7-day grace window). Re-queries blockchain and returns a fresh token. **Rate limit:** 30 requests/minute per wallet. ### JWKS (Public Keys) ```text GET /.well-known/jwks.json ``` Returns all active public keys in standard JWKS format. Cached for 1 hour (`Cache-Control: public, max-age=3600`). ```json { "keys": [ { "kty": "EC", "crv": "P-256", "kid": "trib-2026-03-31-a", "alg": "ES256", "use": "sig", "x": "...", "y": "..." } ] } ``` ## Self-Hosting Every component of Tributary's JWT infrastructure can be self-hosted: | Component | What it does | Self-host? | | ------------- | ------------------------------------ | ---------- | | Checkout page | UI for wallet connection + payment | Yes | | API server | JWT issuance, JWKS, token refresh | Yes | | Indexer | Monitors on-chain payment events | Yes | | JWKS keys | Signing key management + rotation | Yes | | Facilitator | Triggers recurring payment execution | Yes | When self-hosting, configure `TributaryVerifier` with your base URL: ```typescript const verifier = new TributaryVerifier({ baseUrl: "https://payments.yourcompany.com", }); ``` Or set the `TRIBUTARY_BASE_URL` environment variable. ## Key Rotation Signing keys rotate automatically every 30 days: 1. New ES256 key pair generated 1. Old key marked as rotated with a 24-hour grace period 1. JWKS endpoint serves both old and new keys during grace period 1. After grace period, old key is removed from JWKS Merchants don't need to do anything — `TributaryVerifier` and `jose`'s `createRemoteJWKSet` handle key lookup automatically by matching the `kid` header. Admin override is available via `POST /v1/admin/keys/rotate` (requires admin API key). ## Security Model ### What the Merchant Can Trust - The payment exists on Solana — claims reflect on-chain state at issuance time - The user owns the wallet — `sub` claim identifies the wallet; checkout required wallet signature - Payment status is current — as of the token's `iat` timestamp - The token was issued by Tributary — cryptographic ES256 signature verification - Transaction signatures in `lastPayments` are real — they can be looked up on Solana Explorer - The `verifyPayment`/`verifySubscription` methods confirm recipient, wallet, **and** memo match — not just that a payment exists, but that it's **your** payment from **this** user for **this** tracking ID ### What the Merchant Cannot Trust - That the holder of the JWT is the wallet owner (JWTs are bearer tokens — they can be copied). For high-value operations, verify wallet ownership directly. - That the subscription is still active right now — the token reflects state at issuance. Call `/v1/tokens/refresh` for live data. ### Why the Memo Matters The `memo` parameter in `verifyPayment` and `verifySubscription` isn't optional — it's your strongest protection against replay attacks. Without memo matching, a malicious user could: 1. Create a subscription for Product A ($5/month) 1. Get a valid JWT 1. Present it as proof of payment for Product B ($50/month) By requiring the memo (your `trackingId` from checkout creation) to match, you ensure the JWT proves payment for **this specific product/order**, not just "some payment exists." ### Why This Is Safe - **No secrets in tokens** — JWTs contain only public on-chain data (amounts, recipients, payment status). No private keys, no balances. - **Short TTL** — tokens expire with the payment cycle, limiting exposure. - **ES256** — smaller tokens, faster verification than RSA alternatives. - **JWKS rotation** — compromised keys are rotated out automatically. - **No server sessions** — stateless issuance means nothing to leak from a database. - **Memo binding** — verification is scoped to your specific checkout session, not global. ## When Backend Integration Is Required **It usually isn't.** Most merchants can validate the JWT client-side and be done. The JWT gives you everything you need: subscription status, payment history, amounts, transaction signatures, next due date. Backend integration is only needed when you have **background processes that need to check payment status without user interaction** — for example: - A cron job that revokes access for overdue subscriptions - An automated system that sends payment reminders - A service that provisions resources based on subscription tier In those cases, the business runs their own **gateway/facilitator**. This is by design — the facilitator is the entity that triggers payment execution on-chain. It has direct visibility into whether a payment succeeded or failed because it's the one submitting the transactions. ### Decision Guide ```text Do you need to check payment status without a user present? ├── No → Client-side TributaryVerifier is sufficient └── Yes → Are you running your own gateway/facilitator? ├── Yes → Use facilitator's own transaction records └── No → Use /v1/tokens/issue or /v1/tokens/refresh from your backend ``` ## Dependencies ```bash # Recommended: use the SDK pnpm add @tributary-so/payments # Or, for manual verification (any language — here's the JS example) pnpm add jose ``` No Solana libraries required for JWT validation. That's the point. ## Environment Variables | Variable | Required | Description | | -------------------- | -------- | ----------------------------------------------------------- | | `TRIBUTARY_BASE_URL` | No | API base URL, defaults to `https://api.tributary.so` | | `TRIBUTARY_ISSUER` | No | Expected JWT issuer, defaults to `https://api.tributary.so` | | `TRIBUTARY_AUDIENCE` | No | Expected JWT audience, defaults to `tributary-checkout` | ## Next Steps - [Integration Options](https://docs.tributary.so/integration-guide/index.md) — choose your integration method - [Checkout](https://docs.tributary.so/integration-guide/pull-payments/checkout/index.md) — generate payment links with success URL - [Security Model](https://docs.tributary.so/protocol-reference/security/index.md) — protocol-level security details # Pull Payments Direct pull payments: a gateway pulls tokens from the user's token account on a schedule, with fees routed at settlement. ## Overview Tributary's original and simplest payment family. The user approves a **delegate** (the UserPayment PDA) on their SPL token account; a permissionless gateway signer then executes pulls that transfer directly from the user's account to the recipient, deducting protocol and gateway fees inline. There is no intermediate escrow, no swap, and no validation hook — just delegate, pull, settle. This page frames when to choose a pull payment over a programmable one and links out to the SDK, React, checkout, JWT, x402, and CLI integration paths. # SDKs for Developers Tributary provides multiple SDKs for different integration needs. ## TypeScript SDK (`@tributary-so/sdk`) Complete protocol interaction library with Anchor integration. ### Installation ```bash pnpm install @tributary-so/sdk @solana/web3.js @solana/spl-token @coral-xyz/anchor ``` ### Basic Setup ```typescript import { Tributary } from '@tributary-so/sdk'; import { Connection, PublicKey } from '@solana/web3.js'; import { AnchorProvider, Wallet } from '@coral-xyz/anchor'; const connection = new Connection('https://api.mainnet-beta.solana.com'); const wallet: Wallet = /* your connected wallet */; const tributary = new Tributary(connection, wallet); ``` ### Creating Subscriptions ```typescript import { BN } from "@coral-xyz/anchor"; import { PaymentFrequency, createMemoBuffer } from "@tributary-so/sdk"; const tokenMint = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); // USDC const recipient = new PublicKey("..."); const gateway = new PublicKey("..."); const instructions = await tributary.createSubscriptionInstruction( tokenMint, recipient, gateway, new BN(1000000), // 1 USDC true, // autoRenew 12, // maxRenewals { monthly: {} } as PaymentFrequency, createMemoBuffer("Monthly subscription", 64), undefined, // startTime new BN(12000000), // approvalAmount true // executeImmediately ); const tx = new Transaction().add(...instructions); const signature = await provider.sendAndConfirm(tx); ``` ### Creating Milestone Payments ```typescript import { BN } from "@coral-xyz/anchor"; const milestoneAmounts = [ new BN(50000000), // $50 - Initial setup new BN(75000000), // $75 - Core development new BN(100000000), // $100 - Final delivery ]; const milestoneTimestamps = [ new BN(Math.floor(Date.now() / 1000) + 86400 * 7), // 1 week new BN(Math.floor(Date.now() / 1000) + 86400 * 21), // 3 weeks new BN(Math.floor(Date.now() / 1000) + 86400 * 35), // 5 weeks ]; const milestoneIx = await tributary.createMilestonePaymentPolicy( tokenMint, recipient, gateway, milestoneAmounts, milestoneTimestamps, 0, // Time-based release condition createMemoBuffer("Website development project", 64) ); ``` ### Creating Pay-as-you-go Payments ```typescript const maxAmountPerPeriod = new BN(100000000); // $100 per period const maxChunkAmount = new BN(10000000); // $10 max per claim const periodLengthSeconds = new BN(86400 * 30); // 30 days const payAsYouGoIx = await tributary.createPayAsYouGoPaymentPolicy( tokenMint, recipient, gateway, maxAmountPerPeriod, maxChunkAmount, periodLengthSeconds, createMemoBuffer("AI API usage billing", 64) ); ``` ### Key Methods | Method | Description | | --------------------------------- | --------------------------------------------------- | | `createSubscriptionInstruction()` | Create subscription with all necessary instructions | | `createMilestonePaymentPolicy()` | Create milestone-based payment policy | | `createPayAsYouGoPaymentPolicy()` | Create usage-based payment policy | | `executePayment()` | Execute a payment for existing subscription | | `getAllUserPaymentsByOwner()` | Get all user payment accounts | | `getPaymentPoliciesByUser()` | Get all policies for a user | | `getPaymentPoliciesByRecipient()` | Get policies where user is recipient | | `changePaymentPolicyStatus()` | Pause or resume a subscription | | `deletePaymentPolicy()` | Cancel a subscription | ______________________________________________________________________ ## Payments SDK (`@tributary-so/payments`) Payments SDK for Tributary. Supports subscriptions and one-time payments with zero API keys required. It enables the use of a hosted checkout page that facilitates the payments via embedded solana wallet integrations. The merchant does not need to take care of anything blockchain related and can still verify payments through a JWT token handed out after payment succeeded. ### Installation ```bash pnpm install @tributary-so/payments @tributary-so/sdk @solana/web3.js ``` ### Basic Setup ```typescript import { PaymentsClient } from "@tributary-so/payments"; import { Connection } from "@solana/web3.js"; import { Tributary } from "@tributary-so/sdk"; const connection = new Connection("https://api.mainnet-beta.solana.com"); const tributary = new Tributary(connection, wallet); const payments = new PaymentsClient(connection, tributary); ``` ### Create Checkout Session ```typescript const session = await payments.checkout.sessions.create({ payment_method_types: ["tributary"], line_items: [ { description: "Monthly premium access", unitPrice: 20.0, quantity: 1, }, ], paymentFrequency: "monthly", mode: "subscription", success_url: "https://yourapp.com/success", cancel_url: "https://yourapp.com/cancel", tributaryConfig: { gateway: "GATEWAY_PUBLIC_KEY", recipient: "RECIPIENT_PUBLIC_KEY", trackingId: "user_123_monthly_premium", autoRenew: true, }, }); // Redirect to checkout window.location.href = session.url; ``` ### Check Subscription Status ```typescript // User-based lookup const status = await payments.subscriptions.checkStatus({ trackingId: "user_123_monthly_premium", userPublicKey: "USER_PUBLIC_KEY", tokenMint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", }); if (status.status === "active") { console.log("Active!", { paymentCount: status.paymentCount, nextPaymentDue: status.nextPaymentDue, }); } ``` ### One-Time Payments ```typescript const session = await payments.checkout.sessions.create({ payment_method_types: ["tributary"], line_items: [ { description: "Premium feature", unitPrice: 50.0, quantity: 1, }, ], mode: "payment", success_url: "https://yourapp.com/success", cancel_url: "https://yourapp.com/cancel", tributaryConfig: { recipient: "RECIPIENT_PUBLIC_KEY", trackingId: "user_123_premium_upgrade", }, }); ``` ### Check One-Time Payment Status ```typescript const status = await payments.payments.oneTime.checkStatus( "user_123_premium_upgrade" ); if (status.status === "paid") { console.log("Payment completed!", { transaction: status.transaction, paidAt: status.paidAt, }); } ``` ### Status Values **Subscriptions:** | Status | Description | | --------- | ----------------------------------- | | `pending` | Subscription not yet created | | `created` | On-chain, waiting for first payment | | `active` | First payment executed | | `failed` | Payment failed | **One-Time Payments:** | Status | Description | | --------- | ------------------------------------- | | `pending` | Payment not yet executed | | `paid` | SPL transfer found with matching memo | | `expired` | Payment window expired | ### Key Features - **Zero API Keys**: No registration, no configuration - **USDC Only**: Single currency support - **Dual Lookup**: User-based OR gateway-based status checking - **Base64URL Encoding**: Compact, shareable checkout URLs - **Real-time Status**: Live subscription status via PaymentPolicy ______________________________________________________________________ ## React SDK (`@tributary-so/sdk-react`) Pre-built payment components for React applications. ### Installation ```bash pnpm install @tributary-so/sdk-react @solana/web3.js @coral-xyz/anchor ``` ### Subscription Button ```typescript import { SubscriptionButton, PaymentInterval } from "@tributary-so/sdk-react"; import { PublicKey, BN } from "@solana/web3.js"; console.log("Success:", tx)} onError={(err) => console.error("Failed:", err)} />; ``` ### Payment Intervals ```typescript enum PaymentInterval { Daily, Weekly, Monthly, Quarterly, SemiAnnually, Annually, } ``` ______________________________________________________________________ ## x402 SDK (`@tributary-so/x402`) HTTP 402 payment protocol implementation for micropayments. ### Installation ```bash pnpm install @tributary-so/x402 @tributary-so/sdk @solana/web3.js express ``` ### Express Middleware ```typescript import { createX402Middleware } from "@tributary-so/x402"; import { Tributary } from "@tributary-so/sdk"; const tributary = new Tributary(connection, wallet); const middleware = createX402Middleware({ scheme: "deferred", // or "x402://payg" 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: tributary, connection, }); app.use("/api/premium", middleware); ``` ### Schemes | Scheme | Description | | ---------------- | ------------------------- | | `deferred` | Subscription-based access | | `x402://payg` | Metered pay-as-you-go | | `x402://prepaid` | Credit-based prepayment | ______________________________________________________________________ ## CLI (`@tributary-so/cli`) Command-line interface for protocol management. See [CLI Tools](https://docs.tributary.so/integration-guide/pull-payments/cli/index.md) for full documentation. ```bash # Create subscription tributary-cli -c https://api.mainnet-beta.solana.com -k ~/.config/solana/id.json \ create-subscription \ -t [TOKEN_MINT] \ -r [RECIPIENT] \ -g [GATEWAY] \ -a 1000000 \ -f monthly ``` # React SDK (`@tributary-so/sdk-react`) Drop-in payment components and React hooks for Tributary. Handles wallet interaction, transaction construction, signing, and confirmation — so you don't have to. ## Installation ```bash pnpm install @tributary-so/sdk-react @solana/wallet-adapter-react @solana/wallet-adapter-react-ui @solana/web3.js @coral-xyz/anchor ``` ## Wallet Provider Setup All components and hooks require `@solana/wallet-adapter-react` providers. Wrap your app once: ```typescript import { ConnectionProvider, WalletProvider, } from "@solana/wallet-adapter-react"; import { WalletModalProvider } from "@solana/wallet-adapter-react-ui"; import { PhantomWalletAdapter } from "@solana/wallet-adapter-phantom"; import { clusterApiUrl } from "@solana/web3.js"; const wallets = [new PhantomWalletAdapter()]; const endpoint = clusterApiUrl("mainnet-beta"); function App() { return ( {/* Your app */} ); } ``` ## SDK Components - [Hooks](https://docs.tributary.so/integration-guide/pull-payments/sdk-react/hooks/index.md) - [Quick Buttons](https://docs.tributary.so/integration-guide/pull-payments/sdk-react/buttons/index.md) - [Common Patterns](https://docs.tributary.so/integration-guide/pull-payments/sdk-react/common/index.md) # Payment Buttons Pre-built button components for each payment type. Render them, pass props, done. ## SubscriptionButton ```tsx import { SubscriptionButton, PaymentInterval } from "@tributary-so/sdk-react"; import { PublicKey, BN } from "@solana/web3.js"; const USDC_MINT = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); const RECIPIENT = new PublicKey("YOUR_RECIPIENT_WALLET"); const GATEWAY = new PublicKey("CwNybLVQ3sVmcZ3Q1veS6x99gUZcAF2duNDe3qbcEMGr"); console.log("Success:", result.txId)} onError={(err) => console.error("Failed:", err)} />; ``` ### SubscriptionButton Props | Prop | Type | Required | Description | | -------------------- | ------------------ | -------- | ------------------------------------------------------------------------------ | | `amount` | `BN` | Yes | Payment amount in smallest token units (USDC: 6 decimals) | | `token` | `PublicKey` | Yes | Token mint address | | `recipient` | `PublicKey` | Yes | Recipient wallet address | | `gateway` | `PublicKey` | Yes | Payment gateway address | | `interval` | `PaymentInterval` | Yes | Payment frequency | | `custom_interval` | `number` | No | Custom interval in seconds (required if interval is Custom) | | `maxRenewals` | `number` | No | Maximum renewals (null = unlimited) | | `memo` | `string` | No | Payment memo | | `startTime` | `Date` | No | Start date (null = now) | | `approvalAmount` | `BN` | No | Token approval amount | | `executeImmediately` | `boolean` | No | Execute first payment now (default: true) | | `fetchToken` | `boolean` | No | Fetch token info from issuer API before creating subscription (default: false) | | `tokenIssuerConfig` | `object` | No | Token issuer config: `{ apiBaseUrl: string, trackingId?: string }` | | `label` | `string` | No | Button text (default: "Subscribe") | | `className` | `string` | No | CSS classes | | `disabled` | `boolean` | No | Disable button | | `radius` | `string` | No | Button radius: `"none"` | | `size` | `string` | No | Button size: `"sm"` | | `onSuccess` | `(result) => void` | No | Success callback with `{ txId, instructions }` | | `onError` | `(error) => void` | No | Error callback | ## MilestoneButton ```tsx import { MilestoneButton } from "@tributary-so/sdk-react"; import { PublicKey, BN } from "@coral-xyz/anchor"; console.log("Milestone created:", result.txId)} />; ``` ### MilestoneButton Props | Prop | Type | Required | Description | | --------------------- | ------------------ | -------- | -------------------------------------------------- | | `milestoneAmounts` | `BN[]` | Yes | Payment amount per milestone (up to 4) | | `milestoneTimestamps` | `BN[]` | Yes | Unix timestamps for each milestone | | `releaseCondition` | `number` | Yes | 0 = time-based, 1 = manual approval, 2 = automatic | | `token` | `PublicKey` | Yes | Token mint address | | `recipient` | `PublicKey` | Yes | Recipient wallet address | | `gateway` | `PublicKey` | Yes | Payment gateway address | | `memo` | `string` | No | Payment memo | | `approvalAmount` | `BN` | No | Token approval amount | | `executeImmediately` | `boolean` | No | Execute first milestone now (default: true) | | `label` | `string` | No | Button text (default: "Create Milestone Payment") | | `className` | `string` | No | CSS classes | | `disabled` | `boolean` | No | Disable button | | `onSuccess` | `(result) => void` | No | Success callback with `{ txId, instructions }` | | `onError` | `(error) => void` | No | Error callback | ## PayAsYouGoButton ```tsx import { PayAsYouGoButton } from "@tributary-so/sdk-react"; import { PublicKey, BN } from "@coral-xyz/anchor"; console.log("Created:", result.txId)} />; ``` ### PayAsYouGoButton Props | Prop | Type | Required | Description | | --------------------- | ------------------ | -------- | ---------------------------------------------- | | `maxAmountPerPeriod` | `BN` | Yes | Spending cap per billing period | | `maxChunkAmount` | `BN` | Yes | Maximum per individual claim | | `periodLengthSeconds` | `BN` | Yes | Period duration in seconds | | `token` | `PublicKey` | Yes | Token mint address | | `recipient` | `PublicKey` | Yes | Recipient wallet address | | `gateway` | `PublicKey` | Yes | Payment gateway address | | `memo` | `string` | No | Payment memo | | `approvalAmount` | `BN` | No | Token approval amount | | `label` | `string` | No | Button text (default: "Create Pay-as-you-go") | | `className` | `string` | No | CSS classes | | `disabled` | `boolean` | No | Disable button | | `onSuccess` | `(result) => void` | No | Success callback with `{ txId, instructions }` | | `onError` | `(error) => void` | No | Error callback | ## Payment Intervals Used by `SubscriptionButton`: ```typescript enum PaymentInterval { Daily = "daily", Weekly = "weekly", Monthly = "monthly", Quarterly = "quarterly", SemiAnnually = "semiAnnually", Annually = "annually", Custom = "custom", } ``` Custom interval example: ```tsx ``` # Common Patterns ## Multiple Pricing Tiers ```tsx function PricingTiers() { const tiers = [ { name: "Basic", price: 5_000_000, label: "Subscribe Basic" }, { name: "Pro", price: 15_000_000, label: "Subscribe Pro" }, { name: "Enterprise", price: 99_000_000, label: "Subscribe Enterprise" }, ]; return (
{tiers.map((tier) => (

{tier.name} - ${tier.price / 1_000_000}/mo

))}
); } ``` ## Yearly with Discount ```tsx ``` ## Custom Hook with Status Tracking ```tsx function useSubscribeWithStatus() { const { createSubscription, loading, error } = useCreateSubscription(); const [txSignature, setTxSignature] = useState(null); const subscribe = async (params: CreateSubscriptionParams) => { setTxSignature(null); try { const result = await createSubscription(params); setTxSignature(result.txId); return result; } catch (err) { throw err; } }; return { subscribe, loading, error, txSignature }; } ``` ## Important Notes - **Wallet Required**: All hooks and buttons require a connected wallet via `@solana/wallet-adapter-react` - **Token Balance**: User must have sufficient token balance (USDC or configured token) - **SOL for Fees**: User needs SOL for transaction fees - **Default Gateway**: `CwNybLVQ3sVmcZ3Q1veS6x99gUZcAF2duNDe3qbcEMGr` - **Testing**: Use devnet (`https://api.devnet.solana.com`) for testing ## Next Steps - [SDK Reference](https://docs.tributary.so/integration-guide/pull-payments/sdk/index.md) — Full SDK documentation for all packages - [Checkout Links](https://docs.tributary.so/integration-guide/pull-payments/checkout/index.md) — Generate shareable payment URLs - [Integration Options](https://docs.tributary.so/integration-guide/index.md) — Compare all integration methods - [API Reference](https://docs.tributary.so/api/rest-api/index.md) — Check subscription status server-side # Hooks For full control over the UI, use hooks directly. Each hook manages its own loading/error state and returns an async function you call when ready. ## `useTributarySDK` > **Full example integration:** [github.com/tributary-so/tributary/showcase-payments](https://github.com/tributary-so/tributary/tree/develop/apps/showcase-payments) Returns an initialized `Tributary` SDK instance (or `null` if the wallet isn't connected). All other payment hooks depend on this internally — use it when you need raw SDK access. ```typescript import { useTributarySDK } from "@tributary-so/sdk-react"; function MyComponent() { const sdk = useTributarySDK(); if (!sdk) return

Connect your wallet

; // Use sdk.createSubscription(), sdk.createMilestone(), etc. directly const policies = await sdk.getUserPolicies(wallet.publicKey); } ``` **Returns:** `Tributary | null` ______________________________________________________________________ ## `useCreateSubscription` > **Full example integration:** [github.com/tributary-so/tributary/showcase-payments](https://github.com/tributary-so/tributary/tree/develop/apps/showcase-payments) Creates a subscription policy. Handles instruction building, transaction signing, and on-chain confirmation. ```typescript import { useCreateSubscription, PaymentInterval, } from "@tributary-so/sdk-react"; import { PublicKey, BN } from "@solana/web3.js"; function SubscribeForm() { const { createSubscription, loading, error } = useCreateSubscription(); const handleSubscribe = async () => { const result = await createSubscription({ amount: new BN(10_000_000), // 10 USDC token: new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"), recipient: new PublicKey("RECIPIENT_WALLET"), gateway: new PublicKey("CwNybLVQ3sVmcZ3Q1veS6x99gUZcAF2duNDe3qbcEMGr"), interval: PaymentInterval.Monthly, memo: "Pro plan", executeImmediately: true, }); console.log("Subscription created:", result.txId); }; return ( ); } ``` **Parameters:** `CreateSubscriptionParams` | Field | Type | Required | Description | | -------------------- | ----------------- | -------- | ----------------------------- | | `amount` | `BN` | Yes | Amount per cycle | | `token` | `PublicKey` | Yes | Token mint | | `recipient` | `PublicKey` | Yes | Recipient wallet | | `gateway` | `PublicKey` | Yes | Gateway address | | `interval` | `PaymentInterval` | Yes | Billing frequency | | `custom_interval` | `number` | No | Seconds (required for Custom) | | `maxRenewals` | `number` | No | Cap on renewals | | `memo` | `string` | No | On-chain memo | | `startTime` | `Date` | No | First payment date | | `approvalAmount` | `BN` | No | Token delegation amount | | `executeImmediately` | `boolean` | No | Charge first payment now | **Returns:** `{ createSubscription, loading, error }` ______________________________________________________________________ ## `useCreateMilestone` > **Full example integration:** [github.com/tributary-so/tributary/showcase-payments](https://github.com/tributary-so/tributary/tree/develop/apps/showcase-payments) Creates a milestone payment with up to 4 deliverable phases. Each milestone has its own amount and due timestamp. ```typescript import { useCreateMilestone } from "@tributary-so/sdk-react"; import { BN } from "@coral-xyz/anchor"; function MilestoneForm() { const { createMilestone, loading, error } = useCreateMilestone(); const handleCreate = async () => { const result = await createMilestone({ milestoneAmounts: [ new BN(3_000_000), new BN(3_000_000), new BN(4_000_000), ], milestoneTimestamps: [ new BN(Math.floor(Date.now() / 1000) + 86400 * 30), new BN(Math.floor(Date.now() / 1000) + 86400 * 60), new BN(Math.floor(Date.now() / 1000) + 86400 * 90), ], releaseCondition: 0, // time-based release token: USDC_MINT, recipient: RECIPIENT, gateway: GATEWAY, memo: "Website redesign project", executeImmediately: true, }); console.log("Milestones created:", result.txId); }; return ( ); } ``` **Parameters:** `CreateMilestoneParams` | Field | Type | Required | Description | | --------------------- | ----------- | -------- | ----------------------------------- | | `milestoneAmounts` | `BN[]` | Yes | Amount per milestone (max 4) | | `milestoneTimestamps` | `BN[]` | Yes | Unix timestamps for each milestone | | `releaseCondition` | `number` | Yes | 0=time-based, 1=manual, 2=automatic | | `token` | `PublicKey` | Yes | Token mint | | `recipient` | `PublicKey` | Yes | Recipient wallet | | `gateway` | `PublicKey` | Yes | Gateway address | | `memo` | `string` | No | On-chain memo | | `approvalAmount` | `BN` | No | Token delegation amount | | `executeImmediately` | `boolean` | No | Release first milestone now | **Returns:** `{ createMilestone, loading, error }` ______________________________________________________________________ ## `useCreatePayAsYouGo` > **Full example integration:** [github.com/tributary-so/tributary/showcase-payments](https://github.com/tributary-so/tributary/tree/develop/apps/showcase-payments) Creates a pay-as-you-go policy with a spending cap per period. The provider claims incrementally as the user consumes. ```typescript import { useCreatePayAsYouGo } from "@tributary-so/sdk-react"; import { BN } from "@coral-xyz/anchor"; function PayGoForm() { const { createPayAsYouGo, loading, error } = useCreatePayAsYouGo(); const handleCreate = async () => { const result = await createPayAsYouGo({ maxAmountPerPeriod: new BN(50_000_000), // 50 USDC per day maxChunkAmount: new BN(1_000_000), // Max 1 USDC per claim periodLengthSeconds: new BN(86400), // Daily period token: USDC_MINT, recipient: RECIPIENT, gateway: GATEWAY, memo: "API usage", }); console.log("Pay-as-you-go created:", result.txId); }; return ( ); } ``` **Parameters:** `CreatePayAsYouGoParams` | Field | Type | Required | Description | | --------------------- | ----------- | -------- | ---------------------------- | | `maxAmountPerPeriod` | `BN` | Yes | Spending cap per period | | `maxChunkAmount` | `BN` | Yes | Maximum per individual claim | | `periodLengthSeconds` | `BN` | Yes | Period duration in seconds | | `token` | `PublicKey` | Yes | Token mint | | `recipient` | `PublicKey` | Yes | Recipient wallet | | `gateway` | `PublicKey` | Yes | Gateway address | | `memo` | `string` | No | On-chain memo | | `approvalAmount` | `BN` | No | Token delegation amount | **Returns:** `{ createPayAsYouGo, loading, error }` ______________________________________________________________________ ## `useCheckoutSession` > **Full example integration:** [github.com/tributary-so/tributary/showcase-payments](https://github.com/tributary-so/tributary/tree/develop/apps/showcase-payments) Generates checkout URLs or redirects users to the hosted Tributary checkout page. Works for both one-time payments and subscriptions. No wallet connection required — the checkout page handles that. ```typescript import { useCheckoutSession } from "@tributary-so/sdk-react"; function CheckoutButton() { const { generateUrl, initiate } = useCheckoutSession( "https://checkout.tributary.so" ); const opts = { mode: "subscription" as const, tokenMint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", recipient: "YOUR_WALLET_ADDRESS", gateway: "CwNybLVQ3sVmcZ3Q1veS6x99gUZcAF2duNDe3qbcEMGr", amount: 10, // 10 USDC paymentFrequency: "monthly", trackingId: "user-pro-plan", memo: "Pro subscription", }; return (
Share payment link
); } ``` **Parameters:** `CheckoutOptions` | Field | Type | Required | Description | | ------------------ | ----------------------------- | -------- | --------------------------- | | `mode` | `"payment" \| "subscription"` | Yes | Payment type | | `tokenMint` | `string` | Yes | Token mint address | | `recipient` | `string` | Yes | Recipient wallet | | `gateway` | `string` | No | Gateway address | | `amount` | `number` | Yes | Payment amount | | `trackingId` | `string` | No | Your reference ID | | `successPath` | `string` | No | Redirect path after success | | `cancelPath` | `string` | No | Redirect path after cancel | | `paymentFrequency` | `string` | No | Frequency for subscriptions | | `autoRenew` | `boolean` | No | Auto-renew (default: true) | | `maxRenewals` | `number \| null` | No | Cap renewals | | `memo` | `string` | No | Payment memo | **Returns:** `{ generateUrl, initiate }` - `generateUrl(opts)` — returns a checkout URL string - `initiate(opts)` — redirects the browser to the checkout page ______________________________________________________________________ ## `useTributaryToken` > **Full example integration:** [github.com/tributary-so/tributary/showcase-payments](https://github.com/tributary-so/tributary/tree/develop/apps/showcase-payments) Verifies a Tributary JWT token (e.g., from a checkout callback) and decodes its payload. Use this to confirm an active subscription on the client side. ```typescript import { useTributaryToken } from "@tributary-so/sdk-react"; function SuccessPage() { const { token, payload, loading, error } = useTributaryToken(); if (loading) return

Verifying...

; if (error) return

Verification failed: {error}

; if (!payload) return

No token found

; return (

Subscription Active

Status: {payload.status}

Tracking ID: {payload.trackingId}

); } ``` The hook reads the token from the URL query parameter `?token=` by default. Pass a token string explicitly to override. ```typescript const { payload } = useTributaryToken(myJwtToken); ``` **Parameters:** `token?: string, baseUrl?: string` **Returns:** `{ token, payload, loading, error }` | Field | Type | Description | | --------- | ----------------------------- | ---------------------------- | | `token` | `string \| null` | Raw JWT string | | `payload` | `TributaryJWTPayload \| null` | Decoded and verified payload | | `loading` | `boolean` | Verification in progress | | `error` | `string \| null` | Verification error message | ______________________________________________________________________ ## `useTrackingId` > **Full example integration:** [github.com/tributary-so/tributary/showcase-payments](https://github.com/tributary-so/tributary/tree/develop/apps/showcase-payments) Fetches and verifies a payment token for a given tracking ID. Use this to confirm a user's subscription status on the client side by resolving a tracking ID (set during checkout) to a verified JWT payload. ```typescript import { useTrackingId } from "@tributary-so/sdk-react"; function SubscriptionStatus({ trackingId, recipient }) { const { payload, loading, error, refresh } = useTrackingId( trackingId, recipient, "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" ); if (loading) return

Loading...

; if (error) return

Error: {error}

; if (!payload) return

No active subscription

; return (

Status: {payload.status}

Tracking ID: {payload.trackingId}

); } ``` **Parameters:** | Parameter | Type | Required | Description | | ------------ | -------- | -------- | -------------------------------------------------- | | `trackingId` | `string` | Yes | Your reference ID set during checkout | | `recipient` | `string` | Yes | Recipient wallet address | | `tokenMint` | `string` | No | Token mint address to filter by | | `baseUrl` | `string` | No | API base URL (default: `https://api.tributary.so`) | **Returns:** `PaymentTokenState` | Field | Type | Description | | --------- | ----------------------------- | -------------------------------- | | `payload` | `TributaryJWTPayload \| null` | Decoded and verified JWT payload | | `loading` | `boolean` | Token fetch in progress | | `error` | `string \| null` | Error message if fetch failed | | `refresh` | `() => void` | Re-trigger the token fetch | # x402 API Reference Complete API reference for the x402 v2 implementation. ## X402Client The `X402Client` class provides client-side functionality for creating and managing payments. ### Constructor ```typescript new X402Client(config: X402ClientConfig) ``` **X402ClientConfig** | Property | Type | Required | Description | | ------------ | ------------------------------------------- | -------- | ----------------------------------------- | | `rpcUrl` | string | Yes | Solana RPC endpoint URL | | `wallet` | Keypair | Yes | Wallet for signing transactions | | `commitment` | `'processed' \| 'confirmed' \| 'finalized'` | No | Commitment level (default: `'confirmed'`) | ### Methods #### requestQuote() ```typescript async requestQuote(resourceUrl: string): Promise ``` Requests a payment quote from a server. **Parameters:** - `resourceUrl` (string): URL of the protected resource **Returns:** `Promise` - Payment requirements from the server **Example:** ```typescript const quote = await client.requestQuote("https://api.example.com/premium"); console.log(quote.amount); // 100000 console.log(quote.scheme); // 'x402://payg' ``` #### createPayment() ```typescript async createPayment(options: CreatePaymentOptions): Promise ``` Creates a payment for a resource in a single call. **Parameters:** - `options` (CreatePaymentOptions): Payment configuration **CreatePaymentOptions** | Property | Type | Required | Description | | --------------------- | ------------------------ | ------------- | ---------------------------------------- | | `resource` | string | Yes | Resource URL being accessed | | `scheme` | `deferred` | `x402://payg` | `x402://prepaid` | | `network` | string | Yes | CAIP-2 chain identifier | | `amount` | number | Yes | Payment amount in smallest units | | `recipient` | string | Yes | Recipient wallet address | | `gateway` | string | Yes | Gateway/facilitator address | | `tokenMint` | string | Yes | Token mint address | | `paymentFrequency` | `PaymentFrequencyString` | No | Payment frequency (subscriptions) | | `autoRenew` | boolean | No | Auto-renew flag (subscriptions) | | `maxRenewals` | number | null | No | | `maxAmountPerPeriod` | number | No | Max per period (pay-as-you-go) | | `periodLengthSeconds` | number | No | Period length in seconds (pay-as-you-go) | | `maxChunkAmount` | number | No | Max chunk amount (pay-as-you-go) | **Returns:** `Promise` - Payment result with JWT **Example:** ```typescript const result = await client.createPayment({ resource: "https://api.example.com/premium", scheme: "x402://payg", network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", amount: 100000, recipient: "8EVBv...", gateway: "ConT...", tokenMint: "4zMM...", maxAmountPerPeriod: 1000000, periodLengthSeconds: 86400, maxChunkAmount: 100000, }); console.log(result.jwt); // JWT token for future access ``` #### getPolicies() ```typescript async getPolicies(): Promise> ``` Gets all payment policies for the configured wallet. **Returns:** `Promise` - Array of policy summaries **Example:** ```typescript const policies = await client.getPolicies(); policies.forEach((p) => { console.log(`${p.address} - ${p.type} - ${p.active ? "active" : "inactive"}`); }); ``` ## X402Server The `X402Server` class provides server-side functionality for verifying and processing payments. ### Constructor ```typescript new X402Server(config: X402ServerConfig) ``` **X402ServerConfig** | Property | Type | Required | Description | | ------------ | ------------------------------------------- | -------- | ----------------------------------------- | | `rpcUrl` | string | Yes | Solana RPC endpoint URL | | `jwtSecret` | string | Yes | Secret for JWT token generation | | `commitment` | `'processed' \| 'confirmed' \| 'finalized'` | No | Commitment level (default: `'confirmed'`) | ### Methods #### middleware() ```typescript middleware(config: X402MiddlewareConfig): RequestHandler ``` Creates an Express middleware for payment verification. **Parameters:** - `config` (X402MiddlewareConfig): Middleware configuration **X402MiddlewareConfig** | Property | Type | Required | Description | | --------------------- | ---------- | ------------- | ---------------------------------------- | | `scheme` | `deferred` | `x402://payg` | Yes | | `network` | string | Yes | CAIP-2 chain identifier | | `amount` | number | Yes | Payment amount in smallest units | | `recipient` | string | Yes | Recipient wallet address | | `gateway` | string | Yes | Gateway/facilitator address | | `tokenMint` | string | Yes | Token mint address | | `paymentFrequency` | string | No | Payment frequency (subscriptions) | | `autoRenew` | boolean | No | Auto-renew flag (subscriptions) | | `maxRenewals` | number | null | No | | `maxAmountPerPeriod` | number | No | Max per period (pay-as-you-go) | | `periodLengthSeconds` | number | No | Period length in seconds (pay-as-you-go) | | `maxChunkAmount` | number | No | Max chunk amount (pay-as-you-go) | **Returns:** `RequestHandler` - Express middleware function **Example:** ```typescript const server = new X402Server({ rpcUrl: "https://api.devnet.solana.com", jwtSecret: process.env.JWT_SECRET!, }); app.use( "/api/premium", server.middleware({ scheme: "x402://payg", network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", amount: 100000, recipient: "8EVBv...", gateway: "ConT...", tokenMint: "4zMM...", maxAmountPerPeriod: 1000000, periodLengthSeconds: 86400, maxChunkAmount: 100000, }) ); ``` #### verifyPolicy() ```typescript async verifyPolicy(policyAddress: string): Promise ``` Verifies a payment policy on-chain. **Parameters:** - `policyAddress` (string): Policy PDA address **Returns:** `Promise` - Verification result **Example:** ```typescript const verification = await server.verifyPolicy("3Zr..."); if (verification.valid) { console.log(`Policy active: ${verification.type}`); console.log(`Total paid: ${verification.details.totalPaid}`); } ``` ## Usage Metering ### UsageTracker ```typescript new UsageTracker(config: UsageTrackerConfig) ``` Tracks usage for pay-as-you-go payments. **UsageTrackerConfig** | Property | Type | Required | Description | | --------------------- | ---------- | -------- | ------------------------------ | | `sdk` | Tributary | Yes | Tributary SDK instance | | `connection` | Connection | Yes | Solana connection | | `policyAddress` | string | Yes | Policy PDA address | | `periodLengthSeconds` | number | No | Period length (default: 86400) | | `limits` | Partial> | No | Resource limits | | `maxChunkAmount` | number | Yes | Maximum chunk amount | | `onLimitWarning` | Function | No | Warning callback | | `onLimitExceeded` | Function | No | Exceeded callback | **Methods:** | Method | Description | | ----------------------------------------- | -------------------------- | | `trackUsage(requestId, usage, metadata?)` | Track a request's usage | | `getCurrentPeriod()` | Get current period summary | | `getUsageSince(timestamp)` | Get usage since timestamp | | `checkQuota(resource, expected)` | Check remaining quota | | `resetPeriod()` | Reset current period | ### TokenMeter ```typescript // Parse usage from OpenAI response TokenMeter.fromOpenAI(response: { usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number } }): Partial> // Estimate tokens from text TokenMeter.estimateFromText(text: string): number // Estimate tokens from JSON TokenMeter.estimateFromJSON(json: unknown): number ``` ### ComputeMeter ```typescript // Calculate compute units for LLM ComputeMeter.calculateForLLM(model: string, inputTokens: number, outputTokens: number): number // Calculate compute units for embedding ComputeMeter.calculateForEmbedding(model: string, dimensions: number, inputTokens: number): number // Calculate compute units for fine-tuning ComputeMeter.calculateForFineTune(epochs: number, trainingExamples: number, modelSizeParams: number): number ``` ## Types ### PaymentQuote ```typescript interface PaymentQuote { scheme: PaymentScheme; network: string; resource: string; id: string; termsUrl: string; amount: number; currency: string; recipient: string; gateway: string; tokenMint: string; paymentFrequency?: string; autoRenew?: boolean; maxRenewals?: number | null; maxAmountPerPeriod?: number; periodLengthSeconds?: number; maxChunkAmount?: number; } ``` ### PaymentResult ```typescript interface PaymentResult { jwt: string; details: { policyAddress: string; scheme: PaymentScheme; paymentId: string; explorerUrl?: string; }; } ``` ### MeteredResource ```typescript type MeteredResource = | "requests" | "tokens.in" | "tokens.out" | "tokens.total" | "compute.units" | "time.ms" | "bytes.in" | "bytes.out" | "storage.bytes" | "storage.ops" | "credits" | "gpu.ms" | "fine_tune.ops" | "embedding.dims"; ``` ### PeriodSummary ```typescript interface PeriodSummary { startTime: number; endTime: number; totalUsage: Partial>; requestCount: number; totalCost: number; policyAddress: string; } ``` ## Utilities ### formatAmount() ```typescript function formatAmount(amount: number, decimals?: number): string; ``` Formats an amount from smallest units to human-readable format. **Example:** ```typescript formatAmount(1000000, 6); // "1.000000" ``` ### parseAmount() ```typescript function parseAmount(amount: string, decimals?: number): number; ``` Parses a human-readable amount to smallest units. **Example:** ```typescript parseAmount("1.5", 6); // 1500000 ``` ## HTTP Headers ### Payment Header **Client → Server** ```http Payment: ``` **Payload Format:** ```json { "x402Version": 2, "scheme": "x402://payg", "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "id": "payg_1699999999_abc123", "payload": { "serializedTransaction": "" } } ``` ### Payment-Required Header **Server → Client (402 Response)** ```http Payment-Required: scheme="x402://payg", network="solana:...", resource="https://...", id="...", amount=100000, currency="USDC", ... ``` ### Payment-Response Header **Server → Client (200 Response)** ```http Payment-Response: scheme="x402://payg", network="solana:...", id="payg_...", timestamp=1699999999 ``` ## Error Responses ### 402 Payment Required ```json { "accepts": [ { "scheme": "x402://payg", "network": "solana:...", "resource": "https://api.example.com/premium", "id": "payg_...", "amount": 100000, "currency": "USDC", "recipient": "...", "gateway": "...", "tokenMint": "...", "maxAmountPerPeriod": 1000000, "periodLengthSeconds": 86400, "maxChunkAmount": 100000 } ] } ``` ### 400 Bad Request ```json { "error": "Invalid Payment header format" } ``` ### 401 Unauthorized ```json { "error": "Invalid JWT token" } ``` ### 402 Payment Failed ```json { "error": "Payment processing failed", "details": "Transaction simulation failed" } ``` # x402 Getting Started Guide This guide will walk you through integrating x402 v2 payments into your application, whether you're building a server that accepts payments or a client that pays for resources. ## Prerequisites - Node.js 18+ - TypeScript knowledge - Solana wallet (for testing on devnet) - USDC tokens on devnet ## Installation ### For Server Implementation ```bash npm install @tributary-so/x402 express ``` ### For Client Implementation ```bash npm install @tributary-so/sdk-x402 @solana/web3.js ``` ## Step 1: Set Up Your Environment Create a `.env` file: ```text # RPC URL for Solana RPC_URL=https://api.devnet.solana.com # JWT secret for token generation JWT_SECRET=your-super-secret-jwt-key # Your wallet (base58 encoded private key) WALLET_PRIVATE_KEY=[YOUR_BASE58_PRIVATE_KEY] # Payment recipient (your wallet) RECIPIENT_WALLET=your_wallet_address_here # Gateway PDA from Tributary GATEWAY_PDA=ConTf7Qf3r1QoDDLcLTMVxLrzzvPTPrwzEYJrjqm1U7 # USDC token mint (devnet) TOKEN_MINT=4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU # Payment amount (in smallest units - 100000 = 0.1 USDC) PAYMENT_AMOUNT=100000 # Pay-as-you-go settings MAX_AMOUNT_PER_PERIOD=1000000 PERIOD_LENGTH_SECONDS=86400 MAX_CHUNK_AMOUNT=100000 ``` ## Step 2: Build a Payment-Enabled Server ### Basic Pay-as-you-go Server ```typescript // server.ts import express from "express"; import { X402Server } from "@tributary-so/sdk-x402"; import dotenv from "dotenv"; dotenv.config(); const app = express(); app.use(express.json()); const server = new X402Server({ rpcUrl: process.env.RPC_URL!, jwtSecret: process.env.JWT_SECRET!, }); // Apply x402 middleware to protected routes app.use( "/api/premium", server.middleware({ scheme: "x402://payg", network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", amount: parseInt(process.env.PAYMENT_AMOUNT!), recipient: process.env.RECIPIENT_WALLET!, gateway: process.env.GATEWAY_PDA!, tokenMint: process.env.TOKEN_MINT!, maxAmountPerPeriod: parseInt(process.env.MAX_AMOUNT_PER_PERIOD!), periodLengthSeconds: parseInt(process.env.PERIOD_LENGTH_SECONDS!), maxChunkAmount: parseInt(process.env.MAX_CHUNK_AMOUNT!), }) ); // Protected endpoint app.get("/api/premium", (req, res) => { res.json({ data: "🌟 This is premium content accessed via x402! 🌟", timestamp: new Date().toISOString(), }); }); // Health check (no payment required) app.get("/health", (req, res) => { res.json({ status: "healthy" }); }); const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`🚀 Server running on http://localhost:${PORT}`); console.log(`💰 Payment required for /api/premium`); }); ``` ### Subscription-Based Server ```typescript // subscription-server.ts import express from "express"; import { X402Server } from "@tributary-so/sdk-x402"; import dotenv from "dotenv"; dotenv.config(); const app = express(); app.use(express.json()); const server = new X402Server({ rpcUrl: process.env.RPC_URL!, jwtSecret: process.env.JWT_SECRET!, }); // Subscription middleware app.use( "/api/subscription", server.middleware({ scheme: "deferred", network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", amount: parseInt(process.env.PAYMENT_AMOUNT!), recipient: process.env.RECIPIENT_WALLET!, gateway: process.env.GATEWAY_PDA!, tokenMint: process.env.TOKEN_MINT!, paymentFrequency: "monthly", autoRenew: true, }) ); app.get("/api/subscription", (req, res) => { res.json({ data: "🔓 Premium subscription content", access: "unlimited for subscription period", }); }); app.listen(3000, () => { console.log("🚀 Subscription server running"); }); ``` ## Step 3: Build a Payment Client ### CLI Payment Client ```typescript // client.ts import { X402Client } from "@tributary-so/sdk-x402"; import { Keypair } from "@solana/web3.js"; import dotenv from "dotenv"; dotenv.config(); // Load wallet from environment const wallet = Keypair.fromSecretKey( Uint8Array.from(JSON.parse(process.env.WALLET_PRIVATE_KEY!)) ); const client = new X402Client({ rpcUrl: process.env.RECURSOR_URL || "https://api.devnet.solana.com", wallet, }); async function main() { const resourceUrl = process.argv[2] || "http://localhost:3000/api/premium"; console.log(`🔗 Requesting: ${resourceUrl}`); try { const result = await client.createPayment({ resource: resourceUrl, scheme: "x402://payg", network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", amount: parseInt(process.env.PAYMENT_AMOUNT!), recipient: process.env.RECIPIENT_WALLET!, gateway: process.env.GATEWAY_PDA!, tokenMint: process.env.TOKEN_MINT!, maxAmountPerPeriod: parseInt(process.env.MAX_AMOUNT_PER_PERIOD!), periodLengthSeconds: parseInt(process.env.PERIOD_LENGTH_SECONDS!), maxChunkAmount: parseInt(process.env.MAX_CHUNK_AMOUNT!), }); console.log("✅ Payment successful!"); console.log(`🎫 JWT: ${result.jwt.substring(0, 50)}...`); console.log(`📍 Policy: ${result.details.policyAddress}`); if (result.details.explorerUrl) { console.log(`🔗 Explorer: ${result.details.explorerUrl}`); } // Test accessing with JWT const response = await fetch(resourceUrl, { headers: { Authorization: `Bearer ${result.jwt}`, }, }); const content = await response.json(); console.log("📄 Content:", content); } catch (error) { console.error("❌ Payment failed:", error); process.exit(1); } } main(); ``` ### Programmatic Usage ```typescript import { X402Client } from "@tributary-so/sdk-x402"; import { Keypair } from "@solana/web3.js"; async function example() { const wallet = Keypair.generate(); // Or load from file/env const client = new X402Client({ rpcUrl: "https://api.devnet.solana.com", wallet, }); // Option 1: Create payment in one call const result = await client.createPayment({ resource: "http://localhost:3000/api/premium", scheme: "x402://payg", network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", amount: 100000, recipient: "8EVBvLDVhJUw1nkAUp73mPowviVFK9Wza5ba1GRANEw1", gateway: "ConTf7Qf3r1QoDDLcLTMVxLrzzvPTPrwzEYJrjqm1U7", tokenMint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", maxAmountPerPeriod: 1000000, periodLengthSeconds: 86400, maxChunkAmount: 100000, }); // Option 2: Request quote first const quote = await client.requestQuote("http://localhost:3000/api/premium"); console.log("Quote:", quote); // Then create payment manually if needed } example(); ``` ## Step 4: Test Your Integration ### Start the Server ```bash npx tsx server.ts ``` ### Run the Client ```bash npx tsx client.ts http://localhost:3000/api/premium ``` ### Expected Output ```text 🔗 Requesting: http://localhost:3000/api/premium ✅ Payment successful! 🎫 JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... 📍 Policy: 3Zr... 🔗 Explorer: https://explorer.solana.com/tx/... 📄 Content: {"data":"🌟 This is premium content..."} ``` ## Step 5: Verify on Solana Explorer 1. Copy the transaction signature from the output 1. Visit 1. Search for your transaction 1. Verify the payment policy was created ## Common Issues ### "Insufficient USDC balance" Get devnet USDC from the faucet: ```bash spl-token faucet --url devnet ``` ### "Wallet not found" Ensure your wallet has SOL for transaction fees: ```bash solana airdrop 2 --url devnet ``` ### "Policy not found" Verify the gateway PDA and recipient address are correct in your environment variables. ## Next Steps - [x402 API Reference](https://docs.tributary.so/integration-guide/pull-payments/x402/api-reference/index.md) - [Usage Metering](https://docs.tributary.so/integration-guide/pull-payments/x402/overview/#usage-metering) - [Pay-as-you-go Payments](https://docs.tributary.so/protocol-reference/payment-policy/payasyougo/index.md) - [Protocol Reference](https://docs.tributary.so/protocol-reference/overview/index.md) # x402 Payment Protocol x402 is an open, internet-native payment protocol that leverages the HTTP 402 "Payment Required" status code for seamless, low-fee digital dollar transactions. Tributary provides a complete implementation of x402 v2 with support for both subscription and pay-as-you-go payment models. ## Overview x402 enables frictionless payments for API resources and content without requiring users to register, provide credit cards, or complete complex authentication. Payments are embedded directly into the HTTP request/response flow, making it ideal for: - **AI Agents**: Autonomous agents that need to pay for services programmatically - **API Monetization**: Pay-per-use access to premium APIs and data sources - **Content Platforms**: Metered access to articles, videos, or digital goods - **Cloud Services**: Usage-based billing for compute, storage, or networking ## Key Features - **No Registration**: Users pay without creating accounts or providing personal information - **Non-Custodial**: Funds stay in user wallets until payment is executed - **Multi-Chain**: Support for Solana and EVM-compatible networks via CAIP-2 identifiers - **Flexible Payment Models**: Subscription (deferred) and pay-as-you-go schemes - **Low Fees**: Sub-cent transaction costs on Solana - **Fast Settlement**: Sub-second payment confirmation ## Payment Schemes ### Deferred (Subscription) The deferred scheme enables recurring subscriptions where payment is authorized once and executed automatically on a schedule. ```json { "scheme": "deferred", "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "amount": 100000, "currency": "USDC", "paymentFrequency": "monthly", "autoRenew": true } ``` ### Pay-as-you-go The `x402://payg` scheme enables usage-based billing with configurable limits per period. ```json { "scheme": "x402://payg", "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "amount": 100000, "maxAmountPerPeriod": 1000000, "periodLengthSeconds": 86400, "maxChunkAmount": 100000 } ``` ## Quick Start ### Server Implementation ```typescript import express from "express"; import { X402Server } from "@tributary-so/sdk-x402"; const app = express(); const server = new X402Server({ rpcUrl: "https://api.devnet.solana.com", jwtSecret: process.env.JWT_SECRET!, }); // Pay-as-you-go middleware app.use( "/api/premium", server.middleware({ scheme: "x402://payg", network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", amount: 100000, // 0.1 USDC recipient: "8EVBvLDVhJUw1nkAUp73mPowviVFK9Wza5ba1GRANEw1", gateway: "ConTf7Qf3r1QoDDLcLTMVxLrzzvPTPrwzEYJrjqm1U7", tokenMint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", maxAmountPerPeriod: 1000000, // $1 per day max periodLengthSeconds: 86400, maxChunkAmount: 100000, // $0.10 per request max }) ); app.listen(3000); ``` ### Client Implementation ```typescript import { X402Client } from "@tributary-so/sdk-x402"; const client = new X402Client({ rpcUrl: "https://api.devnet.solana.com", wallet: yourKeypair, }); // Create payment and get JWT const result = await client.createPayment({ resource: "https://api.example.com/premium", scheme: "x402://payg", network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", amount: 100000, recipient: "8EVBvLDVhJUw1nkAUp73mPowviVFK9Wza5ba1GRANEw1", gateway: "ConTf7Qf3r1QoDDLcLTMVxLrzzvPTPrwzEYJrjqm1U7", tokenMint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", }); // Use JWT for subsequent requests const response = await fetch("https://api.example.com/premium", { headers: { Authorization: `Bearer ${result.jwt}`, }, }); ``` ## HTTP Headers x402 v2 uses standard HTTP headers (no deprecated X-\* prefix): | Header | Direction | Description | | ------------------ | --------------- | ----------------------------------------- | | `Payment` | Client → Server | Base64-encoded payment payload | | `Payment-Required` | Server → Client | Payment requirements when 402 is returned | | `Payment-Response` | Server → Client | Payment confirmation details | | `Authorization` | Client → Server | JWT token for authenticated access | ## Payment Flow ### 1. Initial Request ```text GET /premium HTTP/1.1 Host: api.example.com Accept: application/json ``` ### 2. 402 Response with Payment Requirements ```text HTTP/1.1 402 Payment Required Payment-Required: scheme="x402://payg", network="solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", resource="https://api.example.com/premium", id="payg_1234567890_abc", amount=100000, currency="USDC", recipient="8EVBv...", gateway="ConT...", tokenMint="4zMM..." {"accepts":[{"scheme":"x402://payg",...}]} ``` ### 3. Payment Submission ```text GET /premium HTTP/1.1 Host: api.example.com Payment: eyJ4MDYyVmVyc2lvbiI6Miwi... HTTP/1.1 200 OK Payment-Response: scheme="x402://payg", network="solana:...", id="payg_1234567890_abc", timestamp=1699999999 {"jwt":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...","message":"Payment successful"} ``` ### 4. Subsequent Requests with JWT ```text GET /premium HTTP/1.1 Host: api.example.com Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... HTTP/1.1 200 OK {"data":"Premium content here"} ``` ## Middleware Options ### X402MiddlewareConfig | Parameter | Type | Required | Description | | --------------------- | ---------- | ------------- | ---------------------------------------- | | `scheme` | `deferred` | `x402://payg` | Yes | | `network` | string | Yes | CAIP-2 chain identifier | | `amount` | number | Yes | Payment amount in smallest units | | `recipient` | string | Yes | Recipient wallet address | | `gateway` | string | Yes | Gateway/facilitator address | | `tokenMint` | string | Yes | Token mint address | | `paymentFrequency` | string | No | Payment frequency (subscriptions) | | `autoRenew` | boolean | No | Auto-renew flag (subscriptions) | | `maxRenewals` | number | null | No | | `maxAmountPerPeriod` | number | No | Max per period (pay-as-you-go) | | `periodLengthSeconds` | number | No | Period length in seconds (pay-as-you-go) | | `maxChunkAmount` | number | No | Max chunk amount (pay-as-you-go) | ## Usage Metering The x402 implementation includes built-in usage metering for pay-as-you-go payments: ```typescript import { UsageTracker, TokenMeter, ComputeMeter } from "@tributary-so/x402"; // Track AI API usage const usage = TokenMeter.fromOpenAI(response); tracker.trackUsage(requestId, usage, { model: "gpt-4" }); // Get period summary const summary = tracker.getCurrentPeriod(); console.log(`Tokens used: ${summary.totalUsage["tokens.total"]}`); console.log(`Cost: ${summary.totalCost}`); ``` ## Integration with Tributary x402 builds on Tributary's smart contract infrastructure: - **Non-Custodial**: Funds stay in user wallets - **Automated Execution**: Smart contracts handle recurring payments - **Token Delegation**: One-time approval enables unlimited payments - **Fee Structure**: Protocol fees + gateway fees See [Pay-as-you-go Payments](https://docs.tributary.so/protocol-reference/payment-policy/payasyougo/index.md) and [Protocol Overview](https://docs.tributary.so/protocol-reference/overview/index.md) for more details. # Gateway Merchant Layer A gateway operator can see their **policies**, **subscribers**, **revenue/MRR**, and **export CSV** — all scoped to their gateway, all derived from the on-chain events stored in Postgres. This is an off-chain view; the protocol itself is unchanged. > See [ADR-0026](https://docs.tributary.so/operate/adr/0026-gateway-merchant-layer-off-chain-derived-analytics.md) for the rationale and rejected alternatives. ## What it is | Surface | Source | | --------------------------------------------- | ---------------------------------------------------------- | | `/v1/gateway/:g/merchant/policies` | All `PaymentPolicy` + `ComposablePolicy` under the gateway | | `/v1/gateway/:g/merchant/subscribers` | Distinct `payer` wallets from `PaymentRecord` events | | `/v1/gateway/:g/merchant/revenue` | MRR + recognized revenue + daily series | | `/v1/gateway/:g/merchant/export/*?format=csv` | CSV dump of any of the above | All merchant routes require a JWT obtained via the auth flow below. Public `/v1/events/*` routes are unchanged. ## Auth flow ```text ┌─────────────┐ 1. challenge ┌──────────┐ │ Client │ ───────────────▶ │ API │ │ (wallet) │ ◀─── nonce ────── │ │ │ │ │ │ │ 2. sign │ │ │ │ nonce │ │ │ │ │ 3. verify │ │ │ │ ───────────────▶ │ │ ──▶ RPC: getAccountInfo(gateway) │ │ │ │ confirm authority == signer │ │ ◀── JWT ───────── │ │ │ │ │ │ │ 4. fetch │ Authorization: │ │ │ merchant │ Bearer │ │ │ data │ ═══════════════▶ │ │ └─────────────┘ └──────────┘ ``` 1. `POST /v1/gateway/:gateway/auth/challenge` → `{ nonce, gateway, expiresAt }` 1. Client signs the nonce with `wallet.signMessage(new TextEncoder().encode(nonce))` 1. `POST /v1/gateway/:gateway/auth/verify` with `{ signer, signature: number[64] }` 1. API verifies the signature, fetches the on-chain `PaymentGateway` account, confirms `authority == signer`, and issues a 15-minute JWT carrying `{ gateway: }`. 1. Attach the JWT as `Authorization: Bearer ` to merchant requests. ## Key concepts ### Plan = Policy There is no on-chain "plan" concept. **A plan is a `PaymentPolicy` or `ComposablePolicy` created under this gateway.** Listing policies IS listing plans. The gateway operator does not register or curate plans off-chain. ### Subscriber = wallet only A subscriber is a distinct `payer` wallet from `PaymentRecord` events under this gateway. Identity = wallet address. No enriched profile (no email, name, billing address). ### MRR ```text MRR = Σ (Subscription.amount / frequency_to_months) over policies that are Active (not Deleted, not Paused) ``` - **PayAsYouGo / Milestone / OneTime / UpTo are EXCLUDED from MRR.** They surface as "recognized revenue" instead. - **NOT churn-adjusted.** Silent churn — delegate revoked, funds moved, payment simply stops — is invisible to the contract. There is no payment-failure event. "Active on-chain" ≠ "commercially active." ### Recognized revenue ```text recognized = Σ PaymentRecord.amount in the time window (all variants) ``` ### Currency Token units with mint label (e.g. "USDC"). **No fiat FX in v1.** ## Status derivation A policy's current state is derived by replaying its events: ```text PaymentPolicyCreated → Active + each StatusChanged → Active | Paused (most recent wins) + PolicyDeleted → Deleted (terminal) ``` For live truth, the endpoint MAY cross-check against the on-chain account via RPC. v1 trusts the event stream. ## Compute model & limits Aggregations run **on-the-fly** per request from the `events` table. - **No materialized snapshot table.** No nightly job. No reconciliation path. - **Documented ceiling:** revisit materialization if a gateway exceeds ~1k active policies. Below that, the request-time aggregation is well within budget. ## UI The gateway manage page (`/gateway/manage`) shows three new sections below the existing identity / fees / referral / keys sections: - `` — MRR (big number), recognized total, active-sub count, 14-day sparkline of recognized revenue. Includes the "Connect & sign" CTA that drives the auth flow. - `` — table with policy address, family, variant, status, amount, frequency, payment count, total paid, last payment. "Export CSV" button. - `` — table with wallet, policy count, total paid, last active. "Export CSV" button. All three are gated behind `isAuthority` and the merchant JWT. Sections stay hidden until the operator signs; signing is one click. ## What this is NOT (v1) - Churn analytics (silent-churn detection — deferred). - Invoicing / tax invoices / PDF receipts (deferred). - Enriched subscriber profiles (email/name/billing). - Off-chain plan registry / plan CRUD. - Multi-tenant SaaS, hosted platform, platform API keys. - Fiat FX / historical price feeds. - Materialized snapshot tables / nightly jobs. ## References - [ADR-0026 — Gateway merchant layer](https://docs.tributary.so/operate/adr/0026-gateway-merchant-layer-off-chain-derived-analytics.md) - `apps/api/src/services/gateway-auth.ts` - `apps/api/src/middleware/gateway-auth.ts` - `apps/api/src/db/merchant.ts` - `apps/api/src/routes/gateway.ts` - `apps/app/src/components/gateway/merchant/api.ts` - `apps/app/src/components/gateway/sections/{revenue,policies,subscribers}-section.tsx` # Building Payment Provider Services Payment providers are the bridge between end users and the Tributary protocol. They create the user experiences, business logic, and specialized services that make automated payments accessible and valuable. ## 🎯 What Payment Providers Do Payment providers build **complete payment solutions** on top of Tributary's infrastructure: ### Core Provider Responsibilities - **User Experience:** Beautiful, intuitive payment interfaces - **Business Logic:** Custom workflows and automation - **Customer Support:** Help users manage their payments - **Integration:** Connect with existing business systems - **Compliance:** Handle regulatory and tax requirements ## 🛠️ Essential Provider Services ### ✅ User Onboarding & Management **Smart Wallet Integration** - Seamless wallet connection flows - Multi-wallet support (Phantom, Solflare, etc.) - Mobile-friendly interfaces - Security education and best practices **Payment Setup Wizards** - Step-by-step subscription creation - Payment preview and confirmation - Token selection and approval flows - Error handling and user guidance **Account Management Dashboards** 🏗️ Work in Progress. Tributary is under active development and interfaces may change at any time." ```typescript // Example: User dashboard features interface UserDashboard { subscriptions: Subscription[]; paymentHistory: Payment[]; upcomingPayments: ScheduledPayment[]; totalSpending: TokenAmount; // Actions pauseSubscription(id: string): Promise; cancelSubscription(id: string): Promise; updatePaymentMethod(id: string, method: PaymentMethod): Promise; } ``` ### ✅ Payment Processing & Monitoring **Automated Execution Services** - Monitor payment schedules - Trigger payments at correct intervals - Handle failed payment retries - Manage payment success/failure states **Real-Time Notifications** 🏗️ Work in Progress. Tributary is under active development and interfaces may change at any time." ```typescript // Webhook system for payment events interface PaymentWebhook { event: "payment.success" | "payment.failed" | "payment.scheduled"; subscriptionId: string; amount: string; token: string; timestamp: number; transactionId: string; } // Provider webhook handler async function handlePaymentWebhook(webhook: PaymentWebhook) { switch (webhook.event) { case "payment.success": await activateUserService(webhook.subscriptionId); await sendSuccessNotification(webhook); break; case "payment.failed": await handleFailedPayment(webhook); await sendFailureNotification(webhook); break; } } ``` ### ✅ Analytics & Business Intelligence **Revenue Analytics** - Monthly recurring revenue (MRR) tracking - Customer lifetime value calculations - Churn analysis and predictions - Payment success rate optimization **Customer Insights** - Subscription behavior patterns - Usage-based billing analytics - Customer segmentation - Retention metrics ### ✅ Advanced Features **Flexible Billing Models** 🏗️ Work in Progress. Tributary is under active development and interfaces may change at any time." ```typescript // Provider-implemented billing logic class BillingService { async calculateUsageBasedBilling(customer: Customer) { const usage = await this.getCustomerUsage(customer.id); const tier = this.getTierForUsage(usage); return { baseAmount: tier.basePrice, usageAmount: usage * tier.perUnitPrice, total: tier.basePrice + usage * tier.perUnitPrice, }; } async handleTierUpgrade(customer: Customer, newTier: BillingTier) { // Prorated billing logic const prorationAmount = this.calculateProration(customer, newTier); await this.tributary.updatePaymentPolicy({ policyId: customer.policyId, amount: newTier.amount, }); } } ``` **Multi-Party Payment Splits** - Revenue sharing between multiple parties - Automated affiliate commissions - Creator royalty distributions - Platform fee management ## 🚀 Provider Service Examples ### SaaS Subscription Provider **"StreamlineSubscriptions"** **Services Offered:** - One-click subscription setup for SaaS businesses - Customer portal with pause/resume functionality - Failed payment recovery workflows - Integration with popular business tools (Slack, Discord, etc.) **Technical Implementation:** 🏗️ Work in Progress. Tributary is under active development and interfaces may change at any time." ```typescript class SaaSProvider { async createSubscription(business: Business, plan: Plan) { // Create Tributary payment policy const policy = await this.tributary.createPaymentPolicy({ amount: plan.monthlyPrice, interval: PaymentInterval.Monthly, recipient: business.walletAddress, maxRenewals: plan.maxRenewals, }); // Provider-specific features await this.setupCustomerPortal(policy.id, business.id); await this.configureFailureHandling(policy.id, business.retrySettings); await this.enableBusinessIntegrations(policy.id, business.integrations); return policy; } } ``` ### Creator Economy Provider **"CreatorFlow"** **Services Offered:** - Fan subscription management - Tiered membership plans - Exclusive content gating - Creator analytics dashboard **Revenue Model:** - 5% fee on all creator subscriptions - Premium analytics for $10/month - White-label solutions for $100/month ### DeFi Yield Provider **"YieldSubscriptions"** **Services Offered:** - Automated DeFi strategy subscriptions - Risk-adjusted pricing - Performance-based fee structures - Portfolio rebalancing automation **Technical Features:** - Integration with major DeFi protocols - Real-time yield tracking - Automated reinvestment options - Risk management controls ## 💰 Provider Business Models ### Revenue Streams 1. **Transaction Fees:** Percentage of payment volume 1. **Subscription Fees:** Monthly/annual provider access fees 1. **Premium Features:** Advanced analytics, integrations, support 1. **White-Label Solutions:** Custom-branded payment solutions 1. **Professional Services:** Setup, consulting, custom development ### Fee Structure Examples ```text Basic Provider: 2% + Tributary's 1% = 3% total Premium Provider: 1.5% + $50/month + Tributary's 1% Enterprise: Custom pricing + white-label features ``` ## 🔧 Technical Implementation ### Provider Architecture ```text ┌─────────────────────────────────────────────────┐ │ Provider Frontend │ │ • User dashboard │ │ • Subscription management │ │ • Analytics views │ └─────────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────────┐ │ Provider Backend │ │ • Business logic API │ │ • Webhook processing │ │ • Database management │ │ • Third-party integrations │ └─────────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────────┐ │ Tributary SDK │ │ • Protocol interactions │ │ • Payment management │ │ • Event monitoring │ └─────────────────────────────────────────────────┘ ``` ### Essential Provider Components **1. User Management System** 🏗️ Work in Progress. Tributary is under active development and interfaces may change at any time." ```typescript interface UserProfile { walletAddress: string; subscriptions: SubscriptionInfo[]; preferences: UserPreferences; paymentMethods: PaymentMethod[]; } ``` **2. Webhook Processing** 🏗️ Work in Progress. Tributary is under active development and interfaces may change at any time." ```typescript class WebhookProcessor { async processPaymentSuccess(webhook: PaymentWebhook) { await this.database.updatePaymentStatus(webhook.subscriptionId, "success"); await this.activateUserServices(webhook.subscriptionId); await this.sendConfirmationEmail(webhook); await this.updateAnalytics(webhook); } } ``` **3. Integration Layer** 🏗️ Work in Progress. Tributary is under active development and interfaces may change at any time." ```typescript class IntegrationService { async syncWithBusinessTools(subscription: Subscription) { // Slack integration await this.slack.addUserToChannel( subscription.userId, subscription.channelId ); // Discord integration await this.discord.assignRole(subscription.userId, subscription.roleId); // Custom API integrations await this.customAPI.activateUser(subscription.userId); } } ``` ## 🌟 Provider Success Factors ### User Experience Excellence - **Intuitive Interfaces:** Easy-to-understand payment flows - **Mobile Optimization:** Seamless mobile experiences - **Error Handling:** Clear error messages and recovery flows - **Performance:** Fast loading times and responsive interactions ### Business Value Creation - **Cost Reduction:** Lower payment processing costs than traditional methods - **Revenue Optimization:** Better retention through automated payments - **Global Reach:** Borderless payments with crypto - **Transparency:** Full payment history and audit trails ### Technical Reliability - **High Uptime:** Reliable service availability - **Security:** Protect user data and payment information - **Scalability:** Handle growing user bases - **Monitoring:** Proactive issue detection and resolution ## 🚀 Getting Started as a Provider ### 1. Technical Setup ```bash npm install @tributary-so/sdk npm install @tributary-so/sdk-react ``` ### 2. Basic Provider Service 🏗️ Work in Progress. Tributary is under active development and interfaces may change at any time." ```typescript import { Tributary } from "@tributary-so/sdk"; class MyPaymentProvider { constructor() { this.tributary = new Tributary({ connection: new Connection(SOLANA_RPC_URL), programId: TRIBUTARY_PROGRAM_ID, }); } async createUserSubscription(params: SubscriptionParams) { // Use Tributary protocol const result = await this.tributary.createSubscription(params); // Add provider value await this.saveToDatabase(result); await this.setupUserDashboard(result); await this.sendWelcomeEmail(result); return result; } } ``` ### 3. Launch Strategy 1. **Choose Your Niche:** Focus on specific use cases or industries 1. **Build MVP:** Start with core subscription management features 1. **User Testing:** Get feedback from early adopters 1. **Scale Features:** Add advanced analytics, integrations, premium features 1. **Business Development:** Partner with businesses that need your services The future of payments is automated, transparent, and user-controlled. Payment providers make this future accessible to everyone. Ready to start building? [Learn the Technical Details →](https://docs.tributary.so/protocol-reference/overview/index.md) # Accounts & PDAs This is the **#1 integration-bug source**. If you build on Tributary, read this page in full before writing any account-derivation code. The SDK helper in `packages/sdk/src/pda.ts` already encodes every seed below — prefer it over hand-rolling `PublicKey.findProgramAddress`. ## PDA seed table | PDA | Seeds | Account struct | Notes | | ------------------ | ------------------------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ProgramConfig` | `[b"config"]` | `ProgramConfig` | Singleton — one per program deployment. Holds admin, protocol fee recipient, protocol fee bps, `emergency_pause`. | | `PaymentGateway` | `[b"gateway", authority]` | `PaymentGateway` | Per gateway authority. Holds fees, signer, feature flags, referral config. | | `UserPayment` | `[b"user_payment", owner, mint]` | `UserPayment` | Per user + mint. **Is** the modern delegate. Tracks two independent counters. | | `PaymentPolicy` | `[b"payment_policy", user_payment, policy_id]` | `PaymentPolicy` | Regular pull-payment policy. `policy_id` = `created_policies_count` at creation. | | `ComposablePolicy` | `[b"composable_policy", user_payment, policy_id]` | `ComposablePolicy` | Programmable pull-payment policy. `policy_id` = `created_composable_count` at creation. | | `ValidationPda` | `[b"composable_validation", composable_policy]` | `ValidationPda` | Stores ≤512 bytes of Lighthouse assertion data for a composable policy. | | `PaymentsDelegate` | `[b"payments"]` | *(no struct)* | **Legacy / deprecated.** Global delegate from v0. Still accepted by `execute_payment` for backward compatibility. New code must use the `UserPayment` PDA. | | `ReferralAccount` | `[b"referral", gateway, referral_code]` | `ReferralAccount` | `referral_code` is a 6-byte alphanumeric code. `zero_copy` account. | All seeds are the literal byte strings from `programs/tributary/src/constants.rs` (`CONFIG_SEED`, `USER_PAYMENT_SEED`, `GATEWAY_SEED`, `PAYMENT_POLICY_SEED`, `PAYMENTS_SEED`, `REFERRAL_SEED`, `COMPOSABLE_POLICY_SEED`, `VALIDATION_PDA_SEED`). ### Counter separation `UserPayment` has **two independent monotonically-increasing counters**: - `created_policies_count` → next `PaymentPolicy.policy_id` - `created_composable_count` → next `ComposablePolicy.policy_id` A regular policy `#1` and a composable policy `#1` can coexist on the same `UserPayment` — they live in different PDA namespaces. IDs are never reused (counters only increment), which is why `policy_id` is part of the seed. ## Account field reference Sizes below include the 8-byte Anchor discriminator. "Padding" is reserved space for future fields — do **not** repurpose it. ### `ProgramConfig` (336 bytes) | Field | Type | Size | Description | | ------------------ | ----------- | ---- | -------------------------------------------------- | | `_discriminator` | `[u8; 8]` | 8 | Anchor discriminator | | `admin` | `Pubkey` | 32 | Can update protocol config / toggle pause | | `fee_recipient` | `Pubkey` | 32 | Receives protocol fees | | `protocol_fee_bps` | `u16` | 2 | Default 100 (1%). Max 10000. | | `_deprecated` | `u32` | 4 | Formerly `max_active_policies`. Tombstoned. | | `emergency_pause` | `bool` | 1 | When `true`, all execution fails (`ProgramPaused`) | | `bump` | `u8` | 1 | PDA bump | | `padding` | `[u8; 256]` | 256 | Reserved | ### `PaymentGateway` (344 bytes) | Field | Type | Size | Description | | ------------------------- | ----------- | ---- | ------------------------------------------------------------- | | `_discriminator` | `[u8; 8]` | 8 | | | `authority` | `Pubkey` | 32 | **Immutable** after creation. Gateway owner. | | `fee_recipient` | `Pubkey` | 32 | Receives gateway fees | | `gateway_fee_bps` | `u16` | 2 | Gateway cut. Combined with protocol fee must be `< 10000`. | | `is_active` | `bool` | 1 | | | `padding1` | `u64` | 8 | Tombstoned field | | `created_at` | `i64` | 8 | | | `bump` | `u8` | 1 | | | `name` | `[u8; 32]` | 32 | Human-readable gateway name | | `url` | `[u8; 64]` | 64 | Gateway service URL | | `signer` | `Pubkey` | 32 | Key authorized to call `execute_*` for this gateway | | `feature_flags` | `u8` | 1 | Bit 0 referral / Bit 1 net-amount / Bit 2 custom-protocol-fee | | `referral_allocation_bps` | `u16` | 2 | Bps **of the gateway fee** routed to referral pool. Cap 2500. | | `referral_tiers_bps` | `[u16; 3]` | 6 | Split of referral pool across L1/L2/L3. Must sum to 10000. | | `custom_protocol_fee_bps` | `u16` | 2 | Used only if `FEATURE_CUSTOM_PROTOCOL_FEE` set | | `padding` | `[u8; 117]` | 117 | Reserved | Feature-flag constants (on `PaymentGateway`): - `FEATURE_REFERRAL = 0x01` - `FEATURE_NET_AMOUNT = 0x02` — recipient gets exactly `amount`, fees added on top - `FEATURE_CUSTOM_PROTOCOL_FEE = 0x04` ### `UserPayment` (382 bytes) | Field | Type | Size | Description | | --------------------------- | ----------- | ---- | ---------------------------------- | | `_discriminator` | `[u8; 8]` | 8 | | | `owner` | `Pubkey` | 32 | The user | | `token_account` | `Pubkey` | 32 | User's ATA for `token_mint` | | `token_mint` | `Pubkey` | 32 | | | `active_policies_count` | `u32` | 4 | Live `PaymentPolicy` count | | `created_at` / `updated_at` | `i64` × 2 | 16 | | | `is_active` | `bool` | 1 | | | `bump` | `u8` | 1 | | | `created_policies_count` | `u32` | 4 | Counter for `PaymentPolicy` IDs | | `rent_payer` | `Pubkey` | 32 | Refunded on close | | `active_composable_count` | `u32` | 4 | Live `ComposablePolicy` count | | `created_composable_count` | `u32` | 4 | Counter for `ComposablePolicy` IDs | | `padding` | `[u8; 210]` | 210 | Reserved | > **The `UserPayment` PDA is the delegate.** When a user calls `spl-token approve `, the program can sign CPIs as that PDA to pull tokens. The legacy global `PaymentsDelegate` (`[b"payments"]`) still works for backward compat but must not be used by new integrations. ### `PaymentPolicy` (602 bytes) | Field | Type | Size | Description | | --------------------------- | --------------- | ---- | --------------------------------------------------------------------------- | | `_discriminator` | `[u8; 8]` | 8 | | | `user_payment` | `Pubkey` | 32 | Parent `UserPayment` | | `recipient` | `Pubkey` | 32 | Final funds recipient | | `gateway` | `Pubkey` | 32 | Executing gateway | | `policy_type` | `PolicyType` | 129 | 1-byte tag + 128-byte body (Subscription/Milestone/PayAsYouGo/OneTime/UpTo) | | `status` | `PaymentStatus` | 1 | `Active` / `Paused` | | `memo` | `[u8; 64]` | 64 | Human-readable description | | `total_paid` | `u64` | 8 | Cumulative payout | | `payment_count` | `u32` | 4 | Executions so far | | `created_at` / `updated_at` | `i64` × 2 | 16 | | | `policy_id` | `u32` | 4 | Unique within this `UserPayment` | | `bump` | `u8` | 1 | | | `rent_payer` | `Pubkey` | 32 | Refunded on close | | `padding` | `[u8; 223]` | 223 | Reserved | ### `ComposablePolicy` | Field | Type | Size | Description | | ------------------------------ | ------------------ | ---- | -------------------------------------------- | | `_discriminator` | `[u8; 8]` | 8 | | | `bump` | `u8` | 1 | | | `user_payment` | `Pubkey` | 32 | | | `gateway` | `Pubkey` | 32 | | | `status` | `PolicyStatus` | 1 | `Active` / `Paused` / `Completed` | | `rent_payer` | `Pubkey` | 32 | | | `policy_type` | `PolicyType` | 129 | Same enum as `PaymentPolicy` | | `forward_config` | `ForwardConfig` | 146 | See below | | `validation_config` | `ValidationConfig` | 33 | See below | | `memo` | `[u8; 64]` | 64 | | | `recipient` | `Pubkey` | 32 | | | `total_input` / `total_output` | `u64` × 2 | 16 | Cumulative (input pulled / output delivered) | | `payment_count` | `u32` | 4 | | | `policy_id` | `u32` | 4 | | | `created_at` / `updated_at` | `i64` × 2 | 16 | | | `padding` | `[u8; 32]` | 32 | Reserved | #### `ForwardConfig` (146 bytes) | Field | Type | Size | Notes | | ------------------- | --------------------- | ---- | --------------------------------------------- | | `target_program` | `Pubkey` | 32 | `Pubkey::default()` = **disabled** (sentinel) | | `input_mint` | `Pubkey` | 32 | Must equal `user_payment.token_mint` | | `output_mint` | `Pubkey` | 32 | Recipient delivery mint | | `min_output_amount` | `Option` | 9 | **Net** (post-fee) minimum. DeFi convention. | | `forward_flags` | `u8` | 1 | Bit 0 = `NATIVE_OUTPUT` (WSOL → SOL sweep) | | `num_data_checks` | `u8` | 1 | 0–4 | | `data_checks` | `[ByteRangeCheck; 4]` | 40 | Each: `offset`, `length`, `expected[8]` | `target_program` must be in `ALLOWED_FORWARD_PROGRAMS` (currently Meteora DLMM `LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo`). When enabled, **at least one** `ByteRangeCheck` must pin the discriminator at offset 0 (`DiscriminatorCheckRequired`). #### `ValidationConfig` (33 bytes) | Field | Type | Size | Notes | | ------------------------- | -------- | ---- | ----------------------------------------- | | `validation_program` | `Pubkey` | 32 | `SystemProgram` = **disabled** (sentinel) | | `num_validation_accounts` | `u8` | 1 | 0–10 read-accounts for the assertion | `validation_program` must be in `ALLOWED_VALIDATION_PROGRAMS` (currently Lighthouse `L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95`). The assertion data itself lives in a separate `ValidationPda`. ### `ValidationPda` (1034 bytes max) | Field | Type | Size | Notes | | ---------------- | ----------- | ---- | ------------------------------------------------------- | | `_discriminator` | `[u8; 8]` | 8 | | | `data_len` | `u16` | 2 | Actual bytes used | | `data` | `[u8; 512]` | 512 | Lighthouse assertion bytes (`MAX_VALIDATION_DATA_SIZE`) | Space is rounded up to 8-byte alignment via `ValidationPda::space_for()` for rent efficiency. Use the smallest size that fits the assertion. ### `ReferralAccount` (152 bytes, `zero_copy`) | Field | Type | Size | Notes | | ---------------- | ---------- | ---- | ---------------------------- | | `_discriminator` | `[u8; 8]` | 8 | | | `gateway` | `Pubkey` | 32 | Scopes the code | | `owner` | `Pubkey` | 32 | Earns rewards | | `referral_code` | `[u8; 6]` | 6 | Alphanumeric | | `_padding_code` | `[u8; 2]` | 2 | Alignment | | `referrer` | `Pubkey` | 32 | Parent in chain (or default) | | `created_at` | `i64` | 8 | | | `total_earned` | `u64` | 8 | Cumulative | | `bump` | `u8` | 1 | | | `_padding_bump` | `[u8; 7]` | 7 | Alignment | | `_padding` | `[u64; 8]` | 64 | Reserved | Chains are validated on execute: max depth 3, no cycles, strict ordering in `remaining_accounts`. ## Rent exemption Every Tributary-created account stores a `rent_payer: Pubkey` field (where present). On account close (`delete_user_payment`, `delete_payment_policy`, `delete_composable_policy`, etc.) the lamports are returned to `rent_payer`, **not** to the signer of the close instruction. This makes it possible for a gateway or dApp to sponsor account creation up-front and recover the rent when the relationship ends. - Closing a `UserPayment` requires `active_policies_count == 0` **and** `active_composable_count == 0` (`HasActivePolicies` / `HasActiveComposables`). - `rent_payer` validity is enforced — passing an unrelated key fails with `InvalidRentPayer`. ## Legacy / deprecated ### `PaymentsDelegate` PDA — `[b"payments"]` Single global delegate from the v0 program. **Still accepted** by `execute_payment` for backward compatibility with already-approved token accounts, but: - **Do not use for new integrations.** Use the per-user `UserPayment` PDA. - The program writes new policies against the `UserPayment` delegate. - If both delegates are approved on the same ATA, the program checks `UserPayment` first. ### ProgramConfig `_deprecated: u32` Was `max_active_policies_per_user`. The on-chain cap was removed; the field is tombstoned (`_deprecated`) and ignored. ### PaymentGateway `padding1: u64` Tombstoned former field. Ignored. ## Where sizes come from Every `impl ` in `programs/tributary/src/state/*.rs` defines a `const SIZE`. The `PolicyType` variants are pinned at 128 bytes body by `PolicyType::VARIANT_SIZE`; do not change this without a full migration. Account padding is reserved for future fields and must not be repurposed — deserialization depends on exact sizes. # Changelog All notable changes to the Tributary protocol are documented here. Dates are the date a change landed on Mainnet (or `Unreleased` while in review). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] — Composable Policy Release The composable-policy family merges into the regular program: validation hooks (Lighthouse) and forward hooks (Meteora DLMM) are now first-class citizens alongside direct pull payments. ### Added - **`PolicyType::OneTime`** (discriminator `3`) — fixed-amount, single-fire pull payment with the full gateway lifecycle (PDA, pausable, deletable, schedulable, composable hooks). `due_date <= 0` = immediate; `expiry_date = None` = never expires. See ADR-0019. - **`PolicyType::UpTo`** (discriminator `4`) — single-use, time-bound variable-amount authorization. Caller-supplied settle amount bounded by `max_amount` (`0 <= actual <= max`, enforced on-chain from the immutable policy). Recipient-triggerable like Pay-as-you-go. The x402 `upto` primitive. See ADR-0020. - **`PolicyExpired`** error variant — shared by OneTime's `expiry_date` and UpTo's `deadline` gates. - **SDK** — `getCreateOneTimePolicyInstruction` / `createOneTimePayment` and `getCreateUpToPolicyInstruction` / `createUpToAuthorization` / `settleUpTo`. - **x402 package** — `"x402://upto"` scheme with verify + settle helpers (`src/upto.ts`) and short-lived JWT (`exp = deadline`). - **`ComposablePolicy`** account and instructions (`create_composable_policy`, `execute_composable`, `change_composable_status`, `delete_composable_policy`) — programmable pull payments with optional validation + forward hooks executed between the pull and the settlement. - **`ValidationPda`** account (`["composable_validation", composable_policy]`) — separate account for Lighthouse assertion data (≤512 bytes), decoupling storage cost from the `ComposablePolicy` itself. - **`ForwardConfig` + `ByteRangeCheck`** — on-chain instruction-data validation for forward CPIs. Up to 4 byte-range checks per policy; at least one must pin the discriminator at offset 0. - **`ValidationConfig`** — opt-in validation hook; `SystemProgram` sentinel disables it. - **`NATIVE_OUTPUT` forward flag** (`FORWARD_FLAG_NATIVE_OUTPUT = 1`) — WSOL → native SOL sweep via a Tributary-controlled `closeAccount` whose destination is pinned to `recipient` on-chain. Requires `output_mint == NATIVE_MINT`. - **Lighthouse SDK facade** in `@tributary-so/sdk` — fluent builder for assertion data (`lighthouse.tokenAccount(ata).amount(n, "<").build()`), wrapping the vendored official Lighthouse client. - **Referral program** — `ReferralAccount`, 3-level chain, configurable gateway-scoped allocation and tiers. - **`ProgramConfig.emergency_pause`** — global pause flag that blocks all `execute_payment` and `execute_composable` calls. - **`PaymentsDelegate`** legacy PDA accepted for backward compatibility with v0-approved token accounts. ### Changed - **`PolicyType` enum is now shared** between `PaymentPolicy` and `ComposablePolicy`. Previously composable had a duplicate `ScheduleType` that drifted in month arithmetic; both now use the unified, 128-byte-fixed enum. See `reports/M-04-inconsistent-month-arithmetic.md`. - **`UserPayment` is the modern delegate.** `execute_payment` / `execute_composable` sign as the per-(owner, mint) `UserPayment` PDA; the legacy global `PaymentsDelegate` PDA remains accepted but is not written for new policies. - **`PaymentGateway`** gains `feature_flags`, `referral_allocation_bps`, `referral_tiers_bps`, `custom_protocol_fee_bps`, and `signer` fields. - **Fee math**: `gateway_fee_bps + effective_protocol_fee_bps` must be strictly `< 10000` (was: `<= 10000`). At exactly 10000 the recipient received zero; the new check rejects that config. - **`min_output_amount`** on `ForwardConfig` is now checked against the **net** (post-fee) output, matching DeFi `amountOutMin` convention. ### Security - **Intermediate ATA ownership decoupled** — intermediate input/output ATAs are owned by the `ComposablePolicy` PDA, **not** the `UserPayment` PDA. This means a forward program can only ever touch transient intermediate balances, never the user's source funds. - **CPI signer sanitization (C-1)** — validation and forward CPI builders no longer forward `is_signer` from `remaining_accounts`. Closes a privilege-pass-through vector where the fee payer (a Signer) re-passed as a remaining account could grant Lighthouse / DLMM unintended signer authority. - **Allowlists** for forward (`ALLOWED_FORWARD_PROGRAMS` — Meteora DLMM) and validation (`ALLOWED_VALIDATION_PROGRAMS` — Lighthouse) target programs. Sentinels (`Pubkey::default()` / `SystemProgram`) disable the hooks. - **`ByteRangeCheck` length unbounded (H-06)** — create-time guard now rejects `length > 8`; runtime `validate` also defends against the panic for hostile / malformed accounts. - **Manual `ValidationPda` init freshness check (M-02)** — explicit `is_fresh` check against pre-funded or wrong-owner accounts to prevent type cosplay and re-initialization. - **Combined-fee underflow (M5)** — `validate_combined_bps` now runs after both fee fields reach their final values, preventing an `ArithmeticOverflow` on every payment through misconfigured gateways. ### Removed - Five dead error variants purged during the ponytail #8 cleanup of `error.rs` (post-cleanup variant count: **58**). ## [Pre-release] — Direct Pull Payments The original program: `PaymentPolicy` (Subscription / Milestone / PayAsYouGo) with direct gateway pulls, `PaymentGateway`, `UserPayment`, `ProgramConfig`, and the `transfer` helper. ### Added - `initialize`, `create_user_payment`, `create_payment_gateway`, `create_payment_policy`, `execute_payment`, `change_payment_policy_status`, `delete_payment_policy`, `delete_user_payment`, `delete_payment_gateway`, `change_gateway_signer`, `change_gateway_fee_recipient`, `change_gateway_fee_bps`, `transfer`. - `ProgramConfig` with admin, protocol fee recipient, `protocol_fee_bps` (default 100). - `PaymentGateway` with per-authority fees and signer. - `PolicyType::Subscription`, `Milestone`, `PayAsYouGo` — fixed 128-byte variants. ## Versioning notes - Tributary does **not** maintain separate program IDs per environment — Devnet and Mainnet share `TRibg8W8zmPHQqWtyAD1rEBRXEdyU13Mu6qX1Sg42tJ`. - Account layouts are padded for forward-compatibility; adding fields consumes reserved padding without a state migration. Variants of `PolicyType` are pinned at 128 bytes — adding a variant is safe as long as the body fits. - See [Deployment](https://docs.tributary.so/protocol-reference/deployment/index.md) for how to verify the current on-chain build, and [Error Codes](https://docs.tributary.so/protocol-reference/error-codes/index.md) for the up-to-date error table. # Deployment ## Program ID ```text TRibg8W8zmPHQqWtyAD1rEBRXEdyU13Mu6qX1Sg42tJ ``` - **Same ID on Devnet and Mainnet.** The program is not redeployed with a new ID per environment — integrations hard-code the single ID above. - **No Testnet deployment.** Tributary is not deployed to Solana Testnet. ## RPC endpoints | Network | Default RPC | Notes | | ----------- | ------------------------------------- | ------------------------------------------------------------------------------------------- | | **Mainnet** | `https://api.mainnet-beta.solana.com` | Rate-limited public endpoint; use a private RPC (Helius, Triton, QuickNode) for production. | | **Devnet** | `https://api.devnet.solana.com` | Airdrop only; suitable for integration tests. | | **Local** | `http://localhost:8899` | Surfpool / `solana-test-validator` for unit tests. | Configure via `solana config set --url ` or pass `--url` per command. ## Verifying a deployment ```bash solana program show TRibg8W8zmPHQqWtyAD1rEBRXEdyU13Mu6qX1Sg42tJ ``` Output should show: - Program owner: the BPF Upgradeable Loader - ProgramData address and the **upgrade authority** - Last deploy / slot On Mainnet, cross-check the upgrade authority against the published multi-sig / authority in the repository README before trusting a deployment. ### Verifying program bytecode ```bash solana program dump TRibg8W8zmPHQqWtyAD1rEBRXEdyU13Mu6qX1Sg42tJ tributary.so --url # compare against the locally built artifact sha256sum tributary.so target/deploy/tributary.so ``` ## Local development ```bash # Surfpool — the preferred local validator for Tributary tests surfpool start --legacy-anchor-compatibility --no-tui # or stock test-validator (does not emulate mainnet state) solana-test-validator --bpf-program TRibg8W8zmPHQqWtyAD1rEBRXEdyU13Mu6qX1Sg42tJ target/deploy/tributary.so ``` Integration tests (`tests/` jest suite, `tests/topup-balance*.test.ts`) are designed against Surfpool — see the repo's `AGENTS.md` for the exact invocation. ## Network feature notes - **Token-2022 / extensions**: rejected with `UnsupportedTokenExtension`. Use plain SPL token mints for user funds. - **Compute budget**: composable executions with a forward + validation path can exceed the default 200k CU budget. Add a `ComputeBudgetProgram.setComputeUnitLimit` instruction (400k–1M) in front of `execute_composable`. - **Priority fees**: recommended on Mainnet for `execute_*` to avoid landing in low-fee queues during congestion. ## Upgrades Program upgrades are gated by the upgrade authority (see `solana program show`). Tributary does not use a separate upgrade authority multisig in devnet — on Mainnet the upgrade authority should be a Squads multisig (verify before trusting). State migrations are not supported; the program is forward-only and account layouts are padded to absorb new fields without redeployment. See [Changelog](https://docs.tributary.so/protocol-reference/changelog/index.md) for the history of upgrades. # Error Codes Anchor's `#[error_code]` macro assigns each variant a numeric code **positionally**, starting at `6000`. The code is `(6000 + variant_index)`. Below is the full table for `TributaryError` (`programs/tributary/src/error.rs`). Codes are **stable as long as variant order is preserved** — inserting or reordering variants in `error.rs` shifts every downstream code. Treat the names, not the numbers, as the source of truth when grepping logs. ## All errors (58 variants) ### General / Validation | Code | Name | Message | Remediation | | ---- | ------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | 6000 | `ProgramPaused` | Program is paused | Wait for admin to clear `ProgramConfig.emergency_pause`, or check you are not hitting it intentionally. | | 6001 | `InvalidAmount` | Amount must be greater than zero | Pass `amount > 0` on policy creation. | | 6002 | `InvalidFrequency` | Invalid payment frequency | Use a `PaymentFrequency` variant; for `Custom`, the interval in seconds must be non-zero. | | 6003 | `Unauthorized` | Unauthorized | Signer is not the `owner` / `authority` / `admin` of the relevant account. | | 6004 | `InvalidPolicyStatusTransition` | Invalid policy status transition | `Paused → Active → Paused` is the only allowed path for `PaymentPolicy`; `ComposablePolicy` adds `Completed`. Don't try to revive a `Completed` policy. | | 6005 | `InsufficientDelegatedAmount` | Insufficient delegated amount | Re-approve the delegate: `spl-token approve `. Delegate must cover `amount + fees`. | | 6006 | `PaymentNotDue` | Payment is not yet due | Wait until `next_payment_due` (Subscription/Milestone) or check period cap (PayAsYouGo). | | 6007 | `InsufficientBalance` | Insufficient balance for payment | Top up the user's ATA; the policy amount + fees exceeds the balance. | | 6008 | `NoDelegateSet` | No or incorrect delegate set in ata | The ATA's delegate must be the `UserPayment` PDA (or legacy `PaymentsDelegate`). Approve the right PDA. | | 6009 | `PolicyPaused` | Payment policy is paused | Resume the policy via `change_payment_policy_status` / `change_composable_status` first. | | 6010 | `InvalidInterval` | Invalid Interval | Custom interval must be `> 0` seconds. | | 6011 | `InvalidFeeBps` | Invalid fee basis points | `gateway_fee_bps` must be `<= 10000`. | | 6012 | `InvalidPaymentDueDate` | Invalid payment due date | `next_payment_due` must be in the future at creation. | | 6013 | `ArithmeticOverflow` | Arithmetic overflow | Usually a fee configuration bug — combined BPS too high, or `amount` near `u64::MAX`. See `validate_combined_bps`. | | 6014 | `UnsupportedTokenExtension` | Token-2022 Extension currently not supported | Use a plain SPL mint (no TransferHook, TransferFee, etc.). | | 6015 | `DistinctPubKeysRequired` | Distinct Pubkeys required! | Two accounts that must differ (e.g. referrer vs payer) are the same. | | 6016 | `InvalidFeatureFlags` | Invalid feature flags | Bits outside the known mask were set on `feature_flags`. | | 6017 | `HasActivePolicies` | Cannot delete user payment with active policies | Close or delete all `PaymentPolicy` children first. | | 6018 | `HasActiveComposables` | Cannot delete user payment with active composable policies | Close or delete all `ComposablePolicy` children first. | | 6019 | `InvalidRentPayer` | Invalid rent payer | The `rent_payer` passed does not match the on-chain stored one (or is invalid). | | 6020 | `CombinedFeeBpsExceedsMax` | Combined fee BPS must be less than 10000 | `gateway_fee_bps + effective_protocol_fee_bps` must be **strictly less than** 10000. Lower one of them. | ### Referral | Code | Name | Message | Remediation | | ---- | ------------------------------------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | 6021 | `InvalidReferralAllocation` | Invalid referral allocation - must be \<= 2500 bps | `referral_allocation_bps` is bps **of the gateway fee**. Cap is 2500 (25%). | | 6022 | `InvalidReferralTiers` | Invalid referral tiers - must sum to 10000 bps | `referral_tiers_bps` (3 entries) must sum to exactly 10000. | | 6023 | `CouldNotDeserializeReferrer` | Could not deserialize referrer account | The account at that index is not a valid `ReferralAccount`. Check you passed the right PDAs in order. | | 6024 | `ReferrerMustBeWritable` | Referrer account must be writable | Mark the referrer `ReferralAccount` as writable in the ix (it earns rewards). | | 6025 | `CircularReferralChain` | Circular referral chain detected | A `ReferralAccount` is its own ancestor. Rebuild the chain. | | 6026 | `MaxReferralDepthExceeded` | Maximum referral chain depth exceeded | Tributary supports up to **3 levels**. Trim the chain. | | 6027 | `InvalidReferralChainOrdering` | Invalid referral chain ordering in remaining_accounts | Pass `[direct_referrer, L2, L3]` in that order — leaf to root. | | 6028 | `InvalidReferralAccountDiscriminator` | Invalid referral account discriminator | Account is not a `ReferralAccount`. | | 6029 | `ReferralAccountSizeMismatch` | Referral account size mismatch | Account was created with an older/different layout. | | 6030 | `InvalidReferralCode` | Invalid referral code - must be alphanumeric | 6-byte ASCII alphanumeric `[A-Za-z0-9]`. | | 6031 | `ReferrerAccountInvalid` | Referrer Account invalid | Generic referrer-account mismatch. Re-derive the PDA. | | 6032 | `ReferrerATAInvalid` | Referrer ATA invalid | The referrer's ATA is missing or wrong owner. | | 6033 | `ReferrerATAMintInvalid` | Referrer ATA with invalid Mint | The referrer's ATA must be for the same mint as the payment. | | 6034 | `MissingReferralAta` | Missing ATA for ReferralAccount | Each referrer in the chain needs a matching token account for the payment mint. | | 6035 | `PayerReferralMismatch` | Payer ReferralAccount does not match the paying wallet | The `ReferralAccount.owner` must equal the paying wallet. | | 6036 | `DuplicateReferralAccount` | Duplicate ReferralAccount supplied in remaining_accounts | Each referrer must appear once. | ### Token accounts | Code | Name | Message | Remediation | | ---- | ----------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------- | | 6037 | `InvalidTokenAccount` | Invalid token account - mint mismatch or deserialization failed | Verify the ATA belongs to the right mint and owner. | | 6038 | `MismatchAtaReferralAccountNumbers` | Mismatch between number referrers and atas! | One ATA per referrer — counts must match. | | 6039 | `TokenMintMismatch` | Token mint mismatch between accounts | All token accounts in the ix must reference the same mint. | ### Composable — Forward | Code | Name | Message | Remediation | | ---- | ---------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | 6040 | `InvalidForwardProgram` | Forward program not whitelisted | `forward_config.target_program` must be in `ALLOWED_FORWARD_PROGRAMS` (Meteora DLMM). Use `Pubkey::default()` to disable. | | 6041 | `ByteRangeCheckFailed` | Byte range check failed | The forward instruction data did not match a pinned `ByteRangeCheck`. Re-check the instruction selector. | | 6042 | `InsufficientOutputAmount` | Insufficient output amount after forward CPI | The forward produced less than `min_output_amount` (net of fees). Raise slippage tolerance or wait for better price. | | 6043 | `InsufficientByteRangeChecks` | Must have at least one byte range check | When forward is enabled, `num_data_checks >= 1`. | | 6044 | `DiscriminatorCheckRequired` | At least one ByteRangeCheck must start at offset 0 to pin the instruction selector | Add a check with `offset = 0` covering the program's first instruction byte(s). | | 6045 | `IntermediateAccountMismatch` | Intermediate token account address does not match the derived ATA | Pass the ATA derived from `(ComposablePolicy PDA, mint)` as the intermediate. | | 6046 | `IntermediateAccountAlreadyExists` | Intermediate token account already exists — it must be freshly created each execution | Composables create + close the intermediate ATA per execution. Close any stale one. | | 6047 | `MissingForwardAccounts` | Forward CPI requires at least one remaining account | Pass the forward program's required accounts. | | 6048 | `ForwardProducedNoOutput` | Forward CPI produced no output (intermediate output balance is zero) | Forward program didn't deliver tokens. Check pool liquidity / route. | | 6049 | `ForwardDisabledRequiresSameMint` | Forward disabled (target_program = default) requires input_mint == output_mint | When you disable the forward, set `output_mint = input_mint` (same-mint topup pattern). | | 6050 | `NativeOutputRequiresWsol` | NATIVE_OUTPUT forward flag requires output_mint == WSOL (NATIVE_MINT) | Either clear the flag, or set `output_mint = So111…111`. | ### Composable — Validation | Code | Name | Message | Remediation | | ---- | -------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | 6051 | `InvalidValidationProgram` | Validation program not whitelisted | `validation_program` must be in `ALLOWED_VALIDATION_PROGRAMS` (Lighthouse). Use `SystemProgram` to disable. | | 6052 | `ValidationPdaMismatch` | Validation PDA does not match derived address | Re-derive `ValidationPda` from `["composable_validation", composable_policy]`. | | 6053 | `ValidationDataTooLarge` | Validation data exceeds maximum size | Truncate to `MAX_VALIDATION_DATA_SIZE` (512 bytes). | | 6054 | `ValidationDataRequired` | Validation program set but no data provided | Either supply validation data, or set `validation_program = SystemProgram`. | | 6055 | `ValidationNotRequired` | Validation not configured but data was provided | You passed assertion data with `validation_program = SystemProgram`. Either set the program or drop the data. | | 6056 | `InvalidValidationPda` | ValidationPDA is malformed — data_len out of bounds | The on-chain `data_len` exceeds 512. Account is corrupted. | ### Authorization (initialization) | Code | Name | Message | Remediation | | ---- | ------------------------- | ----------------------------------------------------- | ------------------------------------------------------------ | | 6057 | `UnauthorizedInitializer` | Only the upgrade authority can initialize the program | `initialize` is upgrade-authority-gated. Sign with that key. | > The legacy `Unauthorized` (6003) covers general authorization failures; `UnauthorizedInitializer` is specific to the program `initialize` call. ## Anchor framework errors In addition to `TributaryError`, you may see Anchor's built-in errors (`AnchorError` namespace, codes 100–4xxx range): `AccountNotInitialized`, `AccountDiscriminatorMismatch`, `ConstraintHasOne`, `InsufficientFunds`, etc. These are documented in the Anchor book; they share the same transaction log format (`Error code: `). # Fees and Account Costs Tributary has two cost dimensions: **payment fees** (deducted from each transfer) and **rent** (Solana's on-chain storage cost for accounts). ______________________________________________________________________ ## Payment Fees Every payment executed through Tributary uses a **unified gateway fee model** (ADR-0017): the gateway declares **one total fee** (`gateway_fee_bps`), and the protocol decomposes it into four carve-outs. The protocol's cut is **20% of the gateway fee by default** — not a flat percentage of the payment. ### How the fee decomposes | Carve-out | Default share of gateway fee | Range | Who Controls | | ----------------------- | --------------------------------- | -------------------------------------------------------- | --------------------- | | Protocol fee | 20% (`protocol_share_bps = 2000`) | Fixed at program initialization; overridable per-gateway | Protocol admin | | Scheduler fee | Set per gateway | 0–10,000 bps | Gateway authority | | Referral pool | Set per gateway | 0–2,500 bps (0–25%) | Gateway authority | | Gateway residual | Remainder | `total − protocol − scheduler − referral` | Gateway authority | | **Gateway fee (total)** | **One number, gateway-set** | **0–10,000 bps (0–100%)** | **Gateway authority** | The four carve-out shares must sum to ≤ 10,000 bps (enforced at every gateway-config write site). ### Fee Distribution ```text $100.00 Payment, 5% gateway fee (500 bps), 10% scheduler, 10% referral Total fee pool: $5.00 (5% of $100) ├── $1.00 → Protocol Treasury (20% of $5.00 fee pool) ├── $0.50 → Scheduler (10% of $5.00 fee pool — pays the execute signer) ├── $0.50 → Referral pool (10% of $5.00 fee pool, if enabled) └── $3.00 → Gateway residual (remaining 60% of $5.00 fee pool) Recipient receives: $95.00 ``` Fees are calculated as `(amount * bps) / 10000`, truncating toward zero. Dust from rounding goes to the protocol treasury. ### The "1%" is derived, not fixed At the **default 20% protocol share** and a **typical 5% gateway fee**, the protocol's effective take is **1% of the payment amount** (`500 bps × 20% = 100 bps = 1%`). But the protocol's absolute take scales with the gateway fee: | Gateway fee | Protocol share (default 20%) | Effective protocol take | | ----------- | ---------------------------- | ----------------------- | | 2.5% | 20% | 0.5% of payment | | 5% | 20% | 1% of payment | | 10% | 20% | 2% of payment | ### Custom Protocol Fee The protocol admin can override the global protocol share on a per-gateway basis via `custom_protocol_share_bps` (requires `FEATURE_CUSTOM_PROTOCOL_FEE` bit set on the gateway). This is useful for special arrangements (e.g., zero protocol fee for strategic partners). When enabled, the custom share replaces the global `protocol_share_bps` for that gateway — it does not stack. ```typescript // Enable 0% protocol fee for a gateway (admin only) await sdk.updateGatewayProtocolFee(gatewayAuthority, true, 0); ``` See [Providers](https://docs.tributary.so/operate/providers/index.md) for gateway configuration details. ### Referral Fee Allocation When a gateway has the referral feature enabled, a portion of the gateway fee is allocated to referral rewards. The gateway authority configures: - **Referral allocation**: Percentage of gateway fee dedicated to rewards (e.g., 500 bps = 5%) - **Tier split**: How rewards distribute across up to 3 referral levels (must sum to 10,000 bps) See [Referral Program](https://docs.tributary.so/protocol-reference/payment-policy/referral-program/index.md) for the full referral chain mechanics. ______________________________________________________________________ ## Rent and Account Costs Solana charges rent for on-chain data storage. Tributary creates several PDA accounts per user, each requiring a rent deposit. This section explains who pays, how much, and how rent is reclaimed. ### Who Pays Rent? The `fee_payer` in the transaction covers the rent deposit at account creation time. Tributary tracks this `rent_payer` on-chain so that when the account is closed, the rent is returned to the original payer — not necessarily the account owner. ### Account Sizes and Costs | Account | Approx. Size | Rent Cost (SOL) | Created By | | ----------------- | ------------ | --------------- | --------------------- | | `ProgramConfig` | ~300 bytes | ~0.002 | Protocol admin (once) | | `PaymentGateway` | ~350 bytes | ~0.002 | Protocol admin | | `UserPayment` | ~370 bytes | ~0.0025 | Any fee payer | | `PaymentPolicy` | ~630 bytes | ~0.004 | Any fee payer | | `ReferralAccount` | ~150 bytes | ~0.001 | Referrer | Actual costs vary with rent-exempt minimums. Accounts are rent-exempt at creation. ### Rent Lifecycle ``` flowchart LR A["fee_payer creates
UserPayment or
PaymentPolicy"] -->|"rent deposit
tracked in account"| B["Account Active"] B -->|"delete policy
(owner signs)"| C["Rent returned to
stored rent_payer"] B -->|"delete user payment
(owner signs,
no active policies)"| D["Rent returned to
stored rent_payer"] ``` #### Creation When a `UserPayment` or `PaymentPolicy` is created, the `fee_payer` submits the rent deposit. The program stores `fee_payer` as `rent_payer` on the account: ```typescript // SDK: user pays rent for their own UserPayment const ix = await sdk.createUserPayment(tokenMint); // rent_payer = user.publicKey (stored on-chain) ``` A third party (e.g., a payment gateway) can sponsor the rent by being the `fee_payer` in the transaction. #### Deletion When an account is closed, the stored `rent_payer` receives the lamports: - **`delete_payment_policy`**: Owner signs, rent goes to `payment_policy.rent_payer` - **`delete_user_payment`**: Owner signs, rent goes to `user_payment.rent_payer`. Requires `active_policies_count == 0`. ```typescript // Delete all policies first for (const policyId of policyIds) { await sdk.deletePaymentPolicy(tokenMint, policyId); } // Then delete the user payment account // (only possible when activePoliciesCount === 0) ``` #### Backwards Compatibility Accounts created before the `rent_payer` field was introduced have `rent_payer` set to `Pubkey::default()` (all zeros). In this case, the program falls back to returning rent to the `owner` (the signer of the delete transaction). This ensures no rent is lost on legacy accounts. ### Delegation Accounts The `payments_delegate` PDA does not hold user funds — it's a program-derived authority for SPL Token transfers. No rent is associated with delegate approval; it's an SPL Token operation (`approve`) that costs only the transaction fee. ______________________________________________________________________ ## Account Cleanup Flow To fully remove a user from the protocol: ```text 1. Delete all PaymentPolicy accounts (one per active subscription/milestone/PAYG) → Each returns rent to that policy's rent_payer → Decrements user_payment.active_policies_count 2. Delete UserPayment account → Requires active_policies_count == 0 → Returns rent to user_payment.rent_payer ``` A user cannot delete their `UserPayment` while any `PaymentPolicy` still references it. ### Example: Full Cleanup via SDK ```typescript const userPayment = await sdk.getUserPayment(userPaymentPDA); const totalPolicies = userPayment.createdPoliciesCount; // Delete all policies (skip already-deleted ones) for (let id = 1; id <= totalPolicies; id++) { const [pda] = derivePolicyPda(userPaymentPda, id); if (await sdk.getPaymentPolicy(pda)) { await sdk.deletePaymentPolicy(tokenMint, id); } } // Now safe to delete the user payment const { address: configPda } = sdk.getConfigPda(); const ix = await program.methods .deleteUserPayment() .accountsStrict({ owner: user.publicKey, userPayment: userPaymentPda, tokenMint, rentPayer: user.publicKey, config: configPda, }) .instruction(); ``` ______________________________________________________________________ ## Transaction Costs Beyond rent, every Tributary instruction incurs Solana's base transaction fee (currently 5,000 lamports per signature). Compute costs are minimal for standard operations. | Operation | Signatures | Est. Compute | | --------------------- | ---------- | ------------ | | Create user payment | 1 | ~50k CU | | Create payment policy | 1 | ~80k CU | | Execute payment | 1 | ~150k CU | | Delete payment policy | 1 | ~30k CU | | Delete user payment | 1 | ~30k CU | CU estimates are conservative. Actual usage depends on policy type and referral chain depth. # IDL The Interface Definition Language (IDL) is the JSON description Anchor programs emit describing their instructions, accounts, types, and errors. Tributary's SDK, the React hooks, the CLI manager, and the tests all consume the same IDL — you should never hand-code instruction layouts. ## Program ID ```text TRibg8W8zmPHQqWtyAD1rEBRXEdyU13Mu6qX1Sg42tJ ``` Same ID on Devnet and Mainnet. See [Deployment](https://docs.tributary.so/protocol-reference/deployment/index.md). ## Fetching the on-chain IDL Anchor stores the IDL in a PDA derived from the program ID. Recover it with the Anchor CLI: ```bash # Requires anchor 0.31.x and a Solana RPC in solana config anchor idl fetch TRibg8W8zmPHQqWtyAD1rEBRXEdyU13Mu6qX1Sg42tJ ``` Or programmatically: ```typescript import { Program, AnchorProvider, Idl } from "@coral-xyz/anchor"; const provider = AnchorProvider.env(); const idl = await Program.fetchIdl( "TRibg8W8zmPHQqWtyAD1rEBRXEdyU13Mu6qX1Sg42tJ", provider ); ``` The IDL PDA seeds are `[b"account-storage", program_id]` (Anchor-managed, not Tributary-defined). If the deployment was made without `--idl`, the on-chain IDL PDA may be empty — in that case read the local `target/idl/tributary.json` after building the program, or pull the published IDL from `@tributary-so/sdk`. ## Instruction families | Family | Instructions | What changes on-chain | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | | **Config** | `initialize` | Creates `ProgramConfig` singleton (upgrade-authority only). | | **UserPayment** | `create_user_payment`, `delete_user_payment` | Per-(owner,mint) delegate account + counters. | | **Gateway** | `create_payment_gateway`, `change_gateway_signer`, `change_gateway_fee_recipient`, `change_gateway_fee_bps`, `update_gateway_referral_settings`, `update_gateway_protocol_fee`, `update_gateway_feature_flags`, `delete_payment_gateway` | Gateway settings, fees, feature flags, referral config. | | **PaymentPolicy** | `create_payment_policy`, `execute_payment`, `change_payment_policy_status`, `delete_payment_policy` | Regular pull-payment lifecycle. | | **ComposablePolicy** | `create_composable_policy`, `execute_composable`, `change_composable_status`, `delete_composable_policy` | Programmable pull-payment lifecycle (validates + forwards). | | **Referral** | `create_referral_account` | Adds a node to a gateway's referral chain. | | **Token** | `transfer` | Helper for moving protocol-owed tokens (admin). | ### Mutability / signer rules at a glance - `initialize` — needs the **upgrade authority** as signer (`UnauthorizedInitializer` otherwise). - All `create_*` — the **owner/authority** signs; a `rent_payer` may be a different account. - `execute_*` — **permissionless** as long as a valid `gateway.signer` is present. Anyone can submit, but only the gateway's authorized signer triggers the pull. - `change_*` / `delete_*` — the original `authority` / `owner` signs. For exact account lists per instruction, `anchor idl fetch` and inspect the `instructions[].accounts` array. ## Consumers - **`@tributary-so/sdk`** (`packages/sdk`) — compiled-in IDL, exposes typed builders (`getCreateSubscriptionPolicyInstruction`, `getCreateComposablePolicyInstruction`, `executeComposable`, …). - **`@tributary-so/sdk-react`** (`packages/sdk-react`) — React hooks built on top of the SDK. - **CLI manager** (`packages/sdk && pnpm run manager`) — interactive policy and gateway management using the same IDL. When the program is redeployed with new instructions or types, regenerate the IDL (`anchor build`) and re-publish the SDK so all consumers stay in sync. The on-chain IDL PDA is updated by `anchor deploy --idl` (or a manual `idl write` instruction). # Protocol Overview Tributary is a non-custodial, pull-payment protocol on Solana. It lets a user authorize a **gateway** to pull tokens from their wallet on a schedule, with fees routed to the protocol, the gateway, and (optionally) a referral chain. The user keeps custody the entire time — authorization is a scoped SPL token delegation that the user can revoke at any moment. The protocol exposes two policy families that share the same schedule model but differ in execution semantics: | Family | What it does | When to use | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | | **PaymentPolicy** | Direct pull: gateway transfers user → recipient + fees in one CPI. | Subscriptions, milestones, pay-as-you-go without swaps. | | **ComposablePolicy** | Programmable pull with optional **validation** (Lighthouse assertion) and **forward** (Meteora DLMM swap) hooks between the pull and settlement. | Auto-topups, "pull USDC, deliver WSOL", gated payments that must pass on-chain conditions. | Both reuse the same `PolicyType` enum (`Subscription` / `Milestone` / `PayAsYouGo`), the same `UserPayment` account, the same fee-distribution logic, and the same gateway/referral plumbing. ## Account relationships ``` graph TD User["👤 User
(wallet owner)"] UP["UserPayment PDA
seeds: [user_payment, owner, mint]
tracks counters + is the delegate"] PP["PaymentPolicy PDA
seeds: [payment_policy, user_payment, policy_id]
direct pull"] CP["ComposablePolicy PDA
seeds: [composable_policy, user_payment, policy_id]
programmable pull"] VP["ValidationPda
seeds: [composable_validation, composable_policy]
≤512 bytes Lighthouse assertion data"] GW["PaymentGateway PDA
seeds: [gateway, authority]
fees, signer, feature flags"] CFG["ProgramConfig PDA
seeds: [config]
singleton — admin, protocol fee, pause"] REF["ReferralAccount PDA
seeds: [referral, gateway, code]
up to 3-level chain"] User -->|"create_user_payment"| UP UP -->|"create_payment_policy"| PP UP -->|"create_composable_policy"| CP CP -.->|"stores assertion"| VP PP -->|"references"| GW CP -->|"references"| GW GW -->|"referral pool"| REF GW -->|"pays protocol fee"| CFG classDef pda fill:#e8f5e8,stroke:#1b5e20,stroke-width:2px classDef cfg fill:#fff3e0,stroke:#e65100,stroke-width:2px class UP,PP,CP,VP,GW,REF pda class CFG cfg ``` For the full PDA seed table and per-account field layouts, see [Accounts & PDAs](https://docs.tributary.so/protocol-reference/accounts-and-pdas/index.md). ## Execution lifecycle ```text 1. SETUP (once per user/mint) ├── createUserPayment(owner, mint) → UserPayment PDA ├── createPaymentGateway(authority, ...) → PaymentGateway PDA (gateway op) └── (optional) create_referral_account → ReferralAccount PDA (referrer) 2. POLICY CREATION ├── create_payment_policy → PaymentPolicy PDA └── create_composable_policy → ComposablePolicy PDA (+ ValidationPda) 3. DELEGATE APPROVAL (user, off-chain or via SDK) └── spl-token approve 4. EXECUTION (permissionless — any gateway signer) ├── execute_payment → transfer user_ata → recipient + fees └── execute_composable → pull → validate → forward → settle 5. ADVANCE / CLOSE ├── advance schedule (Subscription.next_payment_due, PAYG period reset) ├── pause / resume └── delete_* → closes accounts, refunds rent to rent_payer ``` ## The shared `PolicyType` enum All five variants are fixed at **128 bytes** (plus a 1-byte enum discriminator = 129 bytes total). This is a hard invariant: changing padding breaks deserialization of every existing account. | Variant | Semantics | | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Subscription** | Fixed `amount` every `payment_frequency` until `max_renewals` reached (or forever if `auto_renew`). Gated by `next_payment_due`. | | **Milestone** | Up to 4 `(amount, timestamp)` milestones held in escrow. Released via `release_condition` bitmap: bit0=due-date, bits1–3 are mutually-exclusive signer requirements (gateway/owner/recipient). | | **PayAsYouGo** | Usage-based: claim up to `max_chunk_amount` per call, capped at `max_amount_per_period` per `period_length_seconds`. Period auto-resets. | | **OneTime** | Fixed `amount`, fires exactly once then `Completed`. `due_date <= 0` = immediate; `expiry_date = None` = never expires. Full gateway lifecycle. See ADR-0019. | | **UpTo** | Single-use, time-bound variable-amount authorization. Caller-supplied settle amount, `0 <= actual <= max_amount`, enforced on-chain from the immutable policy. Recipient-triggerable. See ADR-0020. | ## Fee distribution flow Every execution — `PaymentPolicy` or `ComposablePolicy` — splits the pulled amount the same way: ```text pulled_amount ├── protocol_fee = amount * effective_protocol_fee_bps / 10000 → ProgramConfig.fee_recipient ├── gateway_fee = amount * gateway_fee_bps / 10000 → PaymentGateway.fee_recipient │ └── (if referral enabled) referral_pool = gateway_fee * referral_allocation_bps / 10000 │ └── split across up to 3 ReferralAccount levels per referral_tiers_bps └── remainder (net) → policy.recipient ``` Key points: - **Protocol fee default is 100 bps (1%)**, configurable globally on `ProgramConfig` and overridable per-gateway via the `FEATURE_CUSTOM_PROTOCOL_FEE` flag. - **`gateway_fee_bps + effective_protocol_fee_bps` must be `< 10000`** (strictly). At 10000 the recipient gets zero; above 10000 the math underflows. Enforced by `CombinedFeeBpsExceedsMax`. - **Referrals are a slice of the gateway fee, not of the payment.** `referral_allocation_bps` is in bps of the gateway fee (cap 2500 = 25%). `referral_tiers_bps` splits that pool across 3 levels and must sum to 10000. - **`min_output_amount` on a Composable forward is checked against the NET (post-fee) output**, matching DeFi convention. - Math is `(amount * bps) / 10000` with floor rounding; dust goes to the protocol. For the full fee mechanics, see [Fees](https://docs.tributary.so/protocol-reference/fees/index.md) and [Referral Program](https://docs.tributary.so/protocol-reference/payment-policy/referral-program/index.md). ## Emergency controls `ProgramConfig.emergency_pause` is a single boolean. When `true`, **both** `execute_payment` and `execute_composable` fail with `ProgramPaused`. Setting it is an admin-only instruction. It does not freeze user funds — users can still revoke delegation and move tokens via SPL token directly. See [Security](https://docs.tributary.so/protocol-reference/security/index.md). ## Where to go next - [Accounts & PDAs](https://docs.tributary.so/protocol-reference/accounts-and-pdas/index.md) — full seed table, field layouts, rent strategy - [IDL](https://docs.tributary.so/protocol-reference/idl/index.md) — fetching the on-chain IDL, instruction inventory - [Deployment](https://docs.tributary.so/protocol-reference/deployment/index.md) — program IDs, RPC endpoints, verification - [Error Codes](https://docs.tributary.so/protocol-reference/error-codes/index.md) — every `TributaryError` variant with remediation - [Changelog](https://docs.tributary.so/protocol-reference/changelog/index.md) — release history - [Security](https://docs.tributary.so/protocol-reference/security/index.md) — delegation model, CPI hardening, audit status # Security Model Tributary is a **non-custodial** pull-payment protocol. Users authorize a gateway to pull tokens from their wallet on a schedule — they never deposit, wrap, or lock funds into the contract. Every security decision in the program flows from that one property. ## Token delegation model - Users call `spl-token approve ` once to grant pull authority to a delegate. Funds stay in the user's wallet. - The modern delegate is the **per-user `UserPayment` PDA** (`["user_payment", owner, mint]`). - The legacy global `PaymentsDelegate` PDA (`["payments"]`) is still accepted for backward compatibility with v0-approved token accounts, but the program writes new policies against `UserPayment`. - Delegations are **scoped** (specific amount) and **revocable** at any time by the user (`spl-token revoke`, or re-approving with a smaller amount). The program cannot prevent revocation — it is a pure SPL token operation. - No funds are ever held in escrow by Tributary itself — with the single exception of `PolicyType::Milestone`, where the `escrow_amount` is a logical counter and the actual tokens still live in the user's ATA until a milestone release pulls them. ### What this means for users - The protocol **cannot** rug, freeze, or move user funds beyond what the policy schedule permits. - Revoking delegation is the kill switch — it instantly stops every policy on that (owner, mint) pair, without on-chain admin involvement. - The amount the delegate can pull is exactly the `delegated_amount` set by the user. Re-approval is required once it is consumed. ## Dual-delegate migration (v0 → v1) | Generation | Delegate | Scope | | ---------- | --------------------------------------------------- | -------------------------------------------------------------- | | **v0** | `PaymentsDelegate` PDA — `["payments"]` | Global single PDA. One delegate for every user on the program. | | **v1** | `UserPayment` PDA — `["user_payment", owner, mint]` | Per (owner, mint). One delegate per user per token. | The migration was made because the v0 global delegate was a shared blast radius — a single key authorized pulls for every user. The v1 per-user PDA isolates authority to the specific (owner, mint) pair, and the `UserPayment` account is also where the policy counters live, so it had to exist anyway. `execute_payment` accepts **either** delegate for backward compatibility: existing v0 token accounts continue to work, but new policies are written against `UserPayment`, and new integrations must use it. ## Token-2022 / extension allowlist Tributary **rejects** token accounts that carry Token-2022 extensions (`UnsupportedTokenExtension`): - `TransferHook` — would let the mint issuer veto pulls. - `TransferFee` — would silently reduce the amount the recipient gets, breaking fee math. - `PermanentDelegate` / `TransferHookAccount` — would let the issuer re-claim pulled funds. - ConfidentialTransfer, MemoTransfer, etc. — incompatible with the deterministic pull model. **Use plain SPL token mints** for user funds. If you need Token-2022 features, the program must be upgraded to support them explicitly per extension; do not attempt to bypass the check. ## CPI security Composable policies invoke external programs during execution (validation via Lighthouse, forward via Meteora DLMM). Three hardening measures apply: ### 1. Target-program allowlists Hard-coded in `programs/tributary/src/constants.rs`: - `ALLOWED_FORWARD_PROGRAMS`: Meteora DLMM (`LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo`) - `ALLOWED_VALIDATION_PROGRAMS`: Lighthouse (`L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95`) A gateway cannot substitute an arbitrary program — `target_program` / `validation_program` must be in the list (or a sentinel disabling the hook). ### 2. Signer sanitization (C-1) Validation and forward CPI builders **do not** forward `is_signer` from `remaining_accounts`. This closes a privilege-pass-through vector where the fee payer (always a `Signer`) re-passed as a remaining account could grant Lighthouse or DLMM unintended signer authority over the CPI call. ### 3. Instruction-data pinning (`ByteRangeCheck`) When the forward hook is enabled, the `ForwardConfig` carries up to 4 `ByteRangeCheck` entries (`offset`, `length`, `expected[8]`). At execute time, the supplied forward instruction data must match every check. **At least one check must start at offset 0** (`DiscriminatorCheckRequired`), which pins the forward program's instruction selector — a gateway cannot swap in an arbitrary instruction against the allowed program. ### 4. Intermediate ATA ownership The transient input/output ATAs used during a composable execution are owned by the **`ComposablePolicy` PDA**, not the `UserPayment` PDA. This decouples the intermediate signing authority from the user's source-funds delegate: a forward program can only move the transient intermediate balance, never the user's wallet. ### 5. `min_output_amount` is net `ForwardConfig.min_output_amount` is checked against the **post-fee** output (after gateway and protocol fees are deducted). This matches DeFi `amountOutMin` convention and prevents the gateway from extracting fee upside by accepting a worse swap. ## Emergency pause `ProgramConfig.emergency_pause` is a single boolean, settable by the program admin. When `true`: - Every `execute_payment` and `execute_composable` fails immediately with `ProgramPaused` — before any token movement. - Users **can still revoke** their delegation and move their tokens via SPL token directly. The pause does not freeze funds — it freezes the program's ability to pull. The pause is a circuit breaker, not a seizure mechanism. ## What users can do to stay safe - **Verify policy terms before signing** the create transaction. The on-chain `policy_type` is authoritative — the SDK exposes its decoded form (`amount`, `frequency`, `max_renewals`, milestone timestamps, etc.). - **Revoke delegation** (`spl-token revoke `) the moment you want to stop a policy. No on-chain admin gate. - **Use a dedicated payment wallet.** Approve only the amount needed for the next billing cycle, not the lifetime cost of the subscription. - **Monitor your policies** — the SDK exposes read helpers (`getPaymentPolicy`, `getUserPayment`) and event streams; subscriptions, gateways, and fee splits are all on-chain and queryable. - **Verify the gateway** you are signing against. Gateway fees, referral configuration, and the signer key are all on the `PaymentGateway` PDA. ## What gateway operators must do - Store the gateway signer key in a HSM / KMS. Anyone with the signer key can execute pulls for your users. - Validate `min_output_amount` and slippage off-chain before submitting composable executions — the on-chain check is a floor, not a target. - Use a Squads multisig for the gateway authority and (on Mainnet) for the program upgrade authority. - Rotate the signer key on personnel changes via `change_gateway_signer`. ## Audit status **Professional security audits are pending.** The code is open-source at [github.com/tributary-so/tributary](https://github.com/tributary-so/tributary). Findings from internal review are tracked under `reports/` and the `## Security` section of each [Changelog](https://docs.tributary.so/protocol-reference/changelog/index.md) entry. Notable resolved findings: H-06 (ByteRangeCheck length unbounded), M-02 (manual ValidationPda write freshness), M-04 (inconsistent month arithmetic), M5 (min-output-amount checked before fees), C-1 (CPI signer pass-through). Until a third-party audit is complete, treat Tributary as beta software: integrate on Devnet first, cap delegated amounts, and monitor actively. # Allowlists & Sentinels ComposablePolicy ships with **two hard-coded program allowlists** and a pair of **sentinel conventions** that let a policy disable either hook without special-cased branches throughout the codebase. This page documents both and the create-time / execute-time checks that enforce them. ## Allowlists Defined in `programs/tributary/src/constants.rs`: ```rust pub const ALLOWED_FORWARD_PROGRAMS: &[Pubkey] = &[ pubkey!("LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo"), // Meteora DLMM pubkey!("CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C"), // Raydium CPMM pubkey!("CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK"), // Raydium CLMM pubkey!("whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc"), // Orca Whirlpool ]; pub const ALLOWED_VALIDATION_PROGRAMS: &[Pubkey] = &[pubkey!("L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95")]; // Lighthouse ``` | Constant | Currently contains | Used by | | ----------------------------- | -------------------------------------------------------------------- | ------------------------------------------ | | `ALLOWED_FORWARD_PROGRAMS` | Meteora DLMM, Raydium CPMM, Raydium CLMM, Orca Whirlpool (4 entries) | `validate_forward_config`, execute handler | | `ALLOWED_VALIDATION_PROGRAMS` | Lighthouse (1 entry) | `create_composable_policy` | Allowlisting exists because the **forward** hook receives `invoke_signed` authority over the ComposablePolicy PDA's intermediate ATAs — an arbitrary program in that slot could move transient balances to attacker accounts. Hard-allowlisting caps the blast radius to four well-audited DEX programs whose instruction semantics Tributary pins via `ByteRangeCheck` discriminator checks (see [forward-hook.md](https://docs.tributary.so/protocol-reference/composable-policy/forward-hook/index.md)). The **validation** hook is read-only and uses plain `invoke` with **no** signer seeds (C-1 fix, [security-model.md](https://docs.tributary.so/protocol-reference/composable-policy/security-model/index.md) §CPI signer sanitization) — so the validation allowlist is defense-in-depth rather than a privilege boundary. Lighthouse is the only entry. ## Sentinel conventions | Hook / shape | Disabled / activated when… | Sentinel | | --------------------- | ------------------------------------------------------------------------- | ------------------- | | Forward | `instruction_constraint.program_id == Pubkey::default()` | `Pubkey::default()` | | Validation (pre/post) | `ValidationSpec::Disabled` | enum variant | | **Act mode** | forward **enabled** AND `forward_config.output_mint == Pubkey::default()` | `Pubkey::default()` | The two hooks use **different** sentinel conventions. Forward uses a pubkey sentinel (`Pubkey::default()` on `instruction_constraint.program_id`) since program ID is the single gate. Validation uses an enum variant (`ValidationSpec::Disabled`) because the spec carries no pubkey — when disabled no assertion data is stored, no `ValidationPda` is allocated, and the caller passes `SystemProgram` as the program account. A third sentinel — `output_mint == Pubkey::default()` **with forward enabled** — selects **act mode** (ADR-0026): the forward CPI consumes the input for non-fungible settlement (e.g. a Velocity subaccount deposit). No output ATA is created, no deliver sweep runs, and the `>0` output guard is skipped. This is distinct from forward-disabled same-mint topup (`output_mint == input_mint`), which is deliver-no-transform. ### Forward-disabled invariants When `instruction_constraint.program_id == Pubkey::default()`: - `ic.num_data_checks == 0` (no forward instruction selector to pin). - `output_mint == input_mint` (no conversion step — the "same-mint topup" pattern. The intermediate is funded by the pull and swept directly). - Execute handler skips both the byte-range check and `run_forward_cpi`. ### Validation-disabled invariants When the spec is `ValidationSpec::Disabled`: - `validation_data.is_empty()` (no assertion to store — enforced by `validate_init` at create time). - No `ValidationPda` account is allocated (neither pre nor post). - Execute handler skips Phase 2 (pre-validation) and Phase 4 (post-validation) entirely; it consumes zero `remaining_accounts` for validation, and the caller passes `SystemProgram` as the program account. ## Create-time checks ### Forward config (`validate_forward_config`) The forward-leg rules are extracted into a unit-testable helper. The current function reads `num_data_checks` and `program_id` through the nested `instruction_constraint`: ```rust pub fn validate_forward_config(forward_config: &ForwardConfig) -> Result<()> { let ic = &forward_config.instruction_constraint; let forward_disabled = ic.is_disabled(); require!( forward_disabled || ALLOWED_FORWARD_PROGRAMS.contains(&ic.program_id), TributaryError::InvalidForwardProgram ); if forward_disabled { require!(ic.num_data_checks == 0, TributaryError::InsufficientByteRangeChecks); require!(forward_config.output_mint == forward_config.input_mint, TributaryError::ForwardDisabledRequiresSameMint); } else { require!(ic.num_data_checks >= 1 && ic.num_data_checks <= MAX_BYTE_RANGE_CHECKS as u8, TributaryError::InsufficientByteRangeChecks); require!(ic.has_effective_pins(), TributaryError::DegenerateForwardPins); require!(!ic.has_duplicate_indices(), TributaryError::DuplicatePinIndex); } if forward_config.is_native_output() { require!(forward_config.output_mint == NATIVE_MINT, TributaryError::NativeOutputRequiresWsol); } Ok(()) } ``` The per-`ByteRangeCheck` sanity loop (including the discriminator-pin requirement for offset 0) lives in the handler body itself, iterating `forward_config.instruction_constraint.data_checks[i]` — see `create_composable_policy.rs`. ### Validation specs (`validate_spec_and_program` + `validate_init`) Validation is now handled per-slot (pre and post) through the `ValidationSpec` enum. At create time the handler calls: ```rust // Resolve each spec against the caller-supplied program account. validate_spec_and_program(pre_validation, ctx.accounts.pre_validation_program.key(), ctx.accounts.system_program.key())?; validate_spec_and_program(post_validation, ctx.accounts.post_validation_program.key(), ctx.accounts.system_program.key())?; // Then validate the init data for each (assertion bytes, pin set). validate_init(&pre_validation, &pre_init)?; validate_init(&post_validation, &post_init)?; ``` Where `validate_spec_and_program` dispatches on the enum: - `ValidationSpec::Disabled` → the passed program account must be `SystemProgram` (no CPI target needed). No `ValidationPda` is allocated. - `ValidationSpec::ProgramCall { program_id }` → the passed program must match `program_id`, which must be in `ALLOWED_VALIDATION_PROGRAMS`. - `ValidationSpec::Inline { .. }` → returns `InlineValidationNotImplemented`. And `validate_init` ensures: - When `Disabled`: `validation_data` must be empty (no assertion to store). - When `ProgramCall`: `validation_data` must be non-empty and ≤ 512 bytes (`MAX_VALIDATION_DATA_SIZE`), pins must be non-default, indices unique, within bounds. The handler also re-pins the named `input_mint` / `output_mint` accounts against the caller-supplied `forward_config` Pubkeys and runs the full Token-2022 extension allowlist on both (`validate_mint_compatible`). Without this, a policy could be created against a `TransferHook` / `PermanentDelegate` / `ConfidentialTransferMint` mint that breaks `transfer_checked` at execute time, or drains the PDA-owned intermediate ATA in the `PermanentDelegate` case (see `reports/L-02-mint-validation-call-sites-incomplete.md`). ## Execute-time re-validation The execute handler re-checks several invariants that were already enforced at create time. The pattern is defense-in-depth: these values are sourced from on-chain state, so a directly-serialized malformed account (or a regression in create-time validation) must not be able to trigger a panic or privilege escalation. | Re-check | Source report | | --------------------------------------------------------------------------------------------- | ------------- | | `validate_mint_compatible(input_mint)` AND `(output_mint)` (Token-2022 extensions mutate) | L-02 | | `n <= checks.len()` before indexing `data_checks[i]` | H-04 | | `ByteRangeCheck::validate` rejects `length > 8` rather than panicking | H-06 | | `pre_validation.program_id() == pre_program_info.key()` (+ same for post) | C-1 | | `forward_config.instruction_constraint.program_id` allowlist (transitively, via stored state) | — | ## Emergency pause `ProgramConfig.emergency_pause` is a global kill switch. The execute handler's `config` account carries: ```rust #[account( seeds = [CONFIG_SEED], bump = config.bump, constraint = !config.emergency_pause, )] pub config: Box>, ``` When the pause flag is true, **every** `execute_composable` (and `execute_payment`) call fails the constraint at the top of the handler, before any state is read or any CPI is attempted. Create is also blocked via the same flag on `CreateComposablePolicy::config`. The flag is intended for incident response — e.g. an unresolved vulability in an allowlisted forward program. It does **not** freeze existing policy state; recipients and users retain full custody, and the flag can be cleared to resume execution. # Composable API Reference Complete reference for the four composable instructions, their accounts, and the key types. ## Instructions | Instruction | Description | | -------------------------- | ---------------------------------------------- | | `create_composable_policy` | Initialize a new ComposablePolicy | | `execute_composable` | Execute a pull + validation + forward + settle | | `delete_composable_policy` | Close a paused/terminated policy (owner only) | | `change_composable_status` | Toggle policy between Active and Paused | ______________________________________________________________________ ## `create_composable_policy` Creates a ComposablePolicy PDA and optionally the pre/post ValidationPdas. ### Accounts | Account | Type | Signer | Notes | | ------------------------- | --------------------------- | ------ | ---------------------------------------------------------------- | | `fee_payer` | `Signer` | ✅ | Pays for account creation | | `user` | `Signer` | ✅ | `user_payment.owner` — must sign | | `recipient` | `UncheckedAccount` | ❌ | Must be non-default | | `composable_policy` | `Account` | PDA | `init`, seeds = `["composable_policy", user_payment, policy_id]` | | `user_payment` | `Account` | PDA | Must be active | | `gateway` | `Account` | PDA | Must be active | | `config` | `Account` | PDA | `!emergency_pause` | | `pre_validation_program` | `UncheckedAccount` | ❌ | Lighthouse or SystemProgram | | `post_validation_program` | `UncheckedAccount` | ❌ | Lighthouse or SystemProgram | | `pre_validation_pda` | `UncheckedAccount` | ❌ | `init` if pre-validation enabled | | `post_validation_pda` | `UncheckedAccount` | ❌ | `init` if post-validation enabled | | `input_mint` | `InterfaceAccount` | ❌ | `== user_payment.token_mint` | | `output_mint` | `UncheckedAccount` | ❌ | SPL Mint or SystemProgram (act mode) | | `system_program` | `Program` | ❌ | | ### Arguments | Arg | Type | Notes | | ---------------------- | ---------------- | ------------------------------------------------------ | | `policy_type` | `PolicyType` | subscription / payAsYouGo / milestone / oneTime / upto | | `memo` | `[u8; 32]` | 32-byte UTF-8 memo | | `forward_config` | `ForwardConfig` | See types below | | `pre_validation` | `ValidationSpec` | `Disabled` or `ProgramCall { program_id }` | | `pre_validation_init` | `ValidationInit` | Pinned accounts + assertion data | | `post_validation` | `ValidationSpec` | Same as pre | | `post_validation_init` | `ValidationInit` | Same as pre | ### SDK ```typescript // High-level (handles ATA + UserPayment + delegate approval): const ixs = await sdk.createComposable( tokenMint, recipient, gateway, policyType, memo, forwardConfig, preValidation, prePinnedAccounts, preValidationData, postValidation, postPinnedAccounts, postValidationData ); // Low-level (instruction only): const ix = await sdk.getCreateComposablePolicyInstruction( tokenMint, recipient, gateway, policyType, memo, forwardConfig, preValidation, prePinnedAccounts, preValidationData, postValidation, postPinnedAccounts, postValidationData ); ``` ______________________________________________________________________ ## `execute_composable` Executes the 7-phase flow: `Byte-range checks` → `Pull` → `Skim` → `Pre-validate` → `Forward` → `Post-validate` → `Settle`. Permissionless if the gateway has the `FEATURE_PERMISSIONLESS` flag; cold relayers (non-trusted callers) additionally require post_validation OR a route pin (ADR-0016). ### Accounts | Account | Type | Signer | Notes | | ----------------------------------- | ---------------------------------------- | ------ | -------------------------------------------------------------------------------- | | `fee_payer` | `Signer` | ✅ | Gateway signer, owner, recipient, or any signer (permissionless) | | `payments_delegate` | `UncheckedAccount` | PDA | Legacy global delegate `["payments"]` (v0) OR v1 UserPayment PDA | | `composable_policy` | `Account` | PDA | Must be `Active` | | `user_payment` | `Account` | PDA | Must be active | | `gateway` | `Account` | PDA | Must match policy's gateway | | `config` | `Account` | PDA | `!emergency_pause` | | `pre_validation_program` | `UncheckedAccount` | ❌ | Lighthouse or SystemProgram | | `post_validation_program` | `UncheckedAccount` | ❌ | Lighthouse or SystemProgram | | `pre_validation_pda` | `UncheckedAccount` | ❌ | Deserialised in handler if enabled | | `post_validation_pda` | `UncheckedAccount` | ❌ | Deserialised in handler if enabled | | `user_token_account` | `InterfaceAccount` | ❌ | Delegate must be UserPayment or PaymentsDelegate PDA | | `mint` | `InterfaceAccount` | ❌ | `== forward_config.input_mint` | | `output_mint` | `UncheckedAccount` | ❌ | SPL Mint or SystemProgram (act mode) | | `intermediate_input_token_account` | `InterfaceAccount` | ❌ | Owned by ComposablePolicy PDA | | `intermediate_output_token_account` | `InterfaceAccount` | ❌ | Owned by ComposablePolicy PDA (deliver-transform only) | | `recipient_token_account` | `InterfaceAccount` | ❌ | Output-mint ATA of recipient (system wallet in act/native mode) | | `gateway_fee_account` | `InterfaceAccount` | ❌ | Input-mint ATA of gateway.fee_recipient | | `protocol_fee_account` | `InterfaceAccount` | ❌ | Input-mint ATA of config.fee_recipient | | `scheduler_ata` | `Option>` | ❌ | Input-mint ATA of caller (scheduler cut, if applicable) | | `forward_program` | `UncheckedAccount` | ❌ | The forward target program (CPI resolution); required even when forward disabled | | `token_program` | `Program` | ❌ | | | `associated_token_program` | `Program` | ❌ | Required for lazy ATA creation | | `system_program` | `Program` | ❌ | | | `remaining_accounts` | `Vec` | ❌ | `[...preValTargets, ...forwardAccounts, ...postValTargets]` | ### Arguments | Arg | Type | Notes | | ------------------ | ------------- | -------------------------------------------------------------- | | `instruction_data` | `Vec` | Forward program instruction data (empty if forward disabled) | | `forward_amount` | `Option` | Pull amount (required for PayAsYouGo; `None` for subscription) | ### SDK ```typescript const execIxs = await sdk.executeComposable( composablePolicyPDA, instructionData, // Buffer — forward instruction data forwardAmount, // BN | null remainingAccounts // AccountMeta[] ); // Returns: [recipientATAEnsure?, gatewayFeeATAEnsure?, protocolFeeATAEnsure?, executeIx] ``` ______________________________________________________________________ ## `delete_composable_policy` Closes a non-Active ComposablePolicy account and reclaims rent. Owner only. ### Accounts | Account | Type | Signer | Notes | | ------------------- | --------------------------- | ------ | -------------------- | | `owner` | `Signer` | ✅ | `user_payment.owner` | | `user_payment` | `Account` | PDA | | | `composable_policy` | `Account` | PDA | Must NOT be `Active` | | `config` | `Account` | PDA | `!emergency_pause` | | `system_program` | `Program` | ❌ | | ### Arguments | Arg | Type | | ----------- | ----- | | `policy_id` | `u32` | ______________________________________________________________________ ## `change_composable_status` Toggles a ComposablePolicy between `Active` and `Paused`. Owner only. ### Accounts | Account | Type | Signer | Notes | | ------------------- | --------------------------- | ------ | --------------------------- | | `owner` | `Signer` | ✅ | `user_payment.owner` | | `user_payment` | `Account` | PDA | | | `composable_policy` | `Account` | PDA | | | `gateway` | `Account` | PDA | Must match policy's gateway | | `config` | `Account` | PDA | | ### Arguments | Arg | Type | Notes | | ----------- | -------------- | -------------------- | | `policy_id` | `u32` | | | `status` | `PolicyStatus` | `Active` or `Paused` | ______________________________________________________________________ ## Key types ### `ComposablePolicy` | Field | Type | Size | Notes | | ----------------- | ---------------- | ---- | ------------------------------------------------------ | | `user_payment` | `Pubkey` | 32 | Parent UserPayment | | `policy_id` | `u32` | 4 | From `created_composable_count` | | `gateway` | `Pubkey` | 32 | | | `recipient` | `Pubkey` | 32 | | | `status` | `PolicyStatus` | 1 | Active / Paused / Terminated | | `policy_type` | `PolicyType` | 128 | subscription / payAsYouGo / milestone / oneTime / upto | | `forward_config` | `ForwardConfig` | 205 | See below | | `pre_validation` | `ValidationSpec` | 33 | | | `post_validation` | `ValidationSpec` | 33 | | | `memo` | `[u8; 32]` | 32 | ADR-0017 | | `total_output` | `u64` | 8 | Lifetime output accumulated | | `bump` | `u8` | 1 | | | `padding` | `[u8; 192]` | 192 | ADR-0022 | ### `ForwardConfig` (205 bytes) | Field | Type | Notes | | ------------------------ | ----------------------- | ------------------------------------------------------------------- | | `instruction_constraint` | `InstructionConstraint` | 140 bytes — see below | | `input_mint` | `Pubkey` | `== user_payment.token_mint` | | `output_mint` | `Pubkey` | Concrete mint, `== input_mint` (no swap), or `default()` (act mode) | | `forward_flags` | `u8` | Bit 0 = `FORWARD_FLAG_NATIVE_OUTPUT` | ### `InstructionConstraint` (140 bytes) | Field | Type | Notes | | --------------------- | --------------------- | ------------------------------------------------------------- | | `program_id` | `Pubkey` | `Pubkey::default()` = forward disabled | | `num_data_checks` | `u8` | Active entries in `data_checks` | | `data_checks` | `[ByteRangeCheck; 4]` | Fixed-size; at least 1 must pin offset 0 when forward enabled | | `num_pinned_accounts` | `u8` | Active entries in `pinned_accounts` | | `pinned_accounts` | `[PinnedAccount; 2]` | Fixed-size | ### `ByteRangeCheck` | Field | Type | Notes | | ---------- | --------- | ------------------------------------------- | | `offset` | `u8` | Byte offset in instruction data (0–255) | | `length` | `u8` | 1–8 | | `expected` | `[u8; 8]` | Expected value at `[offset, offset+length)` | ### `PinnedAccount` | Field | Type | Notes | | -------- | -------- | ---------------------------------------------- | | `index` | `u8` | Position in the forward-account slice | | `pubkey` | `Pubkey` | Must be concrete (no default-pubkey wildcards) | ### `ValidationSpec` | Variant | Payload | Notes | | ------------- | ------------------------ | ---------------------------------------- | | `Disabled` | — | No validation | | `ProgramCall` | `{ program_id: Pubkey }` | Must be in `ALLOWED_VALIDATION_PROGRAMS` | | `Inline` | `{ reserved: u8 }` | Not implemented; errors at create | ## PDA seeds | PDA | Seeds | Notes | | ------------------- | --------------------------------------------------- | ---------------------------------------------------------------- | | `ComposablePolicy` | `["composable_policy", user_payment, policy_id_le]` | `policy_id_le` = `u32` LE bytes from `created_composable_count` | | `PreValidationPda` | `["composable_validation_pre", composable_policy]` | Stores ≤512 bytes of Lighthouse assertion data + pinned accounts | | `PostValidationPda` | `["composable_validation_post", composable_policy]` | Stores ≤512 bytes of Lighthouse assertion data + pinned accounts | ## Fee path (composable — input-side, ADR-0026) Composable fees are **input-side**: the gross pull = `face + fee`, and the fee is skimmed from `intermediate_input_token_account` in Phase 1b **before** the forward runs. After skim, the intermediate holds exactly `face`. This is hardcoded for composable — the `FEATURE_NET_AMOUNT` gateway flag is ignored (it only governs PaymentPolicy fee basis). | Cut | Routing | | ---------------- | ------------------------------------------------------------------------------------------------- | | Protocol | `config.protocol_share_bps × total_fee` → `protocol_fee_account` (input-mint ATA) | | Scheduler | `gateway.scheduler_share_bps × total_fee` → `scheduler_ata` (or merges into gateway if self-exec) | | Referral pool | `gateway.referral_allocation_bps × total_fee` (tiered, `FEATURE_REFERRAL`) | | Gateway residual | remainder → `gateway_fee_account` (input-mint ATA) | Constraint: `protocol_share + scheduler_share + referral_allocation ≤ 10000`. ## Settlement shapes (ADR-0026) The combination of `output_mint` and forward-enabled selects one of three settlement behaviours in Phase 5 (fees already skimmed in 1b; only principal moves here): | Shape | Trigger | Behaviour | | ------------------------ | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | **deliver-no-transform** | `output_mint == input_mint`, forward disabled | Sweep `intermediate_input` (face) → recipient_token_account. | | **deliver-transform** | `output_mint != input_mint`, forward enabled | Forward swaps input → output; sweep `intermediate_output` → recipient (`>0` guard KEPT); input residue → user. | | **act mode** | `output_mint == Pubkey::default()`, forward enabled | Forward consumes input for non-fungible settlement. No output ATA, no deliver sweep, no `>0` guard. Input residue → user. | ## Related - [Overview](https://docs.tributary.so/protocol-reference/composable-policy/overview/index.md) — 7-phase execution flow - [Validation Hook](https://docs.tributary.so/protocol-reference/composable-policy/validation-hook/index.md) — Lighthouse CPI mechanics - [Forward Hook](https://docs.tributary.so/protocol-reference/composable-policy/forward-hook/index.md) — Forward CPI mechanics - [SDK reference](https://docs.tributary.so/integration-guide/programmable-pull-payments/sdk/index.md) # Forward Hook The **forward hook** is an opt-in token-transform CPI that runs **after** the validation hook (Phase 2) and **before** settlement (Phase 4). It lets a policy pull one token from the user and deliver a different token to the recipient — e.g. pull USDC, swap to WSOL via Meteora DLMM, then settle in WSOL (or native SOL — see [native-output.md](https://docs.tributary.so/protocol-reference/composable-policy/native-output/index.md)). The currently allowlisted forward targets are **Meteora DLMM**, **Raydium CPMM**, **Raydium CLMM**, and **Orca Whirlpool** (see [allowlists-and-sentinels.md](https://docs.tributary.so/protocol-reference/composable-policy/allowlists-and-sentinels/index.md) and ADR-0032 for the Raydium CPMM addition). ## ForwardConfig (on-policy) Stored inline on the `ComposablePolicy` account: ```rust #[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, Debug, PartialEq)] pub struct ForwardConfig { /// Unified instruction constraint — pins the forward program, its /// instruction selector, and indexed accounts. Absorbs the old /// `target_program` + `data_checks` fields and the scrapped /// `ForwardAccountsPda` design. /// `instruction_constraint.program_id == Pubkey::default()` is the /// "forward disabled" sentinel. pub instruction_constraint: InstructionConstraint, /// == user_payment.token_mint. Validated at execute time against the /// user_token_account mint. pub input_mint: Pubkey, /// Recipient delivery mint. May equal input_mint only when forward is /// disabled (same-mint topup path). `Pubkey::default()` + forward /// enabled = **act mode** (ADR-0026). pub output_mint: Pubkey, /// Bit 0 = FORWARD_FLAG_NATIVE_OUTPUT (see native-output.md). pub forward_flags: u8, } impl ForwardConfig { pub const SIZE: usize = InstructionConstraint::SIZE + // instruction_constraint 32 + // input_mint 32 + // output_mint 1; // forward_flags = 205 bytes } ``` Source: `programs/tributary/src/state/composable_policy.rs`. ## InstructionConstraint — unified forward guard Replaces the v1.0 flat `target_program` + `ByteRangeCheck[]` model. Wraps the forward-program target, its instruction-selector byte-range checks, and its positional account pins into one struct. At create time `program_id == Pubkey::default()` ≡ forward disabled. ```rust #[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, Debug, PartialEq)] pub struct InstructionConstraint { /// CPI target. `Pubkey::default()` is the forward-disabled sentinel. pub program_id: Pubkey, pub num_data_checks: u8, pub data_checks: [ByteRangeCheck; 4], pub num_pinned_accounts: u8, /// Indexed pins: `pinned_accounts[i]` constrains the account at /// `remaining_accounts[forward_start + pinned_accounts[i].index]` to /// equal `pinned_accounts[i].pubkey`. pub pinned_accounts: [PinnedAccount; 2], } impl InstructionConstraint { pub const SIZE: usize = 140; // = 32 + 1 + (1+1+8)*4 + 1 + (1+32)*2 } ``` ## PinnedAccount — indexed forward-account pin Replaces the old positional `[Pubkey; 4]` array. Each pin independently declares which `remaining_accounts` slot it constrains, so account insertions in the forward program's account list do not shift every pin. ```rust #[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, Debug, PartialEq, Default)] pub struct PinnedAccount { pub index: u8, pub pubkey: Pubkey, } ``` ## ByteRangeCheck — pinning the forward instruction Each `ByteRangeCheck` asserts that a contiguous slice of the caller-supplied `instruction_data` equals a stored constant. The check that matters most is the **discriminator pin** — a check at `offset == 0` that fixes the first bytes of the forward program's instruction selector. Without this, a gateway signer could swap an arbitrary instruction (e.g. a Token `transfer` to itself) into the forward slot. ```rust #[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, Debug, PartialEq)] pub struct ByteRangeCheck { pub offset: u8, pub length: u8, // MUST be <= 8 (validated at create + execute) pub expected: [u8; 8], // only the first `length` bytes are compared } impl ByteRangeCheck { pub fn validate(&self, instruction_data: &[u8]) -> bool { if self.length > 8 { return false; } if self.offset as usize + self.length as usize > instruction_data.len() { return false; } let start = self.offset as usize; let end = start + self.length as usize; instruction_data[start..end] == self.expected[..self.length as usize] } } ``` SDK representation In the Rust program `expected` is `[u8; 8]`. In the TypeScript SDK (resolved from the Anchor IDL) the same field is typed as `Buffer`. The first `length` bytes are compared; trailing bytes are ignored in both representations. ### Create-time rules `validate_forward_config` and the create handler enforce: | Rule | Error | | --------------------------------------------------------------------------------- | --------------------------------- | | `instruction_constraint.program_id == Pubkey::default()` ⟹ `num_data_checks == 0` | `InsufficientByteRangeChecks` | | forward disabled ⟹ `input_mint == output_mint` | `ForwardDisabledRequiresSameMint` | | `instruction_constraint.program_id` in `ALLOWED_FORWARD_PROGRAMS` | `InvalidForwardProgram` | | forward enabled ⟹ `1 <= instruction_constraint.num_data_checks <= 4` | `InsufficientByteRangeChecks` | | forward enabled ⟹ `instruction_constraint.num_pinned_accounts <= 2` | `InsufficientPinnedAccounts` | | **forward enabled ⟹ `has_effective_pins()` (≥ 1 non-default pin)** | `DegenerateForwardPins` | | no duplicate indices among active `pinned_accounts` | `DuplicatePinIndex` | | for each active check: `offset + length <= 1024` | `ByteRangeCheckFailed` | | for each active check: `length <= 8` | `ByteRangeCheckFailed` | | at least one check with `offset == 0 && length > 0` | `DiscriminatorCheckRequired` | | `forward_flags & FORWARD_FLAG_NATIVE_OUTPUT` ⟹ `output_mint == NATIVE_MINT` | `NativeOutputRequiresWsol` | The **degenerate-pin guard** rejects a forward-enabled constraint with zero effective pins. Without it, a gateway could swap in an arbitrary remaining-account at the forward slot — the pin set is what binds the forward CPI to a specific account layout (see CF-001, [security-model.md](https://docs.tributary.so/protocol-reference/composable-policy/security-model/index.md)). ### `output_mint` — three settlement shapes The combination of `output_mint` and whether forward is enabled selects one of three settlement shapes (ADR-0026, see also [overview.md](https://docs.tributary.so/protocol-reference/composable-policy/overview/index.md) Phase 5): | `output_mint` | Forward | Shape | Behaviour | | ------------------------ | -------- | ------------------------ | --------------------------------------------------------------------------------------------------- | | `== input_mint` | disabled | **deliver-no-transform** | Intermediate input swept directly to recipient. No swap. | | concrete mint `!= input` | enabled | **deliver-transform** | Forward swaps input → output; output swept; `>0` guard KEPT. | | `Pubkey::default()` | enabled | **act mode** | Forward consumes input for non-fungible settlement. No output ATA, no deliver sweep, no `>0` guard. | ### Execute-time re-validation Even though create-time validation rejects malformed configs, the handler re-runs both checks with values sourced from on-chain state: 1. **Byte-range check**: `validate_byte_ranges` re-checks `n <= MAX_BYTE_RANGE_CHECKS` and `n <= checks.len()` before indexing (H-04 defense-in-depth — a directly-serialised malformed account must not trigger an indexed panic). 1. **Pinned-account check**: the handler calls `instruction_constraint.pins_match(remaining_accounts, forward_start)` to verify that every active pin's `remaining_accounts[forward_start + pin.index]` equals `pin.pubkey`. ## Output floor — post-validation replaces `min_output_amount` `min_output_amount` was removed from `ForwardConfig` in v2.1. The semantic replacement is **post-validation**: a `ValidationSpec::ProgramCall` CPI (Lighthouse) that runs **after** the forward step and before settlement. The owner writes a Lighthouse assertion that enforces the minimum output amount — for example `"recipient_token_account.amount >= 1_000_000"` after the forward CPI has deposited tokens there. This generalises the v1.0 `min_output_amount` concept: the assertion can check any on-chain state, not just a number-of-tokens threshold. See [validation-hook.md](https://docs.tributary.so/protocol-reference/composable-policy/validation-hook/index.md) for the full post-validation mechanics and assertion-building reference. Deliver-transform output-floor guard In deliver-transform mode the program still asserts `output_amount > 0` — Tributary confirms the forward program **ran**. The **amount** floor (`amount >= N`) is pure post-validation; the `> 0` guard is an existence assertion, not a pricing assertion. ## Forward CPI mechanics `run_forward_cpi` builds the forward instruction from the caller-supplied `remaining_accounts`: ```text remaining_accounts layout: ┌────────────────────────────────────────────────────────────────────────┐ │ validation targets (pre) — consumed by pre-validation CPI │ ├────────────────────────────────────────────────────────────────────────┤ │ forward-program accounts — forwarded to Meteora DLMM etc. │ │ pinned_accounts check: │ │ remaining_mid[fwd_base + pin.index] == pin.pubkey for each pin │ │ (Base index fwd_base = after pre-validation targets.) │ ├────────────────────────────────────────────────────────────────────────┤ │ validation targets (post) — consumed by post-validation CPI │ └────────────────────────────────────────────────────────────────────────┘ ``` Forward accounts are forwarded verbatim — including executable accounts (see the H-04 comment in `run_forward_cpi` explaining why executables are NOT stripped). The CPI is invoked with `invoke_signed` using only the ComposablePolicy PDA seeds: ```rust let instruction = Instruction { program_id: target_program, accounts: build_forward_account_metas(&infos, intermediate_owner_pda), data: instruction_data.to_vec(), }; invoke_signed(&instruction, &all_forward_infos, &[intermediate_owner_seeds])?; ``` `build_forward_account_metas` forces `is_signer: true` **only** for the ComposablePolicy PDA itself; every other forwarded account is `is_signer: false`, even if the caller passed it as a signer in the outer transaction. `is_writable` is forwarded verbatim — the Solana runtime rejects any inner instruction claiming writable access the outer transaction did not also mark writable, so this cannot elevate privileges. See [security-model.md](https://docs.tributary.so/protocol-reference/composable-policy/security-model/index.md) for why the ComposablePolicy PDA is the only signer and why this bounds the forward program's blast radius to the transient intermediate balances. ## Disabling forward Set `instruction_constraint.program_id = Pubkey::default()` at create time (or pass `InstructionConstraint::default()`). The handler detects this sentinel and: - Skips the byte-range check and pinned-account check. - Skips `run_forward_cpi` entirely. - Uses the same-mint topup path: input and output intermediates collapse into one account; the face balance is swept directly to the recipient. This is the "auto topup" pattern — pull USDC, deliver USDC, no swap. See [allowlists-and-sentinels.md](https://docs.tributary.so/protocol-reference/composable-policy/allowlists-and-sentinels/index.md). # Native Output (`FORWARD_FLAG_NATIVE_OUTPUT`) Bit 0 of `ForwardConfig.forward_flags` selects the **native-output** sweep mode. When set, the post-swap settlement step unwraps the WSOL intermediate into native SOL via `closeAccount`, instead of sweeping WSOL into the recipient's WSOL ATA. ```text FORWARD_FLAG_NATIVE_OUTPUT: u8 = 1; // constants.rs NATIVE_MINT: Pubkey = "So11111111111111111111111111111111111111112"; ``` ## When to use Use native-output when the **recipient wants native SOL**, not WSOL. Common cases: - A wallet that does not maintain a WSOL ATA and prefers SOL directly. - A hot-wallet topup flow that pays gas in SOL. - Recipient is a smart contract / program that consumes lamports, not SPL-token balances. Without this flag, the recipient receives WSOL and must separately unwrap it (`createAccount` + `closeAccount`) to spend it as gas. ## Create-time guard `validate_forward_config` enforces: ```rust if forward_config.is_native_output() { require!( forward_config.output_mint == NATIVE_MINT, TributaryError::NativeOutputRequiresWsol, ); } ``` Auto-unwrapping any `output_mint` other than WSOL would `closeAccount` an unrelated token account — the guard makes the invariant explicit. ## Execution difference In normal mode the post-swap sweep is a `transfer_checked`: ```text intermediate_output_ata (WSOL) │ transfer_checked(sweep_amount) ▼ recipient_token_account (recipient's WSOL ATA) ``` In native-output mode the sweep is a `closeAccount`: ```text intermediate_output_ata (WSOL, owned by ComposablePolicy PDA) │ closeAccount │ authority = ComposablePolicy PDA │ destination = recipient_token_account (== recipient's SYSTEM wallet) ▼ recipient_token_account (recipient's system account, native SOL) ↑ receives sweep_amount lamports (the WSOL value) ↑ PLUS the closed ATA's rent lamports (side-effect bonus) ``` Source: `sweep_output_to_recipient` in `execute_composable.rs`. Distinct from act mode This native-output path requires `output_mint == NATIVE_MINT` and `FORWARD_FLAG_NATIVE_OUTPUT` set, with a real WSOL intermediate ATA and a deliver sweep. It is **different from act mode** (`output_mint == Pubkey::default()` + forward enabled, ADR-0026), where the forward consumes the input for non-fungible settlement and there is no output ATA, no deliver sweep, and no `>0` guard. See [overview.md](https://docs.tributary.so/protocol-reference/composable-policy/overview/index.md) Phase 5. Fees are input-side (Phase 1b) Despite the output-focused flow above, fees are **not** taken from the output path. Per ADR-0026, composable fees are input-side — skimmed from the gross pull (USDC/`input_mint`) in Phase 1b, **before** the forward swap or native-output unwrap runs. The protocol and gateway fee accounts hold `input_mint` tokens (USDC in this example), not WSOL. After skim, `intermediate_input` holds exactly `face`, which is what the forward consumes. The output side only delivers the post-swap principal to the recipient. The `sweep_amount` returned for accounting is the face amount that made it through the forward (fees already deducted upstream); the rent bonus shipped by `closeAccount` is excluded from `composable_policy.total_output`. ## Recipient validation The `recipient_token_account` argument has dual meaning: | Mode | What it must be | | ------------- | ---------------------------------------------------------------------------------- | | Normal | The recipient's `output_mint` ATA (`mint == output_mint` AND `owner == recipient`) | | Native-output | The recipient's **system wallet** (`key == composable_policy.recipient`) | Anchor constraints are static, so `recipient_token_account` is declared as `UncheckedAccount` and the handler replicates the two normal-mode checks (mint at bytes `0..32`, owner at bytes `32..64` of the SPL Token layout) inline. In native-output mode the handler asserts: ```rust require!( ctx.accounts.recipient_token_account.key() == ctx.accounts.composable_policy.recipient, TributaryError::Unauthorized, ); ``` ## Security — destination pinned on-chain The `closeAccount` `destination` is constrained on-chain to equal `composable_policy.recipient`. There is no drain vector: a gateway cannot redirect the unwrap to itself, because the handler (not the caller) chooses the destination and validates it against stored policy state. The rejected alternative — a generic Token/wrap forward program that performed the unwrap itself — would have let the gateway redirect `closeAccount`'s `destination` to its own wallet. Pinning the destination in the Tributary handler closes that vector. See `reports/native-output-sweep.md` and bean `tributary-hgp7` for the full write-up. ## Post-sweep cleanup `closeAccount` zeroes the WSOL intermediate, so the handler's "verify intermediates empty" step skips the output-side check when `native_output` is true: ```rust if !native_output { let output_check = read_token_amount(&intermediate_output_ata)?; require!(output_check == 0, TributaryError::InsufficientBalance); } ``` Similarly, the final intermediate-close loop skips re-closing the output intermediate (it no longer exists). The input intermediate is still closed normally to return rent to the fee payer. # ComposablePolicy — Overview A `ComposablePolicy` is a **programmable pull-payment policy** that reuses the same `PolicyType` schedule model as `PaymentPolicy` (see [vs-payment-policy.md](https://docs.tributary.so/protocol-reference/composable-policy/vs-payment-policy/index.md)) but inserts **three opt-in hooks** into the execution path: 1. **Pre-validation** — a read-only assertion CPI (Lighthouse) that runs after the pull but before the forward, vetoing the tx if on-chain state doesn't satisfy a stored predicate. 1. **Forward** — a token-transform CPI (Meteora DLMM) that swaps the pulled input token into an output token before settlement. 1. **Post-validation** — a read-only assertion CPI (Lighthouse) that runs after the forward but before settlement, acting as the owner's floor on output (replacing the removed `min_output_amount`). A `ComposablePolicy` with both hooks disabled behaves like a `PaymentPolicy` but lives in its own PDA namespace and routes the pull through an intermediate ATA owned by the `ComposablePolicy` PDA. ## The 7-Phase Execution Flow `execute_composable` is a single transaction handler (`programs/tributary/src/instructions/composable/execute_composable.rs`) that runs seven phases — Byte-range checks, `PULL`, `SKIM` (input-side fees), `PRE-VALIDATE` (optional), `FORWARD` (optional), `POST-VALIDATE` (optional), and `SETTLE`. The intermediate ATAs are created lazily and closed at the end so rent returns to the fee payer. **NET-on-pull is hardcoded for composable** (ADR-0026): the gross pull = face + fee, fees are skimmed from the input mint *before* the forward runs. ```text execute_composable(policy, instruction_data, forward_amount) │ ├─── Step 1: BYTE-RANGE CHECKS ───────────────────────────────────────┐ │ • composable_policy.status == Active │ │ • !config.emergency_pause │ │ • gateway.is_active && gateway == composable_policy.gateway │ │ • fee_payer ∈ {gateway.signer, user_payment.owner, recipient} │ │ (or gateway.is_permissionless() — ADR-0016 cold-relayer gate) │ │ • validate_mint_compatible(input_mint) AND (output_mint) │ │ (re-checked at execute time — Token-2022 extensions mutate) │ │ • validate_byte_ranges(instruction_data, data_checks) │ │ (skipped when forward disabled — no selector to pin) │ │ • intermediate_input_ata == ATA(ComposablePolicy PDA, mint) │ │ • intermediate_output_ata == ATA(ComposablePolicy PDA, out_mint)│ │ (only in deliver-transform mode) │ │ • resolve face_amount from PolicyType schedule │ │ (PayAsYouGo: caller-supplied forward_amount as face) │ │ • compute gross_pull = face + fee (NET-on-pull hardcoded) │ │ • validate PayAsYouGo caps on GROSS (ADR-0026) │ │ • user_token_account.delegated_amount >= gross_pull │ │ • recipient_token_account matches output_mint+recipient │ │ (skipped in act mode; NATIVE_OUTPUT checks SOL key) │ │ • Cold-relayer OR-gate: post_validation OR has_route_pin │ └──────────────────────────────────────────────────────────────────────┘ │ ├─── Phase 1: PULL (user → intermediate_input) ──────────────────────┐ │ Resolve pull delegate (UserPayment PDA v1 OR legacy │ │ PaymentsDelegate PDA v0). Signer = resolved PDA. │ │ transfer_checked: gross_pull │ │ user_token_account → intermediate_input_ata │ │ (ComposablePolicy PDA owns the intermediate — NOT the │ │ UserPayment PDA; this is the security-critical decoupling.) │ └─────────────────────────────────────────────────────────────────────┘ │ ├─── Phase 1b: SKIM FEES (input-side, ADR-0026) ─────────────────────┐ │ Fees are split from intermediate_input BEFORE forward runs. │ │ After skim, intermediate_input holds exactly `face`. │ │ • calculate_fees(face, gateway_bps, protocol_share, │ │ scheduler_share, referral_allocation, NET-on-pull=true) │ │ • protocol_cut → protocol_fee_account (input_mint) │ │ • gateway_cut → gateway_fee_account (input_mint) │ │ • scheduler_cut → scheduler ATA (input_mint, permissionless) │ │ • referral allocation handled via gateway residual carve-out │ └─────────────────────────────────────────────────────────────────────┘ │ ├─── Phase 2: PRE-VALIDATION (optional) ─────────────────────────────┐ │ Only when pre_validation == ProgramCall{program_id}. │ │ CPI into Lighthouse via plain `invoke` — NO signer seeds. │ │ pre_validation_pda (seed: composable_validation_pre) │ │ contains assertion data + pinned target accounts. │ │ remaining accounts pin-checked against pre_validation_pda. │ │ Read-only: the validation program cannot move funds. │ │ Failure of the assertion aborts the transaction. │ └─────────────────────────────────────────────────────────────────────┘ │ ├─── Phase 3: FORWARD (optional) ────────────────────────────────────┐ │ Only when instruction_constraint.program_id != Pubkey::default()│ │ Indexed pin-check: for each PinnedAccount{index, pubkey}, │ │ remaining_mid[forward_start + index] == pin.pubkey │ │ CPI via `invoke_signed` with ComposablePolicy PDA seeds: │ │ • ComposablePolicy PDA is the ONLY signer forwarded │ │ • all other remaining_account → is_signer=false │ │ Swaps intermediate_input (face) → intermediate_output. │ └─────────────────────────────────────────────────────────────────────┘ │ ├─── Phase 4: POST-VALIDATION (optional) ────────────────────────────┐ │ Only when post_validation == ProgramCall{program_id}. │ │ CPI into Lighthouse via plain `invoke` — NO signer seeds. │ │ post_validation_pda (seed: composable_validation_post) │ │ contains assertion data + pinned target accounts. │ │ Owner's floor on output (deliver) or settlement (act). │ └─────────────────────────────────────────────────────────────────────┘ │ └─── Phase 5: SETTLE (shape-dependent, ADR-0026) ────────────────────┐ Three shapes — fees already skimmed in Phase 1b, only │ principal is moved here. │ │ deliver-no-transform: │ sweep intermediate_input (face) → recipient_token_account │ │ deliver-transform: │ sweep intermediate_output → recipient_token_account │ (>0 guard KEPT — output exists, floor is owner's job) │ return input residue (under-consumed face) → user │ │ act mode: │ forward consumed input for non-token settlement │ return input residue → user (no deliver sweep, no >0 guard) │ │ 1. verify both intermediates empty │ 2. advance_policy(policy_type, now, advance_amount) │ (shared calendar-month math — same as PaymentPolicy) │ 3. update total_input (gross), total_output (swept), count │ 4. close both intermediate ATAs → rent to fee_payer │ └────────────────────────────────────────────────────────────┘ ``` ## PDA Layout | PDA | Seeds | Owner | Purpose | | ------------------- | --------------------------------------------------- | ----------------- | ----------------------------------------------------------- | | `ComposablePolicy` | `["composable_policy", user_payment, policy_id_le]` | Tributary program | The policy state + **owner of both intermediate ATAs** | | `PreValidationPda` | `["composable_validation_pre", composable_policy]` | Tributary program | Stores ≤512 bytes of pre-forward Lighthouse assertion data | | `PostValidationPda` | `["composable_validation_post", composable_policy]` | Tributary program | Stores ≤512 bytes of post-forward Lighthouse assertion data | `policy_id_le` is the `u32` `composable_policy.policy_id` serialized as little-endian bytes. The `policy_id` is sourced from `user_payment.created_composable_count` (NOT `created_policies_count`). ### Counter separation `UserPayment` carries **two independent counters**: | Counter | Feeds IDs for | | -------------------------- | ---------------------- | | `created_policies_count` | `PaymentPolicy` IDs | | `created_composable_count` | `ComposablePolicy` IDs | A regular policy `#1` and a composable policy `#1` can therefore coexist on the same `UserPayment` without colliding — they live in different PDA namespaces (`["payment_policy", …]` vs `["composable_policy", …]`). ## Account Model ```text owns delegates User ───► user_token_account ◄─── user_payment PDA │ (pull signer only) │ │ creates ▼ ComposablePolicy PDA ──── owns ────► intermediate_input_ata (signs forward, sweep, intermediate_output_ata close CPIs; never a token-account delegate) ``` The **ComposablePolicy PDA** — not the `UserPayment` PDA — owns the intermediate ATAs. The `UserPayment` PDA is the delegate on `user_token_account` and signs **only** the initial pull (Phase 1). All subsequent CPIs (`FORWARD`, `SETTLE` fee transfers, sweep, intermediate `closeAccount`) are signed by the ComposablePolicy PDA. Because the ComposablePolicy PDA is never a token-account delegate anywhere, its signing authority can only ever move the transient intermediate balances — never the user's source funds. This is the security-critical decoupling introduced in the C-1 fix (see [security-model.md](https://docs.tributary.so/protocol-reference/composable-policy/security-model/index.md)). ## ComposablePolicy account (state) ```rust #[account] pub struct ComposablePolicy { pub bump: u8, pub user_payment: Pubkey, pub gateway: Pubkey, pub status: PolicyStatus, // Active | Paused | Completed pub rent_payer: Pubkey, pub policy_type: PolicyType, // same enum as PaymentPolicy (128 B) pub forward_config: ForwardConfig, // see forward-hook.md pub pre_validation: ValidationSpec, // pre-forward hook (Disabled | ProgramCall | Inline) pub post_validation: ValidationSpec, // post-forward hook pub memo: [u8; 32], pub recipient: Pubkey, pub total_input: u64, // lifetime gross pulled from user pub total_output: u64, // lifetime net swept to recipient pub payment_count: u32, pub policy_id: u32, pub created_at: i64, pub updated_at: i64, pub padding: [u8; 192], } ``` `ValidationSpec` enum: ```rust pub enum ValidationSpec { Disabled, ProgramCall { program_id: Pubkey }, // CPI to Lighthouse (allowlisted) Inline { reserved: u8 }, // reserved, errors at create } ``` Source: `programs/tributary/src/state/composable_policy.rs`. ## Instructions | Instruction | Description | | -------------------------- | ----------------------------------------------------------------------------------------------- | | `create_composable_policy` | Create the `ComposablePolicy` (+ optional `PreValidationPda` & `PostValidationPda`) account(s). | | `execute_composable` | Run the execution flow above. Permissionless — any gateway signer. | | `change_composable_status` | Toggle `Active` ↔ `Paused`. | | `delete_composable_policy` | Close the policy (+ both validation PDAs); refund rent to `rent_payer`. | ## Related pages - [Validation hook](https://docs.tributary.so/protocol-reference/composable-policy/validation-hook/index.md) — Lighthouse assertion CPI - [Forward hook](https://docs.tributary.so/protocol-reference/composable-policy/forward-hook/index.md) — Meteora DLMM swap + `ByteRangeCheck` pinning - [Native output](https://docs.tributary.so/protocol-reference/composable-policy/native-output/index.md) — `FORWARD_FLAG_NATIVE_OUTPUT` WSOL→SOL unwrap - [Allowlists & sentinels](https://docs.tributary.so/protocol-reference/composable-policy/allowlists-and-sentinels/index.md) — disabling hooks - [Security model](https://docs.tributary.so/protocol-reference/composable-policy/security-model/index.md) — intermediate-ATA ownership + signer sanitization - [vs. PaymentPolicy](https://docs.tributary.so/protocol-reference/composable-policy/vs-payment-policy/index.md) — which to choose # Security Model This page documents the security-critical invariants of `ComposablePolicy`. Auditors should read it alongside the inline `reports/*.md` references in `execute_composable.rs` and `create_composable_policy.rs`. ## 1. Intermediate ATA ownership — ComposablePolicy PDA, NOT UserPayment PDA The two intermediate ATAs (`intermediate_input_token_account` and `intermediate_output_token_account`) are owned by the **`ComposablePolicy` PDA**, not the `UserPayment` PDA. ```text UserPayment PDA │ delegate on user_token_account (signs ONLY the Phase 1 pull) │ │ NOT an owner of any intermediate ATA ▼ ComposablePolicy PDA │ owns intermediate_input_ata │ owns intermediate_output_ata │ signs forward CPI, fee transfers, sweep, closeAccount │ │ NEVER a token-account delegate anywhere ▼ blast radius = transient intermediate balances only ``` ### Why this matters The `UserPayment` PDA is the delegate on `user_token_account` (that's how the Phase 1 pull works). If the `UserPayment` PDA *also* owned the intermediate ATAs, then any CPI signed by `UserPayment` could move funds both **out of** the intermediates and **out of** `user_token_account` — because the same PDA would be both "intermediate-ATA owner" and "user-source delegate". This is exactly the dual-role coupling that the C-1 report documented as a drain vector. By parenting the intermediates under the `ComposablePolicy` PDA (which is **never** a token-account delegate anywhere), the two roles are decoupled: - `UserPayment` PDA signs the pull — and nothing else. - `ComposablePolicy` PDA signs every downstream CPI — but has no authority over `user_token_account`. A forward program that receives the `ComposablePolicy` PDA as a signer can therefore only move the transient intermediate balances (capped at `input_amount`), never the user's source funds. ### Address validation The handler re-derives the expected intermediate ATA addresses from the `ComposablePolicy` PDA at execute time: ```rust let intermediate_owner = ctx.accounts.composable_policy.key(); let expected_input_ata = Pubkey::find_program_address( &[ intermediate_owner.as_ref(), ctx.accounts.token_program.key().as_ref(), ctx.accounts.mint.key().as_ref(), ], ctx.accounts.associated_token_program.key, ).0; require!(ctx.accounts.intermediate_input_token_account.key() == expected_input_ata, TributaryError::IntermediateAccountMismatch); ``` This forces the intermediates to be the canonical ATAs of the `ComposablePolicy` PDA — no attacker-controlled lookalikes. ### Freshness on creation `create_ata` rejects pre-existing accounts: ```rust require!(ata.lamports() == 0, TributaryError::IntermediateAccountAlreadyExists); ``` This prevents stale or attacker-controlled intermediate accounts from sneaking in (e.g. an account pre-funded with a hostile `PermanentDelegate` mint, or an account whose owner is not the ComposablePolicy PDA). ## 2. CPI signer sanitization (C-1 remediation) The C-1 report (`reports/C-1-validation-cpi-signer-leak.md`) documented that the validation CPI previously used `invoke_signed` with `UserPayment` PDA seeds. Because `UserPayment` is the delegate on `user_token_account`, this granted the validation program (and any program it nested into) the ability to drain user funds via a nested Token `transfer`. ### Validation CPI — plain `invoke`, no signers Lighthouse is invoked with plain `invoke` — **no** signer seeds: ```rust anchor_lang::solana_program::program::invoke(&instruction, &all_infos)?; ``` The validation helper also hard-codes every forwarded account to `is_signer: false, is_writable: false`: ```rust fn build_validation_account_metas(accounts: &[AccountInfo<'_>]) -> Vec { accounts.iter().map(|a| AccountMeta { pubkey: *a.key, is_signer: false, is_writable: false, }).collect() } ``` So even if the caller re-passes `fee_payer` (which is a `Signer`) as a remaining_account, Lighthouse cannot inherit that authority. This is safe because (a) Lighthouse is read-only by design and (b) the validation CPI runs **before** Phase 3 funds the intermediates — there is nothing in them to move yet. ### Forward CPI — `invoke_signed` with ComposablePolicy seeds only The forward CPI uses `invoke_signed`, but the only PDA whose seeds are passed is the `ComposablePolicy` PDA: ```rust fn build_forward_account_metas( accounts: &[&AccountInfo<'_>], intermediate_owner_pda: Pubkey, ) -> Vec { accounts.iter().map(|a| AccountMeta { pubkey: *(*a).key, is_signer: *(*a).key == intermediate_owner_pda, // ONLY ComposablePolicy is_writable: (*a).is_writable, // forwarded verbatim }).collect() } ``` `is_writable` forwarding is safe because the Solana runtime rejects any inner instruction that claims writable access to an account the outer transaction did not also mark writable — privileges cannot be elevated by forwarding. The `ComposablePolicy` PDA owns both intermediates and is therefore a legitimate signer for any CPI that moves their balances (forward swap, fee transfers, sweep, closeAccount). Because it is never a delegate on `user_token_account`, this signing authority cannot reach the user's source funds. ## 3. Mint re-validation at execute time Token-2022 extensions (`TransferHook`, `TransferFee`, `PermanentDelegate`, `ConfidentialTransferMint`) can be **mutated after an mint is created**. A mint that was clean at `create_user_payment` time could turn hostile before `execute_composable` runs. The handler re-runs `validate_mint_compatible` on **both** the input and output mints at the top of execution: ```rust validate_mint_compatible(&ctx.accounts.mint.to_account_info())?; validate_mint_compatible(&ctx.accounts.output_mint.to_account_info())?; ``` The output-mint check matters even though the output mint was validated at create time: the execute handler creates a PDA-controlled intermediate ATA for it, and a `PermanentDelegate` output mint would drain that intermediate. See `reports/L-02-mint-validation-call-sites-incomplete.md`. ## 4. Dual-delegate support (v0 + v1) The user's `user_token_account` may delegate to **either** of two PDAs: | Version | Delegate PDA | Seeds | | ------- | -------------------------------------- | ------------------------------- | | v0 | `PaymentsDelegate` (legacy global PDA) | `["payments"]` | | v1 | `UserPayment` PDA (per-user, per-mint) | `["user_payment", owner, mint]` | `shared::delegation::resolve_delegate` picks whichever is actually set on the token account, and the pull CPI signs with that PDA's seeds. The Accounts struct constraint accepts either: ```rust constraint = token_account_has_any_delegate( &user_token_account.delegate, &[&payments_delegate.key(), &user_payment.key()] ) @ TributaryError::NoDelegateSet, ``` This is purely a pull-path concern. **All** downstream CPIs (forward, sweep, close) are signed by the `ComposablePolicy` PDA, so the choice of pull delegate has no effect on the security model of the hooks. ## 5. Arithmetic and panic safety - All `+`, `-`, `*` on user-controlled sizes route through `checked_*` and return `ArithmeticOverflow` on failure. No silent wrapping. - `ByteRangeCheck::validate` defends against `length > 8` panics even though create-time validation rejects them (H-06). - `validate_byte_ranges` re-checks `n <= checks.len()` before indexing (H-04). - The `skip_months` calendar loop is bounded by `MAX_MONTHLY_ITERATIONS = 1200` (~100 years) before bailing with `ArithmeticOverflow` — see `shared/schedule.rs`. ## 6. Emergency pause `ProgramConfig.emergency_pause` is a global kill switch enforced at the top of every `execute_composable` (and `execute_payment`) call. See [allowlists-and-sentinels.md](https://docs.tributary.so/protocol-reference/composable-policy/allowlists-and-sentinels/index.md) for details. ## 7. Settlement output guards — what the on-chain `>0` covers, and what it doesn't The composable execution pipeline (ADR-0026) has three settlement shapes. Each has a different on-chain backstop against a malicious or compromised gateway that controls the forward CPI's `remaining_accounts` and (within the pinned `InstructionConstraint`) the instruction data. | Shape | On-chain guard | Gateway vector | `post_validation` role | | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- | | **deliver-no-transform** (forward disabled, `output_mint == input_mint`) | n/a — forward never runs; intermediate holds deterministic `face` after fee skim | none | not needed | | **deliver-transform** (forward enabled, distinct `output_mint`) | `output_amount > 0` at `sweep_output_to_recipient` (`execute_composable.rs:523`) | **magnitude (dust)** — gateway sets swap `minimum_amount_out = 0`; recipient gets 1 unit; guard passes | optional; owner-economic magnitude floor | | **act mode** (sentinel `output_mint`) | **none** — no intermediate_output ATA; forward consumes input for non-token settlement | **full** — forward delivers nothing observable to Tributary | the only backstop, but target is use-case-specific (external account) | ### What the `>0` guard closes - **No output at all.** If the forward produces zero output (empty swap, wrong route), the guard reverts the transaction. The user loses only gas. - **Wrong destination.** The intermediate_output ATA is re-derived from the policy's declared `output_mint` at execute time (`execute_composable.rs:949`). ATA derivation is deterministic (owner + token_program + mint). If the gateway misroutes the forward's own destination slot (e.g. Raydium CPMM `swap_base_input` account index 11, which is not pinned), the swap credits a different account, Tributary reads 0 in its own intermediate, the guard fails, tx reverts. Fails closed. ### What the `>0` guard does NOT close (the magnitude gap) The guard is an **existence** assertion, not a **magnitude** assertion. A gateway can set the swap's `minimum_amount_out` to 0 in the instruction data (the `InstructionConstraint` pins the discriminator at offset 0 and the pool, not the amount fields). The swap returns dust (1 unit); the guard passes; the recipient receives 1 unit of output for a `face`-sized input pull. This generalizes the `min_output_amount` field removed in v2.1. **Owner opt-in magnitude floor** (deliver-transform): add a Lighthouse `post_validation` assertion on the intermediate_output ATA: ```typescript import { lighthouse } from "@tributary-so/sdk"; // ownerFloor = the minimum acceptable output amount, or 0 for existence // parity with the on-chain guard (defense-in-depth). const guard = lighthouse .tokenAccount(intermediateOutputAta) .amount(ownerFloor, ">=") .build(); // guard.data → Buffer stored in the post ValidationPda // guard.numAccounts → 1 // guard.accounts → [intermediateOutputAta] ``` The post ValidationPda seed is `["composable_validation_post", composablePolicy]`. ### Act mode — no on-chain backstop Act mode skips intermediate_output ATA creation, the deliver sweep, AND the `>0` guard. The forward consumes input for a non-token settlement (e.g. a Velocity subaccount deposit). Tributary cannot observe the delivery on-chain — there is no intermediate_output ATA to read. The owner's `post_validation` is genuinely the only floor here, AND the target account is use-case-specific (the external settlement account), not a Tributary-controlled intermediate. The SDK emits a builder-time warning when an act-mode policy is created without a `post_validation` ProgramCall — see the [SDK reference](https://docs.tributary.so/integration-guide/programmable-pull-payments/sdk/index.md). ### Why no on-chain enforcement Enforcing `post_validation = ProgramCall` on-chain is rejected (ADR-0031): - **Act mode is unenforceable** — the target account is external and use-case-specific; the program cannot know which account to assert against. - **Deliver-transform existence is already covered** — the catastrophic vectors revert. The residual magnitude gap is owner-economic. - **Flexibility** — legitimate use cases accept any non-zero output (volatile pools, trusted gateways). See [ADR-0031](https://docs.tributary.so/adr/0031-settlement-output-post-validation-posture.md) for the full decision and rejected alternatives. ## 8. Cold-relayer safety net (ADR-0016 amended) `execute_composable` is permissionless — any signer that the gateway allows may call it. To prevent a bare drain vector when the caller is neither the gateway signer, the user, nor the recipient (the "cold relayer" / pure-scheduler case), the handler enforces an OR-gate at the top of Phase 0: ```rust // execute_composable.rs — Phase 0 gate let is_trusted = fee_payer == gateway.signer || fee_payer == user_payment.owner || fee_payer == recipient; if !is_trusted { require!( composable_policy.post_validation.is_program_call() || composable_policy.forward_config .instruction_constraint.has_route_pin(), TributaryError::PermissionlessExecutionRequiresSafetyNet ); } ``` A non-trusted caller may only execute policies that carry **either** a post-validation hook (an owner-controlled settlement floor) **or** at least one `pinned_account` on the forward constraint (a route pin that binds the swap destination). This blocks the obvious "scheduler drains arbitrary output to its own ATA" attack: a bare PayAsYouGo policy with no validation and no pins is only executable by the trusted three. ## 9. CF-001 — indexed `PinnedAccount` (cross-account drain fix) The original forward design carried `pinned_accounts: [Pubkey; 4]` — a positional array. The execute handler checked that the slice at positions `[forward_start .. forward_start + n]` matched the stored pubkeys, but the *binding between pin and slot* was implicit. A gateway that controlled account ordering could re-order the forward-account slice so a different account landed on a given pin's slot — a cross-account substitution vector. The fix (CF-001) makes each pin **indexed**: ```rust pub struct PinnedAccount { pub index: u8, // position in remaining_accounts, relative to forward_start pub pubkey: Pubkey, } ``` `InstructionConstraint::pins_match` now verifies that `remaining_accounts[forward_start + pin.index].key() == pin.pubkey` for **every** active pin. The pin and its slot are bound at create time and re-validated at execute time. Combined with the signer sanitization in section 2, this means a forward CPI can only ever land on accounts the owner named when the policy was created. ## 10. Anchor `accountsStrict` validation `ExecuteComposable` uses Anchor's `#[derive(Accounts)]` builder with explicit constraints on every account: owner checks, `has_one` for the `composable_policy.user_payment` ↔ `user_payment` link, seed-derivation checks on every PDA, `mint` matches on every ATA, and `Program<...>` types for `token_program` / `associated_token_program` / `system_program`. The handler does not rely on positional account matching alone — every account is independently validated before the first instruction runs. # Validation Hook The **validation hook** is an opt-in read-only assertion CPI that runs **between** the pull (Phase 1) and the forward (Phase 3) phases of `execute_composable`. It lets a policy encode an on-chain predicate — "the recipient's hot wallet USDC balance is below 50 USDC" — that **must hold** before the rest of the execution proceeds. If the assertion fails, the entire transaction aborts and the pull is rolled back. ComposablePolicy supports **two** independent validation slots: **pre** (runs before forward, after skim) and **post** (runs after forward, before settlement). Each is independently opt-in. The hook is implemented by the **Lighthouse** on-chain assertion checker (`L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95`), the only entry in `ALLOWED_VALIDATION_PROGRAMS`. ## ValidationSpec (on-policy) Stored inline on the `ComposablePolicy` account (two instances: `pre_validation` and `post_validation`): ```rust #[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, Debug, PartialEq, Default)] pub enum ValidationSpec { #[default] Disabled, ProgramCall { program_id: Pubkey, }, Inline { reserved: u8, }, // not implemented — errors at create } impl ValidationSpec { pub const SIZE: usize = 1 + 32; // discriminant + max variant (ProgramCall) pub fn is_program_call(&self) -> bool { ... } pub fn program_id(&self) -> Pubkey { ... } } ``` `Disabled` → no CPI, no `ValidationPda` allocated. `ProgramCall { program_id }` → the program must be in `ALLOWED_VALIDATION_PROGRAMS`. `Inline` is reserved for a future compressed assertion format; it is rejected at create. Source: `programs/tributary/src/state/composable_policy.rs`. ## ValidationPda — separate account, ≤ 512 bytes The assertion payload for each active slot lives in its own PDA: | PDA | Seeds | Max size | | ------------------- | --------------------------------------------------- | ---------------------------------- | | `PreValidationPda` | `["composable_validation_pre", composable_policy]` | 8 (disc) + 68 (header) + 512 = 588 | | `PostValidationPda` | `["composable_validation_post", composable_policy]` | 8 (disc) + 68 (header) + 512 = 588 | ```rust pub const MAX_VALIDATION_DATA_SIZE: usize = 512; pub const MAX_PINNED_ACCOUNTS: usize = 2; #[account] pub struct ValidationPda { pub bump: u8, pub num_pinned_accounts: u8, pub pinned_accounts: [Pubkey; MAX_PINNED_ACCOUNTS], pub data_len: u16, pub data: [u8; MAX_VALIDATION_DATA_SIZE], } impl ValidationPda { pub const SIZE: usize = 8 + 1 + 1 + 64 + 2 + MAX_VALIDATION_DATA_SIZE; // = 588 pub fn get_data(&self) -> &[u8] { ... } pub fn pinned(&self) -> &[Pubkey] { ... } pub fn is_fresh(info: &AccountInfo) -> bool { ... } } ``` Source: `programs/tributary/src/state/validation_pda.rs`. ### Account layout (on-chain bytes) | Offset | Length | Field | Notes | | -------- | ---------- | --------------------- | ------------------------------------------------- | | `0..8` | 8 | Anchor discriminator | `ValidationPda::DISCRIMINATOR` | | `8..9` | 1 | `bump` | Canonical PDA bump | | `9..10` | 1 | `num_pinned_accounts` | Assertion arity ∈ {0, 1, 2} | | `10..74` | 64 | `pinned_accounts` | 2 × 32-byte Pubkeys | | `74..76` | 2 | `data_len` | `u16` LE; bounded by `MAX_VALIDATION_DATA_SIZE` | | `76..` | `data_len` | assertion `data` | Opaque bytes passed verbatim as the CPI `ix.data` | The remaining bytes up to 588 are zero-padded. ## Create-time flow `create_composable_policy` handles pre and post validation independently: 1. **Allowlist check** — `spec.program_id()` must equal Lighthouse, else `InvalidValidationProgram`. Passing `SystemProgram` selects `Disabled` (no `ValidationPda` allocated). 1. **ValidationInit guards** — when `spec` is `ProgramCall`: 1. `validation_data` must be non-empty, capped at `MAX_VALIDATION_DATA_SIZE`. 1. `num_pinned_accounts` ≤ `MAX_PINNED_ACCOUNTS` (2). 1. `pinned_accounts` must have concrete, non-default pubkeys. 1. **PDA derivation** — for each `ProgramCall` slot, the caller-supplied `{pre,post}_validation_pda` must match `find_program_address(["composable_validation_pre", composable_policy])` or `find_program_address(["composable_validation_post", composable_policy])` respectively. 1. **Freshness guard** — each `ValidationPda::is_fresh(info)` requires `lamports == 0` and `owner == system_program`. Defense-in-depth against re-init / type-cosplay (M-02). 1. **Manual init** — `system_instruction::create_account` signed by the fee payer, then discriminator + fields written via `try_borrow_mut_data`. Each slot is independently creatable — a policy may have only pre-validation, only post-validation, both, or neither. ## Execute-time flow `ValidationPda` accounts enter as **typed accounts** in `accountsStrict`, not in `remaining_accounts`. The `remaining_accounts` carry only the **Lighthouse read-target accounts** (pinned by the owner at create time). Layout of `remaining_accounts`: ```text ┌───────────────────────┬────────────────────────┬───────────────────────┬────────────────┐ │ pre-val targets (N) │ forward accounts │ post-val targets (M) │ scheduler ATA? │ └───────────────────────┴────────────────────────┴───────────────────────┴────────────────┘ N = pre-num-pinned ^ M = post-num-pinned (≤ 2) forward_accounts_start (≤ 2) ``` ### Step-by-step 1. **Scheduler strip** — if the caller is not the gateway signer and `scheduler_share_bps > 0`, the last entry is peeled off as the scheduler fee ATA. 1. **Post-validation target strip** — if `post_validation` is `ProgramCall`, the last `num_pinned` accounts are peeled off for Phase 4. The remaining slice (`remaining_mid`) contains `[pre-val targets, forward accounts]`. 1. **Phase 2: Pre-validation** — `run_validation_cpi` reads the `pre_validation_pda` from `accountsStrict`, verifies the PDA seed matches, deserialises the `ValidationPda`, pin-checks `remaining_mid[0..N]` against `pinned_accounts`, then invokes the validation program (Lighthouse) via plain `invoke` with those N accounts. Returns `N` as `forward_accounts_start`. 1. **Phase 3: Forward** — accounts starting at `remaining_mid[forward_start]` are forwarded to the forward program. 1. **Phase 4: Post-validation** — `run_validation_cpi` runs the same logic against `post_validation_pda` and the previously stripped `post_val_accounts`. ```rust // Phase 2 — pre-validation let forward_accounts_start = if pre_validation.is_program_call() { let pre_pda_info = ctx.accounts.pre_validation_pda.to_account_info(); run_validation_cpi( remaining_mid, // [pre-val targets, forward accounts] &pre_pda_info, &policy_key, ctx.program_id, &pre_program_info, VALIDATION_PDA_PRE_SEED, )? } else { 0 }; // Phase 4 — post-validation if post_validation.is_program_call() { let post_pda_info = ctx.accounts.post_validation_pda.to_account_info(); run_validation_cpi( &post_val_accounts, // already stripped before Phase 2 &post_pda_info, &policy_key, ctx.program_id, &post_program_info, VALIDATION_PDA_POST_SEED, )?; } ``` Source: `programs/tributary/src/instructions/composable/execute_composable.rs` (around lines 1232–1295). ## Read-only / no-signer CPI (C-1 remediation) Lighthouse is invoked via **plain `invoke`** — **no** signer seeds are forwarded: ```rust anchor_lang::solana_program::program::invoke(&instruction, &all_infos)?; ``` This is the security-critical fix from `reports/C-1-validation-cpi-signer-leak.md`. The previous implementation called `invoke_signed` with the `UserPayment` PDA seeds; because the `UserPayment` PDA is the delegate on `user_token_account`, this granted the validation program — and any program it nested into — the ability to drain user funds via a nested Token `transfer`. Two properties make the plain-`invoke` fix safe: 1. Lighthouse is an assertion checker — it is read-only by design. 1. The validation CPI has no PDA signing authority. Even though Phase 1b (Skim) funded the intermediate input ATA with `face` before pre-validation runs, the validation program cannot move those tokens — only the `ComposablePolicy` PDA (used with `invoke_signed` in forward/sweep phases) has authority over the intermediates. `build_validation_account_metas` hard-codes every forwarded account to `is_signer: false, is_writable: false`, so even if the caller re-passes `fee_payer` (a `Signer`) as a remaining account, Lighthouse cannot inherit that authority. ## Disabling validation Pass `{ disabled: {} }` as the `preValidation` / `postValidation` parameter at create time. The handler detects the `Disabled` variant, leaves the slot at its `Default`, and skips `ValidationPda` allocation entirely. ## SDK usage Use the `lighthouse` fluent facade from `@tributary-so/sdk` to build the `{ data, numAccounts, accounts }` triple — never hand-roll the serialization. ```typescript import { Tributary, 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 (≤ 512 bytes, stored in ValidationPda) // guard.numAccounts → 1 (numValidationAccounts / numPinnedAccounts) // guard.accounts → [hotWalletUsdcAta] (Lighthouse read-account slice) // Create composable policy with dual validation (pre only, post disabled): const ix = await sdk.getCreateComposablePolicyInstruction( tokenMint, recipient, gateway, policyType, memo, forwardConfig, { programCall: { programId: LIGHTHOUSE_PROGRAM_ID } }, // preValidation guard.accounts, // prePinnedAccounts — Lighthouse targets guard.data, // preValidationData — assertion bytes { disabled: {} }, // postValidation — disabled [], Buffer.alloc(0) ); // At execute time the caller assembles remaining_accounts as: // [prePinnedAccounts, ...forwardAccounts, postPinnedAccounts, schedulerAta?] // The facade owns ONLY the Lighthouse target_account(s); the SDK // provides the full list in the correct order at execute. ``` # ComposablePolicy vs. PaymentPolicy Tributary exposes two families of pull-payment policies that share the same `PolicyType` enum, `UserPayment` account, `PaymentGateway`, and fee-distribution logic — but differ sharply in their execution model. ## Decision matrix | Axis | PaymentPolicy | ComposablePolicy | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Custody model** | Non-custodial. User approves delegate; gateway pulls directly. | Non-custodial. Same delegate approval; pull routes through an intermediate ATA. | | **Execution** | Single CPI: `transfer` from user → recipient + fees. | 7-phase: `BYTE-RANGE CHECKS` → `PULL` (gross) → `SKIM` (input-side fees) → `PRE-VALIDATE` (opt) → `FORWARD` (opt) → `POST-VALIDATE` (opt) → `SETTLE`. | | **Supported hooks** | None. | Validation (Lighthouse) + Forward (Meteora DLMM, Raydium CPMM/CLMM, Orca Whirlpool), each independently disableable. | | **Intermediate accounts** | None. Funds move directly user_token → recipient_token. | Two ATAs owned by the ComposablePolicy PDA; created lazily, closed at end of every execute. | | **PDA seeds** | `["payment_policy", user_payment, policy_id]` | `["composable_policy", user_payment, policy_id]` | | **ID counter** | `user_payment.created_policies_count` | `user_payment.created_composable_count` | | **CU cost** | Lower — one Token CPI, no allocation. | Higher — ATA create + optional CPI(s) + fee math + two ATA closes. | | **Rent churn per execute** | None. | Yes — intermediates are created and closed each call; rent returns to fee_payer but costs CU. | | **Fee path** | Direct (single transfer splits fees off the payment amount; honors `FEATURE_NET_AMOUNT`). | Same `calculate_fees`, but **input-side** — fees are skimmed from the gross pull in Phase 1b before the forward runs (ADR-0026; NET-on-pull hardcoded, flag ignored). | | **Settlement shapes** | Single (direct `transfer` to recipient). | Three: deliver-no-transform (same-mint) / deliver-transform (swap) / act mode (`output_mint=Pubkey::default()`, ADR-0026). | | **Delegate model** | v0 legacy global `PaymentsDelegate` PDA (`["payments"]`) OR v1 per-user `UserPayment` PDA (`["user_payment", owner, mint]`). Both accepted. | Same dual-delegate acceptance; v1 UserPayment PDA is the SDK default. | | **Schedule math** | `shared::schedule::{validate_policy_execution, advance_policy}` | Same functions, same calendar-month arithmetic (M-04). | | **Permissionless execute** | Yes — any gateway signer, the user, or the recipient. | Yes — same `fee_payer ∈ {gateway.signer, user_payment.owner, recipient}` constraint; cold relayers additionally require post_validation OR a route pin (ADR-0016). | | **Use cases** | Simple recurring billing, milestone escrows, pay-as-you-go with no conditional logic. | Programmable conditional payments: "topup if balance low", "pull USDC deliver WSOL", native-SOL delivery, Velocity subaccount deposits (act mode). | | **Forward / swap** | Not supported. | Opt-in via `ForwardConfig.instruction_constraint.program_id` (Meteora DLMM, Raydium CPMM/CLMM, Orca Whirlpool). | | **Native SOL delivery** | Not supported. | Opt-in via `FORWARD_FLAG_NATIVE_OUTPUT` (WSOL → SOL unwrap). | | **External CPI** | None. | Lighthouse (read-only assertion) + 4 DEX programs (swap). Both hard-allowlisted. | ## When to use PaymentPolicy Pick `PaymentPolicy` when: - You want the **simplest** pull payment — fixed schedule, fixed amount, fixed recipient, same-mint delivery. - Recurring billing, milestone escrow, or pay-as-you-go with **no conditional logic** and **no token conversion**. - CU budget is tight (one Token CPI vs. an ATA create/close cycle). - You do not need to assert on-chain state before paying. ## When to use ComposablePolicy Pick `ComposablePolicy` when at least one of these is true: - **Conditional execution** — the payment should only fire if some on-chain predicate holds (e.g. "recipient's hot wallet USDC balance is below threshold"). Use the validation hook + Lighthouse. - **Cross-token delivery** — the user pays in token A but the recipient wants token B (e.g. pull USDC, deliver WSOL). Use the forward hook + Meteora DLMM. - **Native SOL delivery** — recipient wants SOL, not WSOL. Use the forward hook + `FORWARD_FLAG_NATIVE_OUTPUT`. - **Programmable auto-topup** — pull and deliver the same token, but through a PDA-owned intermediate so future upgrades can insert a swap or assertion without changing the user-facing flow. Use the disabled-forward (same-mint topup) path. ## Behavior with both hooks disabled A `ComposablePolicy` with `instruction_constraint.program_id == Pubkey::default()` and `pre/post_validation == ValidationSpec::Disabled` is functionally close to a `PaymentPolicy` — same pull, same fee split, same schedule math — but: - It still pays the **intermediate-ATA hop** cost (create + close per execute). - It lives in the `["composable_policy", …]` PDA namespace. - Its ID comes from `created_composable_count`, not `created_policies_count`. If you never need hooks, prefer `PaymentPolicy` — it is strictly cheaper and simpler. Reserve `ComposablePolicy` for policies that benefit from at least one hook, or for which you anticipate adding one later. ## Counter independence Because the two policy families draw IDs from independent counters on `UserPayment`, a single user can hold both a `PaymentPolicy` and a `ComposablePolicy` with overlapping numeric IDs without collision: ```text UserPayment { created_policies_count: 1 → PaymentPolicy #1 at ["payment_policy", …, 1] created_composable_count: 1 → ComposablePolicy #1 at ["composable_policy", …, 1] } ``` The seeds themselves are distinct, so even the address spaces do not overlap. ## Migration path There is **no on-chain migration** between `PaymentPolicy` and `ComposablePolicy` — they are separate account types with separate PDAs. To "convert", delete one and create the other. The user's `UserPayment` account and delegate approval carry over unchanged, because both policy families use the same pull-delegate (UserPayment PDA). ## Related pages - [ComposablePolicy overview](https://docs.tributary.so/protocol-reference/composable-policy/overview/index.md) - [Validation hook](https://docs.tributary.so/protocol-reference/composable-policy/validation-hook/index.md) - [Forward hook](https://docs.tributary.so/protocol-reference/composable-policy/forward-hook/index.md) - [Native output](https://docs.tributary.so/protocol-reference/composable-policy/native-output/index.md) - [Allowlists & sentinels](https://docs.tributary.so/protocol-reference/composable-policy/allowlists-and-sentinels/index.md) - [Security model](https://docs.tributary.so/protocol-reference/composable-policy/security-model/index.md) # Milestone Payments Project-based payments released as deliverables are completed — for freelancers, dev shops, consultants, and any work that should be paid on completion, not on a clock. ## Overview Milestone policies define **up to 4 payment stages**, each with its own amount and due timestamp. Payments release when conditions are met (time-based, manual approval, or automatic). The total escrow amount is delegated upfront, but funds stay in the user's wallet until each milestone executes. ```text User creates policy --> Delegates total escrow amount | +----------+-----------+-----------+ | | | | Milestone 1 Milestone 2 Milestone 3 Milestone 4 $50 (Week 1) $100 (Week 2) $100 (Week 3) $50 (Week 4) Design Frontend Backend Deploy ``` ## When to Use | Good For | Not Ideal For | | -------------------------------------------- | -------------------------------- | | Freelance projects with deliverables | Predictable recurring billing | | Software development (phased delivery) | High-frequency micro-payments | | Consulting engagements (deliverable-based) | Ongoing services with fixed cost | | Content creation (episode/release-based) | Variable usage patterns | | Any project where payment follows completion | Simple monthly subscriptions | | Escrow-style agreements | | ## On-Chain Specification ```rust PolicyType::Milestone { milestone_amounts: [u64; 4], // Amount for each milestone (lamports) milestone_timestamps: [i64; 4], // Absolute timestamps for each milestone current_milestone: u8, // Next milestone to process (0-3) release_condition: u8, // Bitmap: release requirements total_milestones: u8, // How many milestones configured (1-4) escrow_amount: u64, // Total amount escrowed (sum of all milestones) padding: [u8; 53], // 128-byte alignment } ``` ### Release Condition Bitmap The `release_condition` field is an 8-bit bitmap where each bit controls a requirement: | Bit | Value | Constant | Description | | --- | ------------ | ------------------- | ------------------------------ | | 0 | `0b0001` (1) | `RELEASE_DUE_DATE` | Timestamp must be reached | | 1 | `0b0010` (2) | `RELEASE_GATEWAY` | Gateway authority must sign | | 2 | `0b0100` (4) | `RELEASE_OWNER` | Policy owner (payer) must sign | | 3 | `0b1000` (8) | `RELEASE_RECIPIENT` | Recipient must sign | **Important**: Bits 1-3 are **mutually exclusive** — at most one signer requirement can be set. A value of `0` means no restrictions (anyone can trigger anytime). ### Common Release Conditions | Value | Binary | Due Date? | Signer Required | Behavior | | ----- | -------- | --------- | --------------- | --------------------------------- | | 0 | `0b0000` | No | None | Anyone can trigger anytime | | 1 | `0b0001` | Yes | None | Anyone can trigger after due date | | 2 | `0b0010` | No | Gateway | Gateway must sign | | 3 | `0b0011` | Yes | Gateway | Gateway signs + due date passed | | 4 | `0b0100` | No | Owner | Payer must approve | | 5 | `0b0101` | Yes | Owner | Payer approves + due date passed | | 8 | `0b1000` | No | Recipient | Recipient must claim | | 9 | `0b1001` | Yes | Recipient | Recipient claims after due date | ### Account Size Each `Milestone` variant is exactly **128 bytes**, consistent with all other policy types. ## Creating a Milestone Payment ### Basic Example — Freelance Project ```typescript import { Tributary } from "@tributary-so/sdk"; import { BN } from "@coral-xyz/anchor"; import { createMemoBuffer } from "@tributary-so/sdk"; import { PublicKey, Transaction } from "@solana/web3.js"; const sdk = new Tributary(connection, wallet); const USDC_MINT = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); const recipient = new PublicKey("BxKpT3mZQ5HgeRZFMfWVBpDCmCN8eYwGmCjL7m9mVq"); const gateway = new PublicKey("6ntm5rWqDFefET8RFyZV73FcdqxPMbc7Tso3pCMWk4w4"); const now = Math.floor(Date.now() / 1000); const milestoneAmounts = [ new BN(50_000_000), // $50 - Design & planning new BN(100_000_000), // $100 - Frontend development new BN(100_000_000), // $100 - Backend integration new BN(50_000_000), // $50 - Testing & deployment ]; const milestoneTimestamps = [ new BN(now + 86400 * 7), // Week 1: Design done new BN(now + 86400 * 14), // Week 2: Frontend done new BN(now + 86400 * 21), // Week 3: Backend done new BN(now + 86400 * 28), // Week 4: Deploy done ]; const instructions = await sdk.createMilestone( USDC_MINT, recipient, gateway, milestoneAmounts, milestoneTimestamps, 1, // releaseCondition: time-based only (0b0001) createMemoBuffer("project_abc", 64) ); const tx = new Transaction().add(...instructions); const signature = await sendAndConfirm(connection, tx, [wallet.payer]); ``` ### Manual Approval Required ```typescript // Gateway must sign off on each milestone (quality gate) const instructions = await sdk.createMilestone( USDC_MINT, recipient, gateway, milestoneAmounts, milestoneTimestamps, 3, // 0b0011: due date + gateway signer required createMemoBuffer("consulting_q1", 64) ); ``` ### Fewer than 4 Milestones ```typescript // Only 2 milestones — SDK pads the rest with zeros const instructions = await sdk.createMilestone( USDC_MINT, recipient, gateway, [new BN(200_000_000), new BN(300_000_000)], // $200 + $300 [new BN(now + 86400 * 14), new BN(now + 86400 * 30)], 1, createMemoBuffer("simple_project", 64) ); ``` ## How It Works ### Payment Execution Flow 1. **Policy creation** — user creates policy, delegates the total escrow amount (sum of all milestones) 1. **Waiting** — `current_milestone` timestamp and/or signer condition not yet met 1. **Execution** — when conditions are met, anyone calls `execute_payment` 1. **Transfer** — the current milestone amount moves to recipient, minus fees 1. **Advance** — `current_milestone` increments 1. **Completion** — after the last milestone, the policy auto-pauses ### Release Condition Validation The protocol checks the bitmap before executing: 1. If bit 0 is set (`RELEASE_DUE_DATE`): verify `now >= milestone_timestamps[current_milestone]` 1. If bit 1 is set (`RELEASE_GATEWAY`): verify gateway authority signed the transaction 1. If bit 2 is set (`RELEASE_OWNER`): verify the policy owner (payer) signed 1. If bit 3 is set (`RELEASE_RECIPIENT`): verify the recipient signed If any required condition fails, the transaction reverts. ### Escrow Calculation ```typescript const escrowAmount = milestoneAmounts.reduce( (sum, amount) => sum.add(amount), new BN(0) ); // Total delegated = sum of all milestones ``` ## Managing Milestone Payments ### Query Status ```typescript const policy = await sdk.getPaymentPolicy(policyPda); const ms = policy.policyType.milestone; console.log("Current milestone:", ms.currentMilestone); console.log("Total milestones:", ms.totalMilestones); console.log("Escrow amount:", ms.escrowAmount.toString()); console.log("Release condition:", ms.releaseCondition); // Check if next milestone is due const nextAmount = ms.milestoneAmounts[ms.currentMilestone]; const nextTime = ms.milestoneTimestamps[ms.currentMilestone]; const isDue = Date.now() / 1000 >= nextTime.toNumber(); console.log( `Next: $${nextAmount.toString()} due ${new Date(nextTime.toNumber() * 1000)}` ); ``` ### Execute a Milestone ```typescript // For manual-approval milestones, the authorized signer submits: const instructions = await sdk.executePayment( policyPda, recipient, tokenMint, gateway ); const tx = new Transaction().add(...instructions); await sendAndConfirm(connection, tx, [signer]); ``` ### Pause / Cancel ```typescript // Pause -- stops milestone execution await sdk.changePaymentPolicyStatus(tokenMint, policyId, { paused: {} }); // Resume await sdk.changePaymentPolicyStatus(tokenMint, policyId, { active: {} }); // Cancel -- deletes the policy, revokes delegation await sdk.deletePaymentPolicy(tokenMint, policyId); ``` ## Use Case Examples ### Consulting Engagement ```typescript const monthlyMilestones = [ new BN(500_000_000), // $500 - Month 1: Strategy new BN(400_000_000), // $400 - Month 2: Implementation new BN(300_000_000), // $300 - Month 3: Optimization ]; const monthlyTimestamps = [ new BN(now + 86400 * 30), new BN(now + 86400 * 60), new BN(now + 86400 * 90), ]; // Owner must approve each release (quality gate) await sdk.createMilestone( USDC_MINT, recipient, gateway, monthlyMilestones, monthlyTimestamps, 5, // 0b0101: due date + owner approval createMemoBuffer("consulting_retainer", 64) ); ``` ### Content Series with Auto-Release ```typescript const episodePayments = [ new BN(10_000_000), // $10 per episode new BN(10_000_000), new BN(10_000_000), new BN(10_000_000), ]; const episodeTimestamps = [ new BN(now + 86400 * 7), new BN(now + 86400 * 14), new BN(now + 86400 * 21), new BN(now + 86400 * 28), ]; // Auto-release when due date passes await sdk.createMilestone( USDC_MINT, recipient, gateway, episodePayments, episodeTimestamps, 1, // 0b0001: time-based only createMemoBuffer("podcast_series", 64) ); ``` ## Troubleshooting | Error | Cause | Fix | | ----------------------- | ---------------------------------- | --------------------------------------------- | | `PaymentNotDue` | Milestone timestamp hasn't passed | Wait for the scheduled time | | `InvalidSigner` | Required signer didn't sign the tx | Include the correct signer in the transaction | | `InvalidAmount` | Not applicable for milestones | Milestone amounts are fixed at creation | | Milestone not advancing | Previous milestone not yet paid | Execute current milestone first | ## Comparison with Other Policy Types | | Subscription | Milestone | Pay-as-you-go | OneTime | UpTo | | --------------------- | ------------------ | ---------------------- | ------------------ | --------------------- | ------------------------ | | **Amount** | Fixed per period | Variable per milestone | Variable per claim | Fixed, single fire | Caller-supplied, ≤ max | | **Timing** | Fixed schedule | Event/timestamp based | On-demand | Scheduled / immediate | `[validAfter, deadline)` | | **Fires** | Recurring | Up to 4 phases | Many (within caps) | Exactly once | Exactly once | | **Recipient trigger** | No | Per release_condition | Yes | No | Yes | | **Best For** | Recurring services | Project deliverables | Variable usage | Invoices, one-shots | Usage-based one-shot | | **User Control** | Set up once | Approve per milestone | Period limits | Schedule + delete | Authorize + window | # One-Time Payments Fixed-amount pull payments that fire exactly once then complete — for invoices, one-shot purchases, conditional payouts, and any payment that needs a full gateway lifecycle without recurrence. ## Overview A OneTime policy is a **single-shot, fixed-amount pull payment** with the same gateway machinery as a subscription — PDA, pausability, deletability, schedulability, fee split, referral rewards, and composable hooks. It is the right tool when you want "fire this payment once, on schedule or on demand, through Tributary's fee + referral + composable plumbing" — instead of the stateless [`transfer`](https://docs.tributary.so/protocol-reference/accounts-and-pdas/index.md) instruction which has no policy account and no lifecycle. ```text User creates policy --> amount + due_date + optional expiry_date | (waits until due_date, if set) | execute_payment (once) | status: Active → Completed (re-execution blocked) ``` ## When to Use | Good For | Not Ideal For | | ----------------------------------------------- | ------------------------------------- | | Invoices payable on a due date | Recurring services (use Subscription) | | One-shot purchases with full fee plumbing | Multi-claim usage (use Pay-as-you-go) | | Conditional payouts (e.g. escrow release) | Multi-phase projects (use Milestone) | | Gateway-routed one-time tips / bonuses | Streaming / per-call metered billing | | Composable one-shot payments (Lighthouse-gated) | | ## On-Chain Specification ```rust PolicyType::OneTime { amount: u64, // fixed payment amount, must be > 0 (lamports) due_date: i64, // earliest execution; <= 0 means immediate expiry_date: Option, // None = never expires; Some(ts) = hard deadline padding: [u8; 103], // 128-byte alignment } ``` ### Key Fields | Field | Description | | ------------- | ------------------------------------------------------------------------------------------------------- | | `amount` | Fixed amount pulled on execution. Must be `> 0`. The caller cannot override it at run time. | | `due_date` | Earliest execution timestamp. `<= 0` means immediately executable. Lets you schedule a future one-shot. | | `expiry_date` | Optional hard deadline. `None` means the policy never expires; `Some(ts)` rejects execution after `ts`. | ### Account Size Each `OneTime` variant is exactly **128 bytes**, consistent with all other policy types (ADR-0002). The 1-byte enum discriminator is `3`. ## Creating a One-Time Payment ### Basic Example — Invoice Due in 7 Days ```typescript import { Tributary } from "@tributary-so/sdk"; import { BN } from "@coral-xyz/anchor"; import { createMemoBuffer } from "@tributary-so/sdk"; import { PublicKey, Transaction } from "@solana/web3.js"; const sdk = new Tributary(connection, wallet); const USDC_MINT = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); const merchant = new PublicKey("BxKpT3mZQ5HgeRZFMfWVBpDCmCN8eYwGmCjL7m9mVq"); const gateway = new PublicKey("6ntm5rWqDFefET8RFyZV73FcdqxPMbc7Tso3pCMWk4w4"); const now = Math.floor(Date.now() / 1000); const dueIn7Days = new BN(now + 7 * 86400); const instructions = await sdk.createOneTimePayment( USDC_MINT, merchant, gateway, new BN(50_000_000), // $50 invoice createMemoBuffer("invoice_2024_001", 64), dueIn7Days, // executable starting in 7 days null // never expires ); const tx = new Transaction().add(...instructions); const signature = await sendAndConfirm(connection, tx, [wallet.payer]); ``` ### Immediate vs Scheduled vs Expiring ```typescript // Immediate — fires the moment the gateway executes it. await sdk.createOneTimePayment( USDC_MINT, recipient, gateway, new BN(10_000_000), createMemoBuffer("tip", 64), null // dueDate omitted → immediate ); // Scheduled — waits until dueDate before it can execute. await sdk.createOneTimePayment( USDC_MINT, recipient, gateway, new BN(10_000_000), createMemoBuffer("deferred payout", 64), new BN(futureTimestamp) ); // Time-bound — must execute within [dueDate, expiryDate] or it's dead. await sdk.createOneTimePayment( USDC_MINT, recipient, gateway, new BN(10_000_000), createMemoBuffer("limited-time offer", 64), new BN(now), // due immediately new BN(now + 3600) // expires in 1h ); ``` ## How It Works ### Execution A gateway signer (or the user) calls `execute_payment`. The protocol checks: 1. **Status** — policy is `Active` (not `Paused` or `Completed`). 1. **Due date** — if `due_date > 0`, `current_time >= due_date` must hold. 1. **Expiry** — if `expiry_date = Some(ts)`, `current_time <= ts` must hold. On success, the fixed `amount` is transferred (plus fee split), `payment_count` increments to `1`, and `status` transitions to `Completed`. **Re-execution is blocked by the `Active`-only constraint** — single-fire is airtight. ### Authorization Same as Subscription: the caller must be the `gateway.signer` or the `user_payment.owner`. The recipient cannot trigger a OneTime payment directly (only Pay-as-you-go and UpTo allow recipient triggering). ## Managing One-Time Payments ### Query Status ```typescript const policy = await sdk.getPaymentPolicy(policyPda); const oneTime = policy.policyType.oneTime; console.log("Amount:", oneTime.amount.toNumber()); console.log("Due:", new Date(oneTime.dueDate.toNumber() * 1000)); console.log( "Expires:", oneTime.expiryDate ? new Date(oneTime.expiryDate.toNumber() * 1000) : "never" ); console.log("Status:", Object.keys(policy.status)[0]); // "active" → waiting to fire (or due now) // "paused" → owner paused it // "completed" → fired exactly once, terminal ``` ### Lifecycle ```typescript // Pause while waiting (e.g. dispute the invoice). await sdk.changePaymentPolicyStatus(tokenMint, policyId, { paused: {} }); // Resume. await sdk.changePaymentPolicyStatus(tokenMint, policyId, { active: {} }); // Cancel before it fires — revokes delegation and reclaims rent. await sdk.deletePaymentPolicy(tokenMint, policyId); ``` After completion, the policy remains on-chain in `Completed` state. The owner can `delete_payment_policy` to reclaim rent; the delegate approval should be revoked separately if no longer needed. ## Composable Interplay Because `PolicyType` is shared between `PaymentPolicy` and `ComposablePolicy` (ADR-0007), OneTime lands in the **composable** family for free: - **Validation hook (Lighthouse)** — pay once only if an on-chain assertion holds (e.g. recipient hot-wallet balance below threshold → top-up). - **Forward hook (Meteora DLMM)** — pull input token, swap, deliver output token — one time. See the [Composable Policy overview](https://docs.tributary.so/protocol-reference/composable-policy/overview/index.md) for the validation + forward semantics. All composable invariants (ADR-0008 through ADR-0010) apply unchanged. ## Use Case Examples ### Invoice with Net-30 Terms ```typescript await sdk.createOneTimePayment( USDC_MINT, vendor, gateway, new BN(12_500_000), // $12.5K invoice createMemoBuffer("invoice_NET30_001", 64), new BN(now + 30 * 86400) // due in 30 days ); ``` ### Conditional Payout (Composable + Lighthouse) ```typescript // Pay a $100 bonus to an agent — but only if their hot-wallet USDC balance // drops below $50. One-shot, condition-gated, delivered as USDC. const guard = lighthouse .tokenAccount(agentHotWalletAta) .amount(50_000_000, "<") .build(); // Build a composable OneTime policy with the guard + recipient binding. // (See the Lighthouse Facade doc for the full flow.) ``` ## Best Practices - **Set `expiry_date` when the offer is time-bound** — an open-ended OneTime is fine for invoices, but a stale policy on a deleted gateway can sit forever consuming rent. - **Use OneTime over `transfer` when** you need any of: PDA lifecycle, pausability, gateway fee plumbing, referrals, schedulability, or composable hooks. Use `transfer` for a one-shot payment with none of those. - **Delete after completion** to reclaim rent. ## Comparison with Other Policy Types | | Subscription | Milestone | Pay-as-you-go | OneTime | | --------------------- | ------------------ | ---------------------- | ------------------ | ---------------------- | | **Amount** | Fixed per period | Variable per milestone | Variable per claim | Fixed, single fire | | **Timing** | Fixed schedule | Event/timestamp based | On-demand | Scheduled or immediate | | **Fires** | Recurring | Up to 4 phases | Many (within caps) | Exactly once | | **Recipient trigger** | No | Per release_condition | Yes | No | | **Best For** | Recurring services | Project deliverables | Variable usage | Invoices, one-shots | ## Further Reading - [ADR-0019 — OneTime policy variant](https://github.com/tributary-so/tributary/blob/develop/apps/docs/adr/0019-onetime-policy-variant.md) - [Pay-as-you-go](https://docs.tributary.so/protocol-reference/payment-policy/payasyougo/index.md) — multi-claim usage-based billing - [UpTo](https://docs.tributary.so/protocol-reference/payment-policy/upto/index.md) — single-use variable-amount authorization - [Composable Policy Overview](https://docs.tributary.so/protocol-reference/composable-policy/overview/index.md) # Payment Policy Direct pull payments — the protocol's original, non-programmable policy family. ## Overview A PaymentPolicy is a non-custodial recurring payment where the gateway pulls tokens **directly** from the user's token account to the recipient. No intermediate accounts, no swaps, no validation hooks — a single `transfer` plus fee split. This page orients the reader to the five PaymentPolicy variants (Subscription, Milestone, Pay-as-you-go, OneTime, UpTo), the shared `PolicyType` envelope, and the referral program, before drilling into each variant's dedicated page. ## Variants at a Glance | Variant | Amount | Fires | Recipient trigger | Best For | | ------------------------------------------------------------------------------------------------- | ---------------- | ------------- | ----------------- | ---------------------------- | | [Subscription](https://docs.tributary.so/protocol-reference/payment-policy/subscription/index.md) | Fixed per period | Recurring | No | Recurring services, SaaS | | [Milestone](https://docs.tributary.so/protocol-reference/payment-policy/milestone/index.md) | Per-phase | Up to 4× | Per release_cond. | Project deliverables, escrow | | [Pay-as-you-go](https://docs.tributary.so/protocol-reference/payment-policy/payasyougo/index.md) | Variable/claim | Many (capped) | Yes | Usage billing (AI/LLM, API) | | [OneTime](https://docs.tributary.so/protocol-reference/payment-policy/onetime/index.md) | Fixed | Exactly once | No | Invoices, one-shot payouts | | [UpTo](https://docs.tributary.so/protocol-reference/payment-policy/upto/index.md) | ≤ max (caller) | Exactly once | Yes | Usage-based one-shot (x402) | All five variants share the same 128-byte fixed layout (ADR-0002), the same `PaymentPolicy` envelope (PDA, lifecycle, fees, referrals, composable hooks), and the same `UserPayment`-as-delegate model. They differ only in scheduling semantics and amount resolution. # Pay-as-you-go Payments Usage-based billing where providers claim funds incrementally within predefined limits — for AI agents, APIs, cloud services, and any consumption-based billing. ## Overview Pay-as-you-go policies let service providers **pull payments on-demand**, up to a maximum chunk amount per claim, within a capped total per billing period. When the period ends, counters reset automatically and a fresh cycle begins. ```text User creates policy --> Sets period limit + chunk limit | +------- Period 1 -------+ +------- Period 2 -------+ | | | | | | | Claim 1 Claim 2 Claim 3 Claim 4 Claim 5 ... $3.50 $7.20 $1.80 $5.00 $2.30 (chunk: max $10) (chunk: max $10) (period: max $100/mo) (period resets: fresh $100) ``` ## When to Use | Good For | Not Ideal For | | -------------------------------------- | ------------------------------- | | AI/LLM providers (token-based billing) | Predictable fixed-cost services | | API services (pay-per-call) | One-time purchases | | Cloud resources (compute, storage) | Simple monthly subscriptions | | SaaS with variable consumption | Project-based deliverables | | Any service with unpredictable usage | Fixed-price contracts | | Micro-payments with rate limits | | ## On-Chain Specification ```rust PolicyType::PayAsYouGo { max_amount_per_period: u64, // Total budget per billing period (lamports) max_chunk_amount: u64, // Max per individual claim (lamports) period_length_seconds: u64, // Duration of each period (seconds) current_period_start: i64, // When current period started (unix timestamp) current_period_total: u64, // Amount claimed so far in current period expiry_date: Option, // Optional overall expiry; None = never (ADR-0024) padding: [u8; 79], // 128-byte alignment } ``` ### Key Fields | Field | Description | | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `max_amount_per_period` | Ceiling for total claims within one period. Resets when period rolls over. | | `max_chunk_amount` | Maximum the provider can claim in a single `execute_payment` call. Prevents large unexpected pulls. | | `period_length_seconds` | Billing cycle duration. Any value in seconds — hourly, daily, weekly, monthly, etc. | | `current_period_start` | Timestamp when the current period began. Used to calculate period expiry. | | `current_period_total` | Running total of claims in the current period. Checked against `max_amount_per_period` on each claim. | | `expiry_date` | Optional overall expiration (ADR-0024). `None` = never expires; `Some(ts)` rejects execution once `current_time > ts` (boundary `<=` permitted). Orthogonal to the period cap — whichever trips first wins. | ### Account Size Each `PayAsYouGo` variant is exactly **128 bytes**, consistent with all other policy types. ## Creating a Pay-as-you-go Policy ### Basic Example — AI API Billing ```typescript import { Tributary } from "@tributary-so/sdk"; import { BN } from "@coral-xyz/anchor"; import { createMemoBuffer } from "@tributary-so/sdk"; import { PublicKey, Transaction } from "@solana/web3.js"; const sdk = new Tributary(connection, wallet); const USDC_MINT = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); const provider = new PublicKey("BxKpT3mZQ5HgeRZFMfWVBpDCmCN8eYwGmCjL7m9mVq"); const gateway = new PublicKey("6ntm5rWqDFefET8RFyZV73FcdqxPMbc7Tso3pCMWk4w4"); const instructions = await sdk.createPayAsYouGo( USDC_MINT, provider, // recipient (the service provider) gateway, new BN(100_000_000), // max $100 per month new BN(10_000_000), // max $10 per claim new BN(86400 * 30), // 30-day period createMemoBuffer("openai_api_user123", 64) ); const tx = new Transaction().add(...instructions); const signature = await sendAndConfirm(connection, tx, [wallet.payer]); ``` ### Conservative vs Aggressive Limits ```typescript // Conservative -- low risk, small frequent payments const conservative = { maxAmountPerPeriod: new BN(10_000_000), // $10/month cap maxChunkAmount: new BN(1_000_000), // $1 max per claim periodLength: new BN(86400 * 30), // monthly }; // Aggressive -- higher limits, larger claims const aggressive = { maxAmountPerPeriod: new BN(100_000_000), // $100/month cap maxChunkAmount: new BN(25_000_000), // $25 max per claim periodLength: new BN(86400 * 7), // weekly }; ``` ### Period Length Options ```typescript const periods = { hourly: new BN(3600), // 1 hour daily: new BN(86400), // 1 day weekly: new BN(86400 * 7), // 7 days monthly: new BN(86400 * 30), // 30 days quarterly: new BN(86400 * 90), // 90 days custom: new BN(whatever_you_want), }; ``` ### Optional Overall Expiry (ADR-0024) Pass an `expiryDate` to cap the authorization with a hard deadline, independent of the rolling period cap. `null`/omitted = never expires (the default). ```typescript // $100/month, $10/chunk, but the whole authorization stops after 90 days. const ninetyDays = Math.floor(Date.now() / 1000) + 86400 * 90; const instructions = await sdk.createPayAsYouGo( USDC_MINT, provider, gateway, new BN(100_000_000), // max/period new BN(10_000_000), // max/chunk new BN(86400 * 30), // 30-day period memo, undefined, // approvalAmount (auto) undefined, // referralCode new BN(ninetyDays) // expiryDate — rejects execution once current_time > expiry ); ``` Once `current_time > expiry_date`, both `execute_payment` and `execute_composable` fail with `TributaryError::PolicyExpired`. The boundary `current_time == expiry` is permitted. To reclaim the delegate approval after expiry, call `delete_payment_policy`. ## How It Works ### Provider Claims The service provider (or their automated system) calls `execute_payment` when usage thresholds are met: ```typescript const instructions = await sdk.executePayment( policyPda, provider, // recipient USDC_MINT, gateway, new BN(7_500_000) // claiming $7.50 for recent usage ); const tx = new Transaction().add(...instructions); await sendAndConfirm(connection, tx, [gatewaySigner]); ``` ### Validation & Execution The protocol validates **before** transferring: 1. **Chunk limit**: `payment_amount <= max_chunk_amount` 1. **Period limit**: `current_period_total + payment_amount <= max_amount_per_period` 1. **Period expiry**: if `now >= current_period_start + period_length_seconds`, reset counters first ### Automatic Period Reset When the current time exceeds `current_period_start + period_length_seconds`: - `current_period_total` resets to `0` - `current_period_start` updates to the current timestamp - A fresh billing cycle begins with full limits ```text Period 1: claimed $45 of $100 |--- period_length expires ---| Period 2: fresh $100 limit, claimed $0 ``` ## Token Delegation The approval amount is calculated to cover a reasonable number of periods: ```typescript // SDK calculates: maxAmountPerPeriod * periods_to_cover // Default: covers enough for the initial period + buffer // Or provide explicitly: const instructions = await sdk.createPayAsYouGo( USDC_MINT, provider, gateway, new BN(100_000_000), // $100/period new BN(10_000_000), // $10/chunk new BN(86400 * 30), // 30 days createMemoBuffer("api_billing", 64), new BN(300_000_000) // approvalAmount: $300 = 3 months buffer ); ``` ## Managing Pay-as-you-go Policies ### Query Status ```typescript const policy = await sdk.getPaymentPolicy(policyPda); const payg = policy.policyType.payAsYouGo; console.log("Max per period:", payg.maxAmountPerPeriod.toString()); console.log("Max per chunk:", payg.maxChunkAmount.toString()); console.log("Period length:", payg.periodLengthSeconds.toString(), "seconds"); console.log( "Period started:", new Date(payg.currentPeriodStart.toNumber() * 1000) ); console.log("Period used:", payg.currentPeriodTotal.toString()); const remaining = payg.maxAmountPerPeriod.toNumber() - payg.currentPeriodTotal.toNumber(); const periodEnd = new Date( (payg.currentPeriodStart.toNumber() + payg.periodLengthSeconds.toNumber()) * 1000 ); console.log(`Remaining this period: $${remaining / 1e6}`); console.log(`Period ends: ${periodEnd.toLocaleString()}`); ``` ### Provider-side Claim Check ```typescript async function canClaim( sdk: Tributary, policyPda: PublicKey, amount: number ): Promise { const policy = await sdk.getPaymentPolicy(policyPda); const payg = policy.policyType.payAsYouGo; if (amount > payg.maxChunkAmount.toNumber()) return false; const remaining = payg.maxAmountPerPeriod.toNumber() - payg.currentPeriodTotal.toNumber(); return amount <= remaining; } // Before claiming: if (await canClaim(sdk, policyPda, 7_500_000)) { const instructions = await sdk.executePayment( policyPda, provider, USDC_MINT, gateway, new BN(7_500_000) ); // ... submit transaction } ``` ### Pause / Resume / Cancel ```typescript // Pause -- stops all claims await sdk.changePaymentPolicyStatus(tokenMint, policyId, { paused: {} }); // Resume -- reactivates await sdk.changePaymentPolicyStatus(tokenMint, policyId, { active: {} }); // Cancel -- deletes policy, revokes delegation await sdk.deletePaymentPolicy(tokenMint, policyId); ``` ## Use Case Examples ### AI Agent Token Billing ```typescript // LLM provider charges per token batch await sdk.createPayAsYouGo( USDC_MINT, llmProvider, gateway, new BN(50_000_000), // $50/month max new BN(5_000_000), // $5 max per batch new BN(86400 * 30), // monthly createMemoBuffer("llm_api_user_42", 64) ); // Provider claims after each batch: await sdk.executePayment( policyPda, llmProvider, USDC_MINT, gateway, new BN(2_340_000) // $2.34 for 234K tokens ); ``` ### REST API Pay-per-call ```typescript // API gateway charges per 1000 requests await sdk.createPayAsYouGo( USDC_MINT, apiProvider, gateway, new BN(25_000_000), // $25/month max new BN(500_000), // $0.50 max per claim new BN(86400 * 30), // monthly createMemoBuffer("weather_api_pro", 64) ); ``` ### Cloud Compute ```typescript // Compute provider bills hourly usage await sdk.createPayAsYouGo( USDC_MINT, cloudProvider, gateway, new BN(200_000_000), // $200/month max new BN(20_000_000), // $20 max per claim new BN(86400 * 30), // monthly createMemoBuffer("gpu_cluster_proj_x", 64) ); ``` ## Best Practices ### For Users (Payment Creators) - **Start conservative** — set low limits first, increase as trust builds - **Monitor claims** — track provider behavior and adjust limits accordingly - **Match period to budget** — align billing periods with your financial cycles - **Use pause liberally** — if something looks off, pause first, investigate later ### For Providers (Service Providers) - **Claim reasonably** — don't max out chunks unnecessarily; build user trust - **Transparent conversion** — clearly show how usage maps to payment amounts - **Document pricing** — publish your rate card so users can estimate costs - **Notify on large claims** — give users a heads-up before pulling significant amounts ### Security - **Rate limiting** — implement reasonable delays between claims - **Usage proofs** — consider attaching usage receipts to claim memos - **Emergency pause** — users can pause instantly if they detect abuse - **Audit trail** — all claims are on-chain with timestamps and amounts ## Troubleshooting | Error | Cause | Fix | | --------------------- | ------------------------------------------------- | ----------------------------------------------- | | `InvalidAmount` | Claim exceeds `max_chunk_amount` | Reduce claim amount, or increase chunk limit | | `PeriodLimitExceeded` | Period total would exceed `max_amount_per_period` | Wait for period reset, or increase period limit | | `InvalidDelegation` | Delegated amount insufficient | Re-approve with higher amount | | `PaymentNotDue` | Likely wrong policy type being called | Pay-as-you-go doesn't use due dates | ## Comparison with Other Policy Types | | Subscription | Milestone | Pay-as-you-go | OneTime | UpTo | | --------------------- | ------------------ | ---------------------- | ------------------- | --------------------- | ------------------------ | | **Amount** | Fixed per period | Variable per milestone | Variable per claim | Fixed, single fire | Caller-supplied, ≤ max | | **Timing** | Fixed schedule | Event/timestamp based | On-demand | Scheduled / immediate | `[validAfter, deadline)` | | **Fires** | Recurring | Up to 4 phases | Many (within caps) | Exactly once | Exactly once | | **Recipient trigger** | No | Per release_condition | Yes | No | Yes | | **Zero settle** | n/a | n/a | Rejected (L-01) | n/a | Allowed | | **Best For** | Recurring services | Project deliverables | Variable usage | Invoices, one-shots | Usage-based one-shot | | **Provider Control** | None (automatic) | Claim after approval | Claim within limits | | | # Referral Program Enable viral user acquisition through a simplified referral system where rewards are funded entirely from gateway fees with fixed percentage splits across referral tiers. Quick Start The referral program is **automatically enabled** for all gateways. Users earn rewards for inviting others, and gateways benefit from viral growth. ## Overview Tributary's referral system creates a **gateway-specific referral ecosystem** where: - **Users** earn rewards for inviting others to Tributary-enabled businesses - **Gateways** control their referral budget (0-100% of gateway fees) - **Rewards** are distributed automatically on each payment - **The chain** is validated on-chain to prevent fraud ### Key Features | Feature | Description | | ----------------------- | --------------------------------------------------------- | | **Gateway-Scoped** | Each gateway operates as an isolated referral ecosystem | | **Perfect Accounting** | Every dollar is accounted for - no money creation or loss | | **Configurable Budget** | Gateway operators control referral program size | | **Fixed Split** | Simple 60/30/10% distribution across referral tiers | | **On-Chain Validation** | Chain integrity is verified during payment execution | ## How It Works ### Referral Chain Structure The referral system uses a **linked list** structure where each referral account points to its referrer: ```text Payer (User who makes payment) │ └── refers to → L1 (Immediate Referrer) │ └── refers to → L2 (Who referred L1) │ └── refers to → L3 (Original Referrer) │ └── refers to → null (Origin) ``` ### Account Ordering in Transactions When a payment is executed, referral accounts are passed via `remaining_accounts` in a specific order: ```typescript // SDK's getReferralChain() returns accounts in this order: const chain = await sdk.getReferralChain(payer, gateway); // Returns: [L1, L2, L3] where: // - index 0 = L1 (immediate referrer who referred the payer) // - index 1 = L2 (who referred L1) // - index 2 = L3 (original referrer who started the chain) ``` Critical: Account Order Matters The SDK returns `[L1, L2, L3]` but the Rust program **reverses the reward assignment**. This is intentional and follows the spec: - `level1_referrer` (60%) = L3 (original referrer - index 2) - `level2_referrer` (30%) = L2 (middle - index 1) - `level3_referrer` (10%) = L1 (immediate - index 0) The terminology "Level 1" means "closest to origin" not "first in array." ### Reward Distribution For a $100 payment with default settings: ```text $100 Payment ├── Protocol Fee: $1.00 (1%) → Protocol Treasury ├── Gateway Fee: $2.50 (2.5%) → Gateway │ ├── Referral Pool: $1.25 (50% of gateway fee) │ │ ├── Level 1 (Original): $0.75 (60%) │ │ ├── Level 2 (Middle): $0.375 (30%) │ │ └── Level 3 (Immediate): $0.125 (10%) │ └── Gateway Business Fee: $1.25 (50%) └── Recipient: $96.50 ``` ## User Guide ### Earning Referral Rewards 1. **Create a Subscription**: When you create your first subscription, a referral code is automatically generated 1. **Share Your Code**: Share your 6-character referral code with friends 1. **Earn Rewards**: When someone signs up using your code and makes payments, you earn rewards ### Viewing Your Referrals ```typescript import { Tributary } from "@tributary-so/sdk"; const sdk = new Tributary(provider, programId); // Get your referral chain (who referred you) const chain = await sdk.getReferralChain(myPublicKey, gatewayPDA); // Returns: [referrerL1, referrerL2, referrerL3] or [null, null, null] if you're an origin ``` ### Understanding Your Rewards | Tier | Description | Reward | | ----------- | ------------------------------------------- | -------------------- | | **Level 1** | Original referrer (who started the chain) | 60% of referral pool | | **Level 2** | Middle of the chain | 30% of referral pool | | **Level 3** | Immediate referrer (who referred the payer) | 10% of referral pool | ## Developer Guide ### SDK Integration #### Getting the Referral Chain ```typescript async getReferralChain( user: PublicKey, gateway: PublicKey ): Promise<(PublicKey | null)[]> ``` Returns a fixed 3-element array where: - Index 0: Immediate referrer (L1) - Index 1: Who referred L1 (L2) - Index 2: Original referrer (L3) Missing referrers are represented as `null`. #### Creating a Referral Account ```typescript async createReferralAccount( gateway: PublicKey, referralCode: string, // 6-character alphanumeric referrer: PublicKey | null // null for origin referrers ): Promise ``` #### Executing Payment with Referrals ```typescript async executePayment( paymentPolicy: PublicKey, amount: BN, // Optional: pass referrer accounts explicitly remainingAccounts?: PublicKey[] ): Promise ``` The SDK automatically includes referral accounts in `remaining_accounts` when available. ### Understanding Account Ordering The SDK and Rust program use **different ordering conventions**: ```typescript // SDK: getReferralChain() traverses bottom-up (payer → origin) // Returns array in order: [L1, L2, L3] // L1 = immediate referrer (closest to payer) // L3 = original referrer (farthest from payer) const chain = await sdk.getReferralChain(payer, gateway); // chain = [A, B, C] where: // - A = L1 (referred payer) // - B = L2 (referred A) // - C = L3 (origin, referred by no one) ``` ```rust // Rust: ExecutePayment parses remaining_accounts // SDK passes [L1, L2, L3] but rewards are reversed: let level1_referrer = referral_accounts[2]; // L3 (original) → 60% let level2_referrer = referral_accounts[1]; // L2 (middle) → 30% let level3_referrer = referral_accounts[0]; // L1 (immediate) → 10% ``` Common Bug If you manually pass accounts in the wrong order, rewards will be assigned incorrectly. Always use `getReferralChain()` to get the correct order. ### Chain Validation The Rust program validates the chain structure on each payment: ```rust // Verify L1 refers to L2 if referral_accounts.len() >= 2 { let l1_referrer = get_referrer_from_account(remaining_accounts[0]); assert_eq!(l1_referrer, referral_accounts[1]); } // Verify L2 refers to L3 if referral_accounts.len() >= 3 { let l2_referrer = get_referrer_from_account(remaining_accounts[1]); assert_eq!(l2_referrer, referral_accounts[2]); } ``` If validation fails, the transaction reverts with: ```text Error Code: InvalidReferralChainOrdering ``` ### Error Codes | Code | Message | Cause | | ------------------------------ | ----------------------------------------------------- | ------------------------------ | | `InvalidReferralChainOrdering` | Invalid referral chain ordering in remaining_accounts | Accounts passed in wrong order | | `CircularReferralChain` | Circular referral chain detected | Referrer chain creates a loop | | `MaxReferralDepthExceeded` | Maximum referral chain depth exceeded | Chain longer than 3 levels | ### Testing Considerations When writing tests for referral functionality: 1. **Create fresh referrers**: Don't reuse referrers from other tests 1. **Set null referrers for origins**: The first referrer in a chain must have `null` as their referrer 1. **Validate chain length**: Tests should verify 0, 1, 2, and 3-level chains ```typescript // CORRECT: Create fresh referrer with null referrer const originReferrer = Keypair.generate(); await sdk.createReferralAccount(gateway, "ORIGIN", null); // WRONG: Reusing existing referrer (may already have a chain) const badReferrer = existingReferrerWithExistingChain; // Has L2, L3 already! ``` ## Gateway Configuration ### Default Settings | Parameter | Default | Description | | ------------------------- | ------------------ | --------------------------------------- | | `referral_allocation_bps` | 1250 (50%) | % of gateway fee allocated to referrals | | `referral_tiers_bps` | [6000, 3000, 1000] | 60/30/10% split across tiers | ### Customizing Referral Settings ```typescript async updateGatewayReferralSettings( gateway: PublicKey, settings: { referralAllocationBps?: number; // 0-2500 (0-25%) referralTiersBps?: [number, number, number]; // Must sum to 10000 } ): Promise ``` Gateway Tip Start with conservative referral allocation (e.g., 25%) and increase as you see viral growth. ## Architecture ### Account Structure ```rust #[account] #[derive(InitSpace)] pub struct ReferralAccount { pub owner: Pubkey, // Who owns this referral code pub referral_code: [u8; 6], // 6-character alphanumeric code pub referrer: Option, // Who referred this user (null for origin) pub created_at: i64, // Unix timestamp pub total_earned: u64, // Total rewards earned (in lamports) pub bump: u8, // PDA bump seed } ``` ### PDA Derivation Referral accounts are gateway-scoped: ```rust // PDA seeds: ["referral", gateway_pubkey, user_pubkey] Pubkey::find_program_address( &["referral".as_bytes(), gateway.as_ref(), owner.as_ref()], program_id ) ``` ### Linked List Validation The system validates the referral chain as a linked list: 1. **Creation Time**: Circular chains are prevented during `create_referral_account` 1. **Payment Time**: Chain ordering is verified during `execute_payment` 1. **Depth Limit**: Maximum 3 levels prevents deep chain attacks ## FAQ ### How are rewards calculated? For a $100 payment with default settings: 1. Gateway fee = $2.50 (2.5%) 1. Referral pool = $1.25 (50% of gateway fee) 1. Level 1 = $0.75 (60% of pool) 1. Level 2 = $0.375 (30% of pool) 1. Level 3 = $0.125 (10% of pool) ### Can I refer myself? No. Self-referral attempts are rejected during account creation. ### What happens if a referrer doesn't exist? The chain is padded with `null` values. For example, if only L1 exists: `[L1, null, null]` ### How do I disable referrals for my gateway? Set `referral_allocation_bps` to 0: ```typescript await sdk.updateGatewayReferralSettings(gatewayPDA, { referralAllocationBps: 0, }); ``` ### What's the maximum chain depth? 3 levels maximum. This prevents abuse while allowing meaningful viral growth. ## Related Documentation - [Protocol Overview](https://docs.tributary.so/protocol-reference/overview/index.md) - [Subscription Payments](https://docs.tributary.so/protocol-reference/payment-policy/subscription/index.md) - [SDK Documentation](https://docs.tributary.so/integration-guide/pull-payments/sdk/index.md) - [Fees](https://docs.tributary.so/protocol-reference/fees/index.md) - [Protocol Reference](https://docs.tributary.so/protocol-reference/overview/index.md) # Subscription Payments Recurring payments at fixed intervals — the bread and butter of SaaS, memberships, and any service with predictable billing. ## Overview Subscription policies charge a **fixed amount** on a **fixed schedule** (daily, weekly, monthly, etc.). The user sets it up once, and payments execute automatically via Solana's token delegation. No lock-up — funds stay in the user's wallet until each payment is due. ```text User creates policy --> Delegates token spending authority | +----------+-----------+-----------+----------+ | | | | Payment 1 Payment 2 Payment N ... $10/month $10/month $10/month auto-renew auto-renew auto-renew ``` ## When to Use | Good For | Not Ideal For | | -------------------------------------------- | ----------------------------------- | | SaaS monthly/annual billing | Project-based deliverables | | Content memberships (streaming, newsletters) | Variable usage (API calls, compute) | | Recurring donations | One-off payments | | Software licenses & maintenance | Milestone-based contracts | | API access with fixed monthly fees | Unpredictable billing amounts | | Any predictable, repeating payment | | ## On-Chain Specification ```rust PolicyType::Subscription { amount: u64, // Fixed payment amount per interval (lamports) auto_renew: bool, // Continue after each payment? max_renewals: Option, // Cap on total payments (None = unlimited) payment_frequency: PaymentFrequency, next_payment_due: i64, // Unix timestamp for next execution padding: [u8; 97], // 128-byte alignment } ``` ### PaymentFrequency Enum ```rust pub enum PaymentFrequency { Daily, Weekly, Monthly, Quarterly, SemiAnnually, Annually, Custom(u64), } ``` ### Account Size Each `Subscription` variant is exactly **128 bytes**, consistent with all other policy types. This enables seamless upgrades without breaking existing policies. ## Creating a Subscription ### Basic Monthly Subscription ```typescript import { Tributary } from "@tributary-so/sdk"; import { BN } from "@coral-xyz/anchor"; import { createMemoBuffer } from "@tributary-so/sdk"; import { PublicKey, Transaction } from "@solana/web3.js"; const sdk = new Tributary(connection, wallet); const USDC_MINT = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); const recipient = new PublicKey("BxKpT3mZQ5HgeRZFMfWVBpDCmCN8eYwGmCjL7m9mVq"); const gateway = new PublicKey("6ntm5rWqDFefET8RFyZV73FcdqxPMbc7Tso3pCMWk4w4"); const instructions = await sdk.createSubscription( USDC_MINT, recipient, gateway, new BN(10_000_000), // $10.00 per month (USDC has 6 decimals) true, // auto-renew null, // no max renewals (unlimited) { monthly: {} }, // payment frequency createMemoBuffer("user_123_pro_plan", 64) ); const tx = new Transaction().add(...instructions); const signature = await sendAndConfirm(connection, tx, [wallet.payer]); ``` ### Available Payment Frequencies ```typescript const frequencies = { daily: { daily: {} }, weekly: { weekly: {} }, biweekly: { biWeekly: {} }, monthly: { monthly: {} }, quarterly: { quarterly: {} }, semiAnnually: { semiAnnually: {} }, yearly: { yearly: {} }, }; ``` ### Annual Subscription with Cap ```typescript // $100/year, max 3 years, then auto-pauses const instructions = await sdk.createSubscription( USDC_MINT, recipient, gateway, new BN(100_000_000), // $100/year true, // auto-renew 3, // max 3 renewals { yearly: {} }, createMemoBuffer("annual_membership_3yr", 64) ); ``` ### Custom Start Date ```typescript const nextMonth = new Date(); nextMonth.setMonth(nextMonth.getMonth() + 1); nextMonth.setDate(1); nextMonth.setHours(0, 0, 0, 0); const instructions = await sdk.createSubscription( USDC_MINT, recipient, gateway, new BN(5_000_000), // $5/month donation true, null, { monthly: {} }, createMemoBuffer("charity_monthly", 64), new BN(Math.floor(nextMonth.getTime() / 1000)) // startTime ); ``` ## How It Works ### Payment Execution Flow 1. **Policy creation** — user creates policy, delegates token spending authority 1. **Waiting** — `next_payment_due` timestamp hasn't been reached yet 1. **Execution** — when `now >= next_payment_due`, anyone (typically a gateway signer) calls `execute_payment` 1. **Transfer** — amount moves from user's token account to recipient, minus protocol + gateway fees 1. **Advance** — `next_payment_due` advances by one period, `total_payments` increments 1. **Repeat** — if `auto_renew` is true and `max_renewals` hasn't been reached ### Renewal Behavior | Condition | Result | | ---------------------------------------- | ------------------------- | | `auto_renew = true`, no max | Continues indefinitely | | `auto_renew = true`, `max_renewals = 12` | Pauses after 12 payments | | `auto_renew = false` | Pauses after next payment | ### Fee Distribution Each payment splits into: - **Protocol fee**: 100 bps (1%) -> protocol treasury - **Gateway fee**: configurable by gateway operator -> gateway fee recipient - **Net amount**: remainder -> recipient ```text $10.00 payment -> $0.10 protocol + $0.05 gateway + $9.85 recipient ``` ## Token Delegation Subscriptions use SPL Token delegation — the user approves the protocol's PDA to spend up to the calculated amount. Funds **never leave the wallet** until a payment executes. ```typescript // SDK calculates approval amount automatically: // - Unlimited: amount * payments_per_year // - Limited: amount * max_renewals // Or provide explicitly: const instructions = await sdk.createSubscription( USDC_MINT, recipient, gateway, new BN(10_000_000), true, null, { monthly: {} }, createMemoBuffer("pro_plan", 64), null, // startTime (defaults to now) new BN(120_000_000) // approvalAmount: $120 = $10 x 12 months ); ``` Users can revoke delegation at any time, effectively cancelling the subscription. ## Managing Subscriptions ### Query Status ```typescript const policy = await sdk.getPaymentPolicy(policyPda); const sub = policy.policyType.subscription; console.log("Amount:", sub.amount.toString()); console.log("Next due:", new Date(sub.nextPaymentDue.toNumber() * 1000)); console.log("Auto-renew:", sub.autoRenew); console.log("Max renewals:", sub.maxRenewals); console.log("Payments made:", policy.paymentCount.toNumber()); const isDue = Date.now() / 1000 >= sub.nextPaymentDue.toNumber(); ``` ### Pause / Resume / Cancel ```typescript // Pause -- stops payments but keeps the policy await sdk.changePaymentPolicyStatus(tokenMint, policyId, { paused: {} }); // Resume -- reactivates a paused policy await sdk.changePaymentPolicyStatus(tokenMint, policyId, { active: {} }); // Cancel -- deletes the policy entirely await sdk.deletePaymentPolicy(tokenMint, policyId); ``` ### List All Active Subscriptions ```typescript const allPolicies = await sdk.getPaymentPoliciesByUserPayment(userPaymentPda); const activeSubscriptions = allPolicies.filter( (p) => "subscription" in p.account.policyType && "active" in p.account.status ); for (const { publicKey, account } of activeSubscriptions) { const sub = account.policyType.subscription; const nextDue = new Date(sub.nextPaymentDue.toNumber() * 1000); console.log(`${publicKey}: $${sub.amount} due ${nextDue.toLocaleString()}`); } ``` ## Approval Amount Calculation ```typescript function computePaymentsPerYear(frequency: PaymentFrequency): number { switch (Object.keys(frequency)[0]) { case "daily": return 365; case "weekly": return 52; case "biWeekly": return 26; case "monthly": return 12; case "quarterly": return 4; case "semiAnnually": return 2; case "yearly": return 1; default: return 12; } } const paymentsPerYear = computePaymentsPerYear(frequency); const effectiveRenewals = maxRenewals ?? paymentsPerYear; const approvalAmount = amount.mul(new BN(effectiveRenewals)); ``` ## Troubleshooting | Error | Cause | Fix | | ------------------------- | ----------------------------------- | -------------------------------- | | `InsufficientFunds` | Not enough USDC in wallet | Fund the wallet or reduce amount | | `PaymentNotDue` | `next_payment_due` hasn't passed | Wait for the scheduled time | | `InvalidDelegation` | Delegated amount too low | Re-approve with higher amount | | Subscription not renewing | `auto_renew = false` or max reached | Check renewal settings | ## Comparison with Other Policy Types | | Subscription | Milestone | Pay-as-you-go | OneTime | UpTo | | --------------------- | ------------------ | ---------------------- | ------------------ | --------------------- | ------------------------ | | **Amount** | Fixed per period | Variable per milestone | Variable per claim | Fixed, single fire | Caller-supplied, ≤ max | | **Timing** | Fixed schedule | Event/timestamp based | On-demand | Scheduled / immediate | `[validAfter, deadline)` | | **Fires** | Recurring | Up to 4 phases | Many (within caps) | Exactly once | Exactly once | | **Recipient trigger** | No | Per release_condition | Yes | No | Yes | | **Best For** | Recurring services | Project deliverables | Variable usage | Invoices, one-shots | Usage-based one-shot | | **User Control** | Set up once | Approve per milestone | Period limits | Schedule + delete | Authorize + window | # UpTo Payments Single-use, time-bound authorizations to transfer **up to** a maximum amount — where the **actual settled amount is determined at settle time** by the resource server based on real usage. The x402 `upto` primitive. ## Overview An UpTo policy is a **single-use authorization** that lets a resource server (the recipient, usually) settle **up to `max_amount`** exactly once, within a `[valid_after, deadline)` window. The settled amount can be anywhere in `0..=max_amount` — it is supplied by the caller at execute time, after the actual usage has been measured. After one settlement, the policy transitions to `Completed`. ```text User authorizes --> max_amount + valid_after + deadline | | | (resource consumed; usage measured) | | | settleUpTo(actual <= max) | | | status: Active → Completed | (re-settle blocked; authorization consumed) ``` This is the x402 analog of EVM's Permit2 "upto" witness pattern, realized on Solana via Tributary's PDA-delegate pull-payment model. ## When to Use | Good For | Not Ideal For | | ------------------------------------------------ | -------------------------------------------- | | Pay-per-use LLM calls (settle after tokens used) | Fixed-price one-shots (use OneTime) | | Compute / bandwidth billing (settle after job) | Recurring services (use Subscription) | | HTTP 402 / x402 facilitator flows | Multi-claim within a cap (use Pay-as-you-go) | | Usage-capped single authorization (gas relay) | Milestone-based escrow (use Milestone) | | "Pre-auth hold" style variable settlement | True streaming | ## On-Chain Specification ```rust PolicyType::UpTo { max_amount: u64, // ceiling on the settlement amount (lamports) valid_after: i64, // earliest settlement; <= 0 means immediate deadline: i64, // hard expiry; MUST be > 0 and > valid_after padding: [u8; 104], // 128-byte alignment } ``` ### Key Fields | Field | Description | | ------------- | -------------------------------------------------------------------------------------------------------------------- | | `max_amount` | **Ceiling** on the settlement amount. The settle-time caller cannot exceed it. Must be `> 0`. | | `valid_after` | Earliest settlement timestamp. `<= 0` means immediately settleable. | | `deadline` | Hard expiry. Settlement is rejected when `current_time >= deadline` (strict `<`). Must be `> 0` and `> valid_after`. | There is **no `settled_amount` field**. The actual settlement is recorded in `PaymentPolicy.total_paid` (incremented by `execute_payment`) and `payment_count` goes to `1`. ### Account Size Each `UpTo` variant is exactly **128 bytes**, consistent with all other policy types (ADR-0002). The 1-byte enum discriminator is `4`. ### Why `deadline` is mandatory (not `Option`) Unlike OneTime's optional `expiry_date`, UpTo's `deadline` is a required `i64`. The x402 spec mandates explicit time bounds for an authorization primitive — "transfer up to X" without a hard deadline is an open-ended risk. ## Settle Amount Enforcement (the key invariant) The execute-time gate reads `max_amount` from the **on-chain policy** (immutable post-create) and enforces: ```text 0 <= provided_amount <= max_amount ``` The settle-time caller cannot inflate the ceiling. This satisfies the x402 spec's rule that the facilitator must **re-verify against `permitted.amount`, not settle-time `requirements.amount`** — automatically, because the max is committed on-chain at create time and the program reads it back at execute. ### `actual MAY be 0` A zero settle is explicitly permitted: "no usage → no charge" is a valid single outcome. No transfer CPI runs for the zero case, but the policy still transitions to `Completed` — the authorization is consumed. This is distinct from Pay-as-you-go, which rejects zero chunks (L-01 defense). ## Creating an UpTo Authorization ### Basic Example — LLM Call, Up To $5 ```typescript import { Tributary } from "@tributary-so/sdk"; import { BN } from "@coral-xyz/anchor"; import { createMemoBuffer } from "@tributary-so/sdk"; import { PublicKey, Transaction } from "@solana/web3.js"; const sdk = new Tributary(connection, wallet); const USDC_MINT = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); const llmProvider = new PublicKey("BxKpT3mZQ5HgeRZFMfWVBpDCmCN8eYwGmCjL7m9mVq"); const gateway = new PublicKey("6ntm5rWqDFefET8RFyZV73FcdqxPMbc7Tso3pCMWk4w4"); const now = Math.floor(Date.now() / 1000); const instructions = await sdk.createUpToAuthorization( USDC_MINT, llmProvider, gateway, new BN(5_000_000), // max $5 authorization new BN(now + 3600), // deadline: 1 hour from now createMemoBuffer("llm_session_42", 64), null // validAfter: immediate ); const tx = new Transaction().add(...instructions); const signature = await sendAndConfirm(connection, tx, [wallet.payer]); ``` ### Immediate vs Scheduled vs Time-Bound ```typescript // Immediate — settle any time starting now through deadline. await sdk.createUpToAuthorization( USDC_MINT, recipient, gateway, new BN(5_000_000), new BN(now + 3600), // 1h deadline createMemoBuffer("immediate", 64), null // validAfter omitted → immediate ); // Scheduled — settle window opens later (e.g. authorize now, usable tomorrow). await sdk.createUpToAuthorization( USDC_MINT, recipient, gateway, new BN(5_000_000), new BN(now + 7 * 86400), // 7-day deadline createMemoBuffer("scheduled", 64), new BN(now + 86400) // validAfter: tomorrow ); ``` ## How It Works ### Two-Phase Flow (x402 facilitator) ```text Phase 1 — VERIFY (client presents authorization) 1. Client creates an UpTo policy on-chain + approves the delegate. 2. Client sends the creation tx in the Payment header. 3. Facilitator submits the tx, then verifyUpToAuthorization(): - policy exists, status Active - recipient / gateway / mint match - policy.policyType.upTo.maxAmount == requirements.amount (ceiling) - valid_after / deadline within acceptable window 4. Facilitator issues a short-lived JWT (exp = deadline). Phase 2 — SETTLE (after resource consumption) 1. Resource server measures usage (tokens / bytes / compute). 2. Computes actual = min(usage_cost, max_amount). 3. Calls settleUpTo(policyPda, actual): - on-chain re-checks actual <= max_amount (reads from policy) - time window still valid (now < deadline) - single execute → policy goes Completed 4. PaymentRecord event emitted with the actual settled amount. ``` ### Phase-Dependent `amount` | Phase | `X402PaymentRequirements.amount` | | ------ | -------------------------------- | | Verify | `max_amount` (the ceiling) | | Settle | `actual` (the measured cost) | The facilitator **MUST NOT** trust the settle-time `requirements.amount` for the ceiling — it reads `max_amount` from the on-chain policy. The program does the same. This is automatic: the max is committed at create and immutable. ### Settling The recipient (resource server) or the gateway signer settles: ```typescript // After measuring 2,340,000 lamports of LLM usage ($2.34, well under the $5 max): const settleInstructions = await sdk.settleUpTo( policyPda, new BN(2_340_000) // actual, <= max_amount ); const tx = new Transaction().add(...settleInstructions); await sendAndConfirm(connection, tx, [recipientKeypair]); ``` ### Zero-Amount Settle ```typescript // No usage happened — settle 0. The authorization is consumed (Completed), // but no tokens move. await sdk.settleUpTo(policyPda, new BN(0)); ``` ## Authorization (Who Can Settle) UpTo is **recipient-triggerable**, like Pay-as-you-go. Any of: - the **gateway signer** (trusted facilitator), - the **user** (owner), or - the **recipient** (resource server, often the same entity) may call `execute_payment` for an UpTo policy. Subscription / Milestone / OneTime do not allow recipient triggering. ## Managing UpTo Policies ### Query Status ```typescript const policy = await sdk.getPaymentPolicy(policyPda); const upto = policy.policyType.upTo; console.log("Max:", upto.maxAmount.toNumber()); console.log("Valid after:", new Date(upto.validAfter.toNumber() * 1000)); console.log("Deadline:", new Date(upto.deadline.toNumber() * 1000)); console.log("Status:", Object.keys(policy.status)[0]); // "active" → authorization still consumable // "paused" → owner paused it // "completed" → settled (or zero-settled); terminal ``` ### Lifecycle ```typescript // Pause while the authorization is unused. await sdk.changePaymentPolicyStatus(tokenMint, policyId, { paused: {} }); await sdk.changePaymentPolicyStatus(tokenMint, policyId, { active: {} }); // Cancel before settlement — revokes delegation and reclaims rent. await sdk.deletePaymentPolicy(tokenMint, policyId); ``` If `deadline` passes without settlement, the owner can `delete_payment_policy` to reclaim rent. The authorization cannot be settled after the deadline. ## Composable Interplay Because `PolicyType` is shared between `PaymentPolicy` and `ComposablePolicy` (ADR-0007), UpTo lands in the **composable** family for free: - **Validation hook (Lighthouse)** — settle up to X once, only if an on-chain assertion holds (e.g. hot wallet balance below threshold → top-up). - **Forward hook (Meteora DLMM)** — settle in input token, swap to output token on delivery — once. See the [Composable Policy overview](https://docs.tributary.so/protocol-reference/composable-policy/overview/index.md). All composable invariants (ADR-0008 through ADR-0010) apply unchanged. ## Use Case Examples ### LLM Pay-Per-Session ```typescript // Authorize up to $5 for a single LLM session, valid for 1 hour. await sdk.createUpToAuthorization( USDC_MINT, llmProvider, gateway, new BN(5_000_000), new BN(now + 3600), createMemoBuffer("llm_session", 64) ); // Provider measures actual token cost ($2.34) and settles. await sdk.settleUpTo(policyPda, new BN(2_340_000)); ``` ### HTTP 402 / x402 Resource Access ```typescript // Resource server uses the x402 middleware with the `upto` scheme. import { createX402Middleware } from "@tributary-so/sdk-x402"; const middleware = createX402Middleware({ scheme: "x402://upto", network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", amount: 1_000_000, // verify-time: ceiling ($1) maxAmount: 1_000_000, validAfter: now, deadline: now + 3600, recipient: providerAddress, gateway: gatewayAddress, tokenMint: USDC_MINT, jwtSecret: process.env.JWT_SECRET, sdk, connection, }); ``` ### Compute Job (Composable + Lighthouse) ```typescript // Authorize up to $10 for a GPU job, but only settle if the job's output hash // matches an on-chain assertion. Composable UpTo + Lighthouse guard. const guard = lighthouse .accountData(outputHashAccount) .equals(expectedHash) .build(); // ...build composable UpTo policy with the guard... ``` ## Best Practices - **Set `deadline` tight** — match it to the expected resource-consumption window. An open-ended UpTo (`deadline` years out) is a long-lived liability. - **Approve `max_amount + fee headroom`** as the delegate allowance — gateway fees are pulled on top of the gross settle amount. - **Use UpTo over Pay-as-you-go when** you need single-settlement semantics (one shot, then complete). Pay-as-you-go is for multi-claim within period caps. - **Use UpTo over OneTime when** the actual amount isn't known at create time. OneTime is fixed-amount; UpTo is variable-amount up to a ceiling. ## Comparison with Other Policy Types | | Subscription | Milestone | Pay-as-you-go | OneTime | UpTo | | --------------------- | ------------------ | ---------------------- | ------------------ | --------------------- | ------------------------ | | **Amount** | Fixed per period | Variable per milestone | Variable per claim | Fixed, single fire | Caller-supplied, ≤ max | | **Timing** | Fixed schedule | Event/timestamp | On-demand | Scheduled / immediate | `[validAfter, deadline)` | | **Fires** | Recurring | Up to 4 phases | Many (within caps) | Exactly once | Exactly once | | **Recipient trigger** | No | Per release_condition | Yes | No | Yes | | **Zero settle** | n/a | n/a | Rejected (L-01) | n/a | Allowed | | **Best For** | Recurring services | Project deliverables | Variable usage | Invoices, one-shots | Usage-based one-shot | ## Further Reading - [ADR-0020 — UpTo scheme and policy variant](https://github.com/tributary-so/tributary/blob/develop/apps/docs/adr/0020-upto-scheme-and-policy-variant.md) - [x402 HTTP Payments](https://docs.tributary.so/integration-guide/pull-payments/x402/overview/index.md) — the facilitator flow - [OneTime](https://docs.tributary.so/protocol-reference/payment-policy/onetime/index.md) — fixed-amount single-fire - [Pay-as-you-go](https://docs.tributary.so/protocol-reference/payment-policy/payasyougo/index.md) — multi-claim within period caps - [Composable Policy Overview](https://docs.tributary.so/protocol-reference/composable-policy/overview/index.md) # Glossary Shared terminology spanning Solana primitives and Tributary-specific concepts. ## Overview This glossary defines every domain term used across the Tributary docs — from Solana basics (PDA, ATA, delegate) to protocol-specific nouns (UserPayment, PaymentGateway, ComposablePolicy, ValidationPda). Integrators should be able to read any other page and resolve every unfamiliar term here.