EVM
This page covers Ethereum and EVM-compatible chains: how a wallet proves control, how Bluprynt records the proof, and what @bluprynt/sdk-chain-evm re-checks. For the report format, see Reading the report.
How wallets prove control on EVM
There are two kinds of accounts.
- Externally owned account (EOA). A secp256k1 key pair. The address is the last 20 bytes of the Keccak-256 hash of the public key. Whoever holds the private key controls the address.
- Contract account. Code deployed at an address. It has no private key. It does what its code allows, for example "any 2 of these 3 owners may act".
An EOA proves control by signing a message. Two message formats are common:
- EIP-191 (
personal_sign). The wallet signs the Keccak-256 hash of"\x19Ethereum Signed Message:\n" + length + message. The prefix keeps a signed message from ever being a valid transaction. Anyone can recover the signer's address from the message and the 65-byte signature. No network is needed. - EIP-712 (typed data). The wallet signs structured data with a domain (name, chain, contract). Wallets can show the fields to the user. Recovery works the same way.
A contract can't sign. Instead, it answers the question "is this signature valid for you?" through ERC-1271: isValidSignature(hash, signature) returns the magic value 0x1626ba7e when the contract accepts it. The answer comes from the contract's code and storage, so checking it needs an RPC call. A wallet-extension flow, such as WalletConnect, may return a "signature" for a contract wallet, but normal ECDSA recovery (ecrecover) can't verify it. Only the contract can. See EIP-1271.
A Safe is the most common contract wallet. It has a list of owners and a threshold. It accepts a signature when enough owners signed. Anyone can read its owners and threshold with getOwners() and getThreshold().
How Bluprynt verifies it
The flows are described in Wallet verification. On EVM:
- Wallet extension. The user connects through WalletConnect and signs a Bluprynt-generated message with EIP-191 message signing.
- CLI. The user signs the challenge with Foundry's
cast wallet sign. - On-chain transaction. Bluprynt uses either a unique amount sent to a Bluprynt-provided address, where the amount is the challenge, or a transaction whose calldata carries the challenge.
A verification's method is one of these, and the listed fields are the evidence Bluprynt stores:
method | Used for | Stored evidence |
|---|---|---|
signature / eip191 | EOAs signing in a wallet or with cast wallet sign | message, signature |
signature / eip712 | EOAs signing typed data | message (the typed data as JSON), signature |
contract_signature / eip1271 | Contract wallets such as Safe | message, signature, inner_scheme (eip191 or eip712), caip2, block_number |
transfer / amount | Custody setups that can't sign messages: a unique amount sent to a Bluprynt address | caip2, transaction_hash, sender, recipient, asset, amount |
transfer / memo | A transaction whose calldata carries the challenge | caip2, transaction_hash, sender, recipient, memo |
admin_manual | Set by hand | Nothing to re-check |
Every verification also names the wallet_address and the authority it backs.
What the SDK checks
Offline, with no at, the SDK checks only what needs no chain state. It never calls an RPC.
With at, it reads chain state at each moment you ask for. Each moment is read at one block: the latest block for "now", and the last block at or before the instant otherwise. A contract signature at "verified" is read at its recorded block_number.
The package uses viem for signatures and RPC calls.
| Question | How it's answered |
|---|---|
| Did this EOA sign the message? | recoverMessageAddress or recoverTypedDataAddress from viem. Offline |
| Did the contract wallet accept the signature at that block? | viem's verifyHash with the block number, over your RPC |
| Did the transfer happen as claimed? | eth_getTransactionByHash and eth_getTransactionReceipt |
Does the token's owner() return the authority? | owner() called at the block |
| Does the Safe have the claimed owners and threshold? | getOwners() and getThreshold() called at the block |
| Which block is the moment? | eth_getBlockByNumber, with a binary search over block timestamps for a past instant |
The EVM checks reference lists every step and reason by name.
Quirks
One key, every EVM chain. An EOA's address comes from its key alone. The same key controls the same 0x address on Ethereum, Base, Sepolia and every other EVM chain. An EIP-191 proof says nothing about which chain it was made for. An EIP-712 domain may name a chain, but the recovered address is the same on every EVM chain. A contract wallet is different: it exists only on the chain it was deployed to, and the same address on another chain may be empty or hold different code. That's why a contract_signature records its caip2, and the SDK checks it on that chain only.
So an EOA's proof can carry across EVM chains, and a contract wallet's can't. For example, a token on Plume can be backed by a signature made with the same EOA on Ethereum. A Safe's proof on Ethereum says nothing about the same address on Plume. The SDK doesn't compare a proof's chain with the token's chain. Whether a proof from another chain counts is your call (see What a proof shows).
ERC-1271 answers depend on the block. A Safe's owners and threshold can change. A signature it accepted last month may be rejected today, and the other way round. The SDK reads isValidSignature at a specific block. At "verified", that's the block Bluprynt recorded. At "now", it's the latest block.
Authorities for ERC-20 tokens. The SDK reads two kinds. An owner authority is read through the token's owner(), as in OpenZeppelin's Ownable. A multisig authority, of any type, is read as a Safe. Other ways to control a token, such as access-control roles, have no generic check.
A failed read is invalid. If owner() reverts, or the address has no contract, the on-chain lookup step fails, and the result is invalid at that moment. The same goes for a Safe call. It means this read didn't confirm the claim, for example because the token has no owner() function. It doesn't by itself mean anyone made a false claim.
Past reads need an archive RPC. A regular node keeps state for recent blocks only. Calling owner(), getOwners() or isValidSignature at an old block needs an archive node. Without one, the check is unverifiable with RPC has no state for block <n>, not invalid. Only past moments need one. "now" works with a regular node.
Many tokens. Within one token, the SDK shares the RPC client and block lookups between checks. It doesn't share them across tokens or calls, and it checks a response's tokens at the same time. If you check many tokens, caching and rate limits are up to you. See RPC and errors.
Addresses ignore case. EVM addresses are hex. Mixed case is only an EIP-55 checksum. The SDK compares addresses with viem's isAddressEqual, so 0xf748…b19c matches 0xf748…B19c. The example below shows a lowercase wallet and a checksummed recovered address side by side.
Check it yourself
To re-check a verification by hand, check its proof as shown below. Then confirm that the wallet_address is the authority's address or one of its signers, and that the authority's type and address appear in the token's authorities.
Real Sepolia lookup
This script uses the public Sepolia lookup from Reading the report. It first recovers the signer with viem alone, then checks the same lookup through the SDK. It also changes one line of the signed message and checks it again both ways. Neither needs a network.
import { fileURLToPath } from "node:url";
import {
checkKyiVerification,
verifyKyiResponse,
} from "@bluprynt/sdk-chain-evm";
import { type Hex, isAddressEqual, recoverMessageAddress } from "viem";
import sepolia from "./data/sepolia.json" with { type: "json" };
type KyiTokenLookup = Parameters<typeof verifyKyiResponse>[0];
export async function run() {
const lookup = sepolia as KyiTokenLookup;
const item = lookup.data[0];
const verification = item?.verifications[0];
const method = verification?.method;
if (
!item ||
!verification ||
method?.type !== "signature" ||
!method.message ||
!method.signature
) {
throw new Error("sepolia.json has no signature verification");
}
const wallet = verification.wallet_address;
const { message } = method;
const signature = method.signature as Hex;
const tamperedMessage = message.replace("Issued to: 1173", "Issued to: 1174");
const recover = async (signed: string) => {
const recovered = await recoverMessageAddress({
message: signed,
signature,
});
return {
recovered,
recoversToWallet: isAddressEqual(recovered, wallet as Hex),
};
};
return {
independent: {
wallet,
...(await recover(message)),
tamperedMessage: await recover(tamperedMessage),
},
sdk: {
report: await verifyKyiResponse(lookup),
tamperedMessage: (
await checkKyiVerification(
{ ...verification, method: { ...method, message: tamperedMessage } },
item.token,
)
).proof,
},
};
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
console.log(JSON.stringify(await run(), null, 2));
}Its test checks that this output stays the same:
Output
{
"independent": {
"wallet": "0xf748a9220b9b0d79d6eed2d927fcdfe80055b19c",
"recovered": "0xf748a9220B9B0D79D6eeD2D927fCdFE80055b19c",
"recoversToWallet": true,
"tamperedMessage": {
"recovered": "0x0BF280187914DFD1eF78C0afc6C29d5857eFcbcc",
"recoversToWallet": false
}
},
"sdk": {
"report": {
"claimed": true,
"evidence": "confirmed",
"verified": true,
"matchesClaim": true,
"tokens": [
{
"caip19": "eip155:11155111/erc20:0xcbc76ceb0afa037cbb975fd330bd683754998cc3",
"claimed": true,
"evidence": "confirmed",
"verified": true,
"matchesClaim": true,
"checks": [
{
"kind": "verification",
"subject": "0xf748a9220b9b0d79d6eed2d927fcdfe80055b19c",
"method": "signature:eip191",
"proof": {
"status": "valid",
"steps": [
{
"check": "wallet is the authority or one of its signers",
"via": "offline",
"ok": true,
"expected": [
"0xf748a9220b9b0d79d6eed2d927fcdfe80055b19c"
],
"actual": "0xf748a9220b9b0d79d6eed2d927fcdfe80055b19c"
},
{
"check": "eip191 signature recovers to wallet",
"via": "offline",
"ok": true,
"expected": "0xf748a9220b9b0d79d6eed2d927fcdfe80055b19c",
"actual": "0xf748a9220B9B0D79D6eeD2D927fCdFE80055b19c"
},
{
"check": "verification's authority is listed for the token",
"via": "offline",
"ok": true,
"expected": [
{
"type": "controlled_by",
"address": "0xf748a9220b9b0d79d6eed2d927fcdfe80055b19c"
}
],
"actual": {
"type": "controlled_by",
"address": "0xf748a9220b9b0d79d6eed2d927fcdfe80055b19c"
}
}
]
},
"at": {}
},
{
"kind": "authority",
"subject": "0xf748a9220b9b0d79d6eed2d927fcdfe80055b19c",
"method": "controlled_by",
"at": {}
}
],
"at": {}
}
],
"at": {}
},
"tamperedMessage": {
"status": "invalid",
"reason": "eip191 signature recovers to wallet failed",
"steps": [
{
"check": "wallet is the authority or one of its signers",
"via": "offline",
"ok": true,
"expected": [
"0xf748a9220b9b0d79d6eed2d927fcdfe80055b19c"
],
"actual": "0xf748a9220b9b0d79d6eed2d927fcdfe80055b19c"
},
{
"check": "eip191 signature recovers to wallet",
"via": "offline",
"ok": false,
"expected": "0xf748a9220b9b0d79d6eed2d927fcdfe80055b19c",
"actual": "0x0BF280187914DFD1eF78C0afc6C29d5857eFcbcc"
}
]
}
}
}independent: viem recovers the wallet from the signature, ignoring case. Over the tampered message, it recovers a different address.sdk.report: the proof isvalid, and the evidence isconfirmed.sdk.tamperedMessage: the proof isinvalidwitheip191 signature recovers to wallet failed.
viem and the SDK agree: the SDK's eip191 signature recovers to wallet step shows the same recovered address as independent.recovered, and both reject the tampered message.
Source: examples/chains/src/evm.ts · output: examples/chains/output/evm.json
With Foundry (not run by our tests)
You can check the same signature with Foundry. Save the exact message from the lookup to message.txt, with no trailing newline:
cast wallet verify \
--address 0xf748a9220b9b0d79d6eed2d927fcdfe80055b19c \
"$(cat message.txt)" \
0xec83133187bf78019c3c4b4137d14e1876b167c2f64ca05bfc06f5491c9c257e1477862593d19651ede7268e9f7470c773d46e1a818d2eb3b2b82ede600010141bContract signatures and transfers by hand (not run by our tests)
These need an RPC for the chain the verification records in caip2.
- Contract signature. Hash the
messagethe way itsinner_schemesays: viem'shashMessageforeip191, orhashTypedDataforeip712. CallisValidSignature(hash, signature)on the wallet contract atblock_number. It must return0x1626ba7e. viem'sverifyHashwithblockNumberdoes this call. - Transfer. Fetch the transaction and its receipt by
transaction_hash. The receipt's status must be success, and the transaction'sfrommust be the recordedsender, which must be the wallet. For anamounttransfer, theassetmust be on the same chain. For the native coin (slip44), the transaction'svaluemust equalamount. For an ERC-20 token, the receipt must contain aTransferevent from the token contract inasset, from the sender, with the same value. In both, the recipient must matchrecipientwhen one is recorded. For amemotransfer, the calldata must be the UTF-8 bytes ofmemo, andtomust matchrecipientwhen one is recorded.
At a moment: synthetic data (recorded RPC responses)
The Sepolia lookup above reads no chain state at a moment: its authority is controlled_by, which has no EVM check. See the report with at. This example reads a Safe instead.
The script builds a lookup for a made-up token whose owner() is a made-up 2-of-3 Safe. One owner signs Bluprynt's challenge with a public test key. It then asks for at: ["verified", "now"]. In the recorded chain, one of the Safe's owners was swapped after the verification. The RPC responses are recorded in src/fixtures/evm-safe.json, so the example runs offline. pnpm -C examples/chains fixtures regenerates them.
import { fileURLToPath } from "node:url";
import { verifyKyiResponse } from "@bluprynt/sdk-chain-evm";
import { getAddress } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import fixture from "./fixtures/evm-safe.json" with { type: "json" };
import { withRecordedFetch } from "./replay.ts";
type KyiTokenLookup = Parameters<typeof verifyKyiResponse>[0];
// Public test keys and made-up contract addresses: the recorded chain in fixtures/evm-safe.json is synthetic.
const OWNER_KEYS = [
`0x${"11".repeat(32)}`,
`0x${"22".repeat(32)}`,
`0x${"33".repeat(32)}`,
] as const;
const SAFE = getAddress("0x5afe00000000000000000000000000000000a11c");
const TOKEN = getAddress("0x7e57000000000000000000000000000000000020");
const CAIP2 = "eip155:11155111";
const RPC_URL = "https://sepolia.rpc.example.invalid";
const VERIFIED_AT = "2026-04-15T09:30:00.000Z";
const owners = OWNER_KEYS.map((key) => privateKeyToAccount(key).address);
export async function run() {
const [signer] = OWNER_KEYS;
const wallet = privateKeyToAccount(signer);
const message = [
"Please, sign this message to verify your Bluprynt KYI credentials",
"",
"Purpose: Verify wallet ownership",
"Issued to: example",
`Address: ${wallet.address}::1776245400000::example-nonce`,
].join("\n");
const authority = {
type: "owner",
address: SAFE,
account_type: "multisig" as const,
signers: owners,
threshold: 2,
observed_at: VERIFIED_AT,
};
const lookup: KyiTokenLookup = {
kyi_verified: true,
data: [
{
kyi_verified: true,
token: {
caip19: `${CAIP2}/erc20:${TOKEN}`,
caip2: CAIP2,
chain_slug: "sepolia",
address: TOKEN,
standard: "erc20",
decimals: 18,
verified_at: VERIFIED_AT,
},
asset: {
slug: null,
name: "Example Safe Token",
symbol: "SAFEX",
description: null,
website_url: null,
whitepaper_url: null,
},
issuer: null,
authorities: [authority],
verifications: [
{
verified_at: VERIFIED_AT,
expires_at: null,
wallet_address: wallet.address,
authority,
method: {
type: "signature",
scheme: "eip191",
message,
signature: await wallet.signMessage({ message }),
signer_public_key: null,
},
},
],
},
],
};
return {
safe: SAFE,
token: TOKEN,
claimedOwners: owners,
signer: wallet.address,
report: await withRecordedFetch(fixture.records, () =>
verifyKyiResponse(lookup, {
rpcUrls: { [CAIP2]: RPC_URL },
at: ["verified", "now"],
}),
),
};
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
console.log(JSON.stringify(await run(), null, 2));
}Its test checks that this output stays the same:
Output
{
"safe": "0x5AFe00000000000000000000000000000000a11C",
"token": "0x7E57000000000000000000000000000000000020",
"claimedOwners": [
"0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A",
"0x1563915e194D8CfBA1943570603F7606A3115508",
"0x5CbDd86a2FA8Dc4bDdd8a8f69dBa48572EeC07FB"
],
"signer": "0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A",
"report": {
"claimed": true,
"evidence": "confirmed",
"verified": true,
"matchesClaim": true,
"tokens": [
{
"caip19": "eip155:11155111/erc20:0x7E57000000000000000000000000000000000020",
"claimed": true,
"evidence": "confirmed",
"verified": true,
"matchesClaim": true,
"checks": [
{
"kind": "verification",
"subject": "0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A",
"method": "signature:eip191",
"proof": {
"status": "valid",
"steps": [
{
"check": "wallet is the authority or one of its signers",
"via": "offline",
"ok": true,
"expected": [
"0x5AFe00000000000000000000000000000000a11C",
"0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A",
"0x1563915e194D8CfBA1943570603F7606A3115508",
"0x5CbDd86a2FA8Dc4bDdd8a8f69dBa48572EeC07FB"
],
"actual": "0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A"
},
{
"check": "eip191 signature recovers to wallet",
"via": "offline",
"ok": true,
"expected": "0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A",
"actual": "0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A"
},
{
"check": "verification's authority is listed for the token",
"via": "offline",
"ok": true,
"expected": [
{
"type": "owner",
"address": "0x5AFe00000000000000000000000000000000a11C"
}
],
"actual": {
"type": "owner",
"address": "0x5AFe00000000000000000000000000000000a11C"
}
}
]
},
"at": {
"verified": {
"status": "valid",
"steps": [
{
"check": "eip191 signature recovers to wallet",
"via": "offline",
"ok": true,
"expected": "0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A",
"actual": "0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A"
},
{
"check": "Safe owners match claimed signers",
"via": "rpc",
"ok": true,
"expected": [
"0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A",
"0x1563915e194D8CfBA1943570603F7606A3115508",
"0x5CbDd86a2FA8Dc4bDdd8a8f69dBa48572EeC07FB"
],
"actual": [
"0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A",
"0x1563915e194D8CfBA1943570603F7606A3115508",
"0x5CbDd86a2FA8Dc4bDdd8a8f69dBa48572EeC07FB"
]
},
{
"check": "Safe threshold",
"via": "rpc",
"ok": true,
"expected": 2,
"actual": "2"
},
{
"check": "token owner()",
"via": "rpc",
"ok": true,
"expected": "0x5AFe00000000000000000000000000000000a11C",
"actual": "0x5AFe00000000000000000000000000000000a11C"
}
],
"position": {
"block": "8124750",
"time": "2026-04-15T09:30:00.000Z"
}
},
"now": {
"status": "invalid",
"reason": "Safe owners match claimed signers failed",
"steps": [
{
"check": "eip191 signature recovers to wallet",
"via": "offline",
"ok": true,
"expected": "0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A",
"actual": "0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A"
},
{
"check": "Safe owners match claimed signers",
"via": "rpc",
"ok": false,
"expected": [
"0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A",
"0x1563915e194D8CfBA1943570603F7606A3115508",
"0x5CbDd86a2FA8Dc4bDdd8a8f69dBa48572EeC07FB"
],
"actual": [
"0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A",
"0x1563915e194D8CfBA1943570603F7606A3115508",
"0x7564105E977516C53bE337314c7E53838967bDaC"
]
},
{
"check": "Safe threshold",
"via": "rpc",
"ok": true,
"expected": 2,
"actual": "2"
},
{
"check": "token owner()",
"via": "rpc",
"ok": true,
"expected": "0x5AFe00000000000000000000000000000000a11C",
"actual": "0x5AFe00000000000000000000000000000000a11C"
}
],
"position": {
"block": "9480000",
"time": "2026-10-20T15:00:00.000Z"
}
}
}
},
{
"kind": "authority",
"subject": "0x5AFe00000000000000000000000000000000a11C",
"method": "owner",
"at": {
"verified": {
"status": "valid",
"steps": [
{
"check": "Safe owners match claimed signers",
"via": "rpc",
"ok": true,
"expected": [
"0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A",
"0x1563915e194D8CfBA1943570603F7606A3115508",
"0x5CbDd86a2FA8Dc4bDdd8a8f69dBa48572EeC07FB"
],
"actual": [
"0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A",
"0x1563915e194D8CfBA1943570603F7606A3115508",
"0x5CbDd86a2FA8Dc4bDdd8a8f69dBa48572EeC07FB"
]
},
{
"check": "Safe threshold",
"via": "rpc",
"ok": true,
"expected": 2,
"actual": "2"
},
{
"check": "token owner()",
"via": "rpc",
"ok": true,
"expected": "0x5AFe00000000000000000000000000000000a11C",
"actual": "0x5AFe00000000000000000000000000000000a11C"
}
],
"position": {
"block": "8124750",
"time": "2026-04-15T09:30:00.000Z"
}
},
"now": {
"status": "invalid",
"reason": "Safe owners match claimed signers failed",
"steps": [
{
"check": "Safe owners match claimed signers",
"via": "rpc",
"ok": false,
"expected": [
"0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A",
"0x1563915e194D8CfBA1943570603F7606A3115508",
"0x5CbDd86a2FA8Dc4bDdd8a8f69dBa48572EeC07FB"
],
"actual": [
"0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A",
"0x1563915e194D8CfBA1943570603F7606A3115508",
"0x7564105E977516C53bE337314c7E53838967bDaC"
]
},
{
"check": "Safe threshold",
"via": "rpc",
"ok": true,
"expected": 2,
"actual": "2"
},
{
"check": "token owner()",
"via": "rpc",
"ok": true,
"expected": "0x5AFe00000000000000000000000000000000a11C",
"actual": "0x5AFe00000000000000000000000000000000a11C"
}
],
"position": {
"block": "9480000",
"time": "2026-10-20T15:00:00.000Z"
}
}
}
}
],
"at": {
"verified": {
"evidence": "confirmed",
"verified": true,
"matchesClaim": true
},
"now": {
"evidence": "contradicted",
"verified": false,
"matchesClaim": false
}
}
}
],
"at": {
"verified": {
"evidence": "confirmed",
"verified": true,
"matchesClaim": true
},
"now": {
"evidence": "contradicted",
"verified": false,
"matchesClaim": false
}
}
}
}proof: the signer is one of the claimed owners, and the signature recovers to it.at.verified, read at block 8124750: the Safe's owners and threshold match the claim, andowner()returns the Safe.valid.at.now, read at block 9480000: the owners no longer match, so the result isinvalidwithSafe owners match claimed signers failed. The token'sat.now.evidenceiscontradicted, whileat.verifiedisconfirmed.
The claim held when verified and no longer holds now. Showing a badge suggests how to present that.
Source: examples/chains/src/evm-safe.ts · output: examples/chains/output/evm-safe.json
Limits
These come back unverifiable on EVM:
admin_manualverifications. There is nothing to re-check.- Contract signatures and transfers offline. They need chain state. Ask for a moment.
- Authority types other than
ownerandmultisig, such ascontrolled_by. There's no standard way to read who controls a contract. A verification backed by such an authority isunverifiableat every moment too, even when itsproofisvalid. - Past moments on a non-archive RPC. The node no longer has the state.
- Moments before the record or before the chain. The record or block didn't exist yet.
- Amount transfers of assets other than the native coin and ERC-20 tokens.
- Expired verifications, at moments at or after
expires_at.