# Wallets (/docs/build/wallets)



Members can connect a crypto wallet in your app. That opens up quests such as "Link your wallet", "Hold 10 USDC on Base" or "Hold 1,000 tokens for a week", and lets members sign in with a wallet instead of an email address.

## What members see [#what-members-see]

A member picks their wallet, such as Phantom or MetaMask, and signs a one-time message in it. The message says it does not authorize any transaction or transfer, so linking never moves funds. Domino checks the signature and adds the wallet to the member's list for your project. Where the project enables it, members can also [sign in with a wallet](#sign-in-with-a-wallet).

## What your team sees [#what-your-team-sees]

Each member's page in Console lists their wallets and the balances quests recorded for them. Your team can unlink a wallet, restore one, or stop a wallet from signing in, for example when its key leaks. See [What operators see](#what-operators-see).

| Chain family | Wallets                                                                                         | Address Domino stores                                           |
| ------------ | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| Solana       | Phantom, Solflare, Backpack, Wallet Standard                                                    | Base58                                                          |
| EVM          | MetaMask, Rabby and other injected EVM wallets, and smart-contract wallets such as Base Account | EIP-55 checksummed `0x…`                                        |
| Cosmos SDK   | Keplr, Leap, Cosmostation                                                                       | Lowercase bech32 with the chain's prefix (`cosmos1…`, `osmo1…`) |

An ordinary EVM wallet links once for every EVM chain: the signature proves control of the key behind the address, not of a network.

A smart-contract wallet, such as Base Account or a Privy or Dynamic smart wallet, is a contract rather than a key, so only its chain can check its signature. Domino asks the chain the wallet was on when the member signed, whether the wallet is deployed there yet or not. The wallet links as the account at its address on that chain only, because a contract at the same address on another chain can belong to someone else. Quests read its balances, NFTs, events and contract calls on that chain, tracker records name it there, and payout lists name it with that chain.

> [!DEV]
> A deployed smart wallet's signature is checked with ERC-1271 `isValidSignature`, and a wallet not deployed yet signs an ERC-6492 wrapper, which Domino checks by simulating the deployment. Both use one `eth_call` on the chain in the message's Chain ID line: the `chainId` the signer reports (`evmSigner` asks the wallet with `eth_chainId`). The linked wallet's `provedOn` is that chain's CAIP-2 ID, such as `eip155:8453`; an ordinary wallet's signature is checked without a network call and has no `provedOn`.

A Cosmos wallet links on a chain your app names, such as Cosmos Hub. Domino has no list of Cosmos chains, so your app can offer any of them.

> [!DEV]
> Name a Cosmos chain by its chain ID, such as `cosmoshub-4`: any chain ID of up to 32 letters, digits, `-` and `_` works, with any bech32 prefix. Chain IDs are CAIP-2 references, so a chain ID with a dot, or longer than 32 characters, cannot link. The wallet's `network` is the chain's CAIP-2 ID, such as `cosmos:cosmoshub-4`.

> [!WARNING] A Cosmos wallet's `network` is not proof
> `network` is the chain the app asked the wallet to sign for. Domino does not check it: the signature proves control of the address, and any app can link an address under any `cosmos:<chain ID>`. Gate on the address instead; see [Check wallets in a quest](#check-wallets-in-a-quest).

## Link a wallet [#link-a-wallet]

### 1. Find the member's wallets [#1-find-the-members-wallets]

`@domino-sdk/relay/browser` discovers installed wallets and wraps each in a signer. It has no wallet SDK dependency.

```ts
import {
  createBrowserRelayClient,
  discoverCosmosWallets,
  discoverEvmWallets,
  discoverSolanaWallets,
} from "@domino-sdk/relay/browser"

const relay = createBrowserRelayClient({ baseUrl: "/relay" })
const evm = await discoverEvmWallets()
const solana = discoverSolanaWallets()
const cosmos = discoverCosmosWallets({ chainId: "cosmoshub-4" })
```

Each entry has a `name`, an optional `icon`, and a `signer`. Show them however your app likes. Cosmos wallets sign on the chain you pass, as `{ chainId, chainInfo? }` or as its CAIP-2 ID (`"cosmos:cosmoshub-4"`); call `discoverCosmosWallets` once per chain your app offers.

### 2. Sign and link [#2-sign-and-link]

```ts
import { createBrowserRelayClient, discoverEvmWallets } from "@domino-sdk/relay/browser"

const relay = createBrowserRelayClient({ baseUrl: "/relay" })
const [metamask] = await discoverEvmWallets()

if (metamask) {
  const wallet = await relay.wallets.link(metamask.signer)
  console.log(wallet.address)
}
```

The member's wallet shows a sign-in style message for your app's domain. The message states that it does not authorize any transaction or transfer. `link` returns the linked wallet with its canonical address.

In React, use the hooks instead:

```tsx
import { useWalletDiscovery, useWallets } from "@domino-sdk/relay/react"

function LinkWallets() {
  const { evm, solana, cosmos } = useWalletDiscovery({ cosmos: { chainId: "cosmoshub-4" } })
  const { data: wallets, link, unlink, actionError, isPending } = useWallets()

  return (
    <>
      {[...evm, ...solana, ...cosmos].map((found) => (
        <button key={found.id} disabled={isPending} onClick={() => link(found.signer)}>
          Link {found.name}
        </button>
      ))}
      {wallets?.map((wallet) => (
        <button key={wallet.address} disabled={isPending} onClick={() => unlink(wallet)}>
          Unlink {wallet.address}
        </button>
      ))}
      {actionError && <p role="alert">{actionError.message}</p>}
    </>
  )
}
```

`useWalletDiscovery()` lists Cosmos wallets only for the chains in `cosmos`: one chain or an array of them.

> [!NOTE] Chains the wallet does not know
> Keplr, Leap and Cosmostation refuse a chain they have not been told about. Pass the chain's Keplr `ChainInfo` as `chainInfo`: when the wallet answers that it does not know the chain, the signer suggests it with `experimentalSuggestChain(chainInfo)`, and the member approves it in the wallet. Without `chainInfo`, connecting fails with "Keplr doesn't know the Cosmos chain example-1. Add the chain to the wallet, or pass its chainInfo so the wallet can suggest it." A wallet without `experimentalSuggestChain` fails with "… doesn't know the Cosmos chain example-1 and cannot add chains." Other refusals, such as the member rejecting the connection, fail as the wallet reports them, and nothing is suggested.

```ts
import { discoverCosmosWallets } from "@domino-sdk/relay/browser"

/** Keplr's ChainInfo for your chain: RPC and REST endpoints, bech32 prefixes, currencies. */
declare const exampleChainInfo: { chainId: "example-1" } & Record<string, unknown>

const [keplr] = discoverCosmosWallets({ chainId: "example-1", chainInfo: exampleChainInfo })
```

### 3. Show the linked wallets [#3-show-the-linked-wallets]

`relay.wallets.list()` returns the member's wallets for this project, with `chain`, `network`, `address` and `verifiedAt`, and `provedOn` for a smart-contract wallet.

## Bring your own wallet provider [#bring-your-own-wallet-provider]

Discovery finds only the wallets a browser extension injects into the page. Members who connect through WalletConnect, Reown AppKit, or an embedded wallet such as Privy's, Dynamic's or Para's need your app to hand Domino that wallet itself. Wrap the provider your wallet SDK gives you in a signer, then link or sign in with it as above.

| Your wallet SDK gives you                                                                                                                                           | Wrap it with                         |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
| An EIP-1193 provider, such as WalletConnect's `EthereumProvider`, AppKit's `useAppKitProvider("eip155").walletProvider` or a Privy wallet's `getEthereumProvider()` | `evmSigner(provider, { name })`      |
| A Solana provider with `connect()`, `publicKey` and `signMessage(bytes)`, as Phantom injects it                                                                     | `solanaSigner(provider, { name })`   |
| A Wallet Standard wallet                                                                                                                                            | `walletStandardSolanaSigner(wallet)` |
| A Keplr-style Cosmos wallet                                                                                                                                         | `cosmosSigner(provider, chain)`      |
| Only a function that signs a message                                                                                                                                | Your own `WalletSigner`              |

```ts
import {
  createBrowserRelayClient,
  evmSigner,
  type Eip1193Provider,
} from "@domino-sdk/relay/browser"

// For example WalletConnect's: await EthereumProvider.init({ projectId, ... })
declare const provider: Eip1193Provider

const relay = createBrowserRelayClient({ baseUrl: "/relay" })
const wallet = await relay.wallets.link(evmSigner(provider, { name: "WalletConnect" }))
```

`evmSigner` asks the provider for `eth_requestAccounts`, `eth_chainId` and `personal_sign`, and it follows `accountsChanged` when the provider emits it. A Solana provider whose `signMessage` takes only the message fits `solanaSigner` once you wrap it: `solanaSigner({ connect: async () => ({ publicKey: provider.publicKey }), publicKey: provider.publicKey, signMessage: (message) => provider.signMessage(message) })`.

> [!DEV] Write your own signer
> A `WalletSigner` (a type from `@domino-sdk/relay`) has the wallet's `chain` (`"eip155"`, `"solana"` or `"cosmos"`, with `network` for Cosmos), an optional `name`, `address()`, which connects if needed and returns the address, and `sign(message, address)`, which signs the exact UTF-8 message and returns `{ signature }`. EVM signatures are `0x` and 130 hex digits, Solana signatures base58, and Cosmos signatures base64 with the base64 compressed `publicKey`. The login-provider recipes build one from [Dynamic's](/docs/build/dynamic#3-link-the-embedded-wallet-for-payouts) and [Para's](/docs/build/para#3-link-the-embedded-wallet-for-payouts) `signMessage`.

Embedded wallets your app's login vouches for already count for wallet quests, but leaderboard payout lists leave them out until the member signs once. The recipes for [Privy](/docs/build/privy), [Dynamic](/docs/build/dynamic) and [Para](/docs/build/para) show that step, and [Reown AppKit](/docs/build/reown) signs members in with the wallet AppKit connects.

## Limits [#limits]

* **Five per chain family.** A member can have five Solana, five EVM and five Cosmos wallets linked to a project; the five Cosmos wallets can be on any chains. Linking a sixth fails until they unlink one. Sign-in wallets do not count.
* **One wallet per address.** The same Cosmos key has a different address on chains with different prefixes (`cosmos1…`, `osmo1…`), so it links as separate wallets, each counting toward the five. Chains that share a prefix share the address: linking it again on another chain ID moves the wallet to that chain.
* **One member per address.** An address linked by one member cannot be linked by another, even after it is unlinked.
* **Linking is not sign-in.** A wallet linked while signed in cannot sign in unless an operator enables it.
* **Smart-contract wallets on supported chains.** A smart-contract wallet links or signs in only on an EVM chain Domino reads balances on (`GET /management/v1/wallet-balance-chains`); on another chain it fails with "Domino cannot check smart-wallet signatures on eip155:…". Switch the wallet to a supported chain and connect again.
* **A check the chain didn't answer can be retried.** When the chain doesn't answer, linking or signing in fails with "Couldn't check the wallet's signature on Base. Try again." (502 `unavailable`), nothing links, and the same signature can be sent again for the same challenge. A wrong signer fails with "The wallet signature does not match this challenge", as for any wallet.
* **Ten smart-wallet checks per member.** Each member, or each client when signing in, gets ten smart-wallet signature checks in 10 minutes, and a project environment 600, because each one calls the chain. Ordinary wallets do not count.
* **Accounts that sign only.** 32-byte Cosmos module and contract addresses, and Ethermint-key Cosmos wallets (such as Evmos and Injective), cannot link.
* **Per project and environment.** Each project and environment has its own list.

## Unlink a wallet [#unlink-a-wallet]

```ts
import { createBrowserRelayClient } from "@domino-sdk/relay/browser"

const relay = createBrowserRelayClient({ baseUrl: "/relay" })
const [wallet] = await relay.wallets.list()

if (wallet) {
  await relay.wallets.unlink(wallet)
}
```

The wallet leaves the project's list, and new quest attempts no longer see it. The address stays with the member, who can link it again.

## Check wallets in a quest [#check-wallets-in-a-quest]

A quest can require a linked wallet, such as a Cosmos Hub wallet, before it pays.

> [!DEV]
> Evaluators read the list with `ctx.wallet.list()`. The list is fixed at each evaluation run's first wallet read. Links and unlinks do not change that run, and its retries reuse the list. A scheduled run after defer or progress reads the current links. It is `null`, never empty, when the member has linked no wallet.

```text
import { defineQuest, settings, manual, once, points, decision } from "@domino-sdk/relay/authoring"
import { community } from "./ledgers"

export default defineQuest({
  key: "link-cosmos-hub-wallet",
  title: "Link your Cosmos Hub wallet",
  settings: settings.object({}),
  trigger: manual(),
  completion: once(),
  rewards: [points(community, 25)],
  async evaluate({ wallet }) {
    const wallets = await wallet.list()

    // `network` is the chain the app requested, not proof. The address is: Cosmos Hub accounts start with cosmos1.
    // null when the member has linked no wallet.
    return wallets?.some((linked) => linked.chain === "cosmos" && linked.address.startsWith("cosmos1"))
      ? decision.accept()
      : decision.reject({ reason: "Link a Cosmos Hub wallet, then try again." })
  },
})
```

Publish the imported `community` ledger in the same project. A `null` list means the member has linked no wallet.

Gate a Cosmos wallet on its address, never on its `network`: any app can link an address under any chain ID. The bech32 prefix tells you which chains the address belongs to, and chains that share a prefix share the address. Keep your own list of the chains you accept with their prefixes, check the address against it, and read the chain itself for balances or activity.

## Check token balances [#check-token-balances]

Reward members for holding a token, such as 10 USDC on Base or a minimum of your community's own token. Domino reads the balance of the member's linked wallets when the quest checks it.

`ctx.wallet.balance()` reads a balance for the member's linked wallets on one chain:

| Chain              | `chain`                                                      | `token`                                            |
| ------------------ | ------------------------------------------------------------ | -------------------------------------------------- |
| Solana mainnet     | `"solana"`                                                   | An SPL or Token-2022 mint, or omit it for SOL      |
| An EVM chain       | Its CAIP-2 ID, such as `"eip155:8453"` for Base              | An ERC-20 contract, or omit it for the native coin |
| A Cosmos SDK chain | Its CAIP-2 ID, such as `"cosmos:cosmoshub-4"` for Cosmos Hub | A bank denomination, such as `"uatom"`; required   |

Domino reads balances on the chains `/wallet-balance-chains` lists. For EVM, that is the networks, mainnets and testnets alike, on the chain list of Domino's RPC provider, dRPC; a network dRPC adds later is readable only once Domino updates the list. For Cosmos, it is Cosmos Hub, Osmosis and any other Cosmos SDK chain Domino has configured. A Cosmos read covers only the wallets linked on that exact chain ID. List the supported chains, with their names, from a server or the CLI:

```sh
domino api GET /wallet-balance-chains
```

The answer is `{ chains: [{ chain, name, testnet }] }`: Solana mainnet first, then the Cosmos chains, then each EVM chain by chain ID. In code, call `management.walletBalanceChains()`; it needs `read` permission. An EVM or Cosmos chain that is not in the list throws a `CapabilityError` with code `author-error`.

Domino reads every balance through endpoints it chooses: you can't give it your own RPC or REST URL. If your community's Cosmos chain isn't listed, contact Domino support to add it. For a chain Domino doesn't read, or on-chain state other than a token balance, run your own indexer and send its records to a [custom tracker source](trackers.md#feed-another-source-including-a-blockchain-indexer), keyed by [wallet account](trackers.md#wallet-accounts); quests then read those records.

### 1. Install the snippet [#1-install-the-snippet]

```sh
pnpm dlx shadcn@4.21.0 add https://snippets.domino.run/r/token-holder.json
```

It adds `quests/token-holder.quest.ts`, which accepts when the member's linked wallets together hold at least a minimum. Its settings are the chain, the token address and the minimum, which Console shows in whole tokens. The default is 10 USDC on Base.

> [!DEV]
> The chain is a `settings.chain()` setting and the minimum a `settings.amount()` setting, stored in base units.

### 2. Or write the check yourself [#2-or-write-the-check-yourself]

```text
import { defineQuest, settings, manual, once, points, decision, parseUnits } from "@domino-sdk/relay/authoring"
import { community } from "./ledgers"

export default defineQuest({
  key: "hold-usdc-on-base",
  title: "Hold 10 USDC on Base",
  settings: settings.object({}),
  trigger: manual(),
  completion: once(),
  rewards: [points(community, 50)],
  async evaluate({ wallet }) {
    const held = await wallet.balance({
      chain: "eip155:8453",
      token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
    })

    // null when the member has linked no wallet of the collection's chain (here, EVM).
    if (!held) {
      return decision.reject({ reason: "Link an EVM wallet first." })
    }

    if (held.decimals === null) {
      return decision.review({ reason: "The token reports no decimals." })
    }

    return BigInt(held.total) >= parseUnits("10", held.decimals)
      ? decision.accept()
      : decision.reject({ reason: "Hold at least 10 USDC, then try again." })
  },
})
```

The result gives amounts as integer strings in the token's smallest unit: `total` across the wallets read, and each wallet's `amount`. `parseUnits` and `formatUnits` convert whole-token amounts.

To let operators choose the chain and the minimum, use `settings.chain({ label, default })` and `settings.amount({ label, default, decimals })`. A chain setting holds `"solana"`, Solana mainnet's CAIP-2 ID or an EVM chain ID such as `"eip155:8453"`, and is typed as the query's `chain`. An amount setting holds a base-unit integer string, such as `"10000000"` for 10 USDC, so compare it with `BigInt(held.total) >= BigInt(settings.minimum)`; with `decimals`, Console shows and edits it in whole tokens. Publication and Console edits refuse any other value.

### What the read covers [#what-the-read-covers]

* **All linked wallets of the chain's family.** An EVM chain reads every linked EVM wallet; Solana reads every linked Solana wallet. A Cosmos chain reads only wallets linked on that exact network. Pass `address` to read one of them. Any other address throws a `CapabilityError` with code `author-error` and the message "Read only the member's linked wallets".
* **No wallet is not a zero balance to check.** With no wallet of that family, `ctx.wallet.balance()` returns `null`. Check for it to ask the member to link one.
* **Failures are never ineligibility.** A chain Domino does not read, a malformed token identifier, a Solana account that is not a token mint, an EVM address with no contract and an NFT contract (use [`ctx.wallet.nfts()`](#check-nft-holdings) for those) throw a `CapabilityError` with code `author-error`. A provider outage throws one with code `provider-unavailable`, which Domino retries if your code lets it through. None of them reads as a zero balance. See [evaluator readers](/docs/reference/verification#evaluator-readers-null-errors-and-pinning).
* **Balances at verification time.** Each read is recorded with the attempt, so a retried verification reuses it. It is one of the evaluation's eight capability calls. It doesn't show the member kept holding: for that, see [Reward holding for days](#reward-holding-for-days).
* **Token identifiers come from settings.** Never take a token address or denomination from the member's input: they could point the quest at a token they created.

> [!DEV]
> EVM reads go through dRPC and need the platform's dRPC key. Without it, they fail with "EVM balance checks are not configured". Solana and Cosmos reads need no project key. Cosmos reads use platform-owned HTTPS REST endpoints, verify the chain ID, and pin every bank and metadata query to one block. Missing denomination metadata returns `decimals: null`; a provider failure never becomes a zero balance.

Use `ctx.wallet.balance({ chain: "solana" })` for the total SOL balance of linked Solana wallets; pass `address` to select one. See [wallet balances](/docs/reference/verification#wallet-balances).

## Reward holding for days [#reward-holding-for-days]

For "Hold 1,000 tokens for a week", Domino samples the balance of every wallet members linked once a day, at a time members can't predict. On EVM and Cosmos chains every wallet is read at one block, so tokens passed between wallets don't count twice; Solana can't read a past slot, so there each read only has to reach the slot the sample started at. Each day a member's wallet holds the minimum fills one day of a 7-day streak. Your app runs no balance job.

```bash
pnpm dlx shadcn@4.21.0 add https://snippets.domino.run/r/token-hold-streak.json
```

The snippet adds a `wallet.balances()` tracker and a streak quest on it. A day whose samples all failed has no sample and breaks the streak, since Domino never writes a balance it didn't read. See [Reward holding tokens](trackers.md#reward-holding-tokens) for how sampling works and what each outcome means.

## Check NFT holdings [#check-nft-holdings]

Reward members for holding an NFT from your collection, on an EVM chain or on Solana. Domino counts the NFTs the member's linked wallets of that chain hold when the quest checks it.

A collection is written as a [CAIP-19](https://chainagnostic.org/CAIPs/caip-19) asset type:

| Collection                             | Written as                                                                                                  |
| -------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| ERC-721                                | `eip155:<chain ID>/erc721:<contract>`, such as `eip155:1/erc721:0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D` |
| One token ID of an ERC-1155 contract   | `eip155:<chain ID>/erc1155:<contract>/<token ID>`, such as `eip155:8453/erc1155:0x…/1`                      |
| Metaplex verified collection on Solana | `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/nft:<collection mint>`, the mint of the collection NFT             |

An EVM chain is one of the EVM chains `/wallet-balance-chains` lists. Cosmos NFTs are not read.

On Solana, an NFT counts when a linked Solana wallet holds it (a token account with exactly 1 of a mint with no decimals and a supply of 1) and its Metaplex metadata names your collection with `verified: true`. Only the collection's update authority or a delegate it approved can verify an NFT, so a collection field anyone else set is ignored. Compressed NFTs (cNFTs) and Metaplex Core assets are not counted.

### 1. Install the snippet [#1-install-the-snippet-1]

```sh
pnpm dlx shadcn@4.21.0 add https://snippets.domino.run/r/nft-holder.json
```

It adds `quests/nft-holder.quest.ts`, which accepts when the member's linked wallets of the collection's chain together hold at least one NFT from the collection. Its setting is the collection; the default is Bored Ape Yacht Club on Ethereum, so set it to yours.

### 2. Set the collection [#2-set-the-collection]

When you publish the quest, change the collection in Console, or unarchive the quest, Domino checks the collection on its chain. Make these changes in test; they reach live through a promotion. A wrong collection is refused there with a message that says what it is, so a typo never rejects members who hold the right NFT:

* **EVM:** Domino asks the contract which standard it implements. A token contract, or an ERC-1155 contract written as `erc721`, is refused.
* **Solana:** the mint must be a Metaplex collection NFT. A fungible token, a mint without Metaplex metadata, a print or limited edition, and an NFT that belongs to a collection (the message names the collection to use instead) are refused.

> [!DEV]
> The collection is a `settings.nft({ label, default })` setting, typed as the query's `collection`. Publication, Console edits and unarchiving refuse a value that is not a collection, a chain Domino does not read, an EVM contract that does not report the standard through ERC-165 `supportsInterface`, and a Solana mint that is not a Metaplex collection NFT. A Solana collection NFT qualifies as Token Metadata requires to verify members into it: a unique master edition (`max_supply` 0) whose token standard is `NonFungible` or `ProgrammableNonFungible` (or unset, on metadata written before token standards). Its metadata must also have collection details (a sized collection) or, for a legacy unsized collection, not be a verified member of another collection. On a platform without its dRPC key, every EVM collection is refused, because none can be checked.

### 3. Or write the check yourself [#3-or-write-the-check-yourself]

```text
import { defineQuest, settings, manual, once, points, decision } from "@domino-sdk/relay/authoring"
import { community } from "./ledgers"

export default defineQuest({
  key: "hold-our-nft",
  title: "Hold one of our NFTs",
  settings: settings.object({
    collection: settings.nft({
      label: "NFT collection",
      default: "eip155:8453/erc721:0x52908400098527886E0F7030069857D2E4169EE7",
    }),
  }),
  trigger: manual(),
  completion: once(),
  rewards: [points(community, 50)],
  async evaluate({ wallet, settings }) {
    // Relay supplies nfts() to every evaluation; its type is optional for older test doubles.
    const held = await wallet.nfts!({ collection: settings.collection })

    // null when the member has linked no EVM wallet.
    if (!held) {
      return decision.reject({ reason: "Link an EVM wallet first." })
    }

    return BigInt(held.total) >= 1n
      ? decision.accept()
      : decision.reject({ reason: "Hold one of our NFTs, then try again." })
  },
})
```

The result is `{ collection, chain, standard, total, wallets: [{ address, count }], at, observedAt }`: `total` is how many the wallets read hold together, and each wallet's `count` its own, as integer strings. For ERC-721 that is the contract's `balanceOf(owner)`; for ERC-1155, `balanceOf(owner, id)` for the collection's token ID; for a Solana collection (`standard` `"metaplex"`), the number of the collection's verified NFTs the wallet holds, read at a slot (`at.kind` `"slot"`). Pass `address` to read one linked wallet.

### What the NFT read covers [#what-the-nft-read-covers]

* **All linked wallets of the collection's chain family, or one of them.** EVM wallets for an EVM collection, Solana wallets for a Solana one. Any other `address` throws a `CapabilityError` with code `author-error`.
* **No wallet is not "holds none".** With no linked wallet of that family, `ctx.wallet.nfts()` returns `null`.
* **Failures are never ineligibility.** A malformed collection, a chain Domino does not read and a contract that rejects the read throw `author-error`; a provider outage throws `provider-unavailable`. None of them reads as holding none.
* **Counts at verification time.** Each read is recorded with the attempt, so a retried verification reuses it, and it is one of the evaluation's eight capability calls. It does not prove the member keeps holding the NFT, and it counts NFTs without listing their token IDs.

> [!DEV]
> EVM NFT reads go through dRPC like EVM balance reads and need the platform's dRPC key; without it they fail with `provider-unavailable`. Solana NFT reads use the same Solana RPC as Solana balance reads: the wallets' token accounts through `getTokenAccountsByOwner`, then the candidate mints and their metadata accounts in batched `getMultipleAccounts` calls. `ctx.wallet.nfts` is optional in the `WalletReader` type only so that test doubles written before it keep compiling: Domino supplies it to every evaluation and the testing kit answers it from `facts.wallet.nfts`.

## Check staking and contract state [#check-staking-and-contract-state]

Reward members who stake with you, hold voting power, or have a position in your vault. Pick the read by how the stake is recorded:

| How the stake is recorded                                                                                     | Read it with                                                                         |
| ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| Staking mints a receipt token, such as a liquid-staking token or ERC-4626 vault shares                        | [Token balances](#check-token-balances): `ctx.wallet.balance()` on the receipt token |
| A contract keeps it per address, such as `stakedOf(address)`, `getVotes(address)` or `userInfo(pid, address)` | [Contract state](#read-contract-state): `ctx.wallet.call()`                          |
| Native staking on a Cosmos SDK chain                                                                          | [Cosmos delegations](#check-cosmos-delegations): `ctx.wallet.delegations()`          |

### Read contract state [#read-contract-state]

Domino calls one read-only function of your contract for each of the member's linked EVM wallets, every call at the same block, and gives you each result.

#### 1. Install the snippet [#1-install-the-snippet-2]

```sh
pnpm dlx shadcn@4.21.0 add https://snippets.domino.run/r/contract-state.json
```

It adds `quests/contract-state.quest.ts`, which accepts when the function's values for the member's linked EVM wallets add up to at least a minimum. Its settings are the contract function and the minimum, which Console shows in whole tokens. The default is ENS voting power on Ethereum, so set it to your contract.

#### 2. Set the contract function [#2-set-the-contract-function]

The setting holds the contract and the function in one line: the chain, the contract address, and the function's signature, such as `eip155:8453:0x… function stakedOf(address) view returns (uint256)` on Base. The chain is one `/wallet-balance-chains` lists.

When you publish the quest, change the setting in Console, or unarchive the quest, Domino checks it. A function that can change state, or returns anything but one number, true/false value or address, is refused there, and so is a chain Domino does not read. Make these changes in test; they reach live through a promotion.

> [!DEV]
> The setting is `settings.contractCall({ label, default })`, typed as the query's `call`: the contract's CAIP-10 ID, a space, and a human-readable ABI signature. The function must be `view` or `pure`, take elementary parameters (`address`, `bool`, `string`, `bytes`, `bytesN`, `uintN`, `intN`), and return exactly one `uint`, `int`, `bool` or `address`; arrays, tuples and several return values are refused.

#### 3. Or write the check yourself [#3-or-write-the-check-yourself-1]

```text
import { defineQuest, settings, manual, once, points, decision } from "@domino-sdk/relay/authoring"
import { community } from "./ledgers"

export default defineQuest({
  key: "stake-in-pool",
  title: "Stake in our pool",
  settings: settings.object({
    staking: settings.contractCall({
      label: "Staking contract",
      default:
        "eip155:8453:0x52908400098527886E0F7030069857D2E4169EE7 function userInfo(uint256 pid, address user) view returns (uint256)",
    }),
    pool: settings.integer({ label: "Pool ID", default: 0, min: 0, max: 1000 }),
    minimum: settings.amount({ label: "Minimum stake", default: "1000000000000000000", decimals: 18 }),
  }),
  trigger: manual(),
  completion: once(),
  rewards: [points(community, 50)],
  async evaluate({ wallet, settings }) {
    // { wallet: true } is each linked EVM wallet's address.
    const staked = await wallet.call?.({
      call: settings.staking,
      args: [settings.pool, { wallet: true }],
    })

    // null when the member has linked no EVM wallet.
    if (!staked) {
      return decision.reject({ reason: "Link an EVM wallet first." })
    }

    return staked.total !== null && BigInt(staked.total) >= BigInt(settings.minimum)
      ? decision.accept()
      : decision.reject({ reason: "Stake at least the minimum, then try again." })
  },
})
```

The result is `{ call, chain, contract, returns, total, wallets: [{ address, value }], at, observedAt }`. Each wallet's `value` is a whole number as a string, `true` or `false`, or an address, as the function returns it. For a number, `total` is the wallets' sum; for anything else it is `null`. `at` is the block every call read. Pass `address` to call for one linked wallet.

#### What the contract read covers [#what-the-contract-read-covers]

* **The member's wallet goes in once.** `{ wallet: true }` stands for each linked wallet's address, in one `address` parameter. Every other argument comes from your settings or code, never from the member's input, and must fit its parameter.
* **No wallet is not "has no stake".** With no linked EVM wallet, `ctx.wallet.call()` returns `null`.
* **Failures are never ineligibility.** A revert throws a `CapabilityError` with code `author-error` and the contract's reason, and so do arguments that don't fit the function, an address with no contract, and data that isn't the declared type. A provider outage throws `provider-unavailable`. None of them reads as zero.
* **State at verification time.** Each read is recorded with the attempt, so a retried verification reuses it, and it is one of the evaluation's eight capability calls. It does not prove the member keeps the stake.

> [!DEV]
> Contract calls go through dRPC like EVM balance reads and need the platform's dRPC key: Domino reads the latest block number, then sends every `eth_call` at that block, in batches. `ctx.wallet.call` is optional in the `WalletReader` type only so that test doubles written before it keep compiling: Domino supplies it to every evaluation and the testing kit answers it from `facts.wallet.call`.

### Check Cosmos delegations [#check-cosmos-delegations]

Domino reads what the member's wallets linked on one Cosmos SDK chain delegate to validators, all at one block, in the chain's staking denomination.

#### 1. Install the snippet [#1-install-the-snippet-3]

```sh
pnpm dlx shadcn@4.21.0 add https://snippets.domino.run/r/cosmos-staker.json
```

It adds `quests/cosmos-staker.quest.ts`, which accepts when the member's wallets on the chain delegate at least a minimum, to any validator or to the one you set. Its settings are the chain (such as `cosmos:cosmoshub-4`), the validator's operator address (empty for any validator) and the minimum, which Console shows in whole tokens. The chain is one `/wallet-balance-chains` lists.

#### 2. Or write the check yourself [#2-or-write-the-check-yourself-1]

```text
import { defineQuest, settings, manual, once, points, decision } from "@domino-sdk/relay/authoring"
import { community } from "./ledgers"

export default defineQuest({
  key: "delegate-to-us",
  title: "Delegate to our validator",
  settings: settings.object({
    minimum: settings.amount({ label: "Minimum delegation", default: "1000000", decimals: 6 }),
  }),
  trigger: manual(),
  completion: once(),
  rewards: [points(community, 50)],
  async evaluate({ wallet, settings }) {
    const staked = await wallet.delegations!({
      chain: "cosmos:cosmoshub-4",
      validator: "cosmosvaloper1qyqszqgpqyqszqgpqyqszqgpqyqszqgph84tp0",
    })

    // null when the member has linked no wallet on this chain.
    if (!staked) {
      return decision.reject({ reason: "Link a Cosmos Hub wallet first." })
    }

    return BigInt(staked.total) >= BigInt(settings.minimum)
      ? decision.accept()
      : decision.reject({ reason: "Delegate at least the minimum, then try again." })
  },
})
```

The result is `{ chain, denom, validator, total, wallets: [{ address, amount }], validators: [{ validator, amount }], at, observedAt }`. `denom` is the chain's staking denomination, such as `uatom`, and every amount is a whole number of its base units as a string: `total` for the wallets together, each wallet's own, and each validator's. With `validator`, only delegations to that validator count. Pass `address` to read one linked wallet.

#### What the delegation read covers [#what-the-delegation-read-covers]

* **Wallets linked on that exact chain.** A wallet linked on another Cosmos chain is not read. Any other `address` throws a `CapabilityError` with code `author-error`.
* **No wallet is not "delegates nothing".** With no wallet on the chain, `ctx.wallet.delegations()` returns `null`.
* **Failures are never ineligibility.** A chain Domino does not read and a malformed validator address throw `author-error`; a provider outage throws `provider-unavailable`.
* **Bonded delegations at verification time.** Unbonding delegations, rewards and governance votes are not counted. Each read is recorded with the attempt and is one of the evaluation's eight capability calls.

> [!DEV]
> Delegation reads use the same platform-owned REST endpoint as Cosmos balances: the chain's `bond_denom` and each wallet's `/cosmos/staking/v1beta1/delegations/{delegator}`, every request pinned to the latest block's height. If the endpoint answers every request at one other height, that block is recorded; answers at different heights fail as an outage. `ctx.wallet.delegations` is optional in the `WalletReader` type for older test doubles; the testing kit answers it from `facts.wallet.delegations`.

## Check on-chain actions [#check-on-chain-actions]

The [on-chain actions reference](/docs/reference/onchain) lists every option and result field of the EVM and Solana reads this section and the next use.

Reward members for doing something with your contract on an EVM chain: swapping on your pool, minting from your collection, depositing into your vault, receiving a bridge transfer or voting on your Governor. Domino reads the events your contract emitted that name one of the member's linked wallets, so you need no indexer.

An action counts only when its event names the member's wallet in an **indexed** argument, such as `to` in an ERC-20 `Transfer` or `voter` in a Governor `VoteCast`. A swap or deposit made through a router or aggregator, where the member is only the transaction's sender and your contract's event names the router, cannot be checked this way.

### 1. Install the snippet [#1-install-the-snippet-4]

```sh
pnpm dlx shadcn@4.21.0 add https://snippets.domino.run/r/onchain-action.json
```

It adds `quests/onchain-action.quest.ts`, which accepts when one of the member's linked EVM wallets took part in a new matching event, and awards points once per event. Its settings are the event, the argument that names the member, and an optional minimum amount; the default is a USDC transfer of at least 1 USDC on Base, so set it to your contract.

### 2. Set the event [#2-set-the-event]

The event is your contract on its chain and the event's Solidity signature, with argument names and `indexed` markers:

```text
eip155:8453:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 Transfer(address indexed from, address indexed to, uint256 value)
```

When you publish the quest, change the event in Console, or unarchive the quest, Domino checks it. Make these changes in test; they reach live through a promotion. An event with no indexed address argument, a chain Domino does not read, a chain with no finalized or safe block, and an address with no contract are refused there.

> [!DEV]
> The event is a `settings.contractEvent({ label, default })` setting, typed as the query's `event`: a CAIP-10 contract, a space, and the signature (a leading `event` keyword is accepted). Every argument is named, at most three are indexed, and tuples and arrays of arrays are not supported. Publication, Console edits and unarchiving refuse a malformed event, a chain `/wallet-balance-chains` does not list, a chain whose RPC reports neither a `finalized` nor a `safe` block, and an address with no code. On a platform without its dRPC key, every event is refused, because none can be checked.

### 3. Or write the check yourself [#3-or-write-the-check-yourself-2]

```text
import { defineQuest, settings, manual, once, points, decision } from "@domino-sdk/relay/authoring"
import { community } from "./ledgers"

export default defineQuest({
  key: "vote-on-our-governor",
  title: "Vote on our proposal",
  settings: settings.object({
    vote: settings.contractEvent({
      label: "Vote event",
      default:
        "eip155:1:0x52908400098527886E0F7030069857D2E4169EE7 VoteCast(address indexed voter, uint256 proposalId, uint8 support, uint256 weight, string reason)",
    }),
  }),
  trigger: manual(),
  completion: once(),
  rewards: [points(community, 50)],
  async evaluate({ wallet, settings, now }) {
    const votes = await wallet.events?.({
      event: settings.vote,
      account: "voter",
      since: now - 7 * 24 * 60 * 60 * 1000,
      where: { support: "1" },
    })

    // null when the member has linked no EVM wallet.
    if (!votes) {
      return decision.reject({ reason: "Link an EVM wallet first." })
    }

    return votes.logs.length
      ? decision.accept()
      : decision.reject({ reason: "Vote for the proposal, then try again." })
  },
})
```

The result is `{ event, chain, contract, since, at, finality, complete, logs, observedAt }`. Each log is `{ wallet, block, timestamp, tx, logIndex, args }`, oldest first: `wallet` is the linked wallet the event names, `timestamp` the block's time in epoch milliseconds, and `args` every argument by name, with integers as decimal strings, addresses EIP-55 and bytes as lowercase hex. To reward each action once, key the completion on `` `${chain}:${tx}:${logIndex}` ``, as the snippet does.

`where` narrows the logs by other arguments: an equality (an address in any case, an integer in decimal, `true` or `false`, or hex bytes) or `{ gte }` for an integer at least a value, such as `{ value: { gte: settings.minimum } }`. `afterActivation: true` also skips events before the quest was first activated in the environment. Pass `address` to read one linked wallet.

### What the event read covers [#what-the-event-read-covers]

* **Finalized blocks only.** Domino reads up to the chain's `finalized` block, or its `safe` block on a chain that has no finality tag, so an event it counted is never undone by a reorganization. A transaction appears once its block is final, which takes about 13 minutes on Ethereum, longer on rollups such as Base, Optimism and Arbitrum that finalize after Ethereum does, and seconds to minutes on chains with fast finality. Tell members to try again later rather than reject them for good.
* **At most 30 days back.** `since` earlier than 30 days before the evaluation throws `author-error`.
* **At most 3,000,000 blocks and 1,000 logs.** A read over more than 3,000,000 blocks, or matching more than 1,000 logs before `where` filters non-indexed arguments, throws `author-error`. Pass `newest` to get the newest events instead, as described below. Without it, use a later `since`, or a `where` condition on an indexed argument.
* **3,000,000 blocks is less than 30 days on fast chains.** That's about 34 days of 1-second blocks. At the block times Domino measured in October 2026, it covers fewer than 30 days on these chains:

  | Chain           | Days |
  | --------------- | ---- |
  | Robinhood       | 3    |
  | Gravity         | 8    |
  | opBNB           | 8    |
  | Arbitrum One    | 9    |
  | Monad           | 10   |
  | Cronos          | 14   |
  | Plume           | 15   |
  | BNB Smart Chain | 15   |
  | Sei             | 16   |
  | Telos           | 17   |
  | Arc             | 17   |
  | Tempo           | 19   |
  | Playnance       | 20   |
  | Aurora          | 22   |
  | Stable          | 24   |
  | Moca            | 28   |

  Block times change, so a read near the limit can start failing. Use `newest` for a window this long.
* **`newest` returns a partial read instead of failing.** `newest: n`, from 1 to 1,000, returns at most the `n` newest matching events. Domino reads back from the newest block and stops at the block or log cap instead of throwing. `complete` is `true` when `logs` holds every matching event since `since`. It's `false` when older matching events were left out, or when the read stopped at a cap before reaching `since`. A read without `newest` returns all matching events with `complete: true`, or throws. Results recorded before Domino reported `complete` don't have it.
* **No wallet is not "no activity".** With no linked EVM wallet, `ctx.wallet.events()` returns `null`.
* **Failures are never ineligibility.** A malformed event, a chain Domino does not read, an `account` that is not an indexed address argument, a `where` on an unknown argument or of the wrong type, and an `address` the member never linked throw `author-error`; a provider outage throws `provider-unavailable`. None of them reads as no activity.
* **Read once per attempt.** Each read is recorded with the attempt, so a retried verification reuses it, and it is one of the evaluation's eight capability calls.

**Guard against farming.** An event is cheap to produce: anyone can send a small transfer, or split one deposit across many wallets. Set a minimum amount (`where` with `gte`), count only events after the member linked the wallet (the snippet compares each log's `timestamp` with the wallet's `verifiedAt`), keep the window recent, and cap how often one member can earn with a member budget.

> [!DEV]
> Event reads go through dRPC like EVM balance reads and need the platform's dRPC key. Domino estimates the first block from block times, then sends `eth_getLogs` over 10,000-block ranges, several at a time, halving a range the node refuses down to 100 blocks. `ctx.wallet.events` is optional in the `WalletReader` type only so that test doubles written before it keep compiling: Domino supplies it to every evaluation, and the testing kit answers it from `facts.wallet.events` without applying `since`, `where` or `newest`: answer a `newest` query with the logs and `complete` Domino would return.

## Check on-chain actions on Solana [#check-on-chain-actions-on-solana]

Reward members for using your Solana program: swapping into your token on Jupiter, depositing into your vault, or calling your program at all. Solana has no event logs, so Domino reads the linked wallet's own transactions instead: the ones that succeeded, are final, and called your program. Each one comes with the programs it called and how the wallet's balances changed, so most checks need no decoding. When your program publishes an Anchor IDL, Domino also decodes the instructions it was called with, so you can check their arguments.

| Quest                       | Check                                                                                           |
| --------------------------- | ----------------------------------------------------------------------------------------------- |
| Swapped into our token      | The transaction called the swap program, and the wallet's token went up while another went down |
| Deposited or staked         | The transaction called your program, and the wallet's token went down by at least an amount     |
| Used our program at all     | Any transaction that called your program                                                        |
| Staked for at least 90 days | The transaction called your program's `stake` instruction with a lock of 90 days or more        |

### 1. Install the snippet [#1-install-the-snippet-5]

```sh
pnpm dlx shadcn@4.21.0 add https://snippets.domino.run/r/solana-action.json
```

It adds `quests/solana-action.quest.ts`, which accepts when one of the member's linked Solana wallets signed a new transaction that called your program, and awards points once per transaction. Its settings are the program, an optional token mint the wallet must receive in that transaction, and a minimum amount; the default is a Jupiter swap that brings the wallet at least 1 USDC, so set it to your program and token. For a deposit or stake, where the wallet gives tokens up, its README shows how to check what the wallet spent instead.

### 2. Set the program [#2-set-the-program]

The program is its Solana address, such as Jupiter's:

```text
JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4
```

When you publish the quest, change the program in Console, or unarchive the quest, Domino checks it. Make these changes in test; they reach live through a promotion. An address with no account, and an account that is not a program (a wallet, a token mint, or an account your program owns), are refused there.

Publishing the quest or changing the program also stores the program's Anchor IDL, read from its on-chain IDL account, when it has one. Domino decodes the program's instructions with the stored IDL, so a later IDL upgrade on chain doesn't change a live quest until the next save. For a program without an on-chain IDL, open the quest in Console and paste its IDL under **Instruction decoding**: choose **Paste IDL**, paste the IDL's JSON, and choose **Use this IDL**. A program without any IDL still works; its transactions just come without decoded instructions.

> [!DEV]
> The program is a `settings.solanaProgram({ label, default })` setting (`picker: "solana-program"`), typed as the query's `program`: a base58 address of 32 to 44 characters (`isSolanaProgram()`). Publication, Console edits and unarchiving refuse a malformed address, an address with no account on Solana mainnet, and an account that is not executable; an RPC outage while checking is `503 unavailable`, not a refusal.
>
> The stored IDLs are the release's `solanaIdls`, by setting key: `{ program, source, idl }`, with `source` `"chain"` or `"pasted"`. Anchor IDLs in the legacy and the 0.30+ format are read; Shank and Codama IDLs are not. Domino reads the on-chain IDL account the program owns at `create_with_seed(find_program_address([], program), "anchor:idl", program)`. To paste one through the API, send `solanaIdls: { [settingKey]: idl }` with `POST /management/v1/releases/settings` (`quests.updateSettings()` in the server SDK); `null` removes a pasted IDL. A pasted IDL that is not an Anchor IDL, names another program in its `address`, or is larger than 512 KiB is `422 validation-failed`. An IDL the program publishes on chain takes precedence over a pasted one at the next save.

### 3. Or write the check yourself [#3-or-write-the-check-yourself-3]

```text
import { defineQuest, settings, manual, once, points, decision, received, swapped } from "@domino-sdk/relay/authoring"
import { community } from "./ledgers"

const usdc = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"

export default defineQuest({
  key: "swap-into-usdc",
  title: "Swap into USDC on Jupiter",
  settings: settings.object({
    program: settings.solanaProgram({
      label: "Swap program",
      default: "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4",
    }),
    minimum: settings.amount({ label: "Minimum USDC", default: "10000000", decimals: 6 }),
  }),
  trigger: manual(),
  completion: once(),
  rewards: [points(community, 50)],
  async evaluate({ wallet, settings, now }) {
    const read = await wallet.transactions?.({
      program: settings.program,
      since: now - 7 * 24 * 60 * 60 * 1000,
    })

    // null when the member has linked no Solana wallet.
    if (!read) {
      return decision.reject({ reason: "Link a Solana wallet first." })
    }

    const swap = read.transactions.find(
      (tx) =>
        tx.signer &&
        swapped(tx, { into: usdc }) &&
        BigInt(received(tx, usdc)) >= BigInt(settings.minimum)
    )

    return swap
      ? decision.accept()
      : decision.reject({ reason: "Swap at least 10 USDC on Jupiter, then try again." })
  },
})
```

The result is `{ program, chain, mint, since, at, complete, transactions, observedAt }`. Each transaction is `{ wallet, signature, slot, timestamp, signer, programs, balanceChanges }`, oldest first: `wallet` is the linked wallet whose history holds it, `timestamp` the block's time in epoch milliseconds, `signer` whether that wallet signed it, `programs` every program it called, and `balanceChanges` the wallet's changes as `{ mint, delta }`, with `mint` `"SOL"` for lamports and `delta` a signed integer string in base units. A transaction that touched two of the member's wallets appears once for each. To reward each action once, key the completion on the transaction's `signature`.

Four helpers from `@domino-sdk/relay/authoring` read one transaction:

| Helper                          | Returns                                                               |
| ------------------------------- | --------------------------------------------------------------------- |
| `received(tx, mint)`            | How much of `mint` the wallet gained, as an integer string, or `"0"`  |
| `spent(tx, mint)`               | How much of `mint` the wallet gave up, as an integer string, or `"0"` |
| `swapped(tx, { into?, from? })` | Whether one mint went down and a different one went up                |
| `invoked(tx, program)`          | Whether the transaction called `program`                              |

### 4. Check an instruction's arguments [#4-check-an-instructions-arguments]

When the program's setting has an IDL, each transaction also has `instructions`: the program's instructions in it, top-level and called by other programs, in call order, each `{ name, args, accounts }`. `instruction(tx, name)` from `@domino-sdk/relay/authoring` returns the arguments of the first one named `name`, or `null`. For "Staked for at least 90 days":

```ts
import { defineLedger, defineQuest, settings, manual, once, points, decision, instruction } from "@domino-sdk/relay/authoring"

const community = defineLedger({ key: "community", name: "Community" })

export default defineQuest({
  key: "stake-90-days",
  title: "Stake for at least 90 days",
  settings: settings.object({
    // Your Anchor staking program, whose IDL has a stake(amount, lock_days) instruction. Replace
    // this placeholder with its base58 address: publication checks that the program exists.
    program: settings.solanaProgram({
      label: "Staking program",
      default: "StakeProgram1111111111111111111111111111111",
    }),
  }),
  trigger: manual(),
  completion: once(),
  rewards: [points(community, 100)],
  async evaluate({ wallet, settings, now }) {
    const read = await wallet.transactions?.({
      program: settings.program,
      since: now - 7 * 24 * 60 * 60 * 1000,
    })

    if (!read) {
      return decision.reject({ reason: "Link a Solana wallet first." })
    }

    const staked = read.transactions.some(
      (tx) =>
        tx.signer && Number(instruction(tx, "stake")?.lock_days ?? 0) >= 90
    )

    return staked
      ? decision.accept()
      : decision.reject({ reason: "Stake with a lock of at least 90 days, then try again." })
  },
})
```

Names are spelled as the IDL spells them: snake\_case in a 0.30+ IDL (`lock_days`), camelCase in a legacy one (`lockDays`). An instruction the IDL doesn't describe is left out, never an error.

| In the IDL                                 | In `args`                                               |
| ------------------------------------------ | ------------------------------------------------------- |
| `u8` to `u32`, `i8` to `i32`, `f32`, `f64` | A number                                                |
| `u64`, `i64`, `u128`, `i128`               | A decimal string; compare with `BigInt()`               |
| `pubkey`                                   | A base58 address                                        |
| `string`, `bool`                           | A string, a boolean                                     |
| `bytes`, `vec`, an array                   | An array                                                |
| An option                                  | The value, or `null` when absent                        |
| A struct                                   | An object of its fields                                 |
| An enum                                    | `{ Variant: fields }`, `{ Variant: {} }` without fields |

`accounts` names each account the instruction lists, as the IDL names it, with its address: check `accounts` when the wallet must be the instruction's signer or owner, not only an account it touched.

Pass `mint` to read only the transactions that touched each wallet's associated token account for that token, usually far fewer than the wallet's whole history. `afterActivation: true` also skips transactions before the quest was first activated in the environment. Pass `address` to read one linked wallet.

### What the transaction read covers [#what-the-transaction-read-covers]

* **Finalized transactions only.** Domino reads at the `finalized` commitment, so a transaction it counted is never rolled back. A transaction appears about 13 seconds after it lands.
* **Successful transactions only.** A failed transaction is skipped, even if it called your program.
* **Called anywhere in the transaction.** A program called by another program, such as a pool Jupiter routes through, counts as called. Addresses loaded from lookup tables are included.
* **Balance changes are the wallet's own.** They come from the token accounts the wallet owns and from its SOL balance, which leaves out the fee the wallet paid but includes any rent, such as for opening a token account. A token that passed through an account the wallet doesn't own, such as an intermediate account of a route, doesn't show. A swap out of SOL usually wraps and unwraps it inside the transaction, so it shows as SOL going down; SOL the wallet keeps wrapped shows under the wrapped SOL mint, `So11111111111111111111111111111111111111112`.
* **Transactions that touched the wallet, whoever signed them.** A transfer someone else sent to the member's wallet, in a transaction that called your program, is returned too, with the tokens as received and `signer` false.
* **With `mint`, only the associated token account.** Tokens the wallet holds in another token account aren't read.
* **At most 30 days back.** `since` earlier than 30 days before the evaluation throws `author-error`.
* **At most 1,000 transactions per wallet.** A busy wallet with more than 1,000 successful transactions since `since` returns its newest 1,000 and `complete: false`; it doesn't throw. So does a wallet whose last 10,000 signatures, failed ones included, don't reach back to `since`. Use a later `since` or `mint` to read fewer.
* **No wallet is not "no activity".** With no linked Solana wallet, `ctx.wallet.transactions()` returns `null`.
* **Failures are never ineligibility.** A malformed program, an account that is not a program, a `mint` that is not a token mint, and an `address` the member never linked throw `author-error`; a provider outage throws `provider-unavailable`. None of them reads as no activity.
* **Read once per attempt.** Each read is recorded with the attempt, decoded instructions included, so a retried verification reuses it, and it is one of the evaluation's eight capability calls.

**Guard against farming.** A transaction is cheap: anyone can make a tiny swap, split one deposit across many wallets, or swap and send the output to a member's wallet. Check `signer` for anything that rewards an action, so a transaction counts only when the member's wallet signed it; `received()` alone also rewards transfers someone else sent. Opening a token account costs the wallet about 0.002 SOL of rent, so a swap out of SOL needs a minimum above that, not only `swapped()`. Require a minimum amount (`received(tx, mint)` compared with a `settings.amount()` value), count only transactions after the member linked the wallet (compare `timestamp` with the wallet's `verifiedAt`), keep the window recent, and cap how often one member can earn with a member budget.

> [!DEV]
> Transaction reads use the same Solana RPC as balance reads: dRPC's `solana` network where the platform has its dRPC key. Domino pages `getSignaturesForAddress` at `finalized` back to `since`, skipping failed signatures, then fetches each transaction with `getTransaction` (`jsonParsed`, `maxSupportedTransactionVersion: 1`), several requests at a time. `ctx.wallet.transactions` is optional in the `WalletReader` type only so that test doubles written before it keep compiling: Domino supplies it to every evaluation, and the testing kit answers it from `facts.wallet.transactions` (called with `{ program, since, afterActivation, mint, address }`) without applying `since`, `program` or `mint`. A transaction there without `signer` reads as `false`, as does one captured before Domino reported it. To test an instruction check, answer with transactions that carry `instructions`.

## Sign in with a wallet [#sign-in-with-a-wallet]

A visitor signs in, or signs up, by signing a message with their wallet. A new address creates a new member; the same wallet later signs them in again. The wallet joins the member's list for the project as a **sign-in wallet**.

### 1. Enable wallet sign-in for the project [#1-enable-wallet-sign-in-for-the-project]

In Console, open **Settings → Sign-in**. **Sign-in methods** lists every sign-in method: the OAuth providers and email code Domino configures, read-only, then **Wallet sign-in**. Under **Wallet sign-in**, check the wallet families members can use: **Solana**, **EVM** or **Cosmos SDK**. Choose **Save**. Changing it needs publish permission, and each environment has its own setting. **Cosmos SDK** accepts a Cosmos wallet on any chain: your app names the chains it offers, and Console reminds you while Cosmos is checked.

Agents and scripts use the management API:

```bash
echo '{ "wallets": ["solana", "eip155", "cosmos"] }' > sign-in.json
domino api PUT /participant-sign-in sign-in.json
```

Use only the three family codes shown above. Before upgrading to v1, replace any stored per-network Cosmos value such as `cosmos:cosmoshub-4` with `cosmos`; v1 rejects per-network codes in this project setting.

Wallet sign-in is off until a project enables it. Wallet addresses cost nothing to create, so gate rewards with quests rather than counting sign-ups.

If your app signs members in with a wallet and the test environment has no wallet family enabled, `domino stage` and `domino doctor --remote` warn and print the command that enables one. The build still stages.

### 2. Offer the sign-in options [#2-offer-the-sign-in-options]

`useSignInOptions()` lists every way a member can sign in to the project from this browser, as one flat list: the configured OAuth providers, the installed wallets of the families the project enabled, and email. Each option has an `id`, a `label` ("Google", "Phantom", "Email"), a `kind` (`"oauth"`, `"wallet"` or `"email"`) and an `icon` hint. OAuth and wallet options sign in with `start()`; the email option sends a code with `sendCode(email)` and signs in with `verifyCode(code)`. `signInProviders` lists the configured sign-in providers on their own, Telegram included, each signing in with `start()`: [Telegram](/docs/build/telegram#offer-telegram-login) shows the buttons.

```tsx
import { useState } from "react"
import { useSignInOptions } from "@domino-sdk/relay/react"

function SignIn() {
  const signIn = useSignInOptions({ cosmos: [{ chainId: "cosmoshub-4" }] })
  const [email, setEmail] = useState("")
  const [code, setCode] = useState("")

  if (!signIn.options) {
    return <p>Loading…</p>
  }

  const hint = signIn.unavailable.find((entry) => entry.reason === "no-wallet-installed")

  return (
    <>
      {signIn.options.map((option) =>
        option.kind === "email" ? (
          <form
            key={option.id}
            onSubmit={(event) => {
              event.preventDefault()
              void (signIn.codeSent ? option.verifyCode(code) : option.sendCode(email))
            }}
          >
            {signIn.codeSent ? (
              <input value={code} onChange={(event) => setCode(event.target.value)} />
            ) : (
              <input type="email" value={email} onChange={(event) => setEmail(event.target.value)} />
            )}
            <button disabled={signIn.isPending}>{signIn.codeSent ? "Sign in" : "Email me a code"}</button>
          </form>
        ) : (
          <button key={option.id} disabled={signIn.isPending} onClick={() => void option.start()}>
            Continue with {option.label}
          </button>
        )
      )}
      {hint && <p>{hint.message}</p>}
      {signIn.error && <p role="alert">{signIn.error.message}</p>}
    </>
  )
}
```

Cosmos wallets sign in on your app's chains: pass them as `cosmos`, each `{ chainId, chainInfo? }` or `"cosmos:<chain ID>"`, and Keplr, Leap or Cosmostation are listed once per chain. Without `cosmos`, the hook offers no Cosmos wallets even when the project enabled them.

> [!DEV]
> `options` is `null` until the project's methods are read. OAuth and email appear right away, and installed wallets join them when wallet discovery finishes; for a project that takes only wallets, `options` stays `null` until then. The options keep their identity across renders until the methods, the installed wallets or the chains change, so an effect can depend on them. Their actions never reject: a failure lands in `error`, a `RelayError`. When the methods cannot be read, the hook offers email alone and `methodsError`, also a `RelayError`, says why; the server still has the final say.

`unavailable` says what the project enabled but this app or browser cannot offer:

| `reason`                | When                                                                                                            | Who acts                                                               |
| ----------------------- | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `cosmos-chains-missing` | The project takes Cosmos wallets and the app named no chains.                                                   | The developer: pass `cosmos`.                                          |
| `cosmos-chains-invalid` | The project takes Cosmos wallets and a chain in `cosmos` is invalid, such as `"cosmoshub-4"` without `cosmos:`. | The developer: `message` says what is wrong. Other methods still load. |
| `no-wallet-installed`   | The project takes wallets and none is installed.                                                                | The member: `message` names the wallet families to install.            |

The starter apps build their sign-in on this hook, next to email and OAuth, and a project that enables only wallet sign-in can take members with them. Starters name no Cosmos chains: add yours to the `useSignInOptions()` call in `src/components/sign-in.tsx`. In development, a starter shows `cosmos-chains-missing` and `cosmos-chains-invalid` as a note; a production build never shows them to members.

When a member who signed in with an EVM wallet such as MetaMask or Rabby switches to another account in it, or locks it, the app signs them out and offers sign-in again. The page never keeps showing one account's points and quests while the wallet holds another, and the new account signs in only when the member chooses to. Picking the same account again changes nothing. Solana and Cosmos wallets are not followed this way.

> [!DEV]
> `RelayProvider` follows the wallet for sessions signed in with `useSignIn().withWallet(signer)` or a wallet option's `start()`: an `accountsChanged` event with a different address (compared case-insensitively) or an empty list signs out, as `useSignOut()` does. Pass `followWallet={false}` to `RelayProvider` to keep the session until the member signs out, if your app handles account changes itself. After a reload it finds the wallet again by name and checks `eth_accounts` without a prompt. Without React, `evmSigner(provider).watchAccounts(listener)` reports the accounts and returns the function that stops it; sign out with `relay.auth.signOut()` when they change.

Without React, `signInOptions(relay, { cosmos })` from `@domino-sdk/relay/browser` returns the same `{ options, signInProviders, unavailable, methodsError? }` once wallet discovery finishes, each option signing in with that client. Its actions reject when they fail, so catch them:

```ts
import { createBrowserRelayClient, signInOptions } from "@domino-sdk/relay/browser"

const relay = createBrowserRelayClient({ baseUrl: "/relay" })
const { options } = await signInOptions(relay)
const wallet = options.find((option) => option.kind === "wallet")

if (wallet?.kind === "wallet") {
  await wallet.start().catch((error) => console.error(error.message))
}
```

Apps with their own wallet UI can still pass a discovered wallet's signer to `relay.auth.wallet.signIn(signer)`; `relay.auth.providers().wallets` lists the families this project enabled.

### 3. Handle refusals [#3-handle-refusals]

| Error                                                                                               | Status | What to do                                                                                                                                                                    |
| --------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Wallet sign-in is not enabled for this project                                                      | 403    | Enable the wallet's family in Console, or hide that wallet.                                                                                                                   |
| This wallet is linked to an account but is not a sign-in method. Sign in with your original method. | 409    | The member linked this wallet while signed in. They sign in their original way.                                                                                               |
| Sign out before switching accounts                                                                  | 409    | Sign out first.                                                                                                                                                               |
| Too many sign-in requests. Try again later.                                                         | 429    | Wait: every ten minutes, thirty per IP address (an IPv6 address counts by its /64), ten per wallet address from one IP address, and thirty per wallet address in the project. |
| The wallet signature does not match this challenge                                                  | 400    | Sign again with the same wallet and account.                                                                                                                                  |

A challenge lasts five minutes and works once. `signIn` is not retried automatically: if it fails, start again.

## Show a generated avatar [#show-a-generated-avatar]

`defaultAvatar(seed)` draws a deterministic SVG avatar and returns a data URI. Seed it with the `avatarSeed` Domino returns for the signed-in member: Domino derives it from the member, so linking wallets does not change it, and it reveals no address.

```tsx
import { defaultAvatar } from "@domino-sdk/relay/avatar"
import { useMe } from "@domino-sdk/relay/react"

function Avatar() {
  const me = useMe()

  return me.status === "ready" ? (
    <img src={defaultAvatar(me.data.avatarSeed)} alt="" width={32} height={32} />
  ) : null
}
```

Outside React, `relay.me.get()` returns `{ member, avatarSeed, email, emails, clock }`. After wallet-only sign-in, `email` is null and `emails` is empty. [Member profiles](/docs/build/profiles) use the same seed for their generated avatars.

> [!WARNING] Never seed shared avatars from an address
> Anyone can compute the avatar of any address, so an avatar seeded from a wallet address shows which member holds it. Use `walletAvatarSeed(wallet)` only in a member's own view of their wallets.

## What operators see [#what-operators-see]

A member's page in Console lists their linked wallets by chain family, with a Cosmos wallet's chain ID (such as **Cosmos · cosmoshub-4**), marked **Sign-in wallet** or **Linked wallet**. **Recent balance checks** lists the balances quest verifications recorded for them: the amount, the token and chain, and a link to the attempt. These are saved observations, not live balances. **Unlinked** lists earlier wallets and who unlinked them. An operator with review permission on the project can, for a wallet in the project's list and with a reason the audit log records:

* **Unlink** a wallet. Unlinking does not stop a sign-in wallet from signing in. A wallet marked **Imported** or **Vouched by** that the member never signed in with or proved is released instead: it leaves the member, with nothing to relink, and its owner can link it.
* **Relink** an unlinked wallet. See [restore a retained wallet link](#restore-a-retained-wallet-link).
* **Disable sign-in** for a wallet, for example when its key leaks. It can no longer sign in to the project in that environment; sessions it already started end within 8 hours. Console warns when it is the member's only sign-in method.
* **Enable sign-in** for a wallet the member linked, when you are sure they control it.

### Restore a retained wallet link [#restore-a-retained-wallet-link]

Under the member's **Wallets**, open **Unlinked**, choose **Relink** and record why its ownership is safe to restore. Review permission is required. The wallet must already belong to this member in this project and environment. Relink preserves the original proof and first-link time. It leaves wallet sign-in unchanged; use **Enable sign-in** or **Disable sign-in** separately.

Relinking also gives tracker activity that was skipped while the wallet was unlinked a fresh decision, under each quest's current history policy, and keeps attempts already admitted. See [historical activity](/docs/concepts/trackers#historical-activity).

> [!DEV]
> Scripts read `management.members.identityStates.list(member)`, then call `identityStates.relink(member, { identity: { kind: "wallet", chain, address }, expectedRevision, reason })`. `identityStates.unlink` takes the same input, and `identityStates.setSignIn(member, { identity, enabled, reason })` turns sign-in on or off. See [operator identity changes](/docs/concepts/participants#operator-identity-changes).
