← GUIDES
GUIDE · WITHDRAWALS

Wallet ownership verification — signature, micro-transfer, declaration

Paying out to a wallet the client does not control is a loss and a compliance event at once. Here are the three proofs, exactly as the product implements them, and what each one actually proves.

A deposit tells you where funds came from. A withdrawal makes you the sender, on-chain and permanently, so the question flips: does the client control the destination? Three methods answer it at three levels of assurance. This guide shows the exact mechanics KYTGate uses for each, so a compliance officer can explain them to an auditor and a developer can implement them without guessing.

On this page

EIP-191 signed message (EVM chains)

The client signs a fixed message with the wallet's key; the recovered signer must equal the registered address. Highest assurance: proves control of the private key.

When a wallet is registered (POST /api/v1/wallets), the response includes a message to sign. The format is fixed and contains no amount, no expiry and no transaction — only your tenant id, your customer reference and a per-wallet random nonce (24 hex characters):

KYTGate wallet ownership
tenant: <tenantId>
customer: <customerRef>
nonce: <24-hex nonce>

The client signs it with personal_sign (EIP-191), which prefixes the text with \x19Ethereum Signed Message:\n<length> before hashing — the standard every wallet uses for "sign this message" prompts, and one that can never be replayed as a transaction. The 65-byte r||s||v signature goes to POST /api/v1/wallets/{id}/prove. KYTGate recovers the public key from the signature and the message hash, derives the address, and accepts only when it equals the registered address exactly. Ownership becomes VERIFIED, method signature.

What it proves: whoever answered the prompt controls the private key of that address, right now. What it does not prove: that the person is your customer rather than someone the customer handed the device to, or that funds sent there later will stay under their control. It is EVM-only — Tron, Bitcoin and Solana wallets cannot answer it, which is why the second method exists.

Micro-transfer (Satoshi test) — Tron, Bitcoin, Ethereum, Avalanche and more

The client sends one random exact amount from the wallet to your receiving address within 24 hours; proves control of the funds, not just the key.

The challenge (POST /api/v1/wallets/{id}/challenge) tells the client to send an exact amount of the chain's canonical stablecoin — USDT on Tron, Ethereum and Avalanche; USDC on Base, Arbitrum, Polygon and OP Mainnet — or native satoshis on Bitcoin, from the registered wallet to your receiving address on that chain. The mechanics, as implemented:

  • Random amount, unique per tenant. Between 0.011 and 0.099 with three or four decimals for stablecoins (for example 0.0537), or 1,100–9,900 sats on Bitcoin. The amount is chosen so that no other open challenge in your tenant on that chain uses it — otherwise one customer's transfer could prove another's wallet.
  • 24 hours. The challenge expires 24 hours after creation. An expired challenge answers 410; create a new one.
  • Exact match, every field. A transfer counts only if the raw amount equals the challenge amount to the last unit, the sender is the registered wallet, the recipient is your destination, the token contract is the canonical one, and the transfer was observed at or after the challenge was created (two minutes of clock slack for explorer timestamps). An older transfer that happens to match is not a proof.
  • From the wallet itself. A transfer from an exchange account does not match: the on-chain sender is the exchange, not the wallet. The instructions say so.
  • Where it is looked for. TronGrid on Tron, public Blockscout on Ethereum, Base, Arbitrum, Polygon and OP Mainnet, mempool.space on Bitcoin, and a direct eth_getLogs scan of the canonical USDT contract on Avalanche. The check runs on POST …/challenge/verify, from the console's "check now" button, and automatically every 10 minutes.
  • What is recorded. On a match, ownership becomes VERIFIED with method micro_transfer; the transaction hash (and, on Avalanche, the block number) is stored on the wallet, and the wallet.ownership_verified webhook fires.

What it proves: whoever holds the wallet could move funds out of it during that window. It is slightly weaker than a signature in one respect — it proves control of the funds, which is what a payout actually needs — and stronger in another, because it works on every chain the product supports except BNB Chain and Solana. BNB Chain has no canonical Tether or Circle contract in the issuer list, so there is no challenge token there; use declaration or a manual attestation.

Declared ownership — and why it is lower assurance

A statement by the customer or an agent, recorded as DECLARED. It proves nothing and is treated as unverified by the gate unless you opt in.

POST /api/v1/wallets/{id}/declare with { declaredBy: "customer" | "agent", note? } records that someone said the wallet belongs to the customer. Ownership becomes DECLARED, with a timestamp and the note. No signature is checked and no transfer is looked for.

This exists because some flows have no better option — a legacy customer base, a chain without a proof method, a support agent's phone call — and a recorded statement is better than an unrecorded one. But a declaration is a statement, not a proof, and the product treats it that way: the withdrawal gate and the walletOwnership coverage dimension treat DECLARED exactly like UNVERIFIED unless your admin turns on ownership.acceptDeclared (default off, audited). A VERIFIED or ATTESTED wallet is never downgraded to DECLARED.

Reviewers can also attest ownership manually in the console after their own checks; that is ATTESTED, method manual, and it is accepted by the gate — the reviewer's name and the time are in the audit log.

How the withdrawal gate uses ownership

The gate needs an approved wallet and an ALLOW on a pre_withdrawal screening; ownership enters as the walletOwnership coverage dimension, which your policy decides on.

Before paying out, call POST /api/v1/wallets/check with the customer reference, chain, address and amount. The gate answers allowed: true only when the destination is a registered, approved wallet of that customer and a fresh pre_withdrawal screening returns ALLOW. Anything else — unregistered, pending, revoked, a sanctions match, a freeze, a behaviour signal at REVIEW — is allowed: false with the reason.

Ownership reaches that screening as the walletOwnership coverage dimension: COMPLETE for VERIFIED (either proof) and ATTESTED, PARTIAL for UNVERIFIED and for DECLARED (unless acceptDeclared is on). The response states it explicitly under ownership: { state, method, accepted, acceptDeclared }. A policy rule on that dimension decides what PARTIAL means for the payout; the baseline policy does not block on it, so a tenant that wants "no proof, no payout" adds one rule and backtests it first.

One design point worth knowing: revocation is a status, never a delete. A wallet that was approved, paid to and later revoked stays in the registry with its history, so a withdrawal made last year can still be explained.

Questions

What does the EIP-191 ownership message look like?

Four lines: "KYTGate wallet ownership", then "tenant: <tenantId>", "customer: <customerRef>" and "nonce: <24-hex nonce>". The client signs it with personal_sign; KYTGate recovers the signer from the signature and accepts only if it equals the registered address.

How does the micro-transfer (Satoshi test) work?

The client sends one exact random amount — 0.011 to 0.099 USDT or USDC, or 1,100 to 9,900 sats on Bitcoin — from the registered wallet to your receiving address within 24 hours. The amount is unique among your open challenges. KYTGate matches on exact raw amount, sender, recipient, token contract and time, and records the transaction hash.

Is declared ownership enough to pay out?

By default, no. A declaration is recorded as DECLARED and the withdrawal gate treats it like UNVERIFIED unless your admin turns on ownership.acceptDeclared. It is a statement, not a proof; the setting exists for flows with no better option and its use is audited.

Which chains support which proof?

Signature: EVM chains only (Ethereum, Base, Arbitrum, Polygon, Avalanche, OP Mainnet, BNB Chain). Micro-transfer: Tron, Bitcoin, Ethereum, Avalanche, Base, Arbitrum, Polygon and OP Mainnet. BNB Chain and Solana have no micro-transfer; declaration and manual attestation work everywhere.

Read more

Try the sandboxRequest early access