@playmos/sdk
v0.3.3
Published
Playmos SDK — stablecoin payments for games on Base. One SDK for IAP (1%), skill-game prize-pool entries (10%, 60/30/10), and agent economies. USD in, USDC on-chain, no crypto UX for players.
Maintainers
Readme
@playmos/sdk
🧪 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.
Stablecoin payments for games on Base. One SDK for in-app purchases (1%), skill-game prize-pool entries (10%, 60/30/10), and in-game economies (player · NPC · agent commerce via transfer()). USD in, USDC on-chain — no crypto UX for your players.
Install
npm i @playmos/sdkRequires Node 18 / 20 / 22 LTS for the monorepo toolchains (Hardhat/service). See repo docs/TOOLCHAIN.md (issue #5).
Browser vs server entry
- Games (browser / Vite / Phaser):
import { Playmos } from "@playmos/sdk"— this entry is browser-safe and does not import Nodecrypto(issue #9). - Your backend (webhook receivers):
import { verifyWebhook } from "@playmos/sdk/server"— HMAC verification uses Node crypto and must stay off the client bundle.
The sandbox API also sends CORS headers so browser pay() / enterRound() work without a same-origin proxy (issue #10).
Quickstart — the no-wallet sandbox
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.
The key comes wired to two demo games: game_sandbox_iap (IAP) and game_sandbox_skill (prize pool).
In-app purchase — pay()
import { Playmos } from "@playmos/sdk";
// Public sandbox key — client-safe, like Stripe's pk_test_.
// No wallet needed: Playmos server-settles the test payment for you.
const playmos = new Playmos({ apiKey: "pk_test_playmos_sandbox" });
const payment = await playmos.pay({
gameId: "game_sandbox_iap", // public sandbox IAP game — include it (this key spans IAP + skill)
amount: "0.99", // USD string — sandbox server-settle cap $1.00/request
sku: "gems_500", // your product id
playerId: "player_abc", // your opaque user id
});
payment.id; // "pay_…" — real, server-issued (ULID)
payment.status; // "confirmed" — real, on Base Sepolia
payment.txHash; // 0x… — open on sepolia.basescan.org/tx/{txHash}Verify before you grant
Grant items on a verified confirmation — never on the client pay() return alone.
import { Playmos } from "@playmos/sdk";
// Your server — the secret key lives here, never in a client bundle.
const server = new Playmos({ apiKey: process.env.PLAYMOS_SECRET! }); // sk_test_…
const result = await server.verify(payment.id); // on-chain read — idempotent, safe to retry
// Terminal success is exactly "confirmed" (IAP + entries) — not "settled" / "succeeded".
if (result.status === "confirmed") {
grantItem(result.playerId, result.sku);
}Skill-game entry — enterRound()
const entry = await playmos.enterRound({
gameId: "game_sandbox_skill",
roundId: "5m-5944009",
amount: "1.00", // grows this round's pool
playerId: "player_abc",
});
// entry.status === "confirmed"; entry.split is the on-chain 60/30/10 breakdownMove USDC between wallets — transfer()
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.
// NPC→NPC with a 5% fee (Playmos Town P2P). Requires sk_test_ + agents.createWallet first.
// Full walkthrough: see "In-game economies — the Playmos Town golden path" below.
// Confirm: status === "settled" && txHash, or await playmos.transfers.wait(id) if settling.
const t = await playmos.transfer({
// `from` is OPTIONAL in the sandbox — omit it and the service uses its server-held signer wallet
// (Phase 1a's only payer), so you don't need to know that address to run this example.
to: "0x…blacksmith",
amount: "0.80", // USD; USDC settles on-chain. Stay UNDER the $1.00 sandbox cap (see below).
feeBps: 500, // 5% — 0–10000
feeSink: "0x…treasury", // required when feeBps > 0
memo: "ore", // optional, echoed on the receipt
});
t.status; // "settled" — real, on Base Sepolia
t.txHash; // 0x… open on sepolia.basescan.org
t.net; // "0.76" (payee)
t.fee; // "0.04" (feeSink)
t.idempotentReplay; // false on the first settlefeeBps: 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).
| Field | Required | Notes |
|---|---|---|
| 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. |
| to | yes | Payee wallet — a 0x address. |
| amount | yes | USD decimal string ("0.80"); > 0, ≤ 2 dp. Sandbox cap: ≤ $1.00 per transfer (see limits below). |
| feeBps | no (default 0) | Per-call fee in bps, 0–10000. 0 = untaxed reward/faucet. |
| feeSink | when feeBps > 0 | Fee destination 0x address. |
| memo | no | Optional note, echoed on the receipt. |
| idempotencyKey | no | Pass the same key on a retry to guarantee at most one on-chain transfer (see below). |
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.
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.
REST (no SDK). The SDK's transfer() is a thin wrapper over POST /v1/transfers:
curl -X POST https://<sandbox-host>/v1/transfers \
-H "Authorization: Bearer pk_test_playmos_sandbox" \
-H "Content-Type: application/json" \
-d '{ "to": "0x…blacksmith", "amount": "0.80", "feeBps": 500, "feeSink": "0x…treasury", "memo": "ore", "idempotencyKey": "ore-trade-1" }'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).
Sandbox only (Phase 1a):
transfersettles server-held NPC/agent wallets on Base Sepolia. The player-signed (EIP-3009) non-custodial path is Phase 1b.
In-game economies — the Playmos Town golden path
In-game economies = commerce between players, NPCs, and agents (AI or scripted). The money primitive is transfer() (entity-agnostic); NPC/agent wallets use agents.* (API names unchanged). This is the loop the Playmos Town example runs end to end: give each NPC a wallet, fund it, then move value with a per-call fee — P2P (5%), a monster bounty (0%), a shop sale (100%). Proven on Base Sepolia (transfer layer) — not a claim that a full MMO product is shipped.
Agents need a SECRET test key — sk_test_, not pk_test_. Assigning NPC wallets and transferring from an NPC are privileged, server-side actions, so they require an sk_test_ key kept on your backend — not the public pk_test_playmos_sandbox. pk_test_ runs the no-wallet pay() / enterRound() demos above; sk_test_ unlocks agents.* and NPC-funded transfer.
import { Playmos } from "@playmos/sdk";
// Server-side only — an sk_test_ secret key, never shipped to the browser.
const playmos = new Playmos({ apiKey: process.env.PLAYMOS_SK_TEST });
const feeSink = process.env.PLAYMOS_FEE_SINK; // 0x treasury / shop wallet
// 1) Assign a wallet to an NPC your game already owns (idempotent — safe to re-call).
const miner = await playmos.agents.createWallet({ agentId: "npc_pico_miner" });
miner.address; // 0x… — the NPC's USDC wallet on Base Sepolia
// 2) Fund it from your treasury (sandbox faucet, per-NPC lifetime cap).
const funded = await playmos.agents.fund({ agentId: "npc_pico_miner", amount: "0.20" });
funded.funding.status; // "settled" | "settling"
// 3a) P2P trade — 5% fee to the treasury (feeBps: 500). NPC `from` ⇒ gasless (NPC signs, Playmos relays + pays gas).
const p2p = await playmos.transfer({
from: "npc_pico_miner",
to: "npc_bruna_blacksmith",
amount: "0.10",
feeBps: 500, // 5% P2P
feeSink, // required when feeBps > 0
});
// Confirm with the new ergonomics (#54): wait for a terminal state — no hand-rolled poll loop.
const settled = await playmos.transfers.wait(p2p.id);
settled.status; // "settled" — txHash on sepolia.basescan.org
// 3b) Monster-bounty payout — 0% untaxed reward from the treasury signer (omit `from`, no feeSink).
await playmos.transfer({ to: "npc_wynn_ranger", amount: "0.05", feeBps: 0 });
// 3c) First-party shop sale — 100% to the shop (feeBps: 10000).
await playmos.transfer({ from: "npc_kane_knight", to: feeSink, amount: "0.05", feeBps: 10_000, feeSink });Prefer one call? Confirm inline. Pass { confirm: true } and transfer blocks until the transfer is terminal before it resolves — identical to calling transfers.wait(id) yourself (#54):
const t = await playmos.transfer(
{ from: "npc_pico_miner", to: "npc_bruna_blacksmith", amount: "0.10", feeBps: 500, feeSink },
{ confirm: true },
);
t.status; // "settled" (or "failed") — already terminal, no follow-up callThe sandbox throttles (5 req/min per key), so back-to-back legs can 429. Retries are automatic — the SDK backs off and retries a 429 up to twice by default, and every write carries an idempotency key so a retry never double-broadcasts (tune with new Playmos({ apiKey, retry: { maxRetries } }), or retry: false to opt out).
The full 5-NPC walkthrough — create → fund → P2P / shop / bounty, each confirmed on-chain — is docs/examples/playmos-town-sdk.mjs:
PLAYMOS_SK_TEST=sk_test_… PLAYMOS_FEE_SINK=0x… node docs/examples/playmos-town-sdk.mjsMock mode — offline, deterministic
For CI and wiring checks, mock: true returns instant, deterministic results with no network and no chain. Results carry mock: true and use the real status union, so your handling code sees the exact production shape.
const playmos = new Playmos({ apiKey: "pk_test_playmos_sandbox", mock: true });
const payment = await playmos.pay({
gameId: "game_sandbox_iap", sku: "gems_100", amount: "0.99", playerId: "player_abc",
});
// instant — payment.status === "confirmed", payment.mock === trueMock mode is not test mode: mock is fabricated and offline; the sandbox is real and settles on Base Sepolia.
Production — real player wallets
Outside the sandbox (a pk_live key), or whenever you want the player to sign their own on-chain transaction, configure a wallet connector. With a wallet present the SDK uses the client-signed path instead of server-settle.
const playmos = new Playmos({
apiKey: "pk_live_…",
wallet: { connector: "base-account" }, // or "injected" in a wallet browser
});Promote by swapping the key — the same code that passed in the sandbox works live.
Value movement — escrow, marketplace, transfer (0.2.0, testnet)
0.2.0 adds non-custodial USDC value-movement primitives on top of the settlement core:
playmos.transfer(...)— a direct USDC transfer.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.playmos.marketplace.list / buy / confirm / refund / cancel / get— a listing packaged as an escrow deal (buy= hold,confirm= release, timeout/dispute = refund), includingbuy({ deliver: true })to hold+release in one call.
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.
Resolve status-code contract
The escrow/marketplace resolve endpoints (release/refund/confirm) return a deterministic contract — handle it explicitly:
| Code | Meaning | Client action | |------|---------|---------------| | 404 | Unknown escrow / listing | Terminal — the id does not exist | | 409 | Not resolvable (already released/refunded, or genuinely never held) | Terminal — do not retry | | 503 | Transient (RPC read-lag, or a crash-orphaned hold being reconciled) | Retry with backoff | | 400 | Malformed input | Terminal — fix the request |
On a 503, retry the release/confirm — it means the on-chain state just hasn't propagated yet, not that anything failed.
Docs
Full documentation — skill games, webhooks, gas, payouts, in-game economies, and the REST API — at playmos.io.
