purechain-sdk
v0.0.2
Published
Client library for the PureChain network family (geth, besu, dag variants)
Maintainers
Readme
purechain
A client library for the PureChain network family. Three variants — geth,
besu, dag — behind one interface, so application code does not change when
the variant does.
Status: the geth variant (the live public PureChain network) is implemented
and tested against the real chain. besu and dag are stubs with the full
interface in place; every call raises NotImplementedError naming the variant.
npm install purechain-sdkPublished as
purechain-sdkon npm, matching the Python package of the same name on PyPI. The barepurechainname was taken in 2019 by an unrelated package. Note that the Python one is imported aspurechain, since a distribution name and an import name are separate things there.
Usage
import { createClient, PrivateKeySigner } from "purechain-sdk";
const client = createClient({ signer: new PrivateKeySigner(process.env.PRIVATE_KEY!) });
const hash = await client.sendTransaction({ to: "0xabc...", value: 1n });
const receipt = await client.waitFor(hash);
console.log(receipt.status, receipt.blockNumber);Point it at your own node, or at a private deployment with its own genesis:
const client = createClient({
url: "http://localhost:8545",
network: { name: "devnet", chainId: 424242n },
});What this library does differently
PureChain is a permissioned, free-gas EVM network, and three of its properties break assumptions that general-purpose Ethereum libraries build in. Each one is handled here by default rather than left to the caller.
Fees are zero, and the oracle is never consulted. Transactions are built
with zero fees and signed locally; eth_gasPrice is not called. A node started
without the --gpo.* flags reports a non-zero price on a chain whose base fee
is pinned to zero, so trusting the oracle is how callers end up overpaying — or
getting rejected. Override with fees if a network ever charges:
createClient({ fees: { kind: "tip", tipWei: 1_000_000_000n } });Blocks are not produced on a fixed interval. Smart Auto Mining seals only
while transactions are pending and pauses when the network is idle, so a static
head is healthy rather than stalled. waitFor therefore defaults to inclusion,
not a confirmation count — waiting for depth on a chain that goes quiet right
afterwards would never resolve. Depth is opt-in and always bounded:
await client.waitFor(hash); // inclusion
await client.waitFor(hash, { until: "final", confirmations: 3 });A timeout against a head that never moved raises ChainIdleError rather than
TimeoutError, so "the network is quiet" is distinguishable from "something
went wrong".
There is no replace-by-fee. A pending transaction cannot be bumped or cancelled at zero fee — the pool requires a strictly higher fee, and nothing is higher than zero. Sends are serialised per sender so concurrent calls cannot collide on a nonce, because the usual escape hatch does not exist here.
Capabilities are detected, not assumed
Which JSON-RPC namespaces are available is a property of the node you connected
to, not of the variant. The public endpoints run --http.api eth,net,web3, so
clique_*, txpool_*, admin_* and debug_* are absent even though
purechain-geth implements them.
const caps = await client.capabilities();
caps.zeroFee; // true
caps.replaceByFee; // false
caps.subscriptions; // false — public endpoints are HTTP-only
caps.has("clique_getSigners"); // false on the public RPC
caps.validatorApi; // "clique" | "qbft" | "ibft" | nullAnything outside the core surface is reachable through the raw escape hatch, once you have checked for it:
if (caps.has("clique_getSigners")) {
const signers = await client.rpc("clique_getSigners", []);
}Events
eth_subscribe is unavailable on the public endpoints, so watching polls by
default and tolerates idle gaps. Delivery is ordered and gap-free.
const sub = await client.watchLogs(
contract.filter("Transfer"),
(log) => console.log(contract.decodeLog(log)?.args),
);
await sub.close();Contracts
import { Contract, deployContract } from "purechain-sdk";
const token = new Contract("0xabc...", abi, client);
const balance = await token.read<bigint>("balanceOf", [address]);
await token.writeAndWait("transfer", [to, 100n]);
const { contract, address } = await deployContract(client, { abi, bytecode });Gas is free, so an account with a zero balance can deploy and call. No balance pre-check is performed; a balance is only needed to move value.
Development
npm install
npm test # offline unit tests
npm run test:live # plus read-only tests against the public network
npm run typecheck # src and tests
npm run buildpackage-lock.json pins exact versions; commit it. Use npm install <pkg>@latest
to move a dependency forward deliberately, rather than letting a fresh install
drift on its own.
There is a third suite that broadcasts real transactions to the public network. It is behind its own flag so it never runs by accident:
PURECHAIN_LIVE_WRITE=1 node --test test/live/write.test.tsIt generates a throwaway key and sends zero-value transfers to itself. No funding is needed — gas is free, which is precisely what the test proves.
Source imports carry .ts specifiers, so the test suite runs directly on Node
with no build step; tsc rewrites them to .js on emit.
Design
These are the rules the library is built on. New code should follow them.
Layout
src/
index.ts public API
wallet.ts keys, mnemonics, keystore, signature verification
units.ts PCN <-> wei
address.ts validate, checksum, compare
abi.ts offline encode / decode
metrics.ts throughput, block timing, gas utilisation (reads)
benchmark.ts latency and throughput under load (BROADCASTS)
core/ variant-agnostic types, errors, fee policy, capability detection
client/ the PureChainClient interface and the createClient factory
variants/
evm/ shared EVM engine, signing, contracts, waiting, watching
geth/ purechain-geth — implemented
besu/ stub
dag/ stubThe root modules are the namespaces from rule 9. metrics only reads;
benchmark writes to the chain, which is why they are separate. They sit beside core,
client and variants because they are top-level concerns, not a sub-part of
any of them. The Python package has the same file names in the same places.
1. One interface, three variants
Every variant implements the same PureChainClient interface. That interface
holds only what all three can genuinely do — the intersection, not the union.
This is why waitFor is built around a finality level, with a confirmation
count only as an opt-in extra: a DAG has no block depth to count. Anything one
variant can do beyond the interface sits behind a capability check, or on that
variant's own class.
2. Three layers, one direction
core → client → variants. Code in core never imports from variants.
Nothing in core assumes blocks, a block interval, or an EVM. That single
constraint is what keeps the DAG variant possible behind the same interface.
3. Detect, don't assume
What a node can do is a property of the node, not the variant. The same
purechain-geth build exposes clique_* on your own machine and not on the
public RPC.
Capabilities are read once when the client connects, then cached. Check them before using anything outside the core surface.
4. Defaults match this network, not the ecosystem
Zero fees, and the gas-price oracle is never called. Wait for inclusion, not depth. Poll for events instead of subscribing.
Each of those is unusual for an Ethereum library and correct here. Where the network forbids something outright — replace-by-fee — the library says so with a named error rather than failing in a confusing way.
5. Wrap the cryptography, own the policy
ethers does three jobs: signing, ABI coding, transport. This library decides fees, nonces, waiting, and retries.
ethers types never appear in the public API. That is what lets the Python port sit on web3.py and still behave identically.
6. Always leave an escape hatch
Blocks, transactions, receipts and logs all carry raw — the node's response
untouched, including fields the typed surface does not name. Clients with a real
backend also expose rpc(method, params), which reaches any method at all. Stubs
do not, because they have nothing to call.
A typed API you cannot step outside of is a dead end on a network that adds its own methods.
7. Errors carry codes
Branch on err.code, never on the message text. Messages are written for humans
and will change; codes will not. The code strings are identical in both
languages.
8. Stubs are honest
An unfinished variant still exposes the whole interface, and every call fails
with an error naming the variant. You find out at the call site, not three
frames deep in a TypeError.
9. Objects hold state, namespaces hold pure functions
A client owns a connection, so it is an object. Creating a key or parsing an amount needs no state, so those are namespaces:
import { address, units, wallet } from "purechain-sdk";
const signer = wallet.create(); // no network needed
const wei = units.parsePCN("1.5");
const ok = address.isValid(someString);There are four: wallet, units, address, and abi. Binding an ABI to a
deployed address needs a client, so that stays on the Contract class rather
than becoming a fifth namespace — one way to do it, not two.
10. The two libraries match
Same folders, same module names, same method names, same error codes. The only
intended difference is casing: camelCase here, snake_case in Python.
A change to one library is a change to both.
