@chaindaddy/mcp 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +55 -1
- package/build/index.js.map +1 -1
- package/build/skills.d.ts +10 -0
- package/build/skills.d.ts.map +1 -0
- package/build/skills.js +46 -0
- package/build/skills.js.map +1 -0
- package/package.json +2 -1
- package/skills/add-crypto-to-your-game/SKILL.md +114 -0
- package/skills/build-a-crown-app/SKILL.md +123 -0
- package/skills/check-token-health/SKILL.md +67 -0
- package/skills/export-a-listing/SKILL.md +51 -0
- package/skills/find-and-check-a-token/SKILL.md +86 -0
- package/skills/launch-a-token/SKILL.md +102 -0
- package/skills/sell-items-in-your-app/SKILL.md +108 -0
- package/skills/sign-in-with-chain-daddy/SKILL.md +116 -0
- package/skills/verify-receipts-and-sessions/SKILL.md +106 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sell-items-in-your-app
|
|
3
|
+
description: Run a Chain Daddy creator store - open it, add items priced in a token or in dollars, create orders from your server, have the buyer's wallet pay, wait for confirmed, deliver, read and spend what a wallet owns, drops and rewards, webhooks, refunds - and test the whole loop on a no-money test store first. Use when someone wants to sell credits, skins, passes or badges for their token, give items away, or unlock perks for holders.
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Sell items in your app
|
|
8
|
+
|
|
9
|
+
A store sells **items** for one token. The buyer's wallet pays the token's payout wallet directly, in one
|
|
10
|
+
on-chain transaction; the checkout contract has no owner and holds no balance. Once the payment confirms,
|
|
11
|
+
Chain Daddy grants the item and signs a receipt. Items live off chain: they can't be transferred or cashed out.
|
|
12
|
+
|
|
13
|
+
## Tools
|
|
14
|
+
|
|
15
|
+
| Step | MCP (local server) | CLI (`npm i -g @chaindaddy/cli@beta`) | HTTP (`https://api.chaindaddy.io`) |
|
|
16
|
+
|---|---|---|---|
|
|
17
|
+
| Open the store | `iap_open_store` | `chaindaddy iap store open --chain base --crown 8` | `POST /api/v2/iap/stores` |
|
|
18
|
+
| Test store | `iap_open_test_store` | `chaindaddy iap store test <storeId>` | `POST /api/v2/iap/stores/{id}/test-store` |
|
|
19
|
+
| Items | `iap_create_sku`, `iap_update_sku`, `iap_list_skus` | `chaindaddy iap sku create/update/archive` | `/api/v2/iap/stores/{id}/skus` |
|
|
20
|
+
| Server key | `iap_create_store_key` | `chaindaddy iap key create` | `POST /api/v2/iap/stores/{id}/keys` |
|
|
21
|
+
| Orders | `iap_create_order`, `iap_get_order`, `iap_test_pay` | `chaindaddy iap order create/show/test-pay` | `POST /api/v2/iap/orders` |
|
|
22
|
+
| What a wallet owns | `iap_get_entitlements`, `iap_consume`, `iap_grant`, `iap_revoke` | `chaindaddy iap entitlements/consume/grant/revoke` | `/api/v2/iap/stores/{id}/entitlements` |
|
|
23
|
+
| Rewards | `iap_create_drop`, `iap_set_drop_status`, `iap_award` | `chaindaddy iap drop …` | `/api/v2/iap/stores/{id}/drops` |
|
|
24
|
+
| Events | `iap_list_events`, `iap_set_webhook`, `iap_test_webhook` | `chaindaddy iap events/webhook …` | `/api/v2/iap/stores/{id}/events` |
|
|
25
|
+
|
|
26
|
+
Credentials: account calls (open a store, manage items and keys) use the human's `chaindaddy login` on this
|
|
27
|
+
computer (or a `cd_live_…` key in `CHAINDADDY_API_KEY` on a server). The game server uses a **store key** `cd_iap_…` from `CHAINDADDY_IAP_KEY`: one store,
|
|
28
|
+
only its scopes, sent alone as `Authorization: Bearer`. The plan gate: Developer, Partner or Enterprise, or
|
|
29
|
+
Developer Beta Program membership; otherwise every call answers `403 IAP_PLAN_REQUIRED` - stop and tell the human.
|
|
30
|
+
|
|
31
|
+
## Steps
|
|
32
|
+
|
|
33
|
+
1. **Open the store** with the token page's chain key and crown id. Opening again returns the same store.
|
|
34
|
+
Report `id`, `payoutAddress` (starts as the owner's wallet), and `feeBps` (0 on a token launched in a Chain
|
|
35
|
+
Daddy launchpad pool, 250 otherwise).
|
|
36
|
+
2. **Add items.** New items are `draft`; set `status: "active"` to list one. Create reward-only items (no
|
|
37
|
+
price, like `credits`) before the bundles that `grants` them. Example credit pack:
|
|
38
|
+
```json
|
|
39
|
+
{ "sku": "credits.100", "type": "consumable", "name": "100 credits",
|
|
40
|
+
"priceUsdMicros": 5000000, "minPrice": "1000000000000000000000",
|
|
41
|
+
"grants": [{ "sku": "credits", "quantity": 100 }], "status": "active" }
|
|
42
|
+
```
|
|
43
|
+
`sku`: lowercase letters, digits, `.`, `_`, `-`, up to 64. A fixed `price` is a **base-unit string**
|
|
44
|
+
(5,000 tokens at 18 decimals = `"5000000000000000000000"`). An item has `price` or `priceUsdMicros`, not
|
|
45
|
+
both. `currency` is `token` (default), `native` or `usdc`. Confirm human-readable amounts with the human.
|
|
46
|
+
3. **Make a store key** with only the scopes the server needs, typically `orders:read orders:write
|
|
47
|
+
entitlements:read entitlements:write`. The secret is shown **once**: tell the human to store it as
|
|
48
|
+
`CHAINDADDY_IAP_KEY` in their secrets manager. Never print it back.
|
|
49
|
+
4. **Test it** (next section) before any live sale.
|
|
50
|
+
5. **Server creates the order** for the user's wallet, with `appAccountToken` = your own user id and an
|
|
51
|
+
`Idempotency-Key` per purchase attempt (your cart id). It locks the price for about 20 minutes (5 for a
|
|
52
|
+
dollar price paid in the token or coin).
|
|
53
|
+
6. **Client pays** from the order's `payment` block, as given. EVM: send `calls.approve` (when present), wait
|
|
54
|
+
for it, then `calls.pay`; or sign `permitTypedData` and call `payWithPermit`. Solana: the wallet signs and
|
|
55
|
+
sends `payment.transaction`. The client never computes an amount.
|
|
56
|
+
7. **Wait for `confirmed`.** Poll the order, or listen for `iap.order.confirmed`. Optionally
|
|
57
|
+
`POST /api/v2/iap/orders/{id}/submit` with `{"txHash": …}` to check at once.
|
|
58
|
+
8. **Deliver.** Verify the receipt (`verify-receipts-and-sessions`), then grant; or read the item ledger and
|
|
59
|
+
spend with `consume` (with an idempotency key).
|
|
60
|
+
|
|
61
|
+
On a token page, a Crown App skips steps 5-7: it declares `iap:purchase` and sends
|
|
62
|
+
`onAction({ type: 'iap-purchase', sku, quantity })`; the page runs the sale (see `build-a-crown-app`).
|
|
63
|
+
|
|
64
|
+
A Crown App can also spend the viewer's own consumable with no server: it declares `iap:consume` and sends
|
|
65
|
+
`onAction({ type: 'iap-consume', sku, quantity, key })` from the viewer's tap. It works only for an item whose
|
|
66
|
+
`maxAppSpend` the store's owner set (the most an app may spend per request; null means no app can), and it is
|
|
67
|
+
never proof: a holder can make that spend without the app. Keep `maxAppSpend` null and spend with `consume`
|
|
68
|
+
on your server for anything competitive or redeemed for a prize.
|
|
69
|
+
|
|
70
|
+
## Test store first
|
|
71
|
+
|
|
72
|
+
Available where `GET /api/v2/iap/config` answers `"testMode": true`.
|
|
73
|
+
|
|
74
|
+
1. `iap_open_test_store` with the **live** store id. It copies the live catalog once; after that they are separate.
|
|
75
|
+
2. `iap_create_store_key` on the **test** store id. Its secret starts `cd_iap_test_`.
|
|
76
|
+
3. Create an order with the test key; its `payment` is `{"kind":"test"}`.
|
|
77
|
+
4. `iap_test_pay` (`POST /api/v2/iap/orders/{id}/test-pay`): the order confirms through the real path. No
|
|
78
|
+
chain, no money, no fee.
|
|
79
|
+
5. The receipt and webhooks carry `"livemode": false`. Give the test store its own webhook.
|
|
80
|
+
6. Go live by switching to the live key. Token prizes and claim links don't run on a test store.
|
|
81
|
+
|
|
82
|
+
## Rewards and webhooks
|
|
83
|
+
|
|
84
|
+
- One-off gift: `iap_grant`. A drop: `iap_create_drop` with `mode` `claim` (a link), `push` (a wallet list),
|
|
85
|
+
or `award` (your server awards a wallet with `iap_award`, scope `rewards:award`). Drops start as drafts.
|
|
86
|
+
- Webhook: `iap_set_webhook` with a public `https://` URL; the `whsec_…` secret is shown once. Check
|
|
87
|
+
`X-Webhook-Signature` (hex HMAC-SHA256 of the raw body) before parsing. Answer 2xx within 5 seconds.
|
|
88
|
+
- Reconcile from the event feed (`iap_list_events` with `after` = your last event id). The webhook makes you
|
|
89
|
+
fast; the feed makes you complete.
|
|
90
|
+
- Refunds: the creator sends the money back from their wallet, then records it with `iap_refund_order`
|
|
91
|
+
(`refund_tx_hash`; CLI `--tx`), which revokes the items and receipt. Chain Daddy cannot move a buyer's funds.
|
|
92
|
+
|
|
93
|
+
## Mistakes to avoid
|
|
94
|
+
|
|
95
|
+
- Granting on `paid`, or before verifying the receipt's signature, `storeId` and `livemode`.
|
|
96
|
+
- A store key in the game client. Only the server holds it.
|
|
97
|
+
- Retrying a write with a new idempotency key: it can sell or grant twice. Reuse the same key.
|
|
98
|
+
- Editing the `payment` block, or paying in another currency: the payment is recorded as `order.mismatch`
|
|
99
|
+
and grants nothing.
|
|
100
|
+
- Treating `503 PRICE_UNAVAILABLE` as a failure: the dollar price can't be converted right now; retry in a minute.
|
|
101
|
+
- Items with a random outcome (loot boxes): not allowed. Never call items money or a balance.
|
|
102
|
+
- Sending a payment for the human. They pay from their own wallet.
|
|
103
|
+
|
|
104
|
+
## Docs
|
|
105
|
+
|
|
106
|
+
- https://docs.chaindaddy.io/developer/store-and-rewards (every field, rule, event and limit)
|
|
107
|
+
- https://docs.chaindaddy.io/developer/test-your-integration
|
|
108
|
+
- OpenAPI: https://docs.chaindaddy.io/openapi.yaml, tag **Creator Store**
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sign-in-with-chain-daddy
|
|
3
|
+
description: Add "Sign in with Chain Daddy" to a website, game or app - standard OpenID Connect (authorization code + PKCE, ES256 ID tokens) that tells your server which wallet the visitor controls. Covers registering a client, the stock-library setup, the claims to check, and how it differs from a Crown App session token. Use when someone wants wallet sign-in, "log in with Chain Daddy", or to link a player account to a wallet.
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Sign in with Chain Daddy
|
|
8
|
+
|
|
9
|
+
The visitor goes to chaindaddy.io, approves your site, and comes back with an ID token that says **which
|
|
10
|
+
wallet they control**. It is standard OpenID Connect, so use a stock library (openid-client, Auth.js,
|
|
11
|
+
go-oidc, Authlib, Spring Security, .NET). You get an ID token and nothing else: no refresh token, no access
|
|
12
|
+
to the Chain Daddy API for the user, no email, no balance. Keep your own session afterwards.
|
|
13
|
+
|
|
14
|
+
## Endpoints
|
|
15
|
+
|
|
16
|
+
Use discovery: `https://api.chaindaddy.io/.well-known/openid-configuration`
|
|
17
|
+
|
|
18
|
+
| | |
|
|
19
|
+
|---|---|
|
|
20
|
+
| Issuer | `https://api.chaindaddy.io` |
|
|
21
|
+
| Authorize (consent page) | `https://chaindaddy.io/_oauth/authorize` |
|
|
22
|
+
| Token | `https://api.chaindaddy.io/api/v2/oauth/token` |
|
|
23
|
+
| Keys | `https://api.chaindaddy.io/api/v2/oauth/jwks` |
|
|
24
|
+
|
|
25
|
+
## 1. Register a client (the human does this)
|
|
26
|
+
|
|
27
|
+
Needs Developer Beta Program membership (free to register). Easiest: the developer portal,
|
|
28
|
+
https://chaindaddy.io/_developer → **Sign-in** → **New client**. Or:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
chaindaddy oauth client create --name "My Game" \
|
|
32
|
+
--redirect-uri https://example.com/auth/callback --confidential --crown base:8
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
API: `POST https://api.chaindaddy.io/api/v2/developer/oauth-clients` with the human's `cd_live_…` key.
|
|
36
|
+
|
|
37
|
+
- `redirectUris` match **exactly**: no wildcards. `https` only, except `http://localhost` and
|
|
38
|
+
`http://127.0.0.1` for development.
|
|
39
|
+
- `confidential: true` for a server that keeps a secret (`cd_oauth_…`, shown once - the human stores it).
|
|
40
|
+
A single-page app, game client or native app stays public and uses PKCE with no secret.
|
|
41
|
+
- `name`: up to 40 characters, and may not contain "Chain Daddy", "official", "support" or "wallet".
|
|
42
|
+
- `crown` (optional) earns the consent screen's **verified** mark when the human manages that token page
|
|
43
|
+
and the redirect host is one of the page's verified domains.
|
|
44
|
+
|
|
45
|
+
## 2. Wire the flow
|
|
46
|
+
|
|
47
|
+
Node, with openid-client:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import * as client from 'openid-client';
|
|
51
|
+
|
|
52
|
+
const config = await client.discovery(
|
|
53
|
+
new URL('https://api.chaindaddy.io'), process.env.CD_CLIENT_ID!,
|
|
54
|
+
{ id_token_signed_response_alg: 'ES256' },
|
|
55
|
+
client.ClientSecretPost(process.env.CD_CLIENT_SECRET!), // client.None() for a public client
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// Sign-in: keep verifier, state and nonce in the visitor's session
|
|
59
|
+
const verifier = client.randomPKCECodeVerifier();
|
|
60
|
+
const state = client.randomState();
|
|
61
|
+
const nonce = client.randomNonce();
|
|
62
|
+
const url = client.buildAuthorizationUrl(config, {
|
|
63
|
+
redirect_uri: 'https://example.com/auth/callback', scope: 'openid wallets',
|
|
64
|
+
code_challenge: await client.calculatePKCECodeChallenge(verifier), code_challenge_method: 'S256',
|
|
65
|
+
state, nonce,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Callback
|
|
69
|
+
const tokens = await client.authorizationCodeGrant(config, new URL(req.url, 'https://example.com'), {
|
|
70
|
+
pkceCodeVerifier: verifier, expectedState: state, expectedNonce: nonce,
|
|
71
|
+
});
|
|
72
|
+
const claims = tokens.claims()!;
|
|
73
|
+
if (claims.typ !== 'signin+v1') throw new Error('not a Chain Daddy sign-in');
|
|
74
|
+
const wallet = claims.sub; // start your own session for this wallet
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Auth.js: a provider with `type: 'oidc'`, `issuer: 'https://api.chaindaddy.io'`,
|
|
78
|
+
`checks: ['pkce', 'state', 'nonce']`, `client: { id_token_signed_response_alg: 'ES256' }`, and a `profile()`
|
|
79
|
+
that throws unless `typ === 'signin+v1'`.
|
|
80
|
+
|
|
81
|
+
## Scopes and claims
|
|
82
|
+
|
|
83
|
+
| Scope | Adds |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `openid` (required) | `sub` (the wallet: lowercase hex on EVM, base58 on Solana), `chain` (`evm` or `solana`) |
|
|
86
|
+
| `wallets` | `wallets`: every wallet linked to the account, `sub` first |
|
|
87
|
+
| `profile` | `identity`: the handle they chose to publish |
|
|
88
|
+
|
|
89
|
+
Check on every ID token: signature (ES256, keys above), `iss` = `https://api.chaindaddy.io`, `aud` = your
|
|
90
|
+
client id, `typ` = `signin+v1`, `nonce` = the one you sent, `exp` (5 minutes).
|
|
91
|
+
|
|
92
|
+
## After sign-in
|
|
93
|
+
|
|
94
|
+
Gate on what the wallet owns live, on your server: your store's
|
|
95
|
+
`GET /api/v2/iap/stores/{id}/entitlements?wallet=<sub>` (holder unlocks included). Never put a balance in
|
|
96
|
+
your session: it goes stale in minutes.
|
|
97
|
+
|
|
98
|
+
## Crown Apps use something else
|
|
99
|
+
|
|
100
|
+
Inside a Crown App on a token page, use the `session-token` action (an `app-session+v1` token, EdDSA, keys
|
|
101
|
+
at `/api/v2/apps/jwks`). If your site and your Crown App share a server, verify both kinds and key people by
|
|
102
|
+
`sub`: it is the same wallet string in both. See `verify-receipts-and-sessions`.
|
|
103
|
+
|
|
104
|
+
## Mistakes to avoid
|
|
105
|
+
|
|
106
|
+
- Skipping PKCE (`S256`) or `state`: both are required on every request.
|
|
107
|
+
- Not checking `typ`. A token of another kind must never pass for a sign-in.
|
|
108
|
+
- Expecting a refresh token or API access: there is neither. A returning visitor who approved before signs
|
|
109
|
+
in again without a click.
|
|
110
|
+
- Redirect URIs that differ by a slash or a port: they must match exactly, or chaindaddy.io refuses.
|
|
111
|
+
- Putting the client secret in a browser, game client or repo.
|
|
112
|
+
- Asking the visitor to sign a second login message in their wallet. This is the sign-in.
|
|
113
|
+
|
|
114
|
+
## Docs
|
|
115
|
+
|
|
116
|
+
https://docs.chaindaddy.io/developer/sign-in-with-chain-daddy
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: verify-receipts-and-sessions
|
|
3
|
+
description: Verify what Chain Daddy signs on your own server before acting on it - store purchase receipts (iap-receipt+v1), Crown App session tokens (app-session+v1), Sign in with Chain Daddy ID tokens (signin+v1), and webhook signatures - with the exact keys, claims and checks, plus published test vectors. Use when writing server code that grants items, credits a purchase, trusts a player's wallet, or receives Chain Daddy webhooks.
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Verify receipts, sessions and webhooks
|
|
8
|
+
|
|
9
|
+
Never act on something because a client sent it. Each kind below has its own keys and its own `typ` claim,
|
|
10
|
+
so one can never pass for another - as long as you check `typ`.
|
|
11
|
+
|
|
12
|
+
## Which token is which
|
|
13
|
+
|
|
14
|
+
| You have | `typ` claim | Algorithm | `iss` | Keys (JWKS) | Also check |
|
|
15
|
+
|---|---|---|---|---|---|
|
|
16
|
+
| Store receipt (a confirmed purchase) | `iap-receipt+v1` | EdDSA (Ed25519) | `https://chaindaddy.io` | `https://api.chaindaddy.io/api/v2/iap/jwks` | `storeId` is your store; `livemode` is `true` in production; credit each `orderId` once |
|
|
17
|
+
| Crown App session token (`session-token` action) | `app-session+v1` | EdDSA | `https://chaindaddy.io` | `https://api.chaindaddy.io/api/v2/apps/jwks` | `aud` is your app id (`<developerId>/<name>`); `exp` (5 min, allow 30 s skew); refuse a `jti` you have seen; `idv === true` if you need a verified person |
|
|
18
|
+
| Sign in with Chain Daddy ID token | `signin+v1` | ES256 | `https://api.chaindaddy.io` | `https://api.chaindaddy.io/api/v2/oauth/jwks` | `aud` is your client id; `nonce`; `exp` (5 min) |
|
|
19
|
+
|
|
20
|
+
Notes:
|
|
21
|
+
- The JWS **header** `typ` is a plain `JWT`. What the token is lives in the `typ` **claim**. Check the claim.
|
|
22
|
+
- Pick the key by the header's `kid`. Each set lists the current key first and keeps the previous one, so a
|
|
23
|
+
cached remote key set that refetches on an unknown `kid` survives rotation with no work.
|
|
24
|
+
- `sub` is the wallet in all three: lowercase `0x…` on EVM, base58 on Solana.
|
|
25
|
+
|
|
26
|
+
## TypeScript (jose)
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
30
|
+
|
|
31
|
+
const API = 'https://api.chaindaddy.io';
|
|
32
|
+
const ISSUER = 'https://chaindaddy.io';
|
|
33
|
+
const receiptKeys = createRemoteJWKSet(new URL('/api/v2/iap/jwks', API));
|
|
34
|
+
const sessionKeys = createRemoteJWKSet(new URL('/api/v2/apps/jwks', API));
|
|
35
|
+
|
|
36
|
+
export async function verifyReceipt(jws: string, storeId: string) {
|
|
37
|
+
const { payload } = await jwtVerify(jws, receiptKeys, { algorithms: ['EdDSA'], issuer: ISSUER });
|
|
38
|
+
if (payload.typ !== 'iap-receipt+v1') throw new Error('not a store receipt');
|
|
39
|
+
if (payload.storeId !== storeId) throw new Error('receipt is for another store');
|
|
40
|
+
if (payload.livemode === false && process.env.NODE_ENV === 'production') throw new Error('test receipt');
|
|
41
|
+
return payload; // grant by payload.sku, payload.quantity, payload.wallet; dedupe on payload.orderId
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function verifySession(token: string, appId: string) {
|
|
45
|
+
const { payload } = await jwtVerify(token, sessionKeys, {
|
|
46
|
+
algorithms: ['EdDSA'], issuer: ISSUER, audience: appId, clockTolerance: 30,
|
|
47
|
+
});
|
|
48
|
+
if (payload.typ !== 'app-session+v1') throw new Error('not an app session token');
|
|
49
|
+
if (!payload.exp) throw new Error('no expiry');
|
|
50
|
+
return payload; // payload.sub is the wallet; check payload.idv === true to require a verified person
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
For the ID token, use your OpenID Connect library (see `sign-in-with-chain-daddy`), or `jwtVerify` with the
|
|
55
|
+
oauth key set, `issuer: 'https://api.chaindaddy.io'`, `audience: <client id>`, `algorithms: ['ES256']`, then
|
|
56
|
+
check `typ === 'signin+v1'` and the nonce. Go: `server/cdapi` in https://gitlab.com/opencrown/flux-gp-kit.
|
|
57
|
+
|
|
58
|
+
## Refunds: offline is not enough for everything
|
|
59
|
+
|
|
60
|
+
An offline receipt check can't see a refund made since. For anything that matters after the fact, also check
|
|
61
|
+
online - `POST https://api.chaindaddy.io/api/v2/iap/receipts/verify` with `{"receipt": "…"}` returns
|
|
62
|
+
`valid` or `revoked` - or read the store's item ledger (`GET …/entitlements?wallet=`), which is the source of
|
|
63
|
+
truth for what a wallet owns now.
|
|
64
|
+
|
|
65
|
+
## Webhook signatures
|
|
66
|
+
|
|
67
|
+
| Webhook | Header | How to check |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| Store webhook (`iap.*`) | `X-Webhook-Signature` | Hex HMAC-SHA256 of the **raw body**, keyed with the `whsec_…` secret as given |
|
|
70
|
+
| Crown App runner events | `X-App-Signature` | Hex HMAC-SHA256 of the raw body, keyed with the runner secret **hex-decoded to bytes** |
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
import crypto from 'node:crypto';
|
|
74
|
+
function verifyStoreWebhook(rawBody, signature, secret) {
|
|
75
|
+
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
|
|
76
|
+
return signature?.length === expected.length &&
|
|
77
|
+
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Check against the raw bytes before parsing JSON, compare in constant time, and ignore a repeated event `id`
|
|
82
|
+
(retries reuse it). Answer 2xx within 5 seconds.
|
|
83
|
+
|
|
84
|
+
## Prove your checker with the test vectors
|
|
85
|
+
|
|
86
|
+
https://docs.chaindaddy.io/vectors/tokens.json holds two key sets (`jwks.session`, `jwks.receipts`), a fixed
|
|
87
|
+
`now`, an `appId`, a `storeId` and 15 tokens marked `valid` or not (expired, clock skew, another app, another
|
|
88
|
+
issuer, `typ` only in the header, no expiry, a forgery, a receipt offered as a session and the reverse,
|
|
89
|
+
another store). Load the sets with `createLocalJWKSet`, run every case at `now`, and pass only when your
|
|
90
|
+
code accepts exactly the valid ones. Add this as a unit test.
|
|
91
|
+
|
|
92
|
+
## Mistakes to avoid
|
|
93
|
+
|
|
94
|
+
- Checking the header `typ` instead of the claim, or not checking `typ` at all.
|
|
95
|
+
- Verifying a session token against the receipt keys, or the other way round: they use different key sets.
|
|
96
|
+
- Using `iss: https://api.chaindaddy.io` for receipts and sessions. Only Sign in ID tokens use the `api.` issuer.
|
|
97
|
+
- Accepting a test receipt (`livemode: false`) or another store's receipt in production.
|
|
98
|
+
- Crediting the same `orderId` twice when the webhook and your poller both see it.
|
|
99
|
+
- Parsing the webhook body before checking its signature, or comparing with `===`.
|
|
100
|
+
- Treating a session token as API access: it only proves presence to your server.
|
|
101
|
+
|
|
102
|
+
## Docs
|
|
103
|
+
|
|
104
|
+
- https://docs.chaindaddy.io/developer/verifying-tokens
|
|
105
|
+
- https://docs.chaindaddy.io/developer/store-and-rewards#verifying-receipts-on-your-server
|
|
106
|
+
- https://docs.chaindaddy.io/developer/security#session-tokens
|