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.

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.

  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
  15. Accepting wPCN (BEP-20)

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

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.

Deleting a user must not be able to rewind it

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.

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
GET /api/supplymax, 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.

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.

The address history is the only thing driving your credits

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:

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.

5. The gates

All of them. None optional.

GateValueWhy
confirmations3reorgs of a block or two are routine on a chain this small; the explorer counts them
deposit height≥ 2800below it difficulty is frozen
coinbase maturity100mined output, not spendable sooner
index healthindex.stale == false and blocks_behind == 0blocks_unwound and reorg_count are lifetime counters, not health — they only ever grow
minimum deposityour callignore 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.

Gate on the deposit's height, not the tip

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

A missing height holds. It never rejects.

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

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

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
{                          // 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.

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

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.

A refusal must not fall back to the cache

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.

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

15. Accepting wPCN, the BEP-20 wrapper

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

Why a transaction hash, not an address per customer

This is the first question everyone asks, so: we tried, and the chain will not support it.

PCN (above)wPCN on BSC
tell payers apartone address eachBEP-20 has no memo field
move funds outfreeevery address needs BNB for gas first
watch for depositsexplorer indexeth_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.

The verifier

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>" }
HTTPstateWhat you do
200creditedcredit usd_total, once
200already_claimedcredit nothing; yours says if it was you
200pendingnot on chain yet — let them retry
200confirmingseen, too shallow; show the count
200no_paymentreal transaction, paid us nothing
200revertedfailed on chain
200reorgedblock no longer canonical — do not credit
400bad_requestmalformed hash
401your token is wrong. A deployment bug, not a payment failure
503unreadablewe 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.

What changes in your ledger

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.

Two things a wallet will get wrong unprompted

Read the payment address from GET https://wpcnpay.pc.am/health rather than hardcoding it, so it can be rotated without redeploying every integration.

Before you go live

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.

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