> ## 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.

# Build a corporate-actions distributor

> Pay N share-token holders pro-rata for a tokenized corporate action (dividend, coupon, rebate, airdrop) using Compose to orchestrate an on-demand Goldsky Turbo pipeline as the snapshot subroutine

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](/turbo-pipelines/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

```mermaid theme={null}
flowchart LR
    Op([Operator]) -->|POST campaignId, recordBlock, totalAmount| T[declare_campaign task]
    T -->|1. approve + declare| C[DistributionCampaign.sol]
    T -->|2. POST /api/v1/pipelines| P[Turbo job-mode pipeline]
    P -->|3. backfill Transfers| DB[(share_balances_id<br/>per-campaign table)]
    T -->|4. poll /state every 2s| P
    T -->|5. read snapshot via SQL| DB
    T -->|6. pay each holder (25 concurrent)| C
    T -->|7. read escrowRemaining| C
    T -->|8. DELETE pipeline + drop table| P
```

One HTTP request drives the entire lifecycle. No cron, no off-chain handoff:

1. The operator declares a distribution by `POST`ing `{ 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](/installation)
* [Foundry](https://book.getfoundry.sh/getting-started/installation) 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

```text theme={null}
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:

```bash theme={null}
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:

```typescript theme={null}
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:

```solidity theme={null}
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:

```solidity theme={null}
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()`:

```bash theme={null}
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:

```bash theme={null}
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:

```typescript theme={null}
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
};
```

<Tip>
  `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.
</Tip>

## 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:

```bash theme={null}
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

```bash theme={null}
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`):

```bash theme={null}
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:

```bash theme={null}
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:

```bash theme={null}
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

* [Compose introduction](/compose/introduction)
* [Task triggers](/compose/task-triggers)
* [EVM wallets & gas sponsoring](/compose/context/evm/wallets)
* [Turbo job-mode pipelines](/turbo-pipelines/job-mode)
* [CMTAT IncomeVault](https://github.com/CMTA/IncomeVault), Swiss-bank reference implementation for tokenized-equity dividends. The example's event schema is anchored on this convention.
* [ERC-1726 Dividend-Paying Token](https://github.com/Roger-Wu/erc1726-dividend-paying-token)
* [GitHub repository](https://github.com/goldsky-io/documentation-examples/tree/main/compose/corporate-actions)
