Verify a PaladinFi response

2026-06-12 · Docs ed25519 verification trust-check

PaladinFi's free OFAC wallet-screen endpoint — POST https://swap.paladinfi.com/v1/trust-check/ofac — signs every response with Ed25519. The signature lets you confirm a verdict was produced by PaladinFi and has not been altered between us and you, before you act on it.

Scope: Only the /v1/trust-check/ofac endpoint is signed today. The paid full-composition endpoint (/v1/trust-check) and the swap endpoint (/v1/quote) do not currently carry signature fields — don't run this verifier against them. This page will be updated when signing extends to other endpoints.

What's signed

The response carries three signature fields at the top level of the JSON body:

The signing private key is held in AWS SSM Parameter Store as a KMS-encrypted SecureString, readable only by the service's IAM role; it never leaves the server.

The signing key (pin this)

272b6b62230d9da810f3ed64b5e5147f1ae062cb46ad7f25044b0aab1d18fb6f

Pin this value from this page (a different host, paladinfi.com, than the API host swap.paladinfi.com) — not from the _signature_pubkey_hex field inside a response. Pinning from a separate channel is what stops a forging API origin from handing you a matching key alongside a forged body. To stay rotation-safe, pin it as a small list and accept a response if any pinned key verifies (see the snippets).

The canonical-JSON contract

The server signs the response body with the three _signature* fields removed, serialized exactly as:

json.dumps(body, sort_keys=True, separators=(",", ":"))   # ensure_ascii default (True)

Three things to get right:

Worked canonicalization example (so you can diff your output byte-for-byte). Given a body:

{"address":"0xABC","_signature":"...","_signature_alg":"ed25519","_signature_pubkey_hex":"...","trust":{"version":"1.1","recommendation":"allow"}}

the canonical signed message is exactly:

{"address":"0xABC","trust":{"recommendation":"allow","version":"1.1"}}

(three sig fields removed; keys sorted recursively; no whitespace. This is an abbreviated example to show the transformation — a real /ofac response carries more fields, e.g. chainId and _paid_endpoint_info.)

Verify in Python

import json, base64, urllib.request
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

# Pin from THIS page (paladinfi.com/docs/verify-responses/), not from the response.
# A list, so a key rotation doesn't break every deployed verifier at the cutover.
PALADIN_PUBKEYS_HEX = ["272b6b62230d9da810f3ed64b5e5147f1ae062cb46ad7f25044b0aab1d18fb6f"]
SIGNATURE_FIELDS = ("_signature", "_signature_alg", "_signature_pubkey_hex")

def verify_paladin_response(body: dict) -> bool:
    try:
        if body.get("_signature_alg") != "ed25519":
            return False
        # The in-band pubkey is a convenience copy; require it to match a pinned key.
        if body.get("_signature_pubkey_hex") not in PALADIN_PUBKEYS_HEX:
            return False
        signed = {k: v for k, v in body.items() if k not in SIGNATURE_FIELDS}
        message = json.dumps(signed, sort_keys=True, separators=(",", ":")).encode("utf-8")
        sig = base64.b64decode(body["_signature"])
        for hexkey in PALADIN_PUBKEYS_HEX:
            try:
                Ed25519PublicKey.from_public_bytes(bytes.fromhex(hexkey)).verify(sig, message)
                return True
            except Exception:
                continue
        return False
    except Exception:
        return False   # malformed / missing signature => not verified, never an exception

# Example
req = urllib.request.Request(
    "https://swap.paladinfi.com/v1/trust-check/ofac",
    data=json.dumps({"address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"}).encode(),
    headers={"Content-Type": "application/json"}, method="POST",
)
body = json.loads(urllib.request.urlopen(req).read())
print("verified:", verify_paladin_response(body))   # -> True

Requires pip install cryptography. The Python path uses only the stdlib json for canonicalization (no third-party dependency) and is byte-exact against the server for any payload.

Verify in JavaScript / TypeScript (Node)

const crypto = require("crypto");
const canonicalize = require("canonicalize");   // npm i canonicalize@2  (RFC 8785 JCS)

// Pin from THIS page, not from the response. A list, for rotation safety.
const PALADIN_PUBKEYS_HEX = ["272b6b62230d9da810f3ed64b5e5147f1ae062cb46ad7f25044b0aab1d18fb6f"];
const SIGNATURE_FIELDS = ["_signature", "_signature_alg", "_signature_pubkey_hex"];

function verifyPaladinResponse(body) {
  try {
    if (body._signature_alg !== "ed25519") return false;
    if (!PALADIN_PUBKEYS_HEX.includes(body._signature_pubkey_hex)) return false;
    const signed = Object.fromEntries(
      Object.entries(body).filter(([k]) => !SIGNATURE_FIELDS.includes(k))
    );
    // NOTE: canonicalize() matches the server for the OFAC payload (ASCII + integers).
    // It is NOT byte-identical to Python json.dumps for non-ASCII strings or floats.
    const message = Buffer.from(canonicalize(signed), "utf8");
    const sig = Buffer.from(body._signature, "base64");
    return PALADIN_PUBKEYS_HEX.some((hex) => {
      // 302a300506032b6570032100 is the fixed RFC 8410 SPKI DER header for any
      // Ed25519 public key; prepend it to the raw 32 bytes to build a KeyObject.
      const der = Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), Buffer.from(hex, "hex")]);
      const pub = crypto.createPublicKey({ key: der, format: "der", type: "spki" });
      return crypto.verify(null, message, pub, sig);
    });
  } catch {
    return false;   // malformed / missing signature => not verified, never throw
  }
}

// Example
const res = await fetch("https://swap.paladinfi.com/v1/trust-check/ofac", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" }),
});
console.log("verified:", verifyPaladinResponse(await res.json()));   // -> true

Tested on Node 24 with canonicalize@2.1.0. The snippet uses only crypto.createPublicKey / crypto.verify, which are long-standing in Node's crypto API, but only Node 24 was verified.

What a failed verification means

If verification fails, do not trust the verdict — treat it as if no trust check was performed (fail-visible). A failure means one of:

Trust model — what signing does and does not give you

What it gives you: detection of tampering of the signed JSON body fields by any party between PaladinFi and you. If a malicious RPC, proxy, or cache flips a block to an allow, the signature stops verifying. This matters most when PaladinFi responses pass through infrastructure you don't fully control — an agent runtime, a shared gateway, a caching proxy — where you want to detect a rewritten verdict at the point you consume it. Pinning the key from this page (a different host than the API) means a forging API origin can't simply hand you a matching key with a forged body.

What it does not give you:

Key rotation

The current signing key is always the one published on this page. If we rotate, the new key will be added here and the retired key kept (with its retirement date) for an overlap window — pin the list above and re-pin from this page periodically rather than hard-coding a single key that outlives its rotation.

Last Updated: 2026-06-12 · API version: 1.1 · Service version: 0.11.79