Skip to main content
The Create and track participations recipe covers the SDK surface for reading and creating participations. This recipe walks through the end-to-end UX flow a user goes through to invest in an offer, mirroring the implementation in partner-demo’s useInvestViewModel.tsx. The Passage SDK is responsible for recording the participation (and verifying the on-chain approval). Your app is responsible for connecting the user’s wallet, collecting their input, and submitting the ERC-20 approve transaction.

Prerequisites

  • Completed OAuth with a working CoinListProvider
  • An OfferDetail and the OfferOption the user picked (from Display offer details)
  • A wallet integration. The partner demo uses wagmi + Reown AppKit. Anything that gives you a connected EVM address, balance reads, and writeContract/waitForTransactionReceipt works.

Flow at a glance

  1. idle - user picks the funding asset (USDC, USDT, etc. from offerDetail.fundingAssets) and enters an amount.
  2. awaiting_wallet - validate input, ensure the wallet is on Ethereum mainnet, then send the ERC-20 approve transaction. The wallet popup is open.
  3. confirming_tx - the user signed; wait for waitForTransactionReceipt.
  4. recording - call coinlist.createParticipation({ ..., approvalTransactionHash }). The Passage backend reads the on-chain allowance to confirm before recording.
  5. success / error - redirect on success; surface a user-readable message on error.

Step 1: Connect the wallet

The wallet UI is yours. From the SDK’s point of view all you need is a connected EVM address and the means to submit transactions on Ethereum mainnet. With wagmi + Reown AppKit that’s a few hooks:
"use client";

import { useAppKit, useAppKitAccount } from "@reown/appkit/react";
import { useChainId, useSwitchChain } from "wagmi";

const ETHEREUM_MAINNET = 1;

export function useWallet() {
  const { open } = useAppKit();
  const { address, isConnected } = useAppKitAccount();
  const chainId = useChainId();
  const { mutateAsync: switchChain } = useSwitchChain();
  return { address, isConnected, chainId, switchChain, open };
}
See src/lib/providers/WalletConnectProvider.tsx in the partner demo for the AppKit/wagmi setup.

Step 2: Validate and submit the approval transaction

The approve call grants the funding contract permission to pull the funding asset (USDC, USDT, …) from the wallet later. It does not move funds. Before showing the wallet popup, sanity-check that the user has enough ETH for gas - otherwise the transaction will fail on-chain after the user signs.
"use client";

import { erc20Abi, parseUnits } from "viem";
import { waitForTransactionReceipt } from "@wagmi/core";
import { useConfig, useWriteContract, useBalance } from "wagmi";

// Minimum ETH to cover gas for the approve tx.
const MIN_ETH_FOR_GAS = parseUnits("0.001", 18);

async function sendApproval({
  amount,                  // user input, e.g. "1000"
  payWithAssetId,          // selected funding asset id
  investAssetId,           // offerDetail.asset.id
  address,                 // connected wallet address
  writeContract,           // from wagmi's useWriteContract
  wagmiConfig,             // from wagmi's useConfig
  onSubmitted,             // called once the wallet confirms
}: SendApprovalParams) {
  const approvalAmount = parseUnits(amount.trim(), decimals(payWithAssetId));

  // 1) Wallet popup; resolves with the tx hash after the user confirms.
  const txHash = await writeContract({
    address: assetContract(payWithAssetId),       // USDC/USDT contract
    abi: erc20Abi,
    functionName: "approve",
    args: [fundingContract(investAssetId), approvalAmount],
  });

  onSubmitted(); // UI can switch from "awaiting_wallet" -> "confirming_tx"

  // 2) Wait for the tx to be mined. Passage verifies the allowance on-chain
  //    before recording the participation, so we have to confirm first.
  await waitForTransactionReceipt(wagmiConfig, { hash: txHash });

  return txHash;
}
assetContract(...) and fundingContract(...) are partner-side helpers that map a Passage AssetId to the right ERC-20 contract address and the matching Passage funding contract address. See src/types/coinlist.ts in the partner demo.
If the wallet isn’t on mainnet, prompt the network switch first - wagmi’s useSwitchChain triggers the wallet’s native “Switch Network” dialog:
if (chainId !== ETHEREUM_MAINNET) {
  await switchChain({ chainId: ETHEREUM_MAINNET });
}

Step 3: Record the participation

Once the approval is mined, call createParticipation with the tx hash. The hash is what lets Passage verify on-chain that the allowance actually exists before recording the participation.
"use client";

import { useCoinList } from "@coinlist-co/react";
import { WalletAddress } from "@coinlist-co/react/shared";

const { coinlist } = useCoinList();

await coinlist.createParticipation({
  offerId,                                  // OfferId from OfferDetail
  offerOptionId: option.id,                 // selected OfferOption.id
  chain: "ethereum",
  walletAddress: WalletAddress(address),
  amount,                                   // same string the user entered
  assetId: payWithAssetId,                  // funding asset (USDC, USDT, …)
  approvalTransactionHash: approvalTxHash,  // from step 2
});
If the backend can’t see the on-chain allowance yet (block propagation, rate limit, etc.), createParticipation rejects - surface that as a retryable error rather than a hard failure.

Wiring it together

The full state machine plus error handling lives in useInvestViewModel.tsx. The skeleton is:
const handleSignAndCommit = async () => {
  if (!isConnected || !address || !payWithAssetId) return;

  const validationError = validateSubmit(amount, ethBalance?.value);
  if (validationError) {
    setSubmitState("error");
    setSubmitError(validationError);
    return;
  }

  setSubmitState("awaiting_wallet");
  setSubmitError(null);

  try {
    await ensureMainnet(chainId, switchChain);

    const approvalTxHash = await sendApproval({
      // …
      onSubmitted: () => setSubmitState("confirming_tx"),
    });

    setSubmitState("recording");
    await coinlist.createParticipation({
      offerId,
      offerOptionId: option.id,
      chain: "ethereum",
      walletAddress: WalletAddress(address),
      amount,
      assetId: payWithAssetId,
      approvalTransactionHash: approvalTxHash,
    });

    setSubmitState("success");
    router.push(routes.offerDetail(offerId));
  } catch (err) {
    setSubmitState("error");
    setSubmitError(err instanceof Error ? err.message : "Please try again.");
  }
};

Reflecting status back in the UI

After a successful createParticipation, the user lands back on the offer detail page. Use useParticipations(offerId) to fetch the user’s participations for that offer and render their status (pending, completed, failed, remitted, etc.). The participation moves through statuses asynchronously - the SDK doesn’t push; you re-fetch on session refresh or after a user action.

Common questions

Passage settles the funding move on its own contracts, on its own schedule, after on-chain verification. The user only signs an allowance up to the amount they want to invest; nothing leaves their wallet until the Passage backend later pulls it. This pattern is cancellable (the user can revoke the allowance) and lets Passage batch transfers.
No - any EVM wallet integration that gives you address, balance reads, writeContract, and a way to wait for a tx receipt works. The partner demo uses wagmi + Reown AppKit because it’s a maintained, framework-agnostic option. The SDK only sees the resulting tx hash.
Prompt a network switch before calling writeContract. With wagmi, useSwitchChain triggers the wallet’s native switch dialog. The partner demo bails out via ensureMainnet(currentChainId, switchChain) before submitting the approval.
Gas is paid by the user in ETH, like any other on-chain transaction. The partner demo enforces a minimum ETH balance check (0.001 ETH) before opening the wallet popup so the user gets a clear error instead of a failed transaction.
The flow generalises: connect on the right chain, approve the right ERC-20 contract for the matching funding contract, then call createParticipation with the resulting tx hash. Your account manager will confirm which chains and assets are enabled for your offers.

Next step

Display participation status

Use useParticipations(offerId) or CoinListClient.fetchParticipations() to render the user’s participation status after they invest.