> For the complete documentation index, see [llms.txt](https://zerc20.gitbook.io/zerc20/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://zerc20.gitbook.io/zerc20/for-developers/quickstart/receiving.md).

# Receiving

Receiving zERC20 tokens is a two-phase process:

1. **Authorize + Scan**: Authenticate with the ICP stealth storage canister, decrypt your VetKey, and scan for announcements addressed to you.
2. **Redeem**: Collect on-chain context for eligible transfers, generate a zero-knowledge proof, and submit the redeem transaction to mint tokens.

## Step 1: Create Authorization Payload

Build a time-limited authorization payload that will be signed by the recipient's wallet. The `ttlSeconds` parameter controls how long the authorization remains valid.

```typescript
import { createAuthorizationPayload } from "@zerc20/client-sdk";

const payload = await createAuthorizationPayload(
  client,          // StealthCanisterClient
  address,         // recipient EVM address
  ttlSeconds,      // optional, defaults to a sensible value
);
```

**Signature:**

```typescript
function createAuthorizationPayload(
  client: StealthCanisterClient,
  address: string,
  ttlSeconds?: number,
): Promise<AuthorizationPayload>;
```

**AuthorizationPayload:**

| Field              | Type         | Description                                            |
| ------------------ | ------------ | ------------------------------------------------------ |
| `message`          | `string`     | Human-readable message to display in the wallet prompt |
| `canonicalMessage` | `Uint8Array` | Canonical form used for on-canister verification       |
| `expiryNs`         | `bigint`     | Expiry timestamp in nanoseconds                        |
| `nonce`            | `bigint`     | Random nonce to prevent replay attacks                 |
| `transport`        | `object`     | Ephemeral transport key pair for VetKey decryption     |

## Step 2: Sign the Authorization Message

Sign the authorization message using EIP-191 personal sign. Any standard wallet client works.

```typescript
const signature = await walletClient.signMessage({
  message: payload.message,
});
```

The wallet will display `payload.message` to the user for approval. The resulting signature is used in the next step to prove ownership of the address.

## Step 3: Request VetKey

Submit the signed authorization to the ICP canister to retrieve your VetKey. The VetKey is encrypted in transit using the ephemeral transport key pair from Step 1 and decrypted locally -- the canister never sees the plaintext key.

```typescript
import { requestVetKey } from "@zerc20/client-sdk";

const vetKey = await requestVetKey(
  client,     // StealthCanisterClient
  address,    // recipient EVM address
  payload,    // AuthorizationPayload from Step 1
  signature,  // Uint8Array, raw signature bytes
);
```

**Signature:**

```typescript
function requestVetKey(
  client: StealthCanisterClient,
  address: string,
  payload: AuthorizationPayload,
  signature: Uint8Array,
): Promise<VetKey>;
```

The returned `VetKey` is used to decrypt announcements stored on ICP.

## Step 4: Scan for Announcements

Scan the stealth storage canister for announcements that can be decrypted with your VetKey. Each announcement corresponds to a private transfer sent to you.

Use `scanReceivingsForRecipient()` for production receive flows. It verifies the decoded `generalRecipient.address` against the wallet address you expect and skips payloads for other recipients. The older `scanReceivings()` API is still exported for compatibility but is deprecated because it does not perform this recipient check.

```typescript
import { scanReceivingsForRecipient } from "@zerc20/client-sdk";

const announcements = await scanReceivingsForRecipient({
  client,             // StealthCanisterClient
  vetKey,             // VetKey from Step 3
  expectedRecipientAddress: address,
  pageSize: 100,      // optional, default: 100
  startAfter: undefined, // optional, resume from a previous scan
  tag: undefined,     // optional, filter by tag
});
```

**Signature:**

```typescript
function scanReceivingsForRecipient(
  params: ScanReceivingsForRecipientParams,
): Promise<ScannedAnnouncement[]>;
```

**ScanReceivingsForRecipientParams:**

| Field                      | Type                    | Required | Description                                     |
| -------------------------- | ----------------------- | -------- | ----------------------------------------------- |
| `client`                   | `StealthCanisterClient` | Yes      | ICP canister client                             |
| `vetKey`                   | `VetKey`                | Yes      | Decryption key from Step 3                      |
| `expectedRecipientAddress` | `string`                | Yes      | EVM address that decoded payloads must target   |
| `pageSize`                 | `number`                | No       | Number of announcements per page (default: 100) |
| `startAfter`               | `bigint \| undefined`   | No       | Announcement ID to resume scanning after        |
| `tag`                      | `string \| undefined`   | No       | Filter announcements by tag                     |

### Time-cursor scanning

If your app persists the storage canister's time cursor and resumes scans across calls with `startAfterTs`, use `scanReceivingsByTimeForRecipient()`. It applies the same recipient-address validation as `scanReceivingsForRecipient()`, skips announcements for other recipients, and skips malformed decoded recipient addresses without failing the whole scan.

```typescript
import { scanReceivingsByTimeForRecipient } from "@zerc20/client-sdk";

const announcements = await scanReceivingsByTimeForRecipient({
  client,             // StealthCanisterClient
  vetKey,             // VetKey from Step 3
  expectedRecipientAddress: address,
  pageSize: 100,      // optional, default: 100
  startAfterTs: lastSeenCreatedAtNs, // optional, resume from a saved time cursor
  tag: undefined,     // optional, filter by tag
});
```

The SDK advances through canister pages with `listAnnouncementsByTime` / `nextTs` internally until the canister returns `null`; it does not return one page at a time. Use the returned announcements' `createdAtNs` values, together with your own scan state, to decide which time cursor to persist for the next scan. The return value contains only announcements that matched `expectedRecipientAddress`, so if the result is empty, or if the final canister page contained only skipped announcements, the SDK does not expose a newer cursor to persist. In that case, keep your previous cursor and scan again later.

**Signature:**

```typescript
function scanReceivingsByTimeForRecipient(
  params: ScanReceivingsByTimeForRecipientParams,
): Promise<ScannedAnnouncement[]>;
```

**ScanReceivingsByTimeForRecipientParams:**

| Field                      | Type                    | Required | Description                                                   |
| -------------------------- | ----------------------- | -------- | ------------------------------------------------------------- |
| `client`                   | `StealthCanisterClient` | Yes      | ICP canister client                                           |
| `vetKey`                   | `VetKey`                | Yes      | Decryption key from Step 3                                    |
| `expectedRecipientAddress` | `string`                | Yes      | EVM address that decoded payloads must target                 |
| `pageSize`                 | `number`                | No       | Number of announcements per page (default: 100)               |
| `startAfterTs`             | `bigint \| undefined`   | No       | Announcement `createdAtNs` timestamp to resume scanning after |
| `tag`                      | `string \| undefined`   | No       | Filter announcements by tag                                   |

**ScannedAnnouncement:**

| Field              | Type     | Description                                   |
| ------------------ | -------- | --------------------------------------------- |
| `id`               | `bigint` | Unique announcement identifier                |
| `burnAddress`      | `string` | Truncated burn address (on-chain destination) |
| `fullBurnAddress`  | `string` | Full burn address before truncation           |
| `createdAtNs`      | `bigint` | Creation timestamp in nanoseconds             |
| `recipientChainId` | `bigint` | Chain ID where the recipient will redeem      |

## Step 5: Collect Redeem Context

For each scanned announcement, collect the on-chain context needed to generate a redemption proof. This queries the indexer and contracts to determine which transfers are eligible for redemption.

```typescript
import { collectRedeemContext, createVerifierReader } from "@zerc20/client-sdk";

// Create a verifier contract reader from an EvmReadProvider
const verifierContract = createVerifierReader(readProvider, entry.verifierAddress);

const redeemContext = await collectRedeemContext({
  burn,               // BurnArtifacts derived from the scanned announcement
  tokens,             // token configuration (TokenEntry[])
  hub,                // HubEntry from normalizeTokens
  verifierContract,   // ReadableVerifierContract from createVerifierReader
  indexerUrl,         // indexer endpoint URL
  indexerFetchLimit,  // optional, max events per indexer request
  eventBlockSpan,     // optional, block range per scan
  teleportMode: "global", // optional, default
});
```

**Creating the Verifier Contract:**

Use `createVerifierReader()` to build a `ReadableVerifierContract` from any `EvmReadProvider`. This is the recommended approach -- it works with viem, ethers.js, or any provider that implements the `EvmReadProvider` interface:

```typescript
import { createVerifierReader } from "@zerc20/client-sdk";

const verifierContract = createVerifierReader(readProvider, entry.verifierAddress);
```

**Signature:**

```typescript
function collectRedeemContext(
  params: RedeemContextParams,
): Promise<RedeemContext>;
```

**RedeemContextParams:**

| Field                      | Type                                           | Required    | Description                                                                                                                   |
| -------------------------- | ---------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `burn`                     | `BurnArtifacts`                                | Yes         | Burn artifacts for the announcement                                                                                           |
| `tokens`                   | `TokenEntry[]`                                 | Yes         | Token configuration                                                                                                           |
| `hub`                      | `HubEntry`                                     | Global only | Hub contract address or config; required when `teleportMode` is omitted or `"global"`, optional and ignored in `"local"` mode |
| `verifierContract`         | `ReadableVerifierContract`                     | Yes         | Verifier contract reader                                                                                                      |
| `indexerUrl`               | `string`                                       | Yes         | Indexer HTTP endpoint                                                                                                         |
| `indexerFetchLimit`        | `number`                                       | No          | Max events per indexer request. Defaults to `20`; maximum is `100` for SDK receive flows.                                     |
| `eventBlockSpan`           | `bigint \| number`                             | No          | Block range per event scan                                                                                                    |
| `chainErrorPolicy`         | `"fail-fast" \| "log-and-continue"`            | No          | Per-chain indexer error policy; defaults to `"fail-fast"`                                                                     |
| `teleportMode`             | `"global" \| "local"`                          | No          | Root/proof mode; defaults to `"global"`                                                                                       |
| `verifierContractsByChain` | `ReadonlyMap<bigint, LocalRootReaderContract>` | Local only  | Required for `teleportMode: "local"`, ignored in `"global"` mode                                                              |

`indexerFetchLimit` is capped at `100` because receive flows fetch local Merkle proofs after fetching events, and proof requests accept at most 100 leaf indices. See [Indexer API Reference](/zerc20/for-developers/indexer-api.md) for direct indexer API limits.

**RedeemContext:**

| Field                 | Type                  | Description                                                                                  |
| --------------------- | --------------------- | -------------------------------------------------------------------------------------------- |
| `token`               | `TokenInfo`           | Resolved token metadata                                                                      |
| `aggregationState`    | `AggregationState`    | Current Hub aggregation snapshot                                                             |
| `events`              | `object`              | Contains `eligible` and `ineligible` event arrays                                            |
| `globalProofs`        | `GlobalProof[]`       | Global Merkle proofs for eligible events                                                     |
| `eligibleProofs`      | `EligibleProof[]`     | Per-event proofs ready for ZKP generation                                                    |
| `totalEligibleValue`  | `bigint`              | Sum of values that can be redeemed now                                                       |
| `totalPendingValue`   | `bigint`              | Sum of values not yet included in a proven root                                              |
| `totalIndexedValue`   | `bigint`              | Sum of all indexed values for this burn address                                              |
| `totalTeleported`     | `bigint`              | Amount already teleported (minted) to the recipient                                          |
| `redeemFeeBps`        | `number`              | Current Verifier redeem fee in basis points                                                  |
| `feeRecipient`        | `string`              | Wallet authorized to withdraw accrued Verifier redeem fees                                   |
| `redeemableDiff`      | `bigint`              | New redeemable delta before redeem / relayer fee deductions                                  |
| `redeemFee`           | `bigint`              | Verifier redeem fee for `redeemableDiff`                                                     |
| `redeemNetValue`      | `bigint`              | Redeemable value after Verifier redeem fee, before relayer fee                               |
| `redeemBlockedReason` | `string \| undefined` | `"amount_too_small"` when the Verifier would reject because the redeem fee consumes the diff |
| `chains`              | `ChainBreakdown[]`    | Per-chain breakdown of eligible and pending values                                           |

* **Eligible events**: Transfers whose Merkle roots have been proven on-chain and aggregated by the Hub. These can be redeemed immediately.
* **Ineligible events**: Transfers that are indexed but whose roots are not yet proven or aggregated. These will become eligible once the indexer and cross-chain job catch up.

### Global vs local redeem context

The default `teleportMode: "global"` path reads the Hub aggregation tree. This is the normal cross-chain redeem path and produces calldata with `isGlobal=true` and `rootHint=latestAggSeq`.

For local-only single-chain tokens, pass `teleportMode: "local"` and provide a Verifier local-root reader for each token chain:

```typescript
import {
  collectRedeemContext,
  createLocalRootReader,
  createVerifierReader,
} from "@zerc20/client-sdk";

const verifierContractsByChain = new Map(
  tokens.map((token) => [
    token.chainId,
    createLocalRootReader(readProvidersByChain.get(token.chainId)!, token.verifierAddress),
  ]),
);

const redeemContext = await collectRedeemContext({
  burn,
  tokens,
  verifierContract: createVerifierReader(destinationReadProvider, entry.verifierAddress),
  indexerUrl,
  teleportMode: "local",
  verifierContractsByChain,
});
```

In local mode the SDK skips the Hub aggregation read and synthesizes `aggregationState` from each Verifier's `latestProvedIndex` and `provedTransferRoots`, so `hub` is not required. In global mode, including the default mode when `teleportMode` is omitted, `hub` is required and the SDK reads aggregation state from the Hub. Transaction preparation in local mode then uses `isGlobal=false` and the local transfer `treeIndex` as `rootHint`.

Current local redeem transaction preparation is intentionally limited to eligible events from a single chain, and that chain must match `redeemContext.token.chainId`. Use global mode for multi-chain redeem orchestration.

Redeem fee data comes from the destination Verifier:

* `redeemFeeBps` is read from `redeemFeeBps()`
* `redeemFee` is read from `quoteRedeemFee(redeemableDiff)`
* `redeemableDiff` is `totalEligibleValue - totalTeleported`, clamped to `0`
* `redeemNetValue` is `redeemableDiff - redeemFee`, clamped to `0`

SDK `0.5.0+` expects supported destination Verifiers to expose redeem-fee views. Missing methods, no-data responses, RPC failures, and other redeem-fee read failures are surfaced as errors instead of falling back to `0` bps or zero sentinels. Callers should retry or surface the read failure before showing a redeem quote or preparing a transaction.

Use `redeemNetValue` for UI display and gasless redeem affordability checks. For direct redeem, block submission when `redeemBlockedReason === "amount_too_small"`.

## Step 6: Prepare and Submit Redeem Transaction

The SDK provides a two-step prepare/submit pattern for redeeming. `prepareRedeemTransactionForRecipient()` validates the recipient address, generates the zero-knowledge proof, and assembles the transaction data. `submitRedeemTransaction()` signs and submits it on-chain.

```typescript
import {
  prepareRedeemTransactionForRecipient,
  submitRedeemTransaction,
} from "@zerc20/client-sdk";

// Prepare: generates proof and builds the transaction object
const redeemTx = await prepareRedeemTransactionForRecipient({
  redeemContext,
  burn,                       // BurnArtifacts
  expectedRecipientAddress: address,
  teleportProofClient: sdk.teleportProofs,
  decider: sdk.decider,       // optional, required for batch proofs
  teleportMode: "global",     // optional; use "local" only with a local-mode context
});

// Submit: signs and sends the transaction on-chain
const { transactionHash } = await submitRedeemTransaction({
  writeProvider,              // EvmWriteProvider
  tx: redeemTx,               // RedeemTransaction from prepare step
  readProvider,               // optional: for receipt polling
  feeOverrides,               // optional: gas-price overrides
});
```

The SDK automatically selects the proof mode based on the number of eligible events:

* **1 eligible event** -- uses Groth16 single proof via `Verifier.singleTeleport()`
* **Multiple eligible events** -- uses Nova batch proof via `Verifier.teleport()` (requires a Decider)

### Optional: Gasless redeem

If you want a relay node to submit the redeem transaction on the user's behalf, attach a `RelayerFeeAuthorization` when preparing the transaction. This lets the user cap the fee the relay node may keep while avoiding the need to hold native gas on the destination chain.

```typescript
import {
  estimateRelayFeeVerified,
  prepareRedeemTransactionForRecipient,
  submitRelayTeleport,
  type RelayerFeeAuthorization,
} from "@zerc20/client-sdk";

// Use the token-scoped relay URL for the token being redeemed.
const relayUrl = "https://v1.mainnet.api.zerc20.io/relayer/zusdc";

const feeQuote = await estimateRelayFeeVerified({
  relayUrl,
  chainId: Number(redeemContext.token.chainId),
  verifierContract, // destination Verifier reader from collectRedeemContext setup
});

const maxRelayerFee = (feeQuote.relayerFee * 12n) / 10n; // example 20% quote buffer

const feeAuth: RelayerFeeAuthorization = {
  relayerFee: feeQuote.relayerFee,
  maxFee: maxRelayerFee,
  deadline: 1_700_000_000n,
  signature: "0x...",
};

const redeemTx = await prepareRedeemTransactionForRecipient({
  redeemContext,
  burn,
  expectedRecipientAddress: address,
  teleportProofClient: sdk.teleportProofs,
  decider: sdk.decider,
  relayerFeeAuth: feeAuth,
  teleportMode: "global",
});

const isGlobal = (redeemTx.args[0] as boolean);
const rootHint = redeemTx.args[1] as bigint;

await submitRelayTeleport(relayUrl, {
  isSingle: redeemTx.mode === "single",
  isGlobal,
  rootHint,
  chainId: redeemTx.args[2].chainId,
  recipient: redeemTx.args[2].recipient,
  tweak: redeemTx.args[2].tweak,
  proof: redeemTx.args[3] as `0x${string}`,
  relayerFee: feeAuth.relayerFee,
  maxFee: feeAuth.maxFee,
  redeemFeeBps: feeQuote.redeemFeeBps,
  deadline: feeAuth.deadline,
  signature: feeAuth.signature,
});
```

Practical notes:

* `relayerFeeAuth` is optional. Omit it for the normal direct-wallet redeem flow.
* `relayUrl` must include the token suffix, such as `/relayer/zusdc`. The SDK appends `/relay/fee-estimate`, `/relay/teleport`, and other relay paths internally.
* Relay nodes expose a fee quote endpoint first. Use `estimateRelayFeeVerified()` before signing so the relay quote's `redeemFeeBps` is checked against the destination Verifier's on-chain `redeemFeeBps()`.
* If you already called `estimateRelayFee()`, pass that quote to `verifyRelayFeeEstimate()` before building the authorization.
* Relay nodes should read `redeemFeeBps` from the destination Verifier. If fee data cannot be read, if the relay omits `redeemFeeBps`, or if the quote does not match the on-chain value, treat the quote as unavailable and ask the user to retry instead of submitting with a zero-fee fallback.
* `RelayFeeEstimateMismatchError` guards before signing when the relay quote does not match the Verifier. `RedeemFeeBpsDriftError` guards at submit time if the Verifier redeem fee changes after quoting. In both cases, re-quote and ask the user to approve again.
* For gasless redeem, check that `redeemContext.redeemNetValue > maxFee`; otherwise the redeem amount cannot cover the relay fee after the protocol redeem fee.
* The relay returns a transaction hash after submission. This indicates broadcast, not final mined success.

### prepareRedeemTransactionForRecipient

```typescript
function prepareRedeemTransactionForRecipient(
  params: PrepareRedeemTransactionForRecipientParams,
): Promise<RedeemTransaction>;
```

**PrepareRedeemTransactionForRecipientParams:**

| Field                      | Type                      | Required | Description                                                                |
| -------------------------- | ------------------------- | -------- | -------------------------------------------------------------------------- |
| `redeemContext`            | `RedeemContext`           | Yes      | Context from `collectRedeemContext()`                                      |
| `burn`                     | `BurnArtifacts`           | Yes      | Burn artifacts for the announcement                                        |
| `expectedRecipientAddress` | `string`                  | Yes      | EVM address that `burn.generalRecipient.address` must match before proving |
| `teleportProofClient`      | `TeleportProofClient`     | Yes      | Proof generator (from `sdk.teleportProofs`)                                |
| `decider`                  | `HttpDeciderClient`       | No       | Required for batch proofs; omit for single-only                            |
| `relayerFeeAuth`           | `RelayerFeeAuthorization` | No       | Required only when preparing a gasless redeem                              |
| `teleportMode`             | `"global" \| "local"`     | No       | Defaults to `"global"`; must match the context/proof mode                  |

`prepareRedeemTransaction()` is still exported for compatibility, but it is deprecated. New callers should use `prepareRedeemTransactionForRecipient()` so a mismatched or malformed burn recipient fails before proof generation and calldata assembly.

**RedeemTransaction:**

| Field          | Type                             | Description                             |
| -------------- | -------------------------------- | --------------------------------------- |
| `mode`         | `"single" \| "batch"`            | Which proof mode was used               |
| `address`      | `Hex`                            | Verifier contract address               |
| `abi`          | `object`                         | Verifier ABI                            |
| `functionName` | `"singleTeleport" \| "teleport"` | Contract function to call               |
| `args`         | `readonly [...]`                 | Encoded arguments for the contract call |

### submitRedeemTransaction

```typescript
function submitRedeemTransaction(
  params: SubmitRedeemTransactionParams,
): Promise<{ transactionHash: Hex }>;
```

**SubmitRedeemTransactionParams:**

| Field           | Type                | Required | Description                                               |
| --------------- | ------------------- | -------- | --------------------------------------------------------- |
| `writeProvider` | `EvmWriteProvider`  | Yes      | Wallet provider to sign and send                          |
| `tx`            | `RedeemTransaction` | Yes      | Transaction from `prepareRedeemTransactionForRecipient()` |
| `feeOverrides`  | `FeeOverrides`      | No       | Optional gas-price overrides                              |
| `readProvider`  | `EvmReadProvider`   | No       | Provider for receipt polling                              |

### Batch Redeem with Pre-built Proof

If you already have a Decider proof (e.g., from an external service), use `buildBatchRedeemTransactionForRecipient()` to assemble the transaction directly while validating the recipient:

```typescript
import { buildBatchRedeemTransactionForRecipient } from "@zerc20/client-sdk";

const redeemTx = buildBatchRedeemTransactionForRecipient({
  redeemContext,
  burn,
  expectedRecipientAddress: address,
  deciderProof: proofBytes,  // Uint8Array from Decider
  teleportMode: "global",
});
```

`buildBatchRedeemTransaction()` remains available for older integrations but is deprecated because it does not validate the burn recipient.

## Status Checking

For a lighter-weight check that skips proof collection and generation, use `getAnnouncementStatus`. This is useful for displaying balances or polling for readiness without the overhead of `collectRedeemContext`.

```typescript
import { getAnnouncementStatus } from "@zerc20/client-sdk";

const status = await getAnnouncementStatus({
  burn,
  tokens,
  hub,
  verifierContract,
  indexerUrl,
  teleportMode: "global",
});
```

**Signature:**

```typescript
function getAnnouncementStatus(
  params: AnnouncementStatusParams,
): Promise<AnnouncementStatus>;
```

`getAnnouncementStatus()` accepts the same `teleportMode` and `verifierContractsByChain` fields as `collectRedeemContext`. In local mode it skips the Hub aggregation fetch and checks eligibility against Verifier local proved roots.

**AnnouncementStatus:**

| Field                 | Type                  | Description                                                                                  |
| --------------------- | --------------------- | -------------------------------------------------------------------------------------------- |
| `totalEligibleValue`  | `bigint`              | Sum of values that can be redeemed now                                                       |
| `totalPendingValue`   | `bigint`              | Sum of values not yet included in a proven root                                              |
| `totalIndexedValue`   | `bigint`              | Sum of all indexed values for this burn address                                              |
| `totalTeleported`     | `bigint`              | Amount already teleported (minted) to the recipient                                          |
| `redeemFeeBps`        | `number`              | Current Verifier redeem fee in basis points                                                  |
| `feeRecipient`        | `string`              | Wallet authorized to withdraw accrued Verifier redeem fees                                   |
| `redeemableDiff`      | `bigint`              | New redeemable delta before redeem / relayer fee deductions                                  |
| `redeemFee`           | `bigint`              | Verifier redeem fee for `redeemableDiff`                                                     |
| `redeemNetValue`      | `bigint`              | Redeemable value after Verifier redeem fee, before relayer fee                               |
| `redeemBlockedReason` | `string \| undefined` | `"amount_too_small"` when the Verifier would reject because the redeem fee consumes the diff |
