@puppet.fund/operator 0.1.0 → 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 CHANGED
@@ -24,36 +24,39 @@ The package is split by operator, so you choose yours:
24
24
 
25
25
  - **`@puppet.fund/operator/gmx`** — the ready-made GMX V2 venue (`gmxOperator(core)`), wrapping a
26
26
  core you build. What the scaffolder sets up, and what most users want.
27
- - **`@puppet.fund/operator`** — the generic, venue-agnostic core. `createOperatorCore(config)`
28
- gives you pairing + endpoint resolution, the RPC/indexer clients, account + fund prediction and
27
+ - **`@puppet.fund/operator`** — the generic, venue-agnostic core. `pairOverBrowser()`
28
+ `createOperatorCore(session)` gives you the RPC client, account + fund prediction and
29
29
  deploy checks, the matchmaker compact, the generic `operate(callList)` dispatch,
30
- `getFundBalance()`, and connection lifecycle (`status` / `isOpen()` / `close()`). Compose it
30
+ `getFundBalance()`, and connection lifecycle (`onStatus()` / `isOpen()` / `close()`). Compose it
31
31
  with your own venue call shapes to build an extended operator — a different venue, base token,
32
32
  or surface. The GMX venue is built on exactly this. Also here: `runOperator`,
33
- `pairOverBrowser`, `createCompact`, `TOKEN_ID`, and the compact error helpers. Type your
33
+ `pairOverBrowser`, `buildSession`, `createCompact`, `TOKEN_ID`, and the compact error helpers. Type your
34
34
  operator against `IOperatorCore`.
35
35
 
36
36
  ## Usage
37
37
 
38
- Build the **core** (the connection pairing/endpoint resolution, signing, the matchmaker compact,
39
- your account + fund, lifecycle), then wrap it in the GMX venue. The core does the async setup and
40
- owns the connection; `gmxOperator(core)` is synchronous and adds only GMX execution + reads — so
41
- you call connection/account actions on `core`, GMX actions on `gmx`. When you pair, the site hands
42
- over its matchmaker/indexer endpoints, so a paired operator needs no URL config; RPC defaults to
43
- the public Arbitrum endpoint (set `rpcUrl` for anything beyond a first run). GMX runs on a WETH
44
- fund (collateral and the keeper fee are the same token), so create your account and allocate a
45
- WETH fund on the site first.
38
+ `pairOverBrowser()` returns the **session** the whole sealed identity (account params, the fund
39
+ and its base token, the matchmaker endpoint, the token registry). Pass it to `createOperatorCore`
40
+ to build the **core** (signing, the matchmaker compact, your account + fund, lifecycle), then wrap
41
+ it in the GMX venue. The core does the async setup and owns the connection; `gmxOperator(core)` is
42
+ synchronous and adds only GMX execution + reads so you call connection/account actions on `core`,
43
+ GMX actions on `gmx`. RPC defaults to the public Arbitrum endpoint (set `rpcUrl` beyond a first
44
+ run). The agent trades whatever base the paired fund settles in (the session names it) — WETH is
45
+ the simplest (GMX's keeper fee is the same token, carved straight from the collateral, so the fund
46
+ needs no native ETH); USDC or native ETH work too, but a non-WETH base also needs a little ETH in
47
+ the fund for the GMX execution fee.
46
48
 
47
49
  ```ts
48
- import { createOperatorCore, runOperator } from '@puppet.fund/operator'
49
- import { GMX_BASE_TOKEN_ID, gmxOperator } from '@puppet.fund/operator/gmx'
50
+ import { createOperatorCore, pairOverBrowser, runOperator } from '@puppet.fund/operator'
51
+ import { gmxOperator } from '@puppet.fund/operator/gmx'
50
52
 
51
- // Zero-config: endpoints + session key arrive over the pairing tunnel.
52
- const core = await createOperatorCore({ baseTokenId: GMX_BASE_TOKEN_ID })
53
+ // Pairing carries everything: identity, fund + base token, matchmaker endpoint, registry.
54
+ const session = await pairOverBrowser() // a PAIR_URL for a local site, else https://puppet.fund
55
+ const core = await createOperatorCore(session, { rpcUrl: Bun.env.ARBITRUM_RPC_URL })
53
56
  const gmx = gmxOperator(core)
54
57
 
55
- // Override endpoints by passing matchmakerUrl / indexerUrl / rpcUrl to createOperatorCore,
56
- // or add signerKey + user (with matchmakerUrl + indexerUrl) to run headless without pairing.
58
+ // Headless (no browser): build the session yourself
59
+ // buildSession({ signerKey, user, baseTokenId, name }) then createOperatorCore(session).
57
60
 
58
61
  const deployable = await core.getFundBalance() // core: account + connection
59
62
  const positions = await gmx.getPositions() // gmx: venue reads
@@ -80,25 +83,30 @@ Two addresses matter, both deterministic and printed at startup:
80
83
 
81
84
  ## Surface
82
85
 
83
- - `createOperatorCore(config)` (async, package root) — pairs / resolves endpoints, sets up the
84
- matchmaker compact, predicts + checks the account and the fund, and returns the connection +
85
- account surface you call directly: `getFundBalance()` (live base in the fund),
86
- `getFundSignedBalance()` (the signed portion, on-chain), `isOpen()` (is the matchmaker
87
- connected gate a tick on it), `status`, `operate(callList, transferList?)` (the generic
88
- signed-call dispatch under the venue surface declare value the calls move as transfer legs;
89
- the default 0/0 leg fits value-neutral dispatches, and the protocol attestor co-signs only
90
- calls inside the venue perimeter it can screen, so arbitrary targets are rejected), and
91
- `close()`; plus `account`, `fund`, `signer`,
92
- `user`, `token`, `baseTokenId`, `publicClient`, `sql`, `compact`. Type it as `IOperatorCore`.
93
- `matchmakerUrl` / `indexerUrl` are optional (a paired operator receives them from the site; pass
94
- them to override, or supply both with `signerKey` + `user` to run headless). `rpcUrl` defaults
95
- to the public Arbitrum endpoint. `dryRun: true` runs every dispatch through the identical
96
- local verification + venue screen the matchmaker applies, logs what would be sent, and skips
97
- the dispatch the way to watch a strategy decide without risking funds. The session signer
98
- stays private to the core you get signed intents, not the key.
99
- - `gmxOperator(core, opts?)` (sync, `@puppet.fund/operator/gmx`) the GMX venue over a core. Build
100
- the core with `baseTokenId: GMX_BASE_TOKEN_ID` (WETH) and pass it in; this validates the base token
101
- and adds GMX-only surface. Reads: `getMarket(indexToken)` (the canonical perp for the base token;
86
+ - `pairOverBrowser(pairUrl?)` (async, package root) — opens a one-time `127.0.0.1` listener and
87
+ prints a link to open on the site; returns the sealed `IPairedSession`: `params` ({user,
88
+ signer}), `share` ({master, baseTokenId, name}), `matchmakerUrl`, and the token registry. For
89
+ unattended runs build the same shape with `buildSession({ signerKey, user, baseTokenId, name })`.
90
+ - `createOperatorCore(session, config?)` (async, package root) takes a session, sets up the
91
+ matchmaker compact, predicts + checks the account and the fund (asserting the session's
92
+ identity is internally consistent), and returns the connection + account surface you call
93
+ directly: `getFundBalance()` (live base in the fund), `getFundSignedBalance()` (the signed
94
+ portion, on-chain), `isOpen()` (is the matchmaker connected — gate a tick on it), `onStatus()`,
95
+ `operate(callList, transferList?)` (the generic signed-call dispatch under the venue surface —
96
+ declare value the calls move as transfer legs; the default 0/0 leg fits value-neutral
97
+ dispatches, and the protocol attestor co-signs only calls inside the venue perimeter it can
98
+ screen, so arbitrary targets are rejected), and `close()`; plus `params`, `share`, `account`,
99
+ `fund`, `signer`, `user`, `token`, `baseTokenId`, `publicClient`, `compact`. Type it as
100
+ `IOperatorCore`. The base token is the paired fund's (`share.baseTokenId`); the matchmaker
101
+ endpoint and registry come from the session too. The operator needs no indexer: intent block
102
+ anchors arrive as head frames on the relay socket, balances and settlement are read from the
103
+ chain. `config.rpcUrl` defaults to the public Arbitrum endpoint. `config.dryRun: true` runs
104
+ every dispatch through the identical local verification + venue screen the matchmaker applies,
105
+ logs what would be sent, and skips the dispatch — the way to watch a strategy decide without
106
+ risking funds. The session signer stays private to the core — you get signed intents, not the key.
107
+ - `gmxOperator(core, opts?)` (sync, `@puppet.fund/operator/gmx`) — the GMX venue over a core whose
108
+ fund settles in a GMX-tradable base (WETH is simplest; `GMX_BASE_TOKEN_ID` is its id). Adds
109
+ GMX-only surface. Reads: `getMarket(indexToken)` (the canonical perp for the base token;
102
110
  throws only if still ambiguous), `getPositions(account?)` (defaults to your fund; pass any GMX
103
111
  account to read someone else's), `getOrders(account?)`, `markets`. Writes: `createOrder(...)` —
104
112
  the one primitive for every increase/decrease order type (market/limit/stop/take-profit) via
@@ -110,19 +118,18 @@ Two addresses matter, both deterministic and printed at startup:
110
118
  (size/collateral/margin incl. unrealized PnL, valuing WETH or USDC collateral correctly).
111
119
  `opts.executionFeeBufferBps` (default 2000 = 20%) pads the auto-quote. It does NOT re-expose
112
120
  core methods — call those on `core`.
113
- - Lifecycle + building blocks (`runOperator`, `pairOverBrowser`, `createCompact`, `TOKEN_ID`, compact
114
- types + error helpers) live at the package root — see **Entrypoints** above. A GMX operator is
115
- `createOperatorCore({ baseTokenId: GMX_BASE_TOKEN_ID })` + `gmxOperator(core)`, run under
116
- `runOperator(core, body)`.
121
+ - Lifecycle + building blocks (`runOperator`, `pairOverBrowser`, `buildSession`, `createCompact`,
122
+ `TOKEN_ID`, compact types + error helpers) live at the package root — see **Entrypoints** above.
123
+ A GMX operator is `pairOverBrowser()` `createOperatorCore(session)` `gmxOperator(core)`, run
124
+ under `runOperator(core, body)`.
117
125
 
118
126
  ## Pairing
119
127
 
120
- Without a `signerKey`, the core opens a one-time `127.0.0.1` listener and prints a link you
121
- open on the site. The browser seals your session key and the site's matchmaker/indexer
122
- endpoints — to an ephemeral key in that link and posts it back. The key reaches the operator in
123
- memory only, never written to disk; the endpoints come from the site you're pairing with. Your
124
- account and fund must already exist (create the account and allocate a WETH fund on the site
125
- first).
128
+ `pairOverBrowser()` opens a one-time `127.0.0.1` listener and prints a link you open on the site.
129
+ The browser seals the session your session key, the fund identity (`params` + `share`), the
130
+ matchmaker endpoint, and the token registry — to an ephemeral key in that link and posts it back.
131
+ The key reaches the operator in memory only, never written to disk. Your account and fund must
132
+ already exist (create the account and allocate the fund on the site first).
126
133
 
127
134
  The browser seals the *derived* session key, never your wallet's bind signature, so a
128
135
  paired operator can sign operate intents but cannot deploy accounts under you.
@@ -1,6 +1,4 @@
1
1
  import { Address, Hex, PublicClient } from "viem";
2
- import { IStream } from "aelea/stream";
3
- import { Client } from "graphql-ws";
4
2
  //#region ../contracts/dist/types/types/index.d.ts
5
3
  interface IAccount__CreatePuppetAccountIntent {
6
4
  params: IAccountLib__AccountInitParams;
@@ -181,16 +179,28 @@ interface ISubscribe__SubscribeIntent {
181
179
  //#endregion
182
180
  //#region ../contracts/dist/types/const/index.d.ts
183
181
  declare const TOKEN_ID: {
182
+ readonly ETH: '0xaaaebeba3810b1e6b70781f14b2d72c1cb89c0b2b320c43bb67ff79f562f5ff4';
184
183
  readonly USDC: '0xd6aca1be9729c13d677335161321649cccae6a591554772516700f986f942eaa';
185
184
  readonly WETH: '0x0f8a193ff464434486c0daf7db2a895884365d2bc84ba47a68fcf89c1b14b5b8';
186
185
  };
187
186
  //#endregion
188
- //#region ../sdk/dist/types/state/shared.d.ts
189
- interface IIndexerClient {
190
- httpEndpoint: string;
191
- wsClient: Client;
187
+ //#region ../../indexer/script/__generated/entities.d.ts
188
+ interface IRegisterToken__RegisterToken {
189
+ id: string;
190
+ chainId: bigint;
191
+ tokenId: Hex;
192
+ token: Address;
193
+ cap: bigint;
194
+ hubToken: Address;
195
+ blockTimestamp: number;
196
+ blockNumber: bigint;
197
+ logIndex: number;
198
+ transactionHash: Hex;
192
199
  }
193
200
  //#endregion
201
+ //#region ../sdk/dist/types/state/tokenRegistry.d.ts
202
+ type ITokenInfo = IRegisterToken__RegisterToken;
203
+ //#endregion
194
204
  //#region ../sdk/dist/types/attestation/shared.d.ts
195
205
  interface IIntentByKind {
196
206
  subscribe: ISubscribe__SubscribeIntent;
@@ -433,21 +443,24 @@ type IRelayRequest<K extends IActionKind = IActionKind> = K extends IActionKind
433
443
  intent: IIntentByKind[K];
434
444
  signature: Hex;
435
445
  } : never;
436
- interface IAttestResult {
446
+ interface IDispatchedFrame {
447
+ chainId: bigint;
448
+ account: Address;
449
+ nonce: bigint;
437
450
  txHash: Hex;
438
451
  actualRelayFee: bigint;
439
452
  }
440
453
  type IMatchmakerStatus = 'open' | 'connecting' | 'closed';
441
454
  interface ICompactOpts {
442
455
  matchmakerUrl: string;
443
- sql: IIndexerClient;
444
456
  defaultTimeoutMs?: number;
445
- settlementTimeoutMs?: number;
446
457
  }
447
458
  interface ICompact {
448
- attest(request: IRelayRequest, timeoutMs?: number): Promise<IAttestResult>;
449
- status: IStream<IMatchmakerStatus>;
459
+ attest(request: IRelayRequest, timeoutMs?: number): Promise<IDispatchedFrame>;
460
+ onStatus(cb: (status: IMatchmakerStatus) => void): () => void;
450
461
  isOpen(): boolean;
462
+ head(network: string): bigint | undefined;
463
+ awaitHead(network: string, timeoutMs?: number): Promise<bigint>;
451
464
  close(): void;
452
465
  }
453
466
  declare function createCompact(opts: ICompactOpts): ICompact;
@@ -2952,19 +2965,23 @@ declare function humanizeErrorCode(code: string): string;
2952
2965
  declare function humanizeContractError(code: string, args: readonly unknown[]): string;
2953
2966
  declare function formatThrownError(err: unknown): string;
2954
2967
  //#endregion
2968
+ //#region ../sdk/dist/types/account/pairing.d.ts
2969
+ interface IPairedSession {
2970
+ signerKey: Hex;
2971
+ params: IAccountLib__AccountInitParams;
2972
+ share: IShareLib__ShareInitParams;
2973
+ matchmakerUrl: string;
2974
+ tokenRegistry?: ITokenInfo[];
2975
+ }
2976
+ //#endregion
2955
2977
  //#region src/core.d.ts
2956
2978
  interface IOperatorConfig {
2957
- baseTokenId: Hex;
2958
- matchmakerUrl?: string;
2959
- indexerUrl?: string;
2960
2979
  rpcUrl?: string;
2961
- user?: Address;
2962
- signerKey?: Hex;
2963
- siteUrl?: string;
2964
- pairPort?: number;
2965
2980
  dryRun?: boolean;
2966
2981
  }
2967
2982
  interface IOperatorCore {
2983
+ params: IAccountLib__AccountInitParams;
2984
+ share: IShareLib__ShareInitParams;
2968
2985
  user: Address;
2969
2986
  signer: Address;
2970
2987
  account: Address;
@@ -2972,15 +2989,16 @@ interface IOperatorCore {
2972
2989
  baseTokenId: Hex;
2973
2990
  token: Address;
2974
2991
  publicClient: PublicClient;
2975
- sql: IIndexerClient;
2976
2992
  compact: ICompact;
2977
- status: ICompact['status'];
2993
+ onStatus: ICompact['onStatus'];
2994
+ tokenIdFor: (token: Address) => Hex;
2995
+ readSignedBalance: (tokenId: Hex) => Promise<bigint>;
2978
2996
  isOpen: () => boolean;
2979
2997
  getFundBalance: () => Promise<bigint>;
2980
2998
  getFundSignedBalance: () => Promise<bigint>;
2981
- operate: (callList: IIAccount__Call[], transferList?: IIAccount__SignTransfer[]) => Promise<IAttestResult>;
2999
+ operate: (callList: IIAccount__Call[], transferList?: IIAccount__SignTransfer[]) => Promise<IDispatchedFrame>;
2982
3000
  close: () => void;
2983
3001
  }
2984
- declare function createOperatorCore(config: IOperatorConfig): Promise<IOperatorCore>;
3002
+ declare function createOperatorCore(session: IPairedSession, config?: IOperatorConfig): Promise<IOperatorCore>;
2985
3003
  //#endregion
2986
- export { CompactError as a, humanizeErrorCode as c, ICompactOpts as d, IMatchmakerStatus as f, CompactContractError as i, IAttestResult as l, TOKEN_ID as m, IOperatorCore as n, formatThrownError as o, createCompact as p, createOperatorCore as r, humanizeContractError as s, IOperatorConfig as t, ICompact as u };
3004
+ export { CompactContractError as a, humanizeContractError as c, ICompactOpts as d, IDispatchedFrame as f, TOKEN_ID as g, ITokenInfo as h, IPairedSession as i, humanizeErrorCode as l, createCompact as m, IOperatorCore as n, CompactError as o, IMatchmakerStatus as p, createOperatorCore as r, formatThrownError as s, IOperatorConfig as t, ICompact as u };
@@ -1,6 +1,5 @@
1
- import { l as IAttestResult, n as IOperatorCore } from "../core-DC0-qJhv.js";
1
+ import { f as IDispatchedFrame, n as IOperatorCore } from "../core-DlV1H6H-.js";
2
2
  import { Address, Hex, PublicClient } from "viem";
3
- import { IStream } from "aelea/stream";
4
3
  //#region ../sdk/dist/types/venue/gmx.d.ts
5
4
  declare const GMX_ORDER_TYPE: {
6
5
  readonly MarketSwap: 0;
@@ -994,9 +993,9 @@ declare function gmxOperator(core: IOperatorCore, opts?: IGmxOptions): {
994
993
  _dataList: readonly `0x${string}`[];
995
994
  };
996
995
  }[]>;
997
- createOrder: (p: IGmxOrder) => Promise<IAttestResult>;
998
- cancelOrder(key: Hex): Promise<IAttestResult>;
999
- updateOrder(p: IUpdateOrder): Promise<IAttestResult>;
996
+ createOrder: (p: IGmxOrder) => Promise<IDispatchedFrame>;
997
+ cancelOrder(key: Hex): Promise<IDispatchedFrame>;
998
+ updateOrder(p: IUpdateOrder): Promise<IDispatchedFrame>;
1000
999
  };
1001
1000
  //#endregion
1002
1001
  export { GMX_BASE_TOKEN_ID, GMX_DECREASE_SWAP_TYPE, GMX_ORDER_TYPE, type IGasLimitsConfig, IGmxOptions, type IGmxOrder, IGmxPositionMetrics, IGmxPositionView, IUpdateOrder, acceptablePrice, calculateExecutionFee, dominantPosition, formatUsd, formatWeth, getGasLimitsConfig, getPositionPnlUsd, gmxOperator, gmxPrice, positionMetrics, usd, weth };
package/dist/gmx/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { E as TOKEN_ID, S as CHAIN_TOKEN_MAP, d as applyFactor, f as ARBITRUM_MARKET_LIST, l as selectGmxMarket, n as GMX_INCREASE_TYPES, p as GMX_V2_CONTRACT_MAP, r as GMX_ORDER_TYPE, s as buildGmxOrderCalls, t as GMX_DECREASE_SWAP_TYPE, u as getPositionPnlUsd } from "../gmx-DwTiknYm.js";
2
- import { encodeFunctionData, formatUnits, isAddressEqual, parseUnits } from "viem";
1
+ import { S as symbolForBaseTokenId, d as selectGmxMarket, f as getPositionPnlUsd, h as GMX_V2_CONTRACT_MAP, k as TOKEN_ID, l as buildGmxOrderCalls, m as ARBITRUM_MARKET_LIST, n as GMX_INCREASE_TYPES, p as applyFactor, r as GMX_ORDER_TYPE, t as GMX_DECREASE_SWAP_TYPE } from "../gmx-C0vgbHXM.js";
2
+ import { encodeFunctionData, erc20Abi, formatUnits, getAddress, isAddressEqual, parseUnits, zeroAddress } from "viem";
3
3
  import { encodeAbiParameters as encodeAbiParameters$1, keccak256 as keccak256$1, parseAbiParameters } from "viem/utils";
4
4
  //#region ../sdk/dist/esm/gmx/gmxUtils.js
5
5
  function hashData(types, values) {
@@ -80,7 +80,6 @@ function positionMetrics(p, markPrice, longToken, shortTokenDecimals = 6) {
80
80
  };
81
81
  }
82
82
  function gmxOperator(core, opts = {}) {
83
- if (!isAddressEqual(core.token, CHAIN_TOKEN_MAP[42161].WETH)) throw new Error("gmxOperator needs a WETH-based core — create it with baseTokenId GMX_BASE_TOKEN_ID");
84
83
  const { operate, publicClient, token, fund } = core;
85
84
  const executionFeeBufferBps = opts.executionFeeBufferBps ?? 2000n;
86
85
  let gasLimitsConfig = null;
@@ -91,20 +90,32 @@ function gmxOperator(core, opts = {}) {
91
90
  return calculateExecutionFee(gasLimitsConfig, gasPrice, actionGasLimit) * (10000n + executionFeeBufferBps) / 10000n;
92
91
  }
93
92
  async function createOrder(p) {
94
- const { callList, amountOut } = buildGmxOrderCalls(p, {
93
+ const { callList, outflows } = buildGmxOrderCalls(p, {
95
94
  master: fund,
96
95
  baseToken: token,
97
96
  executionFee: p.executionFee ?? await quoteExecutionFee(p.orderType)
98
97
  });
99
- const [balance, signed] = await Promise.all([core.getFundBalance(), core.getFundSignedBalance()]);
100
- if (balance < amountOut) throw new Error(`fund balance ${balance} below required ${amountOut} — allocate more on the site or size down`);
101
- const surplus = balance > signed ? balance - signed : 0n;
102
- return operate(callList, [{
103
- tokenId: core.baseTokenId,
104
- token,
105
- amountIn: surplus,
106
- amountOut
107
- }]);
98
+ return operate(callList, await Promise.all(outflows.map(async (o) => {
99
+ const outToken = getAddress(o.token);
100
+ const tokenId = core.tokenIdFor(outToken);
101
+ const isNative = isAddressEqual(outToken, zeroAddress);
102
+ const [balance, signed] = await Promise.all([isNative ? publicClient.getBalance({ address: fund }) : publicClient.readContract({
103
+ address: outToken,
104
+ abi: erc20Abi,
105
+ functionName: "balanceOf",
106
+ args: [fund]
107
+ }), core.readSignedBalance(tokenId)]);
108
+ if (o.amountOut > balance) {
109
+ const sym = symbolForBaseTokenId(tokenId) ?? outToken;
110
+ throw new Error(`insufficient ${sym}: fund holds ${balance}, order needs ${o.amountOut} — allocate more on the site or size down`);
111
+ }
112
+ return {
113
+ tokenId,
114
+ token: outToken,
115
+ amountIn: balance > signed ? balance - signed : 0n,
116
+ amountOut: o.amountOut
117
+ };
118
+ })));
108
119
  }
109
120
  return {
110
121
  GMX_ORDER_TYPE,