Quickstart
git clone https://github.com/Ritapossible/Scrip.git
cd Scrip
npm install
npm run probe # read-only, no keys, no gas
npm run web # http://127.0.0.1:8080
Use npm run web rather than a plain static server. These
pages link to ./docs without an extension, which
production resolves through Vercel's cleanUrls. A plain
server returns 404 for it, so every menu entry looks broken when the
site is fine.
The commands
| Command | Needs | What it does |
|---|---|---|
npm run probe |
nothing | Resolves FXRP from the registry and verifies the EIP-712 domain reconstructs. |
npm run test:fork |
anvil, forge build |
Settles a real invoice against a fork of live Coston2, including the attacks. |
npm run price |
nothing | Reads the live XRP/USD FTSO feed and prices a USD invoice in FXRP. |
npm run setup |
RELAYER_PK |
Generates a fresh payer that has never held gas, and writes .env. |
npm run fund |
a funded relayer | Moves FXRP to the payer. Refuses if the payer holds any C2FLR. |
npm run settle |
a funded .env |
Signs and settles one invoice gaslessly against the deployed facilitator. |
npm run serve:x402 |
a funded .env |
Runs the facilitator (/verify, /settle) and a $0.25 paid endpoint. |
npm run agent |
a funded payer | Hits the paid endpoint, gets a 402, pays it, gets the resource. |
npm run typecheck |
nothing | Typechecks every script and module. |
npm run web |
nothing | Serves this site locally with production URL rules. |
The probe
Read-only, and it fails loudly if signatures would not verify. It reads
rather than infers, because FXRP ships a vendored
ERC20Permit rather than stock OpenZeppelin - anything
taken from upstream documentation is a guess.
Three results worth repeating from the live run:
-
Signatures will verify. The EIP-712 domain rebuilt
from
eip712Domain()(IERC5267) matches the token's ownDOMAIN_SEPARATORexactly. This was the highest-risk unknown in the design - a forked domain would make every permit revert with no useful error. -
decimalsis 6, not 18. Hardcoding a standard 18 would put every invoice off by a factor of 1012. -
FXRP is resolved, not hardcoded - registry →
AssetManagerFXRP→fAsset().
The fork test
Settles a real invoice against the real FXRP contract on a fork of live Coston2, and runs the payee-substitution, replay and front-runner cases as attacks. No keys, no funds, nothing on a real chain.
anvil --fork-url https://coston2-api.flare.network/ext/C/rpc --port 8546
forge build
npm run test:fork
What it proves, and how the payer is funded on a fork, is on the status page.
A gasless payment
Copy .env.example to .env and fill in three
throwaway wallets. Generate them fresh - never reuse a key that has
ever touched mainnet.
PAYER
Holds FXRP and deliberately zero C2FLR. That zero is the proof: it is what makes the payment gasless from the agent's side.
RELAYER
Holds C2FLR only. It pays every gas fee.
PAYEE
Just receives. It needs nothing at all.
Fund one wallet from the Coston2 faucet, which dispenses both C2FLR and FXRP, then let the setup script do the rest:
RELAYER_PK=0x<that wallet's key> npm run setup
npm run fund -- 2
settle.ts asserts the payer's native balance is
exactly zero, and no wallet can be drained to exactly zero -
the drain transaction burns gas and leaves dust behind. Only an
account that has never received C2FLR satisfies that equality, so the
payer is created fresh and funded with FXRP alone.
npm run settle # pays 0.5 FXRP
npm run settle -- 1.25 # pays 1.25 FXRP
It resolves FXRP through the registry, checks its locally built EIP-712
digest against the facilitator's own intentDigest() before
signing anything, and refuses to run if the payer is short, the relayer
has no gas, or the invoice was already settled. A revert comes back as
a sentence rather than a stack trace.
Charging for a route
The middleware turns any Express route into a paid one. The price is in dollars; the FXRP amount is computed at the live FTSO rate when the quote is issued.
app.get(
"/api/haiku",
requirePayment({ facilitator, payee, usd: "0.25" }),
(req, res) => res.json({ haiku: HAIKU }),
);
An unpaid request gets a 402 carrying the price, the
amount, the rate it was derived from, and the invoice ID to sign. A
request with a valid X-PAYMENT header is settled on chain
and then handed to the route as though it had been free, with the
receipt in X-PAYMENT-RESPONSE.
exact-permit2612.
Every x402 implementation in the wild is written against USDC, whose
EIP-3009 authorisation names its recipient inside the signed message.
FXRP implements EIP-2612 permit, which does not - so this
rail needs a second signature and says so in the scheme name. A
USDC-shaped client finds out from the name rather than from a revert.
Environment
| Variable | Purpose |
|---|---|
RPC_URL | Coston2 endpoint. Defaults to the public one. |
PAYER_PK | Signs both messages. Holds FXRP, no gas token. |
RELAYER_PK | Sends the transaction and pays for it. |
PAYEE_ADDRESS | Receives the FXRP. |
FACILITATOR_ADDRESS | The deployed contract. Already filled in. |
DEADLINE_SECONDS | Signature lifetime, default 3600. |
INVOICE_ID | Optional fixed invoice ID. Random per run otherwise. |
PORT | Port for the x402 service, default 8402. |
SCRIP_INVOICE_SECRET | Signs invoice IDs. Without it, quotes do not survive a restart. |
Deploying your own
The facilitator is already deployed - see the contract page. To put up your own, keep the key in an encrypted keystore rather than a shell variable:
cast wallet import scrip-deployer --interactive
forge create contracts/ScripFacilitator.sol:ScripFacilitator \
--rpc-url coston2 \
--account scrip-deployer \
--broadcast \
--constructor-args <FXRP address>
--constructor-args is variadic and will silently eat any
flag placed after it, so keep it last. And if verification fails,
do not re-run forge create - that
deploys a second contract and leaves you unsure which is canonical.
Retry with forge verify-contract against the address you
already have.
Two environment notes
The public Coston2 RPC times out on a cold call well past viem's 10 second default, so the transport here is set to 45 seconds with retries. If a call seems to hang, it is usually warming rather than down.
Windows PowerShell 5.1 negotiates TLS 1.0 by default, which Flare's RPC
rejects. Force TLS 1.2 before using Invoke-WebRequest
against it. Node's fetch is unaffected.