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<br/>(user, funding source)"] -->|"pull USDC<br/>(UserPayment PDA signs)"| Inter["intermediate USDC ATA<br/>(owned by ComposablePolicy PDA)"]
Val["Lighthouse CPI:<br/>hotWalletUsdcAta.amount < 50 USDC?"] -.->|"assertion holds → continue<br/>fails → tx reverts"| Inter
Inter -->|"sweep USDC"| Hot["hotWallet<br/>(recipient)<br/>+ 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
UserPaymentPDA forcoldWallet+USDC_MINT. The cold wallet must approve theUserPaymentPDA as delegate on its USDC ATA with sufficientdelegated_amount. - A
PaymentGatewaywith a signer (the executor). - An SPL token account for
hotWalletin USDC — Lighthouse will read it.
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¶
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.
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.
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¶
- Pull —
UserPaymentPDA signs atransferfromcoldWalletUsdcAtainto theintermediate_input_ata(owned by theComposablePolicyPDA). The pull is gross:face + fees. - Skim fees — Protocol fee + gateway fee are routed from the
intermediate_input_atato their respective fee accounts. After skimming, the intermediate holds exactly thefaceamount. - Pre-validate — Tributary CPIs Lighthouse with
guard.data+[hotWalletUsdcAta]as read-accounts. Lighthouse assertshotWalletUsdcAta.amount < 50 USDC. If the hot wallet is at or above the threshold, the assertion fails and the whole transaction reverts — no funds move. - 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 — every assertion family.
- SDK surface —
getCreateComposablePolicyInstruction/executeComposablesignatures.