Wallets

Let members link Solana, EVM and Cosmos wallets, sign in with them, and earn rewards for holding tokens and NFTs, staking, or transactions on your contracts.

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

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.

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.

Chain familyWalletsAddress Domino stores
SolanaPhantom, Solflare, Backpack, Wallet StandardBase58
EVMMetaMask, Rabby and other injected EVM walletsEIP-55 checksummed 0x…
Cosmos SDKKeplr, Leap, CosmostationLowercase bech32 with the chain's prefix (cosmos1…, osmo1…)

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

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.

Find the member's wallets

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

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.

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:

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.

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 })

Show the linked wallets

relay.wallets.list() returns the member's wallets for this project, with chain, network, address and verifiedAt.

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 youWrap 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 itsolanaSigner(provider, { name })
A Wallet Standard walletwalletStandardSolanaSigner(wallet)
A Keplr-style Cosmos walletcosmosSigner(provider, chain)
Only a function that signs a messageYour own WalletSigner
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) }).

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, Dynamic and Para show that step, and Reown AppKit signs members in with the wallet AppKit connects.

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.
  • Wallets that sign as accounts only. Smart-contract wallets, 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.
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

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

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

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:

Chainchaintoken
Solana mainnet"solana"An SPL or Token-2022 mint, or omit it for SOL
An EVM chainIts CAIP-2 ID, such as "eip155:8453" for BaseAn ERC-20 contract, or omit it for the native coin
A Cosmos SDK chainIts CAIP-2 ID, such as "cosmos:cosmoshub-4" for Cosmos HubA 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:

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, keyed by wallet account; quests then read those records.

Install the snippet

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.

Or write the check yourself

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

  • 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() 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.
  • 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.
  • 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.

Use ctx.wallet.balance({ chain: "solana" }) for the total SOL balance of linked Solana wallets; pass address to select one. See wallet balances.

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.

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 for how sampling works and what each outcome means.

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 asset type:

CollectionWritten as
ERC-721eip155:<chain ID>/erc721:<contract>, such as eip155:1/erc721:0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D
One token ID of an ERC-1155 contracteip155:<chain ID>/erc1155:<contract>/<token ID>, such as eip155:8453/erc1155:0x…/1
Metaplex verified collection on Solanasolana: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.

Install the snippet

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.

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.

Or write the check yourself

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

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

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 recordedRead it with
Staking mints a receipt token, such as a liquid-staking token or ERC-4626 vault sharesToken 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: ctx.wallet.call()
Native staking on a Cosmos SDK chainCosmos delegations: ctx.wallet.delegations()

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.

Install the snippet

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.

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.

Or write the check yourself

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

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

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.

Install the snippet

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.

Or write the check yourself

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

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

Check on-chain actions

The on-chain actions reference 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.

Install the snippet

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.

Set the event

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

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.

Or write the check yourself

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

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

    ChainDays
    Robinhood3
    Gravity8
    opBNB8
    Arbitrum One9
    Monad10
    Cronos14
    Plume15
    BNB Smart Chain15
    Sei16
    Telos17
    Arc17
    Tempo19
    Playnance20
    Aurora22
    Stable24
    Moca28

    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.

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.

QuestCheck
Swapped into our tokenThe transaction called the swap program, and the wallet's token went up while another went down
Deposited or stakedThe transaction called your program, and the wallet's token went down by at least an amount
Used our program at allAny transaction that called your program
Staked for at least 90 daysThe transaction called your program's stake instruction with a lock of 90 days or more

Install the snippet

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.

Set the program

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

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.

Or write the check yourself

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:

HelperReturns
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

Check an instruction's 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":

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 IDLIn args
u8 to u32, i8 to i32, f32, f64A number
u64, i64, u128, i128A decimal string; compare with BigInt()
pubkeyA base58 address
string, boolA string, a boolean
bytes, vec, an arrayAn array
An optionThe value, or null when absent
A structAn 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

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

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.

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:

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.

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 shows the buttons.

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.

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

reasonWhenWho acts
cosmos-chains-missingThe project takes Cosmos wallets and the app named no chains.The developer: pass cosmos.
cosmos-chains-invalidThe 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-installedThe 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.

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:

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.

Handle refusals

ErrorStatusWhat to do
Wallet sign-in is not enabled for this project403Enable 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.409The member linked this wallet while signed in. They sign in their original way.
Sign out before switching accounts409Sign out first.
Too many sign-in requests. Try again later.429Wait: 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 challenge400Sign 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

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.

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 use the same seed for their generated avatars.

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

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.

On this page