v0.3.0 is live: hardening sprint walkthrough
v0.3.0 plugin shipped: 2026-05-15. v0.3.1 patch (Tier-1 close-out): 2026-05-15. Server v0.12.0 → v0.12.1 deployed same day.
@paladinfi/eliza-plugin-trust@0.3.0 is the first public release of
our on-chain-trust-anchor architecture for DeFi swaps on Base. The plugin
verifies every /v1/simulate response against
PaladinKeyRegistry
on Base — a non-upgradable contract holding the current 2-of-2 KMS signing
pair, the bundled TOKEN_REGISTRY_HASH, and a revocation mapping.
An independent-from-the-codebase
Gnosis Safe 2-of-3 owns the registry. The customer's defense against
malicious key rotation is the 7-day on-chain timelock plus the plugin's
sticky-revoked enforcement plus the keyTrustMode: 'pinned' config.
We held back an internal v0.2.0 candidate for a security-hardening sprint after a 3-adversary audit returned a major-rewrite verdict. The hardening shipped as v0.12.0 server / v0.3.0 plugin, then a v0.3.1 patch closed remaining ship-debt. This post walks through what changed and why, with links to the live contract, the verified source, and the live test pack.
What the audit surfaced
Three reviewers in parallel: Security (audit-mode framing — treat real-money signing as audit not code review), Maintainer (operational stability), and B2B Buyer + Domain Skeptic (would I integrate this in production). Security returned REQUIRES-MAJOR-REWRITE with four high-severity findings. Two findings were real and structurally fixable and drove the bulk of v0.3.0. Two more led to defense-in-depth fixes after we verified the transitive bindings against ground truth. Every flagged item got verified before becoming either a fix or a defense-in-depth improvement.
The signing digest changed (the C-1 fix)
Pre-v0.3.0, the signing digest formula was a single keccak over a raw domain prefix concatenated with the canonical JCS bytes of the response body:
digest = keccak256(b"paladin-simulate-v1" || JCS(body))
The "domain separator" was the literal UTF-8 bytes of
paladin-simulate-v1 — the same string that appears as the
apiVersion field inside the canonical payload. Cross-service
signature-replay defense was by-policy (different services use different
prefix strings + key segregation across providers), not by digest math.
The auditor flagged this as structurally weak: a sibling signed service
using paladin-quote-v1 as both apiVersion and
domain bytes, sharing a KMS key, opens cross-version sig confusion.
v0.3.0 ships a typed-domain digest with a hashed constant + a hash-of-hash pattern. Both inputs to the outer keccak are now 32-byte hashes, which cannot accidentally collide with any field value the way raw UTF-8 bytes can:
DOMAIN_HASH = keccak256("PaladinFi/simulate/v2") # 32 bytes, computed once
BODY_HASH = keccak256(JCS(body)) # 32 bytes
DIGEST = keccak256(DOMAIN_HASH || BODY_HASH) # 32 bytes
Wire format bumped paladin-simulate-v1 →
paladin-simulate-v2. No v0.2.0 customers existed (this is the
first public release), so the breaking change has no migration friction.
A cross-language parity test asserts byte-equality of the new constant
between the Python server signer and the TypeScript plugin verifier.
chainId is now signed explicitly (the H-1 fix)
The auditor flagged that chainId wasn't a top-level signed
envelope field. They missed that chainId is bound transitively
via requestHash (which the plugin already checked against
expected — a tampered chainId in transit would surface as a
requestHash mismatch, fail-closing the customer). But the
transitive binding is implicit, and audit-grade defense-in-depth says
make load-bearing checks explicit.
v0.3.0 adds chainId as a top-level signed envelope field, and
the plugin verifier now asserts signed.chainId === expectedChainId
as a direct invariant. The VerifyOpts interface gained a
required expectedChainId parameter; both call sites in the
plugin's swap action wire it through. A direct /v1/simulate
caller sending the wrong chainId now gets caught at the
explicit check, not just at the transitive binding.
Cross-language JCS strict mode (the H-3 fix)
Pre-v0.3.0, the Python and TypeScript canonicalizers behaved asymmetrically
at the edges of safe-integer range. Python rejected integers above
2^53 - 1; TypeScript permitted them and serialized through
JS Number. The asymmetry doesn't fire on any field in the current envelope
(epoch, signedAt, gasUsed are all bounded well below 2^53), but a future
field hitting the threshold would silently produce different canonical
bytes on the two sides, which would break signature verification.
v0.3.0 makes TypeScript reject integers outside
Number.MAX_SAFE_INTEGER too. Symmetric strict mode across
both languages. Customers should keep monetary values as strings — that
hasn't changed.
The other six items
Shorter walkthroughs of the remaining hardening:
- EpochUnavailableError 503 (M-3). Pre-v0.3.0, if the
on-chain epoch-cache failed to populate (RPC outage at startup), the
server silently fell back to genesis epoch 0 — customer plugins would
catch the mismatch IF the on-chain epoch is nonzero, but pre-first-rotation
the fail-open path was indistinguishable from healthy. v0.3.0 raises
EpochUnavailableErrorin production posture; the handler returns HTTP 503EPOCH_UNAVAILABLErather than signing with a wrong epoch. - retryToken HMAC scope (M-1). The HMAC binding for
retry-after-503 tokens was
(request_hash, expires_at). A leaked token could be replayed by any party. v0.3.0 expanded the binding to(request_hash, taker, ip_/24-or-/56_prefix, expires_at). Leaked tokens can no longer be replayed across different takers or different networks. Issued explicitly on 503 paths so the plugin can present them on retry and skip the x402 double-charge. - x402 facilitator allowlist (M-4). Pre-hardening, the
facilitator URL was env-configurable with no validation. v0.3.0 enforces
a host allowlist (
x402.org,coinbase.com) at module load. Env override is refused unlessPALADIN_NODE_ENV=test. - Server multi-RPC quorum (L-1). The plugin reads the
registry over a multi-RPC quorum; the server's epoch reader had been
single-RPC. v0.3.0 brings the server to the same quorum discipline:
K-of-N agreement required before updating the cache; a degraded
single-RPC fallback emits an alert key
(
epoch_quorum_pool_degraded_to_single_rpc) so operators get the signal. - XFF trusted-proxy guard. Caller IP extraction now
honors
X-Forwarded-Foronly when the direct peer is inPALADIN_TRUSTED_PROXY_CIDR. Defends against XFF spoofing in deployments where nginx is bypassed (direct hit on uvicorn, dev mode, misconfigured reverse proxy). - Request apiVersion literal (v0.3.1 N-3). The
server's
SimulateRequest.api_versionfield was a free string with a default ofpaladin-simulate-v2— meaning clients sendingpaladin-simulate-v1would be accepted by Pydantic even though the server only emits v2 responses. The C-1 digest fix mitigates the cross-protocol-replay impact, but the ambiguity at the validator surface was worth closing. v0.3.1 tightens the type toLiteral["paladin-simulate-v2"]; v1 requests return HTTP 422 with a clean validation error.
What we didn't ship and why
The honest scope-notes section of the v0.3.0 announce names what's not in this release. Three items worth flagging:
Owner is PaladinFi-controlled. The Gnosis Safe 2-of-3 has
all signer keys under operational control of a small team. The customer's
defense against malicious rotation is the 7-day on-chain timelock plus the
plugin's sticky-revoked plus the keyTrustMode: 'pinned'
config plus monitoring pendingRotation in the returned
TrustState. A future release adds an independent third-party
Safe signer. The 7-day timelock IS the customer's defense in the interim.
Layer 4 is our Anvil, not yours. Truly independent
simulation (Tenderly fork, local Anvil in the plugin) is on the roadmap
but nontrivial in practice — the Uniswap V3 callback pattern's
CallbackTransferFailed() revert in stateOverride mode means
generic publicClient.call doesn't generalize across all
aggregator paths we route through. We don't ship the optimistic version
that pretends to work; the new e2e test #07 exercises real Velora
calldata and honestly reports the Anvil revert as a documented Layer-4
limitation. The signing chain still verifies regardless.
KMS-backed signing keys, not HSM-on-prem. AWS KMS Key #1 = software-key FIPS 140-2 Level 1; GCP Cloud KMS Key #2 = HSM-backed FIPS 140-2 Level 3 (GCP rejects secp256k1 at software level — HSM is mandatory). Cloud-provider operator + lawful-process exposure is documented in the THREAT_MODEL.md. Evaluating HSM-on-prem is on the roadmap; we'll commit to a path when the operational tradeoffs are concrete.
vs running an Anvil fork yourself
A reasonable question: why pay $0.001 per call when I can run
anvil --fork-url base locally for free?
A self-run Anvil fork covers Layer 4 of the plugin's 5-layer defense. The other four layers — router allowlist, selector deny-list, multi-token state-diff invariants, signed-bytes binding — run locally in the plugin for free, but they assume a signed envelope that binds the simulation result to the specific token + amount tuple you requested. That's the Layer 4 attestation v0.3.0 ships. The $0.001 per call buys the signature from the registered KMS pair, anchored to an on-chain epoch, and cross-validated against an independent invariant set. Layer 4 is trusted-but-attested; the signing chain (Layer 5) is what makes a falsely-favorable simulation result detectable by the customer's plugin. A DIY Anvil can't produce that attestation. Layers 2-5 are contributed independently by the plugin gate; you don't pay extra for them.
v0.3.1: Tier-2 follow-up items
v0.3.0 left three items as deferred ship-debt that the Tier-2 Security
reviewer surfaced. v0.3.1 (same day) closes them: a new public
/v1/simulate/health endpoint (so customers can probe the
simulator's version unambiguously, not the co-deployed swap-router's
/health), a quorum-tally key-type hardening (canonical hex
string instead of raw int — defense-in-depth against future numeric type
drift), and removing a dead Pydantic model that wasn't being used to
validate the response path. No wire-format change between v0.3.0 and
v0.3.1; the plugin update is a drop-in replacement.
Verify
- Contract on Basescan (verified source):
0x30Bad67154C0115c5873b291cf3Dda120e508775 - Owner (Gnosis Safe 2-of-3 on Base):
0x824B874dE8E6FEFb99705F9f30097525c1722C2A - Live simulator health (should return
version: 0.3.1): swap.paladinfi.com/v1/simulate/health - Plugin:
npm install @paladinfi/eliza-plugin-trust@0.3.1(npm, GitHub Release). Prior 0.x versions on npm were pre-public previews; v0.3.0 is the first version under the public-stable wire-format commitment. - Threat model: THREAT_MODEL.md §§3-7
- e2e test pack:
paladinfi-contracts/tests/v0.2.0/e2e/— six tests covering signing chain, edge cases, cross-validation, tamper detection, production-readiness, digest parity (path isv0.2.0for historical reasons; tests run against the live v0.3.1 endpoint)
Questions or repro cases: dev@paladinfi.com.