Pay-as-you-go for web content, using the Solana blockchain. A reader puts a little money in a fund, and each site they read draws a small fee per item from it, within a limit the reader sets. The reader can take back what is left at any time. Nothing in the protocol records who read what.
This repository delivers sol-pay — a metering program on Solana and a client
library. A site uses the sol-pay client libraries to build the metering
program instructions and read its accounts.
The program is deployed on devnet at
F8UDAGgxVTm8Vmh4RmskpMBCFqhRvuTqbDxDCj8UMedL, which a devnet
reset can empty without anything here changing. Since 2026-09-30 that
deployment and the published 0.2.0 clients -- crate, npm bundle and PHP
package -- are the fund design described below. The earlier 0.1.x clients
and the program deployed before that date are the delegate design, which drew
from the reader's own token account under an SPL approval; the two do not
interoperate. Look at it on Solana Explorer, or ask a devnet node yourself:
solana program show F8UDAGgxVTm8Vmh4RmskpMBCFqhRvuTqbDxDCj8UMedL --url devnet
Two Rust crates, and one PHP port:
pay-on-chain— the metering program, built with the Anchor framework, and its LiteSVM test suite.wasm-client— the client library a site integrates, published as a crate and as a browser bundle. The same bundle runs on a Node server, so the server half of an integration does not have to be Rust either. Its API specification,wasm-client/SPEC.md, is the document to read before the code.php-client— a server-side PHP client covering what a server needs ofwasm-client's API: PDA derivation, instruction building (including the reader-signed setup a server composes for a Solana Pay request), account decoding, preflight, error mapping and the key proof, for a PHP server with no Rust toolchain and no WASM runtime. Published to Packagist from a subtree split; seephp-client/README.md, andwasm-client/SPEC.md§3.1 for why a port exists at all.
A reader moves money once, from their wallet into a fund: a token account the metering program controls on the reader's behalf. Each site the reader reads draws from that fund through a meter, which counts items at the site's price, up to a limit and an expiry the reader set for that site. The reader can take back whatever is left, or add to it, whenever they like. See SPEC §4.7, "The fund".
The metering program accumulates charges on the meter. It transfers from the fund to the site's treasury when the unpaid total reaches a collection threshold. See SPEC §4.6, "Why there is a collection threshold."
Three keys sign, and none of them sees another's secret.
| signs | where it runs | |
|---|---|---|
| The reader's wallet | open a fund, deposit, open or renew a meter; withdraw and close a fund | the wallet app, by a Solana Pay scan or tap |
| The browser key | the key proof, and close_meter to sign out |
the site's page, through the npm package |
| The site authority | meter_and_settle |
the site's server, through the crate or the PHP port |
The browser key is one the site's page generates, for that site on that device, and names in the meter. It proves which browser is reading and lets the reader leave a machine. It moves no money. See SPEC §4.8, "The key and the expiry".
This state diagram shows the flow of a page request when navigating metered content.
The bold lines show the happy path. It goes like this:
- the reader navigates to metered content, and the site knows which meter this browser uses
- the page signs a nonce from the server with its browser key; the server fetches the meter and checks the signature against the key the meter names, and that the meter has not expired
- the server makes one metering call, which raises the usage by the item price
- that same call moves money only when the unpaid total has reached the collection threshold. The transfer is a cross-program invocation inside the metering instruction, signed by the fund's own address, rather than a transaction of its own
- the server delivers the metered content
A browser with no meter, or one whose proof fails, goes to the set-meter page. It states the cost per item and asks for a limit, an expiry, an amount to deposit, and which of the reader's funds to use. The page generates its browser key and shows a Solana Pay link. The reader's wallet fetches one transaction from the site's server -- open the fund if it is new, deposit, open the meter naming the key -- and the reader signs it in the wallet, on whatever device the wallet is. The reader then returns to the page and clicks continue; the server fetches the meter once. Nothing polls. See SPEC §4.9.
Which fund is always the reader's choice, stated on the page. The server never picks one for them.
The page and the server must enforce a minimum for the limit that is some
multiple of the item price. A multiple of one does not make much sense. Forty
or fifty yields a better minimum. The program itself requires the minimum to
exceed the collection threshold and refuses initialize_site otherwise
(MinimumBelowThreshold): a minimum at or below the threshold would let a
reader sign up for less than a single collection, so the first settle could
never fire within their limit.
When a reader reaches their limit or the expiry, or the fund runs short, the server shows a screen with the usage so far and the fund's balance. It offers to renew the meter -- a new limit, a new expiry, a new device's key -- with the same kind of scan, or to sign out.
Signing out closes the meter. The browser key signs it and the site's server
pays the fee. Closing forgives whatever is unpaid, which is always below the
collection threshold. That is a decision rather than an omission: a close
carrying a transfer could fail and leave the reader unable to leave.
close_meter therefore emits Closed { forgiven }, moves no money, and
returns the meter's rent to the reader.
This is a library, not an application. The cyan nodes in the state diagram --
set_meter, continue, manage_meter, metered_page -- are screens the
integrator builds. They appear in the design only to establish what the
library owes them: the data needed to render each screen, and the operations
its controls invoke. Nothing else. The library does not route, render,
format, or decide.
The obligation that comes with that: remember which meter this browser
uses, and serve the Solana Pay transaction request. meter_and_settle
takes the meter's address, derived from [b"meter", site, fund]. The key
proof tells the server that this browser is the one the meter names. There
is no session token in the protocol and nothing to look up but an account.
Anything more the site knows about the reader -- accounts, login, SSO -- stays
the site's own affair. See wasm-client/SPEC.md §4.
A second obligation comes with the key proof: every nonce the server issues is used once and expires within minutes. A proof accepted twice can be replayed by whoever copies it, and each item the replayer reads is metered against the reader's fund -- theft of service, charged to the reader. The library verifies the signature; only the site's nonce store can refuse the replay. See SPEC §6.6.
This library is authoritative about instruction encoding, PDA derivation,
account layout, the rule that open_fund must precede the deposit in the
same transaction, the key proof, and the arithmetic that decides whether a
meter call will succeed. It has no view on what limit or expiry to suggest,
how to format an amount, when to show the meter, or what to do when a
payment fails.
Metering may be an additional way to pay. A publisher with subscriptions may opt to keep them; metering is what it offers the reader who will not subscribe. SPEC §4.4 has integration consequences for that scenario.
wbreeze/sol-pay-demonstrator is a working site that meters a set of
articles on devnet, built on the PHP port. It is a reference integration:
everything this repository declines to supply — RPC, the wallet adapter, the
session, the viewer-to-wallet map, the decision to meter a request, error
attribution, log hygiene — is there, in one place, in the smallest honest
form. It is built on the 0.1.x delegate design and has not followed the
fund redesign yet, so its screens and its viewer-to-wallet map are the
earlier flow's -- and since the devnet program was upgraded to the fund
design on 2026-09-30, it no longer works against devnet until it does.
The client library is published as sol-pay-client, once per language. The
three are one library: the same instructions, the same encoding, for the same
program.
cargo add sol-pay-client
npm install sol-pay-client
composer require wbreeze/sol-pay-client
cargo add sol-pay-client: a Rust server, or a Rust front endnpm install sol-pay-client: the browser, and Node serverscomposer require wbreeze/sol-pay-client: a PHP server
Every script in bin/ carries a header comment saying what it does,
what it proves and what it does not. Read that before the code.
Three artifacts. The program and the Rust client build with the same toolchain; the PHP port shares nothing with either.
The program. ./bin/build-rust --program builds it with
Anchor. ./bin/test-rust runs its suite, and the client's,
passing any argument through to cargo test as a filter. No validator is
involved: the tests load the built .so into LiteSVM, an
in-process SVM. A local deploy is the one thing here that wants
solana-test-validator, and from a fresh clone it wants anchor keys sync
first, because the program keypair is not in the repository.
pay-on-chain/README.md has both.
The Rust client and the browser bundle are one source tree,
wasm-client. The core is plain Rust under a thin
wasm-bindgen layer, so ./bin/test-rust covers it natively;
./bin/build-rust --client produces the bundle in
wasm-client/pkg. wasm-client/README.md, "Building" gives the
bare cargo and wasm-pack equivalents.
The PHP port, php-client, is composer install and
composer test from its own directory. Its floor is PHP ^8.1 while its test
tooling wants 8.2 — a trap with a standard way out, in
php-client/README.md, "Building and testing".
build-rust and test-rust pass --locked: they build what the committed
Cargo.lock files name, or they fail. Three further scripts test how the
library is consumed rather than how it is built, below.
./bin/clean removes build output. --program takes
pay-on-chain/target and .anchor; --client takes wasm-client/target
and pkg; no flag takes both. The script also removes
php-client/vectors-gen/target and its vectors.json, which belong to
neither flag because the consumption checks below share them. The target
is a full build of the published crate and is the largest thing the script
removes. It leaves test-ledger alone — the local validator's chain state
is not build output — and php-client/vendor, which is an installed
dependency tree that composer install restores rather than any build here.
Three scripts, for the three ways something that is not this library uses it.
./bin/test-node guards a loading contract — Node runs the
same wasm the browser runs, so it cannot disagree with the crate
(SPEC §3.1). ./bin/test-php is a second
implementation that can, and a divergent port signs the wrong thing
(§8.1). ./bin/test-kit is an agreement with an
outside package neither side pins, declared nowhere an installer can see it
(§8.2). bin/test-kit's header sets the three side by side; read it
before changing any of them.
Each builds or generates what it finds missing, and names the tool it wants
when one is absent. test-kit reaches the network by design: the point is
the real kit from npm.
node-conformance.yml and
php-conformance.yml run test-node
and test-php on push to GitHub. They execute across more than one runtime.
kit-agreement.yml runs test-kit on
push to GitHub and on a weekly schedule.
program-tests.yml runs the Rust
program tests using the locked build and both suites on push to GitHub.
dependency-drift.yml re-resolves
the Rust Cargo versions from scratch
weekly, so an upstream release that breaks the version ranges shows up as a red
scheduled run instead of a surprise.
./bin/update-locks moves Cargo.lock files to the
newest versions inside the ranges the manifests already allow, prints what
moved, and then runs whichever suite builds from them. (It does not ask you
to.) It takes --program, --client or --vectors. The third is
php-client/vectors-gen,
proved by test-php rather than test-rust because nothing in the Rust suites
builds it. This is the deliberate answer to a dependency-drift report from CI;
it never crosses a major boundary, so adopting a newer major stays a manifest
edit and a decision.
Publishing is deliberately not scripted for any of the three. It is rare,
irreversible, and needs credentials that belong to a person rather than to a
repository. wasm-client/README.md, "Publishing" covers cargo publish and wasm-pack publish, which share one version number out of
Cargo.toml.
PHP needs one more step, because Packagist derives a version from a git tag
and a tag here would claim to version the other two artifacts as well.
./bin/split-php-client produces the read-only
repository Packagist publishes php-client from, and proves it is publishable
— Composer needs composer.json at a repository root and a tag that is the
version. It verifies and stops: pushing, tagging and publishing leave the
machine and stay manual, the same as cargo publish. See
php-client/README.md, "Publishing".
Dual licensed under either of
- Apache License, Version 2.0 (
LICENSE-APACHE) - MIT license (
LICENSE-MIT)
at your option. See LICENSE.md for why both.