@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.
- package/LICENSE +21 -0
- package/README.md +72 -2
- package/dist/account.d.ts +32 -0
- package/dist/account.js +113 -0
- package/dist/aster.d.ts +74 -0
- package/dist/aster.js +169 -0
- package/dist/book.d.ts +39 -0
- package/dist/book.js +64 -0
- package/dist/client.d.ts +92 -0
- package/dist/client.js +100 -0
- package/dist/delivery.d.ts +68 -0
- package/dist/delivery.js +118 -0
- package/dist/events.d.ts +30 -0
- package/dist/events.js +93 -0
- package/dist/funds.d.ts +19 -0
- package/dist/funds.js +17 -0
- package/dist/generated/api.d.ts +5741 -0
- package/dist/generated/api.js +281 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +14 -0
- package/dist/lighter.d.ts +92 -0
- package/dist/lighter.js +206 -0
- package/dist/maker.d.ts +257 -0
- package/dist/maker.js +222 -0
- package/dist/protocol/consents.d.ts +82 -0
- package/dist/protocol/consents.js +78 -0
- package/dist/protocol/deployments.d.ts +54 -0
- package/dist/protocol/deployments.js +44 -0
- package/dist/protocol/index.d.ts +6 -0
- package/dist/protocol/index.js +6 -0
- package/dist/protocol/intents.d.ts +126 -0
- package/dist/protocol/intents.js +141 -0
- package/dist/protocol/lighter-constants.d.ts +4 -0
- package/dist/protocol/lighter-constants.js +49 -0
- package/dist/protocol/lighter.d.ts +52 -0
- package/dist/protocol/lighter.js +132 -0
- package/dist/protocol/protocol.d.ts +168 -0
- package/dist/protocol/protocol.js +317 -0
- package/dist/protocol/validation.d.ts +11 -0
- package/dist/protocol/validation.js +34 -0
- package/dist/retry-after.d.ts +2 -0
- package/dist/retry-after.js +7 -0
- package/dist/session.d.ts +14 -0
- package/dist/session.js +28 -0
- package/dist/signer.d.ts +43 -0
- package/dist/signer.js +28 -0
- package/dist/store.d.ts +21 -0
- package/dist/store.js +46 -0
- package/dist/take.d.ts +27 -0
- package/dist/take.js +81 -0
- package/dist/transfer.d.ts +63 -0
- package/dist/transfer.js +253 -0
- package/dist/withdraw.d.ts +67 -0
- package/dist/withdraw.js +122 -0
- package/package.json +64 -4
package/dist/signer.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { requireSigningDomains, ESCROW_DEPLOYMENTS } from "./protocol/deployments.js";
|
|
2
|
+
import { convert, insist, same } from "./protocol/protocol.js";
|
|
3
|
+
import { address, record } from "./protocol/validation.js";
|
|
4
|
+
/** `typed` as the API sends it, in the form wallets sign: the domain's type implied, values converted. */
|
|
5
|
+
export function typedDataRequest(typed) {
|
|
6
|
+
const { EIP712Domain: _, ...types } = typed.types;
|
|
7
|
+
return {
|
|
8
|
+
domain: { ...typed.domain, verifyingContract: address(typed.domain.verifyingContract) },
|
|
9
|
+
types,
|
|
10
|
+
primaryType: typed.primaryType,
|
|
11
|
+
message: record(convert(types, typed.primaryType, typed.message)),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export async function signTyped(signer, typed) {
|
|
15
|
+
insist(signer.signTypedData, "This signer cannot sign typed data");
|
|
16
|
+
return signer.signTypedData(typedDataRequest(typed));
|
|
17
|
+
}
|
|
18
|
+
export const signedItem = (item, signature) => ({ kind: item.kind, ...(item.fill_id === undefined ? {} : { fill_id: item.fill_id }), signature });
|
|
19
|
+
/** A checked bundle, signed item by item once every domain is a pinned deployment. */
|
|
20
|
+
export async function signItems(signer, items, manifest = ESCROW_DEPLOYMENTS) {
|
|
21
|
+
requireSigningDomains(items, manifest);
|
|
22
|
+
insist(items.every(item => same(item.signer, signer.address)), "This key cannot sign for a different linked account");
|
|
23
|
+
insist(signer.signTypedData, "Prepare unsigned and submit the collected contract signature");
|
|
24
|
+
const signed = [];
|
|
25
|
+
for (const item of items)
|
|
26
|
+
signed.push(signedItem(item, await signTyped(signer, item.typed_data)));
|
|
27
|
+
return signed;
|
|
28
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Typed } from "./protocol/protocol.ts";
|
|
2
|
+
import type { SignedItem } from "./signer.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Where a flow keeps what it signed until the server has it: a restart then submits the same bytes
|
|
5
|
+
* instead of signing again. Persist it (a file, a database) wherever a process can stop mid-flow.
|
|
6
|
+
* One process writes a store: calls in that process are serialized per operation, another
|
|
7
|
+
* process sharing it would sign apart.
|
|
8
|
+
*/
|
|
9
|
+
export interface SignatureStore {
|
|
10
|
+
load(key: string): unknown | Promise<unknown>;
|
|
11
|
+
save(key: string, value: unknown): void | Promise<void>;
|
|
12
|
+
delete(key: string): void | Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
/** A stored bundle of signed items, read back as such or refused. */
|
|
15
|
+
export declare function signedItems(value: unknown): SignedItem[] | undefined;
|
|
16
|
+
/** Typed data read back from a store, in the shape the checks take; the checks still decide whether it may be submitted. */
|
|
17
|
+
export declare function typedData(value: unknown): Typed;
|
|
18
|
+
/** Lost with the process: enough for one run, not for recovery after a restart. */
|
|
19
|
+
export declare function memoryStore(): SignatureStore;
|
|
20
|
+
/** Runs `run` alone among this process's calls on `store` for `key`: a second call waits, then reads what the first stored. */
|
|
21
|
+
export declare function exclusive<T>(store: SignatureStore, key: string, run: () => Promise<T>): Promise<T>;
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { array, hex, record } from "./protocol/validation.js";
|
|
2
|
+
/** A stored bundle of signed items, read back as such or refused. */
|
|
3
|
+
export function signedItems(value) {
|
|
4
|
+
if (value === undefined)
|
|
5
|
+
return undefined;
|
|
6
|
+
return array(value).map(entry => {
|
|
7
|
+
const item = record(entry);
|
|
8
|
+
return { kind: String(item.kind), ...(typeof item.fill_id === "string" ? { fill_id: item.fill_id } : {}), signature: hex(item.signature) };
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
/** Typed data read back from a store, in the shape the checks take; the checks still decide whether it may be submitted. */
|
|
12
|
+
export function typedData(value) {
|
|
13
|
+
const typed = record(value);
|
|
14
|
+
const domain = record(typed.domain);
|
|
15
|
+
if (typeof typed.primaryType !== "string" || typeof domain.name !== "string" || typeof domain.chainId !== "number" || typeof domain.verifyingContract !== "string")
|
|
16
|
+
throw new Error("Unreadable typed data");
|
|
17
|
+
const types = Object.fromEntries(Object.entries(record(typed.types)).map(([name, fields]) => [name, array(fields).map(field => {
|
|
18
|
+
const { name: key, type } = record(field);
|
|
19
|
+
if (typeof key !== "string" || typeof type !== "string")
|
|
20
|
+
throw new Error("Unreadable typed data");
|
|
21
|
+
return { name: key, type };
|
|
22
|
+
})]));
|
|
23
|
+
return { types, primaryType: typed.primaryType, message: record(typed.message),
|
|
24
|
+
domain: { name: domain.name, chainId: domain.chainId, verifyingContract: domain.verifyingContract, ...(typeof domain.version === "string" ? { version: domain.version } : {}) } };
|
|
25
|
+
}
|
|
26
|
+
/** Lost with the process: enough for one run, not for recovery after a restart. */
|
|
27
|
+
export function memoryStore() {
|
|
28
|
+
const values = new Map();
|
|
29
|
+
return { load: key => values.get(key), save: (key, value) => { values.set(key, value); }, delete: key => { values.delete(key); } };
|
|
30
|
+
}
|
|
31
|
+
const running = new WeakMap();
|
|
32
|
+
/** Runs `run` alone among this process's calls on `store` for `key`: a second call waits, then reads what the first stored. */
|
|
33
|
+
export async function exclusive(store, key, run) {
|
|
34
|
+
const queue = running.get(store) ?? new Map();
|
|
35
|
+
running.set(store, queue);
|
|
36
|
+
const previous = queue.get(key) ?? Promise.resolve();
|
|
37
|
+
const current = previous.catch(() => undefined).then(run);
|
|
38
|
+
queue.set(key, current);
|
|
39
|
+
try {
|
|
40
|
+
return await current;
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
if (queue.get(key) === current)
|
|
44
|
+
queue.delete(key);
|
|
45
|
+
}
|
|
46
|
+
}
|
package/dist/take.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Answer, type Client } from "./client.ts";
|
|
2
|
+
import type { components } from "./generated/api.ts";
|
|
3
|
+
import { type Signer } from "./signer.ts";
|
|
4
|
+
import { type SignatureStore } from "./store.ts";
|
|
5
|
+
export type Fill = Answer<"getFills">[number];
|
|
6
|
+
export type TakeInput = components["schemas"]["TakeInput"];
|
|
7
|
+
export type Balance = Answer<"getBalances">[number];
|
|
8
|
+
type Prepared = Answer<"postTake">;
|
|
9
|
+
export interface TakeOptions {
|
|
10
|
+
store?: SignatureStore | undefined;
|
|
11
|
+
signal?: AbortSignal | undefined;
|
|
12
|
+
/** A fresh read of the taker's balances, when the caller already has one. */
|
|
13
|
+
balances?: Balance[] | undefined;
|
|
14
|
+
/** A take reserved earlier, to sign and submit. */
|
|
15
|
+
prepared?: Prepared | undefined;
|
|
16
|
+
/** The account that must receive on `input.to`; the linked one otherwise. */
|
|
17
|
+
recipient?: string | undefined;
|
|
18
|
+
/** Runs before a new payment signature, never before submitting a stored one: a check that refuses stops the take. */
|
|
19
|
+
beforeSigning?: (() => Promise<void>) | undefined;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Reserves `input`'s quotes, checks what the payment would sign against the request and the
|
|
23
|
+
* linked accounts, signs and submits it. The signature is stored before its first submission, so
|
|
24
|
+
* a restart submits it again rather than signing another. `signer` is the account paying on `input.from`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function take(client: Client, signer: Signer, input: TakeInput, options?: TakeOptions): Promise<Fill[]>;
|
|
27
|
+
export {};
|
package/dist/take.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { LaMargeError } from "./client.js";
|
|
2
|
+
import { checkAsterTakeIntent, checkLighterTakeIntent, checkTakeIntent, takePaymentKind } from "./protocol/intents.js";
|
|
3
|
+
import { COLLATERAL_ROUTE, fromHex, lighterAccountOwner, TAKER_API_KEY, transferMessage, USDC_ASSET } from "./protocol/lighter.js";
|
|
4
|
+
import { ASTER_TRANSFER_CHAIN_ID, checkAsterRequest, insist, same } from "./protocol/protocol.js";
|
|
5
|
+
import { required } from "./protocol/validation.js";
|
|
6
|
+
import { MAX_PARTS } from "./book.js";
|
|
7
|
+
import { signedItem, signItems, signTyped } from "./signer.js";
|
|
8
|
+
import { memoryStore, signedItems } from "./store.js";
|
|
9
|
+
// Kinds are open strings, so the union narrows through these guards.
|
|
10
|
+
const isLighter = (item) => item.kind === "lighter_transfer";
|
|
11
|
+
const isAster = (item) => item.kind === "aster_transfer";
|
|
12
|
+
/** Each venue pays its own way: the escrow payment, or the one transfer LaMarge sends inside Aster or Lighter once the maker is locked. */
|
|
13
|
+
async function signPayment(prepared, input, signer, payer, recipient, signal) {
|
|
14
|
+
const { take, to_sign } = prepared;
|
|
15
|
+
insist(to_sign.every(entry => entry.kind === takePaymentKind(input.from)), "The signing request differs from the take's venue");
|
|
16
|
+
const item = required(to_sign[0]);
|
|
17
|
+
if (isLighter(item)) {
|
|
18
|
+
checkLighterTakeIntent(to_sign, take.fills, input, payer.address, recipient, Number(payer.venue_wallet));
|
|
19
|
+
const terms = item.typed_data;
|
|
20
|
+
insist(same(await lighterAccountOwner(terms.to_account_index, signal), required(take.fills[0]).maker_recipient), "The Lighter transfer pays another account than the maker's");
|
|
21
|
+
const message = transferMessage({
|
|
22
|
+
from: terms.from_account_index, apiKey: TAKER_API_KEY, to: terms.to_account_index, asset: USDC_ASSET, fromRoute: COLLATERAL_ROUTE, toRoute: COLLATERAL_ROUTE,
|
|
23
|
+
amount: BigInt(terms.amount), fee: BigInt(terms.usdc_fee), memo: fromHex(terms.memo), expiredAt: 0, nonce: terms.nonce,
|
|
24
|
+
});
|
|
25
|
+
return [signedItem(item, await signer.signMessage({ message }))];
|
|
26
|
+
}
|
|
27
|
+
if (isAster(item)) {
|
|
28
|
+
checkAsterTakeIntent(to_sign, take.fills, input, payer.address, recipient);
|
|
29
|
+
checkAsterRequest(item.typed_data, ASTER_TRANSFER_CHAIN_ID);
|
|
30
|
+
return [signedItem(item, await signTyped(signer, item.typed_data))];
|
|
31
|
+
}
|
|
32
|
+
const typed = to_sign.filter((entry) => !isLighter(entry) && !isAster(entry));
|
|
33
|
+
checkTakeIntent(typed, take.fills, input, payer.address, recipient);
|
|
34
|
+
return signItems(signer, typed);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Reserves `input`'s quotes, checks what the payment would sign against the request and the
|
|
38
|
+
* linked accounts, signs and submits it. The signature is stored before its first submission, so
|
|
39
|
+
* a restart submits it again rather than signing another. `signer` is the account paying on `input.from`.
|
|
40
|
+
*/
|
|
41
|
+
export async function take(client, signer, input, options = {}) {
|
|
42
|
+
const { store = memoryStore(), signal } = options;
|
|
43
|
+
input = { ...input, max_parts: input.max_parts ?? MAX_PARTS };
|
|
44
|
+
const balances = options.balances ?? await client.call("getBalances", { signal });
|
|
45
|
+
const payer = balances.find(balance => balance.venue === input.from);
|
|
46
|
+
const recipient = balances.find(balance => balance.venue === input.to)?.address;
|
|
47
|
+
insist(payer && recipient, "Link both venues first");
|
|
48
|
+
insist(options.recipient === undefined || same(recipient, options.recipient), "Linked account changed; review the transfer again");
|
|
49
|
+
insist(same(payer.address, signer.address), "The signer is not the account paying on this venue");
|
|
50
|
+
const prepared = options.prepared ?? await client.call("postTake", { body: input, signal });
|
|
51
|
+
const { take: reserved, to_sign } = prepared;
|
|
52
|
+
if (!to_sign.length || input.transfer_intent_id && reserved.fills.some(fill => fill.state !== "RESERVED"))
|
|
53
|
+
return reserved.fills;
|
|
54
|
+
const key = `take:${reserved.id}`;
|
|
55
|
+
let signatures = signedItems(await store.load(key));
|
|
56
|
+
if (!signatures) {
|
|
57
|
+
try {
|
|
58
|
+
await options.beforeSigning?.();
|
|
59
|
+
signatures = await signPayment(prepared, input, signer, payer, recipient, signal);
|
|
60
|
+
signal?.throwIfAborted();
|
|
61
|
+
await store.save(key, signatures);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
// A transfer's own recovery decides what happens to its reservation.
|
|
65
|
+
if (!input.transfer_intent_id)
|
|
66
|
+
await client.call("deleteTake", { path: { id: reserved.id } }).catch(() => null);
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const { fills } = await client.call("postTakeSignatures", { path: { id: reserved.id }, body: { signatures }, signal, retry: true });
|
|
72
|
+
await store.delete(key);
|
|
73
|
+
return fills;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
// Expired: none of its signatures was accepted, so they can never be used.
|
|
77
|
+
if (error instanceof LaMargeError && error.code === "TAKE_EXPIRED")
|
|
78
|
+
await store.delete(key);
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type Answer, type Client } from "./client.ts";
|
|
2
|
+
import type { Signer } from "./signer.ts";
|
|
3
|
+
import { type SignatureStore } from "./store.ts";
|
|
4
|
+
import { type Fill } from "./take.ts";
|
|
5
|
+
export type TransferIntent = Answer<"getTransfer">;
|
|
6
|
+
/**
|
|
7
|
+
* Where a transfer from an escrow venue pays from. `venue`: what the escrow chain does not hold
|
|
8
|
+
* is withdrawn from the venue account first. `wallet`: only the address and its available balance
|
|
9
|
+
* pay; a shortfall stops the transfer before anything is signed, and nothing is withdrawn.
|
|
10
|
+
*/
|
|
11
|
+
export type Funding = "venue" | "wallet";
|
|
12
|
+
export interface TransferRequest {
|
|
13
|
+
/** Persist it before the first call: the same id finds the same transfer again after a restart. */
|
|
14
|
+
client_transfer_id: string;
|
|
15
|
+
from: string;
|
|
16
|
+
to: string;
|
|
17
|
+
amount_in: string;
|
|
18
|
+
min_out: string;
|
|
19
|
+
max_parts?: number | undefined;
|
|
20
|
+
/** The linked account receiving on `to`; the payer is the signer. */
|
|
21
|
+
recipient: string;
|
|
22
|
+
funding: Funding;
|
|
23
|
+
/** The transfer to resume, as `onIntent` last named it. */
|
|
24
|
+
transfer_id?: string | undefined;
|
|
25
|
+
}
|
|
26
|
+
export type TransferStage = "checking" | "funding" | "waiting" | "authorizing" | "taking";
|
|
27
|
+
export interface TransferOptions {
|
|
28
|
+
store?: SignatureStore | undefined;
|
|
29
|
+
signal?: AbortSignal | undefined;
|
|
30
|
+
onStage?: ((stage: TransferStage) => void) | undefined;
|
|
31
|
+
/** Called with each transfer this one becomes: a stale one is replaced, never repeated. Persist its id; the flow waits for a returned promise. */
|
|
32
|
+
onIntent?: ((intent: TransferIntent) => void | Promise<void>) | undefined;
|
|
33
|
+
/** Between reads while funds or a permit arrive. */
|
|
34
|
+
pollMs?: number | undefined;
|
|
35
|
+
/** How long to wait for funds or a permit before `TransferPending`. */
|
|
36
|
+
arrivalWaitMs?: number | undefined;
|
|
37
|
+
now?: (() => number) | undefined;
|
|
38
|
+
}
|
|
39
|
+
/** Nothing failed, but the transfer cannot go on yet; run it again with the same request later. */
|
|
40
|
+
export declare class TransferPending extends Error {
|
|
41
|
+
readonly intent: TransferIntent;
|
|
42
|
+
readonly reason: "arrival_unconfirmed" | "authorization_unconfirmed" | "unknown" | "refused";
|
|
43
|
+
readonly name = "TransferPending";
|
|
44
|
+
constructor(intent: TransferIntent, reason: "arrival_unconfirmed" | "authorization_unconfirmed" | "unknown" | "refused");
|
|
45
|
+
}
|
|
46
|
+
/** A reservation nothing was signed or paid for: replacing it repeats nothing. */
|
|
47
|
+
export declare const unsignedReservation: (fill: {
|
|
48
|
+
state: string;
|
|
49
|
+
payment_leg: string;
|
|
50
|
+
maker_leg: string;
|
|
51
|
+
transactions: readonly unknown[];
|
|
52
|
+
delivery_hash: string | null;
|
|
53
|
+
signed_at?: string | null | undefined;
|
|
54
|
+
payment_locked_at?: string | null | undefined;
|
|
55
|
+
maker_claimed_at?: string | null | undefined;
|
|
56
|
+
}) => boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Moves `amount_in` from the signer's account on `from` to `recipient` on `to`, for at least
|
|
59
|
+
* `min_out`: funding first (per `funding`), then the take, signed and submitted. Durable
|
|
60
|
+
* operations are resolved before anything new: running the same request again resumes it, and a
|
|
61
|
+
* withdrawal is never repeated because a balance looks short.
|
|
62
|
+
*/
|
|
63
|
+
export declare function transfer(client: Client, signer: Signer, request: TransferRequest, options?: TransferOptions): Promise<Fill[]>;
|
package/dist/transfer.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { LaMargeError, wait } from "./client.js";
|
|
2
|
+
import { permitDone, submitPermit } from "./account.js";
|
|
3
|
+
import { prefunded, sweepable } from "./funds.js";
|
|
4
|
+
import { checkFundingPlan } from "./protocol/consents.js";
|
|
5
|
+
import { insist, same, shortfall, VENUE_SETTLED } from "./protocol/protocol.js";
|
|
6
|
+
import { required } from "./protocol/validation.js";
|
|
7
|
+
import { exclusive, memoryStore } from "./store.js";
|
|
8
|
+
import { submitWithdrawal } from "./withdraw.js";
|
|
9
|
+
import { checkQuote, MAX_PARTS } from "./book.js";
|
|
10
|
+
import { take } from "./take.js";
|
|
11
|
+
/** Nothing failed, but the transfer cannot go on yet; run it again with the same request later. */
|
|
12
|
+
export class TransferPending extends Error {
|
|
13
|
+
intent;
|
|
14
|
+
reason;
|
|
15
|
+
name = "TransferPending";
|
|
16
|
+
constructor(intent, reason) {
|
|
17
|
+
super(reason === "refused" ? intent.withdrawal?.error ?? "Funding refused" : reason === "unknown" ? "Funding outcome unknown"
|
|
18
|
+
: reason === "authorization_unconfirmed" ? "Authorization not confirmed yet" : "Funds not received yet");
|
|
19
|
+
this.intent = intent;
|
|
20
|
+
this.reason = reason;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** The book, the caller's own orders aside, pays all of the take for at least its minimum. */
|
|
24
|
+
async function reviewQuote(client, input, signal) {
|
|
25
|
+
const [book, orders, venues] = await Promise.all([
|
|
26
|
+
client.call("getBook", { query: { from: input.from, to: input.to }, signal }), client.call("getOrders", { signal }), client.call("getVenues", { signal })
|
|
27
|
+
]);
|
|
28
|
+
checkQuote(book, new Set(orders.map(order => order.id)), input, BigInt(venues.find(venue => venue.id === input.to)?.release_minimum ?? 0));
|
|
29
|
+
}
|
|
30
|
+
/** A reservation nothing was signed or paid for: replacing it repeats nothing. */
|
|
31
|
+
export const unsignedReservation = (fill) => (fill.state === "RESERVED" || fill.state === "EXPIRED") && fill.signed_at === null
|
|
32
|
+
&& fill.payment_leg === "NONE" && (fill.maker_leg === "NONE" || fill.maker_leg === "ABANDONED")
|
|
33
|
+
&& fill.transactions.length === 0 && !fill.delivery_hash && !fill.payment_locked_at && !fill.maker_claimed_at;
|
|
34
|
+
function replacement(intent, clientId) {
|
|
35
|
+
const { from, to, amount_in, min_out, max_parts, payer, recipient, then, previous_transfer_id: previous } = intent;
|
|
36
|
+
return { client_transfer_id: clientId, from, to, amount_in, min_out, max_parts, payer, recipient, ...(then ? { then } : {}), ...(previous ? { previous_transfer_id: previous } : {}) };
|
|
37
|
+
}
|
|
38
|
+
/** The transfer's withdrawal from the venue account, to the signer, for exactly `amount`. Once the server records it, the transfer names it: the stored signature can go. */
|
|
39
|
+
async function withdrawFor(client, signer, intent, amount, store, signal) {
|
|
40
|
+
const key = `transfer-withdrawal:${intent.id}`;
|
|
41
|
+
return exclusive(store, key, async () => {
|
|
42
|
+
try {
|
|
43
|
+
const { result } = await submitWithdrawal({
|
|
44
|
+
key, venue: intent.from, signer, destination: signer.address, amount, store, signal,
|
|
45
|
+
prepare: async () => intent.withdrawal_preparation?.typed_data
|
|
46
|
+
?? (await client.call("prepareTransferWithdrawal", { path: { id: intent.id }, body: { amount: amount.toString() }, signal })).typed_data,
|
|
47
|
+
submit: (_typed, signature) => client.call("submitTransferWithdrawal", { path: { id: intent.id }, body: { signature }, signal, retry: true }),
|
|
48
|
+
});
|
|
49
|
+
if (result.state !== "unknown")
|
|
50
|
+
await store.delete(key);
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error instanceof LaMargeError && error.code === "VENUE_REJECTED")
|
|
55
|
+
await store.delete(key);
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Moves `amount_in` from the signer's account on `from` to `recipient` on `to`, for at least
|
|
62
|
+
* `min_out`: funding first (per `funding`), then the take, signed and submitted. Durable
|
|
63
|
+
* operations are resolved before anything new: running the same request again resumes it, and a
|
|
64
|
+
* withdrawal is never repeated because a balance looks short.
|
|
65
|
+
*/
|
|
66
|
+
export async function transfer(client, signer, request, options = {}) {
|
|
67
|
+
const { store = memoryStore(), signal, onStage, onIntent, pollMs = 2_000, arrivalWaitMs = 10 * 60_000, now = Date.now } = options;
|
|
68
|
+
const payer = signer.address;
|
|
69
|
+
const maxParts = request.max_parts ?? MAX_PARTS;
|
|
70
|
+
const stage = (value) => { signal?.throwIfAborted(); onStage?.(value); };
|
|
71
|
+
// A venue settled inside itself pays from its account: there is no wallet to pay from.
|
|
72
|
+
insist(request.funding === "venue" || !VENUE_SETTLED.has(request.from), `Wallet funding does not apply to ${request.from}, whose account pays`);
|
|
73
|
+
stage("checking");
|
|
74
|
+
let intent = request.transfer_id
|
|
75
|
+
? await client.call("getTransfer", { path: { id: request.transfer_id }, query: { funding_plan: true }, signal })
|
|
76
|
+
: await client.call("postTransfer", { body: { client_transfer_id: request.client_transfer_id, from: request.from, to: request.to, amount_in: request.amount_in,
|
|
77
|
+
min_out: request.min_out, max_parts: maxParts, payer, recipient: request.recipient }, signal, retry: true });
|
|
78
|
+
// A replacement decided before a lost cancellation answer is completed, never decided again.
|
|
79
|
+
const renewal = (id) => `renewal:${id}`;
|
|
80
|
+
let renewedFrom;
|
|
81
|
+
if (intent.cancelled_at && await store.load(renewal(intent.id)) !== undefined) {
|
|
82
|
+
renewedFrom = intent.id;
|
|
83
|
+
intent = await client.call("postTransfer", { body: replacement(intent, `renew:${renewedFrom}`), signal, retry: true });
|
|
84
|
+
}
|
|
85
|
+
const checkIntent = () => {
|
|
86
|
+
insist(!intent.cancelled_at, "Transfer cancelled");
|
|
87
|
+
insist(intent.max_parts === maxParts && intent.from === request.from && intent.to === request.to && intent.amount_in === request.amount_in
|
|
88
|
+
&& intent.min_out === request.min_out && same(intent.payer, payer) && same(intent.recipient, request.recipient), "Transfer terms changed; review the original transfer");
|
|
89
|
+
};
|
|
90
|
+
checkIntent();
|
|
91
|
+
if (!intent.withdrawal && !intent.take_id && intent.withdrawal_preparation && now() - Date.parse(intent.withdrawal_preparation.created_at) > 45_000) {
|
|
92
|
+
// Cancellation serializes with acceptance; an uncertain withdrawal is never replaced.
|
|
93
|
+
renewedFrom = intent.id;
|
|
94
|
+
await store.save(renewal(renewedFrom), true);
|
|
95
|
+
try {
|
|
96
|
+
await client.call("cancelTransfer", { path: { id: intent.id }, signal });
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
// An answered refusal means nothing was cancelled: no replacement is owed. Only a lost answer keeps the marker.
|
|
100
|
+
if (error instanceof LaMargeError)
|
|
101
|
+
await store.delete(renewal(renewedFrom));
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
intent = await client.call("postTransfer", { body: replacement(intent, `renew:${intent.id}`), signal, retry: true });
|
|
105
|
+
checkIntent();
|
|
106
|
+
}
|
|
107
|
+
await onIntent?.(intent);
|
|
108
|
+
if (renewedFrom)
|
|
109
|
+
await store.delete(renewal(renewedFrom));
|
|
110
|
+
// A replacement of a take that expired unsigned after its funding: never funded again.
|
|
111
|
+
const funded = (id) => `funded:${id}`;
|
|
112
|
+
const alreadyFunded = await store.load(funded(intent.id)) !== undefined;
|
|
113
|
+
const amount = BigInt(intent.amount_in);
|
|
114
|
+
const readFunds = async () => {
|
|
115
|
+
const balances = await client.call("getBalances", { signal });
|
|
116
|
+
const from = balances.find(balance => balance.venue === intent.from);
|
|
117
|
+
const to = balances.find(balance => balance.venue === intent.to);
|
|
118
|
+
insist(same(from?.address, payer) && same(to?.address, request.recipient), "Linked account changed; review the transfer again");
|
|
119
|
+
// Ready pays the take; supplied adds what the deposit address holds until it is swept.
|
|
120
|
+
const ready = prefunded(from, now());
|
|
121
|
+
return ready === null ? null : { ready, supplied: ready + sweepable(from), balances };
|
|
122
|
+
};
|
|
123
|
+
const covers = (funds) => insist(funds !== null && funds.supplied >= amount, "Wallet funds do not cover the transfer");
|
|
124
|
+
// Every new payment signature from wallet funds is preceded by a fresh read that covers it.
|
|
125
|
+
const beforeSigning = request.funding === "wallet" ? async () => covers(await readFunds()) : undefined;
|
|
126
|
+
const takeInput = () => ({ from: intent.from, to: intent.to, amount_in: intent.amount_in, min_out: intent.min_out, max_parts: intent.max_parts,
|
|
127
|
+
client_take_id: intent.client_take_id, transfer_intent_id: intent.id });
|
|
128
|
+
const pay = (balances) => take(client, signer, takeInput(), { store, signal, balances, recipient: request.recipient, beforeSigning });
|
|
129
|
+
// A take reserved again is priced again first, as the app does: a book that no longer pays its minimum reserves nothing.
|
|
130
|
+
const repriced = async (balances) => {
|
|
131
|
+
const [, read] = await Promise.all([reviewQuote(client, takeInput(), signal), balances ?? client.call("getBalances", { signal })]);
|
|
132
|
+
return pay(read);
|
|
133
|
+
};
|
|
134
|
+
/** The take already reserved, signed or paid, settled first; a replaced one is priced and taken again. Null when there is none. */
|
|
135
|
+
const recoverTake = async () => {
|
|
136
|
+
let renewed = false;
|
|
137
|
+
while (intent.take_id) {
|
|
138
|
+
stage("taking");
|
|
139
|
+
let prepared = await client.call("getV1TakesId", { path: { id: intent.take_id }, signal });
|
|
140
|
+
const unsigned = () => prepared.take.fills.length > 0 && prepared.take.fills.every(unsignedReservation);
|
|
141
|
+
if (unsigned() && prepared.take.fills.some(fill => fill.state === "RESERVED" && Date.parse(fill.pay_by) <= now())) {
|
|
142
|
+
// Cancellation serializes with acceptance. A late or uncertain signature is never replaced.
|
|
143
|
+
await client.call("deleteTake", { path: { id: intent.take_id }, signal });
|
|
144
|
+
prepared = await client.call("getV1TakesId", { path: { id: intent.take_id }, signal });
|
|
145
|
+
}
|
|
146
|
+
if (!unsigned() || !prepared.take.fills.every(fill => fill.state === "EXPIRED")) {
|
|
147
|
+
if (!prepared.to_sign.length || prepared.take.fills.some(fill => fill.state !== "RESERVED"))
|
|
148
|
+
return prepared.take.fills;
|
|
149
|
+
return take(client, signer, takeInput(), { store, signal, prepared, recipient: request.recipient, beforeSigning });
|
|
150
|
+
}
|
|
151
|
+
// A deterministic replacement survives a lost response or a restart, with the same terms.
|
|
152
|
+
intent = await client.call("postTransfer", { body: replacement(intent, `retry:${intent.id}`), signal, retry: true });
|
|
153
|
+
checkIntent();
|
|
154
|
+
await store.save(funded(intent.id), true);
|
|
155
|
+
await onIntent?.(intent);
|
|
156
|
+
renewed = true;
|
|
157
|
+
}
|
|
158
|
+
// The original take was already funded: repricing never triggers another withdrawal.
|
|
159
|
+
return renewed ? repriced() : null;
|
|
160
|
+
};
|
|
161
|
+
const recovered = await recoverTake();
|
|
162
|
+
if (recovered)
|
|
163
|
+
return recovered;
|
|
164
|
+
if (alreadyFunded)
|
|
165
|
+
return repriced();
|
|
166
|
+
// Paid inside the venue: nothing to fund, the take's transfer comes from the venue account.
|
|
167
|
+
if (VENUE_SETTLED.has(intent.from)) {
|
|
168
|
+
stage("taking");
|
|
169
|
+
return pay();
|
|
170
|
+
}
|
|
171
|
+
const checkWithdrawalState = () => {
|
|
172
|
+
if (intent.withdrawal?.state === "refused")
|
|
173
|
+
throw new TransferPending(intent, "refused");
|
|
174
|
+
if (intent.withdrawal?.state === "unknown")
|
|
175
|
+
throw new TransferPending(intent, "unknown");
|
|
176
|
+
};
|
|
177
|
+
// A wallet deposit without payment setup signs its permit first; the take waits until it is final.
|
|
178
|
+
let authorizing = false;
|
|
179
|
+
const fund = async () => {
|
|
180
|
+
const plan = checkFundingPlan(intent, now());
|
|
181
|
+
insist(request.funding === "venue" || BigInt(plan.withdrawal_amount) === 0n, "Wallet funds do not cover the transfer");
|
|
182
|
+
authorizing = ["required", "pending"].includes(plan.payment_setup);
|
|
183
|
+
if (plan.requests.some(item => item.kind === "permit")) {
|
|
184
|
+
stage("funding");
|
|
185
|
+
await submitPermit(client, intent.from, signer, signal);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
checkWithdrawalState();
|
|
189
|
+
let funds = await readFunds();
|
|
190
|
+
// Wallet funds are read before the permit too: a transfer they cannot pay signs nothing.
|
|
191
|
+
if (request.funding === "wallet" && !intent.withdrawal)
|
|
192
|
+
covers(funds);
|
|
193
|
+
let planned = false;
|
|
194
|
+
if (!intent.withdrawal) {
|
|
195
|
+
await fund();
|
|
196
|
+
planned = true;
|
|
197
|
+
}
|
|
198
|
+
if (!intent.withdrawal && (intent.withdrawal_preparation || funds !== null && funds.supplied < amount)) {
|
|
199
|
+
insist(request.funding === "venue", "Wallet funds do not cover the transfer");
|
|
200
|
+
// Margin leaves the venue only for a take the book can execute now.
|
|
201
|
+
if (!intent.withdrawal_preparation)
|
|
202
|
+
await reviewQuote(client, takeInput(), signal);
|
|
203
|
+
stage("funding");
|
|
204
|
+
const preparation = intent.withdrawal_preparation;
|
|
205
|
+
const withdrawal = preparation ? BigInt(preparation.amount) : shortfall(amount, required(funds, "A fresh balance is required").supplied, intent.from);
|
|
206
|
+
if (intent.funding_plan)
|
|
207
|
+
insist(withdrawal === BigInt(intent.funding_plan.withdrawal_amount), "Funding amount changed; review the transfer again");
|
|
208
|
+
intent.withdrawal = await withdrawFor(client, signer, intent, withdrawal, store, signal);
|
|
209
|
+
checkWithdrawalState();
|
|
210
|
+
// Until it is relayed the wait below reads the balance anyway.
|
|
211
|
+
if (intent.withdrawal.state === "relayed")
|
|
212
|
+
funds = await readFunds();
|
|
213
|
+
}
|
|
214
|
+
insist(intent.withdrawal || funds !== null, "A fresh balance is required; try again shortly");
|
|
215
|
+
const waitingForFunds = () => funds === null || funds.ready < amount || (intent.withdrawal != null && intent.withdrawal.state !== "relayed");
|
|
216
|
+
const deadline = now() + arrivalWaitMs;
|
|
217
|
+
let reread = false;
|
|
218
|
+
if (waitingForFunds())
|
|
219
|
+
stage("waiting");
|
|
220
|
+
while (waitingForFunds()) {
|
|
221
|
+
if (now() >= deadline)
|
|
222
|
+
throw new TransferPending(intent, "arrival_unconfirmed");
|
|
223
|
+
await wait(pollMs, signal);
|
|
224
|
+
[intent, funds] = await Promise.all([client.call("getTransfer", { path: { id: intent.id }, signal }), readFunds()]);
|
|
225
|
+
checkIntent();
|
|
226
|
+
const accepted = await recoverTake();
|
|
227
|
+
if (accepted)
|
|
228
|
+
return accepted;
|
|
229
|
+
checkWithdrawalState();
|
|
230
|
+
reread = true;
|
|
231
|
+
}
|
|
232
|
+
stage("taking");
|
|
233
|
+
// The wait's last read stands unless the funding plan is still to be read.
|
|
234
|
+
if (!reread || !planned) {
|
|
235
|
+
intent = await client.call("getTransfer", { path: { id: intent.id }, query: { funding_plan: !planned }, signal });
|
|
236
|
+
checkIntent();
|
|
237
|
+
const accepted = await recoverTake();
|
|
238
|
+
if (accepted)
|
|
239
|
+
return accepted;
|
|
240
|
+
}
|
|
241
|
+
if (!planned && intent.funding_plan)
|
|
242
|
+
await fund();
|
|
243
|
+
// The permit gets its own wait, however long the funds took to arrive.
|
|
244
|
+
const confirmBy = now() + arrivalWaitMs;
|
|
245
|
+
while (authorizing && !await permitDone(client, intent.from, signer, signal)) {
|
|
246
|
+
stage("authorizing");
|
|
247
|
+
if (now() >= confirmBy)
|
|
248
|
+
throw new TransferPending(intent, "authorization_unconfirmed");
|
|
249
|
+
await wait(pollMs, signal);
|
|
250
|
+
}
|
|
251
|
+
stage("taking");
|
|
252
|
+
return repriced(required(funds, "A fresh balance is required").balances);
|
|
253
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type Client } from "./client.ts";
|
|
2
|
+
import type { components } from "./generated/api.ts";
|
|
3
|
+
import { type Typed } from "./protocol/protocol.ts";
|
|
4
|
+
import { type Signer } from "./signer.ts";
|
|
5
|
+
import { type SignatureStore } from "./store.ts";
|
|
6
|
+
export type Withdrawal = components["schemas"]["Withdrawal"];
|
|
7
|
+
/**
|
|
8
|
+
* One withdrawal's signature, stored under `key` (with `context`) before its first submission and
|
|
9
|
+
* submitted again after a restart; a stored one is checked at its own signing time. The caller
|
|
10
|
+
* decides when the record may go.
|
|
11
|
+
*/
|
|
12
|
+
export declare function submitWithdrawal(options: {
|
|
13
|
+
key: string;
|
|
14
|
+
context?: Record<string, unknown>;
|
|
15
|
+
venue: string;
|
|
16
|
+
signer: Signer;
|
|
17
|
+
destination: string;
|
|
18
|
+
amount: bigint;
|
|
19
|
+
store: SignatureStore;
|
|
20
|
+
signal?: AbortSignal | undefined;
|
|
21
|
+
prepare: () => Promise<Typed>;
|
|
22
|
+
submit: (typed: Typed, signature: `0x${string}`) => Promise<Withdrawal>;
|
|
23
|
+
}): Promise<{
|
|
24
|
+
result: Withdrawal;
|
|
25
|
+
saved: Record<string, unknown>;
|
|
26
|
+
}>;
|
|
27
|
+
export interface WithdrawalRequest {
|
|
28
|
+
/** Persist it before the first call and keep it: the same id finds this withdrawal again, never a second one. */
|
|
29
|
+
id: string;
|
|
30
|
+
venue: string;
|
|
31
|
+
amount: bigint;
|
|
32
|
+
/** `signer`, the linked address; `wallet`, its venue wallet. */
|
|
33
|
+
to: "wallet" | "signer";
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Withdraws exactly `amount` from the signer's Hyperliquid or Polymarket account. Its signature
|
|
37
|
+
* and outcome stay in `store` under `id`: running the same request again returns the outcome or
|
|
38
|
+
* submits the same signature, and a refused one asks for a new id. Aster relays a withdrawal in a
|
|
39
|
+
* second envelope this flow does not sign yet.
|
|
40
|
+
*/
|
|
41
|
+
export declare function withdraw(client: Client, signer: Signer, request: WithdrawalRequest, options?: {
|
|
42
|
+
store?: SignatureStore | undefined;
|
|
43
|
+
signal?: AbortSignal | undefined;
|
|
44
|
+
}): Promise<Withdrawal>;
|
|
45
|
+
/** Reads the pinned escrows, as a viem `PublicClient` per chain id does with `readContract`. */
|
|
46
|
+
export type ChainReaders = Record<number, {
|
|
47
|
+
readContract(request: {
|
|
48
|
+
address: `0x${string}`;
|
|
49
|
+
abi: readonly unknown[];
|
|
50
|
+
functionName: string;
|
|
51
|
+
args: readonly unknown[];
|
|
52
|
+
blockTag?: "finalized" | "latest";
|
|
53
|
+
}): Promise<unknown>;
|
|
54
|
+
}>;
|
|
55
|
+
/** What a refund reads of the fill: a REST fill fits. */
|
|
56
|
+
export type RefundFill = Pick<components["schemas"]["Fill"], "id" | "role" | "maker_leg" | "payment_leg" | "t2" | "from_venue" | "to_venue" | "maker_recipient" | "maker_owner" | "payer" | "swap_id" | "order_id" | "amount_in" | "hashlock" | "deliver_by" | "delivery_hash">;
|
|
57
|
+
/**
|
|
58
|
+
* The pinned contracts, read directly: the payment lock at a finalized block, the maker's lock at
|
|
59
|
+
* the latest, where the destination has one, and the consent digest, which an old ABI cannot
|
|
60
|
+
* answer. A server's label cannot authorize giving up a payment for capital locked or delivered.
|
|
61
|
+
*/
|
|
62
|
+
export declare function checkEscrowLocks(fill: RefundFill, deadline: number, readers: ChainReaders): Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* As the maker of an abandoned fill, gives the taker's locked payment back before `t2`. Without a
|
|
65
|
+
* reader of each chain the fill touches, nothing is signed.
|
|
66
|
+
*/
|
|
67
|
+
export declare function refundPayment(client: Client, signer: Signer, fill: RefundFill, readers: ChainReaders, signal?: AbortSignal): Promise<void>;
|