@ninsei-labs/sdk 0.39.1 → 0.39.3
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 +128 -0
- package/README.md +25 -1
- package/engine/core/config.js +29 -2
- package/engine/evm/claimGas.js +5 -4
- package/engine/evm/factory.js +106 -0
- package/engine/evm/funding.js +11 -11
- package/engine/recovery/recoveryFile.js +4 -1
- package/engine/sdk/bridge.js +7 -2
- package/index.d.ts +36 -2
- package/package.json +4 -4
- package/src/codes.mjs +5 -0
- package/src/config.mjs +9 -6
- package/src/index.mjs +25 -2
- package/src/levelsWalkSpec.mjs +21 -4
- package/src/quotes.mjs +52 -4
- package/src/swaps.mjs +17 -0
- package/src/sweep.mjs +29 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,131 @@
|
|
|
1
|
+
## 0.39.3 — 2026-10-09
|
|
2
|
+
|
|
3
|
+
A NODE'S ADDRESS DENYLIST REFUSAL REACHES THE APP AS ITS OWN CODE, AND THE INTERFACE CAN CHECK ADDRESSES ITSELF (issue #16).
|
|
4
|
+
A maker's node refuses a firm quote to an address on the maker's denylist (`PUT /v1/denylist`) and answers
|
|
5
|
+
`{ok:false, code:"denied-address", why:"..."}`. Until now `quotes.firm` read the answer with `http.json`, which throws
|
|
6
|
+
`server-unavailable` and LOSES the body on any non-2xx: the app was told the node was unavailable when the node had
|
|
7
|
+
answered and refused, and it could not tell "did not answer" from "answered and refused".
|
|
8
|
+
|
|
9
|
+
- **`quotes.firm` reads the answer as a VALUE** (`http.tryJson`, `src/quotes.mjs`): a failed CONNECTION is still
|
|
10
|
+
`server-unavailable`, while an answer that refused is its own refusal code (`quote-refused`), not "unavailable".
|
|
11
|
+
- **The node's `denied-address` refusal reaches the app under the SDK code `address-denied`** (`src/codes.mjs`,
|
|
12
|
+
`index.d.ts`), WITHOUT the node's `why` text and WITHOUT the address: the node names both, and the interface shows
|
|
13
|
+
its own neutral message. A backend relaying the quote passes the node's `code` through (the demo's
|
|
14
|
+
`app/server.mjs` `/api/order-quote` relay now forwards it).
|
|
15
|
+
- **A new optional address check: `createNinsei({ ..., denylist: [addresses] })`** (`src/index.mjs`, `src/quotes.mjs`,
|
|
16
|
+
`src/swaps.mjs`, `index.d.ts`). Compared WITHOUT CASE; an empty/absent list means "no check"; the SDK fetches
|
|
17
|
+
nothing - the interface brings the list in. Before `quotes.firm` the order's `locker` is checked (on a purchase the
|
|
18
|
+
claimer is the node's own address, so it is deliberately not checked); before `swaps.start` the ACTUAL sender is
|
|
19
|
+
checked - the address the funding transaction will really be sent from, read from the wallet adapter's `address()`
|
|
20
|
+
(`eth_accounts[0]`) at the moment of sending, because the person may have switched accounts. A barred address is
|
|
21
|
+
refused with `address-denied` BEFORE any request; an adapter that cannot hand out the current account is refused
|
|
22
|
+
with `bad-input` rather than skipping the policy silently.
|
|
23
|
+
- Checks (`tools/check-sdk.mjs`): the node's `denied-address` answer yields `address-denied` with neither the address
|
|
24
|
+
nor the `why` in `params`; a plain refusal yields `quote-refused` while a failed connection yields
|
|
25
|
+
`server-unavailable`; a denylisted `order.locker` is refused before any request (zero calls seen); a denylisted
|
|
26
|
+
actual sender stops the lock before it signs (no transaction); the comparison is case-insensitive; an empty list
|
|
27
|
+
changes nothing. The breaking run removes the `denied-address` mapping and the pre-send check and must redden.
|
|
28
|
+
|
|
29
|
+
## Unreleased — 2026-10-08
|
|
30
|
+
|
|
31
|
+
EVERY ORDER'S ESCROW IS A CLONE, AND THE CLIENT NOW CHECKS THE IMPLEMENTATION IT WILL EXECUTE (issue #114).
|
|
32
|
+
Since the contract change an order's escrow is an EIP-1167 minimal proxy of ONE shared implementation, and the
|
|
33
|
+
factory carries the pair (`implementation()`, `implementationCodeHash()`). The factory checks that pair before every
|
|
34
|
+
creation; the client now checks the SAME pair on chain, armed by the interface's own policy - `knownImplementationCodes`,
|
|
35
|
+
an array of known implementation build hashes, sitting next to `knownFactoryCodes`. The verdict reads the pair off the
|
|
36
|
+
factory, reads the CODE at the implementation's address and applies the same check the factory does - and re-hashes the
|
|
37
|
+
live code, so it does not trust the factory's own answer alone. A `delegatecall` to an address without code SUCCEEDS,
|
|
38
|
+
so a clone without an implementation would take the ETH and its `markReady`/`claim`/`refund` would appear to go through
|
|
39
|
+
while doing nothing: that case is refused by name (`implementation-has-no-code`).
|
|
40
|
+
|
|
41
|
+
- **`knownImplementationCodes`** (`src/quotes.mjs`, `src/index.mjs`, `index.d.ts`) - the interface's
|
|
42
|
+
escrow-implementation policy, next to `knownFactoryCodes`. An empty/absent list means "no policy": the pair is not
|
|
43
|
+
read and behaviour is as before (needed while a network still runs a factory of the OLDER build, which has no such
|
|
44
|
+
getters and answers a revert - the deployed one does). A non-empty list refuses an implementation whose recorded
|
|
45
|
+
hash is not a known build with `quote-factory-code-unknown`; a pair that cannot be read at all is refused with
|
|
46
|
+
`quote-factory-unchecked`. Proven by four cases beside the existing factory-code ones: a known build is accepted,
|
|
47
|
+
and a foreign one, a code whose hash does not match the recorded one, and an implementation without code are each
|
|
48
|
+
refused by a named reason.
|
|
49
|
+
- **`verifyFactoryImplementationOnChain`** (`engine/evm/factory.js`) - one place that reads the pair and applies
|
|
50
|
+
`verifyFactoryImplementation`, with the chain injected (`read` = `eth_call`, `code` = `eth_getCode`). The module
|
|
51
|
+
stays pure (no imports, no network) and is the same helper the demo's node and watchtower call with their own reads.
|
|
52
|
+
- **The package source is re-synced from the source-of-truth tree**: `src/**`, `engine/**` and `index.d.ts` are
|
|
53
|
+
byte-for-byte the demo repository's `sdk/`. The demo's interface config pins the known implementation hash - a
|
|
54
|
+
protocol constant, because the implementation has no immutable fields, so its runtime code (and its hash) is the
|
|
55
|
+
same on every network.
|
|
56
|
+
- **Claim and order-creation gas limits are the MEASURED ones** (`engine/evm/claimGas.js`, `engine/evm/funding.js`).
|
|
57
|
+
`CLAIM_GAS_LIMIT` is raised from 120 000 to 200 000: an honest `claim` whose fee is the FIRST credit to the
|
|
58
|
+
recipient's cashier costs 120 721 gas on a live chain (124 072 in `forge --gas-report`), so the former limit cut a
|
|
59
|
+
valid claim. `GAS_CREATE_ORDER_AND_FUND` is lowered from 1 500 000 to 700 000, because an order's escrow is now an
|
|
60
|
+
EIP-1167 clone - the measured full call is 315 530 gas (1 190 683 for the former full contract). The dead
|
|
61
|
+
`GAS_CREATE_ORDER` constant (there is no separate creation path) is removed. The node's own claim in the
|
|
62
|
+
source-of-truth tree signs with the SAME shared limit instead of its own literal.
|
|
63
|
+
- Checks: `node tools/check.mjs` - all green.
|
|
64
|
+
|
|
65
|
+
## 0.39.2 — 2026-10-08
|
|
66
|
+
|
|
67
|
+
`fakechain` IS A KNOWN MONERO NETWORK. The withdrawal page takes the Monero network FROM THE NODE, and monerod in
|
|
68
|
+
regtest mode reports its network as `fakechain` - the official name of that mode. On the CI contour (a regtest node)
|
|
69
|
+
the core therefore refused its OWN recovery file (`bad-input`, field `xmrNetwork`) and the withdrawal never started.
|
|
70
|
+
The name is now accepted by every check of the network value, and everything else is still refused by name.
|
|
71
|
+
|
|
72
|
+
- **`fakechain` joins the accepted networks** (`src/config.mjs`, `index.d.ts`): beside the engine registry's own
|
|
73
|
+
names and the standard mainnet/stagenet/testnet it is a KNOWN name of the regtest mode. Anything else is still a
|
|
74
|
+
`bad-input` refusal that names the known list - the value is checked, not "any word accepted".
|
|
75
|
+
- **The recovery-file reader accepts it as well** (`engine/recovery/recoveryFile.js`): a file whose wallet network is
|
|
76
|
+
`fakechain` reads back; an unknown name is still rejected. Proven by a writer -> reader round trip, with the
|
|
77
|
+
unknown-name case beside it.
|
|
78
|
+
- **The engine's network registry declares it** (`engine/core/config.js`): the Monero wallet adapter builds its list
|
|
79
|
+
of known networks from `XMR_NETWORKS`, so `/adapters` knows the name too.
|
|
80
|
+
- **The package source is re-synced from the source-of-truth tree**: `src/**`, `engine/**` and `index.d.ts` are now
|
|
81
|
+
byte-for-byte the demo repository's `sdk/`. Besides the change above this also carries two changes that were
|
|
82
|
+
already in that tree: the escrow-implementation address/codehash read and verdict (`engine/evm/factory.js`,
|
|
83
|
+
`verifyFactoryImplementation`) and the smallest-trade rule that reads the maker's SENT minimum rather than the
|
|
84
|
+
effective set's first level (`src/levelsWalkSpec.mjs`, the `minAtomic` argument).
|
|
85
|
+
- **A withdrawal no longer loses its refusal code on a chain the wallet library cannot scan** (`src/sweep.mjs`):
|
|
86
|
+
the FREE REMAINDER is read and judged BEFORE any input selection, and a failed `sync()` no longer pre-empts that
|
|
87
|
+
verdict. On the CI contour (a regtest node) monero-ts `sync()` throws a bare `failed to get hashes`; that error
|
|
88
|
+
used to escape ahead of the funds check, so an EMPTY wallet was refused with `code: null` + raw text instead of
|
|
89
|
+
the named `insufficient-funds` - and the page had no phrase to translate, because the core had named nothing.
|
|
90
|
+
- **Every refusal keeps a code from the package's dictionary** (`src/sweep.mjs`, `walletStep`): a wallet step that
|
|
91
|
+
fails with anything other than an `SdkError` is re-thrown as one, on `wallet-rejected`, with the library's own
|
|
92
|
+
message kept as `params.why`. `code: null` + raw text is not a refusal an interface can show or a check assert.
|
|
93
|
+
- Proven by a unit run WITHOUT a browser or a contour (the source-of-truth tree's `tools/check-sweep-empty-wallet.mjs`,
|
|
94
|
+
part of the fast gates): a zero remainder refuses with `insufficient-funds` and human words while input selection
|
|
95
|
+
is never touched; a non-zero remainder reaches signing; a low-level library failure comes out WITH a code. Two
|
|
96
|
+
breaking runs redden - cutting the remainder check (the refusal stops being named) and cutting the wrapper (the
|
|
97
|
+
run then reproduces the exact CI symptom: `code: null` + `failed to get hashes`).
|
|
98
|
+
- Checks: `node tools/check.mjs` - all green.
|
|
99
|
+
|
|
100
|
+
HEAD
|
|
101
|
+
## Unreleased — 2026-10-07
|
|
102
|
+
|
|
103
|
+
PRERELEASES GET THEIR OWN npm TAGS, AND `latest` STOPS MOVING BY ACCIDENT. The release workflow published whatever
|
|
104
|
+
version `package.json` carried under whatever tag npm chose on its own; now the tag is DERIVED from the version and
|
|
105
|
+
handed to `npm publish` by name. This entry rides the next version - nothing in `src/`, `engine/` or `index.d.ts`
|
|
106
|
+
changes here.
|
|
107
|
+
|
|
108
|
+
- **The tag comes from the version** (`tools/npm-tag.mjs`): `X.Y.Z` -> `latest`, `X.Y.Z-develop.N` -> `develop`,
|
|
109
|
+
`X.Y.Z-preview.N` -> `preview`, read from the FIRST identifier of the prerelease part. Anything else is a named
|
|
110
|
+
refusal, not a guess: a version that is not `X.Y.Z[...]`, a prerelease channel that is not declared, a tag that
|
|
111
|
+
reads as a version.
|
|
112
|
+
- **A prerelease can never move `latest`.** The publish step works the tag out before it sends anything, and the
|
|
113
|
+
derivation refuses, naming the case, when a prerelease would take `latest` - even under a manual override. The
|
|
114
|
+
reverse is refused too: a plain version does not publish into a channel tag with no decision behind it.
|
|
115
|
+
- **The tag is passed explicitly.** "Publishing a package sets the `latest` tag to the published version unless
|
|
116
|
+
the `--tag` option is used" (https://docs.npmjs.com/cli/v11/commands/npm-dist-tag), and the `tag` config defaults
|
|
117
|
+
to `latest` (https://docs.npmjs.com/cli/v11/using-npm/config#tag) - so the step now ends in
|
|
118
|
+
`npm publish --access public --provenance --tag <tag>` and never leans on that default.
|
|
119
|
+
- **A manual run may override the tag** with the new `tag` input; the step echoes the override and the tag the
|
|
120
|
+
derivation would have produced into the log before the publish, so the decision is readable, not silent.
|
|
121
|
+
- **The cycle is written down** (README, "Releases and prereleases"): a build for ourselves is a prerelease under
|
|
122
|
+
`develop`/`preview`, the demo pins the EXACT version, and a release is a separate decision - a plain version, the
|
|
123
|
+
only shape that moves `latest`.
|
|
124
|
+
- **Proven locally:** `node tools/npm-tag.test.mjs` walks the table - plain, both channels, build metadata, an
|
|
125
|
+
undeclared channel, garbage, every override - 28 cases, all green. A breaking run that cuts the guard reddens 5
|
|
126
|
+
of those 28, exactly the tooth's own cases, and `tools/npm-tag.mjs` was restored byte for byte. `node
|
|
127
|
+
tools/check.mjs` stays green, and CI gained the same case table as its own named step.
|
|
128
|
+
|
|
1
129
|
## 0.39.1 — 2026-10-07
|
|
2
130
|
|
|
3
131
|
THE FIRST RELEASE CUT BY THE TAG-TRIGGERED WORKFLOW. The package's contents did not change: this entry and the
|
package/README.md
CHANGED
|
@@ -96,7 +96,7 @@ Terminal states (`state.terminal`): `success`, `refunded_eth`, `xmr_returned`, `
|
|
|
96
96
|
### Errors (`error.code`, `SdkError.code`)
|
|
97
97
|
|
|
98
98
|
`not-implemented`, `bad-input`, `wallet-not-connected`, `wallet-rejected`, `wrong-chain`, `quote-unavailable`,
|
|
99
|
-
`quote-refused`, `quote-stale`, `recovery-declined`, `recovery-failed`, `storage-unavailable`,
|
|
99
|
+
`quote-refused`, `address-denied`, `quote-stale`, `recovery-declined`, `recovery-failed`, `storage-unavailable`,
|
|
100
100
|
`server-unavailable`, `server-refused`, `xmr-timeout`, `xmr-locked`, `contract-reverted`, `insufficient-funds`, `unknown`.
|
|
101
101
|
|
|
102
102
|
Three refusals that are easy to merge into one are named DIFFERENTLY, because the advice differs:
|
|
@@ -105,6 +105,9 @@ reason and status are in `params`; investigate); `xmr-timeout` — the wait for
|
|
|
105
105
|
state of the money, not a breakdown); `xmr-locked` — XMR is on the address but the transaction's `unlock_time`
|
|
106
106
|
barred it until a height or a date (`params.until`), so the funds are not an arrival and the ready mark is not
|
|
107
107
|
signed (issue #84).
|
|
108
|
+
`address-denied` (issue #16) - the address is on the interface's `denylist` (refused before any
|
|
109
|
+
request) or the node's own `denied-address` refusal, WITHOUT the node's `why` text and WITHOUT the address: the
|
|
110
|
+
interface shows its own neutral message.
|
|
108
111
|
|
|
109
112
|
## Quotes
|
|
110
113
|
|
|
@@ -185,3 +188,24 @@ trigger where those exist; on another VM they simply do not appear, rather than
|
|
|
185
188
|
touch them: `atomic/order-worker.js`, the Monero bundles.
|
|
186
189
|
6. **Strict CSP.** The package is designed for a policy without `eval` and inline.
|
|
187
190
|
7. **Zero dependencies.**
|
|
191
|
+
|
|
192
|
+
## Releases and prereleases
|
|
193
|
+
|
|
194
|
+
Every published version rides on an npm dist-tag, and the tag is **derived from the version** - never left to
|
|
195
|
+
npm's default:
|
|
196
|
+
|
|
197
|
+
| Version | npm dist-tag | What it is |
|
|
198
|
+
|---|---|---|
|
|
199
|
+
| `X.Y.Z` | `latest` | a release - a separate decision, and the only thing that moves `latest` |
|
|
200
|
+
| `X.Y.Z-develop.N` | `develop` | a build for ourselves |
|
|
201
|
+
| `X.Y.Z-preview.N` | `preview` | a build to show |
|
|
202
|
+
|
|
203
|
+
The loop this serves: build a version for yourself (`0.40.0-develop.1`), publish it under `develop`, and have the
|
|
204
|
+
demo pin the **exact** version (`"@ninsei-labs/sdk": "0.40.0-develop.1"`) rather than a tag. `latest` does not
|
|
205
|
+
move, so `npm install @ninsei-labs/sdk` keeps serving the last real release. Shipping a release is then its own
|
|
206
|
+
decision: a plain `X.Y.Z`, the only shape that publishes under `latest`.
|
|
207
|
+
|
|
208
|
+
The tag is passed to `npm publish` explicitly (`--tag <tag>`), and `tools/npm-tag.mjs` refuses the two ways this
|
|
209
|
+
can go wrong **by name** - a prerelease that would take `latest`, and a plain version that would slide into a
|
|
210
|
+
channel someone else owns. A manual run of the release workflow may override the tag with an input; when it does,
|
|
211
|
+
the log says so. The derivation and its refusals are covered by `node tools/npm-tag.test.mjs`.
|
package/engine/core/config.js
CHANGED
|
@@ -365,7 +365,16 @@ export const CHAINS = [
|
|
|
365
365
|
// селектор predict 0x3d81fc01) и касса 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 (573 байта, но ДРУГОЙ
|
|
366
366
|
// сборки - keccak 0x5c32bb9c…): её код не совпадал с канонической кассой, и эскроу отверг бы такой ордер
|
|
367
367
|
// по #122 - поэтому касса развёрнута заново тем же заходом.
|
|
368
|
-
|
|
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
|
-
|
|
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-файлу - демка за него не подписывает
|
package/engine/evm/claimGas.js
CHANGED
|
@@ -22,10 +22,11 @@
|
|
|
22
22
|
// стоимость claim плюс ЯВНЫЙ ЗАПАС на рост базовой цены между выдачей билета и забором. Второго расчёта
|
|
23
23
|
// размера подарка нет ни у ноды, ни у прогонов: они зовут claimGasGiftWei отсюда.
|
|
24
24
|
|
|
25
|
-
// ПРЕДЕЛ ГАЗА CLAIM - ОДНО ЧИСЛО НА ОБЕИХ
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
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 примерно
|
package/engine/evm/factory.js
CHANGED
|
@@ -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
|
+
}
|
package/engine/evm/funding.js
CHANGED
|
@@ -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
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
//
|
|
116
|
-
|
|
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
|
-
|
|
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)));
|
package/engine/sdk/bridge.js
CHANGED
|
@@ -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=
|
|
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
|
-
/**
|
|
28
|
-
|
|
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,30 @@ 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[];
|
|
108
|
+
/**
|
|
109
|
+
* THE INTERFACE'S ADDRESS DENYLIST (issue #16) - policy, not proof, next to allowedProviders. Addresses the
|
|
110
|
+
* interface will not trade to or from, compared WITHOUT CASE. The SDK fetches nothing: the interface brings the
|
|
111
|
+
* list in. An empty/absent list means "no check".
|
|
112
|
+
* Before `quotes.firm` the order's `locker` is checked (on a purchase the claimer is the node's own address, so
|
|
113
|
+
* it is deliberately not checked), and before `swaps.start` the ACTUAL sender - the address the funding will be
|
|
114
|
+
* sent from (`eth_accounts[0]` read from the wallet adapter at the moment of sending on demand; the person may
|
|
115
|
+
* have switched accounts). A barred address is refused with `address-denied` BEFORE any request. A node's own
|
|
116
|
+
* `denied-address` refusal reaches the app under the same code, WITHOUT the node's `why` text and WITHOUT the
|
|
117
|
+
* address: the interface shows its own neutral message.
|
|
118
|
+
*/
|
|
119
|
+
denylist?: readonly string[];
|
|
92
120
|
/**
|
|
93
121
|
* A PASS TO OUR SERVICE for the "server" step (recording the swap with us). Only a wallet can sign the sign-in
|
|
94
122
|
* message, and the wallet's role in the SDK is an adapter: windows and message signatures remain the interface's,
|
|
@@ -1262,6 +1290,12 @@ export type ErrorCode =
|
|
|
1262
1290
|
| "wrong-chain"
|
|
1263
1291
|
| "quote-unavailable"
|
|
1264
1292
|
| "quote-refused"
|
|
1293
|
+
/**
|
|
1294
|
+
* An address on the interface's `denylist` refused BEFORE any request - the order's `locker` before `quotes.firm`,
|
|
1295
|
+
* or the actual sender before `swaps.start` (issue #16). Also names the node's own `denied-address` refusal as it
|
|
1296
|
+
* reaches the app: WITHOUT the node's `why` text and WITHOUT the address, so the interface shows its own message.
|
|
1297
|
+
*/
|
|
1298
|
+
| "address-denied"
|
|
1265
1299
|
| "quote-stale"
|
|
1266
1300
|
/** The order-quote signature (EIP-712, v6) did not recover to any address. */
|
|
1267
1301
|
| "quote-signature-invalid"
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ninsei-labs/sdk",
|
|
3
|
-
"version": "0.39.
|
|
3
|
+
"version": "0.39.3",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"registry": "https://registry.npmjs.org/"
|
|
7
7
|
},
|
|
8
|
-
"packageManager": "pnpm@
|
|
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/codes.mjs
CHANGED
|
@@ -42,6 +42,11 @@ export const ERROR_CODES = [
|
|
|
42
42
|
"wrong-chain",
|
|
43
43
|
"quote-unavailable",
|
|
44
44
|
"quote-refused",
|
|
45
|
+
// A NODE'S ADDRESS DENYLIST REFUSAL (issue #16): the maker's node answers {ok:false, code:"denied-address", why}
|
|
46
|
+
// before it prices the order, and that refusal reaches the app under THIS code - WITHOUT the node's `why` text
|
|
47
|
+
// and WITHOUT the address, because the interface shows the person its own neutral message. It is its own code,
|
|
48
|
+
// not "quote-refused": the advice to the interface differs ("this address is barred" vs "the quote was refused").
|
|
49
|
+
"address-denied",
|
|
45
50
|
"quote-stale",
|
|
46
51
|
// THE ORDER-QUOTE SIGNATURE (EIP-712, quote format v7). Four different refusals, because the interface owes the
|
|
47
52
|
// person a different piece of advice for each:
|
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
|
|
222
|
-
// (XMR_NETWORKS
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
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,20 @@ 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
|
+
}
|
|
108
|
+
// THE INTERFACE'S ADDRESS DENYLIST (issue #16): policy, like allowedProviders - addresses the interface will not
|
|
109
|
+
// trade to or from. An empty/absent list means "no check"; an entry that is not a string is a typo, not a silent
|
|
110
|
+
// no-op. The SDK fetches nothing: the interface brings the list in.
|
|
111
|
+
if (o.denylist !== undefined && o.denylist !== null) {
|
|
112
|
+
if (!Array.isArray(o.denylist) || o.denylist.some((a) => typeof a !== "string")) fail("bad-input", { field: "denylist" });
|
|
113
|
+
}
|
|
100
114
|
// A PASS TO OUR SERVICE - A SEAM FROM OUTSIDE, like the wallet: only a wallet can sign the sign-in message
|
|
101
115
|
// (www/js/evm/auth.js, signIn), and requiring it of the core would mean opening windows and signing messages
|
|
102
116
|
// inside the SDK. The core receives a ready pass and presents it. A missing pass is NOT an option error: the
|
|
@@ -137,6 +151,8 @@ const normalizeOptions = (options) => {
|
|
|
137
151
|
evmCode: o.evmCode || null,
|
|
138
152
|
allowedProviders: o.allowedProviders === undefined ? null : o.allowedProviders,
|
|
139
153
|
knownFactoryCodes: o.knownFactoryCodes === undefined ? null : o.knownFactoryCodes,
|
|
154
|
+
knownImplementationCodes: o.knownImplementationCodes === undefined ? null : o.knownImplementationCodes,
|
|
155
|
+
denylist: o.denylist === undefined ? null : o.denylist,
|
|
140
156
|
serverAuth: o.serverAuth || null,
|
|
141
157
|
watch: o.watch || null,
|
|
142
158
|
createRecord: o.createRecord || null,
|
|
@@ -185,8 +201,15 @@ export function createNinsei(options) {
|
|
|
185
201
|
const set = new Set(a.map((v) => String(v).toLowerCase()));
|
|
186
202
|
return (provider) => set.has(String(provider).toLowerCase());
|
|
187
203
|
})();
|
|
204
|
+
// THE DENYLIST IS LOWER-CASED ONCE HERE, so the comparison rule (case-insensitive membership) lives in ONE
|
|
205
|
+
// place and BOTH the quote check (`quotes.firm`) and the lock check (`swaps.start`) use it. An empty list is
|
|
206
|
+
// "no policy" (null), not an empty check.
|
|
207
|
+
const denylist = Array.isArray(o.denylist) && o.denylist.length
|
|
208
|
+
? new Set(o.denylist.map((a) => String(a).toLowerCase()))
|
|
209
|
+
: null;
|
|
188
210
|
const quotes = createQuotes({ config, http, now: o.now || (() => Date.now()), evmCall: o.evmCall || null,
|
|
189
|
-
evmCode: o.evmCode || null, providerAllowed, knownFactoryCodes: o.knownFactoryCodes
|
|
211
|
+
evmCode: o.evmCode || null, providerAllowed, knownFactoryCodes: o.knownFactoryCodes, knownImplementationCodes: o.knownImplementationCodes,
|
|
212
|
+
denylist });
|
|
190
213
|
// THE SAME CHAIN-READ SEAM AS THE LOCK AND THE CORE: checking the slots and signing must go one way, otherwise
|
|
191
214
|
// part of the check goes past the wallet and part does not.
|
|
192
215
|
const actions = createActions({ call: o.evmCall });
|
|
@@ -237,7 +260,7 @@ export function createNinsei(options) {
|
|
|
237
260
|
// would not know where to knock, and without the pass there is nothing to present to the service (and that
|
|
238
261
|
// is a refusal with a CODE, not an invented path).
|
|
239
262
|
swaps: createSwaps({ preflight, http, mode: o.mode, xmrNetwork: o.xmrNetwork, call: o.evmCall,
|
|
240
|
-
config, auth: o.serverAuth, watch: o.watch,
|
|
263
|
+
config, auth: o.serverAuth, denylist, watch: o.watch,
|
|
241
264
|
// THE RECORD IS BUILT HERE ITSELF, and one brought from outside is only the caller's preference.
|
|
242
265
|
createRecord: o.createRecord }),
|
|
243
266
|
// XMR WITHDRAWAL: parsing the file and requesting data work; the withdrawal itself is named (the Monero
|
package/src/levelsWalkSpec.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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, denylist = 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
|
|
@@ -334,9 +340,19 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
|
|
|
334
340
|
if (!request || typeof request !== "object") fail("bad-input", { field: "request" });
|
|
335
341
|
if (typeof request.providerId !== "string" || !request.providerId) fail("bad-input", { field: "providerId" });
|
|
336
342
|
if (!request.order || typeof request.order !== "object") fail("bad-input", { field: "order" });
|
|
337
|
-
|
|
343
|
+
// THE INTERFACE'S OWN ADDRESS DENYLIST (issue #16): POLICY, not proof - the interface refuses to ask for a
|
|
344
|
+
// quote for an address on its own list, and does it BEFORE any request is made. `order.locker` is the
|
|
345
|
+
// address the funds come from on a purchase (the claimer there is the node's own address, so it is
|
|
346
|
+
// deliberately not checked). An empty/absent list is "no policy"; the comparison is WITHOUT CASE.
|
|
347
|
+
if (denylist && typeof request.order.locker === "string" && denylist.has(request.order.locker.toLowerCase())) {
|
|
348
|
+
fail("address-denied", { step: "order-quote" });
|
|
349
|
+
}
|
|
350
|
+
let res = null;
|
|
338
351
|
try {
|
|
339
|
-
|
|
352
|
+
// THE ANSWER IS READ AS A VALUE (tryJson), NOT AS AN EXCEPTION: "did not answer" and "answered and
|
|
353
|
+
// refused" must stay different, and a refusal's own body carries the reason. `json` throws
|
|
354
|
+
// server-unavailable and LOSES that body.
|
|
355
|
+
res = await http.tryJson(config.route("orderQuote"), {
|
|
340
356
|
method: "POST",
|
|
341
357
|
headers: { "content-type": "application/json" },
|
|
342
358
|
body: JSON.stringify({ providerId: request.providerId, order: request.order }),
|
|
@@ -345,7 +361,15 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
|
|
|
345
361
|
if (error && error.code === "bad-input") throw error;
|
|
346
362
|
fail("server-unavailable", { step: "order-quote" });
|
|
347
363
|
}
|
|
348
|
-
|
|
364
|
+
// A NODE'S DENYLIST REFUSAL REACHES THE APP UNDER ITS OWN NEUTRAL SDK CODE, WITHOUT the `why` text and
|
|
365
|
+
// WITHOUT the address: the node names both (the maker's denylist and the address), and the interface shows
|
|
366
|
+
// the person its own neutral message. Every other refusal keeps its existing code (quote-refused etc.).
|
|
367
|
+
const refusedCode = res && res.body && typeof res.body.code === "string" ? res.body.code : null;
|
|
368
|
+
if (refusedCode === "denied-address") fail("address-denied", { step: "order-quote" });
|
|
369
|
+
if (!res || res.ok !== true || !res.body || res.body.ok !== true || !res.body.quote) {
|
|
370
|
+
fail("quote-refused", { step: "order-quote" });
|
|
371
|
+
}
|
|
372
|
+
const body = res.body;
|
|
349
373
|
const quote = body.quote;
|
|
350
374
|
// A MISSING FIELD OF THE SIGNED SET IS NOT "PROBABLY ZERO": the digest cannot be built, so the quote is
|
|
351
375
|
// not verifiable and is refused (its own code, so the interface can say which field is unbound).
|
|
@@ -428,6 +452,30 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
|
|
|
428
452
|
fail("quote-factory-code-unknown", { step: "factory", factory: declaredFactory, codeHash });
|
|
429
453
|
}
|
|
430
454
|
}
|
|
455
|
+
// THE IMPLEMENTATION THE CLONES WILL EXECUTE (#114). A pinned factory CODE is not enough on its own: the
|
|
456
|
+
// implementation ADDRESS lives in the factory's STORAGE, so the client re-reads the pair the factory itself
|
|
457
|
+
// checks (implementation() / implementationCodeHash()) and the CODE at that address, then applies the same
|
|
458
|
+
// verdict (verifyFactoryImplementationOnChain). A delegatecall to an address WITHOUT code SUCCEEDS, so a
|
|
459
|
+
// clone without an implementation would take the ETH and its markReady/claim/refund would appear to go
|
|
460
|
+
// through while doing nothing - that is exactly the case this refuses. The live code is re-hashed here, so
|
|
461
|
+
// the verdict does not trust the factory's own answer alone.
|
|
462
|
+
if (implementationPolicy) {
|
|
463
|
+
if (typeof evmCall !== "function") fail("quote-factory-unchecked", { step: "factory", why: "no-chain-read" });
|
|
464
|
+
if (typeof evmCode !== "function") fail("quote-factory-unchecked", { step: "factory", why: "no-code-read" });
|
|
465
|
+
const verdict = await engine.evm.verifyFactoryImplementationOnChain({
|
|
466
|
+
factory: declaredFactory,
|
|
467
|
+
read: evmCall,
|
|
468
|
+
code: (address) => evmCode(address),
|
|
469
|
+
policy: implementationPolicy,
|
|
470
|
+
hashCode: (codeHex) => bytesToHexQ(keccak256(hexToBytesQ(codeHex) || new Uint8Array(0))),
|
|
471
|
+
});
|
|
472
|
+
if (!verdict.ok) {
|
|
473
|
+
const unknown = verdict.why === "implementation-code-unknown" ||
|
|
474
|
+
verdict.why === "implementation-code-mismatch" || verdict.why === "implementation-has-no-code";
|
|
475
|
+
fail(unknown ? "quote-factory-code-unknown" : "quote-factory-unchecked",
|
|
476
|
+
{ step: "factory", why: verdict.why, factory: declaredFactory, implementation: verdict.implementation || null });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
431
479
|
// BINDING THE QUOTE TO OUR TERMS. The signature confirms the provider signed THIS quote, but not that it is
|
|
432
480
|
// about our swap: we are responsible for the binding. The context string is computed by OUR engine module
|
|
433
481
|
// (format arrakis-order-v3; the field order is part of the protocol and must not be rearranged). There is
|
package/src/swaps.mjs
CHANGED
|
@@ -264,6 +264,10 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
264
264
|
// adapter (send/receipt): windows and message signatures remain the interface's. A READY pass arrives here (as
|
|
265
265
|
// www/js/evm/auth.js, signIn obtains it today), and the core merely presents it.
|
|
266
266
|
auth = null,
|
|
267
|
+
// THE INTERFACE'S ADDRESS DENYLIST (issue #16), a Set of lower-cased addresses already assembled by the facade.
|
|
268
|
+
// The check on the ACTUAL sender is done in start() below, ONE place, right before the send - the wallet adapter
|
|
269
|
+
// only HANDS OUT the current account (`address()`), it does not carry the policy.
|
|
270
|
+
denylist = null,
|
|
267
271
|
watch: watchOptions = null } = {}) {
|
|
268
272
|
// WHAT READS THE CHAIN AT THE LOCK IS A SEAM, as in actions.mjs: by default the engine's read, from outside you can
|
|
269
273
|
// substitute your own. Otherwise the lock step could not be checked beyond predicting the address - and "the address
|
|
@@ -757,6 +761,19 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
757
761
|
if (!wallet || typeof wallet.send !== "function" || typeof wallet.receipt !== "function") {
|
|
758
762
|
throw new SdkError("bad-input", { field: "wallet", missing: ["send", "receipt"], surface: "swaps.start", step: "lock" });
|
|
759
763
|
}
|
|
764
|
+
// THE INTERFACE'S ADDRESS DENYLIST, ON THE ACTUAL SENDER (issue #16). The person may have switched accounts
|
|
765
|
+
// in the wallet, so what matters is the address the funding transaction will really be sent from AT THIS
|
|
766
|
+
// MOMENT, not `order.locker` checked earlier. The wallet adapter hands out the current account (the adapter's
|
|
767
|
+
// `address()` reads `eth_accounts[0]`); the comparison lives HERE, one place, right before the send - not
|
|
768
|
+
// inside the adapter. The refusal happens BEFORE any request: no transaction is signed or sent.
|
|
769
|
+
if (denylist) {
|
|
770
|
+
const sender = typeof wallet.address === "function" ? await wallet.address() : null;
|
|
771
|
+
if (typeof sender !== "string" || !sender) {
|
|
772
|
+
// A HALF-ADAPTER CANNOT BE APPLIED THE POLICY TO: silently skipping would make the list skippable.
|
|
773
|
+
throw new SdkError("bad-input", { field: "wallet", missing: ["address"], surface: "swaps.start", step: "lock" });
|
|
774
|
+
}
|
|
775
|
+
if (denylist.has(sender.toLowerCase())) throw new SdkError("address-denied", { step: "lock" });
|
|
776
|
+
}
|
|
760
777
|
// THE LOCK TERMS ARE THE SAME AS THE ORDER'S, and are taken from the caller: exactly these fields enter the
|
|
761
778
|
// context the DLEQ proof is bound to (orderContext.js, FIELDS). The deadlines and composition are checked by the
|
|
762
779
|
// engine (lock.send -> escrow.orderTerms) BEFORE sending, not by our own arithmetic: the contract sets the rules.
|
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 ->
|
|
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
|
-
|
|
175
|
-
|
|
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 };
|