@playmos/sdk 0.1.5 → 0.2.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 +87 -0
- package/dist/chunk-B7SHFZYY.js +62 -0
- package/dist/chunk-B7SHFZYY.js.map +1 -0
- package/dist/errors-CjL85YKR.d.cts +502 -0
- package/dist/errors-CjL85YKR.d.ts +502 -0
- package/dist/index.cjs +610 -55
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +321 -250
- package/dist/index.d.ts +321 -250
- package/dist/index.js +603 -113
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +63 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +26 -0
- package/dist/server.d.ts +26 -0
- package/dist/server.js +47 -0
- package/dist/server.js.map +1 -0
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @playmos/sdk
|
|
2
2
|
|
|
3
|
+
**🧪 Beta — Playmos SDK.** Runs on Base Sepolia **testnet**. The value-movement primitives (escrow / marketplace / transfer) are proven on-chain but **not yet production-hardened**, and nothing is on mainnet. Build and integrate freely against the sandbox; don't route real user funds yet.
|
|
4
|
+
|
|
3
5
|
Stablecoin payments for games on Base. One SDK for in-app purchases (1%), skill-game prize-pool entries (10%, 60/30/10), and agent economies. USD in, USDC on-chain — no crypto UX for your players.
|
|
4
6
|
|
|
5
7
|
## Install
|
|
@@ -8,6 +10,15 @@ Stablecoin payments for games on Base. One SDK for in-app purchases (1%), skill-
|
|
|
8
10
|
npm i @playmos/sdk
|
|
9
11
|
```
|
|
10
12
|
|
|
13
|
+
Requires **Node 18 / 20 / 22 LTS** for the monorepo toolchains (Hardhat/service). See repo `docs/TOOLCHAIN.md` (issue #5).
|
|
14
|
+
|
|
15
|
+
## Browser vs server entry
|
|
16
|
+
|
|
17
|
+
- **Games (browser / Vite / Phaser):** `import { Playmos } from "@playmos/sdk"` — this entry is browser-safe and does **not** import Node `crypto` (issue #9).
|
|
18
|
+
- **Your backend (webhook receivers):** `import { verifyWebhook } from "@playmos/sdk/server"` — HMAC verification uses Node crypto and must stay off the client bundle.
|
|
19
|
+
|
|
20
|
+
The sandbox API also sends CORS headers so browser `pay()` / `enterRound()` work without a same-origin proxy (issue #10).
|
|
21
|
+
|
|
11
22
|
## Quickstart — the no-wallet sandbox
|
|
12
23
|
|
|
13
24
|
The public sandbox key `pk_test_playmos_sandbox` runs against a real Playmos test service on Base Sepolia. **You don't need a wallet:** on a `pk_test` key with no `wallet` configured, the SDK routes to a server-settle path where Playmos signs and submits the on-chain transaction for you. You still get a real, confirmed `txHash` — just signed by the service. No wallet, no gas, no crypto.
|
|
@@ -45,6 +56,59 @@ const entry = await playmos.enterRound({
|
|
|
45
56
|
// entry.status === "confirmed"; entry.split is the on-chain 60/30/10 breakdown
|
|
46
57
|
```
|
|
47
58
|
|
|
59
|
+
### Move USDC between wallets — `transfer()`
|
|
60
|
+
|
|
61
|
+
The base value-movement primitive: move USDC from one wallet to another with a configurable per-call fee. The **game logic is the authority** — you already decided the move is valid — so this is a direct, unconditional push (reach for `escrow`/`marketplace` when a trust boundary needs fair exchange). Settled through the on-chain `PlaymosTransfer` contract; the split is verifiable on BaseScan.
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// NPC→NPC with a 5% fee (Playmos Town P2P). Requires sk_test_ + agents.createWallet first.
|
|
65
|
+
// Full walkthrough: docs/examples/playmos-town-sdk.mjs
|
|
66
|
+
// Confirm: status === "settled" && txHash, or poll playmos.transfers.get(id) if settling.
|
|
67
|
+
const t = await playmos.transfer({
|
|
68
|
+
// `from` is OPTIONAL in the sandbox — omit it and the service uses its server-held signer wallet
|
|
69
|
+
// (Phase 1a's only payer), so you don't need to know that address to run this example.
|
|
70
|
+
to: "0x…blacksmith",
|
|
71
|
+
amount: "0.80", // USD; USDC settles on-chain. Stay UNDER the $1.00 sandbox cap (see below).
|
|
72
|
+
feeBps: 500, // 5% — 0–10000
|
|
73
|
+
feeSink: "0x…treasury", // required when feeBps > 0
|
|
74
|
+
memo: "ore", // optional, echoed on the receipt
|
|
75
|
+
});
|
|
76
|
+
t.status; // "settled" — real, on Base Sepolia
|
|
77
|
+
t.txHash; // 0x… open on sepolia.basescan.org
|
|
78
|
+
t.net; // "0.76" (payee)
|
|
79
|
+
t.fee; // "0.04" (feeSink)
|
|
80
|
+
t.idempotentReplay; // false on the first settle
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**`feeBps: 0` is an untaxed reward/faucet transfer** (no fee taken; `feeSink` not needed) — e.g. a monster-bounty payout from a world wallet to a fighter. Set `feeBps: 10000` for a 100% first-party sale (all of it to `feeSink`).
|
|
84
|
+
|
|
85
|
+
| Field | Required | Notes |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `from` | no | Payer wallet — a `0x` address. **Omit in the sandbox** and the service defaults it to its server-held signer wallet (Phase 1a's only payer). If supplied, it must be that wallet. |
|
|
88
|
+
| `to` | yes | Payee wallet — a `0x` address. |
|
|
89
|
+
| `amount` | yes | USD decimal string ("0.80"); > 0, ≤ 2 dp. **Sandbox cap: ≤ $1.00 per transfer** (see limits below). |
|
|
90
|
+
| `feeBps` | no (default `0`) | Per-call fee in bps, **0–10000**. `0` = untaxed reward/faucet. |
|
|
91
|
+
| `feeSink` | when `feeBps > 0` | Fee destination `0x` address. |
|
|
92
|
+
| `memo` | no | Optional note, echoed on the receipt. |
|
|
93
|
+
| `idempotencyKey` | no | Pass the same key on a retry to guarantee **at most one** on-chain transfer (see below). |
|
|
94
|
+
|
|
95
|
+
**Sandbox limits (enforced before any signing).** The public sandbox signs from a shared funded testnet wallet, so the transfer path is guarded: **$1.00 max per transfer** (a larger `amount` is a `400`), **5 requests/minute** per IP and per key (`429`), and a **$20/day budget** across all sandbox transfers (`429`). These are testnet abuse controls — not product limits.
|
|
96
|
+
|
|
97
|
+
**Retries are safe (idempotent, no double-spend).** Every transfer is anchored on a requirement id derived from `idempotencyKey`. The service durably reserves the transfer *before* broadcasting, so a retry with the same key — or two concurrent calls with the same key — **broadcast at most one transaction**; the loser returns the original result with `idempotentReplay: true`. Preview the split with no network via `previewTransferSplit(amount, feeBps)`. Reusing an id with **different** terms (payTo/amount/fee/sink/from) is rejected, not silently replayed.
|
|
98
|
+
|
|
99
|
+
**REST (no SDK).** The SDK's `transfer()` is a thin wrapper over `POST /v1/transfers`:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
curl -X POST https://<sandbox-host>/v1/transfers \
|
|
103
|
+
-H "Authorization: Bearer pk_test_playmos_sandbox" \
|
|
104
|
+
-H "Content-Type: application/json" \
|
|
105
|
+
-d '{ "to": "0x…blacksmith", "amount": "0.80", "feeBps": 500, "feeSink": "0x…treasury", "memo": "ore", "idempotencyKey": "ore-trade-1" }'
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Fields match the table above (`from` optional → defaults to the signer; `feeBps`/`feeSink` as documented). Poll `GET /v1/transfers/:id` to reconcile a transfer's status against the chain (it recovers a transfer left mid-flight by a crash).
|
|
109
|
+
|
|
110
|
+
> Sandbox only (Phase 1a): `transfer` settles server-held NPC/agent wallets on Base Sepolia. The player-signed (EIP-3009) non-custodial path is Phase 1b.
|
|
111
|
+
|
|
48
112
|
### Verify before you grant
|
|
49
113
|
|
|
50
114
|
Grant items on a verified confirmation — never on the client `pay()` return alone.
|
|
@@ -81,6 +145,29 @@ const playmos = new Playmos({
|
|
|
81
145
|
|
|
82
146
|
Promote by swapping the key — the same code that passed in the sandbox works live.
|
|
83
147
|
|
|
148
|
+
## Value movement — escrow, marketplace, transfer (0.2.0, testnet)
|
|
149
|
+
|
|
150
|
+
`0.2.0` adds non-custodial USDC value-movement primitives on top of the settlement core:
|
|
151
|
+
|
|
152
|
+
- **`playmos.transfer(...)`** — a direct USDC transfer.
|
|
153
|
+
- **`playmos.escrow.hold / release / refund / get`** — hold funds against a deal, then pay the payee (fee skimmed at release) or refund the payer (100%). Every term is frozen on-chain at hold.
|
|
154
|
+
- **`playmos.marketplace.list / buy / confirm / refund / cancel / get`** — a listing packaged as an escrow deal (`buy` = hold, `confirm` = release, timeout/dispute = refund), including `buy({ deliver: true })` to hold+release in one call.
|
|
155
|
+
|
|
156
|
+
**Status quo (2026-07): testnet-sandbox only.** These paths are proven on Base Sepolia (independently reconciled on-chain) but are **not** marketed as production-hardened, and are not on mainnet. For money preflights, **point the service at a dedicated RPC** (Alchemy/QuickNode) — do not rely on a load-balanced public endpoint for read-your-write consistency.
|
|
157
|
+
|
|
158
|
+
### Resolve status-code contract
|
|
159
|
+
|
|
160
|
+
The escrow/marketplace resolve endpoints (release/refund/confirm) return a deterministic contract — handle it explicitly:
|
|
161
|
+
|
|
162
|
+
| Code | Meaning | Client action |
|
|
163
|
+
|------|---------|---------------|
|
|
164
|
+
| **404** | Unknown escrow / listing | Terminal — the id does not exist |
|
|
165
|
+
| **409** | Not resolvable (already released/refunded, or genuinely never held) | Terminal — do not retry |
|
|
166
|
+
| **503** | Transient (RPC read-lag, or a crash-orphaned hold being reconciled) | **Retry** with backoff |
|
|
167
|
+
| **400** | Malformed input | Terminal — fix the request |
|
|
168
|
+
|
|
169
|
+
**On a `503`, retry the release/confirm** — it means the on-chain state just hasn't propagated yet, not that anything failed.
|
|
170
|
+
|
|
84
171
|
## Docs
|
|
85
172
|
|
|
86
173
|
Full documentation — skill games, webhooks, gas, payouts, agent economies, and the REST API — at [playmos.io](https://playmos.io).
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var PlaymosError = class extends Error {
|
|
3
|
+
constructor(code, message, detail) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = new.target.name;
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.detail = detail;
|
|
8
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
var InvalidAmountError = class extends PlaymosError {
|
|
12
|
+
constructor(amount) {
|
|
13
|
+
super(
|
|
14
|
+
"invalid_amount",
|
|
15
|
+
`Invalid amount: ${JSON.stringify(amount)}. Provide a positive USD decimal string with at most 2 decimals, e.g. "4.99".`,
|
|
16
|
+
{ amount }
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
var MissingFieldError = class extends PlaymosError {
|
|
21
|
+
constructor(field) {
|
|
22
|
+
super("missing_field", `Missing required field: "${field}".`, { field });
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
var InsufficientGasError = class extends PlaymosError {
|
|
26
|
+
constructor(detail) {
|
|
27
|
+
super(
|
|
28
|
+
"insufficient_gas",
|
|
29
|
+
`The player's wallet has too little ETH to pay gas. Ask them to add a little ETH, or switch to gas.mode: "sponsored".`,
|
|
30
|
+
detail
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
var WalletConnectionError = class extends PlaymosError {
|
|
35
|
+
constructor(message = "Could not connect the player's wallet.", detail) {
|
|
36
|
+
super("wallet_connection", message, detail);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
var PaymentFailedError = class extends PlaymosError {
|
|
40
|
+
constructor(message = "The on-chain payment did not complete.", detail) {
|
|
41
|
+
super("payment_failed", message, detail);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
var AuthError = class extends PlaymosError {
|
|
45
|
+
constructor(message = "Invalid or missing API key.", detail) {
|
|
46
|
+
super("auth", message, detail);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
var ApiError = class extends PlaymosError {
|
|
50
|
+
constructor(message, detail) {
|
|
51
|
+
super("api_error", message, detail);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
var ConfigError = class extends PlaymosError {
|
|
55
|
+
constructor(message, detail) {
|
|
56
|
+
super("config", message, detail);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export { ApiError, AuthError, ConfigError, InsufficientGasError, InvalidAmountError, MissingFieldError, PaymentFailedError, PlaymosError, WalletConnectionError };
|
|
61
|
+
//# sourceMappingURL=chunk-B7SHFZYY.js.map
|
|
62
|
+
//# sourceMappingURL=chunk-B7SHFZYY.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/errors.ts"],"names":[],"mappings":";AAkBO,IAAM,YAAA,GAAN,cAA2B,KAAA,CAAM;AAAA,EAKtC,WAAA,CAAY,IAAA,EAAwB,OAAA,EAAiB,MAAA,EAAkC;AACrF,IAAA,KAAA,CAAM,OAAO,CAAA;AACb,IAAA,IAAA,CAAK,OAAO,GAAA,CAAA,MAAA,CAAW,IAAA;AACvB,IAAA,IAAA,CAAK,IAAA,GAAO,IAAA;AACZ,IAAA,IAAA,CAAK,MAAA,GAAS,MAAA;AAEd,IAAA,MAAA,CAAO,cAAA,CAAe,IAAA,EAAM,GAAA,CAAA,MAAA,CAAW,SAAS,CAAA;AAAA,EAClD;AACF;AAGO,IAAM,kBAAA,GAAN,cAAiC,YAAA,CAAa;AAAA,EACnD,YAAY,MAAA,EAAiB;AAC3B,IAAA,KAAA;AAAA,MACE,gBAAA;AAAA,MACA,CAAA,gBAAA,EAAmB,IAAA,CAAK,SAAA,CAAU,MAAM,CAAC,CAAA,6EAAA,CAAA;AAAA,MACzC,EAAE,MAAA;AAAO,KACX;AAAA,EACF;AACF;AAGO,IAAM,iBAAA,GAAN,cAAgC,YAAA,CAAa;AAAA,EAClD,YAAY,KAAA,EAAe;AACzB,IAAA,KAAA,CAAM,iBAAiB,CAAA,yBAAA,EAA4B,KAAK,CAAA,EAAA,CAAA,EAAM,EAAE,OAAO,CAAA;AAAA,EACzE;AACF;AAGO,IAAM,oBAAA,GAAN,cAAmC,YAAA,CAAa;AAAA,EACrD,YAAY,MAAA,EAAkC;AAC5C,IAAA,KAAA;AAAA,MACE,kBAAA;AAAA,MACA,CAAA,oHAAA,CAAA;AAAA,MACA;AAAA,KACF;AAAA,EACF;AACF;AAGO,IAAM,qBAAA,GAAN,cAAoC,YAAA,CAAa;AAAA,EACtD,WAAA,CAAY,OAAA,GAAU,wCAAA,EAA0C,MAAA,EAAkC;AAChG,IAAA,KAAA,CAAM,mBAAA,EAAqB,SAAS,MAAM,CAAA;AAAA,EAC5C;AACF;AAGO,IAAM,kBAAA,GAAN,cAAiC,YAAA,CAAa;AAAA,EACnD,WAAA,CAAY,OAAA,GAAU,wCAAA,EAA0C,MAAA,EAAkC;AAChG,IAAA,KAAA,CAAM,gBAAA,EAAkB,SAAS,MAAM,CAAA;AAAA,EACzC;AACF;AAGO,IAAM,SAAA,GAAN,cAAwB,YAAA,CAAa;AAAA,EAC1C,WAAA,CAAY,OAAA,GAAU,6BAAA,EAA+B,MAAA,EAAkC;AACrF,IAAA,KAAA,CAAM,MAAA,EAAQ,SAAS,MAAM,CAAA;AAAA,EAC/B;AACF;AAGO,IAAM,QAAA,GAAN,cAAuB,YAAA,CAAa;AAAA,EACzC,WAAA,CAAY,SAAiB,MAAA,EAAkC;AAC7D,IAAA,KAAA,CAAM,WAAA,EAAa,SAAS,MAAM,CAAA;AAAA,EACpC;AACF;AAGO,IAAM,WAAA,GAAN,cAA0B,YAAA,CAAa;AAAA,EAC5C,WAAA,CAAY,SAAiB,MAAA,EAAkC;AAC7D,IAAA,KAAA,CAAM,QAAA,EAAU,SAAS,MAAM,CAAA;AAAA,EACjC;AACF","file":"chunk-B7SHFZYY.js","sourcesContent":["/**\n * Typed, actionable errors — the Stripe bar (spec §11).\n *\n * Every error carries a stable machine-readable `code` and is thrown at the\n * EARLIEST possible layer: input errors fire client-side before any network or\n * chain call, so a studio never pays gas to discover a typo.\n */\n\nexport type PlaymosErrorCode =\n | \"invalid_amount\"\n | \"missing_field\"\n | \"insufficient_gas\"\n | \"wallet_connection\"\n | \"payment_failed\"\n | \"auth\"\n | \"api_error\"\n | \"config\";\n\nexport class PlaymosError extends Error {\n readonly code: PlaymosErrorCode;\n /** Optional machine context (e.g. the offending field, the http status). */\n readonly detail?: Record<string, unknown>;\n\n constructor(code: PlaymosErrorCode, message: string, detail?: Record<string, unknown>) {\n super(message);\n this.name = new.target.name;\n this.code = code;\n this.detail = detail;\n // Restore prototype chain for `instanceof` across transpile targets.\n Object.setPrototypeOf(this, new.target.prototype);\n }\n}\n\n/** amount ≤ 0, non-numeric, empty, or more than 2 decimal places. */\nexport class InvalidAmountError extends PlaymosError {\n constructor(amount: unknown) {\n super(\n \"invalid_amount\",\n `Invalid amount: ${JSON.stringify(amount)}. Provide a positive USD decimal string with at most 2 decimals, e.g. \"4.99\".`,\n { amount },\n );\n }\n}\n\n/** A required field (sku, playerId, gameId, roundId, agentId…) was empty. */\nexport class MissingFieldError extends PlaymosError {\n constructor(field: string) {\n super(\"missing_field\", `Missing required field: \"${field}\".`, { field });\n }\n}\n\n/** gas mode \"player\" and the player's ETH is too low to cover gas (pre-check, §7). */\nexport class InsufficientGasError extends PlaymosError {\n constructor(detail?: Record<string, unknown>) {\n super(\n \"insufficient_gas\",\n \"The player's wallet has too little ETH to pay gas. Ask them to add a little ETH, or switch to gas.mode: \\\"sponsored\\\".\",\n detail,\n );\n }\n}\n\n/** The player closed or failed the wallet sheet, or no provider is available. */\nexport class WalletConnectionError extends PlaymosError {\n constructor(message = \"Could not connect the player's wallet.\", detail?: Record<string, unknown>) {\n super(\"wallet_connection\", message, detail);\n }\n}\n\n/** The on-chain settlement reverted, was cancelled, or timed out. */\nexport class PaymentFailedError extends PlaymosError {\n constructor(message = \"The on-chain payment did not complete.\", detail?: Record<string, unknown>) {\n super(\"payment_failed\", message, detail);\n }\n}\n\n/** Bad, missing, or wrong-environment API key (e.g. a pk_test_ key on a live route). */\nexport class AuthError extends PlaymosError {\n constructor(message = \"Invalid or missing API key.\", detail?: Record<string, unknown>) {\n super(\"auth\", message, detail);\n }\n}\n\n/** The Playmos service returned a non-2xx we don't have a more specific error for. */\nexport class ApiError extends PlaymosError {\n constructor(message: string, detail?: Record<string, unknown>) {\n super(\"api_error\", message, detail);\n }\n}\n\n/** SDK misconfiguration (e.g. a missing contract address for on-chain mode). */\nexport class ConfigError extends PlaymosError {\n constructor(message: string, detail?: Record<string, unknown>) {\n super(\"config\", message, detail);\n }\n}\n"]}
|