--- url: /quickstart.md --- # Quickstart This page re-checks one KYI token lookup for an EVM token, a test token on Sepolia: first offline, then against the chain as of now. ## Install a chain package Install the package for the token's chain: ```sh npm install @bluprynt/sdk-chain-evm ``` For other chains, the steps are the same with a different package and `rpcUrls` key: `@bluprynt/sdk-chain-solana` with `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` ([Solana](/chains/solana)), or `@bluprynt/sdk-chain-xrpl` with `xrpl:0` and a `wss://` URL ([XRPL](/chains/xrpl)). To handle several chains with one call, see [Reading the report](/sdk/report#one-call-several-chains). Each package ships ESM and CommonJS builds with TypeScript types. The packages don't declare a Node version. The repository builds and tests them on Node 22 or later. Each chain package depends on its ecosystem's library: viem for EVM, `@solana/kit` and the token program clients for Solana, `xrpl` and `ripple-keypairs` for XRPL. ## Fetch a lookup [Bluprynt's public API](https://integrations.bluprynt.com/) serves KYI token lookups at `GET /kyi/v1/tokens`. Look a token up by its [CAIP-19](/reference/glossary#caip) ID, or by `address` with an optional `chain_slug`. The request needs an API key; the [API docs](https://integrations.bluprynt.com/) describe how to send it. ```ts const url = new URL("https://integrations.bluprynt.com/kyi/v1/tokens"); url.searchParams.set( "caip19", "eip155:11155111/erc20:0xcbc76ceb0afa037cbb975fd330bd683754998cc3", ); const res = await fetch(url, { headers: { Authorization: process.env.BLUPRYNT_API_KEY ?? "" }, }); if (!res.ok) throw new Error(`KYI lookup failed: ${res.status}`); const response = await res.json(); ``` The response says whether the token passed KYI (`kyi_verified`). It also lists the token's [authorities](/reference/glossary#authority) and the [verifications](/reference/glossary#verification) that back them. [Reading the report](/sdk/report#example) shows this lookup in full. ## Verify offline The values in the comments are for this Sepolia lookup. ```ts import { verifyKyiResponse } from "@bluprynt/sdk-chain-evm"; const report = await verifyKyiResponse(response); report.claimed; // true: what Bluprynt says report.evidence; // "confirmed" report.verified; // true: what the re-checked proofs support report.matchesClaim; // true: the two agree ``` With no options, the SDK makes no network calls. It checks each verification's proof: the signature recovers to the wallet, the wallet is the authority or one of its signers, and the authority is listed for the token. * **`evidence`:** `confirmed` when at least one verification is valid and no check is invalid. `contradicted` when a check is invalid. `inconclusive` otherwise. * **`verified`:** `true` only when the evidence is `confirmed`. * **`matchesClaim`:** whether `verified` agrees with `claimed`. It's `null` when Bluprynt claims the token is verified but the evidence is inconclusive. These three are the offline summary. They cover signatures and the binding between wallet, authority and token, but not authorities on-chain or expiry. Don't show a badge from `verified` alone: [Showing a badge](/sdk/badge) explains which fields to use. Each token's `checks` hold the steps behind these values. See [Reading the report](/sdk/report). ## Check the chain as of now Offline checks can't tell you whether an authority still controls the token. To read chain state, pass an RPC for the token's chain and ask for a [moment](/reference/glossary#moment): ```ts const report = await verifyKyiResponse(response, { rpcUrls: { "eip155:11155111": "https://sepolia.rpc.example.com" }, at: ["now"], }); report.at.now?.evidence; // the same summary, from the chain as of now report.tokens[0]?.checks[0]?.at.now; // one check's result at that moment ``` The offline summary stays the same. `report.at.now` sums up what held as of now. For this lookup it is `inconclusive`, because its authority is `controlled_by`, which the SDK can't read on EVM. No RPC call is made: a signature reads no chain state either. [Reading the report](/sdk/report#example) walks through it. To see `at.now` read real authorities, run the worked examples at a moment for [EVM](/chains/evm#at-a-moment), [Solana](/chains/solana#at-a-moment) and [XRPL](/chains/xrpl#at-a-moment). You can also ask for `"verified"` (each record's own moment) or any `Date`. [Moments](/sdk/moments) explains which to use. [RPC and errors](/sdk/rpc) covers RPC setup and what throws. The snippets on this page aren't run by our tests. The same lookup is checked offline by a tested example on the [EVM page](/chains/evm#check-it-yourself). --- --- url: /trust-model.md --- # What a proof shows, and what it doesn't A KYI lookup mixes two kinds of statements. Some the SDK re-checks for you from public data: a signature, a transaction, an account on-chain. Others come from Bluprynt's verification process and take more effort to confirm independently. This page says which is which, so you know what the SDK's report covers and where to look for the rest. ## What you can re-check yourself The SDK re-checks these against public data. You can also re-check them with each chain's own library, as the chain pages show under **Check it yourself**. * **Signatures.** A wallet signed the stored message. On EVM, the signature recovers to the wallet ([EIP-191 or EIP-712](/chains/evm#how-wallets-prove-control-on-evm)). On Solana, it verifies for the wallet's public key, raw or in the [off-chain message](/reference/glossary#off-chain-message) format. On XRPL, it verifies for the recorded signer key, and offline that key must be the wallet's [master key](/reference/glossary#master-key). No network is needed. * **Contract signatures.** On EVM, a [contract wallet](/reference/glossary#contract-wallet) accepted the signature through [ERC-1271](/reference/glossary#erc-1271) at a given block. This needs your RPC. * **Transfers.** On EVM, the transaction exists, succeeded, and matches the recorded sender, recipient and amount or calldata. This needs your RPC. * **The wallet backs the authority.** The wallet is the authority or one of its signers, and the authority is one of the token's listed authorities. * **Authorities at a moment.** At the [moments](/sdk/moments) you ask for, the SDK reads who controls the token: `owner()` and Safe owners on EVM, the mint and freeze authorities on Solana (now only), the issuer account on XRPL. On XRPL it also reads whether the signer key could sign for the wallet then. The [Reference](/reference/evm) lists each check by name. ## What takes more effort to verify {#more-effort} These parts of a lookup come from Bluprynt's process. The SDK doesn't re-check them today, but most can still be confirmed with some extra work. * **The listed authorities, offline.** Offline, the SDK checks that a verification's authority is in the token's `authorities`, but not that the list is right. To confirm it, ask for a [moment](/sdk/moments): the SDK then reads the authority types it knows on-chain. Other types, such as `controlled_by`, need you to inspect the token's contracts or accounts yourself. A multisig can be an exception, depending on the chain. See the Reference for [EVM](/reference/evm#authorities), [Solana](/reference/solana#authorities) and [XRPL](/reference/xrpl#authorities). * **`admin_manual` verifications.** Bluprynt set these by hand, so there is no proof for the SDK to re-check, and they can never be `valid`. They can still be `invalid`, if the wallet doesn't back the authority or the authority isn't listed for the token. Confirming one means going through the evidence behind it with Bluprynt. * **The claim and the lists themselves.** `kyi_verified`, the `asset` metadata, and whether the `verifications` and `authorities` lists are complete describe Bluprynt's records. You can compare the authorities with what you read on-chain, but the lookup alone can't show that nothing is missing. * **The issuer.** The SDK doesn't read `issuer`. `issuer.name` and `issuer.kyb_verified` reflect Bluprynt's issuer verification, which happens outside the chain. * **The challenge's content.** The SDK checks that the wallet signed the stored message, not what the message says. It doesn't parse the address, the nonce or the "Issued to" line, and it doesn't check that Bluprynt issued that challenge, or when. You can read the message yourself: it's in the lookup in plain text. The same goes for a transfer's amount or memo: the SDK checks that the transaction matches the stored values, not that those values were the challenge. * **Token features beyond the listed authorities.** The SDK reads only the authorities it can check. Other controls over the token, such as access-control roles on EVM or Token-2022 extensions like a permanent delegate on Solana, aren't read. * **The link between wallet and issuer.** A proof shows that whoever holds the wallet's key took part. That this party is the issuer Bluprynt verified comes from Bluprynt's verification process. * **Which chain a proof counts for.** The SDK doesn't compare a proof's chain with the token's chain. An [EOA](/reference/glossary#eoa) signature is valid on every EVM chain. A contract signature or transfer is checked on the chain it records (`caip2`), which may not be the token's chain. Whether that counts for your token is your call. The [EVM page](/chains/evm#quirks) explains why. ## Keys can leak or change hands A valid signature shows that someone held the key when the signature was made. It can't show that the key wasn't stolen, or that the same party holds it today. * An EVM or Solana key pair can't be rotated. If it leaks, the attacker can sign as the wallet from then on. * A Safe's owners and threshold can change. The SDK reads them at the block for each moment. * An XRPL account can set a regular key or disable its master key. At a moment, the SDK checks that the signer key could sign for the account at that ledger. Offline, a master-key signature is accepted even if the account has set `lsfDisableMaster`. Only a moment read checks that. When control changes, an old proof can be `valid` at `"verified"` and `invalid` at `"now"`: it held when verified, and no longer holds now. The token's `at.now` evidence is then `contradicted`. `at.verified` answers whether the claim held when it was made. `at.now` answers whether it holds today. Worked examples show both cases: [a Safe's owners changed](/chains/evm#at-a-moment) and [an XRPL master key was disabled](/chains/xrpl#at-a-moment). [Showing a badge](/sdk/badge#rotation) suggests how to present it. A signature carries no time of its own. When it was made comes from the record's `verified_at`, which Bluprynt records when it checks the proof. ## Expiry A verification can have an `expires_at`. The SDK applies it only at moments: at a moment on or after `expires_at`, a verification that would otherwise be valid is `unverifiable`. The `proof` result ignores expiry, so the offline summary can count an expired verification as `confirmed`. Ask for `"now"` if expiry matters to you. [Reading the report](/sdk/report#expiry) has the details. ## Facts, not verdicts The SDK doesn't decide whether a token is trustworthy. It reports what it could check, what held, and when: the block, slot or ledger it read. It doesn't pick a moment for you, and it has no maximum age. At a moment, the facts are what your RPC returned, so they're only as reliable as that RPC. Whether a six-month-old proof is good enough, whether an `inconclusive` result is acceptable, and whether a proof from another chain counts are your decisions. [Moments](/sdk/moments) shows how to ask the questions that matter to you. --- --- url: /wallet-verification.md --- # Wallet verification Wallet verification is one of the steps of [KYI](/reference/glossary#kyi). Every record Bluprynt issues for a token or an issuer rests on one assumption: the on-chain addresses in the record are controlled by the issuer who submitted it. So before Bluprynt issues a record, it verifies the issuer's identity and links that identity to one or more wallets. Wallet verification answers one question: can this organization prove control of a specific on-chain address? Bluprynt uses the proof to link the wallet to the issuer's KYI identity. It's part of both KYI flows: * **Verifying a token:** Bluprynt finds the wallets that can control the token, its [authorities](/reference/glossary#authority). The issuer then proves control of one of them. * **Verifying an issuer:** Bluprynt verifies the issuer entity using a partner service ([KYB](/reference/glossary#kyb)). The issuer then adds and verifies the wallets that represent it on-chain. Each chain has its own keys, permissions and signing methods. The chain pages cover them: [EVM](/chains/evm), [Solana](/chains/solana) and [XRPL](/chains/xrpl). ## Principles Bluprynt aims for a verification flow that is: 1. **Cryptographically strong:** proofs that are hard to forge and easy to check. 2. **Easy to use:** it works with common wallets and needs little setup. 3. **Chain-native:** it uses the signatures and transactions each chain expects. 4. **Auditable:** it produces a proof that anyone can check the same way and, where it applies, find on-chain. 5. **Compatible with custody:** it works where an operator can't sign arbitrary messages, as with some HSM and custody setups. ## Challenge lifetimes Every method starts from a challenge that Bluprynt generates. How long a challenge stays usable depends on the method: | Method | Challenge lifetime | Trade-off | | --- | --- | --- | | Wallet extension | 15 minutes | Strictest. The signer must act right away | | CLI or programmatic signing | Longer than a wallet extension's | Gives time to coordinate with colleagues who hold the keys. Slightly less strict | | On-chain transaction | Long-lived | Gives custody workflows time to approve and broadcast. Less strict | A longer lifetime leaves more time between issuing the challenge and using it. The signature or transaction is just as hard to forge either way. ## Wallet extensions (preferred) {#wallet-extensions} This is the easiest flow for most users. The wallet signs an off-chain message, sometimes a typed message. It's not a transaction: anyone can check the signature against the wallet's address or public key. 1. Bluprynt generates a verification message. 2. The user signs it through a wallet connection: WalletConnect on EVM and Solana, XRPL Connect on XRPL. 3. Bluprynt checks the signature and records the link between the issuer's KYI identity and the wallet on that chain. The message format and how it's signed differ per chain. See [EVM](/chains/evm#how-bluprynt-verifies-it), [Solana](/chains/solana#how-bluprynt-verifies-it) and [XRPL](/chains/xrpl#how-bluprynt-verifies-it). ## CLI or programmatic signing {#cli} Standard tooling can sign the challenge outside a wallet app. This suits automation and some custody setups. It takes more setup than a wallet extension, and what each tool supports varies. Bluprynt generates the challenge, the user signs it with the tool, and Bluprynt checks the signature. It stores the challenge, the wallet and the signature as the proof, the same way as for a wallet extension. The tooling is chain-specific: * **EVM chains:** Foundry's [`cast wallet sign`](https://www.getfoundry.sh/reference/cast/wallet/sign). * **Solana:** the Solana CLI's `solana sign-offchain-message`. See [Off-chain message signing with the Solana CLI](https://docs.anza.xyz/cli/examples/sign-offchain-message). * **XRPL:** xrpl.js. See its [GitHub repo](https://github.com/XRPLF/xrpl.js) and [reference docs](https://js.xrpl.org/). ## On-chain transactions {#onchain-transaction} Some custody setups, including some HSM-backed ones, can't sign arbitrary off-chain messages. In that case, the wallet proves control with an on-chain transaction. Transfer proofs are available on EVM chains only. They aren't available on Solana or XRPL today. When a chain can't carry the challenge text as it is, Bluprynt uses a random identifier tied to the Bluprynt account and the KYI subject. The transaction carries it in a way that fits the chain. This prevents a proof from being reused for another identity. Two values matter: * **Challenge carrier:** what the transaction must contain, such as a unique amount or calldata. * **Recipient:** the address the transaction must interact with. The flow: 1. Bluprynt asks the operator to send a transaction from the wallet being verified. 2. Bluprynt checks that the expected transaction happened and came from that wallet. 3. Bluprynt records that the issuer controls the wallet on that chain. How the challenge is carried on EVM is on the [EVM page](/chains/evm#how-bluprynt-verifies-it). ## Multisig wallets A multisig wallet needs several signers to act. When an issuer uses one, Bluprynt supports two approaches: 1. **Verify the signer wallets.** The issuer verifies the individual wallets that can sign for the multisig. This is often the practical choice when the multisig address can't sign off-chain messages. 2. **Verify the multisig itself.** The issuer proves control of the multisig address with a method that fits the chain: message signing where it's supported, otherwise an on-chain transaction. Which approach fits depends on the issuer's custody setup and the chain's multisig tooling. In a lookup, a multisig authority has `account_type: "multisig"`, with its `signers` and `threshold`. A verification can come from the multisig address or from one of its signers. How the SDK checks a multisig on each chain is in the Reference: [EVM](/reference/evm#authorities), [Solana](/reference/solana#authorities) and [XRPL](/reference/xrpl#authorities). ## Checking a verification yourself Bluprynt stores the proof material with each verification. [Lookup fields](/reference/lookup#verifications) lists it. Each chain page shows how to re-check it with the chain's own library, under **Check it yourself**: [EVM](/chains/evm#check-it-yourself), [Solana](/chains/solana#check-it-yourself) and [XRPL](/chains/xrpl#check-it-yourself). A match shows that the wallet's key took part when the signature or transaction was made. It doesn't show that the wallet's authority still controls the token, or that the proof hasn't expired. [What a proof shows, and what it doesn't](/trust-model) covers what a match leaves open. [Moments](/sdk/moments) covers checking authorities and expiry at a chosen time. --- --- url: /chains/evm.md --- # 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](/sdk/report). ## How wallets prove control on EVM There are two kinds of accounts. * **Externally owned account ([EOA](/reference/glossary#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](/reference/glossary#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](https://eips.ethereum.org/EIPS/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](/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`](https://www.getfoundry.sh/reference/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/evm) 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](/trust-model#more-effort)). **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 `, 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](/sdk/rpc#many-tokens). **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](/sdk/report#example). 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. ```ts 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[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: ```json { "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 is `valid`, and the evidence is `confirmed`. * `sdk.tamperedMessage`: the proof is `invalid` with `eip191 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`](https://github.com/blupryntco/sdk/blob/main/examples/chains/src/evm.ts) · output: [`examples/chains/output/evm.json`](https://github.com/blupryntco/sdk/blob/main/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: ```sh cast wallet verify \ --address 0xf748a9220b9b0d79d6eed2d927fcdfe80055b19c \ "$(cat message.txt)" \ 0xec83133187bf78019c3c4b4137d14e1876b167c2f64ca05bfc06f5491c9c257e1477862593d19651ede7268e9f7470c773d46e1a818d2eb3b2b82ede600010141b ``` ### Contract 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 `message` the way its `inner_scheme` says: viem's `hashMessage` for `eip191`, or `hashTypedData` for `eip712`. Call `isValidSignature(hash, signature)` on the wallet contract at `block_number`. It must return `0x1626ba7e`. viem's `verifyHash` with `blockNumber` does this call. * **Transfer.** Fetch the transaction and its receipt by `transaction_hash`. The receipt's status must be success, and the transaction's `from` must be the recorded `sender`, which must be the wallet. For an `amount` transfer, the `asset` must be on the same chain. For the native coin (`slip44`), the transaction's `value` must equal `amount`. For an ERC-20 token, the receipt must contain a `Transfer` event from the token contract in `asset`, from the sender, with the same value. In both, the recipient must match `recipient` when one is recorded. For a `memo` transfer, the calldata must be the UTF-8 bytes of `memo`, and `to` must match `recipient` when one is recorded. ### At a moment: synthetic data (recorded RPC responses) {#at-a-moment} 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`](/sdk/report#example). 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`](https://github.com/blupryntco/sdk/blob/main/examples/chains/src/fixtures/evm-safe.json), so the example runs offline. `pnpm -C examples/chains fixtures` regenerates them. ```ts 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[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: ```json { "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, and `owner()` returns the Safe. `valid`. * `at.now`, read at block 9480000: the owners no longer match, so the result is `invalid` with `Safe owners match claimed signers failed`. The token's `at.now.evidence` is `contradicted`, while `at.verified` is `confirmed`. The claim held when verified and no longer holds now. [Showing a badge](/sdk/badge#rotation) suggests how to present that. Source: [`examples/chains/src/evm-safe.ts`](https://github.com/blupryntco/sdk/blob/main/examples/chains/src/evm-safe.ts) · output: [`examples/chains/output/evm-safe.json`](https://github.com/blupryntco/sdk/blob/main/examples/chains/output/evm-safe.json) ## Limits These come back `unverifiable` on EVM: * **`admin_manual` verifications.** There is nothing to re-check. * **Contract signatures and transfers offline.** They need chain state. Ask for a moment. * **Authority types other than `owner` and `multisig`,** such as `controlled_by`. There's no standard way to read who controls a contract. A verification backed by such an authority is `unverifiable` at every moment too, even when its `proof` is `valid`. * **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`. --- --- url: /chains/solana.md --- # Solana This page covers Solana: how a wallet proves control, how Bluprynt records the proof, and what `@bluprynt/sdk-chain-solana` re-checks. For the report format, see [Reading the report](/sdk/report). ## How wallets prove control on Solana A Solana wallet is an **Ed25519 key pair**. The address is the 32-byte public key, written in base58. So the address is all a verifier needs to check a signature. There's nothing to recover and no key to look up. A wallet proves control by signing a message off-chain. There are two ways it gets signed: * **Raw.** The wallet signs the message's UTF-8 bytes as they are. Wallet apps do this when asked to sign a message. * **[Off-chain message](/reference/glossary#off-chain-message) format.** The signed bytes start with a header that no transaction can start with. The Solana CLI signs this way, with `solana sign-offchain-message`. The header makes it impossible to trick someone into signing a transaction that looks like a message. The two produce different signatures for the same text. A verifier has to know which one was used. Tokens live in a program. **[SPL Token](/reference/glossary#spl-token)** is the original token program. **Token-2022** is a newer one with extensions. In both, each token has a **mint account** that holds two optional authorities: * the **mint authority**, which can create new tokens, and * the **freeze authority**, which can freeze holders' token accounts. Either authority can be a plain wallet or an **SPL multisig**: an account owned by the token program that lists up to 11 signers and needs `m` of them to act. ## How Bluprynt verifies it The flows are described in [Wallet verification](/wallet-verification). On Solana: * **Wallet extension.** The user connects through WalletConnect and signs a Bluprynt-generated message. Wallet apps sign it raw. * **CLI.** The user signs the challenge with `solana sign-offchain-message`. See [Off-chain message signing with the Solana CLI](https://docs.anza.xyz/cli/examples/sign-offchain-message). * **On-chain transaction.** Not used: [transfer proofs aren't available on Solana today](/wallet-verification#onchain-transaction). The SDK re-checks signature verifications: | `method` | Used for | Stored evidence | | --- | --- | --- | | `signature` / `solana_raw` | Wallet apps | `message`, `signature` (base58) | | `signature` / `solana_offchain_v0` | The Solana CLI | `message`, `signature` (base58) | | `signature` with no `scheme` | Older records | `message`, `signature`. The SDK tries both formats | | `admin_manual` | Set by hand | Nothing to re-check | Every verification also names the `wallet_address` and the `authority` it backs. On Solana, authorities are usually `mint_authority` or `freeze_authority`. ## What the SDK checks Offline, with no `at`, the SDK verifies the signature against the wallet address. It never calls an RPC. With `at`, it reads the token's accounts. A Solana RPC can only answer for the current state, so only `"now"` reads anything. The SDK doesn't set a commitment, so reads use `confirmed`, the default of Kit's `createSolanaRpc`. The package uses `@solana/kit`, and the `@solana-program/token` and `@solana-program/token-2022` clients to decode accounts. | Question | How it's answered | | --- | --- | | Did this wallet sign the message? | `verifySignature` from `@solana/kit`, with the public key taken from the address. Offline | | Is the authority still the mint or freeze authority? | `getAccountInfo` on the mint, decoded with the SPL Token or Token-2022 layout. At `"now"` | | Does the multisig have the claimed signers and threshold? | `getAccountInfo` on the multisig account. At `"now"` | The [Solana checks](/reference/solana) reference lists every step and reason by name. ## Quirks **Raw and off-chain signatures differ.** The legacy off-chain format the CLI signs puts a 20-byte header before the message: | Bytes | Field | Value | | --- | --- | --- | | 16 | Signing domain | `\xff` followed by `solana offchain` | | 1 | Version | `0` | | 1 | Format | `0` (restricted ASCII) | | 2 | Message length | little-endian `u16` | The wallet signs the whole buffer: the header followed by the message bytes (`header || message`). A signature over the raw message doesn't verify over these bytes, and the other way round. A record that names the wrong scheme is `invalid`. Format `0` means restricted ASCII: printable characters only (0x20–0x7e), no newlines. Bluprynt's CLI challenge is one line of printable ASCII, so the CLI signs it as format `0`. The SDK always uses format `0`. A message the CLI signs as format `1` or `2` (for example one with a newline) won't verify. Bluprynt's multi-line wallet challenge is signed raw, so its format doesn't matter. **Two things called "version 0".** The scheme name `solana_offchain_v0` refers to the legacy header above, whose version byte is `0`. `@solana/kit` has its own off-chain message codec, and it also calls its layout "version 0". That layout is newer: it adds an application domain and a list of signers to the header. Its bytes differ from what the CLI signs, so the SDK doesn't use it. It builds the legacy header with Kit's generic codecs instead. That header is the one piece the SDK and the example build by hand. Everything else comes from Kit. **Signatures are base58.** A signature is 64 bytes, stored as a base58 string. The SDK decodes it before verifying. **SPL Token and Token-2022.** Both programs use the same mint and multisig layouts for the fields the SDK reads. The SDK accepts accounts owned by either program. An account owned by any other program fails the `… is owned by a token program` step. **Token-2022 extensions aren't checked.** The SDK reads only the base mint's mint and freeze authorities. A Token-2022 mint can carry extensions with their own authorities, such as a permanent delegate, a transfer hook, a mint close authority or a pausable authority. A permanent delegate can transfer or burn tokens from any holder's account. A transfer hook runs a program on every transfer. A pause authority can stop transfers. A wallet that shows a badge should know that a confirmed mint authority says nothing about these. Read the mint's extensions yourself if they matter to you. **SPL multisig signers are compared as a set.** An SPL multisig account has 11 signer slots, of which the first `n` are in use. The SDK reads those `n` signers and compares them with the claimed `signers` as a set: order doesn't matter. It compares the threshold `m` only when the claim has one. **Squads vaults and other PDA authorities aren't supported.** A Squads vault is a program-derived address (PDA) controlled by the Squads program. It isn't an SPL Token multisig. Listed as a multisig, it fails `multisig is owned by a token program`, or `multisig account exists` if the vault account was never funded. Listed as a regular authority, it can't sign a message, because a PDA has no private key. **Only "now".** A Solana RPC serves current account state. It can't tell you who the mint authority was last month. The SDK's Solana checker doesn't try to read historical state, so every moment except `"now"` is `unverifiable` with `Solana RPC cannot read historical account state`. That includes signature verifications. **Addresses are exact.** Base58 is case-sensitive. The SDK compares Solana addresses byte for byte. Changing the case of one letter makes a different address. ## Check it yourself To re-check a verification by hand, verify its signature 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`. ### Synthetic data (fixed public test keys) This script uses a test key pair built from fixed bytes. It's public, holds nothing, and must never be used on-chain. The script signs Bluprynt's multi-line wallet challenge raw, as a wallet app would, and the one-line CLI challenge in the legacy off-chain format, as the CLI would. It builds a lookup in the shape the API returns for a Solana token. It then checks the signatures twice: with Kit alone, and through the SDK. Neither needs a network. ```ts import { fileURLToPath } from "node:url"; import { checkKyiVerification, verifyKyiResponse, } from "@bluprynt/sdk-chain-solana"; import { type Address, createKeyPairFromPrivateKeyBytes, fixCodecSize, getAddressFromPublicKey, getBase16Decoder, getBase58Decoder, getBase58Encoder, getBytesCodec, getPublicKeyFromAddress, getTupleCodec, getU8Codec, getU16Codec, getUtf8Codec, type ReadonlyUint8Array, type SignatureBytes, signBytes, verifySignature, } from "@solana/kit"; type KyiTokenLookup = Parameters[0]; type KyiVerification = Parameters[0]; type KyiSignatureScheme = "solana_raw" | "solana_offchain_v0" | null; // Public test keys: anyone can rebuild them from these bytes, so they must never hold funds or authority on-chain. const WALLET_SECRET = new Uint8Array(32).fill(1); const MINT_SECRET = new Uint8Array(32).fill(2); const CAIP2 = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"; const VERIFIED_AT = "2026-10-01T12:00:00.000Z"; const utf8 = getUtf8Codec(); const legacyOffchain = getTupleCodec([ fixCodecSize(getBytesCodec(), 16), getU8Codec(), getU8Codec(), getU16Codec(), getBytesCodec(), ]); const signingDomain = Uint8Array.from([ 0xff, ...utf8.encode("solana offchain"), ]); function legacyOffchainMessage(message: string): ReadonlyUint8Array { const body = utf8.encode(message); return legacyOffchain.encode([signingDomain, 0, 0, body.length, body]); } async function verifiesFor( wallet: Address, signature: string, bytes: ReadonlyUint8Array, ) { return verifySignature( await getPublicKeyFromAddress(wallet), getBase58Encoder().encode(signature) as SignatureBytes, bytes, ); } export async function run() { const keys = await createKeyPairFromPrivateKeyBytes(WALLET_SECRET); const wallet = await getAddressFromPublicKey(keys.publicKey); const mint = await getAddressFromPublicKey( (await createKeyPairFromPrivateKeyBytes(MINT_SECRET)).publicKey, ); const walletMessage = [ "Please, sign this message to verify your Bluprynt KYI credentials", "", "Purpose: Verify wallet ownership", "Issued to: example", `Address: ${wallet}::1790000000000::example-nonce`, ].join("\n"); const cliMessage = `Bluprynt wallet verification || Issued to: example || Address: ${wallet}::1790000000000::example-nonce`; const offchainBytes = legacyOffchainMessage(cliMessage); const base58 = getBase58Decoder(); const rawSignature = base58.decode( await signBytes(keys.privateKey, utf8.encode(walletMessage)), ); const offchainSignature = base58.decode( await signBytes(keys.privateKey, offchainBytes), ); const formats = async (signature: string, message: string) => ({ overRawMessage: await verifiesFor(wallet, signature, utf8.encode(message)), overOffchainMessage: await verifiesFor( wallet, signature, legacyOffchainMessage(message), ), }); const token = { caip19: `${CAIP2}/token:${mint}`, caip2: CAIP2, chain_slug: "solana", address: mint, standard: null, decimals: null, verified_at: VERIFIED_AT, }; const authority = { type: "mint_authority", address: wallet, account_type: "regular" as const, signers: [], threshold: null, observed_at: VERIFIED_AT, }; const verification = ( scheme: KyiSignatureScheme, message: string, signature: string, ): KyiVerification => ({ verified_at: VERIFIED_AT, expires_at: null, wallet_address: wallet, authority, method: { type: "signature", scheme, message, signature, signer_public_key: null, }, }); const lookup: KyiTokenLookup = { kyi_verified: true, data: [ { kyi_verified: true, token, asset: { slug: null, name: "Example Token", symbol: "EXMPL", description: null, website_url: null, whitepaper_url: null, }, issuer: null, authorities: [authority], verifications: [ verification("solana_raw", walletMessage, rawSignature), verification("solana_offchain_v0", cliMessage, offchainSignature), ], }, ], }; const proof = async ( scheme: KyiSignatureScheme, message: string, signature: string, ) => ( await checkKyiVerification( verification(scheme, message, signature), token, ) ).proof; return { wallet, mint, messages: { wallet: walletMessage, cli: cliMessage }, legacyOffchainHeader: getBase16Decoder().decode(offchainBytes.slice(0, 20)), signatures: { raw: rawSignature, offchain: offchainSignature }, independent: { rawSignature: await formats(rawSignature, walletMessage), offchainSignature: await formats(offchainSignature, cliMessage), }, sdk: { report: await verifyKyiResponse(lookup), rawSignatureRecordedAsOffchain: await proof( "solana_offchain_v0", walletMessage, rawSignature, ), offchainSignatureRecordedAsRaw: await proof( "solana_raw", cliMessage, offchainSignature, ), offchainSignatureWithoutScheme: await proof( null, cliMessage, offchainSignature, ), }, }; } 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: ```json { "wallet": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9", "mint": "9hSR6S7WPtxmTojgo6GG3k4yDPecgJY292j7xrsUGWBu", "messages": { "wallet": "Please, sign this message to verify your Bluprynt KYI credentials\n\nPurpose: Verify wallet ownership\nIssued to: example\nAddress: AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9::1790000000000::example-nonce", "cli": "Bluprynt wallet verification || Issued to: example || Address: AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9::1790000000000::example-nonce" }, "legacyOffchainHeader": "ff736f6c616e61206f6666636861696e00008900", "signatures": { "raw": "3J7c4noSLt9FQuFz4cARW2fWYvJKmxd91xjgM3rfN6F8SHZ28CMWumDqzYuHGvByf4UJpgHixakxrsY6pQL3pmn8", "offchain": "4DGEkBZHtRdLRU7qU7ZA5qK6pSKAJgSqELbyEYjL3hm7S5VtsutXHoeSysWmKArkGZd3QwPK7TWtTD1GohdXQoCY" }, "independent": { "rawSignature": { "overRawMessage": true, "overOffchainMessage": false }, "offchainSignature": { "overRawMessage": false, "overOffchainMessage": true } }, "sdk": { "report": { "claimed": true, "evidence": "confirmed", "verified": true, "matchesClaim": true, "tokens": [ { "caip19": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:9hSR6S7WPtxmTojgo6GG3k4yDPecgJY292j7xrsUGWBu", "claimed": true, "evidence": "confirmed", "verified": true, "matchesClaim": true, "checks": [ { "kind": "verification", "subject": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9", "method": "signature:solana_raw", "proof": { "status": "valid", "steps": [ { "check": "wallet is the authority or one of its signers", "via": "offline", "ok": true, "expected": [ "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" ], "actual": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" }, { "check": "solana_raw signature verifies for wallet", "via": "offline", "ok": true }, { "check": "verification's authority is listed for the token", "via": "offline", "ok": true, "expected": [ { "type": "mint_authority", "address": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" } ], "actual": { "type": "mint_authority", "address": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" } } ] }, "at": {} }, { "kind": "verification", "subject": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9", "method": "signature:solana_offchain_v0", "proof": { "status": "valid", "steps": [ { "check": "wallet is the authority or one of its signers", "via": "offline", "ok": true, "expected": [ "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" ], "actual": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" }, { "check": "solana_offchain_v0 signature verifies for wallet", "via": "offline", "ok": true }, { "check": "verification's authority is listed for the token", "via": "offline", "ok": true, "expected": [ { "type": "mint_authority", "address": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" } ], "actual": { "type": "mint_authority", "address": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" } } ] }, "at": {} }, { "kind": "authority", "subject": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9", "method": "mint_authority", "at": {} } ], "at": {} } ], "at": {} }, "rawSignatureRecordedAsOffchain": { "status": "invalid", "reason": "solana_offchain_v0 signature verifies for wallet failed", "steps": [ { "check": "wallet is the authority or one of its signers", "via": "offline", "ok": true, "expected": [ "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" ], "actual": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" }, { "check": "solana_offchain_v0 signature verifies for wallet", "via": "offline", "ok": false } ] }, "offchainSignatureRecordedAsRaw": { "status": "invalid", "reason": "solana_raw signature verifies for wallet failed", "steps": [ { "check": "wallet is the authority or one of its signers", "via": "offline", "ok": true, "expected": [ "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" ], "actual": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" }, { "check": "solana_raw signature verifies for wallet", "via": "offline", "ok": false } ] }, "offchainSignatureWithoutScheme": { "status": "valid", "steps": [ { "check": "wallet is the authority or one of its signers", "via": "offline", "ok": true, "expected": [ "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" ], "actual": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" }, { "check": "signature verifies for wallet (scheme not recorded, matched solana_offchain_v0)", "via": "offline", "ok": true } ] } } } ``` What it shows: * `messages`: the wallet challenge has newlines; the CLI challenge is one line of printable ASCII. * `legacyOffchainHeader` is the 20-byte header of the CLI challenge in hex: the signing domain, version `00`, format `00`, and the length. * `independent`: for each message, its signature verifies only over the bytes it was made from, raw or with the header. * `sdk.report`: both verifications are `valid` when recorded with their real scheme. * `sdk.rawSignatureRecordedAsOffchain` and `sdk.offchainSignatureRecordedAsRaw`: swapping the scheme makes the proof `invalid`. * `sdk.offchainSignatureWithoutScheme`: with no scheme, the SDK finds the format that matches and names it. Kit and the SDK agree: each signature verifies only in the format it was made in, and a proof recorded with the other format is `invalid`. Source: [`examples/chains/src/solana.ts`](https://github.com/blupryntco/sdk/blob/main/examples/chains/src/solana.ts) · output: [`examples/chains/output/solana.json`](https://github.com/blupryntco/sdk/blob/main/examples/chains/output/solana.json) ### With the Solana CLI (not run by our tests) You can sign and verify the same way with the Solana CLI. See [Off-chain message signing with the Solana CLI](https://docs.anza.xyz/cli/examples/sign-offchain-message) for the exact options of your CLI version. `message.txt` holds the one-line CLI challenge, with no trailing newline. ```sh solana sign-offchain-message --keypair wallet.json "$(cat message.txt)" solana verify-offchain-signature --signer "$(cat message.txt)" ``` ### At a moment: synthetic data (recorded RPC responses) {#at-a-moment} This script builds a lookup with two made-up tokens. In both, the test wallet is the listed `mint_authority`, and it signed Bluprynt's challenge raw. It asks for `at: ["verified", "now"]`. In the recorded chain, the first mint is a Token-2022 mint whose mint authority is still the wallet. The second is an SPL Token mint whose mint authority was handed to another key. The RPC responses are recorded in [`src/fixtures/solana-now.json`](https://github.com/blupryntco/sdk/blob/main/examples/chains/src/fixtures/solana-now.json), so the example runs offline. `pnpm -C examples/chains fixtures` regenerates them. ```ts import { fileURLToPath } from "node:url"; import { verifyKyiResponse } from "@bluprynt/sdk-chain-solana"; import { type Address, createKeyPairFromPrivateKeyBytes, getAddressFromPublicKey, getBase58Decoder, getUtf8Encoder, signBytes, } from "@solana/kit"; import fixture from "./fixtures/solana-now.json" with { type: "json" }; import { withRecordedFetch } from "./replay.ts"; type KyiTokenLookup = Parameters[0]; type KyiTokenLookupItem = KyiTokenLookup["data"][number]; // Public test keys: anyone can rebuild them from these bytes, and the mint accounts recorded in fixtures/solana-now.json are synthetic. const WALLET_SECRET = new Uint8Array(32).fill(1); const KEPT_MINT_SECRET = new Uint8Array(32).fill(2); const HANDED_OVER_MINT_SECRET = new Uint8Array(32).fill(3); const CAIP2 = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"; const RPC_URL = "https://solana.rpc.example.invalid"; const VERIFIED_AT = "2026-10-01T12:00:00.000Z"; const addressOf = async (secret: Uint8Array) => getAddressFromPublicKey( (await createKeyPairFromPrivateKeyBytes(secret)).publicKey, ); export async function run() { const keys = await createKeyPairFromPrivateKeyBytes(WALLET_SECRET); const wallet = await getAddressFromPublicKey(keys.publicKey); const keptMint = await addressOf(KEPT_MINT_SECRET); const handedOverMint = await addressOf(HANDED_OVER_MINT_SECRET); const message = [ "Please, sign this message to verify your Bluprynt KYI credentials", "", "Purpose: Verify wallet ownership", "Issued to: example", `Address: ${wallet}::1790856000000::example-nonce`, ].join("\n"); const signature = getBase58Decoder().decode( await signBytes(keys.privateKey, getUtf8Encoder().encode(message)), ); const mintToken = (mint: Address, symbol: string): KyiTokenLookupItem => { const authority = { type: "mint_authority", address: wallet, account_type: "regular" as const, signers: [], threshold: null, observed_at: VERIFIED_AT, }; return { kyi_verified: true, token: { caip19: `${CAIP2}/token:${mint}`, caip2: CAIP2, chain_slug: "solana", address: mint, standard: null, decimals: 6, verified_at: VERIFIED_AT, }, asset: { slug: null, name: `Example ${symbol}`, symbol, description: null, website_url: null, whitepaper_url: null, }, issuer: null, authorities: [authority], verifications: [ { verified_at: VERIFIED_AT, expires_at: null, wallet_address: wallet, authority, method: { type: "signature", scheme: "solana_raw", message, signature, signer_public_key: null, }, }, ], }; }; const lookup: KyiTokenLookup = { kyi_verified: true, data: [mintToken(keptMint, "KEPT"), mintToken(handedOverMint, "MOVED")], }; return { wallet, mints: { kept: keptMint, handedOver: handedOverMint }, 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: ```json { "wallet": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9", "mints": { "kept": "9hSR6S7WPtxmTojgo6GG3k4yDPecgJY292j7xrsUGWBu", "handedOver": "GyGKxMyg1p9SsHfm15MkNUu1u9TN2JtTspcdmrtGUdse" }, "report": { "claimed": true, "evidence": "confirmed", "verified": true, "matchesClaim": true, "tokens": [ { "caip19": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:9hSR6S7WPtxmTojgo6GG3k4yDPecgJY292j7xrsUGWBu", "claimed": true, "evidence": "confirmed", "verified": true, "matchesClaim": true, "checks": [ { "kind": "verification", "subject": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9", "method": "signature:solana_raw", "proof": { "status": "valid", "steps": [ { "check": "wallet is the authority or one of its signers", "via": "offline", "ok": true, "expected": [ "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" ], "actual": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" }, { "check": "solana_raw signature verifies for wallet", "via": "offline", "ok": true }, { "check": "verification's authority is listed for the token", "via": "offline", "ok": true, "expected": [ { "type": "mint_authority", "address": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" } ], "actual": { "type": "mint_authority", "address": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" } } ] }, "at": { "verified": { "status": "unverifiable", "reason": "Solana RPC cannot read historical account state", "steps": [] }, "now": { "status": "valid", "steps": [ { "check": "solana_raw signature verifies for wallet", "via": "offline", "ok": true }, { "check": "mint account exists", "via": "rpc", "ok": true }, { "check": "mint is owned by a token program", "via": "rpc", "ok": true, "expected": [ "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" ], "actual": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" }, { "check": "account is a token mint", "via": "rpc", "ok": true }, { "check": "mint authority", "via": "rpc", "ok": true, "expected": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9", "actual": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" } ], "position": { "slot": "412345678" } } } }, { "kind": "authority", "subject": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9", "method": "mint_authority", "at": { "verified": { "status": "unverifiable", "reason": "Solana RPC cannot read historical account state", "steps": [] }, "now": { "status": "valid", "steps": [ { "check": "mint account exists", "via": "rpc", "ok": true }, { "check": "mint is owned by a token program", "via": "rpc", "ok": true, "expected": [ "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" ], "actual": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" }, { "check": "account is a token mint", "via": "rpc", "ok": true }, { "check": "mint authority", "via": "rpc", "ok": true, "expected": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9", "actual": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" } ], "position": { "slot": "412345678" } } } } ], "at": { "verified": { "evidence": "inconclusive", "verified": false, "matchesClaim": null }, "now": { "evidence": "confirmed", "verified": true, "matchesClaim": true } } }, { "caip19": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:GyGKxMyg1p9SsHfm15MkNUu1u9TN2JtTspcdmrtGUdse", "claimed": true, "evidence": "confirmed", "verified": true, "matchesClaim": true, "checks": [ { "kind": "verification", "subject": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9", "method": "signature:solana_raw", "proof": { "status": "valid", "steps": [ { "check": "wallet is the authority or one of its signers", "via": "offline", "ok": true, "expected": [ "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" ], "actual": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" }, { "check": "solana_raw signature verifies for wallet", "via": "offline", "ok": true }, { "check": "verification's authority is listed for the token", "via": "offline", "ok": true, "expected": [ { "type": "mint_authority", "address": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" } ], "actual": { "type": "mint_authority", "address": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9" } } ] }, "at": { "verified": { "status": "unverifiable", "reason": "Solana RPC cannot read historical account state", "steps": [] }, "now": { "status": "invalid", "reason": "mint authority failed", "steps": [ { "check": "solana_raw signature verifies for wallet", "via": "offline", "ok": true }, { "check": "mint account exists", "via": "rpc", "ok": true }, { "check": "mint is owned by a token program", "via": "rpc", "ok": true, "expected": [ "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" ], "actual": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" }, { "check": "account is a token mint", "via": "rpc", "ok": true }, { "check": "mint authority", "via": "rpc", "ok": false, "expected": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9", "actual": "EdmxWPmx2WH6WgFfTdu9xfkYf3k1g5wD1zccTVySEEh1" } ], "position": { "slot": "412345679" } } } }, { "kind": "authority", "subject": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9", "method": "mint_authority", "at": { "verified": { "status": "unverifiable", "reason": "Solana RPC cannot read historical account state", "steps": [] }, "now": { "status": "invalid", "reason": "mint authority failed", "steps": [ { "check": "mint account exists", "via": "rpc", "ok": true }, { "check": "mint is owned by a token program", "via": "rpc", "ok": true, "expected": [ "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" ], "actual": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" }, { "check": "account is a token mint", "via": "rpc", "ok": true }, { "check": "mint authority", "via": "rpc", "ok": false, "expected": "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9", "actual": "EdmxWPmx2WH6WgFfTdu9xfkYf3k1g5wD1zccTVySEEh1" } ], "position": { "slot": "412345679" } } } } ], "at": { "verified": { "evidence": "inconclusive", "verified": false, "matchesClaim": null }, "now": { "evidence": "contradicted", "verified": false, "matchesClaim": false } } } ], "at": { "verified": { "evidence": "inconclusive", "verified": false, "matchesClaim": null }, "now": { "evidence": "confirmed", "verified": true, "matchesClaim": true } } } } ``` * `at.verified`: `unverifiable` with `Solana RPC cannot read historical account state`, for both tokens. So each token's `at.verified.evidence` is `inconclusive`. * `at.now` for the first token, read at slot 412345678: the mint authority is the wallet. `valid`, and the token's `at.now.evidence` is `confirmed`. * `at.now` for the second token, read at slot 412345679: the mint authority is another key, so the result is `invalid` with `mint authority failed`. The token's `at.now.evidence` is `contradicted`. The response-level `at.now.evidence` is `confirmed`, because one token is confirmed. For a badge, read each token's own report. See [Showing a badge](/sdk/badge). Source: [`examples/chains/src/solana-now.ts`](https://github.com/blupryntco/sdk/blob/main/examples/chains/src/solana-now.ts) · output: [`examples/chains/output/solana-now.json`](https://github.com/blupryntco/sdk/blob/main/examples/chains/output/solana-now.json) ## Limits These come back `unverifiable` on Solana: * **Every moment except `"now"`.** The RPC can't read past account state. The SDK can't tell either way. * **Transfers and contract signatures.** The SDK doesn't re-check them on Solana. * **`admin_manual` verifications.** There is nothing to re-check. * **Authority types other than `mint_authority` and `freeze_authority`.** A multisig of another type is partly checked: see [Solana checks](/reference/solana#authorities). * **Expired verifications,** at moments at or after `expires_at`. --- --- url: /chains/xrpl.md --- # XRPL This page covers the XRP Ledger: how a wallet proves control, how Bluprynt records the proof, and what `@bluprynt/sdk-chain-xrpl` re-checks. For the report format, see [Reading the report](/sdk/report). ## How wallets prove control on XRPL An XRPL account has a **classic address**, such as `r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC`. The address is a hash of a public key, encoded in base58. The key can be secp256k1 or Ed25519. Ed25519 public keys are written with an `ED` prefix. The key the address comes from is the account's **[master key](/reference/glossary#master-key)**. An account can also have other keys: * **[Regular key](/reference/glossary#regular-key).** The account owner can assign one more key pair with a `SetRegularKey` transaction. It can sign for the account like the master key. It can be changed or removed later. * **Disabled master key.** The owner can set the `lsfDisableMaster` flag. After that, the master key can't sign for the account. Only the regular key (or a signer list) can. * **Signer list.** A list of other accounts and a quorum, for multisigning. So on XRPL, "who can sign for this address" is account state. It can change over time. A wallet proves control by signing a message with one of these keys. The signature is hex. Since the address is only a hash of the key, the address alone can't check a signature. The public key has to travel with it. Bluprynt handles **issued currencies** (IOUs). An issued currency is a currency code plus the account that issues it, for example `USD` issued by `r3sN…6dTC`. Bluprynt writes a token's address as `CURRENCY.rIssuer`, such as `USD.r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC`. The issuer is the authority that matters. Chains are named by [CAIP-2](/reference/glossary#caip) IDs: `xrpl:0` for mainnet, `xrpl:1` for testnet and `xrpl:2` for devnet. `rpcUrls` is keyed by them. A token's CAIP-19 ID has the form `xrpl:0/iou:CURRENCY.rIssuer`. ## How Bluprynt verifies it The flows are described in [Wallet verification](/wallet-verification). On XRPL: * **Wallet extension.** The user connects through XRPL Connect and signs a Bluprynt-generated message. * **CLI / programmatic.** The user signs the challenge with xrpl.js. See its [GitHub repo](https://github.com/XRPLF/xrpl.js) and [reference docs](https://js.xrpl.org/). * **On-chain transaction.** Not used: [transfer proofs aren't available on XRPL today](/wallet-verification#onchain-transaction). The SDK re-checks signature verifications: | `method` | Used for | Stored evidence | | --- | --- | --- | | `signature` / `xrpl` | XRPL wallets | `message`, `signature` (hex), `signer_public_key` | | `admin_manual` | Set by hand | Nothing to re-check | Every verification also names the `wallet_address` and the `authority` it backs. On XRPL, the authority is the token's `issuer`. ## What the SDK checks Offline, with no `at`, the SDK checks the signature against the recorded key. It then checks whether that key is the wallet's master key. It never calls a server. With `at`, it reads the wallet's and the issuer's accounts at the ledger for each moment. For `"now"`, that's the latest validated ledger. For a past instant, it's the last ledger closed at or before it. | Question | How it's answered | | --- | --- | | Did this key sign the message? | `verify` from `ripple-keypairs`, over the message's UTF-8 bytes in hex. Offline | | Is the key the wallet's master key? | `deriveAddress` from `ripple-keypairs` gives the wallet address. Offline | | Could the key sign for the wallet at that ledger? | `account_info` on the wallet: the master key not disabled by `lsfDisableMaster`, or the account's `RegularKey` | | Is the authority the token's issuer? | The issuer is parsed from the token address. Offline step, run at moments | | Did the issuer account exist at that ledger? | `account_info` on the issuer | | Which ledger is the moment? | `ledger` for close times, with a binary search, within the range `server_info` reports in `complete_ledgers` | The [XRPL checks](/reference/xrpl) reference lists every step and reason by name. ## Quirks **The public key travels separately.** On EVM, the signer's address is recovered from the signature. On Solana, the address is the key. On XRPL, neither works: the address is a hash of the key. So Bluprynt stores `signer_public_key` next to the signature. Without it, the proof is `unverifiable`. **The address derives from the key.** `deriveAddress(signer_public_key)` gives an address. If it's the wallet, the key is the master key, and the `proof` result is `valid`. If it isn't, the key may be a regular key, and only account state can tell. **Regular keys and `lsfDisableMaster`.** Offline, the SDK accepts a master-key signature without reading the account. It can't see `lsfDisableMaster` offline. At a moment, it reads the account and checks both: a master key counts only if it wasn't disabled then, and any other key counts only if it was the `RegularKey` then. A key that wasn't authorized, or an account that didn't exist, makes the verification `invalid` at that moment. **Signer lists aren't supported.** An account can authorize a signer list for multisigning. The SDK doesn't read signer lists. At a moment, a key that is neither the master key nor the `RegularKey`, on an account that has a signer list, is `unverifiable` with `XRPL signer lists are not supported`. A master key that the account disabled stays `invalid`, signer list or not. **Pruned servers.** Most XRPL servers keep only recent ledgers. Their `server_info` reports what they have in `complete_ledgers`, such as `32570-96000000`. The SDK reads that range. A moment outside it is `unverifiable` with `XRPL server has no ledger `, not `invalid`. To check past moments, use a server whose history covers them: check its `complete_ledgers` before you rely on it. Public servers are often pruned, and they rate-limit. **Gaps in history.** `complete_ledgers` can list several ranges, such as `32570-50000,60000-96000000`. The SDK uses only the range that contains the latest validated ledger. A moment before that range is `unverifiable`, even if an older range covers it. A server that reports `empty` has no validated ledger to read, so the call throws. See [RPC and errors](/sdk/rpc#what-throws-and-what-s-reported). **Finding a past ledger takes many requests.** XRPL has no "ledger at time" lookup, so the SDK binary-searches ledger close times. On a server with long history, that's roughly 20 to 30 `ledger` requests for each distinct point in time. The SDK shares the search between the checks of one token, not across tokens. **WebSocket only.** The SDK talks to XRPL servers through the `Client` of `xrpl.js`, which uses WebSocket. RPC URLs must be `wss://` (or `ws://`). A server that offers only JSON-RPC over HTTP won't work. **Keys are uppercased.** The SDK uppercases `signer_public_key` before it verifies the signature or derives an address, so a key recorded in lowercase hex works too. **Issuer from `CURRENCY.rIssuer`.** The SDK takes the issuer from the token address: everything after the last `.`. Addresses are compared exactly. ## Check it yourself To re-check a verification by hand, verify its signature as shown below, and check the ledger side at a moment. 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`. ### Synthetic data (fixed public test keys) This script uses two test seeds, one Ed25519 and one secp256k1. They're public, hold nothing, and must never be funded or set on a real account. The script signs a challenge with each key and builds a lookup with two tokens. The first token's issuer has two verifications: one signed with its master key, and one signed with the secp256k1 key acting as its regular key. It then checks the signatures twice: first with `ripple-keypairs` alone, then through the SDK. Last, it changes one line of a signed message and checks it again both ways. Neither needs a network. ```ts import { fileURLToPath } from "node:url"; import { checkKyiVerification, verifyKyiResponse, } from "@bluprynt/sdk-chain-xrpl"; import { deriveAddress, deriveKeypair, sign, verify } from "ripple-keypairs"; import { convertStringToHex } from "xrpl"; type KyiTokenLookup = Parameters[0]; type KyiTokenLookupItem = KyiTokenLookup["data"][number]; type KyiVerification = KyiTokenLookupItem["verifications"][number]; // Public test seeds: anyone can rebuild these keys, so they must never be funded or set on a real account. const ED25519_SEED = "sEdSKaVGtEer9RrxMSMhFM2WVSW5LT3"; const SECP256K1_SEED = "sp5vYGGekvDhXJSn6f7oPdcjheQXW"; const CAIP2 = "xrpl:0"; const VERIFIED_AT = "2026-10-01T12:00:00.000Z"; function account(seed: string) { const keys = deriveKeypair(seed); return { ...keys, address: deriveAddress(keys.publicKey) }; } const challenge = (wallet: string) => [ "Please, sign this message to verify your Bluprynt KYI credentials", "", "Purpose: Verify wallet ownership", "Issued to: example", `Address: ${wallet}::1790000000000::example-nonce`, ].join("\n"); function signed(signer: ReturnType, wallet: string) { const message = challenge(wallet); const signature = sign(convertStringToHex(message), signer.privateKey); return { wallet, message, signature, signerPublicKey: signer.publicKey }; } function independentCheck({ wallet, message, signature, signerPublicKey, }: ReturnType) { const derivedAddress = deriveAddress(signerPublicKey); return { wallet, signerPublicKey, signatureVerifies: verify( convertStringToHex(message), signature, signerPublicKey, ), derivedAddress, derivedAddressIsWallet: derivedAddress === wallet, }; } function issuerToken( currency: string, issuer: string, proofs: ReturnType[], ): KyiTokenLookupItem { const authority = { type: "issuer", address: issuer, account_type: "regular" as const, signers: [], threshold: null, observed_at: VERIFIED_AT, }; const verifications: KyiVerification[] = proofs.map((p) => ({ verified_at: VERIFIED_AT, expires_at: null, wallet_address: p.wallet, authority, method: { type: "signature", scheme: "xrpl", message: p.message, signature: p.signature, signer_public_key: p.signerPublicKey, }, })); return { kyi_verified: true, token: { caip19: `${CAIP2}/iou:${currency}.${issuer}`, caip2: CAIP2, chain_slug: "xrpl", address: `${currency}.${issuer}`, standard: null, decimals: null, verified_at: VERIFIED_AT, }, asset: { slug: null, name: `Example ${currency}`, symbol: currency, description: null, website_url: null, whitepaper_url: null, }, issuer: null, authorities: [authority], verifications, }; } export async function run() { const ed = account(ED25519_SEED); const secp = account(SECP256K1_SEED); const edMaster = signed(ed, ed.address); const secpMaster = signed(secp, secp.address); const edWithRegularKey = signed(secp, ed.address); const tampered = { ...edMaster, message: edMaster.message.replace( "Issued to: example", "Issued to: someone-else", ), }; const lookup: KyiTokenLookup = { kyi_verified: true, data: [ issuerToken("USD", ed.address, [edMaster, edWithRegularKey]), issuerToken("EUR", secp.address, [secpMaster]), ], }; const tamperedItem = issuerToken("USD", ed.address, [tampered]); const [tamperedVerification] = tamperedItem.verifications; if (!tamperedVerification) throw new Error("no tampered verification"); return { independent: { ed25519MasterKey: independentCheck(edMaster), secp256k1MasterKey: independentCheck(secpMaster), regularKey: independentCheck(edWithRegularKey), tamperedMessage: independentCheck(tampered), }, sdk: { report: await verifyKyiResponse(lookup), tamperedMessage: ( await checkKyiVerification(tamperedVerification, tamperedItem.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: ```json { "independent": { "ed25519MasterKey": { "wallet": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC", "signerPublicKey": "ED06895BEC3FDE4090F06D840770D888D49E3089B3757C4285E3851BC33964E0F9", "signatureVerifies": true, "derivedAddress": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC", "derivedAddressIsWallet": true }, "secp256k1MasterKey": { "wallet": "raa1x16A7hZRavaSTL8F8LQhFw7i3cUa4A", "signerPublicKey": "03FBFD40E353E4829EE084AB6D150980D880C430C2BE12C567A732011B95EAA124", "signatureVerifies": true, "derivedAddress": "raa1x16A7hZRavaSTL8F8LQhFw7i3cUa4A", "derivedAddressIsWallet": true }, "regularKey": { "wallet": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC", "signerPublicKey": "03FBFD40E353E4829EE084AB6D150980D880C430C2BE12C567A732011B95EAA124", "signatureVerifies": true, "derivedAddress": "raa1x16A7hZRavaSTL8F8LQhFw7i3cUa4A", "derivedAddressIsWallet": false }, "tamperedMessage": { "wallet": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC", "signerPublicKey": "ED06895BEC3FDE4090F06D840770D888D49E3089B3757C4285E3851BC33964E0F9", "signatureVerifies": false, "derivedAddress": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC", "derivedAddressIsWallet": true } }, "sdk": { "report": { "claimed": true, "evidence": "confirmed", "verified": true, "matchesClaim": true, "tokens": [ { "caip19": "xrpl:0/iou:USD.r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC", "claimed": true, "evidence": "confirmed", "verified": true, "matchesClaim": true, "checks": [ { "kind": "verification", "subject": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC", "method": "signature:xrpl", "proof": { "status": "valid", "steps": [ { "check": "wallet is the authority or one of its signers", "via": "offline", "ok": true, "expected": [ "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" ], "actual": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" }, { "check": "xrpl signature verifies for signer key", "via": "offline", "ok": true }, { "check": "signer key is the wallet's master key", "via": "offline", "ok": true }, { "check": "verification's authority is listed for the token", "via": "offline", "ok": true, "expected": [ { "type": "issuer", "address": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" } ], "actual": { "type": "issuer", "address": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" } } ] }, "at": {} }, { "kind": "verification", "subject": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC", "method": "signature:xrpl", "proof": { "status": "unverifiable", "reason": "signer is not the master key; online check needed", "steps": [ { "check": "wallet is the authority or one of its signers", "via": "offline", "ok": true, "expected": [ "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" ], "actual": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" }, { "check": "xrpl signature verifies for signer key", "via": "offline", "ok": true }, { "check": "verification's authority is listed for the token", "via": "offline", "ok": true, "expected": [ { "type": "issuer", "address": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" } ], "actual": { "type": "issuer", "address": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" } } ] }, "at": {} }, { "kind": "authority", "subject": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC", "method": "issuer", "at": {} } ], "at": {} }, { "caip19": "xrpl:0/iou:EUR.raa1x16A7hZRavaSTL8F8LQhFw7i3cUa4A", "claimed": true, "evidence": "confirmed", "verified": true, "matchesClaim": true, "checks": [ { "kind": "verification", "subject": "raa1x16A7hZRavaSTL8F8LQhFw7i3cUa4A", "method": "signature:xrpl", "proof": { "status": "valid", "steps": [ { "check": "wallet is the authority or one of its signers", "via": "offline", "ok": true, "expected": [ "raa1x16A7hZRavaSTL8F8LQhFw7i3cUa4A" ], "actual": "raa1x16A7hZRavaSTL8F8LQhFw7i3cUa4A" }, { "check": "xrpl signature verifies for signer key", "via": "offline", "ok": true }, { "check": "signer key is the wallet's master key", "via": "offline", "ok": true }, { "check": "verification's authority is listed for the token", "via": "offline", "ok": true, "expected": [ { "type": "issuer", "address": "raa1x16A7hZRavaSTL8F8LQhFw7i3cUa4A" } ], "actual": { "type": "issuer", "address": "raa1x16A7hZRavaSTL8F8LQhFw7i3cUa4A" } } ] }, "at": {} }, { "kind": "authority", "subject": "raa1x16A7hZRavaSTL8F8LQhFw7i3cUa4A", "method": "issuer", "at": {} } ], "at": {} } ], "at": {} }, "tamperedMessage": { "status": "invalid", "reason": "xrpl signature verifies for signer key failed", "steps": [ { "check": "wallet is the authority or one of its signers", "via": "offline", "ok": true, "expected": [ "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" ], "actual": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" }, { "check": "xrpl signature verifies for signer key", "via": "offline", "ok": false } ] } } } ``` What it shows: * `independent`: every signature verifies for its key. For both master keys, the derived address is the wallet. For the regular key, it's a different address. The signature over the tampered message doesn't verify. * `sdk.report`: both master-key proofs are `valid`. The regular-key proof is `unverifiable` with `signer is not the master key; online check needed`. Only the ledger can say whether that key was the wallet's `RegularKey`. * `sdk.tamperedMessage`: the proof is `invalid` with `xrpl signature verifies for signer key failed`. `ripple-keypairs` and the SDK agree: the SDK's `xrpl signature verifies for signer key` step matches `signatureVerifies`, and its master-key step matches `derivedAddressIsWallet`. Source: [`examples/chains/src/xrpl.ts`](https://github.com/blupryntco/sdk/blob/main/examples/chains/src/xrpl.ts) · output: [`examples/chains/output/xrpl.json`](https://github.com/blupryntco/sdk/blob/main/examples/chains/output/xrpl.json) ### At a moment: synthetic data (recorded RPC responses) {#at-a-moment} Whether a key could sign for the wallet is account state, so it needs a server. This script builds a lookup for a made-up issued currency. The issuer signed Bluprynt's challenge with its master key. Then it set a regular key and disabled its master key. Then it signed again with the regular key. The script asks for `at: ["verified", "now"]`. The server responses are recorded in [`src/fixtures/xrpl-rotated.json`](https://github.com/blupryntco/sdk/blob/main/examples/chains/src/fixtures/xrpl-rotated.json), so the example runs offline. `pnpm -C examples/chains fixtures` regenerates them. ```ts import { fileURLToPath } from "node:url"; import { verifyKyiResponse, type XrplKyiCheckOptions, } from "@bluprynt/sdk-chain-xrpl"; import { deriveAddress, deriveKeypair, sign } from "ripple-keypairs"; import { convertStringToHex } from "xrpl"; import fixture from "./fixtures/xrpl-rotated.json" with { type: "json" }; import { recordedXrplClient } from "./replay.ts"; type KyiTokenLookup = Parameters[0]; type KyiVerification = KyiTokenLookup["data"][number]["verifications"][number]; // Public test seeds: the recorded ledgers in fixtures/xrpl-rotated.json are synthetic, and these keys must never be set on a real account. const MASTER_SEED = "sEdSKaVGtEer9RrxMSMhFM2WVSW5LT3"; const REGULAR_SEED = "sp5vYGGekvDhXJSn6f7oPdcjheQXW"; const CAIP2 = "xrpl:0"; const RPC_URL = "wss://xrpl.rpc.example.invalid"; const SIGNED_WITH_MASTER_AT = "2026-10-20T12:00:00.000Z"; const SIGNED_WITH_REGULAR_AT = "2026-10-20T13:20:00.000Z"; function account(seed: string) { const keys = deriveKeypair(seed); return { ...keys, address: deriveAddress(keys.publicKey) }; } export async function run() { const master = account(MASTER_SEED); const regular = account(REGULAR_SEED); const issuer = master.address; const authority = { type: "issuer", address: issuer, account_type: "regular" as const, signers: [], threshold: null, observed_at: SIGNED_WITH_MASTER_AT, }; const verification = ( signer: ReturnType, verifiedAt: string, ): KyiVerification => { const message = [ "Please, sign this message to verify your Bluprynt KYI credentials", "", "Purpose: Verify wallet ownership", "Issued to: example", `Address: ${issuer}::${Date.parse(verifiedAt)}::example-nonce`, ].join("\n"); return { verified_at: verifiedAt, expires_at: null, wallet_address: issuer, authority, method: { type: "signature", scheme: "xrpl", message, signature: sign(convertStringToHex(message), signer.privateKey), signer_public_key: signer.publicKey, }, }; }; const lookup: KyiTokenLookup = { kyi_verified: true, data: [ { kyi_verified: true, token: { caip19: `${CAIP2}/iou:USD.${issuer}`, caip2: CAIP2, chain_slug: "xrpl", address: `USD.${issuer}`, standard: null, decimals: null, verified_at: SIGNED_WITH_MASTER_AT, }, asset: { slug: null, name: "Example USD", symbol: "USD", description: null, website_url: null, whitepaper_url: null, }, issuer: null, authorities: [authority], verifications: [ verification(master, SIGNED_WITH_MASTER_AT), verification(regular, SIGNED_WITH_REGULAR_AT), ], }, ], }; const options: XrplKyiCheckOptions = { rpcUrls: { [CAIP2]: RPC_URL }, at: ["verified", "now"], createClient: recordedXrplClient(fixture.records), }; return { issuer, regularKey: regular.address, report: await verifyKyiResponse(lookup, options), }; } 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: ```json { "issuer": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC", "regularKey": "raa1x16A7hZRavaSTL8F8LQhFw7i3cUa4A", "report": { "claimed": true, "evidence": "confirmed", "verified": true, "matchesClaim": true, "tokens": [ { "caip19": "xrpl:0/iou:USD.r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC", "claimed": true, "evidence": "confirmed", "verified": true, "matchesClaim": true, "checks": [ { "kind": "verification", "subject": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC", "method": "signature:xrpl", "proof": { "status": "valid", "steps": [ { "check": "wallet is the authority or one of its signers", "via": "offline", "ok": true, "expected": [ "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" ], "actual": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" }, { "check": "xrpl signature verifies for signer key", "via": "offline", "ok": true }, { "check": "signer key is the wallet's master key", "via": "offline", "ok": true }, { "check": "verification's authority is listed for the token", "via": "offline", "ok": true, "expected": [ { "type": "issuer", "address": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" } ], "actual": { "type": "issuer", "address": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" } } ] }, "at": { "verified": { "status": "valid", "steps": [ { "check": "xrpl signature verifies for signer key", "via": "offline", "ok": true }, { "check": "signer key was authorised for the wallet", "via": "rpc", "ok": true }, { "check": "issuer matches the token", "via": "offline", "ok": true, "expected": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC", "actual": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" }, { "check": "issuer account exists", "via": "rpc", "ok": true } ], "position": { "ledger": 99202077, "time": "2026-10-20T12:00:00.000Z" } }, "now": { "status": "invalid", "reason": "signer key was authorised for the wallet failed", "steps": [ { "check": "xrpl signature verifies for signer key", "via": "offline", "ok": true }, { "check": "signer key was authorised for the wallet", "via": "rpc", "ok": false }, { "check": "issuer matches the token", "via": "offline", "ok": true, "expected": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC", "actual": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" }, { "check": "issuer account exists", "via": "rpc", "ok": true } ], "position": { "ledger": 99204000 } } } }, { "kind": "verification", "subject": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC", "method": "signature:xrpl", "proof": { "status": "unverifiable", "reason": "signer is not the master key; online check needed", "steps": [ { "check": "wallet is the authority or one of its signers", "via": "offline", "ok": true, "expected": [ "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" ], "actual": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" }, { "check": "xrpl signature verifies for signer key", "via": "offline", "ok": true }, { "check": "verification's authority is listed for the token", "via": "offline", "ok": true, "expected": [ { "type": "issuer", "address": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" } ], "actual": { "type": "issuer", "address": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" } } ] }, "at": { "verified": { "status": "valid", "steps": [ { "check": "xrpl signature verifies for signer key", "via": "offline", "ok": true }, { "check": "signer key was authorised for the wallet", "via": "rpc", "ok": true }, { "check": "issuer matches the token", "via": "offline", "ok": true, "expected": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC", "actual": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" }, { "check": "issuer account exists", "via": "rpc", "ok": true } ], "position": { "ledger": 99203307, "time": "2026-10-20T13:19:57.000Z" } }, "now": { "status": "valid", "steps": [ { "check": "xrpl signature verifies for signer key", "via": "offline", "ok": true }, { "check": "signer key was authorised for the wallet", "via": "rpc", "ok": true }, { "check": "issuer matches the token", "via": "offline", "ok": true, "expected": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC", "actual": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" }, { "check": "issuer account exists", "via": "rpc", "ok": true } ], "position": { "ledger": 99204000 } } } }, { "kind": "authority", "subject": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC", "method": "issuer", "at": { "verified": { "status": "valid", "steps": [ { "check": "issuer matches the token", "via": "offline", "ok": true, "expected": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC", "actual": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" }, { "check": "issuer account exists", "via": "rpc", "ok": true } ], "position": { "ledger": 99202077, "time": "2026-10-20T12:00:00.000Z" } }, "now": { "status": "valid", "steps": [ { "check": "issuer matches the token", "via": "offline", "ok": true, "expected": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC", "actual": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC" }, { "check": "issuer account exists", "via": "rpc", "ok": true } ], "position": { "ledger": 99204000 } } } } ], "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 } } } } ``` * The master-key verification: its `proof` result is `valid`. At `"verified"`, ledger 99202077, the master key could sign: `valid`. At `"now"`, ledger 99204000, the master key is disabled, so the result is `invalid` with `signer key was authorised for the wallet failed`. * The regular-key verification: its `proof` result is `unverifiable` with `signer is not the master key; online check needed`. At `"verified"`, ledger 99203307, and at `"now"`, the key is the account's `RegularKey`: `valid`. * The token's `at.verified.evidence` is `confirmed`. Its `at.now.evidence` is `contradicted`, because the master-key check fails now, even though the regular-key check holds. The master-key proof held when verified and no longer holds now. [Showing a badge](/sdk/badge#rotation) suggests how to present that. Source: [`examples/chains/src/xrpl-rotated.ts`](https://github.com/blupryntco/sdk/blob/main/examples/chains/src/xrpl-rotated.ts) · output: [`examples/chains/output/xrpl-rotated.json`](https://github.com/blupryntco/sdk/blob/main/examples/chains/output/xrpl-rotated.json) ### The ledger side by hand (not run by our tests) You can check whether a key could sign for the wallet at a moment with any XRPL server: 1. **Find the ledger.** For `"now"`, use `ledger_index: "validated"`. For a past instant, find the last ledger that closed at or before it. The `ledger` command returns `close_time` in seconds since the Ripple epoch, 2000-01-01T00:00:00Z. Add 946684800 to get Unix time. Binary-search ledger indexes within the server's `complete_ledgers`. 2. **Read the wallet's account at that ledger.** ```json { "command": "account_info", "account": "r3sNTMefq5gsRumMYsNznnX6yzzxVH6dTC", "ledger_index": 99202077, "signer_lists": true } ``` Use the wallet address and the ledger you found. An `actNotFound` error means the account didn't exist at that ledger. 3. **Derive the signer's address** from `signer_public_key` with `deriveAddress` from `ripple-keypairs`. 4. **Decide.** If the derived address is the wallet, the key is the master key. It could sign if `account_data.Flags & 0x00100000` (`lsfDisableMaster`) is `0`. Otherwise, it could sign if `account_data.RegularKey` is the derived address. If the key is neither the master key nor the `RegularKey`, and `signer_lists` isn't empty, the key may belong to the signer list, which the SDK doesn't check. For the `issuer` authority, run `account_info` on the issuer at the same ledger. The account must exist. ## Limits These come back `unverifiable` on XRPL: * **Signatures by a key other than the master key, offline.** Ask for a moment to read the account's `RegularKey`. * **Signatures with no `signer_public_key`.** There's no key to verify against. * **Transfers and contract signatures.** The SDK doesn't re-check them on XRPL. * **`admin_manual` verifications.** There is nothing to re-check. * **Multisig authorities and authority types other than `issuer`.** * **Keys that may belong to a signer list,** at a moment, when the key is neither the master key nor the `RegularKey`. * **Past moments the server doesn't have.** Use a server whose `complete_ledgers` covers them. * **Expired verifications,** at moments at or after `expires_at`. --- --- url: /sdk/moments.md --- # Moments When you look up a token through [Bluprynt's public API](https://integrations.bluprynt.com/) (`GET /kyi/v1/tokens`), the response tells you whether the token passed KYI (`kyi_verified`). It also includes the evidence behind that claim: * **[Authorities](/reference/glossary#authority):** the addresses that control the token, such as its `owner()` or a Safe multisig and its signers. * **[Verifications](/reference/glossary#verification):** the wallet proofs showing that the issuer controls one of those authorities. A proof is a signature, a contract signature or an on-chain transfer (see [Wallet verification](/wallet-verification)). Each verification names the authority it backs, and that authority must be one of the token's listed authorities. The SDK re-checks that evidence against public data and your own RPC, so you don't have to rely on the `kyi_verified` flag alone. ::: warning Experimental The KYI response types follow the public API's current shape. They will change until they are generated from the OpenAPI spec. ::: ## Which moment to ask for The SDK reports [facts, not verdicts](/trust-model#facts-not-verdicts): what held, and when. Chain state changes. A wallet that controlled a token six months ago may not control it today. Whether that matters depends on your question: | Your question | Moment to check | | --- | --- | | Did a claim made six months ago hold when it was made? | `"verified"` | | Is a recent claim backed? | `"verified"` on the newer record, or the instant it was made | | Does the wallet still control the token? | `"now"` | By default the SDK works offline. It checks only timeless facts: who signed what, whether the wallet backs the authority it names, and whether that authority is listed for the token. It makes no RPC calls and needs no `rpcUrls`. To read chain state, ask for moments with `at`: ```ts const report = await verifyKyiResponse(response, { rpcUrls: { "eip155:1": "https://mainnet-eth.rpc.example.com" }, at: ["verified", "now", new Date("2026-04-01T00:00:00Z")], }); ``` Each moment is reported separately, under its own label: * **`"verified"`:** each record's own moment. For a verification, that's its `verified_at`, and its own authority is also read at that time. For a listed authority, it's `observed_at`. An EVM contract signature is read at its recorded block. * **`"now"`:** the latest state the RPC serves. * **A `Date`:** the instant you choose. Its label is the ISO string, such as `"2026-04-01T00:00:00.000Z"`. The SDK doesn't pick a moment for you, and it has no maximum age. If a 6-month-old verification's wallet is no longer an authority, the report says it held then and doesn't hold now: `valid` at `"verified"`, `invalid` at `"now"`. [Showing a badge](/sdk/badge#rotation) walks through two examples. Each result carries the timestamps and chain [positions](/reference/glossary#position) it was read at, so you can apply your own freshness rule. Some chains can't answer for every moment. A Solana RPC can't read past account state, so a past moment there is `unverifiable`. That is a limit of the chain, not a failed check. ## What gets checked, and when For each verification, the SDK first checks that `wallet_address` is the authority itself or one of its signers. It then re-checks the proof according to its method. When you use `verifyKyiToken` or `verifyKyiResponse`, it last checks that the verification's `authority` is listed in the token's `authorities`, with the same `type` and the same address. Without that, a proof for some unrelated authority could vouch for the token, so the verification is `invalid`. Addresses are compared with the chain's rules: exactly on Solana and XRPL, ignoring case on EVM. These checks are timeless and go in the verification's `proof`. Everything that depends on chain state runs only at the moments you ask for. At each moment, a verification is re-checked by its method and its own authority is read on-chain. Each listed authority is read on-chain at each moment too. Authority types the SDK can't check are `unverifiable` at every moment and need no RPC. A multisig can be an exception, depending on the chain: see the Reference for [EVM](/reference/evm#authorities), [Solana](/reference/solana#authorities) and [XRPL](/reference/xrpl#authorities). A moment before the record, or `"verified"` for a record with no moment, gets its own reason instead (see [Reading the report](/sdk/report#reasons-for-moments)). How each chain reads a moment, and which methods and authorities it checks, is in the Reference: [EVM](/reference/evm), [Solana](/reference/solana) and [XRPL](/reference/xrpl). ## Which function to call Each chain has its own checker: `evmKyi`, `solanaKyi` and `xrplKyi`. Each chain package also exports functions bound to its checker. | Function | Checks | | --- | --- | | `verifyKyiResponse(response, options)` | A whole `GET /kyi/v1/tokens` response | | `verifyKyiToken(item, options)` | One item from the response's `data` | | `checkKyiVerification(verification, token, options)` | One verification on its own | | `checkKyiAuthority(authority, token, options)` | One authority on its own | All of them accept `at` and `rpcUrls`. The `@bluprynt/sdk` versions of `verifyKyiResponse` and `verifyKyiToken` also take `chains`, and route each token to the checker for its chain. `verifyKyiToken` and `verifyKyiResponse` check that each verification's authority is listed for the token. `checkKyiVerification` skips that check. Use the token or response functions when it matters. --- --- url: /sdk/report.md --- # Reading the report `verifyKyiResponse` returns one report for the whole response, with a report per token inside it. This page explains each part, then walks through a real lookup. ## Checks Each token report has `checks`: one per verification and one per listed authority. A verification check has a `proof` and an `at`. An authority check has only an `at`. * **`proof`:** the timeless facts: the wallet backs the authority, the signature recovers, the authority is listed for the token. It needs no network. * **`at[label]`:** the result at one [moment](/sdk/moments) you asked for. It has a `status`, a `reason` when not `valid`, its `steps`, and a `position` when the chain was read. `position` is the chain position the result was read at: `block` and `time` on EVM, `slot` on Solana, `ledger` on XRPL (plus `time` for a past moment). Compare its `time` with your own freshness rule. Each check also has a `subject` (the wallet or authority address) and a `method`, such as `signature:eip191`, `contract_signature:eip1271/eip712`, `transfer:amount`, `admin_manual`, or the authority's `type`. ## Status Each result has a `status`: * **`valid`:** every step passed. * **`invalid`:** a step failed. `reason` is ` failed`. On EVM, a transaction that doesn't exist, a contract call that reverts, or a call to an address with no contract also counts as a failure. * **`unverifiable`:** the claim couldn't be checked, for example because the proof material is missing, the method isn't supported, or the verification has expired. A verification's status at a moment combines its proof with that moment's state. If the proof is `invalid`, the verification is `invalid` at every moment, with the proof's reason. Otherwise the moment decides. ## Reasons for moments A failed step makes the result `invalid` with the reason ` failed`. These reasons come from the SDK core and apply on every chain: | `reason` | When | | --- | --- | | `verification's authority is listed for the token failed` | The verification's authority isn't in the token's `authorities`. Makes the `proof` result `invalid` | | `record postdates this moment` | The moment is before `verified_at` or `observed_at` | | `no recorded moment` | `"verified"` on an authority with no `observed_at` | | `verification expired at ` | A verification that would be valid at the moment had expired by then | Each chain adds its own, such as `online checks not requested` on EVM. The Reference lists them, with when each applies: [EVM](/reference/evm#reasons), [Solana](/reference/solana#reasons) and [XRPL](/reference/xrpl#reasons). ## Steps The same signature step can appear under both `proof` and `at[label]`. A moment's `steps` hold the method re-check and the chain-state steps for that moment, so `at.now` shows what was read without going back to `proof`. They don't repeat the steps that the wallet backs the authority and that the authority is listed. Those live only in `proof`. If the proof is `invalid`, every moment is `invalid` with the proof's reason, even when all of that moment's steps passed. On XRPL, a moment replaces the offline master-key step with the key authorization read at that ledger. Steps include the expected and actual values and whether they ran `offline` or over `rpc`. You can log or store them as an audit trail. The Reference lists every step by name. ## Evidence and summaries The SDK sums up the checks for each token as `evidence`: | `evidence` | Meaning | `verified` | | --- | --- | --- | | `confirmed` | At least one verification is valid and no check is invalid | `true` | | `contradicted` | At least one check is invalid | `false` | | `inconclusive` | Nothing is invalid, but no verification could be confirmed | `false` | `matchesClaim` compares `verified` with what Bluprynt claimed. It is `null` when Bluprynt claims the token is verified but the evidence is inconclusive. In that case, the SDK can neither confirm nor refute the claim. There is one summary offline and one per moment: * **Top-level `evidence`, `verified`, `matchesClaim`:** from the verifications' `proof` only. Authorities are not part of it, because they're checked only at moments. * **`at[label]`:** from every check's result at that moment, verifications and authorities alike. To show a token as verified, read a moment, not the offline summary. [Showing a badge](/sdk/badge) explains which fields to use. The response has its own `evidence`, offline and at each moment: `confirmed` if any token is confirmed, otherwise `contradicted` if any token is contradicted, otherwise `inconclusive`. Its `verified` is `true` when its `evidence` is `confirmed`. Its `matchesClaim` compares `claimed` with `verified` and is `null` only when the evidence is inconclusive and Bluprynt claims the response is verified. ## Expiry Expiry applies at each moment, not to `proof`. A verification whose `expires_at` is at or before the moment is `unverifiable` at that moment, with the reason `verification expired at `. For `"now"`, that's the current time. An expired proof no longer shows control, but it doesn't disprove anything either, so it can't override another, current proof. A verification that fails its check is still `invalid`. Because `proof` ignores expiry, the offline summary can be `confirmed` by an expired verification. Ask for a moment when expiry matters. ## One call, several chains Each chain has its own checker: `evmKyi`, `solanaKyi` and `xrplKyi`. To check a response that spans chains, import `verifyKyiResponse` from `@bluprynt/sdk` and pass the checkers you need in `chains`. A token is routed to the checker for its chain. ```ts import { verifyKyiResponse } from "@bluprynt/sdk"; import { evmKyi } from "@bluprynt/sdk-chain-evm"; import { solanaKyi } from "@bluprynt/sdk-chain-solana"; import { xrplKyi } from "@bluprynt/sdk-chain-xrpl"; const report = await verifyKyiResponse(response, { chains: [evmKyi, solanaKyi, xrplKyi], rpcUrls: { "eip155:1": "https://mainnet-eth.rpc.example.com", "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": "https://solana.rpc.example.com", "xrpl:0": "wss://xrpl.rpc.example.com", }, at: ["now"], }); ``` A token on a chain with no checker in `chains` throws. See [RPC and errors](/sdk/rpc). ## Example Below is a lookup for a test token on Sepolia. Its issuer proved control of the token's controlling wallet with an EIP-191 signature. ```json { "kyi_verified": true, "data": [ { "kyi_verified": true, "token": { "caip19": "eip155:11155111/erc20:0xcbc76ceb0afa037cbb975fd330bd683754998cc3", "caip2": "eip155:11155111", "chain_slug": "sepolia", "address": "0xCBc76CeB0Afa037cBb975Fd330Bd683754998Cc3", "standard": null, "decimals": null, "verified_at": "2026-10-06T13:30:46.372Z" }, "asset": { "slug": "onvi-2mdu3y", "name": "Onyx Viper", "symbol": "ONVI", "description": "https://coo.coo", "website_url": "https://coo.coo", "whitepaper_url": "https://coo.coo" }, "issuer": { "name": "Example Organization", "kyb_verified": true }, "authorities": [ { "type": "controlled_by", "address": "0xf748a9220b9b0d79d6eed2d927fcdfe80055b19c", "account_type": "regular", "signers": [], "threshold": null, "observed_at": "2026-10-06T13:29:21.759Z" } ], "verifications": [ { "verified_at": "2026-10-06T13:30:46.372Z", "expires_at": null, "wallet_address": "0xf748a9220b9b0d79d6eed2d927fcdfe80055b19c", "authority": { "type": "controlled_by", "address": "0xf748a9220b9b0d79d6eed2d927fcdfe80055b19c", "account_type": "regular", "signers": [], "threshold": null, "observed_at": "2026-10-06T13:29:21.759Z" }, "method": { "type": "signature", "scheme": "eip191", "message": "Please, sign this message to verify your Bluprynt KYI credentials\n\nPurpose: Verify wallet ownership\nIssued to: 1173\nAddress: 0xf748a9220b9b0d79d6eed2d927fcdfe80055b19c::1791293432710::37ae7bb2fb93c56f09e7c5f523d8060b.b163c968e50ed343ad4aa773bce98de8dea7def92bddb4f16ebdf8265c14e062", "signature": "0xec83133187bf78019c3c4b4137d14e1876b167c2f64ca05bfc06f5491c9c257e1477862593d19651ede7268e9f7470c773d46e1a818d2eb3b2b82ede600010141b", "signer_public_key": null } } ] } ] } ``` The [EVM example](/chains/evm#check-it-yourself) passes it to `verifyKyiResponse(lookup)` with no options. Its test checks that this output stays the same. The report is under `sdk.report`: ```json { "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" } ] } } } ``` The signature recovers to the wallet without any network access, and the verification's authority is one of the token's listed authorities, so the proof is `valid`. No moments were asked for, so every `at` is empty. One valid proof makes the offline evidence `confirmed`, which matches Bluprynt's claim. The SDK has no generic on-chain check for a `controlled_by` authority on EVM. Asking for `at: ["verified", "now"]` shows what that means: at every moment, both the authority and the verification are `unverifiable`. The verification is read with its own authority, so it can't be confirmed at a moment either. Its `proof` result is still `valid`. This report needs no RPC calls, because a signature and a `controlled_by` authority read no chain state. ::: details Report with at: \["verified", "now"] ```json { "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": { "verified": { "status": "unverifiable", "reason": "no generic EVM check for controlled_by", "steps": [ { "check": "eip191 signature recovers to wallet", "via": "offline", "ok": true, "expected": "0xf748a9220b9b0d79d6eed2d927fcdfe80055b19c", "actual": "0xf748a9220B9B0D79D6eeD2D927fCdFE80055b19c" } ] }, "now": { "status": "unverifiable", "reason": "no generic EVM check for controlled_by", "steps": [ { "check": "eip191 signature recovers to wallet", "via": "offline", "ok": true, "expected": "0xf748a9220b9b0d79d6eed2d927fcdfe80055b19c", "actual": "0xf748a9220B9B0D79D6eeD2D927fCdFE80055b19c" } ] } } }, { "kind": "authority", "subject": "0xf748a9220b9b0d79d6eed2d927fcdfe80055b19c", "method": "controlled_by", "at": { "verified": { "status": "unverifiable", "reason": "no generic EVM check for controlled_by", "steps": [] }, "now": { "status": "unverifiable", "reason": "no generic EVM check for controlled_by", "steps": [] } } } ], "at": { "verified": { "evidence": "inconclusive", "verified": false, "matchesClaim": null }, "now": { "evidence": "inconclusive", "verified": false, "matchesClaim": null } } } ], "at": { "verified": { "evidence": "inconclusive", "verified": false, "matchesClaim": null }, "now": { "evidence": "inconclusive", "verified": false, "matchesClaim": null } } } ``` ::: The offline summary still confirms the claim. At each moment, the evidence is `inconclusive` and `matchesClaim` is `null`: nothing contradicts Bluprynt, but nothing on-chain confirms the authority either. For an `owner` or Safe authority, the same request reads the chain at each moment and reports the block it read. The [Safe example](/chains/evm#at-a-moment) shows one. [Showing a badge](/sdk/badge) suggests how to turn these summaries into a label. --- --- url: /sdk/badge.md --- # Showing a badge A wallet, explorer or exchange often shows a short label next to a token: a badge. This page suggests how to pick one from the SDK's report. The SDK reports facts. The states and wording below are suggestions, not part of the SDK. ## Which fields to use A report has one summary offline and one for each moment you ask for. They answer different questions. | Field | What it covers | Question it answers | | --- | --- | --- | | `evidence`, `verified`, `matchesClaim` | The offline summary: the `proof` results only. Signatures and the binding between wallet, authority and token. It ignores authorities on-chain and expiry | Do the stored proofs check out? | | `at.verified` | Every check at each record's own moment, including authorities on-chain and expiry | Did the claim hold when it was made? | | `at.now` | Every check at the latest state your RPC serves, including authorities on-chain and expiry | Does the claim hold now? | Always ask for both moments, `at: ["verified", "now"]`, so a failure now can be told apart from a claim that never held. Don't base a badge on the top-level `verified` alone. It stays `true` for a token whose authority changed hands after the proof was made, and for a proof that has expired. Ask for `at: ["verified", "now"]` and read `at.now` first. The response report and each token report (`report.tokens[i]`) have the same three summaries. For a badge on one token, read its token report. ```ts const report = await verifyKyiResponse(response, { rpcUrls: { "eip155:1": "https://mainnet-eth.rpc.example.com" }, at: ["verified", "now"], }); const token = report.tokens[0]; token?.evidence; // offline summary token?.at.verified?.evidence; // when each record was made token?.at.now?.evidence; // now ``` ## Suggested states {#states} Go down the table and use the first row that matches. "Any" means the value doesn't matter for that row. | `claimed` | Offline `evidence` | `at.verified` | `at.now` | What it means | Suggested badge | | --- | --- | --- | --- | --- | --- | | `false` | Any | Any | Any | Bluprynt doesn't claim the token passed KYI | No badge | | `true` | `contradicted` | Any | Any | A stored proof doesn't check out: the signature fails, the wallet doesn't back its authority, or the authority isn't listed for the token | Proof check failed | | `true` | Any | Any | `confirmed` | At least one proof holds at the latest state, and no check failed | Verified now | | `true` | Any | `confirmed` | `contradicted` | The claim held when it was made. A check fails now | Verified on \; control has changed since | | `true` | Any | `inconclusive` | `contradicted` | The past couldn't be read. A check fails now | Proof checked; doesn't hold now | | `true` | Any | Not asked for | `contradicted` | A check fails now | Doesn't hold now | | `true` | Any | `contradicted` | `contradicted` | A check failed at every moment read | Not confirmed on-chain | | `true` | Any | Any | `inconclusive` | Nothing failed now, and nothing confirmed the claim now. Decide by the reasons alone: see [Telling inconclusive results apart](#inconclusive) | By reason | | `true` | `confirmed` | Not asked for | Not asked for | Offline only: the proofs check out | Proof checked | | `true` | `inconclusive` | Not asked for | Not asked for | Offline only, and no proof could be confirmed offline | Couldn't check | If the call throws, for example on an RPC timeout or a rate limit, nothing was checked. Show "Couldn't check" and try again later. Don't show a failure. [RPC and errors](/sdk/rpc#what-throws-and-what-s-reported) lists what throws. For \, use the verification's `verified_at`, or the `time` in the `position` of its `at.verified` result. ### Telling inconclusive results apart {#inconclusive} An `inconclusive` summary means nothing failed and nothing was confirmed. Decide by the `reason` on each check's result alone, whatever the offline summary says: it is `inconclusive` by design for some proofs, such as EVM contract signatures and transfers, and XRPL regular-key signatures. Look at the token's `checks[i].at.now.reason`: | `reason` | What happened | Suggested badge | | --- | --- | --- | | `no generic EVM check for `, `no Solana check for `, `no XRPL check for `, `XRPL multisig is not supported` | The SDK can't read this kind of authority on this chain, such as `controlled_by` | Proof checked; authority can't be read on this chain | | `XRPL signer lists are not supported` | The signing key may belong to the account's signer list, which the SDK doesn't read | Signer can't be checked on this chain | | `verification expired at ` | The proof had a lifetime, and it's over | Proof expired on \ | | `admin_manual is not part of the public trust model`, `proof material missing`, `signer public key not recorded`, ` is not supported on Solana`, ` is not supported on XRPL`, `scheme is not EVM` | There's no proof the SDK can re-check | Couldn't check | The Sepolia lookup in the [Quickstart](/quickstart) is the first case: its authority is `controlled_by`, so the summary is `inconclusive` at every moment, while its `proof` result is `valid`. Missing history affects past moments only. `RPC has no state for block ` (no archive RPC), `XRPL server has no ledger ` (no full-history server) and `Solana RPC cannot read historical account state` leave `at.verified` `unverifiable`. That says nothing about the token. With `at.now` `confirmed`, "Verified now" still fits. The reason tables for each chain are in the Reference: [EVM](/reference/evm#reasons), [Solana](/reference/solana#reasons) and [XRPL](/reference/xrpl#reasons). ## When an authority or key changes {#rotation} A proof can hold when it was made and stop holding later. The SDK reports that as two facts: the verification is `valid` at `"verified"` (it held when verified) and `invalid` at `"now"` (it no longer holds now). The token's `at.now.evidence` is then `contradicted`, because one of its checks failed at that moment. Each moment answers its own question: | Question | Moment | Read | | --- | --- | --- | | Did the claim hold when Bluprynt verified it? | `"verified"` | `at.verified` | | Does it hold today? | `"now"` | `at.now` | Two worked examples show this with recorded RPC responses: * **EVM: a Safe's owners changed.** A token's `owner()` is a 2-of-3 Safe, and one of its owners signed. At `"verified"`, the Safe's owners match the claimed ones, so the verification is `valid`. Later, one owner was swapped. At `"now"`, the verification is `invalid` with `Safe owners match claimed signers failed`. See [EVM: at a moment](/chains/evm#at-a-moment). * **XRPL: the master key was disabled.** An issuer signed with its master key, then set a regular key and disabled its master key, then signed again with the regular key. The master-key proof is `valid` at `"verified"` and `invalid` at `"now"` with `signer key was authorised for the wallet failed`. The regular-key proof is `valid` at both. The token's `at.now.evidence` is still `contradicted`, because one check fails. See [XRPL: at a moment](/chains/xrpl#at-a-moment). In both, the offline summary is `confirmed`, `at.verified` is `confirmed` and `at.now` is `contradicted`. A suggested badge is "Verified on \; control has changed since". In the XRPL case, a check that's `valid` at `"now"` shows a current proof too. You can show that next to the badge. ## Show where it was read Show the `position` of the result next to the badge, so a reader can look it up. It's on each check's result, under `checks[i].at[label].position`: * **EVM:** `block` and its `time`. * **Solana:** `slot`. * **XRPL:** `ledger`, plus its close `time` for a past moment. For `"now"` on Solana and XRPL there's no `time`. Record when you made the call. Checks of one token can be read at slightly different positions, so show the one from the verification behind the badge. ## Your RPC is part of the result `at.now` is only as reliable as the RPC you configure. The SDK reads what the RPC returns and doesn't cross-check it with another source. An endpoint that lags or returns wrong data can make a check pass or fail. Use an endpoint you trust. [RPC and errors](/sdk/rpc) covers setup. ## What the badge claims, and what it doesn't You can give this to an auditor, with your own RPC and positions filled in: > **"Verified now"** means: at the stated block, slot or ledger, read through our RPC, at least one wallet proof recorded by Bluprynt checked out. The wallet was the token's listed authority or one of its signers, that authority held on-chain, the proof hadn't expired, and no check failed. > > It doesn't mean that the issuer is the organization Bluprynt names (that comes from Bluprynt's KYB process), that Bluprynt's list of authorities is complete, or that the wallet's key hasn't leaked. It says nothing about token features beyond the authorities the SDK reads. [What a proof shows, and what it doesn't](/trust-model) lists what the SDK re-checks and what takes more effort to verify. --- --- url: /sdk/rpc.md --- # RPC and errors ## RPC configuration Pass RPC endpoints in `rpcUrls`, keyed by [CAIP-2](/reference/glossary#caip) chain ID. They're needed only when you ask for moments, and only for checks that read chain state. Offline checks never call an RPC. Which checks read chain state at a moment: | Chain | Reads chain state | Needs no RPC | | --- | --- | --- | | EVM | Contract signatures, transfers, `owner` authorities and multisig authorities | Signatures and other authority types | | Solana | `mint_authority`, `freeze_authority` and multisig authorities, at `"now"` only | Signatures, other authority types, and every moment except `"now"` | | XRPL | Signature verifications (to read the wallet's keys) and `issuer` authorities | Multisig and other authority types | A contract signature or transfer is read on the chain it records (`caip2`). Everything else is read on the token's chain. If a needed RPC isn't configured, the call throws `RPC not configured for `. XRPL URLs must be WebSocket URLs (`wss://` or `ws://`), because the SDK uses the `Client` of `xrpl.js`. Past moments need history. On EVM that's an archive RPC. On XRPL it's a server whose `complete_ledgers` cover the moment. See [EVM checks](/reference/evm#how-a-moment-is-read) and [XRPL checks](/reference/xrpl#how-a-moment-is-read). The SDK trusts what the RPC returns and doesn't cross-check it. Results at a moment are only as reliable as the endpoint you configure. ## Many tokens `verifyKyiResponse` checks a response's tokens at the same time. Within one token, EVM checks share the RPC client and block lookups, and XRPL checks share the connection and ledger lookups. Solana checks don't share reads. Nothing is shared across tokens or across calls. If you check many tokens, or the same token often, caching and rate limits are up to you. ## What throws, and what's reported The SDK reports what it could check as `valid`, `invalid` or `unverifiable`. When it can't run the checks at all, it throws. **`invalid`** means a check ran and failed: a signature that doesn't recover or verify, a wallet that isn't the authority or one of its signers, an authority that isn't listed for the token, or chain state that doesn't match. On EVM, a transaction that doesn't exist, a contract call that reverts, or a call to an address with no contract is a failed `on-chain lookup` step. On XRPL, an account that didn't exist at the moment fails its step. **`unverifiable`** means the SDK couldn't tell either way: missing proof material, a method or authority type it can't check, an expired verification, a moment before the record, or a past moment the RPC or chain can't serve. On EVM, an RPC without state for a past block gives `RPC has no state for block `. On XRPL, a server without a past ledger gives `XRPL server has no ledger `. On Solana, every moment except `"now"` gives `Solana RPC cannot read historical account state`. **Throws:** * `RPC not configured for `: a check needs an RPC that isn't in `rpcUrls`. * `No KYI checker for namespace `: a token's chain has no checker in `chains`. * `Malformed verified_at: `, `Malformed expires_at: `, `Malformed observed_at: `: a timestamp that doesn't parse. * `Malformed XRPL token address:
`: an `issuer` authority checked at a moment on an XRPL token address without a `.`. * Network and RPC errors, such as timeouts, rate limits or a bad URL. On EVM and XRPL, a failed lookup is shared by the token's checks, so the call fails once. * Proof material a chain library can't parse, such as an EIP-712 message that isn't JSON. Network and RPC errors are thrown, not reported as failed checks. This way, a flaky RPC can't make a valid claim look `invalid`. An RPC or server that lacks history for a past moment is different: that's reported as `unverifiable` at that moment. --- --- url: /reference/evm.md --- # EVM checks What `@bluprynt/sdk-chain-evm` checks, step by step. For background, see the [EVM page](/chains/evm). For the report format, see [Reading the report](/sdk/report). ## Methods | Method | Check | Reads chain state | | --- | --- | --- | | `signature` / `eip191` | The signature over the stored message recovers to the wallet | No | | `signature` / `eip712` | The typed-data signature recovers to the wallet | No | | `signature` with no `scheme` | Treated as `eip191` when the wallet is a `0x` address | No | | `contract_signature` / `eip1271` | `isValidSignature` on the wallet contract, at the block for the moment. At `"verified"`, that's the recorded block | Yes | | `transfer` / `amount` | The transaction succeeded, was sent from the wallet, and the asset is on the transaction's chain. For the native asset (`slip44`), its recipient and value match. For an ERC-20 token, the receipt contains a matching `Transfer` event | Yes | | `transfer` / `memo` | The transaction succeeded, its sender and recipient match, and its calldata carries the memo | Yes | | `admin_manual` | Not re-checkable. Reported as `unverifiable` | — | Contract signatures and transfers need chain state, so offline their `proof` is `unverifiable` with the reason `online checks not requested`. Ask for a moment to check them. A transaction mined after the moment's block is `unverifiable` at that moment with the reason `record postdates this moment`. The same applies to a contract signature whose recorded block is after it. A contract signature or transfer is read on the chain it records (`caip2`), not necessarily the token's chain. ## Authorities * **`owner`:** the token's `owner()` returns the authority's address. * **Multisig** (`account_type: "multisig"`, of any `type`, including `controlled_by`): read as a Safe. The Safe's `getOwners()` and `getThreshold()` match the claimed signers and threshold. The threshold is compared only when the claim has one. Authority types without a generic EVM check, such as `controlled_by`, are `unverifiable` at every moment and need no RPC. A verification backed by such an authority is `unverifiable` at every moment too, even when its `proof` result is `valid`. A moment before the record still reports `record postdates this moment`, and `"verified"` on a record with no moment reports `no recorded moment`. ## Steps The steps the EVM checker can report. Their names are exact. | Step | Where | | --- | --- | | `wallet is the authority or one of its signers` | `proof` | | `eip191 signature recovers to wallet` | `proof` and each moment | | `eip191 signature recovers to wallet (scheme not recorded, assumed eip191)` | `proof` and each moment, when `scheme` is `null` and the wallet is a `0x` address | | `eip712 signature recovers to wallet` | `proof` and each moment | | `verification's authority is listed for the token` | `proof`, from `verifyKyiToken` and `verifyKyiResponse` | | `eip1271 isValidSignature on at block ` | Moments | | `claimed sender is the wallet` | Moments (transfers) | | `transaction succeeded` | Moments (transfers) | | `transaction sender` | Moments (transfers) | | `transaction recipient` | Moments (native transfers with a recipient, memo transfers with a recipient) | | `asset is on the transaction's chain` | Moments (amount transfers) | | `transferred amount` | Moments (native `slip44` transfers) | | `matching ERC-20 Transfer in receipt` | Moments (`erc20` transfers) | | `calldata carries the memo` | Moments (memo transfers) | | `Safe owners match claimed signers` | Moments (multisig authorities) | | `Safe threshold` | Moments (multisig authorities with a threshold) | | `token owner()` | Moments (`owner` authorities) | | `on-chain lookup` | Moments, when the transaction doesn't exist or a contract call reverts or hits an address with no code | ## Reasons A failed step makes the result `invalid` with the reason ` failed`. These reasons leave a result `unverifiable`: | `reason` | When | | --- | --- | | `proof material missing` | The method lacks the fields it needs | | `scheme is not EVM` | A `signature` with a non-EVM scheme, or with no scheme on a wallet that isn't a `0x` address | | `online checks not requested` | A contract signature or transfer in `proof`. It needs a moment | | `admin_manual is not part of the public trust model` | `admin_manual` verifications | | `no generic EVM check for ` | An authority that is neither `owner` nor a multisig, such as `controlled_by` | | `asset is not supported` | An amount transfer of something other than `slip44` or `erc20` | | `record postdates this moment` | The moment is before the record, its transaction's block, or its contract signature's block | | `chain has no block at or before this moment` | The moment is before the chain's first block | | `RPC has no state for block ` | A past block the RPC can't serve | Reasons from the SDK core apply on every chain. They're listed in [Reading the report](/sdk/report#reasons-for-moments). ## How a moment is read Each moment is read at one block. For `"now"`, that's the latest block. For a past instant, the SDK finds the last block mined at or before it with a binary search over block headers. The search runs once per distinct point in time for each token, and all checks for that token share it: once for `"now"` or a `Date`, and once per distinct `verified_at` or `observed_at` for `"verified"`. A contract signature at `"verified"` is read at its recorded `block_number`, while its authority is read at the block for `verified_at`. If the chain has no block that early, the check is `unverifiable` with the reason `chain has no block at or before this moment`. Past blocks need an archive RPC. A regular node keeps only recent state. When the RPC has no state for the block, the check is `unverifiable` with the reason `RPC has no state for block `. A result read on-chain has a `position` with the `block` and its `time`. --- --- url: /reference/solana.md --- # Solana checks What `@bluprynt/sdk-chain-solana` checks, step by step. For background, see the [Solana page](/chains/solana). For the report format, see [Reading the report](/sdk/report). ## Methods | Method | Check | Reads chain state | | --- | --- | --- | | `signature` / `solana_raw` | The Ed25519 signature over the UTF-8 message verifies for the wallet's public key | No | | `signature` / `solana_offchain_v0` | The signature verifies over the message wrapped in the legacy Solana off-chain message format (the format the Solana CLI signs) | No | | `signature` with no `scheme` | Tries both formats and notes which one matched | No | | `admin_manual` | Not re-checkable. Reported as `unverifiable` | — | Other signature schemes, contract signatures and transfers are not supported on Solana and are reported as `unverifiable` (see [transfer proofs](/wallet-verification#onchain-transaction)). ## Authorities * **`mint_authority`:** the mint account's mint authority is the authority's address. * **`freeze_authority`:** the mint account's freeze authority is the authority's address. * **Multisig:** when the authority's `account_type` is `"multisig"`, the SDK reads the SPL Token multisig account. It takes the first `n` signers, the slots in use, and compares them with the claimed signers as a set, so order doesn't matter. The threshold `m` is compared only when the claim has one. Mint and multisig accounts can belong to SPL Token or Token-2022. Only the base mint's mint and freeze authorities are read. Token-2022 extensions, such as a permanent delegate or a transfer hook, are not checked. Squads vaults and other PDA authorities are not SPL Token multisigs and are not supported (see [Solana quirks](/chains/solana#quirks)). An account owned by another program, an account that isn't a token mint, or one that isn't a token multisig fails the check. Other authority types are reported as `unverifiable` with `no Solana check for `. If such an authority is a multisig, its signers and threshold are still read at `"now"`: a mismatch makes it `invalid`, a match leaves it `unverifiable`. Otherwise it needs no RPC. ## Steps The steps the Solana checker can report. Their names are exact. | Step | Where | | --- | --- | | `wallet is the authority or one of its signers` | `proof` | | `solana_raw signature verifies for wallet` | `proof` and `"now"` | | `solana_offchain_v0 signature verifies for wallet` | `proof` and `"now"` | | `signature verifies for wallet (scheme not recorded, matched solana_raw)` | `proof` and `"now"`, when `scheme` is `null`. Ends in `solana_offchain_v0` when that format matched | | `signature verifies for wallet (scheme not recorded)` | Same, when neither format matched | | `verification's authority is listed for the token` | `proof`, from `verifyKyiToken` and `verifyKyiResponse` | | `multisig account exists` | `"now"` (multisig authorities) | | `multisig is owned by a token program` | `"now"` (multisig authorities) | | `account is a token multisig` | `"now"` (multisig authorities) | | `multisig signers match claimed signers` | `"now"` (multisig authorities) | | `multisig threshold` | `"now"` (multisig authorities with a threshold) | | `mint account exists` | `"now"` (`mint_authority`, `freeze_authority`) | | `mint is owned by a token program` | `"now"` (`mint_authority`, `freeze_authority`) | | `account is a token mint` | `"now"` (`mint_authority`, `freeze_authority`) | | `mint authority` | `"now"` (`mint_authority`) | | `freeze authority` | `"now"` (`freeze_authority`) | ## Reasons A failed step makes the result `invalid` with the reason ` failed`. These reasons leave a result `unverifiable`: | `reason` | When | | --- | --- | | `proof material missing` | No `message` or `signature` | | `signature: is not supported on Solana` | A signature with a non-Solana scheme | | ` is not supported on Solana` | Contract signatures and transfers, such as `transfer:memo is not supported on Solana` | | `admin_manual is not part of the public trust model` | `admin_manual` verifications | | `Solana RPC cannot read historical account state` | Any moment other than `"now"`, for verifications and for the authorities the SDK can check | | `no Solana check for ` | Authorities other than `mint_authority` and `freeze_authority` | Reasons from the SDK core apply on every chain. They're listed in [Reading the report](/sdk/report#reasons-for-moments). ## How a moment is read Solana answers only `"now"`. A Solana RPC can't read account state as of a past slot, so at every other moment, verifications and the authorities it can check are `unverifiable` with the reason `Solana RPC cannot read historical account state`. This applies to signature verifications too: the verification check itself skips past moments, even though a signature needs no chain state. It's a limit of the chain: the SDK can't tell either way. At `"now"`, the SDK reads accounts with `getAccountInfo`, at `confirmed` commitment, the default of Kit's `createSolanaRpc`. A result read on-chain has a `position` with the `slot`. A multisig mint or freeze authority reads two accounts, the multisig and the mint. They may be served from different slots. `position.slot` is the slot of the first read, the multisig account. --- --- url: /reference/xrpl.md --- # XRPL checks What `@bluprynt/sdk-chain-xrpl` checks, step by step. For background, see the [XRPL page](/chains/xrpl). For the report format, see [Reading the report](/sdk/report). ## Methods | Method | Check | Reads chain state | | --- | --- | --- | | `signature` / `xrpl` | The signature over the message, hex-encoded, verifies for the recorded signer public key. At a moment, that key was authorized for the wallet | At a moment only | | `signature` with no `scheme` | Checked as `xrpl` | At a moment only | | `admin_manual` | Not re-checkable. Reported as `unverifiable` | — | Offline, the signer key decides how the wallet is matched: * **[Master key](/reference/glossary#master-key):** the key derives the wallet's address. The proof is `valid`. * **Any other key:** the proof is `unverifiable` until it's checked at a moment. * **No signer public key recorded:** reported as `unverifiable`. At a moment, the SDK reads the wallet's account at that moment's ledger. The key was authorized if it's the master key and the master key wasn't disabled then, or if it was the account's `RegularKey` then. A key that wasn't authorized, or an account that didn't exist, makes the verification `invalid` at that moment. It may still be valid at another one. The exception is a key that is neither the master key nor the `RegularKey` on an account with a signer list: the SDK doesn't read signer lists, so that verification is `unverifiable` with `XRPL signer lists are not supported`. A disabled master key is `invalid` either way. A signature that doesn't verify for the signer key is `invalid`. Other signature schemes, contract signatures and transfers are not supported on XRPL and are reported as `unverifiable`. ## Authorities * **`issuer`:** the authority's address is the issuer of the token, and the issuer account exists at the moment. The issuer is everything after the last `.` in the token address. The account lookup runs only after the issuer matches the token. XRPL multisig authorities are not supported. They and every other authority type are reported as `unverifiable` and need no RPC. At a moment, an `issuer` authority on a token address without a `.` throws `Malformed XRPL token address`. ## Steps The steps the XRPL checker can report. Their names are exact. | Step | Where | | --- | --- | | `wallet is the authority or one of its signers` | `proof` | | `xrpl signature verifies for signer key` | `proof` and each moment | | `signer key is the wallet's master key` | `proof`, when the key derives the wallet's address | | `verification's authority is listed for the token` | `proof`, from `verifyKyiToken` and `verifyKyiResponse` | | `signer key was authorised for the wallet` | Moments | | `issuer matches the token` | Moments (`issuer` authorities) | | `issuer account exists` | Moments (`issuer` authorities) | ## Reasons A failed step makes the result `invalid` with the reason ` failed`. These reasons leave a result `unverifiable`: | `reason` | When | | --- | --- | | `proof material missing` | No `message` or `signature` | | `signature: is not supported on XRPL` | A signature with a non-XRPL scheme | | `signer public key not recorded` | No `signer_public_key` | | `signer is not the master key; online check needed` | Offline, when the key isn't the wallet's master key | | ` is not supported on XRPL` | Contract signatures and transfers | | `admin_manual is not part of the public trust model` | `admin_manual` verifications | | `XRPL multisig is not supported` | Multisig authorities | | `XRPL signer lists are not supported` | At a moment, a key that is neither the master key nor the `RegularKey`, on an account with a signer list | | `no XRPL check for ` | Authorities other than `issuer` | | `XRPL server has no ledger ` | A past ledger the server doesn't have | | `chain has no ledger at or before this moment` | The moment is before the first ledger | Reasons from the SDK core apply on every chain. They're listed in [Reading the report](/sdk/report#reasons-for-moments). ## How a moment is read For `"now"`, the SDK reads the latest validated ledger. For a past instant, it finds the last ledger closed at or before it, with a binary search over ledger close times. On a server with long history, that's roughly 20 to 30 `ledger` requests. The search runs once per distinct point in time for each token, and all checks for that token share it. Past ledgers need a server whose history covers them. Most servers keep only recent ledgers. The SDK reads the server's `complete_ledgers` from `server_info`. When it lists several ranges, the SDK uses the one that contains the latest validated ledger. When the moment is before that range, or the server can't serve a ledger, the check is `unverifiable` with the reason `XRPL server has no ledger `. When the server's history reaches back to the first ledger and the moment is earlier still, the reason is `chain has no ledger at or before this moment`. The SDK connects with the `Client` of `xrpl.js`, over WebSocket only. A result read on-chain has a `position` with the `ledger`, plus its close `time` for a past moment. --- --- url: /reference/lookup.md --- # Lookup fields The SDK re-checks a response from `GET /kyi/v1/tokens`. This page lists the fields it reads and what it does with them. The [API docs](https://integrations.bluprynt.com/) describe the full response. ::: warning Experimental The SDK's types for the response follow the public API's current shape. They will change until they are generated from the OpenAPI spec. ::: ## Response | Field | What the SDK does with it | | --- | --- | | `kyi_verified` | Bluprynt's claim for the whole response. Reported as the report's `claimed` | | `data[]` | One item per token. Each is checked on its own | ## Token item | Field | What the SDK does with it | | --- | --- | | `kyi_verified` | Bluprynt's claim for this token. Reported as the token report's `claimed` | | `token.caip19` | Copied to the token report | | `token.caip2` | Picks the chain's checker and the RPC from `rpcUrls` | | `token.address` | The token contract on EVM, the mint on Solana, `CURRENCY.rIssuer` on XRPL | | `authorities[]` | Each one is checked at each moment. See [Authorities](#authorities) | | `verifications[]` | Each one is checked offline and at each moment. See [Verifications](#verifications) | The SDK doesn't read `asset`, `issuer`, or the token's `chain_slug`, `standard`, `decimals` and `verified_at`. [What a proof shows](/trust-model#more-effort) explains what that leaves to you. ## Authorities {#authorities} An authority is an address that controls the token on-chain, as Bluprynt lists it. | Field | Meaning | | --- | --- | | `type` | What kind of control it is, such as `owner`, `mint_authority`, `freeze_authority`, `issuer` or `controlled_by` | | `address` | The authority's address | | `account_type` | `"regular"` or `"multisig"` | | `signers` | A multisig's signers. A wallet backs the authority if it's the `address` or one of these | | `threshold` | A multisig's threshold, or `null`. Compared only when it isn't `null` | | `observed_at` | When Bluprynt saw the authority. It's the authority's `"verified"` moment. With `null`, `"verified"` gives `no recorded moment` | Which types each chain reads on-chain, and how it treats a multisig, is in the Reference: [EVM](/reference/evm#authorities), [Solana](/reference/solana#authorities) and [XRPL](/reference/xrpl#authorities). ## Verifications {#verifications} A verification records that a wallet proved control, backing one authority. Bluprynt stores enough with it to re-check it. | Field | Meaning | | --- | --- | | `wallet_address` | The wallet whose proof backs the verification | | `authority` | The authority it backs, with the same fields as above. Its `type` and `address` must match one of the token's `authorities` | | `method` | How control was proven, with the proof material. See [Methods](#methods) | | `verified_at` | When Bluprynt checked the proof. It's the verification's `"verified"` moment | | `expires_at` | When the proof stops counting, or `null`. Applied at moments only. See [Expiry](/sdk/report#expiry) | A timestamp that doesn't parse throws, such as `Malformed verified_at: `. ## Methods {#methods} `method.type` says how the wallet proved control. The other fields are the proof material. | `type` | Fields | Chains | | --- | --- | --- | | `signature` | `scheme` (`eip191`, `eip712`, `solana_raw`, `solana_offchain_v0`, `xrpl` or `null`), `message`, `signature`, `signer_public_key` (needed on XRPL) | EVM, Solana, XRPL | | `contract_signature` | `scheme` (`eip1271`), `inner_scheme` (`eip191` or `eip712`), `message`, `signature`, `caip2`, `block_number` | EVM | | `transfer` with `kind: "amount"` | `caip2`, `transaction_hash`, `sender`, `recipient`, `asset` (a CAIP-19 ID), `amount` (in base units) | EVM | | `transfer` with `kind: "memo"` | `caip2`, `transaction_hash`, `sender`, `recipient`, `memo` | EVM | | `admin_manual` | None. Set by hand, so there is nothing to re-check | Any | The chain pages show what each method looks like and how it's produced: [EVM](/chains/evm#how-bluprynt-verifies-it), [Solana](/chains/solana#how-bluprynt-verifies-it) and [XRPL](/chains/xrpl#how-bluprynt-verifies-it). --- --- url: /reference/glossary.md --- # Glossary ## KYI {#kyi} "Know your issuer". Bluprynt's check that a token's issuer is a verified organization that controls the token on-chain. A KYI token lookup says whether a token passed (`kyi_verified`) and lists the evidence: the token's authorities and the verifications that back them. ## KYB {#kyb} "Know your business". The check that an organization is who it says it is. Bluprynt's KYB of the issuer happens outside the chain. The SDK doesn't re-check it, and the lookup reports it as `issuer.kyb_verified`. ## Authority {#authority} An address that controls a token on-chain, as Bluprynt lists it in a lookup's `authorities`. It has a `type` (such as `owner`, `mint_authority`, `freeze_authority`, `issuer` or `controlled_by`), an `address`, an `account_type` (`"regular"` or `"multisig"`), the multisig's `signers` and `threshold`, and `observed_at`, when Bluprynt saw it. The SDK reads authorities on-chain only at moments, and only the types each chain can check. See the Reference for [EVM](/reference/evm#authorities), [Solana](/reference/solana#authorities) and [XRPL](/reference/xrpl#authorities). ## Verification {#verification} A record that a wallet proved control, backing one authority. It has the `wallet_address`, the `authority` it backs, `verified_at`, an optional `expires_at`, and the `method` with its proof. See [Lookup fields](/reference/lookup#verifications). ## Proof {#proof} The evidence that a wallet's key took part: a signature, a contract signature or a transfer, stored in a verification's `method`. When the SDK re-checks it offline, the result is the verification's `proof` result in the report. See [Reading the report](/sdk/report#checks). ## Moment, `at` {#moment} A point in time to read chain state at. Pass moments in the `at` option: `"verified"` (each record's own moment), `"now"` (the latest state), or a `Date`. With no `at`, the SDK checks offline only. Results for each moment are under `at[label]`. See [Moments](/sdk/moments). ## Position {#position} Where on the chain a result was read: `block` and `time` on EVM, `slot` on Solana, `ledger` on XRPL (plus `time` for a past moment). ## Evidence {#evidence} The SDK's summary of a token's checks: `confirmed`, `contradicted` or `inconclusive`. There is one offline, from the `proof` results, and one per moment. See [Reading the report](/sdk/report#evidence-and-summaries). ## CAIP-2 and CAIP-19 {#caip} Chain-agnostic IDs. A [CAIP-2](https://chainagnostic.org/CAIPs/caip-2) ID names a chain, such as `eip155:1` for Ethereum mainnet, `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` for Solana mainnet or `xrpl:0` for XRPL mainnet. `rpcUrls` is keyed by it. A [CAIP-19](https://chainagnostic.org/CAIPs/caip-19) ID names an asset on a chain, such as `eip155:11155111/erc20:0xcbc76ceb0afa037cbb975fd330bd683754998cc3`. ## EOA {#eoa} An externally owned account on EVM: an address controlled by a secp256k1 key pair. It proves control by signing a message, and the same key controls the same address on every EVM chain. See [EVM](/chains/evm#how-wallets-prove-control-on-evm). ## Contract wallet {#contract-wallet} An EVM account that is code, such as a Safe. It has no private key. It accepts or rejects signatures through ERC-1271, and it exists only on the chain it was deployed to. ## ERC-1271 {#erc-1271} The EVM standard for contract signatures. A contract's `isValidSignature(hash, signature)` returns `0x1626ba7e` when it accepts the signature. The answer depends on the contract's state, so the SDK checks it at a block, over your RPC. See [EIP-1271](https://eips.ethereum.org/EIPS/eip-1271). ## Off-chain message {#off-chain-message} On Solana, a message signed with a header that no transaction can start with, so a signed message can't be mistaken for a transaction. The Solana CLI signs this way. Wallet apps usually sign the raw message instead. The scheme `solana_offchain_v0` is the legacy header the CLI uses, not Kit's newer "version 0" layout. See [Solana](/chains/solana#quirks). ## SPL Token and Token-2022 {#spl-token} Solana's two token programs. SPL Token is the original. Token-2022 is a newer one with extensions. Each token has a mint account holding its mint and freeze authorities. The SDK reads mint and multisig accounts of both programs. ## Master key {#master-key} On XRPL, the key pair an account's address is derived from. It can sign for the account unless the account sets `lsfDisableMaster`. ## Regular key {#regular-key} On XRPL, a second key pair an account can assign with `SetRegularKey`. It can sign for the account like the master key, and it can be changed or removed. Only account state shows whether a key was the regular key at a given time. ## `controlled_by` {#controlled-by} An authority type for an address that controls a token when there's no standard on-chain way to read that control. The SDK has no check for it, so it is `unverifiable` at every moment, and so is a verification backed by it. A `controlled_by` multisig is the exception on EVM and Solana: see the Reference for [EVM](/reference/evm#authorities) and [Solana](/reference/solana#authorities). --- --- url: /kyi-verification.md --- # Verifying KYI claims This page has moved. Its content is now split across these pages: * [Quickstart](/quickstart): fetch a lookup, verify it offline, then at `"now"`. * [Moments](/sdk/moments): moments, what gets checked and when, and which function to call. * [Reading the report](/sdk/report): checks, statuses, reasons, evidence, expiry, and the [Sepolia example](/sdk/report#example). * [RPC and errors](/sdk/rpc): RPC configuration, and what throws versus what's reported. The per-chain method, step and reason tables are in the Reference: [EVM](/reference/evm), [Solana](/reference/solana) and [XRPL](/reference/xrpl).