Verify a PaladinFi response
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.
/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:
_signature— base64-encoded Ed25519 signature (64 bytes / 88 base64 chars)._signature_alg—"ed25519"._signature_pubkey_hex— a convenience copy of the signing public key. Do not use it as your verification key; pin the key from this page instead (see below).
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:
- Strip exactly these three keys —
_signature,_signature_alg,_signature_pubkey_hex— and nothing else. Do not strip by prefix: every other field must stay in the signed message so that any field injected in transit causes verification to fail. - Sort keys recursively, at every level (Python's
sort_keys=Trueis recursive). A top-level-only sort produces different bytes for the nestedtrustandfactorsobjects and will not verify. - This is Python
json.dumpscanonicalization, not RFC 8785. For the OFAC payload — which contains only ASCII strings and integers — it is byte-identical to RFC 8785 (JCS), so a JCS library reproduces it, and PaladinFi commits to keeping the/ofacpayload within that ASCII+integer intersection. If you verify in a language other than Python, prefer replicating the exact serializer above; a JCS library is correct for this payload but would diverge fromjson.dumpson non-ASCII strings or non-integer numbers.
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:
- the response was altered after PaladinFi signed it (a proxy, a MITM, a cache that rewrote the body); or
- you canonicalized differently than the contract above (most common — check the recursive sort and the exact three-key strip); or
- you verified against the wrong key; or
- PaladinFi did not sign the response. Signing is fail-open server-side: if the signing key is temporarily unavailable or signing is disabled for an operational rollback, responses arrive with no
_signature*fields. That is a known mode, not necessarily an attack — but you should still treat the verdict as unverified.
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:
- It is not a defense against PaladinFi's own infrastructure being compromised — this page and the API are both under PaladinFi's control, so an attacker who fully controlled our origin and DNS could rotate both. Signing protects the path between us, not us.
- It protects the signed body fields only — not HTTP status, headers, or any unsigned content.
- It is not anti-replay. Responses carry no per-response timestamp or nonce, so a validly-signed older response can be replayed indefinitely. The in-body
_ofac_list_updated_atand_ofac_sdn_countbound the freshness of the SDN list, not the age of the response — and the list moves slowly (sometimes days between updates). If you need replay resistance, make a fresh request per decision over TLS and don't treat a forwarded signed response as proof.
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