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.
Three production integrations follow this guide — AiControl, webbuilderbot, webai.pc.am and AiControl. 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 three 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.
If you allocate with MAX(derivation_index) + 1, then anything that
deletes address rows — a user purge, a GDPR erasure, a test cleanup —
lowers the maximum, and the next user is handed an address that already
belonged to somebody else. That breaks the one invariant the whole ledger
rests on. Two things go wrong at once: an older deposit to that address credits the
new owner, and if you also deleted the idempotency row, the same deposit can be
credited a second time.
This is not hypothetical. One live integration had already recycled derivation index 0 before anyone noticed, and had a deposit row pointing at an address with no owner record at all.
Allocate from a counter that only ever goes up, bumped inside the same transaction as the address insert:
-- inside the transaction that inserts the address
SELECT value FROM settings WHERE `key` = 'pcn_next_index' FOR UPDATE;
-- ...use it, then...
INSERT INTO settings (`key`, `value`) VALUES ('pcn_next_index', ?)
ON DUPLICATE KEY UPDATE `value` = ?;
Do not bump it through a helper that uses a different connection — the read and the write would land in different transactions and you would be back to the race the lock exists to prevent.
Seed the counter above anything ever issued, not at
MAX + 1. Seeding from the maximum inherits the very gap the deletions
created, because those indexes are gone from the table. There are 231
indexes available; starting at 1000 costs nothing and guarantees no address ever
handed out can be handed out again.
Retiring an index strands no money. The coins sit at an address derived from your seed at a known path, so you can always spend them — with or without a database row.
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 |
GET /api/supply | max, total, circulating and immature supply |
GET /api/supply/{max|total|circulating|immature} | the same figures one at a time, as a bare number in text/plain |
Two things about supply that are not obvious and get asked every time.
It is computed from the UTXO set, not from height × subsidy, so the
genesis output is excluded — it is unspendable by consensus and never
enters the set, exactly as in Bitcoin, which is why the figure is one block reward below
what multiplying gives you. And immature coinbase is included in
total and circulating: those coins exist and are owned, they are
simply not spendable for 100 blocks yet. It is reported on its own line so you can subtract
it if your definition differs — we will not make that choice silently on your behalf.
circulating equals total because PCoin had no premine, no founder
allocation and no vesting.
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.balance key, split into 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.
Almost every integration is built the same way: fetch the recent transactions for each address, and credit what you find. That means a deposit you hold is retried only while it stays inside that page of results. Deposit addresses are per-user and reused forever, so a busy address eventually pushes a held deposit out of the window — and from that moment it is money received, recorded, and never credited again, with nothing alerting.
A deposit can be held for entirely ordinary reasons: coinbase immaturity, a spending cap, an unreadable rate, a failed second-source check. All of them are correct. None of them should be permanent.
Two defences, and you want both:
limit is capped at 200;
anything higher is rejected with 400 bad_request, so
limit=250 silently stops your watcher reading the chain at all.seen or
confirming and re-check those txids directly via
/tx/{txid}. A held row must be reachable from your database.And when you monitor for stuck money, watch seen as well as
confirming. A transaction that is recorded but never mined sits in
seen forever, and a monitor that only looks at confirming
will never mention it.
All of them. None optional.
| Gate | Value | Why |
|---|---|---|
| confirmations | 3 | reorgs of a block or two are routine on a chain this small; the explorer counts them |
| deposit height | ≥ 2800 | below it difficulty is frozen |
| coinbase maturity | 100 | mined output, not spendable sooner |
| index health | index.stale == false and blocks_behind == 0 | blocks_unwound and reorg_count are lifetime counters, not health — they only ever grow |
| minimum deposit | your call | ignore dust rather than write a row per satoshi |
All six production integrations run 3 confirmations — about half an hour at the 600 s target, less when the chain runs fast. Several also corroborate every deposit above $0.05 against a second, independent explorer, which is the better defence at a shallow depth. 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 was frozen at
1e0b7c33 — the legacy retarget fired only every 2016 blocks, so those
blocks are cheap to rewrite for anyone renting hashrate, and the chain cannot push
back. LWMA has retargeted every block since 2800, but a deposit that sits in the
frozen era stays cheap to reorganise forever. A tip-based gate credits such a
deposit the instant the tip is high enough, 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");
(int) null is 0, and 0 is below any
sane min_height. If your gate reacts to that by writing
status = 'rejected', one malformed explorer item permanently keeps a
customer's money — usually with a note blaming a pre-fork block that has
nothing to do with it. rejected is normally terminal, so the row is
skipped forever, and held-money alerts never match it because they look for
seen and confirming.
if (!isset($t['height']) || !is_numeric($t['height']) || (int) $t['height'] <= 0) {
$summary['skipped'][] = 'no_height';
continue; // hold. the next tick re-reads it.
}
Reserve rejected for things you have positively established, from
data you actually read. Everything else holds.
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: every one live at the time it was found — four of the five that existed then —
had shipped it.
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
{ // example from August 2026 — the live value differs
"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.
PancakeSwap is not the price either. wPCN (a BEP-20 claim on PCN) trades on BNB Smart Chain, and a bot holds that pool to the price.pc.am rate — the pool follows the feed, never the reverse. Its liquidity is a few hundred dollars, so a single small trade moves it by percent; reading it as a price lets anyone who spends $20 set your credit rate. Read price.pc.am, and only price.pc.am. A customer holding wPCN pays you by redeeming it for PCN at wrapdesk.pc.am/redeem and sending the PCN — never accept wPCN directly unless you run your own BSC watcher.
It moves. pc.am itself displayed “1,000 PCN = $1.00” for hours after the rate had changed, quoting customers one fifteenth of what the four services live at the time 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.
Sanity-check the rate: a floor, a ceiling, and a limit on how far it may move in
one poll against the last value you accepted. Then be careful where the refusal
lands. Most integrations wrap the whole fetch in one try, with a
catch that falls back to a cached rate — which is right when the
oracle is unreachable.
It is exactly wrong when the oracle answered and you rejected what it
said. The cache was filled by the same oracle, so falling back to it launders the
reading you just refused into a usable one. This was shipped, in this exact form:
every band and staleness violation threw, landed in the generic catch, and came back
out as usable: true from cache. The guards were present and caught
nothing.
catch (DomainException $e) { // the oracle answered; the answer was insane
return ['usable' => false, 'error' => $e->getMessage()]; // refuse. no cache.
} catch (Throwable $e) { // the oracle could not be reached
return $recentCache ?: ['usable' => false]; // cache is legitimate
}
Two different failures, two different answers. If you cannot tell them apart in your code, you cannot tell them apart in production either. Test the refusal direction explicitly — a guard that has only ever been observed letting good data through has not been tested.
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 INDEXladder.stale honoured, and
credited_rate_usd stamped on the row — no rate anywhere in a template or a constantblock_hash re-checked for recent credits; flags, does not reverse — and the flag is visibleEverything above is about PCN on its own chain. wPCN is the same coin wrapped as a BEP-20 token on BNB Smart Chain, backed 1:1 by PCN in a public reserve. Taking it is a second, much smaller integration, and it does not reuse the deposit-address model above.
Paying in wPCN earns 10% more credit than the same value in PCN. That is deliberate: to get the bonus a customer has to buy wPCN on PancakeSwap, which is demand the token does not otherwise have.
This is the first question everyone asks, so: we tried, and the chain will not support it.
| PCN (above) | wPCN on BSC | |
|---|---|---|
| tell payers apart | one address each | BEP-20 has no memo field |
| move funds out | free | every address needs BNB for gas first |
| watch for deposits | explorer index | eth_getLogs, which public BSC RPCs refuse |
Measured 2026-09-08 against bsc-dataseed.binance.org, filtered to the
wPCN contract alone, every range was rejected — including a
single block:
span 1 block -> ERROR: limit exceeded span 50 blocks -> ERROR: limit exceeded span 500 blocks -> ERROR: limit exceeded span 5000 blocks -> ERROR: limit exceeded
So address-watching on BSC means a paid RPC subscription or your own node, and
per-customer addresses mean funding thousands of them with BNB before a single
payment can be swept. eth_getTransactionReceipt for one hash is still
free everywhere. The customer pastes the hash instead: one extra form field removes
the gas problem, the sweeping problem and the indexing problem together.
You do not talk to BNB Smart Chain yourself. One shared service does, and it owns the claim ledger — so a transaction hash can be banked exactly once across every integration, and you never have to ask whether somebody else already took it.
POST https://wpcnpay.pc.am/verify
Authorization: Bearer <your token>
Content-Type: application/json
{ "txhash": "0x&64 hex&", "user_ref": "<your user id>" }
| HTTP | state | What you do |
|---|---|---|
| 200 | credited | credit usd_total, once |
| 200 | already_claimed | credit nothing; yours says if it was you |
| 200 | pending | not on chain yet — let them retry |
| 200 | confirming | seen, too shallow; show the count |
| 200 | no_payment | real transaction, paid us nothing |
| 200 | reverted | failed on chain |
| 200 | reorged | block no longer canonical — do not credit |
| 400 | bad_request | malformed hash |
| 401 | — | your token is wrong. A deployment bug, not a payment failure |
| 503 | unreadable | we could not look. Resolve nothing |
Section 6 applies here unchanged, and this is where it bites hardest:
unreadable does not mean “you did not pay”. It means the
question is unanswered. Telling a paying customer “no payment found”
because a network call failed is how you convince someone they were robbed.
The (txid, address) rule from section 7 becomes
(txhash, log_index). Not the hash alone: one BSC
transaction can carry several Transfer logs, and keying on the hash
silently drops the second rather than erroring — the same shape as the
(txid, vout) bug every early PCN integration shipped.
UNIQUE KEY `uniq_claim` (`txhash`, `log_index`)
Insert that row and credit the balance in one transaction, with
SELECT & FOR UPDATE on the balance row. If the insert hits the unique key
you have already credited it: credit nothing and report success. Stamp
credited_rate_usd exactly as in section 7 — the verifier returns the
rate it used, and that is the one to store.
Read the payment address from GET https://wpcnpay.pc.am/health rather
than hardcoding it, so it can be rotated without redeploying every integration.
The section 13 discipline applies, plus one test nobody runs unless told to: make the verifier unreachable — block it, or point the client at a dead port — and prove your page says “could not check, try again” and writes nothing. A working reference integration clears this bar:
wrong token -> unreadable dead port -> unreadable wrong endpoint (an HTML page) -> unreadable <- not "no payment" malformed hash -> bad_request nonexistent hash -> pending <- NOT no_payment rows written during all of the above: 0
The full implementation brief, and drop-in PHP and Node clients you should copy rather than reimplement, are in contrib/wpcn-pay. Ask us for a project token.
| 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 |