This guide walks you through building a corporate-actions distributor: an HTTP-triggered Compose task that snapshots holders of a share token at an operator-supplied record block, then pays each holder their pro-rata share of an escrowed USDC pool. Compose orchestrates Goldsky Turbo as an ephemeral, on-demand subroutine. When a campaign is declared, the task spawns a one-shot job-mode pipeline to snapshot holders, waits for it to finish, pays each holder, and deletes the pipeline. There is no always-on indexing.Documentation Index
Fetch the complete documentation index at: https://docs.goldsky.com/llms.txt
Use this file to discover all available pages before exploring further.
How it works
One HTTP request drives the entire lifecycle. No cron, no off-chain handoff:- The operator declares a distribution by
POSTing{ campaignId, recordBlock, totalAmount }. The task validatesrecordBlock <= currentBlock, approves USDC, and callsDistributionCampaign.declare(), which atomically pulls the escrow. - The same task spawns a job-mode Turbo pipeline filtered to the share-token contract over
block_number BETWEEN <deployBlock> AND <recordBlock>. The planner prunes everything outside that window. The sink writes rawTransferrows into a per-campaign Postgres table. - The task polls
/stateevery 2 seconds until the pipeline reportscompleted(or its k8s deployment auto-cleans up after a successful run, which we infer fromstate=unknownplus the table having rows). - The task reads the snapshot with a SQL aggregate over the raw rows (
SUM(credits) - SUM(debits)per account) and computes pro-rata. Each holder getsfloor(balance × totalAmount / totalSupply). The floor remainder goes to the last holder so the sum equalstotalAmountexactly. - The task fires up to 25 sponsored
pay()calls concurrently viaPromise.allSettled. Already-paid holders are filtered out by an on-chainisPaid()read first. The contract’srequire(!paid[id][holder])guard means duplicates are structurally impossible anyway. - The task re-reads
escrowRemainingon-chain to confirm completion. Zero means done: mark the campaigncomplete,DELETEthe pipeline, drop the per-campaign table. Non-zero means the operator can re-POST the samecampaignIdto resume.
campaignId after any kind of failure picks up cleanly. The contract is the sole source of truth for “did this holder get paid?”, so Compose’s state machine never has to be.
Prerequisites
- Goldsky CLI installed
- Foundry for the contract deploys
- A small amount of ETH on Base mainnet for the three contract deploys (around 0.0005 ETH)
- A project API key for
goldsky compose deploy -t <key>and a separate (or same) key set as theGOLDSKY_PROJECT_KEYsecret so the running app can manage Turbo pipelines
Project structure
Step 1: Set up the project
Clone the example repository:Step 2: Understand the task
declare-campaign.ts is the only task. It validates input, approves USDC, declares the campaign on-chain, spawns the pipeline, and drives the campaign through its state machine inside the HTTP request:
src/lib/driver.ts. driveCampaign is a state-machine dispatcher: snapshotting polls the pipeline, then transitions to paying; paying reads the snapshot, fires pay() calls, then checks escrowRemaining to decide whether to mark complete.
Key Compose features used
- Compose orchestrating Turbo via the v1 pipelines REST API. The task spawns, polls, and deletes the pipeline from inside its own HTTP handler.
- Auto-provisioned hosted Neon DB. Compose-cloud creates a per-app Neon project and a
CORPORATE_ACTIONSproject secret pointing at it. Turbo writes to that DB via the same secret. No glue code on either side. - Gas-sponsored writes.
sponsorGas: trueonevm.wallet()means the operator wallet never holds ETH. Goldsky pays gas for everyapprove()andpay(). context.fetchfor everything external. The Neon HTTP/sqlclient, the v1 pipelines API client, and the chain RPC reads all go throughcontext.fetch. That’s the only--allow-netegress path the task gets.- Resumable on
campaignId. Compose’s collection holds campaign metadata. The contract holds payment truth. Re-POSTing the same id drives the existing row forward.
Step 3: Understand the contracts
DistributionCampaign.sol is the core piece. It holds USDC in escrow, gates payouts on a per-holder paid bitmap, and emits a rich audit event:
ShareToken.sol is a minimal ERC-20 that pre-mints to a demo holder set in its constructor. MockUSDC.sol is a 6-decimal mock with permissionless mint, so you can fund the operator without bridging real USDC.
Step 4: Pre-create the operator wallet
The operator wallet is the address that callsdeclare() and pay() on the contract. Provision it ahead of the deploy so you can hard-code its address into the contract’s deployment if you want to restrict who can call declare():
Step 5: Deploy the contracts
scripts/deploy.sh deploys all three contracts in one run. It reads seed-holders.json to pre-mint share-token balances to 25 demo addresses:
Step 6: Wire in the addresses
Opensrc/lib/constants.ts and replace the four constants:
Step 7: Set the project secret
The running compose app needs a Goldsky project API key so it can spawn, poll, and delete Turbo pipelines via the v1 API. Set it once:CORPORATE_ACTIONS secret. Compose-cloud provisions a Neon DB for the app on first deploy and creates that secret for you, pointed at the new DB.
Step 8: Deploy to Goldsky
- Provision a hosted Neon DB for the app.
- Create the
CORPORATE_ACTIONSproject secret pointed at it. - Build and start the task pod. The HTTP trigger is now reachable at
https://api.goldsky.com/api/admin/compose/v1/corporate-actions/tasks/declare_campaign.
Step 9: Declare a campaign and verify
Fund the operator wallet with MockUSDC (the address was printed at the end ofgoldsky compose wallet create):
pay() calls in a single batch. The response body includes the final campaign state (complete on the happy path, or paying if it needs another drive call).
Verify on-chain:
escrowRemaining is exactly 0 once all 25 holders are paid. The full audit trail is in the contract’s HolderPaid events.
Crash-safety walkthrough
The contract is the source of truth for “did this holder get paid?”. Compose can crash any time and restart any time without double-paying or skipping a holder. To verify:- POST a fresh campaign (new
campaignId). - Mid-flight, pause the compose app:
goldsky compose pause. - Resume:
goldsky compose resume. - Re-POST the same
campaignId. Within a single drive call, every remaining holder gets paid. There are zero duplicates on-chain. Verify by countingHolderPaidevents for the campaign.
- Compose’s collection only stores campaign metadata (status, declareTxHash, pipelineName, persisted payouts). It does not cache per-holder paid state, so there’s no off-chain table that can diverge from on-chain truth.
- Per-holder
isPaid()check before eachpay()call. Already-paid holders are skipped before the tx is even attempted. - The contract’s
require(!paid[id][holder], "AlreadyPaid")guard. Even if a stale tx arrives after restart, the contract rejects it. Double-pay is structurally impossible regardless of compose’s state.
campaignId polls the existing pipeline. If the pipeline auto-cleaned up after success, we infer completion from the agg table having rows.
Customization
Why operator-supplied recordBlock
In real corporate actions, the record date is set in advance and is the cutoff for who gets the payout. The snapshot is by definition backwards-looking. So the operator passes an explicit recordBlock, typically a block past finality like currentBlock - 32. The pipeline backfills exactly that range and commits the snapshot. There’s no live “wait for finality” gate inside compose. The operator already accommodated finality when they chose the block.
Future-dated record blocks (declare today, snapshot tomorrow) are a real corporate-action feature but out of scope for this demo.
Swap the share token
UpdateCONFIG.shareToken and CONFIG.shareTokenDeployBlock in src/lib/constants.ts and redeploy the compose app. Each campaign’s pipeline bakes in the address at declare time, so existing campaigns are unaffected.
Add a chain
Add achain discriminator to Campaign and DeclareParams, populate CONFIG with a per-chain map, and pass chain into buildSnapshotPipeline so the dataset name (base.erc20_transfers vs arbitrum.erc20_transfers) is parameterized.
Use real USDC
ReplaceMockUSDC.sol with the real USDC address per chain in CONFIG.payToken. Real USDC isn’t fee-on-transfer, so escrowRemaining math is exact.
Tune concurrency
CONCURRENCY in src/lib/constants.ts is the upper bound on parallel pay() calls. The gas-sponsored bundler caps per-sender throughput around 1 to 5 userOps per second; the default of 25 is sized for the demo’s 25-holder set firing in one visible batch. For larger distributions, drop it to keep the bundler happy.
When NOT to use this pattern
This is push-based pro-rata. It scales comfortably up to roughly 100 holders per request. Beyond that, the right shape is a merkle-claim contract instead: the operator publishes one merkle root and holders pull. The compose plumbing (orchestrating Turbo for the snapshot, computing pro-rata, building the audit trail) carries over to either model. Only the on-chain shape changes.Resources
- Compose introduction
- Task triggers
- EVM wallets & gas sponsoring
- Turbo job-mode pipelines
- CMTAT IncomeVault, Swiss-bank reference implementation for tokenized-equity dividends. The example’s event schema is anchored on this convention.
- ERC-1726 Dividend-Paying Token
- GitHub repository