Skip to content

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.

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. An account can also have other keys:

  • 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 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. On XRPL:

The SDK re-checks signature verifications:

methodUsed forStored evidence
signature / xrplXRPL walletsmessage, signature (hex), signer_public_key
admin_manualSet by handNothing 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.

QuestionHow 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 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 <n>, 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.

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<typeof verifyKyiResponse>[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<typeof account>, 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<typeof signed>) {
  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<typeof signed>[],
): 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:

Output
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 · output: examples/chains/output/xrpl.json

At a moment: synthetic data (recorded RPC responses) ​

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, 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<typeof verifyKyiResponse>[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<typeof account>,
    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:

Output
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 suggests how to present that.

Source: examples/chains/src/xrpl-rotated.ts · output: 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.