mishmar 0.0.0-stage → 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/LICENSE +174 -0
- package/README.md +77 -2
- package/dist/agentkey.d.ts +63 -0
- package/dist/agentkey.js +118 -0
- package/dist/canon.d.ts +14 -0
- package/dist/canon.js +64 -0
- package/dist/client.d.ts +119 -0
- package/dist/client.js +188 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +5 -0
- package/dist/proposal.d.ts +108 -0
- package/dist/proposal.js +159 -0
- package/dist/signer.d.ts +139 -0
- package/dist/signer.js +392 -0
- package/dist/time.d.ts +5 -0
- package/dist/time.js +17 -0
- package/package.json +41 -4
package/dist/client.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small client for the engine, on fetch.
|
|
3
|
+
*
|
|
4
|
+
* Proposals go to POST /proposals with the signature in the body. Everything
|
|
5
|
+
* else an agent may call (its own decisions, approvals, positions, PnL,
|
|
6
|
+
* trading accounts, and the signer channel) is under /agent and signed with the
|
|
7
|
+
* agentAuth headers, so a bot can function without an operator token and
|
|
8
|
+
* cannot act on the account.
|
|
9
|
+
*/
|
|
10
|
+
import { sign, toWire } from "./proposal.js";
|
|
11
|
+
const RESOLVED_APPROVAL = new Set(["approved", "rejected", "expired"]);
|
|
12
|
+
/** The engine answered with an error: the HTTP status and the code and message from its body. */
|
|
13
|
+
export class EngineError extends Error {
|
|
14
|
+
status;
|
|
15
|
+
code;
|
|
16
|
+
/** The `Retry-After` header of a 429, when the engine sent one. */
|
|
17
|
+
retryAfter;
|
|
18
|
+
constructor(status, code, message, retryAfter) {
|
|
19
|
+
super(`${status} ${code}: ${message}`);
|
|
20
|
+
this.name = "EngineError";
|
|
21
|
+
this.status = status;
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.retryAfter = retryAfter;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export const DEFAULT_RETRIES = { budgetMs: 45_000, minWaitMs: 250, maxWaitMs: 8_000 };
|
|
27
|
+
/** The answers that mean the request did nothing and may go again. */
|
|
28
|
+
const RETRYABLE = new Set([503, 429]);
|
|
29
|
+
/**
|
|
30
|
+
* Milliseconds to wait before attempt `attempt + 1` (from 0) after an answer
|
|
31
|
+
* with the status and Retry-After header, `elapsedMs` after the first attempt
|
|
32
|
+
* began; `undefined` means stop and throw the answer.
|
|
33
|
+
*/
|
|
34
|
+
export function retryWait(retries, attempt, status, retryAfter, elapsedMs) {
|
|
35
|
+
if (!RETRYABLE.has(status))
|
|
36
|
+
return undefined;
|
|
37
|
+
const budget = retries.budgetMs ?? DEFAULT_RETRIES.budgetMs;
|
|
38
|
+
const minWait = retries.minWaitMs ?? DEFAULT_RETRIES.minWaitMs;
|
|
39
|
+
const maxWait = Math.max(retries.maxWaitMs ?? DEFAULT_RETRIES.maxWaitMs, minWait);
|
|
40
|
+
let wait = parseRetryAfter(retryAfter);
|
|
41
|
+
if (wait === undefined) {
|
|
42
|
+
const step = Math.min(minWait * 2 ** Math.min(attempt, 20), maxWait);
|
|
43
|
+
wait = step / 2 + Math.random() * (step / 2);
|
|
44
|
+
}
|
|
45
|
+
return elapsedMs + wait > budget ? undefined : wait;
|
|
46
|
+
}
|
|
47
|
+
function parseRetryAfter(value) {
|
|
48
|
+
if (!value)
|
|
49
|
+
return undefined;
|
|
50
|
+
if (/^\d+$/.test(value.trim()))
|
|
51
|
+
return Number(value) * 1000;
|
|
52
|
+
const at = Date.parse(value);
|
|
53
|
+
return Number.isNaN(at) ? undefined : Math.max(at - Date.now(), 0);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Talks to one engine as one agent. The trading key's share lives in the
|
|
57
|
+
* agent's signer, never here: this client proposes, reads, and relays the
|
|
58
|
+
* signer channel.
|
|
59
|
+
*/
|
|
60
|
+
export class Client {
|
|
61
|
+
engineUrl;
|
|
62
|
+
agentKey;
|
|
63
|
+
keyId;
|
|
64
|
+
#options;
|
|
65
|
+
constructor(engineUrl, agentKey, keyId, options = {}) {
|
|
66
|
+
this.engineUrl = engineUrl.replace(/\/+$/, "");
|
|
67
|
+
this.agentKey = agentKey;
|
|
68
|
+
this.keyId = keyId;
|
|
69
|
+
this.#options = options;
|
|
70
|
+
}
|
|
71
|
+
// Proposals
|
|
72
|
+
/** Sign the proposal with the agent's key (unless already signed) and submit it. */
|
|
73
|
+
async propose(proposal) {
|
|
74
|
+
const withKey = proposal.keyId ? proposal : { ...proposal, keyId: this.keyId };
|
|
75
|
+
const signed = withKey.signature ? withKey : sign(withKey, this.agentKey);
|
|
76
|
+
return (await this.#call("POST", "/proposals", toWire(signed)));
|
|
77
|
+
}
|
|
78
|
+
// Reads, scoped to this agent
|
|
79
|
+
/** A page of this agent's decisions, newest first. */
|
|
80
|
+
async decisions(query = {}) {
|
|
81
|
+
return (await this.#agentCall("GET", "/agent/decisions", { query }));
|
|
82
|
+
}
|
|
83
|
+
async decision(decisionId) {
|
|
84
|
+
return (await this.#agentCall("GET", `/agent/decisions/${encodeURIComponent(decisionId)}`));
|
|
85
|
+
}
|
|
86
|
+
async approvals(status) {
|
|
87
|
+
return (await this.#agentCall("GET", "/agent/approvals", { query: { status } }));
|
|
88
|
+
}
|
|
89
|
+
async positions() {
|
|
90
|
+
return (await this.#agentCall("GET", "/agent/positions"));
|
|
91
|
+
}
|
|
92
|
+
/** Daily PnL of this agent; `from` and `to` are dates (YYYY-MM-DD). */
|
|
93
|
+
async pnl(query = {}) {
|
|
94
|
+
return (await this.#agentCall("GET", "/agent/pnl", { query }));
|
|
95
|
+
}
|
|
96
|
+
async tradingAccount(tradingAccountId) {
|
|
97
|
+
return (await this.#agentCall("GET", `/agent/trading-accounts/${encodeURIComponent(tradingAccountId)}`));
|
|
98
|
+
}
|
|
99
|
+
/** Poll a decision until it no longer waits for a person, or until timeoutMs; returns the last read. */
|
|
100
|
+
/** What the trading account's venue accepts per symbol (tick, step, minimums), as the engine last read it. */
|
|
101
|
+
async venueRules(tradingAccountId, symbol) {
|
|
102
|
+
return (await this.#agentCall("GET", `/agent/trading-accounts/${encodeURIComponent(tradingAccountId)}/venue-rules`, { query: { symbol } }));
|
|
103
|
+
}
|
|
104
|
+
async wait(decisionId, { timeoutMs = 60_000, intervalMs = 1_000 } = {}) {
|
|
105
|
+
const deadline = Date.now() + timeoutMs;
|
|
106
|
+
for (;;) {
|
|
107
|
+
const d = await this.decision(decisionId);
|
|
108
|
+
if (resolved(d) || Date.now() >= deadline)
|
|
109
|
+
return d;
|
|
110
|
+
await new Promise((r) => setTimeout(r, Math.min(intervalMs, Math.max(0, deadline - Date.now()))));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// The signer channel
|
|
114
|
+
/** Wait up to waitSeconds for requests this agent's signer must sign. */
|
|
115
|
+
async signerPoll(waitSeconds = 25) {
|
|
116
|
+
const timeoutMs = (this.#options.timeoutMs ?? 10_000) + waitSeconds * 1000;
|
|
117
|
+
return (await this.#agentCall("POST", "/agent/signer/poll", { body: { waitSeconds }, timeoutMs }));
|
|
118
|
+
}
|
|
119
|
+
/** Answer a sign request: a share, a signature, a venue response or a refusal. */
|
|
120
|
+
/** Take up to `count` of the engine's single-use FROST commitments for one-shot signing with a venue key (CommitmentBatch). */
|
|
121
|
+
async signerCommitments(venueKeyId, count) {
|
|
122
|
+
const body = { venueKeyId };
|
|
123
|
+
if (count !== undefined)
|
|
124
|
+
body.count = count;
|
|
125
|
+
return (await this.#agentCall("POST", "/agent/signer/commitments", { body }));
|
|
126
|
+
}
|
|
127
|
+
async signerAnswer(requestId, answer) {
|
|
128
|
+
await this.#agentCall("POST", `/agent/signer/requests/${encodeURIComponent(requestId)}/answer`, { body: answer });
|
|
129
|
+
}
|
|
130
|
+
// Plumbing
|
|
131
|
+
async #agentCall(method, path, { query, body, timeoutMs } = {}) {
|
|
132
|
+
const params = new URLSearchParams();
|
|
133
|
+
for (const [k, v] of Object.entries(query ?? {}))
|
|
134
|
+
if (v !== undefined)
|
|
135
|
+
params.set(k, String(v));
|
|
136
|
+
const queryText = params.toString();
|
|
137
|
+
const bodyText = body === undefined ? undefined : JSON.stringify(body);
|
|
138
|
+
// Each attempt is signed anew, with its own nonce and time, so a retry is never a replay.
|
|
139
|
+
const sign = () => this.agentKey.signRequest({ keyId: this.keyId, method, path, query: queryText, body: bodyText });
|
|
140
|
+
return this.#call(method, path + (queryText ? "?" + queryText : ""), body, sign, timeoutMs);
|
|
141
|
+
}
|
|
142
|
+
/** Sends the request, again after an answer that did nothing (`retries`), and returns the final answer decoded; `sign` makes each attempt's headers. */
|
|
143
|
+
async #call(method, path, body, sign, timeoutMs) {
|
|
144
|
+
const retries = this.#options.retries ?? {};
|
|
145
|
+
const start = Date.now();
|
|
146
|
+
for (let attempt = 0;; attempt++) {
|
|
147
|
+
const { res, text } = await this.#once(method, path, body, sign?.() ?? {}, timeoutMs);
|
|
148
|
+
if (res.ok)
|
|
149
|
+
return text ? JSON.parse(text) : null;
|
|
150
|
+
let code = "http_error";
|
|
151
|
+
let message = res.statusText || text.slice(0, 200);
|
|
152
|
+
try {
|
|
153
|
+
const parsed = JSON.parse(text);
|
|
154
|
+
if (typeof parsed.code === "string")
|
|
155
|
+
code = parsed.code;
|
|
156
|
+
if (typeof parsed.message === "string")
|
|
157
|
+
message = parsed.message;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// not JSON: keep the text
|
|
161
|
+
}
|
|
162
|
+
const err = new EngineError(res.status, code, message, res.headers.get("retry-after") ?? undefined);
|
|
163
|
+
const wait = retries === false ? undefined : retryWait(retries, attempt, res.status, err.retryAfter ?? null, Date.now() - start);
|
|
164
|
+
if (wait === undefined)
|
|
165
|
+
throw err;
|
|
166
|
+
await (this.#options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))))(wait);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async #once(method, path, body, headers, timeoutMs) {
|
|
170
|
+
const h = { Accept: "application/json", ...headers };
|
|
171
|
+
if (body !== undefined)
|
|
172
|
+
h["Content-Type"] = "application/json";
|
|
173
|
+
const doFetch = this.#options.fetch ?? fetch;
|
|
174
|
+
const res = await doFetch(this.engineUrl + path, {
|
|
175
|
+
method,
|
|
176
|
+
headers: h,
|
|
177
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
178
|
+
signal: AbortSignal.timeout(timeoutMs ?? this.#options.timeoutMs ?? 10_000),
|
|
179
|
+
});
|
|
180
|
+
return { res, text: await res.text() };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** Whether the decision no longer waits for a person. */
|
|
184
|
+
export function resolved(d) {
|
|
185
|
+
if (d.verdict !== "require_approval")
|
|
186
|
+
return true;
|
|
187
|
+
return RESOLVED_APPROVAL.has(d.approval?.status ?? "");
|
|
188
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { AgentKey, HEADER_KEY_ID, HEADER_NONCE, HEADER_SIGNATURE, HEADER_SIGNED_AT, newNonce, requestSigningBytes, secret, verify, verifyRequest, } from "./agentkey.ts";
|
|
2
|
+
export type { RequestToSign } from "./agentkey.ts";
|
|
3
|
+
export { canonicalBytes, canonicalJson } from "./canon.ts";
|
|
4
|
+
export { Client, DEFAULT_RETRIES, EngineError, resolved, retryWait } from "./client.ts";
|
|
5
|
+
export type { ClientOptions, Decision, DecisionPage, Retries, SignRequest, SignerAnswer } from "./client.ts";
|
|
6
|
+
export { fromWire, isDecimal, rfc3339, sign, signingBytes, toWire, validate } from "./proposal.ts";
|
|
7
|
+
export type { Kind, OrderType, Proposal, Scheme, Side, VenueMessage, WireProposal } from "./proposal.ts";
|
|
8
|
+
export { Signer, downloadBinary, findBinary, platformName, RELEASES } from "./signer.ts";
|
|
9
|
+
export type { ConnectOptions, OrderIntent, SignerOptions } from "./signer.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { AgentKey, HEADER_KEY_ID, HEADER_NONCE, HEADER_SIGNATURE, HEADER_SIGNED_AT, newNonce, requestSigningBytes, secret, verify, verifyRequest, } from "./agentkey.js";
|
|
2
|
+
export { canonicalBytes, canonicalJson } from "./canon.js";
|
|
3
|
+
export { Client, DEFAULT_RETRIES, EngineError, resolved, retryWait } from "./client.js";
|
|
4
|
+
export { fromWire, isDecimal, rfc3339, sign, signingBytes, toWire, validate } from "./proposal.js";
|
|
5
|
+
export { Signer, downloadBinary, findBinary, platformName, RELEASES } from "./signer.js";
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build and sign what an agent sends to POST /v1/proposals.
|
|
3
|
+
*
|
|
4
|
+
* The engine authenticates a proposal by its signature alone: the canonical
|
|
5
|
+
* JSON of the submitted object without `signature`, signed with the agent's
|
|
6
|
+
* key. The bytes are pinned in spec/vectors/proposal-signing.json, which the
|
|
7
|
+
* tests here run.
|
|
8
|
+
*
|
|
9
|
+
* Rules: size, price and leverage are decimal strings signed as written (no
|
|
10
|
+
* number ever touches them); times are RFC 3339 UTC with a Z suffix and the
|
|
11
|
+
* fraction trimmed; optional fields are present only when set, never null.
|
|
12
|
+
* A cancel (kind "cancel") carries targetClientOrderId and none of the order
|
|
13
|
+
* fields; every other kind carries side, size, orderType and reduceOnly. A
|
|
14
|
+
* venueMessage, when the signer built the venue request itself, is nested
|
|
15
|
+
* and canonicalised the same way, keys sorted.
|
|
16
|
+
*/
|
|
17
|
+
import type { AgentKey } from "./agentkey.ts";
|
|
18
|
+
import { newNonce } from "./agentkey.ts";
|
|
19
|
+
import { rfc3339 } from "./time.ts";
|
|
20
|
+
export { newNonce, rfc3339 };
|
|
21
|
+
export type Kind = "order" | "transaction" | "signed_message" | "cancel";
|
|
22
|
+
export type Side = "buy" | "sell";
|
|
23
|
+
export type OrderType = "limit" | "market";
|
|
24
|
+
export type Scheme = "frost-ed25519" | "ecdsa-2p";
|
|
25
|
+
/**
|
|
26
|
+
* The venue request the agent's signer built, with its half of the
|
|
27
|
+
* signature, for the one-shot path (the contract's ProposalVenueMessage).
|
|
28
|
+
* Base64 throughout.
|
|
29
|
+
*/
|
|
30
|
+
export interface VenueMessage {
|
|
31
|
+
venueKeyId: string;
|
|
32
|
+
scheme: Scheme;
|
|
33
|
+
message: string;
|
|
34
|
+
engineCommitmentId: string;
|
|
35
|
+
/** FROST only: the signer's own nonce commitment. */
|
|
36
|
+
commitment?: string;
|
|
37
|
+
share: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A proposal as the agent means it. `sign` fills what is left out. A cancel
|
|
41
|
+
* names the order to pull and leaves the order fields out:
|
|
42
|
+
* `{ kind: "cancel", tradingAccountId, symbol, targetClientOrderId }`.
|
|
43
|
+
*/
|
|
44
|
+
export interface Proposal {
|
|
45
|
+
tradingAccountId: string;
|
|
46
|
+
symbol: string;
|
|
47
|
+
/** Required for every kind but cancel. */
|
|
48
|
+
side?: Side;
|
|
49
|
+
/** Decimal string, e.g. "0.25". Required for every kind but cancel. */
|
|
50
|
+
size?: string;
|
|
51
|
+
/** Default "limit". */
|
|
52
|
+
orderType?: OrderType;
|
|
53
|
+
/** Decimal string; required for a limit order, absent for a market order. */
|
|
54
|
+
price?: string;
|
|
55
|
+
/** Decimal string. */
|
|
56
|
+
leverage?: string;
|
|
57
|
+
/** Default false; never sent on a cancel. */
|
|
58
|
+
reduceOnly?: boolean;
|
|
59
|
+
/** Default "order". */
|
|
60
|
+
kind?: Kind;
|
|
61
|
+
/** Cancels only: the clientOrderId of the order to pull. */
|
|
62
|
+
targetClientOrderId?: string;
|
|
63
|
+
venueMessage?: VenueMessage;
|
|
64
|
+
/** The Client fills this in. */
|
|
65
|
+
keyId?: string;
|
|
66
|
+
nonce?: string;
|
|
67
|
+
proposedAt?: Date;
|
|
68
|
+
expiresAt?: Date;
|
|
69
|
+
/** How long the agent gives the order, when expiresAt is not set. Default one minute. */
|
|
70
|
+
validForMs?: number;
|
|
71
|
+
signature?: string;
|
|
72
|
+
}
|
|
73
|
+
/** The request body as the contract names it. */
|
|
74
|
+
export interface WireProposal {
|
|
75
|
+
keyId: string;
|
|
76
|
+
kind: Kind;
|
|
77
|
+
nonce: string;
|
|
78
|
+
orderType?: OrderType;
|
|
79
|
+
proposedAt: string;
|
|
80
|
+
expiresAt: string;
|
|
81
|
+
reduceOnly?: boolean;
|
|
82
|
+
tradingAccountId: string;
|
|
83
|
+
side?: Side;
|
|
84
|
+
size?: string;
|
|
85
|
+
symbol: string;
|
|
86
|
+
price?: string;
|
|
87
|
+
leverage?: string;
|
|
88
|
+
targetClientOrderId?: string;
|
|
89
|
+
venueMessage?: VenueMessage;
|
|
90
|
+
signature?: string;
|
|
91
|
+
}
|
|
92
|
+
/** Whether s is a decimal string the contract accepts ("0.5", "-12", "98000.25"). */
|
|
93
|
+
export declare function isDecimal(s: unknown): s is string;
|
|
94
|
+
/** The proposal's times, defaulting to now and now plus validForMs. */
|
|
95
|
+
export declare function times(p: Proposal): {
|
|
96
|
+
proposedAt: Date;
|
|
97
|
+
expiresAt: Date;
|
|
98
|
+
};
|
|
99
|
+
/** Throw naming the first field the engine would refuse. */
|
|
100
|
+
export declare function validate(p: Proposal): void;
|
|
101
|
+
/** The request body for the proposal (the signature included once signed). */
|
|
102
|
+
export declare function toWire(p: Proposal): WireProposal;
|
|
103
|
+
/** The inverse of toWire, for vectors and for replaying a stored proposal. */
|
|
104
|
+
export declare function fromWire(w: WireProposal): Proposal;
|
|
105
|
+
/** What the agent signs: the canonical JSON of the proposal without the signature. */
|
|
106
|
+
export declare function signingBytes(p: Proposal): Uint8Array;
|
|
107
|
+
/** Pin the times, validate, and return the proposal with its signature set. */
|
|
108
|
+
export declare function sign(p: Proposal, key: AgentKey): Proposal;
|
package/dist/proposal.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build and sign what an agent sends to POST /v1/proposals.
|
|
3
|
+
*
|
|
4
|
+
* The engine authenticates a proposal by its signature alone: the canonical
|
|
5
|
+
* JSON of the submitted object without `signature`, signed with the agent's
|
|
6
|
+
* key. The bytes are pinned in spec/vectors/proposal-signing.json, which the
|
|
7
|
+
* tests here run.
|
|
8
|
+
*
|
|
9
|
+
* Rules: size, price and leverage are decimal strings signed as written (no
|
|
10
|
+
* number ever touches them); times are RFC 3339 UTC with a Z suffix and the
|
|
11
|
+
* fraction trimmed; optional fields are present only when set, never null.
|
|
12
|
+
* A cancel (kind "cancel") carries targetClientOrderId and none of the order
|
|
13
|
+
* fields; every other kind carries side, size, orderType and reduceOnly. A
|
|
14
|
+
* venueMessage, when the signer built the venue request itself, is nested
|
|
15
|
+
* and canonicalised the same way, keys sorted.
|
|
16
|
+
*/
|
|
17
|
+
import { newNonce } from "./agentkey.js";
|
|
18
|
+
import { canonicalBytes } from "./canon.js";
|
|
19
|
+
import { parseTime, rfc3339 } from "./time.js";
|
|
20
|
+
export { newNonce, rfc3339 };
|
|
21
|
+
const DECIMAL = /^-?[0-9]+(\.[0-9]+)?$/;
|
|
22
|
+
/** Whether s is a decimal string the contract accepts ("0.5", "-12", "98000.25"). */
|
|
23
|
+
export function isDecimal(s) {
|
|
24
|
+
return typeof s === "string" && DECIMAL.test(s);
|
|
25
|
+
}
|
|
26
|
+
/** The proposal's times, defaulting to now and now plus validForMs. */
|
|
27
|
+
export function times(p) {
|
|
28
|
+
const proposedAt = p.proposedAt ?? new Date();
|
|
29
|
+
const expiresAt = p.expiresAt ?? new Date(proposedAt.getTime() + (p.validForMs ?? 60_000));
|
|
30
|
+
return { proposedAt, expiresAt };
|
|
31
|
+
}
|
|
32
|
+
/** Throw naming the first field the engine would refuse. */
|
|
33
|
+
export function validate(p) {
|
|
34
|
+
const orderType = p.orderType ?? "limit";
|
|
35
|
+
const kind = p.kind ?? "order";
|
|
36
|
+
if (!p.keyId)
|
|
37
|
+
throw new Error("keyId is required (the Client fills it in)");
|
|
38
|
+
if (!p.tradingAccountId)
|
|
39
|
+
throw new Error("tradingAccountId is required");
|
|
40
|
+
if (!p.symbol)
|
|
41
|
+
throw new Error("symbol is required");
|
|
42
|
+
if (!p.nonce)
|
|
43
|
+
throw new Error("nonce is required");
|
|
44
|
+
if (!["order", "transaction", "signed_message", "cancel"].includes(kind))
|
|
45
|
+
throw new Error("kind must be order, transaction, signed_message or cancel");
|
|
46
|
+
if (p.venueMessage !== undefined)
|
|
47
|
+
validateVenueMessage(p.venueMessage);
|
|
48
|
+
const { proposedAt, expiresAt } = times(p);
|
|
49
|
+
if (expiresAt.getTime() <= proposedAt.getTime())
|
|
50
|
+
throw new Error("expiresAt must be after proposedAt");
|
|
51
|
+
if (kind === "cancel") {
|
|
52
|
+
if (!p.targetClientOrderId)
|
|
53
|
+
throw new Error("a cancel needs targetClientOrderId, the clientOrderId of the order to pull");
|
|
54
|
+
if (p.side !== undefined || p.size !== undefined || p.price !== undefined || p.orderType !== undefined || p.leverage !== undefined || p.reduceOnly !== undefined) {
|
|
55
|
+
throw new Error("a cancel carries no order fields (side, size, price, orderType, leverage, reduceOnly)");
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (p.targetClientOrderId !== undefined)
|
|
60
|
+
throw new Error("targetClientOrderId is for cancels only");
|
|
61
|
+
if (p.side !== "buy" && p.side !== "sell")
|
|
62
|
+
throw new Error("side must be buy or sell");
|
|
63
|
+
if (orderType !== "limit" && orderType !== "market")
|
|
64
|
+
throw new Error("orderType must be limit or market");
|
|
65
|
+
if (!isDecimal(p.size))
|
|
66
|
+
throw new Error('size must be a decimal string such as "0.5"');
|
|
67
|
+
if (orderType === "limit" && !isDecimal(p.price))
|
|
68
|
+
throw new Error("a limit order needs a decimal price string");
|
|
69
|
+
if (orderType === "market" && p.price !== undefined)
|
|
70
|
+
throw new Error("a market order carries no price");
|
|
71
|
+
if (p.leverage !== undefined && !isDecimal(p.leverage))
|
|
72
|
+
throw new Error("leverage must be a decimal string");
|
|
73
|
+
if (p.reduceOnly !== undefined && typeof p.reduceOnly !== "boolean")
|
|
74
|
+
throw new Error("reduceOnly must be a boolean");
|
|
75
|
+
}
|
|
76
|
+
function validateVenueMessage(m) {
|
|
77
|
+
if (!m.venueKeyId || !m.scheme || !m.message || !m.engineCommitmentId || !m.share) {
|
|
78
|
+
throw new Error("venueMessage needs venueKeyId, scheme, message, engineCommitmentId and share");
|
|
79
|
+
}
|
|
80
|
+
if (m.scheme !== "frost-ed25519" && m.scheme !== "ecdsa-2p")
|
|
81
|
+
throw new Error("venueMessage.scheme must be frost-ed25519 or ecdsa-2p");
|
|
82
|
+
if (m.scheme === "frost-ed25519" && !m.commitment)
|
|
83
|
+
throw new Error("a FROST venueMessage needs the signer's commitment");
|
|
84
|
+
}
|
|
85
|
+
/** The request body for the proposal (the signature included once signed). */
|
|
86
|
+
export function toWire(p) {
|
|
87
|
+
const { proposedAt, expiresAt } = times(p);
|
|
88
|
+
const kind = p.kind ?? "order";
|
|
89
|
+
const w = {
|
|
90
|
+
keyId: p.keyId ?? "",
|
|
91
|
+
kind,
|
|
92
|
+
nonce: p.nonce ?? "",
|
|
93
|
+
proposedAt: rfc3339(proposedAt),
|
|
94
|
+
expiresAt: rfc3339(expiresAt),
|
|
95
|
+
tradingAccountId: p.tradingAccountId,
|
|
96
|
+
symbol: p.symbol,
|
|
97
|
+
};
|
|
98
|
+
if (kind !== "cancel") {
|
|
99
|
+
w.orderType = p.orderType ?? "limit";
|
|
100
|
+
w.reduceOnly = p.reduceOnly ?? false;
|
|
101
|
+
if (p.side !== undefined)
|
|
102
|
+
w.side = p.side;
|
|
103
|
+
if (p.size !== undefined)
|
|
104
|
+
w.size = p.size;
|
|
105
|
+
if (p.price !== undefined)
|
|
106
|
+
w.price = p.price;
|
|
107
|
+
if (p.leverage !== undefined)
|
|
108
|
+
w.leverage = p.leverage;
|
|
109
|
+
}
|
|
110
|
+
if (p.targetClientOrderId !== undefined)
|
|
111
|
+
w.targetClientOrderId = p.targetClientOrderId;
|
|
112
|
+
if (p.venueMessage !== undefined)
|
|
113
|
+
w.venueMessage = { ...p.venueMessage };
|
|
114
|
+
if (p.signature !== undefined)
|
|
115
|
+
w.signature = p.signature;
|
|
116
|
+
return w;
|
|
117
|
+
}
|
|
118
|
+
/** The inverse of toWire, for vectors and for replaying a stored proposal. */
|
|
119
|
+
export function fromWire(w) {
|
|
120
|
+
const p = {
|
|
121
|
+
keyId: w.keyId,
|
|
122
|
+
kind: w.kind,
|
|
123
|
+
nonce: w.nonce,
|
|
124
|
+
proposedAt: parseTime(w.proposedAt),
|
|
125
|
+
expiresAt: parseTime(w.expiresAt),
|
|
126
|
+
tradingAccountId: w.tradingAccountId,
|
|
127
|
+
symbol: w.symbol,
|
|
128
|
+
};
|
|
129
|
+
if (w.orderType !== undefined)
|
|
130
|
+
p.orderType = w.orderType;
|
|
131
|
+
if (w.reduceOnly !== undefined)
|
|
132
|
+
p.reduceOnly = w.reduceOnly;
|
|
133
|
+
if (w.side !== undefined)
|
|
134
|
+
p.side = w.side;
|
|
135
|
+
if (w.size !== undefined)
|
|
136
|
+
p.size = w.size;
|
|
137
|
+
if (w.price !== undefined)
|
|
138
|
+
p.price = w.price;
|
|
139
|
+
if (w.leverage !== undefined)
|
|
140
|
+
p.leverage = w.leverage;
|
|
141
|
+
if (w.targetClientOrderId !== undefined)
|
|
142
|
+
p.targetClientOrderId = w.targetClientOrderId;
|
|
143
|
+
if (w.venueMessage !== undefined)
|
|
144
|
+
p.venueMessage = { ...w.venueMessage };
|
|
145
|
+
if (w.signature !== undefined)
|
|
146
|
+
p.signature = w.signature;
|
|
147
|
+
return p;
|
|
148
|
+
}
|
|
149
|
+
/** What the agent signs: the canonical JSON of the proposal without the signature. */
|
|
150
|
+
export function signingBytes(p) {
|
|
151
|
+
const { signature: _signature, ...unsigned } = toWire(p);
|
|
152
|
+
return canonicalBytes(unsigned);
|
|
153
|
+
}
|
|
154
|
+
/** Pin the times, validate, and return the proposal with its signature set. */
|
|
155
|
+
export function sign(p, key) {
|
|
156
|
+
const pinned = { ...p, nonce: p.nonce ?? newNonce(), ...times(p) };
|
|
157
|
+
validate(pinned);
|
|
158
|
+
return { ...pinned, signature: key.sign(signingBytes(pinned)) };
|
|
159
|
+
}
|
package/dist/signer.d.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The signer as a child process, or one already running.
|
|
3
|
+
*
|
|
4
|
+
* `Signer` starts `mishmar signer run --local …` with the agent's config and
|
|
5
|
+
* talks to it over a Unix socket (or, on Windows, localhost with a token).
|
|
6
|
+
* The child holds the agent's key and its halves of the venue keys; this
|
|
7
|
+
* process holds no secret at all. An order is an intent: the child turns it
|
|
8
|
+
* into a proposal signed with the agent's key, sends it one-shot where it can
|
|
9
|
+
* build the venue's message, through the channel otherwise, and hands back
|
|
10
|
+
* the decision as the engine recorded it. The agent's reads pass through the
|
|
11
|
+
* child the same way.
|
|
12
|
+
*
|
|
13
|
+
* const s = await new Signer().start(); // ~/.mishmar/agent.json, mishmar on PATH
|
|
14
|
+
* let d = await s.order({ tradingAccountId: "ta_…", symbol: "BTC", side: "buy", size: "0.001", price: "79500" });
|
|
15
|
+
* if (d.verdict === "require_approval") d = await s.wait(d.id);
|
|
16
|
+
* await s.stop();
|
|
17
|
+
*
|
|
18
|
+
* The child lives and dies with this process: it gets a pipe on stdin that
|
|
19
|
+
* is never written to and stops when the pipe closes, so it does not outlive
|
|
20
|
+
* a bot that crashed or was killed. A signer run on its own instead (systemd,
|
|
21
|
+
* launchd, Docker: `mishmar signer run --local unix:/path`) is reached with
|
|
22
|
+
* `Signer.connect`, which never starts or stops it; that is the way for
|
|
23
|
+
* several processes of one agent, or to keep the signer connected across bot
|
|
24
|
+
* restarts. One signer runs per config: a second one on the same config
|
|
25
|
+
* stops at once and says which process holds it.
|
|
26
|
+
*
|
|
27
|
+
* The binary is found through `MISHMAR_BIN`, the PATH, or
|
|
28
|
+
* `~/.mishmar/bin/<version>/mishmar`; `downloadBinary` fetches a release from
|
|
29
|
+
* GitHub and checks it against the release's checksums.
|
|
30
|
+
*/
|
|
31
|
+
import type { ChildProcess } from "node:child_process";
|
|
32
|
+
import type { Decision, DecisionPage } from "./client.ts";
|
|
33
|
+
export declare const RELEASES = "https://github.com/mishmar-io/mishmar/releases/download";
|
|
34
|
+
/** The release asset's os_arch for this machine: linux_amd64, darwin_arm64, … */
|
|
35
|
+
export declare function platformName(): string;
|
|
36
|
+
/**
|
|
37
|
+
* Where the mishmar binary is: the argument, `MISHMAR_BIN`, the PATH, or a
|
|
38
|
+
* release `downloadBinary` fetched. Throws with what to do when there is none.
|
|
39
|
+
*/
|
|
40
|
+
export declare function findBinary(explicit?: string): Promise<string>;
|
|
41
|
+
/**
|
|
42
|
+
* Fetch the release `version` (a tag, v0.1.0) for this machine into `destDir`
|
|
43
|
+
* (default `~/.mishmar/bin/<version>`), after checking its SHA-256 against the
|
|
44
|
+
* release's SHA256SUMS. Returns the binary's path.
|
|
45
|
+
*/
|
|
46
|
+
export declare function downloadBinary(version: string, destDir?: string, fetchImpl?: typeof fetch): Promise<string>;
|
|
47
|
+
/** An order to propose. Sizes and prices are decimal strings as the venue quotes them. */
|
|
48
|
+
export interface OrderIntent {
|
|
49
|
+
tradingAccountId: string;
|
|
50
|
+
symbol: string;
|
|
51
|
+
side: "buy" | "sell";
|
|
52
|
+
size: string;
|
|
53
|
+
/** limit (the default; needs price) or market (priced at the engine's mark). */
|
|
54
|
+
orderType?: "limit" | "market";
|
|
55
|
+
price?: string;
|
|
56
|
+
reduceOnly?: boolean;
|
|
57
|
+
leverage?: string;
|
|
58
|
+
/** How long the proposal stands, at most 86400; default 60. */
|
|
59
|
+
validForSeconds?: number;
|
|
60
|
+
/** Leave the venue request to the engine and the signer's poller even where one-shot is possible. */
|
|
61
|
+
channel?: boolean;
|
|
62
|
+
}
|
|
63
|
+
export interface SignerOptions {
|
|
64
|
+
/** The signer's config file; default $MISHMAR_AGENT_CONFIG or ~/.mishmar/agent.json. */
|
|
65
|
+
config?: string;
|
|
66
|
+
/** The mishmar binary; default what findBinary finds. */
|
|
67
|
+
binary?: string;
|
|
68
|
+
/** Where the child listens: unix:/path, or host:port on a loopback address (with a token); default a private socket. */
|
|
69
|
+
address?: string;
|
|
70
|
+
startTimeoutMs?: number;
|
|
71
|
+
timeoutMs?: number;
|
|
72
|
+
logLevel?: "debug" | "info" | "warn" | "error";
|
|
73
|
+
/** Where the child's log lines go; default stderr. */
|
|
74
|
+
onLog?: (line: string) => void;
|
|
75
|
+
}
|
|
76
|
+
/** How to reach a signer that runs on its own (`Signer.connect`). */
|
|
77
|
+
export interface ConnectOptions {
|
|
78
|
+
/** The signer's token, for a loopback host:port; default $MISHMAR_LOCAL_TOKEN. */
|
|
79
|
+
token?: string;
|
|
80
|
+
startTimeoutMs?: number;
|
|
81
|
+
timeoutMs?: number;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The agent's signer, run as a child process and spoken to over its local API.
|
|
85
|
+
* The child stops when this process exits, however it exits. `Signer.connect`
|
|
86
|
+
* speaks to a signer that runs on its own instead.
|
|
87
|
+
*/
|
|
88
|
+
export declare class Signer {
|
|
89
|
+
#private;
|
|
90
|
+
readonly options: SignerOptions;
|
|
91
|
+
address: string | undefined;
|
|
92
|
+
token: string | undefined;
|
|
93
|
+
process: ChildProcess | undefined;
|
|
94
|
+
constructor(options?: SignerOptions);
|
|
95
|
+
/**
|
|
96
|
+
* Speak to a signer that already runs on its own (`mishmar signer run
|
|
97
|
+
* --local ADDRESS`, kept up by systemd, launchd or Docker), and wait until it
|
|
98
|
+
* answers. `address` is `unix:/path`, or a loopback host:port with the
|
|
99
|
+
* signer's token. Nothing is started, and `stop` leaves the signer running.
|
|
100
|
+
*/
|
|
101
|
+
static connect(address: string, options?: ConnectOptions): Promise<Signer>;
|
|
102
|
+
/** Start the child and wait until its local API answers; for a signer from `connect`, only wait until it answers. */
|
|
103
|
+
start(): Promise<Signer>;
|
|
104
|
+
/**
|
|
105
|
+
* Stop the child, gently then not: its stdin closes, which stops it, and it
|
|
106
|
+
* gets SIGTERM, then SIGKILL after 5 s. A signer from `connect` keeps
|
|
107
|
+
* running; this only forgets it was ready.
|
|
108
|
+
*/
|
|
109
|
+
stop(): Promise<void>;
|
|
110
|
+
/** Send an intent (the local API's ProposeRequest) and get the decision back. */
|
|
111
|
+
propose(intent: Record<string, unknown>): Promise<Decision>;
|
|
112
|
+
/** Propose an order. A limit order needs a price; a market order is priced at the engine's mark. */
|
|
113
|
+
order(intent: OrderIntent): Promise<Decision>;
|
|
114
|
+
/** Cancel a resting order this agent placed, by the clientOrderId its decision carries. */
|
|
115
|
+
cancel(tradingAccountId: string, symbol: string, clientOrderId: string, validForSeconds?: number): Promise<Decision>;
|
|
116
|
+
health(): Promise<Record<string, unknown>>;
|
|
117
|
+
decision(decisionId: string): Promise<Decision>;
|
|
118
|
+
decisions(query?: {
|
|
119
|
+
tradingAccountId?: string;
|
|
120
|
+
verdict?: string;
|
|
121
|
+
from?: string;
|
|
122
|
+
to?: string;
|
|
123
|
+
cursor?: string;
|
|
124
|
+
limit?: number;
|
|
125
|
+
}): Promise<DecisionPage>;
|
|
126
|
+
approvals(status?: string): Promise<Record<string, unknown>[]>;
|
|
127
|
+
positions(): Promise<Record<string, unknown>[]>;
|
|
128
|
+
pnl(query?: {
|
|
129
|
+
from?: string;
|
|
130
|
+
to?: string;
|
|
131
|
+
}): Promise<Record<string, unknown>>;
|
|
132
|
+
tradingAccount(tradingAccountId: string): Promise<Record<string, unknown>>;
|
|
133
|
+
venueRules(tradingAccountId: string, symbol?: string): Promise<Record<string, unknown>>;
|
|
134
|
+
/** Poll a decision until it no longer waits for a person, or until timeoutMs. */
|
|
135
|
+
wait(decisionId: string, { timeoutMs, intervalMs }?: {
|
|
136
|
+
timeoutMs?: number | undefined;
|
|
137
|
+
intervalMs?: number | undefined;
|
|
138
|
+
}): Promise<Decision>;
|
|
139
|
+
}
|