← GUIDES
GUIDE · WITHDRAWALS

Wallet ownership verification for withdrawals

Published 2026-09-09 · Updated

Paying out to a wallet the client does not control is both a loss and a compliance event. This guide covers the three proofs KYTGate supports (signed message, micro-transfer and declaration), how each is implemented and what each one proves.

A deposit tells you where funds came from. On a withdrawal you become the sender, on-chain and permanently, so the question is whether the client controls the destination. Wallet ownership verification answers it, and there are three methods with different levels of assurance. Below are the mechanics KYTGate uses for each, in enough detail for a compliance officer to explain them to an auditor and for a developer to implement them.

Start free sandboxTalk to us50 screenings a month free, no card.

On this page

EIP-191 signed message (EVM chains)

The client signs a fixed message with the wallet's key, and the recovered signer must equal the registered address. This gives the highest assurance because it 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. This is the standard behind every wallet's "sign this message" prompt, and a signature made this way cannot 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.

A valid signature proves that whoever answered the prompt controls the private key of that address at that moment. 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. The method works only on EVM chains. Tron, Bitcoin and Solana wallets cannot produce the signature, which is why the second method exists.

Micro-transfer (Satoshi test)

The client sends one exact random amount from the wallet to your receiving address within 24 hours. This proves control of the funds in the wallet. Works on Tron, Bitcoin, Ethereum, Avalanche and more.

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 are as follows:

  • 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 on 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 client instructions point this out.
  • 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.

A matching transfer proves that whoever holds the wallet could move funds out of it during that window. In one respect that is slightly weaker than a signature, although control of the funds is what a payout needs. In another it is stronger, 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 a declaration or a manual attestation instead.

Declared ownership

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.

Declaration exists because some flows have no better option: a legacy customer base, a chain without a proof method, or a support agent's phone call. A recorded statement is better than an unrecorded one. It is still only a statement, and the product treats it as one. The withdrawal gate and the walletOwnership coverage dimension handle DECLARED the same way as UNVERIFIED unless your admin turns on ownership.acceptDeclared (off by default, and audited). A VERIFIED or ATTESTED wallet is never downgraded to DECLARED.

Reviewers can also attest ownership manually in the console after their own checks. This records ATTESTED with method manual, which the gate accepts. The reviewer's name and the time go into 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, and your policy decides what to do with it.

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 shows it 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, after backtesting it.

Revoking a wallet changes its status and never deletes the record. 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. A declaration is a statement rather than 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

Start free sandbox Talk to us