- 1Ask for the price.
POST /keysorPOST /topupwithout a payment answers402. Each pack has anm402entry: the price in NIGHT atoms, the vault, the pack's service id. - 2Deposit. The vault's
deposit(amount, nonceSeed)takes your NIGHT and gives you a shielded credit coin of the same amount. - 3Pay the pack.
pay(serviceId, credit, receiptSecret)spends a credit coin of exactly the pack's price.receiptSecretis 32 random bytes you keep; the vault stores only its hash. - 4Send the secret. Repeat the call with the service id and the secret in
PAYMENT-SIGNATURE. The gateway derives the receipt, finds it in the vault and credits the pack once.
Buy a key with tNIGHT
A key or a top-up is paid on Midnight, through a payment vault. The vault records a receipt hash, not who paid.
Buying with tNIGHT is switched off on this network right now. Sign in with a wallet, or buy with tADA.
How it works
http# 1. price
POST https://api.preprod.odatano.dev/keys?pack=500
→ 402 { "x402Version": 2, "accepts": [ …, {
"scheme": "m402", "network": "midnight:preprod",
"amount": "5000000", "payTo": "<vault>",
"extra": { "units": 500, "serviceId": "…" } } ] }
# 4. same call, with the receipt secret
POST https://api.preprod.odatano.dev/keys?pack=500
PAYMENT-SIGNATURE: base64({
"x402Version": 2, "scheme": "m402", "network": "midnight:preprod",
"payload": { "serviceId": "…", "receiptSecret": "…" } })
→ 201 { "token": "oda_…", "units": 500, "validUntil": "…" }
Packs
| Calls | Price | Named pack |
|---|---|---|
| … | ||
- Network…
- Vault…
- Credit colour…
- Validitya bought key is valid for … from the purchase
- Price per callevery forwarded call costs 1, metadata and the verify lane are free
Fees, privacy, replay
- Feeswith a key, the platform sponsor pays the tDUST of
depositandpay; each counts as one Midnight call. Without a key your wallet pays its own fee. - Publicdeposits, the pack's price and service, the receipt hash
- Privatewho paid, and which deposit paid for which pack
- Replaya receipt is credited once (
409after that);payrefuses a receipt the vault already holds - Pendinga receipt the vault does not show yet answers
402withpending: true; repeat the call once the pay transaction landed
- With the key
POST /topupwithAuthorization: Bearer oda_…, same round trip - By key id
POST /topup?key=<id>, no secret needed; the id is on the account page - Exact pricedeposit the pack's exact price, so one credit coin pays one pack.
redeem(recipient, credit)turns unused credit back into NIGHT.
Client
- Packages
@odatano/nightgate-txbuilds and proves the vault calls on your machine;@odatano/contract-m402-vaultis the vault contract - Walleta 64-byte seed with NIGHT on the network above; the seed never leaves your process
- Sync
depositneeds the wallet's NIGHT coins (walletSync: 'coins'),paythe shielded coins ('shielded'); with a key the wallet syncs through the gateway - Contractgithub.com/ODATANO/NIGHTGATE-CONTRACTS, package
packages/m402-vault
jsimport { randomBytes } from 'node:crypto';
import { createTxBuilder } from '@odatano/nightgate-tx/txbuilder';
import { connect } from '@odatano/nightgate-tx/client';
const api = { baseUrl: 'https://api.preprod.odatano.dev', token: process.env.ODATANO_ACCESS_KEY };
const topup = (h = {}) => fetch(`${api.baseUrl}/topup?pack=500`, { method: 'POST', headers: { Authorization: `Bearer ${api.token}`, ...h } });
const offer = (await (await topup()).json()).accepts.find((a) => a.scheme === 'm402');
// one sponsored vault call; walletSync 'coins' for deposit, 'shielded' for pay
async function call(circuitId, args, walletSync) {
const b = await createTxBuilder({ seedHex, networkId: 'preprod', api, package: '@odatano/contract-m402-vault', walletSync });
await b.waitForSync();
const { finalizedTxB64 } = await b.buildSponsorable({ contractAddress: offer.payTo, call: { circuitId, args, witnesses: {} } });
await b.close();
return connect(api).sponsorFinalized({ finalizedTxB64 });
}
const price = BigInt(offer.amount), serviceId = offer.extra.serviceId, secret = randomBytes(32);
await call('deposit', [price, randomBytes(32)], 'coins');
await call('pay', [Buffer.from(serviceId, 'hex'), { nonce: randomBytes(32), color: Buffer.from(offer.extra.creditColor, 'hex'), value: price }, secret], 'shielded');
const payment = { x402Version: 2, scheme: 'm402', network: offer.network, payload: { serviceId, receiptSecret: secret.toString('hex') } };
const res = await topup({ 'PAYMENT-SIGNATURE': Buffer.from(JSON.stringify(payment)).toString('base64') });