@dshtrading/authority 0.6.1 → 0.6.2

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.
@@ -0,0 +1,182 @@
1
+ //#region src/order-intents.d.ts
2
+ /** 台账目录名(挂在 trading home 下;与授权平面无关)。 */
3
+ declare const ORDER_INTENTS_DIR_NAME = "order-intents";
4
+ /** 记录协议版本(逐帧校验;换格式时旧记录必须被拒而不是被猜)。 */
5
+ declare const ORDER_INTENT_PROTOCOL_VERSION = 1;
6
+ /** 本模块的机器可读错误码(与 @dshtrading/api 的 TradingErrorCode 同名同义)。 */
7
+ type OrderIntentErrorCode = 'TRADING_INTENT_REQUIRED' | 'TRADING_INTENT_CONFLICT' | 'TRADING_INTENT_UNKNOWN' | 'TRADING_INTENT_STORE_FAILED' | 'TRADING_EXCHANGE_ERROR';
8
+ declare class OrderIntentError extends Error {
9
+ readonly code: OrderIntentErrorCode;
10
+ constructor(code: OrderIntentErrorCode, message: string, cause?: unknown);
11
+ }
12
+ /**
13
+ * 意图身份的作用域:provider + 环境 + 账户三者与载荷一起进指纹。
14
+ *
15
+ * `account` 必须是**非密**账户定位(配置里的 accId/accountId,或插件确实没有账户
16
+ * 字段时的稳定占位 `default`)。它**不得**是密钥/token/私钥:凭据会轮换,而轮换不是
17
+ * 换账户——把凭据放进身份会让「同一个未知意图」在换密钥后变成一条新记录并被重发。
18
+ * 同理,账户定位里也不要放能从凭据派生出来的东西。
19
+ */
20
+ interface OrderIntentScope {
21
+ /** 连接器/provider slug(如 `okx`)。 */
22
+ readonly provider: string;
23
+ /** 执行环境(`live` / `demo` / `REAL` / `SIMULATE` …)。 */
24
+ readonly environment: string;
25
+ /** 非密账户定位(无账户字段的插件用 `default`)。 */
26
+ readonly account: string;
27
+ }
28
+ /** 参与冲突判定的规范化载荷:语义字段(不含任何凭据、不含 venue client key)。 */
29
+ interface OrderIntentPayload {
30
+ readonly symbol: string;
31
+ readonly side: 'buy' | 'sell';
32
+ readonly type: 'limit' | 'market';
33
+ readonly quantity: number;
34
+ readonly price?: number;
35
+ /** 连接器特有的、会改变发往 venue 的请求的字段(如 OKX 的 `tdMode`/`posSide`)。 */
36
+ readonly extra?: Readonly<Record<string, string | number | boolean>>;
37
+ }
38
+ /**
39
+ * venue 回执(Order 的持久子集)。`id` 恒为 venue 侧真实订单 id——
40
+ * 没有它就不是回执,本模块拒绝落库。
41
+ *
42
+ * `status` 是 **venue 原样状态串**:api 的 `OrderStatus` 是展示词汇,而各 venue 的
43
+ * 真实状态集更宽(Alpaca 有 accepted/pending_new/…,OKX 有 live/partially_filled/…),
44
+ * 连接器本就按自己的映射回带。台账**原样保存**、不在这里做窄化或翻译——
45
+ * 抹掉真实状态串就等于在回执里说假话。
46
+ */
47
+ interface OrderIntentReceipt {
48
+ readonly id: string;
49
+ readonly symbol: string;
50
+ readonly side: 'buy' | 'sell';
51
+ readonly type: 'limit' | 'market';
52
+ readonly status: string;
53
+ readonly price?: number;
54
+ readonly quantity: number;
55
+ readonly filledQuantity?: number;
56
+ readonly timestamp: number;
57
+ }
58
+ /** 一条持久意图记录(一份文件 = 一个 intentId)。 */
59
+ interface OrderIntentRecord {
60
+ readonly version: number;
61
+ readonly intentId: string;
62
+ /** `unknown` = 已提交/在飞但结局未定;`confirmed` = 有 venue 回执。 */
63
+ readonly state: 'unknown' | 'confirmed';
64
+ /** 规范化载荷 + 作用域的 SHA-256(冲突判据)。 */
65
+ readonly fingerprint: string;
66
+ readonly provider: string;
67
+ readonly environment: string;
68
+ readonly account: string;
69
+ readonly createdAt: string;
70
+ readonly updatedAt: string;
71
+ /** 仅 `confirmed` 有值。 */
72
+ readonly receipt?: OrderIntentReceipt;
73
+ }
74
+ /**
75
+ * 提交前裁决:`submit` = 本次可以触网;`replay` = 已有回执,直接返回且不得触网。
76
+ * 两种结果都回带**规范化后的 intentId**(已 trim):调用方据此派生 venue client key
77
+ * 与写回执,不需要自己再判一次「id 到底给没给」。
78
+ */
79
+ type OrderIntentDecision = {
80
+ readonly action: 'submit';
81
+ readonly intentId: string;
82
+ } | {
83
+ readonly action: 'replay';
84
+ readonly intentId: string;
85
+ readonly receipt: OrderIntentReceipt;
86
+ };
87
+ interface OrderIntentLedgerOptions extends OrderIntentScope {
88
+ /** 显式 home(缺省按 `$DSH_HOME` 解析;测试必须显式指到临时目录)。 */
89
+ readonly homeDir?: string;
90
+ /** 等锁上限(毫秒),透传 `transactStore`。 */
91
+ readonly timeoutMs?: number;
92
+ }
93
+ /**
94
+ * venue client order key 的派生规则。
95
+ *
96
+ * `maxLength` 是该 venue 官方契约里的硬上限,`prefix` 用于在 venue 侧一眼认出
97
+ * 这是本仓派生出来的键。派生是**确定性**的(同一 intentId 恒得同一键),
98
+ * 对账时可由 intentId 重算,因此本模块不需要持久化这个键(它也就不会出现在任何
99
+ * DTO/响应里)。
100
+ */
101
+ interface VenueClientKeyRule {
102
+ /** venue 允许的字符集内的前缀(本仓一律用 ASCII 十六进制 + 字母前缀)。 */
103
+ readonly prefix: string;
104
+ /** venue 官方契约的长度上限(含前缀)。 */
105
+ readonly maxLength: number;
106
+ }
107
+ /** 从 intentId 派生 venue client order key:sha256 十六进制(天然落在字母数字集内)。 */
108
+ declare function deriveVenueClientKey(intentId: string, rule: VenueClientKeyRule): string;
109
+ /** api `Order` 的最小结构面(本包不依赖 @dshtrading/api,连接器传自己的 Order 即可)。 */
110
+ interface OrderLike {
111
+ readonly id: string;
112
+ readonly symbol: string;
113
+ readonly side: 'buy' | 'sell';
114
+ readonly type: 'limit' | 'market';
115
+ /** venue 原样状态串(api `Order` 的 status 联合更窄,回程由连接器自己窄化)。 */
116
+ readonly status: string;
117
+ readonly price?: number;
118
+ readonly quantity: number;
119
+ readonly filledQuantity?: number;
120
+ readonly timestamp: number;
121
+ }
122
+ /** api `Order` → 落盘回执(**只接受非空 id**;venue 没给句柄就不是成功)。 */
123
+ declare function receiptFromOrder(order: OrderLike): OrderIntentReceipt;
124
+ /**
125
+ * 落盘回执 → api `Order` 的同形结构,`dryRun` 恒为 false:能进台账的都是实盘单。
126
+ * `status` 保持 venue 原样串,因此它比 api `OrderStatus` 联合更宽——调用方按自己的
127
+ * 映射收窄(连接器本来就在做这件事)。
128
+ */
129
+ declare function orderFromReceipt(receipt: OrderIntentReceipt): OrderLike & {
130
+ readonly dryRun: false;
131
+ };
132
+ declare class OrderIntentLedger {
133
+ private readonly scope;
134
+ private readonly options;
135
+ constructor(options: OrderIntentLedgerOptions);
136
+ /** 台账目录:显式 home 优先,否则按 `$DSH_HOME` 解析(与全仓 home 契约同源)。 */
137
+ get directory(): string;
138
+ /** 某个 intentId 的记录文件(sha256 派生,O(1);不含意图内容,也不含账户明文)。 */
139
+ filePath(intentId: string): string;
140
+ /** 规范化载荷 + 作用域的指纹(冲突判据;不含任何凭据/venue client key)。 */
141
+ fingerprint(payload: OrderIntentPayload): string;
142
+ /** 只读一条记录(对账/诊断用;坏形与缺失都返回 undefined)。 */
143
+ read(intentId: string): Promise<OrderIntentRecord | undefined>;
144
+ /**
145
+ * 触网前裁决。返回值 `submit` 表示「本次已落下 unknown 记录,可以调用 venue」;
146
+ * 抛错即**不得**调用 venue:
147
+ * - `TRADING_INTENT_REQUIRED` 缺 intentId;
148
+ * - `TRADING_INTENT_CONFLICT` 同 ID 不同载荷/provider/环境/账户;
149
+ * - `TRADING_INTENT_UNKNOWN` 同 ID 已有未定结局的记录(在飞/未知),不重发;
150
+ * - `TRADING_INTENT_STORE_FAILED` 记录写不下去(宁可不发)。
151
+ */
152
+ begin(input: {
153
+ readonly intentId: string | undefined;
154
+ readonly payload: OrderIntentPayload;
155
+ }): Promise<OrderIntentDecision>;
156
+ /**
157
+ * 记录 venue 回执。只接受**真实非空 orderId**:没有它就是没拿到 venue 句柄,
158
+ * 本次仍停在 `unknown`(调用方应当把该情况当失败上报,而不是造一个 id)。
159
+ */
160
+ confirm(input: {
161
+ readonly intentId: string;
162
+ readonly payload: OrderIntentPayload;
163
+ readonly receipt: OrderIntentReceipt;
164
+ }): Promise<OrderIntentRecord>;
165
+ /**
166
+ * 用 **venue 侧读到的**回执推进一条未定结局的意图(人工/读对账的唯一入口)。
167
+ * 记录必须已存在(本模块不凭对账凭空造意图),回执必须带真实 orderId。
168
+ */
169
+ reconcile(input: {
170
+ readonly intentId: string;
171
+ readonly receipt: OrderIntentReceipt;
172
+ }): Promise<OrderIntentRecord>;
173
+ private write;
174
+ /**
175
+ * 读盘:**只有真的不存在**才算「没有记录」。文件在但读不出来(坏 JSON、坏形状、
176
+ * 未来协议版本)一律 fail-closed 报「未知」——把坏记录当成没有记录,等于把
177
+ * 「上一笔可能已经到 venue」静默遗忘,正好是重复下单的入口。
178
+ */
179
+ private readFromDisk;
180
+ }
181
+ //#endregion
182
+ export { ORDER_INTENTS_DIR_NAME, ORDER_INTENT_PROTOCOL_VERSION, OrderIntentDecision, OrderIntentError, OrderIntentErrorCode, OrderIntentLedger, OrderIntentLedgerOptions, OrderIntentPayload, OrderIntentReceipt, OrderIntentRecord, OrderIntentScope, OrderLike, VenueClientKeyRule, deriveVenueClientKey, orderFromReceipt, receiptFromOrder };
@@ -0,0 +1,300 @@
1
+ import { createHash } from "node:crypto";
2
+ import { join } from "node:path";
3
+ import { readFile } from "node:fs/promises";
4
+ import { SKIP_WRITE, dshHomeDir, transactStore } from "@dshtrading/dsh-home";
5
+ //#region src/order-intents.ts
6
+ /**
7
+ * 订单意图台账(order-intent ledger)——实盘「同 ID 只下一单」的持久前提。
8
+ *
9
+ * 这是 **Node-only** 的独立入口(`@dshtrading/authority/order-intents`),与运行期
10
+ * 验签入口(`.`,只读)分开:验签入口不 import 本模块,本模块也不 import 签署侧
11
+ * (`./sign`)。台账是 **agent 自己的运营数据**,写在 trading home 下
12
+ * (`<DSH_HOME>/order-intents/`),**绝不写人工持有的签名授权平面**
13
+ * (`$DSH_TRADING_AUTHORITY_DIR`)——两处只有一个共同点:都在 agent 可写路径之外
14
+ * 的那一份是授权,这里这份是「我发过什么」。
15
+ *
16
+ * 为什么需要它(资金正确性优先):
17
+ * venue 的 client order key(OKX `clOrdId`、Alpaca `client_order_id`、IBKR `cOID`)
18
+ * 只在**特定条件下**去重(OKX 仅约束未成交挂单、Alpaca 仅对活跃单回 422、IBKR 要求
19
+ * 24h 内唯一),而且「请求超时/连接断开」与「venue 已建单但响应丢了」在客户端看起来
20
+ * 一模一样。所以 exactly-once 不能建立在 client key 上,只能建立在一份**先落盘、
21
+ * 后触网**的本地意图记录上:记录写不下去就不发;记录写了而结局未定就**永不自动重发**。
22
+ *
23
+ * 三态(本模块只写其中两态,第三种是「没有记录」):
24
+ * - 无记录 → begin() 落一条 `unknown` 记录并返回 `submit`(调用方这时才可触网);
25
+ * - `unknown` → begin() 抛 `TRADING_INTENT_UNKNOWN`(在飞/未知),**不重发**;
26
+ * - `confirmed` → begin() 返回记录里的同一份回执,**不重发**。
27
+ *
28
+ * 只有「venue 回执带真实非空 orderId」才会写 `confirmed`:`confirm()` 拒绝空 id,
29
+ * 本模块任何路径都不会为让调用方好看而生成一个 id(`TRADING_EXCHANGE_ERROR`)。
30
+ *
31
+ * 冲突:同一 `intentId` 绑定的规范化载荷/provider/环境/账户是一个 SHA-256 指纹,
32
+ * 指纹不同即 `TRADING_INTENT_CONFLICT`——**不自动改参数**、不当作新单。
33
+ * 身份里**没有凭据**:轮换密钥不会让同一个未知意图看起来是新的(凭据轮换不是换账户)。
34
+ *
35
+ * 并发:记录文件按 `sha256(intentId)` 命名(O(1) 定位,不随台账增长变慢),
36
+ * 读-改-写走 @dshtrading/dsh-home 的 `transactStore`(跨进程排他锁 + 锁内新鲜读 +
37
+ * 原子写 + fsync)。同 ID 并发两次:先到者落 `unknown` 并提交,后到者读到 `unknown`
38
+ * 即抛错——只有一个请求能越过网络写边界。
39
+ *
40
+ * 推进未知意图是**显式动作**:`reconcile()` 只接受 venue 侧读到的真实 orderId
41
+ * (人工/读对账),本模块不提供「遗忘」接口,也不自动清理未知记录。
42
+ */
43
+ /** 台账目录名(挂在 trading home 下;与授权平面无关)。 */
44
+ const ORDER_INTENTS_DIR_NAME = "order-intents";
45
+ /** 记录协议版本(逐帧校验;换格式时旧记录必须被拒而不是被猜)。 */
46
+ const ORDER_INTENT_PROTOCOL_VERSION = 1;
47
+ var OrderIntentError = class extends Error {
48
+ code;
49
+ constructor(code, message, cause) {
50
+ super(message);
51
+ this.name = "OrderIntentError";
52
+ this.code = code;
53
+ if (cause !== void 0) this.cause = cause;
54
+ }
55
+ };
56
+ /** 从 intentId 派生 venue client order key:sha256 十六进制(天然落在字母数字集内)。 */
57
+ function deriveVenueClientKey(intentId, rule) {
58
+ const digest = createHash("sha256").update(intentId).digest("hex");
59
+ const room = Math.max(0, rule.maxLength - rule.prefix.length);
60
+ return (rule.prefix + digest.slice(0, room)).slice(0, rule.maxLength);
61
+ }
62
+ /** 规范化序列化(键序无关、undefined 丢弃):指纹必须与 JSON 排版无关。 */
63
+ function canonicalize(value) {
64
+ if (value === void 0) return "null";
65
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
66
+ if (Array.isArray(value)) return "[" + value.map((item) => canonicalize(item)).join(",") + "]";
67
+ const record = value;
68
+ return "{" + Object.keys(record).filter((key) => record[key] !== void 0).sort().map((key) => JSON.stringify(key) + ":" + canonicalize(record[key])).join(",") + "}";
69
+ }
70
+ function requireNonEmpty(label, value) {
71
+ const trimmed = value.trim();
72
+ if (trimmed === "") throw new OrderIntentError("TRADING_INTENT_REQUIRED", `order intent ledger: ${label} must be a non-empty string (got ${JSON.stringify(value)})`);
73
+ return trimmed;
74
+ }
75
+ function isRecord(value) {
76
+ if (value === null || typeof value !== "object") return false;
77
+ const record = value;
78
+ if (record.version !== 1) return false;
79
+ if (typeof record.intentId !== "string" || record.intentId === "") return false;
80
+ if (record.state !== "unknown" && record.state !== "confirmed") return false;
81
+ if (typeof record.fingerprint !== "string" || record.fingerprint === "") return false;
82
+ if (typeof record.provider !== "string" || typeof record.environment !== "string" || typeof record.account !== "string") return false;
83
+ if (typeof record.createdAt !== "string" || typeof record.updatedAt !== "string") return false;
84
+ if (record.state === "confirmed" && !isReceipt(record.receipt)) return false;
85
+ if (record.receipt !== void 0 && !isReceipt(record.receipt)) return false;
86
+ return true;
87
+ }
88
+ function isReceipt(value) {
89
+ if (value === null || typeof value !== "object") return false;
90
+ const receipt = value;
91
+ return typeof receipt.id === "string" && receipt.id.trim() !== "" && typeof receipt.symbol === "string" && typeof receipt.quantity === "number" && typeof receipt.timestamp === "number" && typeof receipt.status === "string" && receipt.status.trim() !== "";
92
+ }
93
+ /** api `Order` → 落盘回执(**只接受非空 id**;venue 没给句柄就不是成功)。 */
94
+ function receiptFromOrder(order) {
95
+ return {
96
+ id: order.id,
97
+ symbol: order.symbol,
98
+ side: order.side,
99
+ type: order.type,
100
+ status: order.status,
101
+ ...order.price !== void 0 ? { price: order.price } : {},
102
+ quantity: order.quantity,
103
+ ...order.filledQuantity !== void 0 ? { filledQuantity: order.filledQuantity } : {},
104
+ timestamp: order.timestamp
105
+ };
106
+ }
107
+ /**
108
+ * 落盘回执 → api `Order` 的同形结构,`dryRun` 恒为 false:能进台账的都是实盘单。
109
+ * `status` 保持 venue 原样串,因此它比 api `OrderStatus` 联合更宽——调用方按自己的
110
+ * 映射收窄(连接器本来就在做这件事)。
111
+ */
112
+ function orderFromReceipt(receipt) {
113
+ return {
114
+ id: receipt.id,
115
+ symbol: receipt.symbol,
116
+ side: receipt.side,
117
+ type: receipt.type,
118
+ status: receipt.status,
119
+ ...receipt.price !== void 0 ? { price: receipt.price } : {},
120
+ quantity: receipt.quantity,
121
+ ...receipt.filledQuantity !== void 0 ? { filledQuantity: receipt.filledQuantity } : {},
122
+ dryRun: false,
123
+ timestamp: receipt.timestamp
124
+ };
125
+ }
126
+ /** 一份记录文件的内容:读盘形状与坏形降级口径(坏形 = 当作没有记录,绝不猜)。 */
127
+ function parseRecord(text) {
128
+ try {
129
+ const parsed = JSON.parse(text);
130
+ return isRecord(parsed) ? parsed : void 0;
131
+ } catch {
132
+ return;
133
+ }
134
+ }
135
+ var OrderIntentLedger = class {
136
+ scope;
137
+ options;
138
+ constructor(options) {
139
+ this.scope = {
140
+ provider: requireNonEmpty("provider", options.provider),
141
+ environment: requireNonEmpty("environment", options.environment),
142
+ account: requireNonEmpty("account", options.account)
143
+ };
144
+ this.options = options;
145
+ }
146
+ /** 台账目录:显式 home 优先,否则按 `$DSH_HOME` 解析(与全仓 home 契约同源)。 */
147
+ get directory() {
148
+ return join(this.options.homeDir ?? dshHomeDir(), ORDER_INTENTS_DIR_NAME);
149
+ }
150
+ /** 某个 intentId 的记录文件(sha256 派生,O(1);不含意图内容,也不含账户明文)。 */
151
+ filePath(intentId) {
152
+ const digest = createHash("sha256").update(requireNonEmpty("intentId", intentId)).digest("hex");
153
+ return join(this.directory, digest + ".json");
154
+ }
155
+ /** 规范化载荷 + 作用域的指纹(冲突判据;不含任何凭据/venue client key)。 */
156
+ fingerprint(payload) {
157
+ return createHash("sha256").update(canonicalize({
158
+ provider: this.scope.provider,
159
+ environment: this.scope.environment,
160
+ account: this.scope.account,
161
+ payload
162
+ })).digest("hex");
163
+ }
164
+ /** 只读一条记录(对账/诊断用;坏形与缺失都返回 undefined)。 */
165
+ async read(intentId) {
166
+ return this.readFromDisk(this.filePath(intentId));
167
+ }
168
+ /**
169
+ * 触网前裁决。返回值 `submit` 表示「本次已落下 unknown 记录,可以调用 venue」;
170
+ * 抛错即**不得**调用 venue:
171
+ * - `TRADING_INTENT_REQUIRED` 缺 intentId;
172
+ * - `TRADING_INTENT_CONFLICT` 同 ID 不同载荷/provider/环境/账户;
173
+ * - `TRADING_INTENT_UNKNOWN` 同 ID 已有未定结局的记录(在飞/未知),不重发;
174
+ * - `TRADING_INTENT_STORE_FAILED` 记录写不下去(宁可不发)。
175
+ */
176
+ async begin(input) {
177
+ const intentId = requireNonEmpty("intentId", input.intentId ?? "");
178
+ const fingerprint = this.fingerprint(input.payload);
179
+ const file = this.filePath(intentId);
180
+ let record;
181
+ try {
182
+ record = await transactStore(file, () => this.readFromDisk(file), (onDisk) => {
183
+ if (onDisk === void 0) {
184
+ const now = (/* @__PURE__ */ new Date()).toISOString();
185
+ return {
186
+ version: 1,
187
+ intentId,
188
+ state: "unknown",
189
+ fingerprint,
190
+ provider: this.scope.provider,
191
+ environment: this.scope.environment,
192
+ account: this.scope.account,
193
+ createdAt: now,
194
+ updatedAt: now
195
+ };
196
+ }
197
+ if (onDisk.fingerprint !== fingerprint) throw conflictError(intentId);
198
+ if (onDisk.state === "confirmed" && onDisk.receipt !== void 0) return SKIP_WRITE;
199
+ throw new OrderIntentError("TRADING_INTENT_UNKNOWN", `order intent ${intentId} was already submitted and its venue outcome is unresolved (no receipt recorded); not resubmitting — reconcile against the venue (read the order by its client key) and record the receipt before retrying`);
200
+ }, (value) => value, "[dsh-trading/order-intents] failed to atomic flush order intent to", this.options.timeoutMs !== void 0 ? { timeoutMs: this.options.timeoutMs } : {});
201
+ } catch (error) {
202
+ if (error instanceof OrderIntentError) throw error;
203
+ throw new OrderIntentError("TRADING_INTENT_STORE_FAILED", `order intent ${intentId} could not be recorded (${error instanceof Error ? error.message : String(error)}); no venue call was made — a live order is never sent without a durable intent record`, error);
204
+ }
205
+ if (record === void 0) throw new OrderIntentError("TRADING_INTENT_STORE_FAILED", `order intent ${intentId}: no durable record was created; no venue call may be made`);
206
+ return record.state === "confirmed" && record.receipt !== void 0 ? {
207
+ action: "replay",
208
+ intentId,
209
+ receipt: record.receipt
210
+ } : {
211
+ action: "submit",
212
+ intentId
213
+ };
214
+ }
215
+ /**
216
+ * 记录 venue 回执。只接受**真实非空 orderId**:没有它就是没拿到 venue 句柄,
217
+ * 本次仍停在 `unknown`(调用方应当把该情况当失败上报,而不是造一个 id)。
218
+ */
219
+ async confirm(input) {
220
+ const intentId = requireNonEmpty("intentId", input.intentId);
221
+ const venueOrderId = requireVenueOrderId(input.receipt);
222
+ const fingerprint = this.fingerprint(input.payload);
223
+ const file = this.filePath(intentId);
224
+ return this.write(file, intentId, (onDisk) => {
225
+ if (onDisk === void 0) throw new OrderIntentError("TRADING_INTENT_STORE_FAILED", `order intent ${intentId}: no durable record found while recording venue receipt ${venueOrderId}; the venue order exists — do not resubmit, reconcile it manually`);
226
+ if (onDisk.fingerprint !== fingerprint) throw conflictError(intentId);
227
+ if (onDisk.state === "confirmed" && onDisk.receipt !== void 0) {
228
+ if (onDisk.receipt.id !== venueOrderId) throw new OrderIntentError("TRADING_INTENT_CONFLICT", `order intent ${intentId} is already confirmed with venue order ${onDisk.receipt.id}; refusing to overwrite it with ${venueOrderId}`);
229
+ return SKIP_WRITE;
230
+ }
231
+ return {
232
+ ...onDisk,
233
+ state: "confirmed",
234
+ receipt: input.receipt,
235
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
236
+ };
237
+ });
238
+ }
239
+ /**
240
+ * 用 **venue 侧读到的**回执推进一条未定结局的意图(人工/读对账的唯一入口)。
241
+ * 记录必须已存在(本模块不凭对账凭空造意图),回执必须带真实 orderId。
242
+ */
243
+ async reconcile(input) {
244
+ const intentId = requireNonEmpty("intentId", input.intentId);
245
+ const venueOrderId = requireVenueOrderId(input.receipt);
246
+ const file = this.filePath(intentId);
247
+ return this.write(file, intentId, (onDisk) => {
248
+ if (onDisk === void 0) throw new OrderIntentError("TRADING_INTENT_UNKNOWN", `order intent ${intentId} has no durable record; refusing to fabricate one from reconciliation (venue order ${venueOrderId} needs an explicit operator decision outside the ledger)`);
249
+ if (onDisk.state === "confirmed" && onDisk.receipt !== void 0) {
250
+ if (onDisk.receipt.id !== venueOrderId) throw new OrderIntentError("TRADING_INTENT_CONFLICT", `order intent ${intentId} is already confirmed with venue order ${onDisk.receipt.id}; refusing to overwrite it with ${venueOrderId}`);
251
+ return SKIP_WRITE;
252
+ }
253
+ return {
254
+ ...onDisk,
255
+ state: "confirmed",
256
+ receipt: input.receipt,
257
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
258
+ };
259
+ });
260
+ }
261
+ async write(file, intentId, mutate) {
262
+ let outcome;
263
+ try {
264
+ outcome = await transactStore(file, () => this.readFromDisk(file), (onDisk) => mutate(onDisk), (value) => value, "[dsh-trading/order-intents] failed to atomic flush order intent to", this.options.timeoutMs !== void 0 ? { timeoutMs: this.options.timeoutMs } : {});
265
+ } catch (error) {
266
+ if (error instanceof OrderIntentError) throw error;
267
+ throw new OrderIntentError("TRADING_INTENT_STORE_FAILED", `order intent ${intentId} could not be written (${error instanceof Error ? error.message : String(error)})`, error);
268
+ }
269
+ if (outcome === void 0) throw new OrderIntentError("TRADING_INTENT_STORE_FAILED", `order intent ${intentId}: ledger returned no record`);
270
+ return outcome;
271
+ }
272
+ /**
273
+ * 读盘:**只有真的不存在**才算「没有记录」。文件在但读不出来(坏 JSON、坏形状、
274
+ * 未来协议版本)一律 fail-closed 报「未知」——把坏记录当成没有记录,等于把
275
+ * 「上一笔可能已经到 venue」静默遗忘,正好是重复下单的入口。
276
+ */
277
+ async readFromDisk(file) {
278
+ let text;
279
+ try {
280
+ text = await readFile(file, "utf8");
281
+ } catch (error) {
282
+ if (error.code === "ENOENT") return void 0;
283
+ throw error;
284
+ }
285
+ const record = parseRecord(text);
286
+ if (record === void 0) throw new OrderIntentError("TRADING_INTENT_UNKNOWN", `order intent record ${file} exists but cannot be read as a protocol-v1 record; refusing to treat it as absent (a previous attempt may already have reached the venue) — reconcile or repair it before retrying`);
287
+ return record;
288
+ }
289
+ };
290
+ function conflictError(intentId) {
291
+ return new OrderIntentError("TRADING_INTENT_CONFLICT", `order intent ${intentId} is already bound to a different request (payload/provider/environment/account fingerprint mismatch); refusing to submit a re-parameterised order under the same id — use a new intentId for a different order`);
292
+ }
293
+ /** venue 句柄纪律:回执必须带真实非空订单 id——本模块绝不代生成。 */
294
+ function requireVenueOrderId(receipt) {
295
+ const id = typeof receipt.id === "string" ? receipt.id.trim() : "";
296
+ if (id === "") throw new OrderIntentError("TRADING_EXCHANGE_ERROR", "order intent ledger: refusing to record a confirmed intent without a real non-empty venue order id (the venue did not hand back a handle — this is not a success)");
297
+ return id;
298
+ }
299
+ //#endregion
300
+ export { ORDER_INTENTS_DIR_NAME, ORDER_INTENT_PROTOCOL_VERSION, OrderIntentError, OrderIntentLedger, deriveVenueClientKey, orderFromReceipt, receiptFromOrder };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dshtrading/authority",
3
3
  "description": "Live-trading authority plane for dsh-trading: a fail-closed, human-signed (Ed25519) grant outside every agent-writable path. The preset asset's liveTrading flag is demoted to a non-authoritative mirror that can only deny (design doc §5 / invariant 13.3).",
4
- "version": "0.6.1",
4
+ "version": "0.6.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "./lib/index.d.ts",
@@ -18,6 +18,10 @@
18
18
  "types": "./lib/testing.d.ts",
19
19
  "default": "./lib/testing.js"
20
20
  },
21
+ "./order-intents": {
22
+ "types": "./lib/order-intents.d.ts",
23
+ "default": "./lib/order-intents.js"
24
+ },
21
25
  "./package.json": "./package.json"
22
26
  },
23
27
  "files": [
@@ -25,7 +29,7 @@
25
29
  ],
26
30
  "license": "PolyForm-Noncommercial-1.0.0",
27
31
  "dependencies": {
28
- "@dshtrading/dsh-home": "^0.6.1"
32
+ "@dshtrading/dsh-home": "^0.6.2"
29
33
  },
30
34
  "devDependencies": {
31
35
  "@types/node": "^24.0.0",