
Security News
Rust 1.100 Will Add Dependency Cooldowns to Cargo
Rust 1.100 will add opt-in dependency cooldowns to Cargo, giving security scanners and maintainers time to catch malicious crate releases.
@vet402/sdk
Advanced tools
Trust scores for AI agents and x402 payees. Read a score, gate a payment with SpendGuard, or let payOrRefuse pay on Base or Solana only after every check passes.
Thin TypeScript client for the vet402 Trust API. Node 20+, ESM, zero runtime dependencies.
npm install @vet402/sdk
Get a key at vet402.com/dashboard/keys,
export it as VOUCH_API_KEY, and this runs as-is. apiKey is optional
(2026-09-07): without it the client sends no Authorization header and
getDecision still answers, key-less at 10 requests/min per IP (429
rate_limited beyond that). Scores, webhooks, watchlist and attest still
need a key — the server answers 401 missing_api_key, passed through as-is.
import { createVouchClient } from "@vet402/sdk";
// apiUrl defaults to https://vet402.com/api/v1 — pass it only to point at
// another deployment (a local dev server, say).
const vouch = createVouchClient({ apiKey: process.env.VOUCH_API_KEY! });
// Seller side: "should I accept payment from this wallet?"
const score = await vouch.getWalletScore("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045");
console.log(score.trustScore, score.recommendation); // 0–100 and ALLOW | WARN | BLOCK — live values, they move
// Buyer side: "should my agent pay this wallet?"
const payee = await vouch.getPayeeScore("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045");
console.log(payee.score, payee.recommendation, payee.dataDepth);
// After an x402 payment settles, feed it back (weights future scores).
await vouch.attestX402Payment({
wallet: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
txHash: "0x" + "11".repeat(32),
resource: "/api/premium/data",
});
Methods: getAgentScore, getWalletScore, getPayeeScore,
getPayeeVerdictFast, batchScore, attestX402Payment, createSpendGuard,
and — since 0.5.0 — getDecision and resolve.
getDecision / resolve (0.5.0)Product spec §7.3 / §9.1: the canonical integration is one call that returns the
L0–L2 facts and the ALLOW / WARN / BLOCK recommendation in the same
document. resolve turns whatever you hold (a URL, a domain, an address, a tx hash,
a chain:address payee id) into the resource id that getDecision takes.
const found = await vouch.resolve("https://api.example.com/v1/quote");
const resourceId = found.resource?.resource_id; // sha256 hex, or undefined when unlisted
if (resourceId) {
const d = await vouch.getDecision(resourceId, { role: "payer", callerDialect: "v2" });
// d.recommendation, d.reason_codes, d.facts (SellerFacts), d.freshness, d.evidence
// Pay only on ALLOW with degraded === false. WARN and BLOCK are both a refusal
// under the default allow-only policy.
}
getDecision needs a key (1 rate-limit unit per call; pass idempotencyKey to retry
without spending a second one). resolve is key-less on the wire but is sent through
the same client. Both are read-only; wiring the decision into SpendGuard is a later
release.
recommendationA payee score always carries degraded and signalsUnavailable, and both
override the recommendation:
| Field | Meaning | What you must do |
|---|---|---|
degraded: true | An input could not be read at all. The body is a refusal, not a measurement. | Never treat as ALLOW. |
signalsUnavailable non-empty | Some inputs were not measured (wallet_metrics, native_drain, usdc_drain, outcome_history) — a real but partial view, capped below ALLOW for that reason. | Never treat as ALLOW. |
SpendGuard already refuses both (payee_score_degraded /
payee_partial_measurement). If you read getPayeeScore directly, make the
same two checks yourself before moving money.
Every request is bounded by AbortSignal.timeout, default
DEFAULT_REQUEST_TIMEOUT_MS (10 s), overridable per client:
const vouch = createVouchClient({ apiKey: process.env.VOUCH_API_KEY!, timeoutMs: 3000 });
There is no way to disable it. SpendGuard is fail-closed, and a fail-closed
judgement that never runs is not a judgement — a timeout surfaces as
payee_trust_unavailable (a deny), never as a hang.
getPayeeVerdictFast)For facilitators and payment middleware that need the check inside the
settlement flow. This surface never computes: it returns the engine's already
pinned verdict, or an honest cache_cold.
import { payeeVerdictFastAllows } from "@vet402/sdk";
const verdict = await vouch.getPayeeVerdictFast(payee);
if (!payeeVerdictFastAllows(verdict)) {
void vouch.getPayeeScore(payee); // fire-and-forget warm, same cache (TTL 5 min)
return queueForRetry(); // cache_cold is the ABSENCE of a verdict, not an allow
}
SpendGuard deliberately does not use it: the guard enforces minPayeeScore
bands and its own maxScoreAgeMs and reports the full payeeScore, none of
which the fast body can supply. It is a pre-check, not a replacement for
getPayeeScore.
On 0.1.0? Check with
npm ls @vet402/sdk. That release predates three things documented here: theapiUrldefault (passapiUrl: "https://vet402.com/api/v1"explicitly), theVouchApiErrorclass (errors are plainErrors whosemessageis the API's code), and the fail-closedtrustPolicydefault (see SpendGuard below). Everything else below is the same on both.
payOrRefuse on Solana (0.7.0)payOrRefuse gates a payment before a signature exists: on anything other than ALLOW the
signer is never reached. Since 0.7.0 the payee's shape picks the rail — a 0x address pays on Base
(EIP-3009, account), a base58 address pays on Solana (SVM exact, svm). The 0x path is
unchanged.
import { Keypair } from "@solana/web3.js"; // optional peer dependency, loaded only on ALLOW
import { payOrRefuse, svmAccountFromKeypair } from "@vet402/sdk";
const r = await payOrRefuse({
payee: "<seller's base58 wallet>",
resource: "https://seller.example/api/paid",
amountUsd: 0.01,
fetch,
svm: { account: svmAccountFromKeypair(Keypair.fromSecretKey(secret)), rpcUrl: process.env.SOLANA_RPC_URL! },
});
// r.status: "paid" | "refused" | "failed" · r.rail: "svm" · r.svmTransaction: signed tx (base64) or null
BLOCK, WARN (unless waived with requireVet402Allow: false and a floor), degraded, an unreadable
decision, and a resource outside the catalogue (resource_uncatalogued + evidence_unavailable) all
refuse before the 402 is fetched, with zero sign* accesses and zero RPC calls.scheme: "exact", network solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp, asset
EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v (exact match), x402 v2, and an extra.feePayer that is
neither payTo nor your signer. payTo must equal payee exactly — base58 is case-sensitive.
Otherwise: chain_or_asset_mismatch / payee_mismatch / price_above_ceiling / price_above_declared.ALLOW the SDK builds the transaction itself (compute-unit limit, compute-unit price ≤ 5,
TransferChecked to the ATA of payTo, a memo it generates), asks your signer to sign, and re-sends it to
the seller. If the signed transaction's message is not byte-identical to what it built, nothing is sent.
nonce is that memo, txHash the seller-claimed Solana signature, and attested is always false.evidence.source: "subgraph" | "both" is a caller error for a Solana payee (The Graph's x402 Base subgraph
does not index Solana). @solana/web3.js is never imported on a refusal, so EVM-only users need not install it.network: "base-sepolia"A 0x payee is paid on Base mainnet unless another chain is named. network: "base-sepolia" pays with
Base Sepolia USDC (0x036CbD53842c5426634e7929541eC2318f3dCF7e, chain id 84532) and never attests the
payment to the production ledger, so attested stays false. Any other value throws invalid_network
before a request is made, so a typo cannot fall back to mainnet. Leave network out for a base58 payee.
const r = await payOrRefuse({ payee, resource, amountUsd: 0.01, fetch, account, network: "base-sepolia" })
minChainReceiptspolicy.evidence.minChainReceipts is a floor on the USDC transfers the payee has received on the
payment chain. Pass two independent readers, chainReader and chainReaderCrossCheck (a viem
PublicClient fits each). The SDK reads the Transfer logs through both and counts them only when the
two sets are identical.
network: "base-sepolia" only. On mainnet it throws invalid_evidence_policy, because a payee can
create its own receipts.>= 0. Zero-value transfers are not counted.evidence.chainFromBlock sets the first block. A window
above 100,000 blocks is not read.insufficient_chain_evidence (read, below the floor), or evidence_unavailable with
chain_evidence_unavailable (a reader failed, answered for another chain, or the two disagreed).requireVet402Allow: false.payeeNamePass payeeName and ens in place of payee. Before vet402 is asked anything, the SDK reads the
seller's x402-offer text record on Sepolia ENS and the ENSIP-29 attestations over it, through two
independent Sepolia RPC clients. The payee is the payTo of the attested offer.
const r = await payOrRefuse({
payeeName: "seller.eth",
ens: {
clients: { primary: sepoliaClientA, secondary: sepoliaClientB }, // two different RPCs (viem PublicClient)
policy: { trustedAttesters: [{ name: "atst.example.eth", address: "0x...", recordKeys: ["x402-offer"] }] },
},
network: "base-sepolia",
resource: "https://seller.example/api/paid",
amountUsd: 0.01,
fetch,
account,
})
network other than "base-sepolia" throws invalid_ens_chain, so Base mainnet
cannot be paid by name.viem (an optional peer dependency). It is loaded only when payeeName is passed.payTo, same network and asset, and an amount no
higher than the offer. A difference refuses with ens_offer_mismatch. The record is read a second time
right before signing.payee is passed as well, it must equal the offer's payTo (payee_mismatch otherwise).policy.evidence.minEnsAttestations is a floor on valid attestations. The refusal words start with
ens_ and are exported as ENS_ATTESTATION_REASONS from @vet402/sdk/ens.r.decision.ens records both reads../ens subpath@vet402/sdk/ens exports the ENSIP-29 gate on its own: checkEnsOffer, assertEnsAttestationPolicy,
compareOfferToAccept, the attestation codec and the ENS readers. It is the only entry that imports
viem, so install viem to use it. The package root never loads it.
import { checkEnsOffer } from "@vet402/sdk/ens"
failed paymentstatus reports what the seller answered. "failed" with signed: true means a signed payment exists
and the seller did not confirm it. That payment can still settle on chain, or may have settled already.
Calling payOrRefuse again signs a second payment, so check the first one before retrying. The SDK does
not retry and does not read the chain.
r.receipt.retry (0.7.1) holds what to check.
| Rail | Did it settle? | Can it still settle? |
|---|---|---|
Base, retry.authorization | USDC authorizationState(from, nonce) returns true. | Only until validBefore (unix seconds, at most 120 seconds after signing). |
Solana, retry.svm | getSignaturesForAddress for receipt.payer lists an entry with err: null whose memo contains retry.svm.memo. | Only while isBlockhashValid answers true for retry.svm.recentBlockhash. |
Retry once the payment has not settled and no longer can. retry.signed: false means nothing was signed,
so a retry cannot pay twice.
r.receipt also carries the endpoint and method, the terms selected from the 402 (network, asset,
payTo, amount), the ceiling applied (maxPerTxUsd), the payer address once something was signed,
the reason_codes, and the seller's PAYMENT-RESPONSE as settlement with the HTTP responseStatus.
settlement is the seller's claim, not an on-chain confirmation. The EVM signature is never included.
On Solana, retry.svm.transaction is the transaction that was sent to the seller, with the payer's
signature in it (the same value as r.svmTransaction). The fee payer's signature is still missing, so the
fee payer named in the 402 is the one who can submit it, while its blockhash is valid, and the chain accepts
it once. Keep it out of logs that others can read.
SpendGuard counts the day's total in whole micro-dollars (0.000001 USD) instead of adding dollar floats. Before, 99 payments of 0.01 added up to 0.99000000000000007, so a 1.00 dailyBudgetUsd refused the 100th payment it should have allowed. The error only ever refused, it never let extra money through. Amounts are rounded up and limits rounded down to the micro-dollar, so a value finer than that can only make the guard refuse sooner. Values within float noise of a whole micro-dollar count as that whole number.requireVet402Allow: false no longer pays when the verdict word is missing or is not ALLOW, WARN
or BLOCK. The call refuses with evidence_unavailable on every path (a 0x payee, a resource outside
the catalogue, a Solana payee). In 0.7.0 only the payeeName path checked the word, and the other paths
paid on the caller's floors alone. Callers on the default (requireVet402Allow unset or true) get the
same refusals as before.PayOrRefuseResult.receipt is new (see the section above). It is additive, and no existing field
changed meaning.svmTransaction is kept when the seller's response cannot be read after the transaction was
sent. In 0.7.0 it could come back null for a transaction that had been sent.User-Agent: vet402-sdk/<version>, so vet402 can count calls per client
version. It is not set in a browser. Requests to sellers, Solana RPCs and The Graph are unchanged,
and a seller is not told which SDK is paying.These were not listed when 0.7.0 shipped. They are compile-time changes for TypeScript callers upgrading from 0.6.0.
PayOrRefuseResult gained two required fields, rail and svmTransaction. Code that builds a
PayOrRefuseResult by hand (a mock or a test double) must add them.PayRefuseReason gained members: insufficient_chain_evidence, chain_evidence_unavailable,
insufficient_ens_attestations, vet402_unreachable and the 13 ens_* words. An exhaustive switch
over it needs a default branch or the new cases.A non-2xx answer throws a VouchApiError carrying the API's machine-readable
code and the HTTP status, so you can tell your key is wrong from we are
having a bad day without parsing strings:
import { VouchApiError } from "@vet402/sdk";
try {
await vouch.getPayeeScore("0x...");
} catch (err) {
if (err instanceof VouchApiError && err.status === 401) {
// err.code === "missing_api_key" | "invalid_api_key" — fix the key.
}
}
Buyer-side counterpart to the score lookups above: before your agent pays someone, ask the guard. It returns an allow/deny decision plus machine-readable reasons — and nothing else. SpendGuard is strictly non-custodial: it never touches keys, funds, signing, or transaction submission. Execution stays with your wallet stack (Coinbase AgentKit, Privy, ...).
BREAKING (v0.2.0): fail-closed by default. Money moves only on a clean
ALLOWverdict unless you explicitly opt out. With notrustPolicyset, everyevaluate()performs the payee trust lookup and denies when:
Condition Reason code Recommendation is WARNorBLOCKpayee_recommendation_not_allowThe score came from a degraded read payee_score_degradedPartial measurement ( signalsUnavailablenon-empty)payee_partial_measurementThe lookup was refused for your key (401/403) payee_trust_unauthenticatedThe lookup failed on our side (5xx, timeout, rate limit) payee_trust_unavailableOpt-outs:
trustPolicy: "block-only"(WARN passes; BLOCK, degraded and failed lookups still deny) ortrustPolicy: "custom"(pre-0.2.0 behaviour — only the rules you set apply, and the lookup only runs whenminPayeeScore/blockOnRecommendationis set).Which default do you have? Check with
npm ls @vet402/sdk. 0.1.0 has notrustPolicyoption: the lookup runs only when you setminPayeeScore/blockOnRecommendation, which is what"custom"means here. 0.2.0 and later are fail-closed out of the box.
const guard = vouch.createSpendGuard({
maxPerTxUsd: 10, // deny any single payment above $10
dailyBudgetUsd: 50, // deny once today's allowed total would pass $50
// trustPolicy: "allow-only" is the default: deny anything but a clean ALLOW
minPayeeScore: 40, // optional stricter floor on top of the policy
});
const decision = await guard.evaluate({ payee: "0x...", amountUsd: 5 });
if (decision.allow) {
// hand off to AgentKit / Privy / your own signer
} else {
console.error(decision.reasons); // e.g. ["payee_recommendation_not_allow"]
}
How it works:
maxPerTxUsd, dailyBudgetUsd) are optional — set only
the ones you want. Under the default trustPolicy: "allow-only" the payee
trust lookup (GET /v1/payees/{address}/score) always runs, but is skipped
when a local rule already denied, so no quota is burned on a dead payment.
Only trustPolicy: "custom" makes the lookup conditional on
minPayeeScore / blockOnRecommendation being set — with "custom" and
neither set, no API calls happen at all.payee_trust_unauthenticated means the API key is missing or invalid and
retrying will not help; payee_trust_unavailable means the upstream is
unhappy and retrying might.evaluate calls within one process cannot
race past the daily budget together. If an allowed transfer then fails or
is skipped, call guard.release(amountUsd) to give the reservation back.A green suite only proves the tests pass, not that they hold the rules.
test-mutations.mjs breaks one gate at a time in src/pay-or-refuse.ts and
src/subgraph-evidence.ts (BLOCK waived, degraded waived, an evidence floor
inverted, payTo check removed, the payment module hoisted out of the ALLOW
branch, _meta.block faked, key redaction removed, …), rebuilds, runs the
suite, restores the source, and prints one row per mutation:
npm run mutations
# | 変異 | 壊した規則 | fail 数 | KILLED/SURVIVED
finally); every mutation carries a
MUTANT marker, so grep -rn MUTANT src must print nothing afterwards,
and the run ends by re-checking that the tree is green (restored: green).About two minutes for 50 mutations on a laptop (tsc + node --test per mutation). npm run mutations:svm
runs 21 more against the Solana path.
Absolute, because relative repo paths do not resolve on the npm package page.
VOUCH_API_KEY@vet402/middleware — seller side (x402 request gate)@vet402/mcp-server — MCP toolMIT · vet402
Under the default trustPolicy: "allow-only", SpendGuard currently denies
every payee that exists. Know this before you wire it into a payment path.
It is not a lookup failure — it is the engine's banding working as designed:
PAYEE_THIN_SCORE_CEILING, the 2026-08-13 score-manipulation ruling);On the operator benchmark at https://vet402.com/accuracy the 17 known-good addresses (Vitalik, the Ethereum Foundation, Coinbase, Kraken, the ENS DAO treasury, Gitcoin) score 0 ALLOW / 17 WARN / 0 BLOCK, and a live payee with 48 delivery-verified L1 receipts and zero failures still scores WARN.
The default is deliberate and is not changing. "We could not verify this"
must keep meaning "do not pay". The mistake to avoid is reaching for
trustPolicy: "custom" to unblock yourself — "custom" also disables the
staleness (H-2), degraded and partial-measurement refusals, which is almost
certainly not what you wanted.
trustPolicy: "evidence" — accept a WARN you can justifyconst guard = vouch.createSpendGuard({
maxPerTxUsd: 10,
trustPolicy: "evidence",
requireEvidence: { minL1Deliveries: 3 },
});
Pick floors that can actually be met. Measured on production 2026-08-29: every payee's
l1DistinctBuyersis 1 (the observatory is the only buyer on record), so a floor ofminL1DistinctBuyers: 2denies every payee — including one with 62 delivery-verified receipts and zero failures. Floors are a statement about the evidence that exists, not a wish list.
A WARN passes only when the payee's measured signals.receiving record
clears the floors you name. Everything else behaves exactly like
"allow-only":
| Situation | "evidence" |
|---|---|
| BLOCK | deny (payee_recommendation_block) — evidence never overturns a refusal |
| WARN, floors cleared | allow |
| WARN, floors not cleared | deny (payee_insufficient_evidence) |
| Evidence field absent from the response | counts as 0 — absence is not a pass |
| Degraded read | deny (payee_score_degraded) |
| Stale score | deny (payee_score_stale) |
| Partial measurement | deny (payee_partial_measurement) |
| Lookup failed | deny (payee_trust_unavailable / ..._unauthenticated) |
Floors available: minL1Deliveries, minL1DistinctBuyers,
minX402Payments, minDistinctPayers. At least one must be >= 1 —
all-zero floors would accept every WARN, which is "block-only" and has to
be spelled that way. requireEvidence is rejected under any other policy, so
the opt-out is always visible at the call site.
The Python SDK (
vet402) does not yet haveevidence; itstrust_policyoptions remainallow-only/block-only/custom.
FAQs
Trust scores for AI agents and x402 payees. Read a score, gate a payment with SpendGuard, or let payOrRefuse pay on Base or Solana only after every check passes.
The npm package @vet402/sdk receives a total of 63 weekly downloads. As such, @vet402/sdk popularity was classified as not popular.
We found that @vet402/sdk demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Security News
Rust 1.100 will add opt-in dependency cooldowns to Cargo, giving security scanners and maintainers time to catch malicious crate releases.

Security News
Cloudflare is acquiring Deno’s team, which will provide another year of bug fixes and security updates before ending support for the runtime.

Security News
Anthropic’s free OSS Scanner uses Mythos and other Claude models to find open source vulnerabilities and send maintainers reports with proposed fixes.