poly-rewards-shared
v0.4.0
Published
Client SDK for the Polymarket rewards engine. Ships:
Readme
poly-rewards-shared
Client SDK for the Polymarket rewards engine. Ships:
- Four factory helpers:
createClient,createPullLoop,createWebhookReceiver,registerWebhook. - Two HMAC utilities:
signBody,verifySignature. - Typed errors:
RewardsErrorbase +UnauthorizedError,ForbiddenError,NotFoundError,ValidationError,RateLimitedError,NetworkError,HmacVerifyError. - Zod schemas: request/response shapes (
./api) and theEventEnvelopediscriminated union (./events).
For an end-to-end integration that wires every helper together, read
packages/smoke-harness/src/index.ts. Bot
developers are expected to copy that file and adapt the onEvent callbacks
for their channel — see the smoke harness README.
Install
Published on npm. External consumers install it like any other package:
npm install poly-rewards-shared
# or
bun add poly-rewards-sharedWorkspaces inside this monorepo continue to reference it via workspace:*:
{
"dependencies": {
"poly-rewards-shared": "workspace:*"
}
}Then import from the barrel or any subpath:
import { createClient, createPullLoop } from 'poly-rewards-shared';
import { signBody } from 'poly-rewards-shared/hmac';createClient
Builds a RewardsClient that wraps every read/manage route. Each method
adds Authorization: Bearer <apiKey>, applies defaultTimeoutMs, and
validates responses against the Zod schemas in ./api. Status codes map to
typed errors: 401 → UnauthorizedError, 403 → ForbiddenError, 404 →
NotFoundError, 400/422 → ValidationError, 429 → RateLimitedError (with
retryAfter from the Retry-After header), 5xx / network → NetworkError.
import { createClient } from 'poly-rewards-shared';
const client = createClient({
baseUrl: 'http://localhost:3000',
apiKey: process.env.REWARDS_API_KEY!,
defaultTimeoutMs: 10_000,
});
const health = await client.healthz();
const markets = await client.markets.list({ limit: 50 });Sub-namespaces: markets, recommendations, yolo, alerts, events,
webhooks, meta, plus top-level healthz(). healthz and meta.* are
the only no-auth methods.
defaultHeaders
Pass defaultHeaders: Record<string, string> to attach static headers to
every outbound request made by this client — and by anything that operates
on it (createPullLoop, registerWebhook). The primary use case is
reaching an engine instance protected by a header-based gateway such as a
Cloudflare Access service-token policy:
import { createClient } from 'poly-rewards-shared';
const client = createClient({
baseUrl: 'https://rewards-engine.titus.finance',
apiKey: process.env.REWARDS_API_KEY!,
defaultHeaders: {
'CF-Access-Client-Id': process.env.CF_ACCESS_CLIENT_ID!,
'CF-Access-Client-Secret': process.env.CF_ACCESS_CLIENT_SECRET!,
},
});SDK-set headers win — putting Authorization here is a no-op; use the fetchImpl escape hatch (an option on createClient that lets you wrap the underlying fetch call) if you need to override Bearer.
createPullLoop
In-memory cursor; the bot owns persistence via onCursorAdvance. Use the
hook to write the cursor to your bot's store on every advance. On crash,
reload the cursor and pass it as initialCursor on next boot — or start at
0 and dedupe on event.id (event IDs are stable).
import { createPullLoop } from 'poly-rewards-shared';
const loop = createPullLoop({
client,
initialCursor: 0, // or load from your bot's store
pollIntervalMs: 5_000,
batchSize: 200,
onEvent: async (event) => {
if (event.event_type === 'market.new') {
// typed payload narrowed by event_type
console.log('new market', event.condition_id);
}
},
onCursorAdvance: async (cursor) => {
// persist `cursor` to your bot's durable store here
},
onError: (err) => {
console.warn('pull-loop error', err);
},
});
loop.start();
// later — for tests: await loop.pollOnce();
// shutdown: await loop.stop();If onEvent throws, the whole batch is retried with capped backoff (1s/5s/30s).
After the 4th failure, onError is called once and the cursor stays put — bots
that also receive the same events via push will see them again, OR can rewind
manually.
createWebhookReceiver
Returns { fetch } you can hand straight to Bun.serve. The receiver:
- Rejects non-POST methods with 405.
- Verifies HMAC on the raw request bytes (so do not consume
req.bodybefore delegating; the smoke harness's wrapper sniffs headers only). - Replay-protects via
toleranceMs(default 5 minutes). - Validates payloads against
EventSchema. - Auto-handles
system.ping: responds 200 with the challenge and bypassesonEvent. Bots don't need to handle ping themselves.
import { createWebhookReceiver } from 'poly-rewards-shared';
const receiver = createWebhookReceiver({
secret: webhookSecret, // returned from registerWebhook(...)
onEvent: async (event) => {
// type-narrow via event.event_type
if (event.event_type === 'rewards.config_changed') {
// ...
}
},
onError: (err) => { /* HmacVerifyError | ValidationError | thrown errors */ },
});
Bun.serve({ port: 8080, fetch: receiver.fetch });registerWebhook
Thin composite over client.webhooks.create. Engine generates the secret,
sends a signed system.ping to the URL, and verifies via the challenge echo.
import { registerWebhook } from 'poly-rewards-shared';
const { id, secret, verified } = await registerWebhook(client, {
url: 'https://my-bot.example.com/webhooks/rewards',
events: ['*'], // or specific event types
channel: 'my-bot',
});
// `secret` is shown ONCE here — store it in your bot's secret store. Use it
// to construct createWebhookReceiver({ secret }).signBody
Returns the engine's signature format t=<timestampMs>,v1=<hex>.
import { signBody } from 'poly-rewards-shared/hmac';
const signature = signBody({
secret: 'your-shared-secret',
timestampMs: Date.now(),
body: JSON.stringify(payload),
});
// signature === 't=1715354400000,v1=2f5a...e1'verifySignature
Returns a discriminated { ok: true } | { ok: false; reason } where
reason is one of 'malformed' | 'expired' | 'mismatch'.
import { verifySignature } from 'poly-rewards-shared/hmac';
const result = verifySignature({
secret,
signature, // 't=...,v1=...'
timestampMs: Date.now(),
body: rawBytes,
toleranceMs: 5 * 60 * 1000,
});
if (!result.ok) {
// result.reason ∈ {'malformed','expired','mismatch'}
}createWebhookReceiver calls this internally. Use it directly only when
you need custom dispatch (e.g. integrating into a non-Bun.serve framework).
Multi-bot consumers
Multiple bots can consume the engine simultaneously. Each runs its own
pull-loop with its own initialCursor (persisted bot-side), or registers
its own webhook with its own secret. The engine fans out events to all
matching subscribers; consumers don't see each other's deliveries. v2 has
only proven this with the smoke harness as a single consumer; the
multi-consumer scenario will be re-validated in v3.
Versioning policy
poly-rewards-shared is published to npm. The package follows semver:
patch-level for additive non-breaking changes, minor for backwards-compatible
new helpers, major for any breaking change. Any change to the
EventSchema discriminated union shape is a breaking change (existing
bots' type-narrowing would silently break) and bumps the major version.
Workspace consumers continue to track workspace:* and ride whatever version
sits in packages/shared/package.json; external consumers pin a published
version range.
