@lamarge/sdk 0.0.0-stage → 0.1.1

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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -2
  3. package/dist/account.d.ts +32 -0
  4. package/dist/account.js +113 -0
  5. package/dist/aster.d.ts +74 -0
  6. package/dist/aster.js +169 -0
  7. package/dist/book.d.ts +39 -0
  8. package/dist/book.js +64 -0
  9. package/dist/client.d.ts +92 -0
  10. package/dist/client.js +100 -0
  11. package/dist/delivery.d.ts +68 -0
  12. package/dist/delivery.js +118 -0
  13. package/dist/events.d.ts +30 -0
  14. package/dist/events.js +93 -0
  15. package/dist/funds.d.ts +19 -0
  16. package/dist/funds.js +17 -0
  17. package/dist/generated/api.d.ts +5741 -0
  18. package/dist/generated/api.js +281 -0
  19. package/dist/index.d.ts +15 -0
  20. package/dist/index.js +14 -0
  21. package/dist/lighter.d.ts +92 -0
  22. package/dist/lighter.js +206 -0
  23. package/dist/maker.d.ts +257 -0
  24. package/dist/maker.js +222 -0
  25. package/dist/protocol/consents.d.ts +82 -0
  26. package/dist/protocol/consents.js +78 -0
  27. package/dist/protocol/deployments.d.ts +54 -0
  28. package/dist/protocol/deployments.js +44 -0
  29. package/dist/protocol/index.d.ts +6 -0
  30. package/dist/protocol/index.js +6 -0
  31. package/dist/protocol/intents.d.ts +126 -0
  32. package/dist/protocol/intents.js +141 -0
  33. package/dist/protocol/lighter-constants.d.ts +4 -0
  34. package/dist/protocol/lighter-constants.js +49 -0
  35. package/dist/protocol/lighter.d.ts +52 -0
  36. package/dist/protocol/lighter.js +132 -0
  37. package/dist/protocol/protocol.d.ts +168 -0
  38. package/dist/protocol/protocol.js +317 -0
  39. package/dist/protocol/validation.d.ts +11 -0
  40. package/dist/protocol/validation.js +34 -0
  41. package/dist/retry-after.d.ts +2 -0
  42. package/dist/retry-after.js +7 -0
  43. package/dist/session.d.ts +14 -0
  44. package/dist/session.js +28 -0
  45. package/dist/signer.d.ts +43 -0
  46. package/dist/signer.js +28 -0
  47. package/dist/store.d.ts +21 -0
  48. package/dist/store.js +46 -0
  49. package/dist/take.d.ts +27 -0
  50. package/dist/take.js +81 -0
  51. package/dist/transfer.d.ts +63 -0
  52. package/dist/transfer.js +253 -0
  53. package/dist/withdraw.d.ts +67 -0
  54. package/dist/withdraw.js +122 -0
  55. package/package.json +64 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Snowbl Capital
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,73 @@
1
- # Temporary Holding Version
1
+ # @lamarge/sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The TypeScript client of the [LaMarge API](https://docs.lamarge.trade), for apps, takers and makers. It runs in browsers, Bun and Node 22.12+, ships ESM only and has no runtime dependency.
4
+
5
+ | Entry | Contents |
6
+ | --- | --- |
7
+ | `@lamarge/sdk` | `createClient`, every public operation by its `operationId` (`client.call`), `LaMargeError`, sign-in, signing of checked items, taker flows, `Maker`, `Quoter`, the event feed, `VenueDeliverer` |
8
+ | `@lamarge/sdk/protocol` | The reviewed deployments and every check run before a signature: EIP-712 schemas and domains, intents, withdrawals, Aster and Lighter messages, units |
9
+ | `@lamarge/sdk/aster` | `AsterDeliverer`: an active maker's transfers inside Aster. Needs `viem`, an optional peer dependency |
10
+ | `@lamarge/sdk/lighter` | `LighterDeliverer`: an active maker's transfers inside Lighter, signed by a `LighterSigner` the application holds (lighter-ts-sdk or Lighter's Go signer) |
11
+
12
+ ```ts
13
+ import { createClient, signIn, signItems } from "@lamarge/sdk";
14
+ import { privateKeyToAccount } from "viem/accounts";
15
+
16
+ const client = createClient({ apiUrl: "https://api.lamarge.trade" });
17
+ const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
18
+ await signIn(client, account, "https://app.lamarge.trade");
19
+ const balances = await client.call("getBalances");
20
+ ```
21
+
22
+ A transfer, as a server-side taker runs it. Persist `client_transfer_id`, the transfer id `onIntent` names and the store's contents: running the same request again resumes it and submits what was signed rather than signing again.
23
+
24
+ ```ts
25
+ import { transfer } from "@lamarge/sdk";
26
+
27
+ const fills = await transfer(client, account, {
28
+ client_transfer_id: "rebalance-2026-10-17-1", from: "hyperliquid", to: "polymarket",
29
+ amount_in: "250000000", min_out: "249500000", recipient: account.address,
30
+ funding: "wallet", // never withdraws from the venue; "venue" withdraws the shortfall first
31
+ }, { store, onIntent: intent => save(intent.id) });
32
+ ```
33
+
34
+ | Flow | Does |
35
+ | --- | --- |
36
+ | `signIn`, `checkDeployment` | SIWE for the app's origin, after checking every chain the API names against the pinned deployment |
37
+ | `linkAccount`, `setUpVenue` | Links an account, by the link message when it is not the master's; runs its setup steps (permit, Aster agent, Lighter key) |
38
+ | `transfer` | Funding, the take, its payment; recovery by client id; `TransferPending` when funds or a permit have not arrived yet |
39
+ | `take` | Reserves quotes, checks and signs the payment (escrow, Aster or Lighter), submits it |
40
+ | `changes` | Snapshot, then every page of events over the WebSocket, HTTP replay between connections, `{ resync }` when the journal moved on |
41
+
42
+ Making a market takes two keys on two hosts. `Maker` holds the financial key and only provisions and releases capital; `Quoter` holds a scoped credential (`mk_`) and the quote key, and only quotes, cancels and reports deliveries.
43
+
44
+ ```ts
45
+ import { createClient, Maker, Quoter } from "@lamarge/sdk";
46
+
47
+ const master = new Maker(createClient({ apiUrl }), financialAccount, operatorAddress, "https://app.lamarge.trade");
48
+ await master.login();
49
+ const credential = await master.createCredential("quoter", quoteAccount.address);
50
+ const order = await master.waitForFunding(await master.addCapital({ venue: "hyperliquid", amount: "5000000", client_request_id, funding_source: "venue" }, credential, store));
51
+
52
+ const quoter = new Quoter(createClient({ apiUrl, token: credential.token }), credential.id, quoteAccount);
53
+ await quoter.lease(30);
54
+ await quoter.batch([await quoter.prepare(await quoter.order(order.id), quotes, 5_000_000n, Math.floor(Date.now() / 1000) + 25)]);
55
+ ```
56
+
57
+ A passive maker's pools sit in the escrow (Hyperliquid, Polymarket): the operator settles every fill. An active maker's pools sit inside a venue (Lighter, Aster): its agent also runs a deliverer, which sends each paid fill's transfer, persists it in a `DeliveryJournal` before sending, never signs a fill twice and reports the transfer. The agent itself (its loop, configuration, journal storage, single-writer lock and shutdown) is the application's; LaMarge's maker example is one, available on request.
58
+
59
+ A `Signer` is an address with `signMessage` and `signTypedData`: a viem `LocalAccount`, a KMS key through viem's `toAccount`, or a browser wallet's client wrapped in those two methods. The SDK hands it only what its checks accepted; never sign a `to_sign` item that a check of `@lamarge/sdk/protocol` has not compared with your own request.
60
+
61
+ Answers are typed from the public API reference, not validated: enums stay open to values a newer server adds. A refusal is a `LaMargeError` whose `code` and `reason` are stable. Only reads and calls marked `retry` are repeated, with the identical request; never retry a write by building a new one.
62
+
63
+ ## Development
64
+
65
+ ```sh
66
+ bun install --frozen-lockfile
67
+ bun run check # generated types are current, typecheck, tests, build
68
+ bun run api:generate
69
+ ```
70
+
71
+ `src/generated/api.ts` comes from `docs/api/api-reference/openapi.yaml`, the API's public contract. The SDK is developed in LaMarge's repository, next to the server, the app and that contract; [snowbl-capital/lamarge-sdk](https://github.com/snowbl-capital/lamarge-sdk) mirrors each release, with the public contract and the Lighter test vectors its checks need. Changes are made upstream; issues are welcome there.
72
+
73
+ Each release `sdk-vX.Y.Z` is cut from the commit production runs, so the server keeps every operation and field it uses. Before 1.0 a minor release may break.
@@ -0,0 +1,32 @@
1
+ import { type Client } from "./client.ts";
2
+ import { type Signer } from "./signer.ts";
3
+ /**
4
+ * Links `owner`'s account on `venue` under the signed-in `master`: the master's own address needs
5
+ * no proof, any other signs the link message once it is checked. Its setup steps follow (`setUpVenue`).
6
+ */
7
+ export declare function linkAccount(client: Client, master: Pick<Signer, "address">, venue: string, owner?: Pick<Signer, "address" | "signMessage">, signal?: AbortSignal): Promise<{
8
+ setup: import("./index.ts").components["schemas"]["SetupSteps"];
9
+ venue: import("./index.ts").components["schemas"]["VenueId"];
10
+ address: import("./index.ts").components["schemas"]["Address"];
11
+ venue_wallet: string | null;
12
+ linked_at: import("./index.ts").components["schemas"]["Timestamp"];
13
+ }>;
14
+ /**
15
+ * Does each step of the linked account that the venue does not show yet (a permit, Aster's agent
16
+ * approval, Lighter's key) and waits until the venue shows it. A step submitted before is awaited,
17
+ * never signed twice. `owner` is the account linked on `venue`; `steps` limits the run to those
18
+ * steps, as a maker pool needs only Aster's agent and none of a taker's payment setup.
19
+ */
20
+ export declare function setUpVenue(client: Client, venue: string, owner: Signer, options?: {
21
+ steps?: readonly string[] | undefined;
22
+ signal?: AbortSignal | undefined;
23
+ }): Promise<{
24
+ venue: import("./index.ts").components["schemas"]["VenueId"];
25
+ address: import("./index.ts").components["schemas"]["Address"];
26
+ ready: boolean;
27
+ steps: import("./index.ts").components["schemas"]["SetupStep"][];
28
+ }>;
29
+ /** The permit a transfer from wallet funds asks for; one already submitted or final needs nothing. */
30
+ export declare function submitPermit(client: Client, venue: string, owner: Signer, signal?: AbortSignal): Promise<void>;
31
+ /** Whether the account's submitted permit is final; a failed one stops the transfer. */
32
+ export declare function permitDone(client: Client, venue: string, owner: Pick<Signer, "address">, signal?: AbortSignal): Promise<boolean>;
@@ -0,0 +1,113 @@
1
+ import { LaMargeError, wait } from "./client.js";
2
+ import { checkLinkChallenge } from "./protocol/consents.js";
3
+ import { requireV5Deployment } from "./protocol/deployments.js";
4
+ import { fromHex, registrationMessage, TAKER_API_KEY } from "./protocol/lighter.js";
5
+ import { CHAINS, checkAsterApproval, checkPermit, insist, same } from "./protocol/protocol.js";
6
+ import { required } from "./protocol/validation.js";
7
+ import { signTyped } from "./signer.js";
8
+ /**
9
+ * Links `owner`'s account on `venue` under the signed-in `master`: the master's own address needs
10
+ * no proof, any other signs the link message once it is checked. Its setup steps follow (`setUpVenue`).
11
+ */
12
+ export async function linkAccount(client, master, venue, owner = { ...master, signMessage: () => Promise.reject(new Error("Unreachable")) }, signal) {
13
+ let proof;
14
+ if (!same(owner.address, master.address)) {
15
+ const challenge = await client.call("postLinkChallenge", { body: { venue, address: owner.address }, signal });
16
+ checkLinkChallenge(challenge, owner.address, master.address);
17
+ proof = { nonce: challenge.nonce, signature: await owner.signMessage({ message: challenge.message }) };
18
+ }
19
+ const linked = await client.call("postLink", { body: { venue, address: owner.address, ...proof }, signal, retry: true });
20
+ insist(linked.venue === venue && same(linked.address, owner.address), "Linked another account");
21
+ return linked;
22
+ }
23
+ /** A prepared step's request, checked against what LaMarge may ask of the account, then signed. */
24
+ async function signStep(step, venue, owner, wallet) {
25
+ const request = required(step.to_sign, "Prepare the setup again");
26
+ insist(same(request.signer, owner.address), "Setup asks another account to sign");
27
+ if (request.kind === "permit") {
28
+ const chain = required(CHAINS[venue]);
29
+ requireV5Deployment(chain.id, chain.escrow);
30
+ const item = { kind: request.kind, signer: request.signer, typed_data: required(request.typed_data) };
31
+ checkPermit(item, venue, owner.address);
32
+ return signTyped(owner, item.typed_data);
33
+ }
34
+ if (request.kind === "aster_approval") {
35
+ const typed = required(request.typed_data);
36
+ const approval = { message: String(typed.message.msg), nonce: request.ref, expires_at: required(request.expires_at), typed_data: typed };
37
+ checkAsterApproval(approval, owner.address);
38
+ return signTyped(owner, approval.typed_data);
39
+ }
40
+ // Lighter's own owner message: LaMarge's key at its index, for the linked account, at the key's nonce.
41
+ const message = required(request.message);
42
+ const key = /^Register Lighter Account\n\npubkey: 0x([0-9a-f]{80})\n/.exec(message)?.[1];
43
+ insist(key && message === registrationMessage({ account: Number(required(wallet, "Link the Lighter account again")), apiKey: TAKER_API_KEY, publicKey: fromHex(key), nonce: Number(request.ref) }), "Unexpected Lighter key registration");
44
+ return owner.signMessage({ message });
45
+ }
46
+ /** Prepares and, when it is still required, signs and submits one step; an unanswered submission is sent again unchanged. */
47
+ async function runStep(client, venue, step, owner, wallet, signal) {
48
+ // Signed once: a stale Aster approval is the only one prepared and signed again.
49
+ for (let retried = false;; retried = true) {
50
+ const prepared = await client.call("postVenueSetupStep", { path: { venue, step }, body: {}, signal });
51
+ if (prepared.status !== "required")
52
+ return prepared;
53
+ const body = { ref: required(prepared.to_sign).ref, signature: await signStep(prepared, venue, owner, wallet) };
54
+ try {
55
+ for (let attempt = 1;; attempt++) {
56
+ try {
57
+ return await client.call("postVenueSetupStep", { path: { venue, step }, body, signal });
58
+ }
59
+ catch (error) {
60
+ if (!(error instanceof LaMargeError && error.code === "VENUE_UNAVAILABLE") || attempt === 3)
61
+ throw error;
62
+ await wait(2_000 * attempt, signal);
63
+ }
64
+ }
65
+ }
66
+ catch (error) {
67
+ // Aster names a stale approval only in its message; the API has no reason for it yet.
68
+ if (retried || !(error instanceof LaMargeError && error.code === "VENUE_REJECTED" && /nonce|expired/i.test(error.message)))
69
+ throw error;
70
+ }
71
+ }
72
+ }
73
+ /**
74
+ * Does each step of the linked account that the venue does not show yet (a permit, Aster's agent
75
+ * approval, Lighter's key) and waits until the venue shows it. A step submitted before is awaited,
76
+ * never signed twice. `owner` is the account linked on `venue`; `steps` limits the run to those
77
+ * steps, as a maker pool needs only Aster's agent and none of a taker's payment setup.
78
+ */
79
+ export async function setUpVenue(client, venue, owner, options = {}) {
80
+ const { steps, signal } = options;
81
+ // Lighter's key registration names the linked account's index.
82
+ let [setup, wallet] = await Promise.all([client.call("getVenueSetup", { path: { venue }, signal }),
83
+ venue === "lighter" ? client.call("getBalances", { signal }).then(rows => rows.find(row => row.venue === venue)?.venue_wallet ?? null) : null]);
84
+ insist(same(setup.address, owner.address), "Setup returned another account");
85
+ const todo = setup.steps.filter(row => row.status !== "done" && (!steps || steps.includes(row.step)));
86
+ for (const { step } of todo) {
87
+ let current = await runStep(client, venue, step, owner, wallet, signal);
88
+ for (let read = 0; current.status === "pending"; read++) {
89
+ insist(read < 60, "Setup is awaiting the venue's confirmation. Check again.");
90
+ await wait(2_000, signal);
91
+ setup = await client.call("getVenueSetup", { path: { venue }, signal });
92
+ current = required(setup.steps.find(row => row.step === step));
93
+ }
94
+ insist(current.status === "done", `${venue} setup failed`);
95
+ }
96
+ if (todo.length)
97
+ setup = await client.call("getVenueSetup", { path: { venue }, signal });
98
+ insist(same(setup.address, owner.address) && (steps ? setup.steps.every(row => row.status === "done" || !steps.includes(row.step)) : setup.ready), "Setup is incomplete");
99
+ return setup;
100
+ }
101
+ /** The permit a transfer from wallet funds asks for; one already submitted or final needs nothing. */
102
+ export async function submitPermit(client, venue, owner, signal) {
103
+ await runStep(client, venue, "permit", owner, null, signal);
104
+ }
105
+ /** Whether the account's submitted permit is final; a failed one stops the transfer. */
106
+ export async function permitDone(client, venue, owner, signal) {
107
+ const setup = await client.call("getVenueSetup", { path: { venue }, signal });
108
+ insist(same(setup.address, owner.address), "Setup returned another account");
109
+ const permit = required(setup.steps.find(step => step.step === "permit"));
110
+ if (permit.status === "required")
111
+ throw new Error("Payment authorization failed");
112
+ return permit.status === "done";
113
+ }
@@ -0,0 +1,74 @@
1
+ import { VenueDeliverer, type DeliveryJournal, type FillSource, type Log } from "./delivery.ts";
2
+ import type { DeliveryFill } from "./maker.ts";
3
+ import { type Signer } from "./signer.ts";
4
+ export declare const ASTER_MAINNET: {
5
+ chain: string;
6
+ futures: string;
7
+ spot: string;
8
+ };
9
+ /** The pool's own address, whose key signs its transfers: a dedicated address, never the master. */
10
+ export type AsterConfig = {
11
+ account: string;
12
+ };
13
+ /**
14
+ * A delivery's signed request, persisted before its first send. `attempted` is set before any
15
+ * send, `transfer_id` once Aster answered. `outcome` once settled: `reported` to LaMarge;
16
+ * `failed`, LaMarge will not take it; `refused` by Aster on the first send; `dropped`, never sent
17
+ * before the deadline; `unknown`, never answered within the nonce window, never signed again.
18
+ */
19
+ export type AsterDelivery = {
20
+ fill_id: string;
21
+ query: string;
22
+ nonce: number;
23
+ amount: string;
24
+ to: string;
25
+ attempted: boolean;
26
+ transfer_id?: string;
27
+ outcome?: "reported" | "failed" | "dropped" | "refused" | "unknown";
28
+ created_at: string;
29
+ };
30
+ /** `Message{msg}` in Aster's signing domain: its transfers' chain for requests, BSC's for an agent approval. */
31
+ export declare const signAster: (account: Pick<Signer, "signTypedData">, msg: string, chainId?: number) => Promise<`0x${string}`>;
32
+ export declare class AsterDeliverer extends VenueDeliverer<AsterDelivery> {
33
+ private owner;
34
+ private agent;
35
+ private api;
36
+ private lastNonce;
37
+ /** When each fill's next send may be tried. */
38
+ private sendAt;
39
+ /**
40
+ * `owner` signs transfers; `agent`, an agent of the same account allowed only to trade,
41
+ * moves its margin to Spot first.
42
+ */
43
+ constructor(owner: Signer, agent: Signer, store: DeliveryJournal<AsterDelivery>, api?: {
44
+ chain: string;
45
+ futures: string;
46
+ spot: string;
47
+ }, now?: () => number);
48
+ /** Microseconds, distinct for each request. */
49
+ private nonce;
50
+ /** Aster's JSON answer; a `{code<0}` answer to a request it read is a refusal, anything else unanswered is an error. */
51
+ private request;
52
+ /** A request of the agent for the account: the parameters, then nonce, user and signer. */
53
+ private byAgent;
54
+ /** The USDT the account's Spot wallet holds, in base units. */
55
+ private spot;
56
+ /**
57
+ * Moves what the Spot wallet lacks for the fill from the margin; the move's id makes a retry a
58
+ * no-op. `held` is Spot's balance when known, otherwise it is read. Returns what Spot keeps
59
+ * after the fill's transfer, or undefined after a margin move.
60
+ */
61
+ fundSpot(fill: DeliveryFill, held?: bigint): Promise<bigint | undefined>;
62
+ /** The fill's transfer at `nonce`, signed by the account's key over the request. */
63
+ sign(fill: DeliveryFill, nonce: number): Promise<AsterDelivery>;
64
+ /** Sends persisted bytes; returns the transfer id, executed or pending. */
65
+ send(delivery: AsterDelivery): Promise<string>;
66
+ /**
67
+ * Signs and sends due fills whose payment is locked, one at a time, while each transfer gets
68
+ * an answer from Aster. Spot is read once and each amount deducted locally; it is read again
69
+ * after a margin move.
70
+ */
71
+ protected deliver(quoter: FillSource, due: DeliveryFill[], deliveries: Record<string, AsterDelivery>, log: Log): Promise<boolean>;
72
+ /** Moves one persisted delivery forward; false while Aster has not answered its request. */
73
+ protected settle(quoter: FillSource, delivery: AsterDelivery, fill: DeliveryFill | undefined, deliveries: Record<string, AsterDelivery>, log: Log): Promise<boolean>;
74
+ }
package/dist/aster.js ADDED
@@ -0,0 +1,169 @@
1
+ // Delivery inside Aster: one transfer per fill from the pool's Spot wallet to the taker's,
2
+ // signed by the account's own key, persisted before it is sent and never signed twice.
3
+ import { getAddress, parseUnits } from "viem";
4
+ import { errorText, Refused, VenueDeliverer } from "./delivery.js";
5
+ import { asterAmount, asterClientId, asterRequest, ASTER_TRANSFER_CHAIN_ID, insist, venueUnits } from "./protocol/protocol.js";
6
+ import { isRecord } from "./protocol/validation.js";
7
+ import { signTyped } from "./signer.js";
8
+ export const ASTER_MAINNET = { chain: "https://chainapi.asterdex.com", futures: "https://fapi.asterdex.com", spot: "https://sapi.asterdex.com" };
9
+ /** Aster takes a signed request while its nonce is within about a minute of its clock: the same bytes are resent for 50 seconds at most. */
10
+ const WINDOW_MS = 50_000;
11
+ /** No request is sent closer than this to `deliver_by`: each one reaches Aster within its 10-second timeout, and Aster transfers synchronously. */
12
+ const SEND_MARGIN_MS = 15_000;
13
+ /** `Message{msg}` in Aster's signing domain: its transfers' chain for requests, BSC's for an agent approval. */
14
+ export const signAster = (account, msg, chainId = ASTER_TRANSFER_CHAIN_ID) => signTyped(account, asterRequest(msg, chainId));
15
+ const query = (fields) => fields.map(([name, value]) => `${name}=${value}`).join("&");
16
+ export class AsterDeliverer extends VenueDeliverer {
17
+ owner;
18
+ agent;
19
+ api;
20
+ lastNonce = 0;
21
+ /** When each fill's next send may be tried. */
22
+ sendAt = new Map();
23
+ /**
24
+ * `owner` signs transfers; `agent`, an agent of the same account allowed only to trade,
25
+ * moves its margin to Spot first.
26
+ */
27
+ constructor(owner, agent, store, api = ASTER_MAINNET, now = () => Date.now()) {
28
+ super("aster", SEND_MARGIN_MS, store, now);
29
+ this.owner = owner;
30
+ this.agent = agent;
31
+ this.api = api;
32
+ }
33
+ /** Microseconds, distinct for each request. */
34
+ nonce() { this.lastNonce = Math.max(this.now() * 1000, this.lastNonce + 1); return this.lastNonce; }
35
+ /** Aster's JSON answer; a `{code<0}` answer to a request it read is a refusal, anything else unanswered is an error. */
36
+ async request(method, url) {
37
+ const response = await fetch(url, { method, headers: { accept: "application/json", ...(method === "POST" ? { "content-type": "application/x-www-form-urlencoded" } : {}) }, signal: AbortSignal.timeout(10_000) });
38
+ const answer = await response.json().catch(() => null);
39
+ const body = isRecord(answer) ? answer : null;
40
+ if (body && typeof body.code === "number" && body.code < 0 && response.status < 500 && response.status !== 429)
41
+ throw new Refused(`Aster refused: ${body.code} ${String(body.msg ?? "")}`);
42
+ if (!response.ok || !body)
43
+ throw new Error(`Aster ${new URL(url).pathname}: HTTP ${response.status}`);
44
+ return body;
45
+ }
46
+ /** A request of the agent for the account: the parameters, then nonce, user and signer. */
47
+ async byAgent(method, url, params) {
48
+ const message = query([...params, ["nonce", String(this.nonce())], ["user", this.owner.address], ["signer", this.agent.address]]);
49
+ return this.request(method, `${url}?${message}&signature=${await signAster(this.agent, message)}`);
50
+ }
51
+ /** The USDT the account's Spot wallet holds, in base units. */
52
+ async spot() {
53
+ const account = await this.byAgent("GET", `${this.api.spot}/api/v3/account`, []);
54
+ insist(Array.isArray(account.balances), "Aster answered no Spot balances");
55
+ const balances = account.balances;
56
+ const free = balances.filter(isRecord).find(balance => balance.asset === "USDT")?.free ?? "0";
57
+ insist(typeof free === "string" && /^\d+(\.\d+)?$/.test(free), "Unexpected Aster Spot balance");
58
+ return parseUnits(free, 18);
59
+ }
60
+ /**
61
+ * Moves what the Spot wallet lacks for the fill from the margin; the move's id makes a retry a
62
+ * no-op. `held` is Spot's balance when known, otherwise it is read. Returns what Spot keeps
63
+ * after the fill's transfer, or undefined after a margin move.
64
+ */
65
+ async fundSpot(fill, held) {
66
+ const needed = venueUnits(BigInt(fill.amount_out), "aster");
67
+ held ??= await this.spot();
68
+ if (held >= needed)
69
+ return held - needed;
70
+ await this.byAgent("POST", `${this.api.futures}/fapi/v3/asset/wallet/transfer`, [["amount", asterAmount(needed - held)], ["asset", "USDT"], ["clientTranId", `m${asterClientId(fill.id)}`], ["kindType", "FUTURE_SPOT"]]);
71
+ return undefined;
72
+ }
73
+ /** The fill's transfer at `nonce`, signed by the account's key over the request. */
74
+ async sign(fill, nonce) {
75
+ insist(fill.to_venue === "aster" && fill.delivery_to !== null && /^0x[0-9a-fA-F]{40}$/.test(fill.delivery_to), "Not an Aster delivery");
76
+ const message = query([["asset", "USDT"], ["amount", asterAmount(BigInt(fill.amount_out))], ["toAddress", getAddress(fill.delivery_to)], ["clientTranId", asterClientId(fill.id)], ["nonce", String(nonce)], ["user", this.owner.address]]);
77
+ return { fill_id: fill.id, query: `${message}&signature=${await signAster(this.owner, message)}`, nonce, amount: fill.amount_out, to: fill.delivery_to, attempted: false, created_at: new Date(this.now()).toISOString() };
78
+ }
79
+ /** Sends persisted bytes; returns the transfer id, executed or pending. */
80
+ async send(delivery) {
81
+ const reply = await this.request("POST", `${this.api.chain}/aster-chain/v3/transfer?${delivery.query}`);
82
+ const id = typeof reply.transferId === "number" ? String(reply.transferId) : reply.transferId;
83
+ insist(typeof id === "string" && /^\d+$/.test(id) && ["SUCCESS", "PENDING"].includes(String(reply.status)), "Unexpected Aster transfer answer");
84
+ return id;
85
+ }
86
+ /**
87
+ * Signs and sends due fills whose payment is locked, one at a time, while each transfer gets
88
+ * an answer from Aster. Spot is read once and each amount deducted locally; it is read again
89
+ * after a margin move.
90
+ */
91
+ async deliver(quoter, due, deliveries, log) {
92
+ let spot;
93
+ for (const fill of due.filter(fill => fill.payment_leg === "LOCKED")) {
94
+ try {
95
+ if (!this.turn(this.sendAt, fill.id))
96
+ return false;
97
+ // Sign only once Spot holds the amount, so the whole nonce window is left for sending.
98
+ spot = await this.fundSpot(fill, spot);
99
+ const delivery = await this.sign(fill, this.nonce());
100
+ deliveries[fill.id] = delivery;
101
+ await this.store.save(deliveries);
102
+ this.sendAt.delete(fill.id);
103
+ if (!(await this.settle(quoter, delivery, fill, deliveries, log)))
104
+ return false;
105
+ }
106
+ catch (error) {
107
+ log(`${fill.id}: ${errorText(error)}`);
108
+ return false;
109
+ }
110
+ }
111
+ return true;
112
+ }
113
+ /** Moves one persisted delivery forward; false while Aster has not answered its request. */
114
+ async settle(quoter, delivery, fill, deliveries, log) {
115
+ const id = delivery.fill_id;
116
+ const save = async () => { await this.store.save(deliveries); };
117
+ const reported = delivery.transfer_id !== undefined && fill?.delivery_hash === delivery.transfer_id;
118
+ if (!this.awaits(fill) || reported) {
119
+ delivery.outcome = reported ? "reported" : "failed";
120
+ await save();
121
+ log(`${id}: fill no longer awaits this delivery`);
122
+ return true;
123
+ }
124
+ if (delivery.transfer_id === undefined) {
125
+ if (this.now() >= delivery.nonce / 1000 + WINDOW_MS || Date.parse(fill.deliver_by ?? "") - this.now() <= SEND_MARGIN_MS) {
126
+ // Past its window Aster refuses these bytes; an attempted one may have executed, and
127
+ // another request for the fill could pay twice.
128
+ delivery.outcome = delivery.attempted ? "unknown" : "dropped";
129
+ await save();
130
+ log(delivery.attempted
131
+ ? `${id}: Aster never answered transfer ${asterClientId(id)}; not signed again: report its transferId if the account's history shows it`
132
+ : `${id}: too close to the delivery deadline, transfer not sent`);
133
+ return true;
134
+ }
135
+ if (!this.turn(this.sendAt, id))
136
+ return false;
137
+ // Recorded before the request: a lost answer is never taken for a refusal.
138
+ const first = !delivery.attempted;
139
+ delivery.attempted = true;
140
+ await save();
141
+ try {
142
+ delivery.transfer_id = await this.send(delivery);
143
+ }
144
+ catch (error) {
145
+ // Only a first send's refusal is final: after a lost answer, the earlier send may have
146
+ // executed, and the resend's refusal says nothing of it.
147
+ if (!(error instanceof Refused) || !first)
148
+ throw error;
149
+ // Private account (-5124) or Spot short (032209): left to its refund, never signed again.
150
+ delivery.outcome = "refused";
151
+ await save();
152
+ log(`${id}: ${error.message}`);
153
+ return true;
154
+ }
155
+ this.sendAt.delete(id);
156
+ await save();
157
+ log(`${id}: transfer ${delivery.transfer_id} sent`);
158
+ }
159
+ if (!this.reportDue(id))
160
+ return true;
161
+ // Executed or pending on Aster: reported until the server takes it or never will.
162
+ const outcome = await this.report(quoter, id, delivery.transfer_id, log);
163
+ if (outcome) {
164
+ delivery.outcome = outcome;
165
+ await save();
166
+ }
167
+ return true;
168
+ }
169
+ }
package/dist/book.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ type Level = {
2
+ order_id: string;
3
+ supply: string;
4
+ receive: string;
5
+ available: string;
6
+ min_fill: string;
7
+ valid_until: string;
8
+ };
9
+ /** The most fills one take reserves. */
10
+ export declare const MAX_PARTS = 8;
11
+ /** `out` received on the destination, `pay` paid on the source. */
12
+ export interface Sweep {
13
+ out: bigint;
14
+ pay: bigint;
15
+ parts: number;
16
+ }
17
+ /**
18
+ * As the server matches: best price first, at most `maxParts`; each part takes the least of what
19
+ * is left, `available` and `maxFill`, is skipped below `min_fill` and pays rounded up. Nothing
20
+ * executes when the parts together stay below `floor`, the receiving venue's minimum deposit.
21
+ */
22
+ export declare function sweep(levels: Level[], maxFill: bigint, own: Set<string>, amount: bigint, maxParts?: number, floor?: bigint): Sweep;
23
+ /** Smallest payment the book executes, in payment units: each part meets its maker's minimum and
24
+ * the parts together `floor`. A larger payment never receives less, so a bisection finds it. */
25
+ export declare function minimumTake(levels: Level[], maxFill: bigint, own: Set<string>, floor?: bigint): bigint | null;
26
+ /** The book cannot take this request now: nothing was reserved or signed. */
27
+ export declare class QuoteUnavailable extends Error {
28
+ readonly name = "QuoteUnavailable";
29
+ }
30
+ /** That the book pays all of `amount_in` for at least `min_out`, the caller's own orders aside; `floor` is the receiving venue's minimum deposit. */
31
+ export declare function checkQuote(book: {
32
+ max_fill: string;
33
+ levels: Level[];
34
+ }, own: Set<string>, input: {
35
+ amount_in: string;
36
+ min_out: string;
37
+ max_parts?: number | undefined;
38
+ }, floor?: bigint): void;
39
+ export {};
package/dist/book.js ADDED
@@ -0,0 +1,64 @@
1
+ /** The most fills one take reserves. */
2
+ export const MAX_PARTS = 8;
3
+ const divCeil = (n, d) => (n + d - 1n) / d;
4
+ const live = (levels) => levels.filter(level => Date.parse(level.valid_until) > Date.now());
5
+ /**
6
+ * As the server matches: best price first, at most `maxParts`; each part takes the least of what
7
+ * is left, `available` and `maxFill`, is skipped below `min_fill` and pays rounded up. Nothing
8
+ * executes when the parts together stay below `floor`, the receiving venue's minimum deposit.
9
+ */
10
+ export function sweep(levels, maxFill, own, amount, maxParts = MAX_PARTS, floor = 0n) {
11
+ let left = amount;
12
+ let out = 0n;
13
+ let parts = 0;
14
+ for (const level of live(levels)) {
15
+ if (parts === maxParts || left === 0n)
16
+ break;
17
+ if (own.has(level.order_id))
18
+ continue;
19
+ const supply = BigInt(level.supply);
20
+ const receive = BigInt(level.receive);
21
+ const got = [(left * supply) / receive, BigInt(level.available), maxFill].reduce((a, b) => (a < b ? a : b));
22
+ if (got === 0n || got < BigInt(level.min_fill))
23
+ continue;
24
+ out += got;
25
+ left -= divCeil(got * receive, supply);
26
+ parts += 1;
27
+ }
28
+ return out < floor ? { out: 0n, pay: 0n, parts: 0 } : { out, pay: amount - left, parts };
29
+ }
30
+ /** Smallest payment the book executes, in payment units: each part meets its maker's minimum and
31
+ * the parts together `floor`. A larger payment never receives less, so a bisection finds it. */
32
+ export function minimumTake(levels, maxFill, own, floor = 0n) {
33
+ let high = 0n;
34
+ for (const level of live(levels)) {
35
+ if (own.has(level.order_id))
36
+ continue;
37
+ const most = BigInt(level.available) < maxFill ? BigInt(level.available) : maxFill;
38
+ high += divCeil(most * BigInt(level.receive), BigInt(level.supply));
39
+ }
40
+ if (!sweep(levels, maxFill, own, high, MAX_PARTS, floor).parts)
41
+ return null;
42
+ let low = 0n;
43
+ while (high - low > 1n) {
44
+ const middle = (low + high) / 2n;
45
+ if (sweep(levels, maxFill, own, middle, MAX_PARTS, floor).parts)
46
+ high = middle;
47
+ else
48
+ low = middle;
49
+ }
50
+ return high;
51
+ }
52
+ /** The book cannot take this request now: nothing was reserved or signed. */
53
+ export class QuoteUnavailable extends Error {
54
+ name = "QuoteUnavailable";
55
+ }
56
+ /** That the book pays all of `amount_in` for at least `min_out`, the caller's own orders aside; `floor` is the receiving venue's minimum deposit. */
57
+ export function checkQuote(book, own, input, floor = 0n) {
58
+ const amount = BigInt(input.amount_in);
59
+ const next = sweep(book.levels, BigInt(book.max_fill), own, amount, input.max_parts, floor);
60
+ if (!next.parts)
61
+ throw new QuoteUnavailable("No executable price available");
62
+ if (next.pay !== amount || next.out < BigInt(input.min_out))
63
+ throw new QuoteUnavailable("No price meets this transfer's minimum");
64
+ }