Skip to main content

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.

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.

How it works

One HTTP request drives the entire lifecycle. No cron, no off-chain handoff:
  1. The operator declares a distribution by POSTing { campaignId, recordBlock, totalAmount }. The task validates recordBlock <= currentBlock, approves USDC, and calls DistributionCampaign.declare(), which atomically pulls the escrow.
  2. 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 raw Transfer rows into a per-campaign Postgres table.
  3. The task polls /state every 2 seconds until the pipeline reports completed (or its k8s deployment auto-cleans up after a successful run, which we infer from state=unknown plus the table having rows).
  4. 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 gets floor(balance × totalAmount / totalSupply). The floor remainder goes to the last holder so the sum equals totalAmount exactly.
  5. The task fires up to 25 sponsored pay() calls concurrently via Promise.allSettled. Already-paid holders are filtered out by an on-chain isPaid() read first. The contract’s require(!paid[id][holder]) guard means duplicates are structurally impossible anyway.
  6. The task re-reads escrowRemaining on-chain to confirm completion. Zero means done: mark the campaign complete, DELETE the pipeline, drop the per-campaign table. Non-zero means the operator can re-POST the same campaignId to resume.
Re-posting the same 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 the GOLDSKY_PROJECT_KEY secret so the running app can manage Turbo pipelines

Project structure

corporate-actions/
├── compose.yaml                 # 1 HTTP task; declares GOLDSKY_PROJECT_KEY secret
├── contracts/
│   ├── ShareToken.sol           # Minimal ERC-20, pre-mints to 25 demo holders
│   ├── MockUSDC.sol             # 6-decimal mock with permissionless mint
│   └── DistributionCampaign.sol # AlreadyPaid guard, escrow, audit events
├── scripts/
│   ├── seed-holders.json        # 25 demo holders, uneven amounts
│   └── deploy.sh                # forge create x3 in one go
└── src/
    ├── lib/
    │   ├── constants.ts         # CONFIG, polling cadence, concurrency
    │   ├── turbo.ts             # /api/v1/pipelines client + snapshot pipeline builder
    │   ├── db.ts                # Neon HTTP /sql client (via context.fetch)
    │   └── driver.ts            # state-machine driver: snapshot → paying → complete
    └── tasks/
        └── declare-campaign.ts  # HTTP trigger; drives full lifecycle inline

Step 1: Set up the project

Clone the example repository:
git clone https://github.com/goldsky-io/documentation-examples.git
cd documentation-examples/compose/corporate-actions

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:
export async function main(context: TaskContext, params?: DeclareParams) {
  const { evm, collection } = context;

  // Idempotent on campaignId. A second POST drives an existing campaign
  // forward instead of declaring a new one.
  const campaigns = await collection<Campaign>("campaigns", [
    { path: "status", type: "text" },
  ]);
  const existing = await campaigns.getById(params.campaignId.toLowerCase());
  if (existing) {
    await driveCampaign(context, campaigns, existing);
    return responseFor(existing, "resumed");
  }

  // Approve + declare on-chain (atomic escrow pull).
  const wallet = await evm.wallet({ name: "corp-actions-operator", sponsorGas: true });
  await wallet.writeContract(chain, payToken, "approve(address,uint256)", [campaignContract, totalAmount]);
  const { hash } = await wallet.writeContract(chain, campaignContract,
    "declare(bytes32,address,address,uint256)",
    [campaignId, payToken, shareToken, totalAmount]);

  // Spawn the snapshot pipeline.
  const pipeline = await createSnapshotPipeline(context, { campaignId, shareToken, recordBlock });

  // Persist campaign metadata, then drive the campaign through
  // snapshot → paying → complete inline.
  await campaigns.setById(rowId, { ...campaign, status: "snapshotting", pipelineName: pipeline.name });
  await driveCampaign(context, campaigns, campaign);
  return responseFor(campaign, "declared");
}
The lifecycle logic lives in 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_ACTIONS project secret pointing at it. Turbo writes to that DB via the same secret. No glue code on either side.
  • Gas-sponsored writes. sponsorGas: true on evm.wallet() means the operator wallet never holds ETH. Goldsky pays gas for every approve() and pay().
  • context.fetch for everything external. The Neon HTTP /sql client, the v1 pipelines API client, and the chain RPC reads all go through context.fetch. That’s the only --allow-net egress 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:
struct Campaign {
    address operator;
    address payToken;
    address shareToken;
    uint256 totalAmount;
    uint256 escrowRemaining;
    uint256 declaredAt;
    bool    declared;
    bool    sealed;
}

function declare(bytes32 userId, address payToken, address shareToken, uint256 totalAmount) external;
function pay(bytes32 id, address holder, uint256 amount, uint256 sharesAtSnapshot) external;
function isPaid(bytes32 id, address holder) external view returns (bool);
function seal(bytes32 id) external; // operator can recover unspent escrow

event HolderPaid(
    bytes32 indexed id,
    address indexed holder,
    address indexed payToken,
    uint256 amount,
    uint256 sharesAtSnapshot
);
A holder can never be paid twice, regardless of what Compose does:
require(!paid[id][holder], "AlreadyPaid");
paid[id][holder] = true;
campaigns[id].escrowRemaining -= amount;
IERC20(payToken).transfer(holder, amount);
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 calls declare() 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():
goldsky compose wallet create corp-actions-operator --env cloud
The command prints the wallet address. Save it for the next step.

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:
PRIVATE_KEY=0x... ./scripts/deploy.sh
The script prints the three addresses and the ShareToken’s deploy block. You need all four values in the next step.

Step 6: Wire in the addresses

Open src/lib/constants.ts and replace the four constants:
export const CONFIG = {
  chain: "base" as const,
  shareToken:       "0x..." as Hex,  // from step 5
  payToken:         "0x..." as Hex,  // MockUSDC from step 5
  campaignContract: "0x..." as Hex,  // from step 5
  shareTokenDeployBlock: 45654954,   // block number printed by deploy.sh
};
shareTokenDeployBlock matters for performance. The job-mode source has to be set to start_at: "earliest" (the source-level end_block would make it non-hybrid and Turbo refuses to run). So we anchor the scan window in the SQL filter instead: block_number BETWEEN <deployBlock> AND <recordBlock>. The planner prunes everything outside that window before it touches a row.

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:
goldsky secret create GOLDSKY_PROJECT_KEY <your-cm...-project-api-key>
You don’t have to create the 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

goldsky compose deploy
The compose-cloud deploy will:
  1. Provision a hosted Neon DB for the app.
  2. Create the CORPORATE_ACTIONS project secret pointed at it.
  3. 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 of goldsky compose wallet create):
cast send <MOCK_USDC> "mint(address,uint256)" <OPERATOR_ADDRESS> 1000000000000 \
  --rpc-url https://mainnet.base.org --private-key $PRIVATE_KEY
That mints 1,000,000 mUSDC, enough for many demo campaigns. Pick a record block past finality, then POST:
RECORD_BLOCK=$(cast block-number --rpc-url https://mainnet.base.org)
RECORD_BLOCK=$((RECORD_BLOCK - 32))

curl -sX POST "https://api.goldsky.com/api/admin/compose/v1/corporate-actions/tasks/declare_campaign" \
  -H "content-type: application/json" \
  -H "Authorization: Bearer $GOLDSKY_TOKEN" \
  -d "{
    \"campaignId\":  \"0x000000000000000000000000000000000000000000000000000000000000c0a1\",
    \"recordBlock\": $RECORD_BLOCK,
    \"totalAmount\": \"10000000000\"
  }"
That declares a 10,000 mUSDC distribution. The request stays open for roughly 10 to 30 seconds while compose snapshots, computes pro-rata, and fires the 25 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:
cast call <DISTRIBUTION_CAMPAIGN> "getCampaign(bytes32)" <onChainId> \
  --rpc-url https://mainnet.base.org
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:
  1. POST a fresh campaign (new campaignId).
  2. Mid-flight, pause the compose app: goldsky compose pause.
  3. Resume: goldsky compose resume.
  4. Re-POST the same campaignId. Within a single drive call, every remaining holder gets paid. There are zero duplicates on-chain. Verify by counting HolderPaid events for the campaign.
What’s protecting you:
  • 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 each pay() 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.
A pod kill mid-snapshot is also recoverable. Re-POSTing the same 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

Update CONFIG.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 a chain 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

Replace MockUSDC.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