Hartii developer docs

Games

Pay with HPAY button

The Pay with HPAY button and the verifyPayment check shipped in Games/Biome 1.21.0. They are Beta: tested by the project's own test suites, not independently audited. Start with small amounts.

The flow is non-custodial. The payer's wallet signs, and the money goes straight to your address. Hartii holds no keys or funds, takes no fee, and adds no contract. The browser message you get back is a hint, not proof. Only verifyPayment, run by you on your server, proves a payment.

A live builder is at https://hartiibiome.com/hpay/button. It takes an address, an amount and a reference, makes one order and renews it before it ends, shows the real button, lists every message the pop-up sends, runs the check, prints the snippets below and downloads one complete example page. The snippets it prints are run by the test suite against recorded chain data.

Add the button

html
<script src="https://hartiibiome.com/hpay.js"></script>
<button data-hpay-request="https://hartiibiome.com/pay?to=<your 0x00 address>&amp;amt=1500000000000000000&amp;ref=order-1001&amp;chain=9&amp;v=1"></button>
<script>
  document.addEventListener('hpay:submitted', function (event) {
    // event.detail.txHash and event.detail.ref are a hint for your page, not proof.
    // Send them to YOUR server. It checks the payment before it ships anything.
  });
</script>

hpay.js is one classic script with no dependencies. It turns every element that has data-hpay-request into a button (label from data-hpay-label, default "Pay with HPAY"). Add data-hpay-auto="false" to the script tag to turn that off. Your server makes the link for each order (see Orders on a server); never take the amount from the browser.

Or from your own code, inside a click (browsers only open pop-ups after one):

js
HPAY.pay({ to: '<your 0x00 address>', amt: '1500000000000000000', ref: 'order-1001' }).then(
  function (result) { /* result.txHash, result.ref, result.request */ },
  function (error) { /* error.code: 'blocked', 'busy', 'closed' or 'invalid-request' */ }
);
APIWhat it does
HPAY.requestUrl(fields)The link for these fields. Throws a TypeError with code invalid-request for anything the pay page would refuse.
HPAY.pay(linkOrFields, options)Opens the pay page in a pop-up. Resolves when the wallet returns a transaction id: { txHash, ref, request, popup, verified: false }. verified is always false. Options: onSubmitted, onUpdate.
HPAY.button(element, linkOrFields, options)Makes a button. Options: label, unstyled, onSubmitted, onUpdate, onClosed, onError. The element also fires hpay:submitted, hpay:closed and hpay:error with the result or error in event.detail.
HPAY.mount(scope)Turns [data-hpay-request] elements into buttons. Safe to call again.
HPAY.verifyPayment(request, txHash, options)Loads hpayVerify.mjs from the same origin as the script and runs the check below. Run it on your server.

Fields are to, amt, usd, ref, tag, exp and memo, with the rules in HPAY Request v1. amt is exact wei as text or BigInt, never a number. Unknown fields are refused.

Errors: invalid-request and bad-origin are TypeErrors. blocked (pop-up blocked), busy, closed and no-window are Errors.

  • A second click for the same order focuses the open window and returns the same promise.
  • A call for a different order while a window is open is rejected with busy and focuses the open window.
  • After closed a payment may still have been sent, and a phone or Blip may never report back at all, so look in your own records first.

The pop-up protocol

The pop-up is the hosted pay page (/pay?...&popup=1), never an iframe: hartiibiome.com cannot be framed. It must carry a valid v1 request, or it shows the reason and does nothing. Every message is { source: 'hpay', v: 1, type, ... }.

FromMessageMeaning
pay page to openerreadySent once to * when the page loads. It carries nothing.
opener to pay pagehelloThe opener's answer, sent to the pay page's origin only.
pay page to openersubmitted { txHash, ref }The wallet returned a transaction id.
pay page to openerconfirmed { txHash, ref, confirmations }The page saw the payment succeed on the chain.
pay page to openerfailed { txHash, ref }The transaction failed on the chain.

Why a handshake: the pay page cannot know who opened it. It learns the opener's real origin from event.origin of the hello, which a page cannot fake, and sends results to that origin and no other. Only the window that opened the pop-up is listened to, only the first answer counts, and an opaque origin is never used. A page that opens the pay page and never says hello hears ready and nothing else. The pay page tells the payer who asked ("This payment was requested by" and the opener's origin) and shows a Close this window button after paying. hpay.js accepts a result only from the window it opened, from its own origin, in exactly the right shape, for the order it asked about, and it never posts to *.

Shop checklist

  • Build the request on your server, one order at a time, each with its own tag (the last digits of the amount), an expiry, and the moment you made it. Never trust an amount, address or reference that came back from the browser.
  • The message is a hint for your page. Only verifyPayment, run by you, proves a payment.
  • Keep every transaction id you accept, so one payment cannot pay two orders. The reference is not on the chain, so it does not prove which order a payment was for. The tagged amount and the time window do.
  • Your content security policy must allow script-src https://hartiibiome.com for hpay.js. To call HPAY.verifyPayment in the browser it also needs connect-src https://rpc.quai.network and to allow importing https://hartiibiome.com/hpayVerify.mjs. Both files are served with Access-Control-Allow-Origin: * and Cross-Origin-Resource-Policy: cross-origin, and are cached for an hour.
  • Pop-ups must be opened by a click. If the browser blocks one, HPAY.pay rejects with blocked.

Check a payment

verifyPayment(request, txHash, options) is one file with no imports, for Node 18 or newer (a server) or a browser. Save https://hartiibiome.com/hpayVerify.mjs next to your code:

js
import { verifyPayment } from './hpayVerify.mjs';
// order = { request, createdAt }, made once on your server when the shopper pressed Buy
const result = await verifyPayment(order.request, txHash, { createdAt: order.createdAt });
if (result.ok) { /* hand over the goods, once for this txHash */ }

request is the order's own request: the link, a URL, search params, or an object with to, amt (string or BigInt, exact wei), tag, exp, ref and usd. It must have:

  • a 0x address (a name can change owner: needs-address),
  • the exact amt it was tagged with,
  • a tag that is the last 1 to 9 digits of amt and is not all zeros,
  • an exp.

options.createdAt (Unix seconds) is required: it is when your server made the order. It may be at most a week before exp (bad-created-at otherwise), because a createdAt of 1 would accept every old payment of the same amount.

Other options: graceSeconds (0 to 86400, default 0: extra time after exp that you choose to honour), confirmations (1 to 1000, default 12, a placeholder until finality is measured), rpcUrl (default the public Cyprus-1 node), fetchImpl, timeoutMs (default 8000) and now (Unix seconds). minimumWei no longer exists: asking for it throws no-minimum, because "at least this much" accepts a payment made for something else.

A payment is confirmed only if the node says all of this:

  • the transaction is in a block with receipt status 1;
  • it is a plain transfer, or a QuaiRelay send whose receipt holds the matching Relayed event (nothing else counts as a payment);
  • the money went to the request's address;
  • the amount is exactly the request's tagged amt;
  • the block time is at or after createdAt minus 60 seconds, and at or before exp plus graceSeconds (the matcher's window, plus 60 seconds of slack before the order was made, which the matcher does not have);
  • the block the node returns is the block the receipt names (same hash and number, else node-mismatch);
  • it has at least confirmations.

A payment outside the window is a mismatch, whatever its amount (reason early before the window, window after it).

What this proves, and what it does not. It proves that a payment of this order's exact tagged amount reached the address inside the order's time window. It does not prove who paid. It does not prove which order the payment was meant for if you reuse an amount, a tag or a transaction id. That is why each order needs its own tag and why you keep every accepted hash. A closed window proves nothing either way. It never accepts a payment because a reference matches, since the reference is not on the chain.

The result is { ok, status, reasons, checks, payment, request, ref, window, checkedAt }. window is { notBefore, until }, the Unix seconds the check used.

statusMeaning
confirmedEvery rule holds. ok is true.
confirmingEverything else holds; waiting for more confirmations. Look again.
pendingSent, but not in a block yet (or the node returned a status it does not recognise).
not-foundThe node does not know the transaction yet.
failedThe transaction failed, so nobody was paid.
mismatchWrong recipient, wrong amount, outside the time window, or not a plain transfer or relay send. reasons says which.

checks is { recipient, amount, window, confirmations } (true or false; null where a check could not run). The Button page labels them "Right address", "Exact tagged amount", "Inside the order's time window" and "Confirmations". payment is { hash, method, payer, recipient, amountWei, paidAt, blockNumber, confirmations, required }, where method is plain, relay or other.

It never says "not paid" when it could not look. A node that cannot be reached throws an Error with code node-unreachable, and a node that answers about something else throws node-mismatch. A request or option that cannot be trusted throws a TypeError with a code before any network call: bad-hash, bad-confirmations, no-minimum, needs-amount, needs-tag, weak-tag, needs-expiry, needs-created-at, bad-created-at, bad-grace, bad-request or needs-address.

Privacy: only the transaction hash (and the block hash the node returned) is sent to the node. Not the order reference, the dollar price or the memo.

Orders on a server

Node 18 or newer. Save https://hartiibiome.com/hpayRequest.mjs and https://hartiibiome.com/hpayVerify.mjs (each one file, no imports) next to your code. This is the snippet the Button page prints, trimmed:

js
import { buildRequest, generateTag, tagAmount } from './hpayRequest.mjs';
import { verifyPayment } from './hpayVerify.mjs';

const SHOP = '<your 0x00 address>';
const openOrders = [];
const closedTags = [];          // { tag, freeAt }
const accepted = new Map();     // txHash in lower case -> the order reference it paid
const TAG_HOLD_SECONDS = 60 + 30 * 60;   // the check's minute of slack, plus half an hour

// Tags no new order may use: open orders, and closed ones until their window and the hold are over.
export function tagsInUse(now = Math.floor(Date.now() / 1000)) {
  return [...openOrders.map((o) => o.request.tag), ...closedTags.filter((c) => c.freeAt > now).map((c) => c.tag)];
}
// Call closeOrder(order) when an order is paid or has expired.
export function closeOrder(order) {
  const at = openOrders.indexOf(order);
  if (at >= 0) openOrders.splice(at, 1);
  closedTags.push({ tag: order.request.tag, freeAt: order.request.exp + TAG_HOLD_SECONDS });
}

export function makeOrder(ref, priceWei) {
  const tag = generateTag({ inUse: tagsInUse() });
  const amount = tagAmount(priceWei, tag);                 // your price, plus the tag in the last digits
  if (!amount.ok) throw new Error(amount.reason);
  const createdAt = Math.floor(Date.now() / 1000);
  const built = buildRequest({ to: SHOP, amt: amount.amountWei.toString(), tag, ref, exp: createdAt + 900 });
  if (!built.ok) throw new Error(built.reason);
  const order = { request: built.request, createdAt, url: built.url };
  openOrders.push(order);
  return order;                                            // the browser opens order.url with HPAY.pay(order.url)
}

export async function checkPayment(order, txHash) {
  const used = accepted.get(txHash.toLowerCase());
  if (used !== undefined) return used === order.request.ref ? 'paid' : 'already-used';
  const result = await verifyPayment(order.request, txHash, { createdAt: order.createdAt });
  if (!result.ok) return result.status;                    // 'confirming', 'pending', 'not-found', 'failed' or 'mismatch'
  accepted.set(txHash.toLowerCase(), order.request.ref);
  return 'paid';
}

A node that cannot be reached throws node-unreachable. That is never "not paid": try again later. The lists live in memory here so the example runs; keep them in your database.

Dollar-priced orders

js
import { generateTag, requestFromQuote } from './hpayRequest.mjs';

export async function makeOrderInDollars(usd, ref, openOrders = []) {
  const quote = await (await fetch('https://hartiibiome.com/api/hpay/quote?usd=' + usd)).json();
  if (!quote.ok) throw new Error(quote.reason);            // never guess a price
  const createdAt = Math.floor(Date.now() / 1000);
  const tag = generateTag({ inUse: openOrders.map((order) => order.request.tag) });
  const built = requestFromQuote(quote, { to: SHOP, tag, ref, now: createdAt });   // { ok, request, url }
  if (!built.ok) throw new Error(built.reason);
  return { request: built.request, createdAt, url: built.url };
}

The request ends when the quote does (15 minutes). Open order.url with HPAY.pay(order.url) and later run verifyPayment(order.request, txHash, { createdAt: order.createdAt }). Keep closed tags out of reuse as in the previous snippet.

Honest limits

  • Beta, not independently audited.
  • A shop that skips verifyPayment is trusting the browser.
  • A phone or Blip may never report back, and a closed window proves nothing, so a shop checks its own records.
  • The dollar figure in a link is the link maker's own price. The chain only records QUAI.
  • 12 confirmations is a placeholder until finality on Quai has been measured on mainnet. You can set it per call.
Quai Network mainnet · chain 9 · Cyprus-1. Figures marked "read on" a date were read from the chain that day; re-read before relying on them.