@ninsei-labs/sdk 0.39.1 → 0.39.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,103 @@
1
+ ## Unreleased — 2026-10-08
2
+
3
+ EVERY ORDER'S ESCROW IS A CLONE, AND THE CLIENT NOW CHECKS THE IMPLEMENTATION IT WILL EXECUTE (issue #114).
4
+ Since the contract change an order's escrow is an EIP-1167 minimal proxy of ONE shared implementation, and the
5
+ factory carries the pair (`implementation()`, `implementationCodeHash()`). The factory checks that pair before every
6
+ creation; the client now checks the SAME pair on chain, armed by the interface's own policy - `knownImplementationCodes`,
7
+ an array of known implementation build hashes, sitting next to `knownFactoryCodes`. The verdict reads the pair off the
8
+ factory, reads the CODE at the implementation's address and applies the same check the factory does - and re-hashes the
9
+ live code, so it does not trust the factory's own answer alone. A `delegatecall` to an address without code SUCCEEDS,
10
+ so a clone without an implementation would take the ETH and its `markReady`/`claim`/`refund` would appear to go through
11
+ while doing nothing: that case is refused by name (`implementation-has-no-code`).
12
+
13
+ - **`knownImplementationCodes`** (`src/quotes.mjs`, `src/index.mjs`, `index.d.ts`) - the interface's
14
+ escrow-implementation policy, next to `knownFactoryCodes`. An empty/absent list means "no policy": the pair is not
15
+ read and behaviour is as before (needed while a network still runs a factory of the OLDER build, which has no such
16
+ getters and answers a revert - the deployed one does). A non-empty list refuses an implementation whose recorded
17
+ hash is not a known build with `quote-factory-code-unknown`; a pair that cannot be read at all is refused with
18
+ `quote-factory-unchecked`. Proven by four cases beside the existing factory-code ones: a known build is accepted,
19
+ and a foreign one, a code whose hash does not match the recorded one, and an implementation without code are each
20
+ refused by a named reason.
21
+ - **`verifyFactoryImplementationOnChain`** (`engine/evm/factory.js`) - one place that reads the pair and applies
22
+ `verifyFactoryImplementation`, with the chain injected (`read` = `eth_call`, `code` = `eth_getCode`). The module
23
+ stays pure (no imports, no network) and is the same helper the demo's node and watchtower call with their own reads.
24
+ - **The package source is re-synced from the source-of-truth tree**: `src/**`, `engine/**` and `index.d.ts` are
25
+ byte-for-byte the demo repository's `sdk/`. The demo's interface config pins the known implementation hash - a
26
+ protocol constant, because the implementation has no immutable fields, so its runtime code (and its hash) is the
27
+ same on every network.
28
+ - **Claim and order-creation gas limits are the MEASURED ones** (`engine/evm/claimGas.js`, `engine/evm/funding.js`).
29
+ `CLAIM_GAS_LIMIT` is raised from 120 000 to 200 000: an honest `claim` whose fee is the FIRST credit to the
30
+ recipient's cashier costs 120 721 gas on a live chain (124 072 in `forge --gas-report`), so the former limit cut a
31
+ valid claim. `GAS_CREATE_ORDER_AND_FUND` is lowered from 1 500 000 to 700 000, because an order's escrow is now an
32
+ EIP-1167 clone - the measured full call is 315 530 gas (1 190 683 for the former full contract). The dead
33
+ `GAS_CREATE_ORDER` constant (there is no separate creation path) is removed. The node's own claim in the
34
+ source-of-truth tree signs with the SAME shared limit instead of its own literal.
35
+ - Checks: `node tools/check.mjs` - all green.
36
+
37
+ ## 0.39.2 — 2026-10-08
38
+
39
+ `fakechain` IS A KNOWN MONERO NETWORK. The withdrawal page takes the Monero network FROM THE NODE, and monerod in
40
+ regtest mode reports its network as `fakechain` - the official name of that mode. On the CI contour (a regtest node)
41
+ the core therefore refused its OWN recovery file (`bad-input`, field `xmrNetwork`) and the withdrawal never started.
42
+ The name is now accepted by every check of the network value, and everything else is still refused by name.
43
+
44
+ - **`fakechain` joins the accepted networks** (`src/config.mjs`, `index.d.ts`): beside the engine registry's own
45
+ names and the standard mainnet/stagenet/testnet it is a KNOWN name of the regtest mode. Anything else is still a
46
+ `bad-input` refusal that names the known list - the value is checked, not "any word accepted".
47
+ - **The recovery-file reader accepts it as well** (`engine/recovery/recoveryFile.js`): a file whose wallet network is
48
+ `fakechain` reads back; an unknown name is still rejected. Proven by a writer -> reader round trip, with the
49
+ unknown-name case beside it.
50
+ - **The engine's network registry declares it** (`engine/core/config.js`): the Monero wallet adapter builds its list
51
+ of known networks from `XMR_NETWORKS`, so `/adapters` knows the name too.
52
+ - **The package source is re-synced from the source-of-truth tree**: `src/**`, `engine/**` and `index.d.ts` are now
53
+ byte-for-byte the demo repository's `sdk/`. Besides the change above this also carries two changes that were
54
+ already in that tree: the escrow-implementation address/codehash read and verdict (`engine/evm/factory.js`,
55
+ `verifyFactoryImplementation`) and the smallest-trade rule that reads the maker's SENT minimum rather than the
56
+ effective set's first level (`src/levelsWalkSpec.mjs`, the `minAtomic` argument).
57
+ - **A withdrawal no longer loses its refusal code on a chain the wallet library cannot scan** (`src/sweep.mjs`):
58
+ the FREE REMAINDER is read and judged BEFORE any input selection, and a failed `sync()` no longer pre-empts that
59
+ verdict. On the CI contour (a regtest node) monero-ts `sync()` throws a bare `failed to get hashes`; that error
60
+ used to escape ahead of the funds check, so an EMPTY wallet was refused with `code: null` + raw text instead of
61
+ the named `insufficient-funds` - and the page had no phrase to translate, because the core had named nothing.
62
+ - **Every refusal keeps a code from the package's dictionary** (`src/sweep.mjs`, `walletStep`): a wallet step that
63
+ fails with anything other than an `SdkError` is re-thrown as one, on `wallet-rejected`, with the library's own
64
+ message kept as `params.why`. `code: null` + raw text is not a refusal an interface can show or a check assert.
65
+ - Proven by a unit run WITHOUT a browser or a contour (the source-of-truth tree's `tools/check-sweep-empty-wallet.mjs`,
66
+ part of the fast gates): a zero remainder refuses with `insufficient-funds` and human words while input selection
67
+ is never touched; a non-zero remainder reaches signing; a low-level library failure comes out WITH a code. Two
68
+ breaking runs redden - cutting the remainder check (the refusal stops being named) and cutting the wrapper (the
69
+ run then reproduces the exact CI symptom: `code: null` + `failed to get hashes`).
70
+ - Checks: `node tools/check.mjs` - all green.
71
+
72
+ HEAD
73
+ ## Unreleased — 2026-10-07
74
+
75
+ PRERELEASES GET THEIR OWN npm TAGS, AND `latest` STOPS MOVING BY ACCIDENT. The release workflow published whatever
76
+ version `package.json` carried under whatever tag npm chose on its own; now the tag is DERIVED from the version and
77
+ handed to `npm publish` by name. This entry rides the next version - nothing in `src/`, `engine/` or `index.d.ts`
78
+ changes here.
79
+
80
+ - **The tag comes from the version** (`tools/npm-tag.mjs`): `X.Y.Z` -> `latest`, `X.Y.Z-develop.N` -> `develop`,
81
+ `X.Y.Z-preview.N` -> `preview`, read from the FIRST identifier of the prerelease part. Anything else is a named
82
+ refusal, not a guess: a version that is not `X.Y.Z[...]`, a prerelease channel that is not declared, a tag that
83
+ reads as a version.
84
+ - **A prerelease can never move `latest`.** The publish step works the tag out before it sends anything, and the
85
+ derivation refuses, naming the case, when a prerelease would take `latest` - even under a manual override. The
86
+ reverse is refused too: a plain version does not publish into a channel tag with no decision behind it.
87
+ - **The tag is passed explicitly.** "Publishing a package sets the `latest` tag to the published version unless
88
+ the `--tag` option is used" (https://docs.npmjs.com/cli/v11/commands/npm-dist-tag), and the `tag` config defaults
89
+ to `latest` (https://docs.npmjs.com/cli/v11/using-npm/config#tag) - so the step now ends in
90
+ `npm publish --access public --provenance --tag <tag>` and never leans on that default.
91
+ - **A manual run may override the tag** with the new `tag` input; the step echoes the override and the tag the
92
+ derivation would have produced into the log before the publish, so the decision is readable, not silent.
93
+ - **The cycle is written down** (README, "Releases and prereleases"): a build for ourselves is a prerelease under
94
+ `develop`/`preview`, the demo pins the EXACT version, and a release is a separate decision - a plain version, the
95
+ only shape that moves `latest`.
96
+ - **Proven locally:** `node tools/npm-tag.test.mjs` walks the table - plain, both channels, build metadata, an
97
+ undeclared channel, garbage, every override - 28 cases, all green. A breaking run that cuts the guard reddens 5
98
+ of those 28, exactly the tooth's own cases, and `tools/npm-tag.mjs` was restored byte for byte. `node
99
+ tools/check.mjs` stays green, and CI gained the same case table as its own named step.
100
+
1
101
  ## 0.39.1 — 2026-10-07
2
102
 
3
103
  THE FIRST RELEASE CUT BY THE TAG-TRIGGERED WORKFLOW. The package's contents did not change: this entry and the
package/README.md CHANGED
@@ -185,3 +185,24 @@ trigger where those exist; on another VM they simply do not appear, rather than
185
185
  touch them: `atomic/order-worker.js`, the Monero bundles.
186
186
  6. **Strict CSP.** The package is designed for a policy without `eval` and inline.
187
187
  7. **Zero dependencies.**
188
+
189
+ ## Releases and prereleases
190
+
191
+ Every published version rides on an npm dist-tag, and the tag is **derived from the version** - never left to
192
+ npm's default:
193
+
194
+ | Version | npm dist-tag | What it is |
195
+ |---|---|---|
196
+ | `X.Y.Z` | `latest` | a release - a separate decision, and the only thing that moves `latest` |
197
+ | `X.Y.Z-develop.N` | `develop` | a build for ourselves |
198
+ | `X.Y.Z-preview.N` | `preview` | a build to show |
199
+
200
+ The loop this serves: build a version for yourself (`0.40.0-develop.1`), publish it under `develop`, and have the
201
+ demo pin the **exact** version (`"@ninsei-labs/sdk": "0.40.0-develop.1"`) rather than a tag. `latest` does not
202
+ move, so `npm install @ninsei-labs/sdk` keeps serving the last real release. Shipping a release is then its own
203
+ decision: a plain `X.Y.Z`, the only shape that publishes under `latest`.
204
+
205
+ The tag is passed to `npm publish` explicitly (`--tag <tag>`), and `tools/npm-tag.mjs` refuses the two ways this
206
+ can go wrong **by name** - a prerelease that would take `latest`, and a plain version that would slide into a
207
+ channel someone else owns. A manual run of the release workflow may override the tag with an input; when it does,
208
+ the log says so. The derivation and its refusals are covered by `node tools/npm-tag.test.mjs`.
@@ -365,7 +365,16 @@ export const CHAINS = [
365
365
  // селектор predict 0x3d81fc01) и касса 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 (573 байта, но ДРУГОЙ
366
366
  // сборки - keccak 0x5c32bb9c…): её код не совпадал с канонической кассой, и эскроу отверг бы такой ордер
367
367
  // по #122 - поэтому касса развёрнута заново тем же заходом.
368
- escrow: { address: "0xcbEAF3BDe82155F56486Fb5a1072cb8baAf547cc", mode: "live", cashier: "0x1429859428C0aBc9C2C47C8Ee9FBaf82cFA0F20f" },
368
+ // ХЕШ КОДА РЕАЛИЗАЦИИ ЭСКРОУ (#114): то, что исполняет клон каждого ордера и что интерфейс обязан
369
+ // сверить сам, а не только довериться фабрике. Значение - keccak256 runtime-кода NinseiEscrow, взято
370
+ // из СВОЕЙ сборки: forge inspect NinseiEscrow deployedBytecode --root mvp/contracts и keccak (совпало
371
+ // с keccak256(type(NinseiEscrow).runtimeCode), которое записывает фабрика, и с артефактом out/).
372
+ // У реализации НЕТ immutables, поэтому это КОНСТАНТА ПРОТОКОЛА - один и тот же хеш в любой сети со
373
+ // сборкой #114. Лежит при сети, а не одним общим числом, потому что политика может отсутствовать: сеть
374
+ // со СТАРОЙ фабрикой (без implementation()) обязана остаться БЕЗ политики, иначе чтение пары откажет и
375
+ // живые котировки этой сети встанут. Так и есть у arbitrum-sepolia: развёрнутая там фабрика сборки #114
376
+ // не знает (оба геттера revert-ят), поэтому у неё поля НЕТ; включается тем же заходом, что и новый деплой.
377
+ escrow: { address: "0xcbEAF3BDe82155F56486Fb5a1072cb8baAf547cc", mode: "live", cashier: "0x1429859428C0aBc9C2C47C8Ee9FBaf82cFA0F20f", implementationCodeHash: "0xfd1ef12c1d96d3ee7060bd5901e06108723783403fad00c9630dcdeb6193674b" },
369
378
  // Токенов в песочнице нет: расплата идёт нейтивом. Пустой список - это факт о песочнице, а не забывчивость.
370
379
  tokens: [],
371
380
  // Uniswap в песочнице не развёрнут и не нужен: тут своя цепочка для сквозных прогонов, а DEX-нога
@@ -692,6 +701,18 @@ export function knownFactoryCodesFor(chainOrId) {
692
701
  return list.map((h) => String(h).toLowerCase());
693
702
  }
694
703
 
704
+ // СПИСОК ИЗВЕСТНЫХ СБОРОК РЕАЛИЗАЦИИ ЭСКРОУ - ПОЛИТИКА ИНТЕРФЕЙСА (#114), при самой сети, где живёт адрес
705
+ // фабрики (escrow.address). Хеш её кода - КОНСТАНТА ПРОТОКОЛА (у реализации нет immutables), но политика
706
+ // может быть не задана: у сети без поля implementationCodeHash её НЕТ (старая фабрика пары не отдаёт).
707
+ // Пусто = политики нет; непусто = интерфейс торгует только через фабрики, ведущие на эту сборку.
708
+ export function knownImplementationCodesFor(chainOrId) {
709
+ const id = typeof chainOrId === "string" ? chainOrId : chainOrId && chainOrId.id;
710
+ const chain = CHAINS.find((c) => c.id === id) || chainById(id);
711
+ const pinned = chain && chain.escrow ? chain.escrow.implementationCodeHash : null;
712
+ if (typeof pinned !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(pinned)) return null;
713
+ return [pinned.toLowerCase()];
714
+ }
715
+
695
716
  export function chainById(id) {
696
717
  return CHAINS.find((c) => c.id === id) || CHAINS.find((c) => c.id === DEFAULT_CHAIN) || CHAINS[0];
697
718
  }
@@ -795,7 +816,13 @@ export const API = {
795
816
  // но это только вредило: адреса в мок-режиме выглядели как mainnet (префикс 4), а живая нода -
796
817
  // stagenet (префикс 5), и в одном UI соседствовали две цепи. Смешение цепей в свопе означает
797
818
  // потерянные деньги (см. monero/address.js), поэтому теперь и мок, и живой режим - stagenet.
798
- export const XMR_NETWORKS = { app: "stagenet", mock: "stagenet" };
819
+ //
820
+ // ПЛЮС ЗНАКОМОЕ ИМЯ REGTEST. Ключи app/mock отвечают на вопрос «в какой сети кошелёк этого источника».
821
+ // Третья запись источника не имеет: fakechain - официальное имя regtest-режима monerod, которым нода
822
+ // называет себя на контуре CI. Она нужна, чтобы имя сети ПРИНИМАЛОСЬ: ядро сверяет xmrNetwork со списком
823
+ // сетей движка (sdk/src/config.mjs, STANDARD_XMR_NETWORKS), а адаптер кошелька ядра берёт список сетей
824
+ // из ЭТОГО реестра (sdk/src/adapters/monero-wallet.mjs). Кошелёк демки при этом остаётся stagenet.
825
+ export const XMR_NETWORKS = { app: "stagenet", mock: "stagenet", fakechain: "fakechain" };
799
826
 
800
827
  // Страница sweep: отдельный домен со своей (более строгой) политикой и WASM-кошельком.
801
828
  // Вывод XMR делает сам пользователь там, по своему recovery-файлу - демка за него не подписывает
@@ -22,10 +22,11 @@
22
22
  // стоимость claim плюс ЯВНЫЙ ЗАПАС на рост базовой цены между выдачей билета и забором. Второго расчёта
23
23
  // размера подарка нет ни у ноды, ни у прогонов: они зовут claimGasGiftWei отсюда.
24
24
 
25
- // ПРЕДЕЛ ГАЗА CLAIM - ОДНО ЧИСЛО НА ОБЕИХ СТОРОНАХ. Это тот же предел, с которым нода подписывает свой
26
- // claim в rfq/payout.mjs, и тот же, что отправляет страница/SDK (sdk/src/swap-flow.mjs). Число не
27
- // выдумано: живой прогон меряет фактический gasUsed забора сверху (tools/live-gas-run.mjs).
28
- export const CLAIM_GAS_LIMIT = 120_000n;
25
+ // ПРЕДЕЛ ГАЗА CLAIM - ОДНО ЧИСЛО НА ОБЕИХ СТОРОНАХ: его берут и страница/SDK (sdk/src/swap-flow.mjs), и
26
+ // нода (rfq/payout.mjs). ИЗМЕРЕНО: честный claim, когда получателю комиссии ещё НЕ начислено (первая запись
27
+ // в кассе = SSTORE нуля), стоит 120 721 газа на живой цепи (124 072 в gas-report канонических контрактов) -
28
+ // больше прежних 120 000, чем законный забор и резался. Лимит задан с запасом: 200 000.
29
+ export const CLAIM_GAS_LIMIT = 200_000n;
29
30
 
30
31
  // ПОЛ ЧАЕВЫХ - ПОЛИТИКА СЕТИ, А НЕ КОНСТАНТА В КОДЕ ОТПРАВКИ. Раньше session.js ставил минимум 1 gwei
31
32
  // ВСЕГДА. На сети с дешёвым газом (Arbitrum One: baseFee ~0.02 gwei) это раздувало maxFeePerGas примерно
@@ -147,3 +147,109 @@ export function decodeAddress(returnedHex) {
147
147
  if (hex.length < 64) throw new Error("короткий ответ: " + returnedHex);
148
148
  return "0x" + hex.slice(24, 64);
149
149
  }
150
+
151
+
152
+ // ============================================================================================
153
+ // РЕАЛИЗАЦИЯ ЭСКРОУ: ЕЁ АДРЕС И ХЕШ ЕЁ КОДА (issue #114, поправка 2).
154
+ // С #114 эскроу каждого ордера - КЛОН EIP-1167 одной общей реализации, а адрес реализации и хеш её кода
155
+ // лежат у фабрики (implementation() / implementationCodeHash()). ФАБРИКА при создании ордера проверяет эту
156
+ // пару; КЛИЕНТ обязан проверить ТУ ЖЕ пару, иначе клон исполнит чужой код, а денежные пути "пройдут" впустую
157
+ // (delegatecall на адрес без кода УСПЕШЕН). Здесь - только чтение полей и вердикт; вызовы к цепи делает
158
+ // носитель (session.js: readContract/readCode), поэтому функция чистая и проверяется без сети.
159
+ // ============================================================================================
160
+
161
+ // Селекторы посчитаны инструментом, не вручную:
162
+ // cast sig "implementation()" -> 0x5c60da1b
163
+ // cast sig "implementationCodeHash()" -> 0xbc0a3981
164
+ export const FACTORY_READS = {
165
+ implementation: "0x5c60da1b",
166
+ implementationCodeHash: "0xbc0a3981",
167
+ };
168
+
169
+ // Чтение поля фабрики без аргументов: calldata равен селектору.
170
+ export function encodeFactoryRead(name) {
171
+ const sel = FACTORY_READS[name];
172
+ if (typeof sel !== "string" || !/^0x[0-9a-f]{8}$/.test(sel)) throw new Error("нет такого чтения фабрики: " + name);
173
+ return sel;
174
+ }
175
+
176
+ // implementation() - один адрес в 32-байтовом слове.
177
+ export function decodeImplementation(returnedHex) {
178
+ return decodeAddress(returnedHex);
179
+ }
180
+
181
+ // implementationCodeHash() - одно слово bytes32.
182
+ export function decodeImplementationCodeHash(returnedHex) {
183
+ const hex = String(returnedHex || "").replace(/^0x/, "");
184
+ if (hex.length < 64) throw new Error("короткий ответ implementationCodeHash(): " + returnedHex);
185
+ return "0x" + hex.slice(0, 64).toLowerCase();
186
+ }
187
+
188
+ // ВЕРДИКТ ПО РЕАЛИЗАЦИИ - те же три условия, что проверяет фабрика (и что закрывает поправки #114):
189
+ // 1) адрес реализации назван и её КОД на цепи непуст: delegatecall на адрес без кода успешен, поэтому
190
+ // клон без реализации принял бы ETH, а markReady/claim/refund "прошли" бы, ничего не сделав;
191
+ // 2) хеш кода реализации равен recorded-хешу фабрики (сравнение делает сама фабрика через codehash);
192
+ // 3) recorded-хеш входит в политику интерфейса (список известных сборок) - если она задана.
193
+ // Если передан hashCode (keccak256 байтов кода), сверяется и живой код: тогда вердикт не доверяет одному
194
+ // лишь ответу фабрики, а пересчитывает хеш из eth_getCode.
195
+ export function verifyFactoryImplementation({ implementation, implementationCodeHash, code, hashCode, policy } = {}) {
196
+ if (!implementation || !/^0x[0-9a-fA-F]{40}$/.test(String(implementation))) {
197
+ return { ok: false, why: "implementation-missing" };
198
+ }
199
+ if (!implementationCodeHash || !/^0x[0-9a-fA-F]{64}$/.test(String(implementationCodeHash))) {
200
+ return { ok: false, why: "code-hash-missing" };
201
+ }
202
+ if (code === undefined || code === null || code === "0x" || code === "0x0") {
203
+ return { ok: false, why: "implementation-has-no-code" };
204
+ }
205
+ const pinned = String(implementationCodeHash).toLowerCase();
206
+ if (Array.isArray(policy) && policy.length) {
207
+ if (!policy.map((h) => String(h).toLowerCase()).includes(pinned)) {
208
+ return { ok: false, why: "implementation-code-unknown", codeHash: pinned };
209
+ }
210
+ }
211
+ if (typeof hashCode === "function") {
212
+ const live = String(hashCode(code)).toLowerCase();
213
+ if (live !== pinned) return { ok: false, why: "implementation-code-mismatch", got: live, want: pinned };
214
+ }
215
+ return { ok: true, codeHash: pinned };
216
+ }
217
+
218
+ // ЧТЕНИЕ ПАРЫ У ФАБРИКИ + ВЕРДИКТ - ОДНО МЕСТО НА ВСЕ НОСИТЕЛИ (SDK, узел, сторож). Читает пару полей
219
+ // фабрики (implementation() / implementationCodeHash()) и КОД по адресу реализации, затем применяет тот же
220
+ // вердикт, что и фабрика при создании ордера (verifyFactoryImplementation выше). Цепь инжектится: read -
221
+ // это eth_call ({to,data}) -> hex, code - это eth_getCode (address) -> hex. Модуль остаётся чистым: ни
222
+ // одного импорта, ни одного сетевого вызова, поэтому проверяется без сети. Носители подставляют СВОИ
223
+ // чтения (SDK - evmCall/evmCode кошелька страницы, узел и сторож - свой RPC). hashCode, если передан,
224
+ // пересчитывает живой код: тогда вердикт не доверяет одному лишь ответу фабрики.
225
+ export async function verifyFactoryImplementationOnChain({ factory, read, code, policy = null, hashCode = null } = {}) {
226
+ if (!factory || !/^0x[0-9a-fA-F]{40}$/.test(String(factory))) {
227
+ return { ok: false, why: "factory-missing" };
228
+ }
229
+ if (typeof read !== "function") return { ok: false, why: "no-chain-read" };
230
+ if (typeof code !== "function") return { ok: false, why: "no-code-read" };
231
+ let implRaw, hashRaw;
232
+ try {
233
+ implRaw = await read({ to: factory, data: FACTORY_READS.implementation });
234
+ hashRaw = await read({ to: factory, data: FACTORY_READS.implementationCodeHash });
235
+ } catch {
236
+ // ЧТЕНИЕ ПАРЫ НЕ УДАЛОСЬ - ЭТО НАЗВАННЫЙ ОТКАЗ, А НЕ МОЛЧАНИЕ. Старая фабрика (без этих геттеров)
237
+ // отвечает revert'ом: пара не прочитана, вердикта нет, и носитель обязан отказать.
238
+ return { ok: false, why: "implementation-unreadable", factory: String(factory).toLowerCase() };
239
+ }
240
+ let implementation, implementationCodeHash;
241
+ try {
242
+ implementation = decodeImplementation(implRaw);
243
+ implementationCodeHash = decodeImplementationCodeHash(hashRaw);
244
+ } catch {
245
+ return { ok: false, why: "implementation-unreadable", factory: String(factory).toLowerCase() };
246
+ }
247
+ let liveCode;
248
+ try {
249
+ liveCode = await code(implementation);
250
+ } catch {
251
+ return { ok: false, why: "implementation-code-unreadable", implementation };
252
+ }
253
+ const verdict = verifyFactoryImplementation({ implementation, implementationCodeHash, code: liveCode, hashCode, policy });
254
+ return { ...verdict, implementation, implementationCodeHash };
255
+ }
@@ -104,17 +104,17 @@ export function toWei(human, decimals = 18) {
104
104
  return BigInt(intPart || "0") * 10n ** BigInt(decimals) + BigInt(frac || "0");
105
105
  }
106
106
 
107
- // ГАЗ ВЫСТАВЛЯЕМ САМИ И С ЗАПАСОМ - ЭТО НЕ ПЕРЕСТРАХОВКА, А ЛЕЧЕНИЕ КОНКРЕТНОЙ ОШИБКИ.
108
- // createOrder и lock уходят двумя транзакциями подряд. Кошелёк оценивает вторую в момент подписи, когда
109
- // эскроу ещё НЕ создан, поэтому считает по пустому адресу обычный перевод: 21000 + стоимость данных. На
110
- // живом контракте это дало лимит 24167 при реальной надобности 50294 - и lock упал по газу, хотя вызов
111
- // был полностью верный (та же сумма, тот же отправитель, тот же адрес). Оценку кошелька здесь доверять
112
- // нельзя, поэтому лимиты задаём явно, с запасом от измеренных значений.
113
- const GAS_CREATE_ORDER = 1_200_000n; // измерено 612567 - запас ~2x: создаётся целый контракт
114
- // Создание И взнос в одной транзакции: создание контракта плюс запись взноса, запас намеренно большой -
115
- // недобор лимита стоит человеку подписи, а лишний газ в лимите на Arbitrum почти ничего не стоит.
116
- const GAS_CREATE_ORDER_AND_FUND = 1_500_000n;
117
- // GAS_LOCK УБРАН вместе с вызовом lock(bytes): взнос идёт в той же транзакции, что и создание ордера.
107
+ // ГАЗ ВЫСТАВЛЯЕМ САМИ И С ЗАПАСОМ - ЭТО ЛЕЧЕНИЕ КОНКРЕТНОЙ ОШИБКИ, А НЕ ПЕРЕСТРАХОВКА.
108
+ // createOrderAndFund уходит ОДНОЙ транзакцией, но кошелёк оценивает её по адресу, где эскроу ещё НЕ создан:
109
+ // вместо создания клона и записи взноса он считает обычный перевод (21000 плюс данные). Оценку кошелька
110
+ // здесь доверять нельзя, поэтому лимит задаём явно, с запасом от ИЗМЕРЕННОГО значения.
111
+ // ИЗМЕРЕНО: полный createOrderAndFund после перехода на клоны EIP-1167 - 315 530 газа (у прежнего целого
112
+ // контракта было 1 190 683); замер и методика - в описании PR в репозитории контрактов. Лимит примерно
113
+ // вдвое выше измеренного: недобор стоит человеку подписи, а лишний газ в лимите почти ничего не стоит.
114
+ const GAS_CREATE_ORDER_AND_FUND = 700_000n;
115
+ // ОТДЕЛЬНОЙ КОНСТАНТЫ «СОЗДАНИЕ БЕЗ ВЗНОСА» НЕТ: целого контракта больше не создаётся - эскроу каждого
116
+ // ордера есть клон EIP-1167 одной общей реализации, условия лежат в коде клона, а фабрика умеет только
117
+ // createOrderAndFund. GAS_LOCK УБРАН вместе с вызовом lock(bytes): взнос идёт в той же транзакции.
118
118
 
119
119
  // СОБЫТИЕ OrderCreated: topic0 посчитан по фактической подписи (и сверяется проверкой watchtower),
120
120
  // topics = [topic0, escrow, locker, claimer]; в data - оба обязательства, обе точки, сумма, readyBy, t1, соль.
@@ -88,7 +88,10 @@ export const PAYLOAD_VERSION = 3;
88
88
  // источник, v3 - ПОЛОВИНЫ. Читать обязаны все три: у людей на руках есть файлы всех версий, и файл
89
89
  // восстановления - единственный способ забрать свои XMR. Выбрасывать старые версии нельзя.
90
90
  const SUPPORTED_PAYLOAD_VERSIONS = [1, 2, 3];
91
- const NETWORKS = ["mainnet", "stagenet", "testnet"];
91
+ // СЕТИ, КОТОРЫЕ ЧИТАТЕЛЬ ФАЙЛА ЗНАЕТ. Три стандартные плюс fakechain - официальное имя regtest-режима
92
+ // monerod: нода контура CI называет себя именно так, и файл, выпущенный на такой сети, обязан читаться.
93
+ // Список поимённый: сеть вне него отвергается как неизвестная, и это остаётся в силе.
94
+ const NETWORKS = ["mainnet", "stagenet", "testnet", "fakechain"];
92
95
 
93
96
  function b64(bytes) {
94
97
  return btoa(String.fromCharCode(...new Uint8Array(bytes)));
@@ -37,7 +37,7 @@
37
37
  // загрузился (нет файла, старый кеш, сеть) - это НАЗВАННЫЙ отказ с причиной в консоли (sdkBundle ниже), а не
38
38
  // молчание и не подмена.
39
39
 
40
- import { chainById, DEFAULT_CHAIN, MONERO, allowedProvidersFor, knownFactoryCodesFor } from "../core/config.js";
40
+ import { chainById, DEFAULT_CHAIN, MONERO, allowedProvidersFor, knownFactoryCodesFor, knownImplementationCodesFor } from "../core/config.js";
41
41
  import { storage as pageStorage } from "../core/store.js";
42
42
  import { token as authToken, signIn } from "../evm/auth.js";
43
43
  import * as evm from "../evm/index.js";
@@ -69,7 +69,7 @@ import { height as nodeHeightValue } from "../monero/node.js";
69
69
 
70
70
  // АДРЕС ЯДРА СТРОКОЙ - СОБРАННЫЙ БАНДЛ ПОД www/ (tools/build-sdk.mjs): страница получает ядро ОДНИМ
71
71
  // модулем, а не пачкой исходников sdk/. ?v=<хеш> проставляет tools/stamp-assets.mjs.
72
- const SDK_BUNDLE = "/assets/vendors/sdk/sdk-browser.js?v=bdb79a39";
72
+ const SDK_BUNDLE = "/assets/vendors/sdk/sdk-browser.js?v=31e62de5";
73
73
 
74
74
  // ОДНА ЗАГРУЗКА НА СТРАНИЦУ. Отказ - это состояние, а не поломка: он назван в консоли, и вызывающий решает.
75
75
  let loading = null;
@@ -140,6 +140,11 @@ function optionsFor(chain) {
140
140
  // берёт из записи реестра провайдера, а её КОД сверяет с этим списком (extcodehash). Пусто = политики
141
141
  // нет; тогда фабрика всё равно обязана совпасть с подписанной котировкой.
142
142
  knownFactoryCodes: knownFactoryCodesFor(chain),
143
+ // ХЕШ ИЗВЕСТНОЙ СБОРКИ РЕАЛИЗАЦИИ ЭСКРОУ (#114) - РЯДОМ С knownFactoryCodes. Фабрика сверяет пару
144
+ // (implementation, implementationCodeHash) при создании ордера; ядро сверяет ТУ ЖЕ пару на цепи, и этот
145
+ // список - политика интерфейса: какие сборки реализации он принимает. Пусто = политики нет (сеть со
146
+ // старой фабрикой), и тогда пара всё равно читается, а код по адресу реализации обязан быть непустым.
147
+ knownImplementationCodes: knownImplementationCodesFor(chain),
143
148
  };
144
149
  }
145
150
 
package/index.d.ts CHANGED
@@ -24,8 +24,12 @@ export interface NinseiOptions {
24
24
  * without a driver answers with the code `not-implemented`, rather than being assumed EVM by default.
25
25
  */
26
26
  settlement: { chain: string; vm?: string };
27
- /** The Monero network. A value from `sdk.config.xmrNetworks`. */
28
- xmrNetwork: "mainnet" | "stagenet" | "testnet";
27
+ /**
28
+ * The Monero network. A value from `sdk.config.xmrNetworks`. Besides mainnet/stagenet/testnet it names
29
+ * `fakechain` - the official network name monerod reports in regtest mode, which the withdrawal page picks up
30
+ * from the node on the CI contour.
31
+ */
32
+ xmrNetwork: "mainnet" | "stagenet" | "testnet" | "fakechain";
29
33
  /** Where to load workers and WASM from (order-worker, the Monero bundles). Default "/". */
30
34
  assetsBase?: string;
31
35
  /** Storage for unfinished swaps and sessions. Default - a localStorage adapter, if present. */
@@ -89,6 +93,18 @@ export interface NinseiOptions {
89
93
  * policy": the record's factory must still be named and agree with the quote.
90
94
  */
91
95
  knownFactoryCodes?: readonly string[];
96
+ /**
97
+ * THE INTERFACE'S ESCROW-IMPLEMENTATION POLICY (issue #114) - policy, not proof, next to knownFactoryCodes and
98
+ * allowedProviders. Since #114 every order's escrow is an EIP-1167 clone of ONE shared implementation, and the
99
+ * factory carries the pair (`implementation()`, `implementationCodeHash()`). The factory checks that pair at
100
+ * creation; the core checks the SAME pair on chain - it reads the pair off the factory, reads the code at the
101
+ * implementation's address, re-hashes it and matches it against this list of known build hashes (`keccak256` of
102
+ * the implementation's runtime code, a protocol constant: the implementation has no immutables). An implementation
103
+ * whose code is not a known build (or has no code at all) is refused with `quote-factory-code-unknown`; a pair
104
+ * that cannot be read at all (an older factory without those getters) is refused with `quote-factory-unchecked`.
105
+ * An empty/absent list means "no policy": the pair is still read and the implementation's code must be non-empty.
106
+ */
107
+ knownImplementationCodes?: readonly string[];
92
108
  /**
93
109
  * A PASS TO OUR SERVICE for the "server" step (recording the swap with us). Only a wallet can sign the sign-in
94
110
  * message, and the wallet's role in the SDK is an adapter: windows and message signatures remain the interface's,
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@ninsei-labs/sdk",
3
- "version": "0.39.1",
3
+ "version": "0.39.2",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
7
7
  },
8
- "packageManager": "pnpm@9.15.9",
8
+ "packageManager": "pnpm@12.10.1",
9
9
  "description": "XMR <-> EVM swap engine: quotes, guards, order steps, recovery (no DOM, no user-facing strings)",
10
10
  "license": "MIT",
11
11
  "type": "module",
@@ -16,9 +16,9 @@
16
16
  "check:import": "node tools/check.mjs --only=import",
17
17
  "check:surface": "node tools/check.mjs --only=surface",
18
18
  "check:mirror": "node tools/check.mjs --only=mirror",
19
- "check:language": "node tools/check.mjs --only=language",
20
19
  "check:pack": "node tools/check.mjs --only=pack",
21
- "check:secrets": "node tools/check.mjs --only=secrets"
20
+ "check:secrets": "node tools/check.mjs --only=secrets",
21
+ "check:sweep": "node tools/sweep-refusal.test.mjs"
22
22
  },
23
23
  "types": "./index.d.ts",
24
24
  "exports": {
package/src/config.mjs CHANGED
@@ -218,12 +218,15 @@ export function buildConfig(options) {
218
218
  if (!known.includes(id)) fail("bad-input", { field: "routing.prefer", unknown: id, known });
219
219
  }
220
220
 
221
- // MONERO NETWORKS THE CORE KNOWS. The engine's registry declares only the one the demo is configured for
222
- // (XMR_NETWORKS: stagenet). But the SDK contract (index.d.ts, xmrNetwork) names THREE standard names -
223
- // mainnet/stagenet/testnet - and that is not about the demo: the withdrawal page takes the network FROM THE
224
- // NODE, and on a production network its network is mainnet, not a typo. So three standard ones are added to
225
- // the declared ones; anything else is still a refusal with the known list.
226
- const STANDARD_XMR_NETWORKS = ["mainnet", "stagenet", "testnet"];
221
+ // MONERO NETWORKS THE CORE KNOWS. The engine's registry declares only the ones the demo is configured for
222
+ // (XMR_NETWORKS). But the SDK contract (index.d.ts, xmrNetwork) names the STANDARD ones - mainnet/stagenet/
223
+ // testnet - and that is not about the demo: the withdrawal page takes the network FROM THE NODE, and on a
224
+ // production network its network is mainnet, not a typo.
225
+ // REGTEST JOINS THEM BY ITS OFFICIAL NAME. monerod in regtest mode reports its network as `fakechain`, and the
226
+ // withdrawal page builds the facade from the network the node hands it - so the CI contour (a regtest node)
227
+ // hands out `fakechain`. Without that name the core refuses its OWN recovery file and the withdrawal never
228
+ // starts. This is a KNOWN name, not a wildcard: anything else is still a refusal with the known list below.
229
+ const STANDARD_XMR_NETWORKS = ["mainnet", "stagenet", "testnet", "fakechain"];
227
230
  const xmrNetworks = [...new Set([...Object.values(engine.config.XMR_NETWORKS || {}), ...STANDARD_XMR_NETWORKS])];
228
231
  if (!xmrNetworks.includes(options.xmrNetwork)) {
229
232
  fail("bad-input", { field: "xmrNetwork", known: xmrNetworks });
package/src/index.mjs CHANGED
@@ -97,6 +97,14 @@ const normalizeOptions = (options) => {
97
97
  if (o.knownFactoryCodes !== undefined && o.knownFactoryCodes !== null && !Array.isArray(o.knownFactoryCodes)) {
98
98
  fail("bad-input", { field: "knownFactoryCodes" });
99
99
  }
100
+ // THE INTERFACE'S ESCROW-IMPLEMENTATION POLICY (issue #114): an array of code hashes of known implementations
101
+ // of the escrow - the code every order's clone delegatecalls. The client checks the pair (implementation,
102
+ // implementationCodeHash) the factory carries, exactly as the factory does at creation; this list pins WHICH
103
+ // builds the interface accepts. Same rule as knownFactoryCodes: a POLICY list, empty/absent means "no policy"
104
+ // (then the pair is still read and the code at the implementation's address must be non-empty).
105
+ if (o.knownImplementationCodes !== undefined && o.knownImplementationCodes !== null && !Array.isArray(o.knownImplementationCodes)) {
106
+ fail("bad-input", { field: "knownImplementationCodes" });
107
+ }
100
108
  // A PASS TO OUR SERVICE - A SEAM FROM OUTSIDE, like the wallet: only a wallet can sign the sign-in message
101
109
  // (www/js/evm/auth.js, signIn), and requiring it of the core would mean opening windows and signing messages
102
110
  // inside the SDK. The core receives a ready pass and presents it. A missing pass is NOT an option error: the
@@ -137,6 +145,7 @@ const normalizeOptions = (options) => {
137
145
  evmCode: o.evmCode || null,
138
146
  allowedProviders: o.allowedProviders === undefined ? null : o.allowedProviders,
139
147
  knownFactoryCodes: o.knownFactoryCodes === undefined ? null : o.knownFactoryCodes,
148
+ knownImplementationCodes: o.knownImplementationCodes === undefined ? null : o.knownImplementationCodes,
140
149
  serverAuth: o.serverAuth || null,
141
150
  watch: o.watch || null,
142
151
  createRecord: o.createRecord || null,
@@ -186,7 +195,7 @@ export function createNinsei(options) {
186
195
  return (provider) => set.has(String(provider).toLowerCase());
187
196
  })();
188
197
  const quotes = createQuotes({ config, http, now: o.now || (() => Date.now()), evmCall: o.evmCall || null,
189
- evmCode: o.evmCode || null, providerAllowed, knownFactoryCodes: o.knownFactoryCodes });
198
+ evmCode: o.evmCode || null, providerAllowed, knownFactoryCodes: o.knownFactoryCodes, knownImplementationCodes: o.knownImplementationCodes });
190
199
  // THE SAME CHAIN-READ SEAM AS THE LOCK AND THE CORE: checking the slots and signing must go one way, otherwise
191
200
  // part of the check goes past the wallet and part does not.
192
201
  const actions = createActions({ call: o.evmCall });
@@ -141,11 +141,22 @@ function walkCostForXmr(levels, xmrAtomic) {
141
141
  return costWei;
142
142
  }
143
143
 
144
+ // THE SMALLEST TRADE (rule 3). It is the size of the FIRST level the maker SENT, and the node's own
145
+ // deductions (outstanding quotes, a balance cut) never change it: they take depth off the TOP, and a trade
146
+ // that was allowed when the set arrived must stay allowed after the top was eaten. If the minimum were read
147
+ // off the EFFECTIVE set, eating the first level whole would RAISE it to the next, larger level, and eating it
148
+ // in part would LOWER it below what the maker sent - both wrong. Callers pass the sent minimum as minAtomic;
149
+ // absent, the set's own first level is the minimum (the historical behaviour and what the vectors pin).
150
+ function minOf(levels, minAtomic) {
151
+ if (minAtomic === null || minAtomic === undefined) return toBigInt(levels[0].amount);
152
+ return toBigInt(minAtomic);
153
+ }
154
+
144
155
  // DIRECTION 1: "I give/take ASSET - how much XMR".
145
156
  // buy: assetWei - the asset the taker PAYS; the walk budget = assetWei - fee;
146
157
  // sell: assetWei - the asset the taker RECEIVES; the walk budget = assetWei + fee (the set must yield that
147
158
  // much so that AFTER the fee is taken out exactly assetWei is left in hand).
148
- export function xmrForAsset({ levels, role, feeWei = 0n, assetWei, orientation = "ask" }) {
159
+ export function xmrForAsset({ levels, role, feeWei = 0n, assetWei, orientation = "ask", minAtomic = null }) {
149
160
  const verdict = checkLevels(levels, orientation);
150
161
  if (!verdict.ok) return { ok: false, reason: verdict.reason, role, index: verdict.index };
151
162
  if (!verdict.enabled) return { ok: false, reason: "side-off", role };
@@ -157,7 +168,12 @@ export function xmrForAsset({ levels, role, feeWei = 0n, assetWei, orientation =
157
168
  if (budget <= 0n) return { ok: false, reason: "levels-fee-beyond-budget", role };
158
169
  const { fullCostWei } = sideSummary(levels);
159
170
  if (budget > fullCostWei) return { ok: false, reason: "levels-exceed-side", role };
160
- const minCost = levelCostWei(toBigInt(levels[0].amount), toBigInt(levels[0].price));
171
+ // THE SMALLEST TRADE, from the set AS THE MAKER SENT IT (rule 3): callers pass it in minAtomic and the
172
+ // node's deductions never change it. Absent, the set's own first level is used, as it always was.
173
+ const min = minOf(levels, minAtomic);
174
+ // Its cost ON THE EFFECTIVE SET: after deductions the smallest trade may reach into a later level, so the
175
+ // cost is walked, not read off one price.
176
+ const minCost = walkCostForXmr(levels, min);
161
177
  if (budget < minCost) return { ok: false, reason: "levels-below-minimum", role };
162
178
  const xmrAtomic = walkXmrForBudget(levels, budget);
163
179
  return { ok: true, role, xmrAtomic, assetWei: asset };
@@ -166,7 +182,7 @@ export function xmrForAsset({ levels, role, feeWei = 0n, assetWei, orientation =
166
182
  // DIRECTION 2: "I give/want XMR - how much ASSET".
167
183
  // buy: xmrAtomic - how much XMR the taker WANTS; the asset to pay = the walk + fee;
168
184
  // sell: xmrAtomic - how much XMR the taker GIVES; the asset to receive = the walk - fee.
169
- export function assetForXmr({ levels, role, feeWei = 0n, xmrAtomic, orientation = "ask" }) {
185
+ export function assetForXmr({ levels, role, feeWei = 0n, xmrAtomic, orientation = "ask", minAtomic = null }) {
170
186
  const verdict = checkLevels(levels, orientation);
171
187
  if (!verdict.ok) return { ok: false, reason: verdict.reason, role, index: verdict.index };
172
188
  if (!verdict.enabled) return { ok: false, reason: "side-off", role };
@@ -175,7 +191,8 @@ export function assetForXmr({ levels, role, feeWei = 0n, xmrAtomic, orientation
175
191
  const fee = toBigInt(feeWei === undefined || feeWei === null ? 0n : feeWei);
176
192
  if (want === null || fee === null || fee < 0n) return { ok: false, reason: "levels-not-integer", role };
177
193
  if (want > verdict.totalAtomic) return { ok: false, reason: "levels-exceed-side", role };
178
- if (want < verdict.minAtomic) return { ok: false, reason: "levels-below-minimum", role };
194
+ // THE SMALLEST TRADE AS THE MAKER SENT IT, never the effective set's first level (rule 3): see minOf.
195
+ if (want < minOf(levels, minAtomic)) return { ok: false, reason: "levels-below-minimum", role };
179
196
  const walkWei = walkCostForXmr(levels, want);
180
197
  const assetWei = role === "buy" ? walkWei + fee : walkWei - fee;
181
198
  if (assetWei <= 0n) return { ok: false, reason: "levels-fee-beyond-budget", role };
package/src/quotes.mjs CHANGED
@@ -208,13 +208,19 @@ const compactOffer = (o) => ({
208
208
  // the price of the failed row to tell the person WHY there is no offer (and not just "none").
209
209
  const compactRefusal = (r) => ({ ...compactOffer(r), code: r.code, why: r.why });
210
210
 
211
- export function createQuotes({ config, http, now = () => Date.now(), evmCall = null, evmCode = null, providerAllowed = null, knownFactoryCodes = null }) {
211
+ export function createQuotes({ config, http, now = () => Date.now(), evmCall = null, evmCode = null, providerAllowed = null, knownFactoryCodes = null, knownImplementationCodes = null }) {
212
212
  // THE INTERFACE'S FACTORY POLICY (#103): the code hashes of factories it is willing to trade through
213
213
  // (extcodehash semantics = keccak256 of the runtime code). An EMPTY list is "no policy" (like
214
214
  // allowedProviders); a NON-EMPTY one refuses any factory whose code is not on it. Lower-cased once.
215
215
  const factoryPolicy = Array.isArray(knownFactoryCodes) && knownFactoryCodes.length
216
216
  ? knownFactoryCodes.map((h) => String(h).toLowerCase()).filter(Boolean)
217
217
  : null;
218
+ // THE INTERFACE'S ESCROW-IMPLEMENTATION POLICY (#114): the code hashes of the escrow IMPLEMENTATION the
219
+ // factory must lead to - the code every order's clone delegatecalls. Same rule as the factory policy: an
220
+ // EMPTY list is "no policy" (then the pair is still read and the implementation's code must be non-empty).
221
+ const implementationPolicy = Array.isArray(knownImplementationCodes) && knownImplementationCodes.length
222
+ ? knownImplementationCodes.map((h) => String(h).toLowerCase()).filter(Boolean)
223
+ : null;
218
224
  const listeners = new Set();
219
225
  let timer = null;
220
226
  let size = 1; // the request size in units of the pair's ASSET; we start from one, as the engine does
@@ -428,6 +434,30 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
428
434
  fail("quote-factory-code-unknown", { step: "factory", factory: declaredFactory, codeHash });
429
435
  }
430
436
  }
437
+ // THE IMPLEMENTATION THE CLONES WILL EXECUTE (#114). A pinned factory CODE is not enough on its own: the
438
+ // implementation ADDRESS lives in the factory's STORAGE, so the client re-reads the pair the factory itself
439
+ // checks (implementation() / implementationCodeHash()) and the CODE at that address, then applies the same
440
+ // verdict (verifyFactoryImplementationOnChain). A delegatecall to an address WITHOUT code SUCCEEDS, so a
441
+ // clone without an implementation would take the ETH and its markReady/claim/refund would appear to go
442
+ // through while doing nothing - that is exactly the case this refuses. The live code is re-hashed here, so
443
+ // the verdict does not trust the factory's own answer alone.
444
+ if (implementationPolicy) {
445
+ if (typeof evmCall !== "function") fail("quote-factory-unchecked", { step: "factory", why: "no-chain-read" });
446
+ if (typeof evmCode !== "function") fail("quote-factory-unchecked", { step: "factory", why: "no-code-read" });
447
+ const verdict = await engine.evm.verifyFactoryImplementationOnChain({
448
+ factory: declaredFactory,
449
+ read: evmCall,
450
+ code: (address) => evmCode(address),
451
+ policy: implementationPolicy,
452
+ hashCode: (codeHex) => bytesToHexQ(keccak256(hexToBytesQ(codeHex) || new Uint8Array(0))),
453
+ });
454
+ if (!verdict.ok) {
455
+ const unknown = verdict.why === "implementation-code-unknown" ||
456
+ verdict.why === "implementation-code-mismatch" || verdict.why === "implementation-has-no-code";
457
+ fail(unknown ? "quote-factory-code-unknown" : "quote-factory-unchecked",
458
+ { step: "factory", why: verdict.why, factory: declaredFactory, implementation: verdict.implementation || null });
459
+ }
460
+ }
431
461
  // BINDING THE QUOTE TO OUR TERMS. The signature confirms the provider signed THIS quote, but not that it is
432
462
  // about our swap: we are responsible for the binding. The context string is computed by OUR engine module
433
463
  // (format arrakis-order-v3; the field order is part of the protocol and must not be rearranged). There is
package/src/sweep.mjs CHANGED
@@ -14,6 +14,22 @@ const STEPS = ["unlock", "sync", "sweep", "relay"];
14
14
 
15
15
  const contentsOf = (file) => (typeof file === "string" ? file : file && typeof file.contents === "string" ? file.contents : null);
16
16
 
17
+ // A LOW-LEVEL WALLET ERROR IS STILL A REFUSAL, AND EVERY REFUSAL CARRIES A CODE.
18
+ //
19
+ // The library brings its own exceptions with no code at all - monero-ts answers "failed to get hashes" while
20
+ // scanning, and lets it out as a bare `Error`. Releasing one turns a named refusal into `code: null` + raw
21
+ // text: the interface cannot translate it (it has no phrase to look up) and a check cannot assert it. So a
22
+ // wallet step that fails with anything but an SdkError is re-thrown as one, on a code from the package's own
23
+ // dictionary, with the library's message kept as a detail.
24
+ const walletStep = async (surface, step) => {
25
+ try {
26
+ return await step();
27
+ } catch (error) {
28
+ if (error instanceof SdkError) throw error;
29
+ throw new SdkError("wallet-rejected", { surface, why: String((error && error.message) || error) });
30
+ }
31
+ };
32
+
17
33
  export function createSweep({ http, config }) {
18
34
  // THE NAME IS NEEDED BY THE OBJECT ITSELF: `run` calls `api.plan(...)` - the step order is held by the plan and
19
35
  // the withdrawal living in one object, not in two copies. During a manual merge of branches the object was left
@@ -157,9 +173,16 @@ export function createSweep({ http, config }) {
157
173
 
158
174
  // SIGNING WITHOUT SENDING - AS A SEPARATE STEP. The core HOLDS the signed transaction until the person's
159
175
  // decision: otherwise the interface could not show what exactly will leave, and would tell about it only after
160
- // sending. Here is the whole order UP TO AND INCLUDING SIGNING: plan -> wallet -> open -> sync -> free remainder
176
+ // sending. Here is the whole order UP TO AND INCLUDING SIGNING: plan -> wallet -> open -> free remainder
161
177
  // (an empty one STOPS the withdrawal with a code) -> sign (relay: false). The wallet stays open until `relay`,
162
178
  // and the session token is the only handle to it.
179
+ //
180
+ // THE FREE REMAINDER IS READ AND JUDGED BEFORE ANY INPUT SELECTION, AND A SYNC PASS IS NOT THE VERDICT.
181
+ // The wallet library fails low-level while scanning a chain it cannot read (measured: monero-ts `sync()` against
182
+ // a regtest node answering "failed to get hashes"), and that bare error used to escape BEFORE the remainder was
183
+ // read - an empty wallet was refused by a raw exception (`code: null`) instead of `insufficient-funds`. A failed
184
+ // pass is not fatal by itself: the remainder below decides, and anything still blocking the signature is refused
185
+ // WITH A CODE from the dictionary (see `walletStep`).
163
186
  async sign(request) {
164
187
  const plan = await api.plan(request);
165
188
  if (!request || typeof request.to !== "string" || !request.to) throw new SdkError("bad-input", { field: "to" });
@@ -171,12 +194,14 @@ export function createSweep({ http, config }) {
171
194
 
172
195
  await wallet.open(plan);
173
196
  try {
174
- await wallet.sync();
175
- const unlocked = BigInt(await wallet.unlockedBalance());
197
+ // A SYNC PASS - BEST-EFFORT, NOT THE VERDICT. A node whose chain the wallet cannot scan makes the library
198
+ // throw; that must not pre-empt the funds verdict below, and it must not leave as raw text.
199
+ try { await wallet.sync(); } catch { /* the remainder below decides; a real blocker is refused by a code */ }
200
+ const unlocked = BigInt(await walletStep("unlockedBalance", () => wallet.unlockedBalance()));
176
201
  // AN EMPTY REMAINDER STOPS THE WITHDRAWAL BEFORE SIGNING: otherwise the wallet would sign a transaction for
177
202
  // zero, and the refusal would look like success.
178
203
  if (unlocked <= 0n) throw new SdkError("insufficient-funds", { unlocked: unlocked.toString() });
179
- const signed = await wallet.sweepUnlocked({ address: request.to, relay: false });
204
+ const signed = await walletStep("sweepUnlocked", () => wallet.sweepUnlocked({ address: request.to, relay: false }));
180
205
  const token = "sweep-" + (++sessionSeq);
181
206
  sessions.set(token, { wallet, plan, signed });
182
207
  return { token, amount: unlocked.toString(), to: request.to, swept: plan.address };