From dd3a6a683fc8389f9b250627f7985561d6b20c0f Mon Sep 17 00:00:00 2001 From: Oleksandr Ostrovskyi Date: Sun, 31 May 2026 17:19:32 +0300 Subject: [PATCH] feat(payments): add USDC payment verification for Hedera Add `verifyUsdcPayment` and `waitForUsdcPayment` as convenience wrappers over the HTS verification engine, with 6-decimal amount parsing, an optional token-id override, and a verified USDC token registry. - `verifyUsdcPayment` / `waitForUsdcPayment`: required `network`, forced 6-decimal parsing, optional `tokenId` override for dev/mock tokens; result `asset` tagged `{ tokenId, decimals: 6, symbol: "USDC" }`. - USDC registry: `USDC_TOKEN_IDS`, `getUsdcTokenId`, `USDC_DECIMALS`, `isUsdcPaymentResult`. Token ids verified against the live Mirror Node and Circle's official docs: mainnet 0.0.456858, testnet 0.0.429274; previewnet has none and throws. - core: add `UnsupportedAssetError` (thrown for networks without a verified USDC token id). - `PaymentAsset` gains an additive optional `symbol?` (non-breaking). - Tests: 22 unit cases plus an opt-in, read-only live smoke test gated by `HBARKIT_LIVE=1`. - Docs/example/llms: new "Verify a USDC payment" guide, README sections, `verify:usdc` example script, and llms.txt/llms-full.txt updates. Changeset: payments minor, core minor. --- .changeset/usdc-payment-verification.md | 13 + .gitignore | 2 + README.md | 19 ++ docs/.vitepress/config.mts | 1 + docs/guide/amounts-and-decimals.md | 4 + docs/guide/verify-hts.md | 3 + docs/guide/verify-usdc-payment.md | 150 +++++++++ docs/guide/wait-for-payment.md | 4 + docs/reference/payments.md | 15 +- examples/node-payment-verifier/.env.example | 8 + examples/node-payment-verifier/README.md | 12 +- examples/node-payment-verifier/package.json | 3 +- .../node-payment-verifier/src/verify-usdc.ts | 23 ++ llms-full.txt | 15 +- llms.txt | 7 +- packages/core/src/errors.ts | 7 + packages/core/test/errors.test.ts | 8 + packages/payments/README.md | 41 +++ packages/payments/src/index.ts | 10 +- packages/payments/src/types.ts | 2 +- packages/payments/src/usdc.ts | 97 ++++++ packages/payments/src/wait.ts | 4 + packages/payments/test/usdc.live.test.ts | 29 ++ packages/payments/test/usdc.test.ts | 287 ++++++++++++++++++ 24 files changed, 753 insertions(+), 11 deletions(-) create mode 100644 .changeset/usdc-payment-verification.md create mode 100644 docs/guide/verify-usdc-payment.md create mode 100644 examples/node-payment-verifier/src/verify-usdc.ts create mode 100644 packages/payments/src/usdc.ts create mode 100644 packages/payments/test/usdc.live.test.ts create mode 100644 packages/payments/test/usdc.test.ts diff --git a/.changeset/usdc-payment-verification.md b/.changeset/usdc-payment-verification.md new file mode 100644 index 0000000..b3683ef --- /dev/null +++ b/.changeset/usdc-payment-verification.md @@ -0,0 +1,13 @@ +--- +"@hbar-kit/payments": minor +"@hbar-kit/core": minor +--- + +Add USDC payment verification helpers for Hedera. + +Introduces `verifyUsdcPayment` and `waitForUsdcPayment` as convenience wrappers over HTS payment +verification, with 6-decimal amount parsing, optional token ID override, docs, examples, and tests. +Adds a verified USDC token registry (`USDC_TOKEN_IDS`, `getUsdcTokenId`) with mainnet/testnet token +ids confirmed against the Mirror Node and Circle's official docs, plus `isUsdcPaymentResult` and an +optional `symbol` field on `PaymentAsset` (additive, non-breaking). Adds a new `UnsupportedAssetError` +to `@hbar-kit/core` for networks without a verified USDC token id (e.g. previewnet). diff --git a/.gitignore b/.gitignore index e878e28..fe56e94 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ docs/.vitepress/cache next-env.d.ts .claude .claude/ +.claude.local.md +.idea/ diff --git a/README.md b/README.md index da82abd..0e69e30 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,25 @@ if (result.matched) { No private keys, no signing, no funds held — `verifyHbarPayment` only **reads** public Mirror Node data and tells you whether the payment you expected actually arrived. +### Verify a USDC payment + +USDC on Hedera is an HTS token. hbar-kit includes a USDC convenience helper for common invoice, +checkout, and payment-link flows — it fills in the verified USDC token id and 6-decimal parsing. + +```ts +import { verifyUsdcPayment } from "@hbar-kit/payments" + +const result = await verifyUsdcPayment({ + network: "mainnet", + receiver: "0.0.12345", + amount: "25.00", + memo: "invoice_123", + after: new Date(Date.now() - 30 * 60 * 1000), +}) +``` + +See [Verify a USDC payment](https://devwhodevs.github.io/hbar-kit/guide/verify-usdc-payment). + ## Use cases - Hedera payment links diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 831407d..4c2f4c3 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -39,6 +39,7 @@ export default defineConfig({ items: [ { text: "Verify an HBAR payment", link: "/guide/verify-hbar" }, { text: "Verify an HTS token payment", link: "/guide/verify-hts" }, + { text: "Verify a USDC payment", link: "/guide/verify-usdc-payment" }, { text: "Wait for a payment", link: "/guide/wait-for-payment" }, { text: "Use a custom Mirror Node", link: "/guide/custom-mirror-node" }, { text: "Partial & duplicate payments", link: "/guide/partial-and-duplicate" }, diff --git a/docs/guide/amounts-and-decimals.md b/docs/guide/amounts-and-decimals.md index 8c7e093..9525969 100644 --- a/docs/guide/amounts-and-decimals.md +++ b/docs/guide/amounts-and-decimals.md @@ -12,3 +12,7 @@ formatUnits(2_889_029n, 6) // "2.889029" ``` Parsing is **strict**: a value with more decimals than allowed throws `InvalidAmountError`. + +USDC on Hedera uses **6 decimals**. [`verifyUsdcPayment`](/guide/verify-usdc-payment) always parses +amounts at 6 decimals, so `"25.001234"` is fine but `"25.0012345"` (7 dp) throws +`InvalidAmountError`. diff --git a/docs/guide/verify-hts.md b/docs/guide/verify-hts.md index e926f6b..2a0189b 100644 --- a/docs/guide/verify-hts.md +++ b/docs/guide/verify-hts.md @@ -15,3 +15,6 @@ const result = await verifyHtsPayment({ ``` If you omit `decimals`, hbar-kit fetches it from the token info endpoint and caches it. + +> Paying in **USDC**? Use [`verifyUsdcPayment`](/guide/verify-usdc-payment) — a thin wrapper that +> fills in the verified USDC token id and 6-decimal parsing for you. diff --git a/docs/guide/verify-usdc-payment.md b/docs/guide/verify-usdc-payment.md new file mode 100644 index 0000000..4d6b0b6 --- /dev/null +++ b/docs/guide/verify-usdc-payment.md @@ -0,0 +1,150 @@ +# Verify a USDC payment on Hedera + +USDC on Hedera is a normal **HTS token**, so hbar-kit can already verify it with +[`verifyHtsPayment`](/guide/verify-hts). `verifyUsdcPayment` is a thin convenience wrapper that +fills in the **verified USDC token id** for the network and parses amounts at **6 decimals**, so you +don't rebuild that boilerplate for every invoice, checkout, or payment-link flow. + +It is the same read-only, non-custodial verification as the rest of hbar-kit: no private keys, no +funds moved — it only reads public Mirror Node data and tells you whether the expected payment +arrived. + +```ts +import { verifyUsdcPayment } from "@hbar-kit/payments" + +const result = await verifyUsdcPayment({ + network: "mainnet", + receiver: "0.0.12345", + amount: "25.00", // 25 USDC — a decimal string, never a float + memo: "invoice_123", // your order / invoice id + after: new Date(Date.now() - 30 * 60 * 1000), +}) + +if (result.matched) { + // Confirmed. Mark the invoice paid using result.transactionId as your idempotency key. +} +``` + +## What it does under the hood + +`verifyUsdcPayment` calls `verifyHtsPayment` with: + +- the canonical USDC token id for the selected `network`, +- `decimals = 6`, +- the same `receiver` / `amount` / `memo` / time-window rules, +- the same [payment status model](/guide/concepts). + +The returned `PaymentResult` is identical to the HTS one, except its `asset` is tagged as USDC: + +```ts +result.asset // { tokenId: "0.0.456858", decimals: 6, symbol: "USDC" } +``` + +Use `isUsdcPaymentResult(result)` (or inspect `result.asset.tokenId`) to detect USDC results in a +mixed pipeline. + +## Verified token ids + +USDC token ids are **network-specific**, so `network` is **required** — the USDC helper never +assumes mainnet. The ids are verified against the live Hedera Mirror Node and Circle's official +[USDC contract addresses](https://developers.circle.com/stablecoins/usdc-contract-addresses): + +| Network | USDC token id | +| ---------- | --------------------------------------------------- | +| mainnet | `0.0.456858` | +| testnet | `0.0.429274` | +| previewnet | _not issued by Circle_ → throws `UnsupportedAssetError` | + +```ts +import { getUsdcTokenId, USDC_TOKEN_IDS } from "@hbar-kit/payments" + +getUsdcTokenId("mainnet") // "0.0.456858" +USDC_TOKEN_IDS.testnet // "0.0.429274" +``` + +## Custom / mock tokens (testnet & dev) + +For local testing against a mock HTS token, pass an explicit `tokenId`. It is still parsed at 6 +decimals and tagged as USDC: + +```ts +const result = await verifyUsdcPayment({ + network: "testnet", + tokenId: process.env.TESTNET_USDC_TOKEN_ID!, // your dev/mock token + receiver: "0.0.12345", + amount: "10.00", + memo: "test_invoice_1", +}) +``` + +Production mainnet flows should omit `tokenId` and use the verified canonical id. + +## Amount precision + +Amounts are **decimal strings**, never floats, and USDC is always **6 decimals**: + +```ts +"25.00" // ✅ 25 USDC +"25.001234" // ✅ 6 decimals +"25.0012345" // ❌ 7 decimals → throws InvalidAmountError +``` + +## Production checklist + +- **Always verify server-side.** Never trust a client-supplied amount, receiver, token id, or memo + — look them up from your own records, keyed by an opaque order id. +- **Always use a time window** (`after`/`before`) to bound the search. +- **Use a unique memo per payment request** so duplicates are detectable. +- **Handle every status**, not just `confirmed`: `underpaid`, `overpaid`, `duplicate`, `mismatch`, + `pending`, `expired`. +- **Use `comparison: "atLeast"` only if you accept overpayment**; the default is exact-match. +- **Use `transactionId` for idempotency** in your own database so each payment is processed once. + +```ts +const result = await verifyUsdcPayment({ + network: "mainnet", + receiver: "0.0.12345", + amount: "25.00", + memo: "invoice_123", + after: new Date(Date.now() - 30 * 60 * 1000), +}) + +switch (result.status) { + case "confirmed": + // result.matched === true — settle the order, store result.transactionId + break + case "underpaid": + case "overpaid": + case "duplicate": + case "mismatch": + case "pending": + // branch on each explicitly + break +} +``` + +## Wait for a USDC payment + +`waitForUsdcPayment` polls until the payment is confirmed (or `overpaid`/`duplicate`), or until +`timeoutMs` elapses (then `status: "expired"`). It mirrors `waitForHbarPayment` / +`waitForHtsPayment` and accepts `timeoutMs`, `pollIntervalMs`, and an `AbortSignal`: + +```ts +import { waitForUsdcPayment } from "@hbar-kit/payments" + +const result = await waitForUsdcPayment({ + network: "mainnet", + receiver: "0.0.12345", + amount: "25.00", + memo: "invoice_123", + timeoutMs: 5 * 60 * 1000, + pollIntervalMs: 3000, +}) +``` + +## When to use which + +- **`verifyUsdcPayment`** — you're accepting USDC and want the token id + 6-decimal parsing handled + for you. The common case. +- **`verifyHtsPayment`** — any other HTS token, or when you need full control over `tokenId` and + `decimals`. diff --git a/docs/guide/wait-for-payment.md b/docs/guide/wait-for-payment.md index 95deeef..08bb0b4 100644 --- a/docs/guide/wait-for-payment.md +++ b/docs/guide/wait-for-payment.md @@ -16,3 +16,7 @@ const result = await waitForHbarPayment({ Pass an `AbortSignal` as `signal` to cancel early. Polling stops immediately on a confirmed, duplicate, or overpaid result. + +The same polling exists for tokens — `waitForHtsPayment` and +[`waitForUsdcPayment`](/guide/verify-usdc-payment) — with identical `timeoutMs` / `pollIntervalMs` / +`signal` options. diff --git a/docs/reference/payments.md b/docs/reference/payments.md index a823578..3020851 100644 --- a/docs/reference/payments.md +++ b/docs/reference/payments.md @@ -26,7 +26,7 @@ interface PaymentResult { | "expired" | "failed" receiver: string - asset: "HBAR" | { tokenId: string; decimals: number } + asset: "HBAR" | { tokenId: string; decimals: number; symbol?: string } transactionId?: string payer?: string amount?: string @@ -43,6 +43,17 @@ interface PaymentResult { Adds `tokenId: string` and optional `decimals?: number` (auto-fetched when omitted). -## waitForHbarPayment / waitForHtsPayment +## verifyUsdcPayment(params) + +Wrapper over `verifyHtsPayment` that resolves the verified USDC token id for `network` (**required**; +mainnet `0.0.456858`, testnet `0.0.429274`, previewnet throws `UnsupportedAssetError`) and forces +`decimals = 6`. Accepts an optional `tokenId` override (dev/mock tokens, still 6 decimals). The +result `asset` is tagged `{ tokenId, decimals: 6, symbol: "USDC" }`. See +[Verify a USDC payment](/guide/verify-usdc-payment). + +Also exported: `getUsdcTokenId(network)`, `USDC_TOKEN_IDS`, `USDC_DECIMALS` (= 6), and +`isUsdcPaymentResult(result)`. + +## waitForHbarPayment / waitForHtsPayment / waitForUsdcPayment Adds `timeoutMs?`, `pollIntervalMs?`, `signal?`. Resolves `confirmed` or `expired`. diff --git a/examples/node-payment-verifier/.env.example b/examples/node-payment-verifier/.env.example index 24b9731..de636ac 100644 --- a/examples/node-payment-verifier/.env.example +++ b/examples/node-payment-verifier/.env.example @@ -1,4 +1,12 @@ +# Used by verify:hbar and verify:hts (network is hardcoded to testnet in those scripts) HBARKIT_RECEIVER=0.0.12345 HBARKIT_AMOUNT=25 HBARKIT_MEMO=order_6471727153206 HBARKIT_TOKEN=0.0.456858 + +# Used by verify:usdc +HEDERA_NETWORK=mainnet +RECEIVER=0.0.12345 +AMOUNT=25.00 +MEMO=invoice_123 +# USDC_TOKEN_ID=0.0.429274 # optional override for a dev/testnet mock token diff --git a/examples/node-payment-verifier/README.md b/examples/node-payment-verifier/README.md index d408875..8d07c2a 100644 --- a/examples/node-payment-verifier/README.md +++ b/examples/node-payment-verifier/README.md @@ -22,10 +22,18 @@ pnpm --filter @hbar-kit/example-node-payment-verifier verify:hbar # Verify an HTS token payment pnpm --filter @hbar-kit/example-node-payment-verifier verify:hts + +# Verify a USDC payment +pnpm --filter @hbar-kit/example-node-payment-verifier verify:usdc ``` -Each script prints `PAID` / `NOT PAID` plus the full `PaymentResult` (status, amount, payer), and — -when matched — a HashScan explorer deep-link. +`verify:hbar` / `verify:hts` print `PAID` / `NOT PAID` plus the full `PaymentResult` (status, +amount, payer), and — when matched — a HashScan explorer deep-link. + +`verify:usdc` reads `HEDERA_NETWORK`, `RECEIVER`, `AMOUNT`, `MEMO`, and an optional `USDC_TOKEN_ID` +override (for a dev/testnet mock token), then prints `PAID` / `NOT PAID`, the status, transaction id, +payer, amount, and a HashScan link when matched. With no `USDC_TOKEN_ID` it uses the verified +canonical USDC token id for the network. ## Key idea: server-side verification diff --git a/examples/node-payment-verifier/package.json b/examples/node-payment-verifier/package.json index 9c8d5d2..3ff6db2 100644 --- a/examples/node-payment-verifier/package.json +++ b/examples/node-payment-verifier/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "verify:hbar": "tsx src/verify-hbar.ts", - "verify:hts": "tsx src/verify-hts.ts" + "verify:hts": "tsx src/verify-hts.ts", + "verify:usdc": "tsx src/verify-usdc.ts" }, "dependencies": { "@hbar-kit/payments": "workspace:*" diff --git a/examples/node-payment-verifier/src/verify-usdc.ts b/examples/node-payment-verifier/src/verify-usdc.ts new file mode 100644 index 0000000..8c60a8d --- /dev/null +++ b/examples/node-payment-verifier/src/verify-usdc.ts @@ -0,0 +1,23 @@ +import { verifyUsdcPayment } from "@hbar-kit/payments" + +const network = (process.env.HEDERA_NETWORK ?? "mainnet") as "mainnet" | "testnet" | "previewnet" +const receiver = process.env.RECEIVER ?? "0.0.12345" +const amount = process.env.AMOUNT ?? "25.00" +const memo = process.env.MEMO +const tokenId = process.env.USDC_TOKEN_ID // optional override (dev/testnet mock token) + +const result = await verifyUsdcPayment({ + network, + receiver, + amount, + memo, + tokenId, // undefined → uses the verified canonical USDC token id for `network` + after: new Date(Date.now() - 30 * 60 * 1000), +}) + +console.log(result.matched ? "PAID" : "NOT PAID") +console.log("status: ", result.status) +console.log("transaction id:", result.transactionId ?? "—") +console.log("payer: ", result.payer ?? "—") +console.log("amount: ", result.amount ? `${result.amount} USDC` : "—") +if (result.matched) console.log("hashscan: ", result.explorerUrl) diff --git a/llms-full.txt b/llms-full.txt index 614eba3..00085a5 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -35,14 +35,23 @@ Business-level verification on top of `@hbar-kit/mirror`. Exports: - `verifyHbarPayment(params): Promise` — verify an HBAR payment. - `verifyHtsPayment(params): Promise` — verify an HTS token payment (auto-resolves token decimals from the Mirror Node if `decimals` is omitted, and caches them). +- `verifyUsdcPayment(params): Promise` — verify a USDC payment. Convenience wrapper + over `verifyHtsPayment` that resolves the verified USDC token id for `network` (required; + mainnet `0.0.456858`, testnet `0.0.429274`, previewnet throws) and forces `decimals = 6`. Accepts + an optional `tokenId` override for dev/mock tokens; tags the result `asset` as + `{ tokenId, decimals: 6, symbol: "USDC" }`. USDC is verified through the Mirror Node as an HTS + token — no custody, no Circle API, no fiat conversion. - `waitForHbarPayment(params & WaitOptions): Promise` — poll until the payment is confirmed or the timeout elapses. - `waitForHtsPayment(params & WaitOptions): Promise` — same, for HTS. +- `waitForUsdcPayment(params & WaitOptions): Promise` — same, for USDC. +- `getUsdcTokenId(network)`, `USDC_TOKEN_IDS`, `USDC_DECIMALS` (= 6), `isUsdcPaymentResult(result)` + — USDC registry/helpers. - `hashscanTxUrl(network, consensusTimestamp, transactionId): string` — build a HashScan explorer link. - Lower-level helpers: `netToReceiver`, `memoMatches`, `classifyAmount`. - Types: `PaymentResult`, `PaymentStatus`, `PaymentMatch`, `PaymentAsset`, `MemoComparison`, - `VerifyHbarParams`, `VerifyHtsParams`, `VerifyBaseParams`, `WaitOptions`. + `VerifyHbarParams`, `VerifyHtsParams`, `VerifyUsdcParams`, `VerifyBaseParams`, `WaitOptions`. `verify*` params (`VerifyBaseParams`): @@ -57,6 +66,8 @@ Business-level verification on top of `@hbar-kit/mirror`. Exports: - `after?: Date | string`, `before?: Date | string` — restrict the time window. - `client?: MirrorClient` — reuse a pre-built Mirror client. - HTS only (`VerifyHtsParams`): `tokenId: string`, `decimals?: number`. +- USDC only (`VerifyUsdcParams`): `network` required; `tokenId?` optional override (decimals are + always 6). `WaitOptions`: `timeoutMs?` (default 10 min), `pollIntervalMs?` (default 3000 ms), `signal?` (AbortSignal). @@ -89,7 +100,7 @@ interface PaymentResult { matched: boolean // true ONLY when the expected payment is satisfied by exactly one tx status: PaymentStatus receiver: string - asset: "HBAR" | { tokenId: string; decimals: number } + asset: "HBAR" | { tokenId: string; decimals: number; symbol?: string } // symbol:"USDC" via verifyUsdcPayment transactionId?: string payer?: string amount?: string // human-readable, e.g. "25" diff --git a/llms.txt b/llms.txt index e29942f..702200e 100644 --- a/llms.txt +++ b/llms.txt @@ -22,8 +22,10 @@ reports whether the expected payment arrived. ## Packages - `@hbar-kit/payments` — payment verification. Functions: `verifyHbarPayment`, `verifyHtsPayment`, - `waitForHbarPayment`, `waitForHtsPayment`. Returns a typed `PaymentResult` (discriminated by - `status`). + `verifyUsdcPayment`, `waitForHbarPayment`, `waitForHtsPayment`, `waitForUsdcPayment`. Returns a + typed `PaymentResult` (discriminated by `status`). USDC on Hedera is verified through the Mirror + Node as an HTS token (6 decimals); `verifyUsdcPayment` is a convenience wrapper that fills in the + verified USDC token id — no custody, no Circle API, no fiat conversion. - `@hbar-kit/mirror` — typed Hedera Mirror Node REST client: `createMirrorClient` (transactions, tokens, balances) with cursor pagination and a pluggable transport. - `@hbar-kit/core` — zero-dependency Hedera primitives: network config (`NETWORKS`, @@ -56,6 +58,7 @@ if (result.matched) { - verify an HBAR payment in TypeScript - verify a Hedera payment by memo / order id - verify an HTS token payment +- verify a USDC payment on Hedera (HTS token, 6 decimals) - accept HBAR payments without custody - build a Hedera payment link, invoice, or checkout flow (incl. Next.js) - backend / server-side Hedera payment verification API diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 1a72135..07b0461 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -85,3 +85,10 @@ export class PaymentVerificationError extends HbarKitError { this.name = "PaymentVerificationError" } } +/** A requested asset is not available on the selected network (e.g. USDC on previewnet). */ +export class UnsupportedAssetError extends HbarKitError { + constructor(message: string, opts?: HbarKitErrorOptions) { + super(message, opts) + this.name = "UnsupportedAssetError" + } +} diff --git a/packages/core/test/errors.test.ts b/packages/core/test/errors.test.ts index ccc1239..37c0a37 100644 --- a/packages/core/test/errors.test.ts +++ b/packages/core/test/errors.test.ts @@ -4,6 +4,7 @@ import { InvalidAmountError, InvalidParamsError, NotFoundError, + UnsupportedAssetError, } from "../src/errors.js" describe("error hierarchy", () => { @@ -26,4 +27,11 @@ describe("error hierarchy", () => { const top = new InvalidParamsError("top", { cause: mid }) expect(top.walk((e) => e === root)).toBe(root) }) + it("UnsupportedAssetError extends HbarKitError, sets name, and carries docsPath", () => { + const e = new UnsupportedAssetError("no USDC here", { docsPath: "/guide/verify-usdc-payment" }) + expect(e).toBeInstanceOf(HbarKitError) + expect(e).toBeInstanceOf(Error) + expect(e.name).toBe("UnsupportedAssetError") + expect(e.docsPath).toBe("/guide/verify-usdc-payment") + }) }) diff --git a/packages/payments/README.md b/packages/payments/README.md index 4641cd9..197dc5c 100644 --- a/packages/payments/README.md +++ b/packages/payments/README.md @@ -21,3 +21,44 @@ if (result.matched) { Statuses: `confirmed | pending | underpaid | overpaid | duplicate | mismatch | expired | failed`. A non-match is a result (with `reason`), not a thrown error. See the [docs](https://github.com/devwhodevs/hbar-kit). + +## USDC + +USDC on Hedera is an HTS token, so `verifyUsdcPayment` / `waitForUsdcPayment` are thin wrappers over +`verifyHtsPayment` / `waitForHtsPayment` that fill in the **verified USDC token id** for the network +and parse amounts at **6 decimals**. + +```ts +import { verifyUsdcPayment } from "@hbar-kit/payments" + +const result = await verifyUsdcPayment({ + network: "mainnet", // required — USDC token ids are network-specific (no default) + receiver: "0.0.12345", + amount: "25.00", // decimal string, 6 decimals; ">6 dp" throws InvalidAmountError + memo: "invoice_123", + after: new Date(Date.now() - 30 * 60 * 1000), +}) +// result.asset === { tokenId: "0.0.456858", decimals: 6, symbol: "USDC" } +``` + +- **When to use the USDC helper vs `verifyHtsPayment`:** use `verifyUsdcPayment` when you're + accepting USDC and want the token id + 6-decimal parsing handled for you; use `verifyHtsPayment` + for any other token or when you need full control over `tokenId`/`decimals`. +- **Verified token ids:** mainnet `0.0.456858`, testnet `0.0.429274` (verified against the Mirror + Node and Circle's official docs); `previewnet` throws `UnsupportedAssetError`. Also exported as + `USDC_TOKEN_IDS` / `getUsdcTokenId(network)`. +- **Precision:** amounts are decimal strings, never floats; USDC is always 6 decimals. +- **Custom / mock token:** pass an explicit `tokenId` (still parsed at 6 decimals) for dev/testnet: + + ```ts + await verifyUsdcPayment({ + network: "testnet", + tokenId: process.env.TESTNET_USDC_TOKEN_ID!, + receiver: "0.0.12345", + amount: "10.00", + memo: "test_invoice_1", + }) + ``` + +Use `isUsdcPaymentResult(result)` to detect USDC results. Full guide: +[Verify a USDC payment](https://devwhodevs.github.io/hbar-kit/guide/verify-usdc-payment). diff --git a/packages/payments/src/index.ts b/packages/payments/src/index.ts index 5fe1a73..cf9f54b 100644 --- a/packages/payments/src/index.ts +++ b/packages/payments/src/index.ts @@ -1,7 +1,15 @@ export { verifyHbarPayment, verifyHtsPayment } from "./verify.js" export type { VerifyHbarParams, VerifyHtsParams, VerifyBaseParams } from "./verify.js" -export { waitForHbarPayment, waitForHtsPayment } from "./wait.js" +export { waitForHbarPayment, waitForHtsPayment, waitForUsdcPayment } from "./wait.js" export type { WaitOptions } from "./wait.js" +export { + verifyUsdcPayment, + getUsdcTokenId, + isUsdcPaymentResult, + USDC_TOKEN_IDS, + USDC_DECIMALS, +} from "./usdc.js" +export type { VerifyUsdcParams } from "./usdc.js" export { hashscanTxUrl } from "./explorer.js" export { netToReceiver, memoMatches, classifyAmount } from "./match.js" export type { diff --git a/packages/payments/src/types.ts b/packages/payments/src/types.ts index 707affa..f710567 100644 --- a/packages/payments/src/types.ts +++ b/packages/payments/src/types.ts @@ -10,7 +10,7 @@ export type PaymentStatus = | "expired" | "failed" -export type PaymentAsset = "HBAR" | { tokenId: string; decimals: number } +export type PaymentAsset = "HBAR" | { tokenId: string; decimals: number; symbol?: string } export interface PaymentMatch { transactionId: string diff --git a/packages/payments/src/usdc.ts b/packages/payments/src/usdc.ts new file mode 100644 index 0000000..51cab6c --- /dev/null +++ b/packages/payments/src/usdc.ts @@ -0,0 +1,97 @@ +import { + assertEntityId, + InvalidParamsError, + UnsupportedAssetError, + type HederaNetwork, +} from "@hbar-kit/core" +import { verifyHtsPayment, type VerifyHtsParams } from "./verify.js" +import type { PaymentAsset, PaymentResult } from "./types.js" + +/** USDC uses 6 decimals on every network Circle issues it on, including Hedera. */ +export const USDC_DECIMALS = 6 +const USDC_SYMBOL = "USDC" + +/** + * Canonical Circle-issued USDC token ids on Hedera, per network. + * + * Verified two independent ways before hardcoding — do NOT change without re-verifying both: + * + * 1. Live Hedera Mirror Node token metadata (on-chain ground truth): + * - mainnet `GET https://mainnet-public.mirrornode.hedera.com/api/v1/tokens/0.0.456858` + * → { symbol: "USDC", name: "USD Coin", decimals: "6", type: "FUNGIBLE_COMMON", + * treasury_account_id: "0.0.439909" } + * - testnet `GET https://testnet.mirrornode.hedera.com/api/v1/tokens/0.0.429274` + * → { symbol: "USDC", name: "USD Coin", decimals: "6", type: "FUNGIBLE_COMMON", + * treasury_account_id: "0.0.5176" } + * + * 2. Circle's official "USDC Contract Addresses" documentation: + * https://developers.circle.com/stablecoins/usdc-contract-addresses + * (Hedera row links to hashscan.io/mainnet/token/0.0.456858; Hedera Testnet row links to + * hashscan.io/testnet/token/0.0.429274). Circle publishes the native 0.0.x form, not an EVM + * address. + * + * Previewnet: Circle does not issue USDC there, so there is no verified id. `getUsdcTokenId` + * throws rather than silently falling back to a mainnet/testnet id. + */ +export const USDC_TOKEN_IDS = { + mainnet: "0.0.456858", + testnet: "0.0.429274", + previewnet: undefined, +} as const satisfies Record + +/** Resolve the verified USDC token id for a network, or throw if none is known for it. */ +export function getUsdcTokenId(network: HederaNetwork): string { + const tokenId = USDC_TOKEN_IDS[network] + if (tokenId === undefined) { + throw new UnsupportedAssetError( + `USDC has no verified token id on Hedera ${network}. ` + + `Pass an explicit \`tokenId\` to verify a custom token, or use "mainnet" or "testnet".`, + { docsPath: "/guide/verify-usdc-payment" }, + ) + } + return tokenId +} + +/** + * Params for {@link verifyUsdcPayment}. Same as {@link VerifyHtsParams} minus `tokenId`/`decimals` + * (USDC is always 6 decimals) and with `network` **required** — USDC token ids and HashScan URLs are + * network-specific, so there is no implicit default network for the USDC helper. + */ +export interface VerifyUsdcParams extends Omit { + network: HederaNetwork + /** + * Override the canonical USDC token id — useful for a dev/testnet mock token. The amount is still + * parsed at 6 decimals. Production mainnet flows should omit this and use the verified token id. + */ + tokenId?: string +} + +/** + * Verify a USDC payment on Hedera. A convenience wrapper over {@link verifyHtsPayment} that resolves + * the canonical USDC token id for `network`, forces 6-decimal amount parsing, and tags the result + * asset as USDC. Every other semantic — `confirmed`/`pending`/`underpaid`/`overpaid`/`duplicate`/ + * `mismatch`/`expired`/`failed`, memo/amount/time-window matching — is identical to HTS verification. + */ +export async function verifyUsdcPayment(p: VerifyUsdcParams): Promise { + if (!p.network) { + throw new InvalidParamsError( + "`network` is required for USDC verification — USDC token ids and explorer URLs are network-specific", + { docsPath: "/guide/verify-usdc-payment" }, + ) + } + const tokenId = p.tokenId ?? getUsdcTokenId(p.network) + assertEntityId(tokenId) + const result = await verifyHtsPayment({ ...p, tokenId, decimals: USDC_DECIMALS }) + // verifyHtsPayment always yields a token asset (never "HBAR"); tag it so consumers can show USDC. + const asset: PaymentAsset = + result.asset === "HBAR" ? result.asset : { ...result.asset, symbol: USDC_SYMBOL } + return { ...result, asset } +} + +/** + * True when a PaymentResult was produced by {@link verifyUsdcPayment} (its asset is tagged + * `symbol: "USDC"`). Useful for narrowing/displaying results from a mixed payment pipeline. + */ +export function isUsdcPaymentResult(result: PaymentResult): boolean { + return typeof result.asset === "object" && result.asset.symbol === USDC_SYMBOL +} diff --git a/packages/payments/src/wait.ts b/packages/payments/src/wait.ts index b2085d0..783c23a 100644 --- a/packages/payments/src/wait.ts +++ b/packages/payments/src/wait.ts @@ -4,6 +4,7 @@ import { type VerifyHbarParams, type VerifyHtsParams, } from "./verify.js" +import { verifyUsdcPayment, type VerifyUsdcParams } from "./usdc.js" import type { PaymentResult } from "./types.js" export interface WaitOptions { @@ -46,3 +47,6 @@ export function waitForHbarPayment(p: VerifyHbarParams & WaitOptions): Promise

{ return poll(() => verifyHtsPayment(p), p) } +export function waitForUsdcPayment(p: VerifyUsdcParams & WaitOptions): Promise { + return poll(() => verifyUsdcPayment(p), p) +} diff --git a/packages/payments/test/usdc.live.test.ts b/packages/payments/test/usdc.live.test.ts new file mode 100644 index 0000000..0c30e2e --- /dev/null +++ b/packages/payments/test/usdc.live.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest" +import { createMirrorClient } from "@hbar-kit/mirror" +import { USDC_TOKEN_IDS, getUsdcTokenId, USDC_DECIMALS } from "../src/usdc.js" + +const LIVE = process.env.HBARKIT_LIVE === "1" + +/** + * Opt-in, read-only live check against the public Hedera Mirror Node. Confirms the hardcoded USDC + * token ids still resolve to the real Circle USDC on each network. No private keys, no funds moved — + * only a public token-metadata read. Skipped unless HBARKIT_LIVE=1 so it never blocks normal CI. + * + * HBARKIT_LIVE=1 pnpm --filter @hbar-kit/payments test + */ +describe.runIf(LIVE)("USDC registry — live Mirror Node smoke (opt-in)", () => { + for (const network of ["mainnet", "testnet"] as const) { + it( + `${network} USDC token ${USDC_TOKEN_IDS[network]} exists with symbol USDC and 6 decimals`, + async () => { + const client = createMirrorClient({ network }) + const token = await client.tokens.get(getUsdcTokenId(network)) + expect(token.tokenId).toBe(USDC_TOKEN_IDS[network]) + expect(token.symbol).toBe("USDC") + expect(token.decimals).toBe(USDC_DECIMALS) + expect(token.type).toBe("FUNGIBLE_COMMON") + }, + 20_000, + ) + } +}) diff --git a/packages/payments/test/usdc.test.ts b/packages/payments/test/usdc.test.ts new file mode 100644 index 0000000..edd14fd --- /dev/null +++ b/packages/payments/test/usdc.test.ts @@ -0,0 +1,287 @@ +import { describe, it, expect, vi } from "vitest" +import { createMirrorClient } from "@hbar-kit/mirror" +import { InvalidAmountError, InvalidParamsError, UnsupportedAssetError } from "@hbar-kit/core" +import { + verifyUsdcPayment, + getUsdcTokenId, + isUsdcPaymentResult, + USDC_TOKEN_IDS, + USDC_DECIMALS, + type VerifyUsdcParams, +} from "../src/usdc.js" +import { waitForUsdcPayment } from "../src/wait.js" +import { verifyHbarPayment } from "../src/verify.js" + +const MAINNET_USDC = "0.0.456858" +const b64 = (s: string) => Buffer.from(s, "utf8").toString("base64") + +/** Build a single-page Mirror Node list body with one HTS (USDC-style) credit to `receiver`. */ +function tokenList(opts: { + tokenId?: string + receiver?: string + payer?: string + base?: number // smallest-unit amount credited to the receiver + memo?: string + result?: string + txId?: string + ts?: string +}) { + const tokenId = opts.tokenId ?? MAINNET_USDC + const receiver = opts.receiver ?? "0.0.12345" + const payer = opts.payer ?? "0.0.6628041" + const base = opts.base ?? 25_000_000 + return { + transactions: [ + { + result: opts.result ?? "SUCCESS", + name: "CRYPTOTRANSFER", + consensus_timestamp: opts.ts ?? "1780150231.356942024", + transaction_id: opts.txId ?? "0.0.6628041-1780150230-000000000", + memo_base64: opts.memo !== undefined ? b64(opts.memo) : "", + nonce: 0, + scheduled: false, + parent_consensus_timestamp: null, + charged_tx_fee: 1, + nft_transfers: [], + token_transfers: [ + { token_id: tokenId, account: receiver, amount: base, is_approval: false }, + { token_id: tokenId, account: payer, amount: -base, is_approval: false }, + ], + transfers: [{ account: payer, amount: -1, is_approval: false }], + }, + ], + links: { next: null }, + } +} + +const empty = { transactions: [], links: { next: null } } + +/** A Mirror client whose fetch always returns `body` (token endpoint is never hit for USDC). */ +function client(body: unknown, network: "mainnet" | "testnet" = "mainnet") { + const fetchMock = async () => new Response(JSON.stringify(body), { status: 200 }) + return createMirrorClient({ network, fetch: fetchMock as unknown as typeof fetch }) +} + +/** A Mirror client that returns each body in `bodies` on successive polls. */ +function clientSequence(bodies: unknown[]) { + let i = 0 + const fetchMock = vi.fn(async () => { + const body = bodies[Math.min(i, bodies.length - 1)] + i++ + return new Response(JSON.stringify(body), { status: 200 }) + }) + return createMirrorClient({ + network: "mainnet", + fetch: fetchMock as unknown as typeof fetch, + }) +} + +const base = (over: Partial = {}): VerifyUsdcParams => ({ + network: "mainnet", + receiver: "0.0.12345", + amount: "25.00", + ...over, +}) + +describe("USDC token registry", () => { + it("getUsdcTokenId returns the verified mainnet/testnet ids", () => { + expect(getUsdcTokenId("mainnet")).toBe("0.0.456858") + expect(getUsdcTokenId("testnet")).toBe("0.0.429274") + }) + it("registry constant matches and exposes 6 decimals", () => { + expect(USDC_TOKEN_IDS.mainnet).toBe("0.0.456858") + expect(USDC_TOKEN_IDS.testnet).toBe("0.0.429274") + expect(USDC_TOKEN_IDS.previewnet).toBeUndefined() + expect(USDC_DECIMALS).toBe(6) + }) + it("throws UnsupportedAssetError on previewnet (no verified token id)", () => { + expect(() => getUsdcTokenId("previewnet")).toThrowError(UnsupportedAssetError) + expect(() => getUsdcTokenId("previewnet")).toThrowError(/USDC|previewnet/i) + }) +}) + +describe("verifyUsdcPayment", () => { + it("confirms an exact USDC payment and tags the asset as USDC", async () => { + const r = await verifyUsdcPayment(base({ client: client(tokenList({ memo: "invoice_123" })), memo: "invoice_123" })) + expect(r.matched).toBe(true) + expect(r.status).toBe("confirmed") + expect(r.amountBase).toBe(25_000_000n) + expect(r.amount).toBe("25") + expect(r.payer).toBe("0.0.6628041") + expect(r.asset).toEqual({ tokenId: MAINNET_USDC, decimals: 6, symbol: "USDC" }) + expect(r.explorerUrl).toContain("hashscan.io/mainnet/transaction/1780150231.356942024") + expect(isUsdcPaymentResult(r)).toBe(true) + }) + + it("returns underpaid when the amount is short", async () => { + const r = await verifyUsdcPayment( + base({ amount: "25.00", client: client(tokenList({ base: 20_000_000, memo: "invoice_123" })), memo: "invoice_123" }), + ) + expect(r.matched).toBe(false) + expect(r.status).toBe("underpaid") + }) + + it("returns overpaid (exact comparison) when more was sent", async () => { + const r = await verifyUsdcPayment( + base({ client: client(tokenList({ base: 30_000_000, memo: "invoice_123" })), memo: "invoice_123" }), + ) + expect(r.matched).toBe(false) + expect(r.status).toBe("overpaid") + }) + + it("confirms an overpayment under comparison: atLeast", async () => { + const r = await verifyUsdcPayment( + base({ + comparison: "atLeast", + client: client(tokenList({ base: 30_000_000, memo: "invoice_123" })), + memo: "invoice_123", + }), + ) + expect(r.matched).toBe(true) + expect(r.status).toBe("confirmed") + }) + + it("returns mismatch on the wrong memo", async () => { + const r = await verifyUsdcPayment( + base({ client: client(tokenList({ memo: "something_else" })), memo: "invoice_123" }), + ) + expect(r.matched).toBe(false) + expect(r.status).toBe("mismatch") + }) + + it("returns mismatch when a memo is expected but missing", async () => { + const r = await verifyUsdcPayment(base({ client: client(tokenList({})), memo: "invoice_123" })) + expect(r.status).toBe("mismatch") + }) + + it("returns pending when nothing credits the receiver (wrong receiver)", async () => { + const r = await verifyUsdcPayment( + base({ receiver: "0.0.99999", client: client(tokenList({ memo: "invoice_123" })), memo: "invoice_123" }), + ) + expect(r.matched).toBe(false) + expect(r.status).toBe("pending") + }) + + it("returns pending when the token id does not match USDC (wrong token)", async () => { + const r = await verifyUsdcPayment( + base({ client: client(tokenList({ tokenId: "0.0.111111", memo: "invoice_123" })), memo: "invoice_123" }), + ) + expect(r.status).toBe("pending") + }) + + it("flags duplicates when two USDC payments satisfy the request", async () => { + const dup = { + transactions: [ + tokenList({ memo: "invoice_123" }).transactions[0], + tokenList({ memo: "invoice_123", txId: "0.0.6628041-1780150240-000000000", ts: "1780150241.000000000" }).transactions[0], + ], + links: { next: null }, + } + const r = await verifyUsdcPayment(base({ client: client(dup), memo: "invoice_123" })) + expect(r.status).toBe("duplicate") + expect(r.matches.length).toBe(2) + }) + + it("excludes failed transactions (treated as pending)", async () => { + const r = await verifyUsdcPayment( + base({ client: client(tokenList({ memo: "invoice_123", result: "INSUFFICIENT_PAYER_BALANCE" })), memo: "invoice_123" }), + ) + expect(r.status).toBe("pending") + }) + + it("supports a custom tokenId override (dev/testnet mock token) while still tagging USDC + 6 decimals", async () => { + const custom = "0.0.555555" + const r = await verifyUsdcPayment( + base({ + network: "testnet", + amount: "10.00", + tokenId: custom, + client: client(tokenList({ tokenId: custom, base: 10_000_000, memo: "test_invoice_1" }), "testnet"), + memo: "test_invoice_1", + }), + ) + expect(r.matched).toBe(true) + expect(r.asset).toEqual({ tokenId: custom, decimals: 6, symbol: "USDC" }) + expect(r.explorerUrl).toContain("hashscan.io/testnet") + }) + + it("uses the testnet registry id and a testnet explorer url", async () => { + const r = await verifyUsdcPayment( + base({ + network: "testnet", + amount: "10.00", + client: client(tokenList({ tokenId: "0.0.429274", base: 10_000_000, memo: "t" }), "testnet"), + memo: "t", + }), + ) + expect(r.matched).toBe(true) + expect(r.asset).toEqual({ tokenId: "0.0.429274", decimals: 6, symbol: "USDC" }) + expect(r.explorerUrl).toContain("hashscan.io/testnet") + }) + + it("throws UnsupportedAssetError on previewnet without a tokenId override", async () => { + await expect(verifyUsdcPayment(base({ network: "previewnet" }))).rejects.toThrowError( + UnsupportedAssetError, + ) + }) + + it("throws InvalidParamsError when network is omitted", async () => { + const bad = { receiver: "0.0.12345", amount: "25.00" } as unknown as VerifyUsdcParams + await expect(verifyUsdcPayment(bad)).rejects.toThrowError(InvalidParamsError) + await expect(verifyUsdcPayment(bad)).rejects.toThrowError(/network/i) + }) + + it("throws InvalidParamsError on a malformed custom tokenId", async () => { + await expect( + verifyUsdcPayment(base({ tokenId: "not-an-id", client: client(empty) })), + ).rejects.toThrowError(InvalidParamsError) + }) + + it("throws InvalidAmountError when the amount has more than 6 decimals", async () => { + await expect( + verifyUsdcPayment(base({ amount: "10.1234567", client: client(empty), memo: "x" })), + ).rejects.toThrowError(InvalidAmountError) + }) +}) + +describe("isUsdcPaymentResult", () => { + it("is false for a plain HBAR result", async () => { + const r = await verifyHbarPayment({ + network: "mainnet", + receiver: "0.0.12345", + amount: "1", + client: client(empty), + }) + expect(isUsdcPaymentResult(r)).toBe(false) + }) +}) + +describe("waitForUsdcPayment", () => { + it("polls until a USDC payment appears, then resolves confirmed", async () => { + const r = await waitForUsdcPayment({ + network: "mainnet", + receiver: "0.0.12345", + amount: "25.00", + memo: "invoice_123", + client: clientSequence([empty, empty, tokenList({ memo: "invoice_123" })]), + timeoutMs: 1000, + pollIntervalMs: 1, + }) + expect(r.matched).toBe(true) + expect(r.status).toBe("confirmed") + expect(isUsdcPaymentResult(r)).toBe(true) + }) + + it("returns expired when the timeout elapses with no payment", async () => { + const r = await waitForUsdcPayment({ + network: "mainnet", + receiver: "0.0.12345", + amount: "25.00", + client: clientSequence([empty]), + timeoutMs: 5, + pollIntervalMs: 1, + }) + expect(r.matched).toBe(false) + expect(r.status).toBe("expired") + }) +})