Everything needed to take PCoin in your own product: a wallet you generate offline, deposit addresses derived from an xpub, one HTTP API to watch them, and the rules that stop you crediting money that is not there.
Four production integrations follow this guide — AiControl, checker.pc.am, webbuilderbot and 3dmodels.pc.am. Every rule below exists because it was got wrong once, usually expensively. Where a rule looks paranoid, the reason is stated so you can judge it yourself.
PCN top-up is one-way and custody-free. Your server derives a deposit address per user, watches it, and credits when the payment is deep enough. It never holds a private key, so it is cryptographically incapable of moving funds. That is the design, not a limitation: a breach of your web server costs an attacker nothing but a list of addresses.
m/84'/9444'/0'.There is nothing to "collect". Every user's deposit address is a child of one account key, so all your deposits are already in a single wallet. Your balance is a sum over the pool, not a transaction anybody has to make. This is why no sweep code exists in any of the four integrations — see §12.
Coin type 9444' is load-bearing. PCoin kept
Bitcoin's xprv/xpub version bytes, so a PCoin extended key serialises literally
as xpub… and the same seed under coin type 0' derives
live Bitcoin keys. Nothing in the encoding warns you. Get it
wrong and you are generating real Bitcoin addresses you cannot spend from.
Never use 9444' on a testnet either —
testnets use SLIP-44's universal coin type 1'.
This is the step that decides whether a bad day costs you an outage or costs you every customer deposit. Do it first, and do it yourself.
contrib/vault/pcoin-seed-vault.mjs in the PCoin repo does the whole procedure. Run it on your own machine, offline if you can:
npm install
node pcoin-seed-vault.mjs --selftest # must print ALL CHECKS PASSED
node pcoin-seed-vault.mjs new --system myapp
It produces exactly two files:
| File | Goes to | Can it spend? |
|---|---|---|
myapp-xpub.txt | your server | no — watch only |
myapp-seed.enc.json | your backups | only with your passphrase |
Three of its behaviours are worth copying even if you write your own:
verify decrypts the phrase, derives the account xpub again, and
compares it against the xpub recorded in the blob — proving the file restores
that specific wallet, not merely that it is well-formed JSON. It exits
0/1 so it works in a cron check.A backup is not a backup until it has been loaded. Restore your phrase into a throwaway wallet and confirm it reproduces the same first address, before a single coin depends on it. This project has paid for that lesson.
Never put a child private key on the server either. With BIP32, an account xpub plus any one child private key lets an attacker derive every sibling private key. The xpub is safe alone; the combination is not.
Addresses are BIP84 native SegWit with hrp pc, so they look like
pc1q…. Derive m/84'/9444'/0'/0/index, one index
per user.
| Approach | How | Trade-off |
|---|---|---|
| Derive on demand | keep the xpub in config, derive at request time, allocate MAX(index)+1 under a row lock | no pool to run dry; needs a bech32 encoder in your language |
| Pre-derived pool | generate N addresses offline, import them in index order, hand out the next free row | no crypto library on the server at all; must top up before it empties |
Both are in production. If you use a pool, generate it from the xpub alone:
node pcoin-seed-vault.mjs pool --system myapp --count 2000
The start index is load-bearing. A user's row is identified by its derivation index, so importing a batch at the wrong offset points their deposit at a different row than the one they were shown. Validate every line before inserting and refuse the whole batch if any line is bad — a partial import shifts every later index out of alignment with the wallet.
Index order is signup order. Anyone holding your xpub can derive every address, query them all, and read your entire customer payment history in sequence. Treat the xpub as confidential. It also cannot be rotated without orphaning every address you have already issued — so decide once.
A bech32 address is valid in all lowercase or all uppercase
— pc1q… and PC1Q… are the same address to
the chain and two different strings to your database. If your uniqueness check
is case-sensitive, both forms get stored and your deposit key splits in two.
The explorer will not save you here. Query the uppercase form and it answers HTTP 200 with zero transactions — not an error, an empty answer. Your watcher logs nothing, raises nothing, and the customer’s coins are simply invisible. Normalise to lowercase before you validate, before you compare, and before you store.
And normalise new rows only. Rewriting the address on a row that has already been issued re-keys its deposit history, and the next watcher tick credits the same on-chain transaction a second time.
Two concurrent first-time callers must not receive the same index. Take the
current maximum under a row lock, and put a UNIQUE constraint on both
the address and the index as a backstop — a collision should fail the insert, not
silently credit one user's deposit to another.
https://explorer.pc.am/api — HTTP, no auth, no key.
| Endpoint | Use |
|---|---|
GET /api/status | tip height, index health |
GET /api/address/{addr} | confirmed / unconfirmed / lifetime balances |
GET /api/address/{addr}/txs | transactions — see the shape trap below |
GET /api/tx/{txid} | authoritative height, block_hash and is_coinbase |
POST /api/addresses | batch, up to 500 — the intended polling path |
Two concrete reasons, both of which have cost real time:
scantxoutset, which is O(entire
UTXO set) and globally serialised behind a process-wide flag. One HTTP request
could stall every other RPC on that node.-rpcwallet scoped.
getaddressinfo, gettransaction and
getbalances will answer confidently about the wrong wallet when two
are loaded. That has already cost this project money.POST /api/addresses takes up to 500 addresses, so a quiet fleet
costs one request per cycle. Prefer the POST form over the ?list=
query string: a query string ends up in proxy logs, linking every address in it to
every other.
curl -s -X POST https://explorer.pc.am/api/addresses \
-H 'Content-Type: application/json' \
-d '{"addresses":["pc1q…","pc1q…"]}'
confirmed.items[], not at the top level.vout and no
is_coinbase — its keys stop at
received_sat / net_sat / n_out. Both facts
drive §7. Fetch /api/tx/{txid} when you need them.confirmed (with
mature_sat, immature_sat, spendable_sat),
unconfirmed, and lifetime. Each carries both a
_sat integer and a _pcn string — use the integer.unconfirmed.known tells you whether the mempool was actually
readable. An unknown mempool is not an empty mempool.The API answers 200 for any string. It returns a healthy
zero-balance body for notanaddress and for a valid Bitcoin address.
Validate locally (§6) — an integration that trusts a remote service to reject bad
input is one outage from crediting nonsense.
All of them. None optional.
| Gate | Value | Why |
|---|---|---|
| confirmations | 6 | ~69 chain tips seen; ~3% lifetime stale rate |
| deposit height | ≥ 2800 | below it difficulty is frozen |
| coinbase maturity | 100 | mined output, not spendable sooner |
| index health | blocks_unwound == 0 | reorg in flight = untrustworthy counts |
| minimum deposit | your call | ignore dust rather than write a row per satoshi |
All four production integrations run 6 confirmations — roughly half an hour of work behind a deposit at current spacing. Make it a config value, not a constant, and raise it for large amounts. Pair a low value with a per-user monthly cap: the cap is what bounds your loss between reviews.
Below height 2800 the difficulty is frozen at
1e0b7c33 — the legacy retarget fires only every 2016 blocks and does
not run again until 4032. Anyone renting hashrate can rewrite that history cheaply
and the chain cannot push back. A tip-based gate credits a pre-LWMA deposit the
instant the tip crosses 2800, which is exactly wrong.
// wrong: credits old, cheap-to-reorg coins once the tip moves
if (tipHeight >= MIN_HEIGHT) credit();
// right: judge the deposit's own block, and assert it twice --
// once from the address summary, once from the authoritative /tx
if (deposit.height < MIN_HEIGHT) reject("pre-LWMA");
startmining "<address>" needs no wallet, so a user can point a
miner straight at their deposit address. Without a maturity check each 50 PCN
reward would credit about 95 blocks before it is spendable at all. Read
is_coinbase from /api/tx/{txid} and require
max(min_conf, 100) for those.
A call that failed, timed out, or answered "I do not know" resolves NOTHING. It can never advance a record, never clear one, and never authorise a credit.
optInt("confirmations", 0) on a call that may not have happened is a
bug: an unanswerable question silently becomes a definite "not confirmed", and in a
send path that authorises spending the same coins twice. Model unknown as
its own state, distinct from no. Every failure path must
hold, never credit.
Concretely, distinguish these three outcomes and never collapse them:
| Outcome | Means | Do |
|---|---|---|
| request threw | nothing is known | hold, retry later |
| 200, tx absent | a real fact: not seen | act on it |
| 200, tx present | a real fact: seen | act on it |
Validate addresses locally. Check the bech32 checksum
yourself and reject a Bitcoin bc1… address on its hrp. Do not rely on
the API, which answers 200 for anything.
Key the ledger on (txid, address) — never on
(txid, vout). This is the single most expensive mistake
made in these integrations, and all four shipped it before it was found.
The explorer's address-tx summary carries no vout at all. Faced
with a vout column, every implementation wrote a hardcoded
0. A unique index on (txid, vout) with a constant vout
degenerates to UNIQUE(txid) — so when one transaction pays
two of your addresses, the second lookup finds the first user's
row, concludes the deposit is already recorded, and credits nobody.
It never double-credits — it fails safe — but the second user silently loses their deposit, and nothing in your logs says so.
received_sat is already aggregated per address per transaction, so
one row per (txid, address) is exactly the right grain:
-- right
UNIQUE KEY uq_txaddr (txid, address)
-- wrong, when vout is always 0
UNIQUE KEY uq_outpoint (txid, vout)
Fixing the lookup is only half the fix. If your schema uses
CREATE TABLE IF NOT EXISTS, an existing install keeps the old index
and the code change never reaches the database — turning a silent dropped deposit
into a duplicate-key error instead. Write an explicit migration, add the new key
before dropping the old one so the table is never briefly unguarded, and
refuse outright if real duplicate rows exist rather than deleting money rows to
force an index through. Verify with SHOW INDEX, not by reading the
schema file.
Treat a duplicate-key error as already applied rather than an error. A retrying poller must be a no-op.
Do not put UNIQUE(ref_type, ref_id) on a shared
credit ledger: a debit and a refund both legitimately write
('card', <same id>). Use a separate nullable idempotency column.
If your credits are integers, carry the sub-credit remainder forward per address. Flooring each deposit independently silently eats up to a whole credit every time.
If your ledger is a file rather than a database, never write it in place. Truncate-then-write leaves a truncated file if the process dies, the disk fills, or the OOM killer arrives mid-write — and if your reader turns a failed decode into an empty collection, that file now reads as “no deposits have ever happened” and every past deposit credits again.
// wrong — a crash between these two lines destroys the ledger
ftruncate($fh, 0); fwrite($fh, json_encode($new));
// right — the rename is atomic; the old file survives until it succeeds
$tmp = $f . '.tmp.' . getmypid() . '.' . bin2hex(random_bytes(4));
file_put_contents($tmp, $out); // check the return
fsync($tmpHandle); rename($tmp, $f); fsync($dirHandle);
Three details that are easy to miss: json_encode can return
false, and writing that produces a zero-byte file
that passes a naive length check; fflush, fsync and
fclose all have return values, and ENOSPC surfaces there rather
than at the write; and a rename is only durable once the parent
directory is synced.
A failed read is not an empty ledger. Bytes that will not
decode must abort the tick, not resolve to []. That is
§6 applied to your own storage.
Two writes cannot be atomic across two files, so choose the order whose crash window is survivable. Credit the balance under a durable, never-evicted idempotency key; only then mark the deposit credited. A crash in between leaves the deposit unfinished, the next tick retries, and the idempotency key makes the retry a no-op. The other order — mark first, credit second — loses the money silently.
Two traps inside that: an idempotency set with a size cap is not durable, and evicting the oldest key is exactly what lets an old deposit re-credit. And on the duplicate path, do not re-stamp the amount or the rate from the current tick — return what was actually applied. If the stamped amount feeds a daily cap or a report, a duplicate that re-stamps makes both drift with nothing in the log.
When you make a write throw, ask what was going to run next. Hardening a write is right, and it is also how the next class of bug gets in. An exception thrown from a storage helper unwinds past the compensating action — past the refund, past the upstream cancel, past the line that records the reserve you just debited. The user is charged and there is no path that gives it back.
The mirror image is just as bad: a helper that used
to throw and now returns null turns a caller’s
if (found) into a fall-through. An
idempotency check that fails open charges twice. Every time you change how a
function reports failure, re-read its callers.
And in a batch loop — a sweeper, a watcher — wrap each item. One poisoned row that throws will otherwise kill the whole run, and if the ordering is deterministic it holds the same slot forever and nothing behind it is ever processed again.
Store block_hash at credit time and re-check it for deposits
credited in the last ~7 days. If the funding transaction is no longer in that
block, flag and alert — do not claw back automatically. A reversal
is itself exploitable, and on a chain with this many tips it will eventually hit an
honest user during a routine reorg. A human posts the compensating entry.
Flagging only works if somebody can see the flag. If your
watcher sets a reorg_flagged column and no screen ever renders it,
you have chosen "a human reviews" and then removed the human. Put it on the admin
page as a banner, not a column nobody scrolls to.
Amounts arrive as bare JSON numbers. There is no string form —
JSON_BIGINT_AS_STRING only affects integers. Convert to satoshis with
integer arithmetic and round once, deliberately. Never accumulate in floats.
21 million PCN is 2.1×1015 satoshis, which fits a 64-bit
integer comfortably and a IEEE-754 double only by luck. Use
BigInt/int64 and serialise totals as strings if they
cross a JSON boundary — BigInt does not survive
JSON.stringify.
A deposit arrives in PCN. Your product is priced in something else. Converting between the two is where the two most expensive mistakes on this chain were made, and neither looked like a bug at the time.
The rate is published, free and unauthenticated:
curl -s https://price.pc.am
{
"price": 0.015,
"serviceRate": 0.015, <-- USD per 1 PCN. Use THIS.
"currency": "USD",
"stale": false,
"at": "2026-08-12T19:26:46.829Z"
}
Read serviceRate. It is USD per one PCN, so
usd = pcn × serviceRate.
It moves. pc.am itself displayed “1,000 PCN = $1.00” for hours after the rate had changed, quoting customers one fifteenth of what four live services were actually paying them. A number written into a template is a number nobody will remember to change.
Store the rate you used next to the credit you granted, in the same row:
credited_usd DECIMAL(18,8)
credited_rate_usd DECIMAL(18,8) -- the rate AT THE MOMENT OF CREDIT
Without it you cannot answer “why was this customer credited that amount” a week later, and you cannot detect that a bad rate was ever used. One integration credited a batch of deposits at 1/15th of their value and it was invisible until someone compared two rows by hand.
This is section 6 applied to the one input that costs money. If the oracle times
out, returns nonsense, or reports "stale": true, you have no
rate — which is not the same as a rate of zero, and not a reason to
credit anyway.
if (!rate.usable) {
hold(deposit, 'no_usable_rate'); // credit nothing, lose nothing, retry later
return;
}
Holding costs a customer a few minutes. Crediting on a guessed rate costs them their money, or costs you yours, and both are silent. The deposit is on the chain and is not going anywhere — there is never a reason to resolve the question early.
A healthy-looking response can still be quoting a frozen price, because
stale and stateAgeSeconds describe the
replica’s sync, not the price:
{
"serviceRate": 0.015,
"stale": false, // is this replica in sync with the primary?
"stateAgeSeconds": 25, // how long since it last synced
"ladder": {
"ageSeconds": 35, // how long since the PRICE itself moved
"stale": false // <-- the one that says the price is usable
}
}
Guarding only stateAgeSeconds catches a replica that has fallen
behind — but on a replica that condition already implies
stale: true, so the guard adds almost nothing. What it misses is the
case that costs money: the replica syncing perfectly while the market ladder
behind it is frozen. Then stale is false, the sync age is seconds,
and you credit at a stale price indefinitely.
Honour ladder.stale, and bound
ladder.ageSeconds as well. Give the freshness bound its own
config key: reusing the cache-age bound means tightening one destroys the other.
Cache for a minute or two so a burst of deposits does not hammer the oracle, but give the cache an expiry and treat an expired entry as unknown rather than serving it forever. A cache with no expiry is a hardcoded rate that took longer to write.
Two numbers, and they are not the same number.
| Figure | What it is |
|---|---|
| Credited | what your ledger converted to balance |
| Treasury | what the wallet actually holds on chain |
They differ by design, and the difference is the interesting part: deposits below the height gate, still confirming, held for review or rejected are real coins your ledger does not count. Sum the on-chain balance across your whole address pool with the batch endpoint and show it. Before moving any money, the treasury figure is the one to trust.
Refuse to show a partial total. If any page of the pool goes unanswered, report the balance as unknown rather than a sum quietly short by one chunk. A treasury figure missing a slice reads as "coins are missing" and will send somebody hunting a theft that never happened.
Also worth putting on that page, because each answers a question you will otherwise ask a database at 2am: which gate is currently closed and why; every deposit with a link to the explorer; per-user totals aggregated in SQL, not by summing a capped list in the browser; reorg-flagged credits; deposits held for review; and address-pool depth.
Your server cannot do this, and that is the point. Moving funds is a deliberate offline act.
pcoin-seed-vault.mjs restore --file myapp-seed.enc.jsonbitcoin-cli -named createwallet wallet_name=sweep-tmp descriptors=true blank=true
bitcoin-cli getdescriptorinfo 'wpkh([<fingerprint>/84h/9444h/0h]<account-xprv>/0/*)'
bitcoin-cli -rpcwallet=sweep-tmp importdescriptors '[
{"desc":"<receive-with-checksum>","timestamp":0,"active":true,"range":[0,2500],"internal":false},
{"desc":"<change-with-checksum>", "timestamp":0,"active":true,"range":[0,2500],"internal":true}
]'
bitcoin-cli -rpcwallet=sweep-tmp rescanblockchain 0
bitcoin-cli -rpcwallet=sweep-tmp getbalances
bitcoin-cli -rpcwallet=sweep-tmp -named sendall recipients='["<your-address>"]'
bitcoin-cli unloadwallet sweep-tmp
importdescriptors returns a result per descriptor and does
not fail the call when only one worked. Check every success.
A wallet that imported the receive descriptor but not the change one accepts coins
and cannot build change — invisible until the first send.
Two node settings a spend depends on, both mandatory:
fallbackfee=0.00001 — Core's default is 0 and PCoin has no fee
history, so without it every mainnet send fails with "Fee estimation
failed".changetype=bech32 — a phrase-backed wallet holds only
wpkh descriptors, so sending to a taproot pc1p… address
fails while allocating change.Other traps: a descriptor range smaller than your address pool
silently misses coins on higher indexes; skipping rescanblockchain
reports a zero balance you will believe; and immature coinbase output cannot be
swept at all until 100 confirmations.
A test that cannot fail is not a test. Three habits that produced green suites over broken code here: asserting a fixture the test itself built two lines earlier without ever calling the code under test; a second call that hits an early return and never reaches the branch being tested; and a fixture missing the very field whose handling is the fix. Before you trust a new test, delete the fix and confirm the test goes red.
(txid, address), and the migration verified with SHOW INDEXblock_hash re-checked for recent credits; flags, does not reverse — and the flag is visible| Item | Value |
|---|---|
| Proof of work | RandomX (CPU) |
| Difficulty | LWMA, retargets every block from height 2800 |
| Block reward | 50 PCN, halving every 210,000 |
| Coinbase maturity | 100 blocks |
| Addresses | pc1… bech32 · base58 55 / 56 / 183 |
| Derivation | m/84'/9444'/account'/change/index |
| Ports | P2P 9444 · RPC 9443 |
| Config file | pcoin.conf — not bitcoin.conf |
| Explorer API | explorer.pc.am/api |
| Custody tool | contrib/vault |