@integraledger/lcp-placement-x402
v0.20.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), written by place |
| Terms URL | extensions.legalContext.info.legalContextUrl and accepts.0.extra.legalContextUrl — both written by place |
| Carrier types | sha256 |
| Spec | x402 v2 (x402-foundation/x402@db5da2e65952, specs/x402-specification-v2.md, read 2026-08-11), 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(
{ ref: { type: "sha256", value: "0x…" }, termsUrl: "https://seller.example/.well-known/legal-context.json" },
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.placewrites the §C.4 mirror and the terms URL there because §6.1 reserves those names for exactly this use, and writing only the extensions map left a challenge the published buyer parsers refuse (integra-protocol#8).
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 §C.4 — recorded then adopted by LCP
The host governs: its live specification is binding and LCP's Appendix C is an illustration. Two differences, neither of which changes the design:
- The appendix once 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. LCP §C.4 now
says exactly that — it records the rule "in lower case and about the
infopayload rather than the map" — 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. 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.
Limitations, and the three this version resolved
The predecessor recorded four limitations here. Three were one defect wearing different clothes — integra-protocol#8, a seller built on this package emitting a challenge the published buyer refuses — and are resolved rather than recorded:
placenow writes the whole advertisement.place({ ref, termsUrl }, doc)puts the reference and the terms URL on the wire together, at every slottermsUrlFieldsdeclares — theinfomember the authority schema requires and theaccepts[0].extramirror §C.4's illustration carries — and REFUSES an integrity-bearing advertisement with no URL, because a hash no counterparty can resolve is unverifiable by construction. The predecessor's "place writes the hash, never the terms URL" division put the URL in no published writer at all while every published reader demanded it.- Both URL spellings are declared, and both are written.
termsUrlFieldsis plural; the singular member could not express the second spelling and the shipped carrier'sextra-side URL lived only in this README. - Our own entry is still replaced whole — and no longer costs the URL.
placerebuilds{info, schema}wholesale (junk a counterparty parked inside our entry does not ride our wire), and the URL survives because the rebuild happens after the kit has written it intoinfo. The predecessor measured the opposite: the leaf-write droppedlegalContextUrlon the very document sellers emit.
One limitation is withdrawn rather than resolved: the url carrier admission is gone.
carrierTypes is sha256 alone. A url here was well-formed against the manifest and refused by every
shipped reader — a permission no reader accepts is a claim about the ecosystem, not a description of it —
and the schema this package itself puts on the wire (the authority document's shape, drift-gated in
@integraledger/lcp-conformance) is const: "sha256", so a url reference would emit a challenge that
violates its own adjacent schema.
What remains, and is a fact rather than a defect: readDeclaredPaths labels a canonical-field hit
carrierClass: "integrity" unconditionally. That is binding-core behaviour shared by every placement,
and requireIntegrity() (from
@integraledger/lcp-binding-core, like makePlacement) is where a caller that
needs a content-addressed value says so — it checks the decoded type as well as the slot's label.
Provenance
Cut against x402 v2 and reconciled against LCP §C.4 on 2026-07-30, and re-read against LCP §C.4
on 2026-08-12 — which adopted the drift items below and added §6.1's reserved keys. The host revision is
x402-foundation/x402@db5da2e65952 (specs/x402-specification-v2.md), which is what spec-pins.json
records this tree read on 2026-08-11 and what pnpm spec-drift watches. 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.
