@integraledger/lcp-placement-x402
v0.10.1
Published
Places an LCP reference into an x402 v2 payment challenge, and reads it back.
Readme
@integraledger/lcp-placement-x402
Places an LCP reference into an x402 v2 payment challenge, and reads it back out.
This is the HTTP-layer carrier, not the x402 weld. @integraledger/lcp-binding-evm-x402 binds atrHash
into the EIP-3009 authorization nonce — that is the settlement binding, and it answers what did the money
commit to? This package answers a different question — where does the reference ride on the wire? — and
both are true at the same time. x402 is the proof that one protocol can need a binding and a placement.
It is also the reason this package exists at all: the carrier it declares was previously only a private
TypeScript interface inside a seller implementation, and a private convention nobody outside that codebase
can read is not an artifact.
npm install @integraledger/lcp-placement-x402| | |
|---|---|
| Chain | none here — settlement is binding-evm-x402's, on whichever EVM rail the scheme selects |
| Pattern | http-advisory (LCP §8.3.7, Tier A) |
| Field | extensions.legalContext.info — the top-level extensions map, the carrier x402 protects |
| Read also | accepts.0.extra.atrHash — a bare hash, its own encoding (integrity) |
| Terms URL | extensions.legalContext.info.legalContextUrl — declared, never written by place |
| Carrier types | sha256, url |
| Spec | x402 v2 (x402-foundation/x402@1fec3aa04e41, specs/x402-specification-v2.md), gate discharged 2026-07-30 |
Use
import { X402_PLACEMENT, x402Placement } from "@integraledger/lcp-placement-x402";
declare const challenge: unknown; // the x402 402 challenge document, as received
const placed = x402Placement.place({ type: "sha256", value: "0x…" }, challenge);
const ref = x402Placement.extract(challenge); // reads either carrier, canonical firstBoth members are total: a refusal is a returned value, never a thrown exception. Entries beside our own in
the extensions map are preserved on every place. That is narrower than x402's echo rule, which protects the
legalContext entry's contents too — see Known limitations: place replaces our entry whole, so it is a
seller's write verb, not a buyer's echo verb.
Specification provenance — verified against the live host, 2026-07-30
Read against the live x402 v2 specification in the x402 repository (specs/x402-specification-v2.md, §5.1.2
and the PaymentRequirements table), four facts were confirmed and each one decides something here:
extensionsis carried onPaymentRequired,PaymentPayloadandSettlementResponse. A reference placed there is on the receipt, not only the proposal — so the record can bind at execution time.- Each entry carries
infoandschema—infois "Extension-specific data provided by the server",schemais a "JSON Schema defining the expected structure ofinfo". Nothing in the spec fetches or validatesschema; it is a pointer. That{info, schema}wrapper is why this is the one placement in the plan that overrides the kit'splace(see below). - The echo rule: "The client must include at least the info received; it may append additional info but cannot delete or overwrite existing info." This is the carrier the protocol itself protects, which is why it is canonical here rather than the per-requirement object.
extrais "Scheme-specific additional information" on aPaymentRequirementsentry — the payment scheme's object, whose contents that scheme defines. Soplacenever writes there; the alias is read-only, and that is a decision about whose namespace it is, not an omission.
Extension identifiers are implementation-defined strings — no registry, no reverse-domain rule — so the
legalContext key is available today and this placement is Tier A on the wire.
Drift from LCP v1.37 §C.4 — recorded then adopted by v1.38
The host governs: its live specification is binding and LCP's Appendix C is an illustration. Two differences, neither of which changes the design:
- v1.37 rendered the echo rule in RFC-2119 capitals ("MAY append to but MUST NOT delete or overwrite")
where the host states it in lower case and about the data rather than the map. v1.38 §C.4 now says
exactly that, so this item is discharged; it is kept because it is why the package reads the way it does
and states it about the extensions map. The live spec states it in lower case and about the
infopayload. Same rule, weaker modality, one level lower. - The live spec also carries
extraonPaymentPayload.accepted, which §C.4 does not mention.
Drift from a strict buyer-side reader, recorded for the same reason
Buyer gates that parse this wire document already exist. The paths and shapes match field for field — accepts[0].extra.atrHash, accepts[0].extra.legalContextUrl,
extensions.legalContext.info.{type,value,legalContextUrl}. The resolution semantics do not, and three
differences are real. Recording them is the whole point of declaring the carrier: an undisclosed
divergence between two readers of one document is the drift this package exists to end, not a smaller version
of it.
- Carrier precedence is inverted. This placement answers with the canonical
extensionsslot when both carriers are present — the manifest's declared field wins, which isbinding-core's rule for every protocol in the set. A buyer gate may instead preferaccepts[].extra, on the ground that it is the per-requirement carrier and binds to the requirement actually being paid. Measured on one challenge carrying0xaa…inextensionsand0xbb…inextra, this package answers0xaa…. - Carrier disagreement is resolved here and REFUSED by a strict reader. A buyer gate may refuse outright
rather than pick, because two values on one challenge would let a seller advertise different terms to
different readers. A placement is structural — it reads the strongest
declared carrier and does not adjudicate the host's document — so it answers with the canonical value and
says nothing. A caller that needs the commerce reader's guarantee must compare both carriers itself:
readDeclaredPathsreturns the first hit, not the set. - A
urlcarrier is placeable here and rejected there.carrierTypespermitssha256andurl; a strict buyer gate refusesextensions.legalContext.info.type !== "sha256"outright, because it compares the advertised value against a recomputed record hash and nothing but a hash can be. See Known limitations.
Reconciling 1 and 2 belongs to a universal buyer parser that reads both carriers through this manifest; a follow-on re-expresses the seller's private carrier interface in terms of this manifest and touches the writer only. Neither is done here.
Tier B forward work — prose only, no manifest
§C.4's two forward paths are real and neither is shippable: a reference inside the signed Offer/Receipt artifact (the EIP-712 types are closed structures and any change to them is a breaking version change; under JWS an added claim is signed but explicitly uninterpreted), and a registered extension identifier with a published schema (a standardization step, not a protocol change). No Tier B manifest is declared for either — a manifest carrying a shape whose owner has not defined it is exactly the assertion this seam refuses.
The one override, and why it earns it
extract is the kit's, unchanged: reading extensions.legalContext.info is an ordinary object-path read,
and the bare-hash alias is handled by its own declared encoding. place is overridden, because x402's slot
does not hold the reference — it holds { info, schema }, a wrapper that no container kind models.
Inventing an x402-extension container kind would put one protocol's name inside a generic enum, which is
the abstraction leaking. One overridden member is composition; the test suite asserts extract still behaves
exactly as a freshly built kit adapter does, over every accept-and-refuse path, so a later edit cannot
quietly fork the read half too.
The override changes the shape it writes, never which documents it will write into. place refuses
exactly what a kit adapter built from this manifest alone refuses, over the same inputs and with the same
codes, and a test pins that equality. Two rules do the work:
- Own properties only. A challenge with zero own properties does not inherit an
extensionsmap into the document we emit.extractreports such a document asreference-absent, and the two halves must agree about what is present — aplacedocument is exactly as attacker-influenced as anextractone. - An
extensionsthat is present and is not a map REFUSES (x402/document-malformed), rather than being replaced. The declared field isextensions.legalContext.info, solegalContextis the field's direct holder andextensionssits one level above it:binding-core's malformed-container rule replaces at the holder and refuses above it, because replacing an intermediate discards everything beneath. Absent is still created — that is the extension point working — and our own entry, being the direct holder, is still replaced.
Two carriers, and the alias has a different SHAPE
x402 is the only protocol in the set whose alias is encoded differently from its canonical field. The
canonical slot holds an LCP §8.1 {type, value} object; accepts[0].extra.atrHash holds a bare hash.
Writing lcp:sha256:0x… into extra.atrHash would emit a field neither our own seller nor any x402
counterparty parses. This is the reason a readAlso entry declares its own encoding at all.
The alias is index 0 only. A locator names one path, and accepts[0] is what the shipped buyer parser
reads: the reference must bind to the requirement actually being paid, and searching every requirement would
let a seller park a second set of terms on an alternative it never expects to be chosen.
Reaching that path is also the reason binding-core's readAtPath now indexes arrays — narrowly, on a
canonical non-negative integer segment only, so length and every other array property stay unreachable.
That relaxation lives in the kit rather than in a private loop here, because a read rule nobody can find is
the same defect as a carrier nobody can find.
Known limitations
placewrites the hash, never the terms URL.place(ref, doc)holds one reference; the terms URL is a second datum.termsUrlFieldis declared so a parser can find the URL our seller does emit, the same divisionplacement-acpdraws withmetadata.legal_context_url.- The shipped carrier repeats the URL at
accepts[0].extra.legalContextUrl. A singletermsUrlFieldcannot express two spellings, so that one is recorded here rather than half-declared in the manifest. - A
urlcarrier is placeable here and no shipped x402 reader accepts one.carrierTypespermitssha256andurl— the §8.1 integrity form and the §8.1 discovery form, the same pairplacement-acpandplacement-ucppermit — while a strict buyer gate refuses anyinfo.typebutsha256. The permission is not narrowed away here because narrowing the reference field to one type is a statement about what the slot may hold across the whole set, not a fact about x402. Related and not ours to fix in this package:readDeclaredPathslabels a canonical-field hitcarrierClass: "integrity"unconditionally, so aurlread fromextensions.legalContext.infois labelledintegrityand passesrequireIntegrity()(from@integraledger/lcp-binding-core, likemakePlacement). That isbinding-corebehaviour thatplacement-acpandplacement-ucpalready share onmain; this manifest only adds a third reference field with the same property, andrequireIntegrityhas no production caller yet. - Our own
legalContextentry is replaced whole, not merged — and the key that loses islegalContextUrl, the onetermsUrlFieldpoints at.placewrites{info, schema}, so nothing previously inside our entry survives, and on the document sellers actually emit that is concretely the terms-URL half: measured on the vector case for the long-standing shipped carrier,termsUrlFieldreadshttps://seller.example/.well-known/legal-context.jsonbeforeplaceandundefinedafter. This is the kit's leaf-write semantics, not an override defect —makePlacement(X402_PLACEMENT).placedrops it identically — and it is why the previous bullet saysplaceholds one reference: a caller that needs both halves on the wire writes the URL itself. Consequence for a buyer. Do not build aPaymentPayloadecho withplace. x402's rule is that the client "cannot delete or overwrite existinginfo", and re-placing over a server-sent entry deletes the URL the server put there. Echo the received entry verbatim and place only where no entry exists yet; the reconciled read/echo path belongs to a universal buyer parser, not this package. x402 defines onlyinfoandschemafor an entry and thelegalContextkey is ours, so owning the entry is the point for the seller direction; entries beside ours in theextensionsmap are preserved unconditionally. LEGAL_CONTEXT_SCHEMAis INLINED, not a reference. It was a$refathttps://legalcontextprotocol.org/schemas/lcp-extension.json, which returns 404 — re-measured 2026-08-11 — and x402 makesschemaa REQUIRED member of every extension entry, so that shipped a required member no counterparty could resolve. All nine extensions published in the x402 repository inline a complete JSON Schema, and one of them, Bazaar, forbids an external$refoutright. The exported value is now a frozen literal that is byte-identical to what the placement emits. Changing it is a wire change: it appears in every challenge.
Provenance
Cut against x402 v2 (x402-foundation/x402@1fec3aa04e41, specs/x402-specification-v2.md, read 2026-07-30) and reconciled
against LCP v1.37 §C.4 the same day, and re-read against v1.38 §C.4 on 2026-08-12 — which adopted the
drift items below and added §6.1's reserved keys. The paths and shapes are matched field-for-field against the
shipped seller carrier and the buyer parsers that read it; the resolution semantics diverge from a strict
buyer gate in three recorded ways, and Drift from a strict buyer-side reader
above states each one and who closes it. Both drift sections are the disclosure, not a to-do list — a
divergence written down is governed; the same divergence unwritten is the undisclosed-drift defect wearing
a new name.
Requires Node >= 24. Part of the Legal Context Protocol open layer — see the documentation and the package index. Apache-2.0.
