PCoin

Accept PCN payments

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.

  1. The model
  2. Custody: generate the wallet
  3. Deposit addresses
  4. The explorer API
  5. The gates
  6. Failure resolves nothing
  7. Recording a deposit
  8. Reorgs
  9. Amounts
  10. Pricing: what is a PCN worth?
  11. The admin panel you need
  12. Getting the money out
  13. Testing before you go live
  14. Go-live checklist

1. The model

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.

  1. Generate a BIP39 seed offline. (§2)
  2. Derive the account xpub at m/84'/9444'/0'.
  3. Put only the xpub on the server.
  4. Derive one address per user from it — non-hardened children, so the xpub alone suffices.
  5. Poll the explorer. Credit when every gate in §5 passes.

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'.

2. Custody: generate the wallet

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.

Use the tool, or match what it does

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:

FileGoes toCan it spend?
myapp-xpub.txtyour serverno — watch only
myapp-seed.enc.jsonyour backupsonly with your passphrase

Three of its behaviours are worth copying even if you write your own:

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.

Where the pieces live

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.

3. Deposit addresses

Addresses are BIP84 native SegWit with hrp pc, so they look like pc1q…. Derive m/84'/9444'/0'/0/index, one index per user.

Two ways to issue them

ApproachHowTrade-off
Derive on demandkeep the xpub in config, derive at request time, allocate MAX(index)+1 under a row lockno pool to run dry; needs a bech32 encoder in your language
Pre-derived poolgenerate N addresses offline, import them in index order, hand out the next free rowno 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.

Lowercase every address before you compare or store it

A bech32 address is valid in all lowercase or all uppercasepc1q… 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.

Allocate the index under a lock

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.

4. The explorer API

https://explorer.pc.am/api — HTTP, no auth, no key.

EndpointUse
GET /api/statustip height, index health
GET /api/address/{addr}confirmed / unconfirmed / lifetime balances
GET /api/address/{addr}/txstransactions — see the shape trap below
GET /api/tx/{txid}authoritative height, block_hash and is_coinbase
POST /api/addressesbatch, up to 500 — the intended polling path

Do not use node RPC

Two concrete reasons, both of which have cost real time:

Poll in batches

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…"]}'

Response shapes that have caught people

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.

5. The gates

All of them. None optional.

GateValueWhy
confirmations6~69 chain tips seen; ~3% lifetime stale rate
deposit height≥ 2800below it difficulty is frozen
coinbase maturity100mined output, not spendable sooner
index healthblocks_unwound == 0reorg in flight = untrustworthy counts
minimum deposityour callignore 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.

Gate on the deposit's height, not the tip

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");

Coinbase deposits need 100 confirmations

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.

6. Failure resolves nothing

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:

OutcomeMeansDo
request threwnothing is knownhold, retry later
200, tx absenta real fact: not seenact on it
200, tx presenta real fact: seenact 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.

7. Recording a deposit

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.

Idempotency

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.

Write the ledger atomically, or it can reset to empty

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.

Credit the balance first, mark the deposit second

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.

8. Reorgs: detect, never auto-reverse

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.

9. Amounts

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.

10. Pricing: what is a PCN worth?

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.

Never hardcode the rate

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.

Stamp the rate on the deposit row

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.

An unreadable rate is not a rate of zero

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.

The oracle has two clocks. Read both.

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 it, briefly, and age it out

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.

11. The admin panel you need

Two numbers, and they are not the same number.

FigureWhat it is
Creditedwhat your ledger converted to balance
Treasurywhat 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.

12. Getting the money out

Your server cannot do this, and that is the point. Moving funds is a deliberate offline act.

  1. Read the treasury figure, not the credited total.
  2. Recover the phrase on a machine you trust: pcoin-seed-vault.mjs restore --file myapp-seed.enc.json
  3. Create a temporary node wallet and import both descriptors:
bitcoin-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:

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.

13. Testing before you go live

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.

14. Go-live checklist

Chain reference

ItemValue
Proof of workRandomX (CPU)
DifficultyLWMA, retargets every block from height 2800
Block reward50 PCN, halving every 210,000
Coinbase maturity100 blocks
Addressespc1… bech32 · base58 55 / 56 / 183
Derivationm/84'/9444'/account'/change/index
PortsP2P 9444 · RPC 9443
Config filepcoin.confnot bitcoin.conf
Explorer APIexplorer.pc.am/api
Custody toolcontrib/vault