Skip to content

Commit 8150180

Browse files
committed
feat(rain): add Rain (Arbitrum AMM+CLOB) as the 15th exchange
End-to-end adapter for rain.one: reads (markets, events, outcomes, emulated 1-level orderbook, OHLCV, positions, balance) plus on-chain writes (AMM market buys, limit buys/sells, cancels) via @buidlrrr/rain-sdk and viem. Multi-option markets fan out into synthetic binary markets under one UnifiedEvent (e.g. the 16-team FIFA World Cup market). ERC20 approval is auto-sent on first buy per market. Verified live against rain.one production.
1 parent eb47163 commit 8150180

14 files changed

Lines changed: 2661 additions & 232 deletions

File tree

changelog.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,19 @@
22

33
All notable changes to this project will be documented in this file.
44

5-
## [2.49.11] - 2026-06-12
5+
## [2.50.0] - 2026-06-15
6+
7+
Added Rain ([rain.one](https://rain.one)) as the 15th venue — a permissionless AMM-plus-orderbook prediction market on Arbitrum One. Reads (markets, events, outcomes, orderbook, OHLCV, positions, balance) and full on-chain writes (market buys via AMM, limit buys/sells through the order book, cancels) are wired end-to-end via the official `@buidlrrr/rain-sdk` and viem signing. Verified live against production: markets like "Which Team will win FIFA World Cup 2026?" (16 outcomes) and Trump/Khamenei binary markets round-trip correctly through `fetchMarkets`, `fetchEvents`, and `fetchOrderBook` with prices, liquidity, and statuses populated.
8+
9+
### Added
10+
11+
- **`core/src/exchanges/rain/`**: New adapter following the existing 3-layer pattern (fetcher, normalizer, websocket) plus a small `auth.ts` that derives an EVM signer + Arbitrum public client from a `privateKey`. The SDK is loaded via ESM dynamic `import()` (same shape as Opinion) since `@buidlrrr/rain-sdk` is ESM-only. Multi-option markets (e.g. the 16-team FIFA market) are expanded into one synthetic binary `UnifiedMarket` per option inside a single `UnifiedEvent`, matching the Polymarket/Myriad grouping the matching engine expects. Orderbook is a 1-level emulated book at AMM spot (mirrors Myriad). Subgraph-backed methods (`fetchOHLCV`, `fetchTrades`, `fetchMyTrades`, `fetchOpenOrders`) return `[]` when no `subgraphUrl` is configured rather than throwing, since `'emulated'` is the honest capability for an on-chain venue with no native list endpoint.
12+
- **`core/src/exchanges/rain/index.ts`**: Full trading path. `buildOrder` returns a populated `BuiltOrder.tx = { to, data, value, chainId: 42161 }` from the SDK's transaction builders — `buildBuyOptionRawTx` for market buys, `buildLimitBuyOptionTx` for limit buys, `buildSellOptionTx` for sells (Rain has no AMM market-sell; that path throws `NotSupported` with a message pointing at the limit branch). `submitOrder` signs with the viem `WalletClient`, auto-sends an ERC20 `approve(MAX_UINT256)` on the market contract before the first buy on that market, and waits for the approval receipt before submitting the order tx. `cancelOrder` parses an `id` of the form `rain:{contract}:{side}:{option}:{price1e18}:{rainOrderId}:{txHash}` and dispatches to `buildCancelBuyOrdersTx` / `buildCancelSellOrdersTx`, so cancels round-trip without needing subgraph state.
13+
- **`core/src/exchanges/rain/utils.ts`**: `resolveDecimals()` helper. The Rain SDK returns `baseTokenDecimals` as the **scale factor** (e.g. `1000000n` for a 6-decimal token), not the decimal count — passing it straight into a `10 ** n` computation produced astronomically large scales and silently zeroed every liquidity and volume reading. The helper detects this (`> 36 → log10`) and normalizes to a real decimal count; called from every fetcher/normalizer site that touches base-token math.
14+
- **`core/src/exchanges/rain/normalizer.ts`**: Reads from both the list-shape response (`getPublicMarkets` returns Mongo-style `_id`, `question`, `options[].percentage` as 0-100, and `totalLiquidityUSD` in base-token wei) and the on-chain details-shape (`getMarketDetails` returns `id`, `title`, `options[].currentPrice` as 1e18 bigint, `totalLiquidity` as wei bigint). The list-shape is the source of truth for the catalog and is sufficient on its own — the original implementation called `getMarketDetails` for every market in an N+1 enrichment loop because the published agent docs describe only the details shape; the loop is kept (bounded parallel, top 25 by default) so on-chain prices override the cached `percentage` when available, but the adapter still works correctly if every detail call fails.
15+
- **`core/src/index.ts`, `core/src/server/exchange-factory.ts`, `core/src/server/openapi.yaml`**: Standard 3-site registration. `case "rain"` reads `RAIN_PRIVATE_KEY`, `RAIN_WALLET_ADDRESS`, `RAIN_SUBGRAPH_URL`, `RAIN_SUBGRAPH_API_KEY`, `RAIN_WS_RPC_URL`, `RAIN_ENVIRONMENT` from env when no explicit credentials are passed.
16+
- **`core/package.json`**: Added `@buidlrrr/rain-sdk ^2.0.0`. The SDK declares optional peer deps on `@account-kit/*` and `@alchemy/aa-*` for its account-abstraction path; we intentionally do not install them in v1 since PMXT trades from an EOA via viem rather than through Rain's smart-account wrapper.
17+
- **`README.md`**: Rain logo added to the supported-venues row.
618

719
Hosted trading works from ESM apps and Opinion orders pass pre-sign validation. Two independent bugs each blocked all hosted writes for affected callers: the ESM build could not lazy-load ethers (bare `require` is undefined in ESM, and the failure was silently swallowed — the signer was dropped and every write died with "hosted write requires a signer" even when a `privateKey` was passed), and the client-side economics validator demanded `message.opinion_market_id` from a trading-API message schema that no longer carries it (the signed economic identity is the outcome `tokenId`). Both verified live against `trade.pmxt.dev` from an ESM consumer.
820

core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"license": "MIT",
4646
"type": "commonjs",
4747
"dependencies": {
48+
"@buidlrrr/rain-sdk": "^2.0.0",
4849
"@limitless-exchange/sdk": "^1.0.5",
4950
"@opinion-labs/opinion-clob-sdk": "^0.6.0",
5051
"@polymarket/clob-client-v2": "^1.0.5",

core/src/exchanges/rain/auth.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { ExchangeCredentials } from '../../BaseExchange';
2+
import { AuthenticationError } from '../../errors';
3+
import {
4+
createWalletClient, createPublicClient, http,
5+
type WalletClient, type PublicClient, type Hex,
6+
} from 'viem';
7+
import { privateKeyToAccount } from 'viem/accounts';
8+
import { arbitrum } from 'viem/chains';
9+
10+
export interface RainCredentials extends ExchangeCredentials {
11+
privateKey?: string;
12+
walletAddress?: string;
13+
subgraphUrl?: string;
14+
subgraphApiKey?: string;
15+
wsRpcUrl?: string;
16+
rpcUrl?: string;
17+
environment?: 'development' | 'stage' | 'production';
18+
}
19+
20+
const DEFAULT_ARBITRUM_RPC = 'https://arb1.arbitrum.io/rpc';
21+
22+
export class RainAuth {
23+
readonly creds: RainCredentials;
24+
private wallet?: WalletClient;
25+
private publicClient?: PublicClient;
26+
private signerAddress?: `0x${string}`;
27+
28+
constructor(creds: RainCredentials) {
29+
this.creds = creds;
30+
}
31+
32+
get walletAddress(): string | undefined {
33+
return this.creds.walletAddress ?? this.deriveAddress();
34+
}
35+
36+
get privateKey(): string | undefined {
37+
return this.creds.privateKey;
38+
}
39+
40+
private deriveAddress(): `0x${string}` | undefined {
41+
if (this.signerAddress) return this.signerAddress;
42+
if (!this.creds.privateKey) return undefined;
43+
const pk = this.creds.privateKey.startsWith('0x')
44+
? this.creds.privateKey as Hex
45+
: (`0x${this.creds.privateKey}` as Hex);
46+
this.signerAddress = privateKeyToAccount(pk).address;
47+
return this.signerAddress;
48+
}
49+
50+
resolveAddress(): string | undefined {
51+
return this.walletAddress;
52+
}
53+
54+
requireWalletAddress(method: string): string {
55+
const addr = this.resolveAddress();
56+
if (!addr) {
57+
throw new AuthenticationError(
58+
`${method} requires a wallet address. Pass { walletAddress } or { privateKey } in credentials.`,
59+
'Rain',
60+
);
61+
}
62+
return addr;
63+
}
64+
65+
ensureWalletClient(): WalletClient {
66+
if (this.wallet) return this.wallet;
67+
if (!this.creds.privateKey) {
68+
throw new AuthenticationError(
69+
'Trading requires a privateKey. Initialize RainExchange with { privateKey } in credentials.',
70+
'Rain',
71+
);
72+
}
73+
const pk = this.creds.privateKey.startsWith('0x')
74+
? this.creds.privateKey as Hex
75+
: (`0x${this.creds.privateKey}` as Hex);
76+
const account = privateKeyToAccount(pk);
77+
this.signerAddress = account.address;
78+
this.wallet = createWalletClient({
79+
account,
80+
chain: arbitrum,
81+
transport: http(this.creds.rpcUrl ?? DEFAULT_ARBITRUM_RPC),
82+
});
83+
return this.wallet;
84+
}
85+
86+
ensurePublicClient(): PublicClient {
87+
if (this.publicClient) return this.publicClient;
88+
this.publicClient = createPublicClient({
89+
chain: arbitrum,
90+
transport: http(this.creds.rpcUrl ?? DEFAULT_ARBITRUM_RPC),
91+
});
92+
return this.publicClient;
93+
}
94+
}

core/src/exchanges/rain/errors.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { ErrorMapper } from '../../utils/error-mapper';
2+
3+
export class RainErrorMapper extends ErrorMapper {
4+
constructor() {
5+
super('Rain');
6+
}
7+
}
8+
9+
export const rainErrorMapper = new RainErrorMapper();

core/src/exchanges/rain/fetcher.ts

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
// Thin wrapper around @buidlrrr/rain-sdk. SDK is ESM-only; we use dynamic
2+
// import() to stay CJS-friendly. All methods return raw Rain SDK types;
3+
// the normalizer maps them into the Unified schema.
4+
5+
import { rainErrorMapper } from './errors';
6+
import { logger } from '../../utils/logger';
7+
8+
// ESM dynamic import (same pattern as Opinion adapter).
9+
type RainSdk = typeof import('@buidlrrr/rain-sdk');
10+
type RainClient = InstanceType<RainSdk['Rain']>;
11+
12+
let sdkPromise: Promise<RainSdk> | undefined;
13+
function loadSdk(): Promise<RainSdk> {
14+
if (!sdkPromise) sdkPromise = import('@buidlrrr/rain-sdk');
15+
return sdkPromise;
16+
}
17+
18+
export interface RainFetcherConfig {
19+
environment?: 'development' | 'stage' | 'production';
20+
subgraphUrl?: string;
21+
subgraphApiKey?: string;
22+
rpcUrl?: string;
23+
wsRpcUrl?: string;
24+
}
25+
26+
// Re-export raw SDK types as the fetcher's contract surface.
27+
export type RainRawMarket = Awaited<ReturnType<RainClient['getPublicMarkets']>>[number];
28+
export type RainRawMarketDetails = Awaited<ReturnType<RainClient['getMarketDetails']>>;
29+
export type RainRawOptionPrice = Awaited<ReturnType<RainClient['getMarketPrices']>>[number];
30+
export type RainRawPositions = Awaited<ReturnType<RainClient['getPositions']>>;
31+
export type RainRawBalance = Awaited<ReturnType<RainClient['getSmartAccountBalance']>>;
32+
export type RainRawPriceHistory = Awaited<ReturnType<RainClient['getPriceHistory']>>;
33+
export type RainRawTransactions = Awaited<ReturnType<RainClient['getTransactions']>>;
34+
export type RainRawMarketTransactions = Awaited<ReturnType<RainClient['getMarketTransactions']>>;
35+
36+
// What the fetcher returns: a market plus its enriched details (when available).
37+
export interface RainMarketWithDetails {
38+
market: RainRawMarket;
39+
details?: RainRawMarketDetails;
40+
}
41+
42+
const DETAIL_ENRICHMENT_LIMIT = 25;
43+
const DETAIL_PARALLEL_BATCH = 5;
44+
45+
export class RainFetcher {
46+
private readonly config: RainFetcherConfig;
47+
private client?: RainClient;
48+
49+
constructor(config: RainFetcherConfig) {
50+
this.config = config;
51+
}
52+
53+
private async getClient(): Promise<RainClient> {
54+
if (!this.client) {
55+
const sdk = await loadSdk();
56+
this.client = new sdk.Rain({
57+
environment: this.config.environment ?? 'production',
58+
rpcUrl: this.config.rpcUrl,
59+
subgraphUrl: this.config.subgraphUrl,
60+
subgraphApiKey: this.config.subgraphApiKey,
61+
wsRpcUrl: this.config.wsRpcUrl,
62+
});
63+
}
64+
return this.client;
65+
}
66+
67+
/**
68+
* List markets. Enriches the first `DETAIL_ENRICHMENT_LIMIT` with on-chain
69+
* details (options + prices) in bounded-parallel batches. Beyond that, only
70+
* the basic list-view fields are populated. ponytail: N+1 enrichment is
71+
* fine for the typical 25-market view; switch to a multicall bundler if a
72+
* larger feed needs full options on every row.
73+
*/
74+
async fetchRawMarkets(params?: {
75+
limit?: number;
76+
offset?: number;
77+
sortBy?: 'Liquidity' | 'Volumn' | 'latest';
78+
status?: string;
79+
}): Promise<RainMarketWithDetails[]> {
80+
try {
81+
const client = await this.getClient();
82+
const markets = await client.getPublicMarkets({
83+
limit: params?.limit,
84+
offset: params?.offset,
85+
sortBy: params?.sortBy ?? 'Liquidity',
86+
status: params?.status as any,
87+
});
88+
89+
const enrichUpTo = Math.min(markets.length, DETAIL_ENRICHMENT_LIMIT);
90+
const enriched: RainMarketWithDetails[] = [];
91+
92+
const resolveId = (m: any): string | undefined => m?._id ?? m?.id;
93+
94+
for (let i = 0; i < enrichUpTo; i += DETAIL_PARALLEL_BATCH) {
95+
const batch = markets.slice(i, i + DETAIL_PARALLEL_BATCH);
96+
const details = await Promise.all(
97+
batch.map((m) => {
98+
const mid = resolveId(m);
99+
return mid ? this.safeFetchDetails(client, mid) : Promise.resolve(undefined);
100+
}),
101+
);
102+
batch.forEach((m, j) => enriched.push({ market: m, details: details[j] }));
103+
}
104+
105+
for (let i = enrichUpTo; i < markets.length; i++) {
106+
enriched.push({ market: markets[i] });
107+
}
108+
109+
return enriched;
110+
} catch (error: any) {
111+
throw rainErrorMapper.mapError(error);
112+
}
113+
}
114+
115+
async fetchRawMarket(marketId: string): Promise<RainMarketWithDetails | null> {
116+
try {
117+
const client = await this.getClient();
118+
const details = await client.getMarketDetails(marketId);
119+
if (!details) return null;
120+
return {
121+
market: {
122+
id: details.id,
123+
title: details.title,
124+
totalVolume: '0',
125+
status: details.status,
126+
contractAddress: details.contractAddress,
127+
} as RainRawMarket,
128+
details,
129+
};
130+
} catch (error: any) {
131+
throw rainErrorMapper.mapError(error);
132+
}
133+
}
134+
135+
async fetchRawOHLCV(marketId: string, optionIndex: number, interval: string, limit?: number): Promise<RainRawPriceHistory | null> {
136+
if (!this.config.subgraphUrl) return null;
137+
try {
138+
const client = await this.getClient();
139+
return await client.getPriceHistory({
140+
marketId,
141+
optionIndex,
142+
interval: interval as any,
143+
limit,
144+
});
145+
} catch (error: any) {
146+
throw rainErrorMapper.mapError(error);
147+
}
148+
}
149+
150+
async fetchRawPositions(walletAddress: string): Promise<RainRawPositions> {
151+
try {
152+
const client = await this.getClient();
153+
return await client.getPositions(walletAddress as `0x${string}`);
154+
} catch (error: any) {
155+
throw rainErrorMapper.mapError(error);
156+
}
157+
}
158+
159+
async fetchRawBalance(walletAddress: string, tokenAddresses: string[]): Promise<RainRawBalance> {
160+
try {
161+
const client = await this.getClient();
162+
return await client.getSmartAccountBalance({
163+
address: walletAddress as `0x${string}`,
164+
tokenAddresses: tokenAddresses as `0x${string}`[],
165+
});
166+
} catch (error: any) {
167+
throw rainErrorMapper.mapError(error);
168+
}
169+
}
170+
171+
async fetchRawMarketTrades(marketAddress: string, limit?: number): Promise<RainRawMarketTransactions | null> {
172+
if (!this.config.subgraphUrl) return null;
173+
try {
174+
const client = await this.getClient();
175+
return await client.getMarketTransactions({
176+
marketAddress: marketAddress as `0x${string}`,
177+
first: limit,
178+
});
179+
} catch (error: any) {
180+
throw rainErrorMapper.mapError(error);
181+
}
182+
}
183+
184+
async fetchRawUserTrades(walletAddress: string, marketAddress?: string, limit?: number): Promise<RainRawTransactions | null> {
185+
if (!this.config.subgraphUrl) return null;
186+
try {
187+
const client = await this.getClient();
188+
return await client.getTransactions({
189+
address: walletAddress as `0x${string}`,
190+
first: limit,
191+
marketAddress: marketAddress as `0x${string}` | undefined,
192+
});
193+
} catch (error: any) {
194+
throw rainErrorMapper.mapError(error);
195+
}
196+
}
197+
198+
/** Expose the underlying SDK client for trade-tx builders in index.ts. */
199+
async sdkClient(): Promise<RainClient> {
200+
return this.getClient();
201+
}
202+
203+
private async safeFetchDetails(client: RainClient, marketId: string): Promise<RainRawMarketDetails | undefined> {
204+
try {
205+
return await client.getMarketDetails(marketId);
206+
} catch (err) {
207+
logger.warn('RainFetcher: getMarketDetails failed', { marketId, error: String(err) });
208+
return undefined;
209+
}
210+
}
211+
212+
async close(): Promise<void> {
213+
if (this.client && typeof (this.client as any).destroyWebSocket === 'function') {
214+
try {
215+
await (this.client as any).destroyWebSocket();
216+
} catch { /* ignore */ }
217+
}
218+
this.client = undefined;
219+
}
220+
}

0 commit comments

Comments
 (0)