From 4edf8ff9cb72a442d67e15604c3d1d3699f24509 Mon Sep 17 00:00:00 2001 From: Seun Lanlege Date: Thu, 10 Sep 2026 10:50:44 +0000 Subject: [PATCH] [repo]: split docs/ai into one file per entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three shared files per package — ChangeLog.md, Decisions.md, Flow.md — were append-only with newest entries on top, so any two concurrent PRs wrote the same line and GitHub marked both conflicted. #1251 declared `merge=union` for the two log files. That does resolve the collision locally, but GitHub ignores merge drivers when it computes a PR's mergeability, so the PRs stayed blocked regardless. Measured on #1250, whose merge base is the .gitattributes commit itself and which changes nothing but those two logs and one test: it merges into main clean locally with the union driver, conflicts locally without it, and GitHub reports `mergeable: false` either way. So stop sharing the files. Each entry is now its own file under docs/ai/{changelog,decisions,flows}/, named by date and title. Two PRs add two different files and there is nothing to merge — the same reason changesets keeps one file per change instead of one shared changelog. .gitattributes goes with it, having nothing left to declare. 396 entries moved across the four packages. The prose is untouched; the only edit is each entry's headings moving up one level, from `##` to `#`, since every entry is now a document rather than a section. Verified by demoting every new file back a level and comparing it to the source text it came from, byte for byte. Flows stay one file per flow and are still edited in place, so two PRs revising the same flow still conflict. That one is worth seeing: it means two changes disagree about how the code runs. Open PRs that append to the old files will hit a modify/delete conflict once. Resolving it is moving the entry into a new file. --- .gitattributes | 9 - CLAUDE.md | 34 +- sdk/packages/core/docs/ai/ChangeLog.md | 287 --- sdk/packages/core/docs/ai/Decisions.md | 301 --- sdk/packages/core/docs/ai/README.md | 22 + ...ught-back-in-sync-with-the-gateway-1160.md | 22 + ...o-hostmanager-gated-on-the-same-relayer.md | 14 + ...addressed-to-the-manager-the-host-still.md | 13 + ...n-hyperfungibletoken-fail-closed-on-the.md | 16 + ...relayer-allowlist-on-the-intent-gateway.md | 19 + ...ts-arm-the-relayer-in-the-deploy-script.md | 13 + ...fused-on-any-proxy-already-at-a-version.md | 12 + ...akes-the-relayer-and-lands-at-version-2.md | 38 + ...e-governance-relayer-gateway-setrelayer.md | 39 + ...t-checks-caller-and-relayer-through-one.md | 11 + ...-of-hyperfungibletoken-into-bridgetoken.md | 14 + ...omes-execute-one-governance-door-to-the.md | 25 + ...asserts-the-armed-mainnet-proxy-instead.md | 11 + ...ernance-deliveries-gated-on-one-relayer.md | 13 + ...0-docs-ai-split-into-one-file-per-entry.md | 20 + ...ept-identical-to-intentsbase-not-merely.md | 13 + ...tadded-was-renamed-rather-than-kept-for.md | 12 + ...s-are-gated-too-and-that-does-not-touch.md | 25 + ...opts-in-to-the-relayer-gate-bridgetoken.md | 18 + ...-does-not-declare-setrelayer-or-relayer.md | 5 + ...s-gated-on-the-token-unlike-the-gateway.md | 5 + ...layer-accepts-the-host-not-address-this.md | 11 + ...etter-was-dropped-to-stay-under-eip-170.md | 8 + ...ate-storage-variable-not-a-params-field.md | 14 + ...osed-and-the-upgrade-arms-it-atomically.md | 14 + ...e-implementation-an-upgrade-is-one-such.md | 22 + ...st-only-and-the-only-writer-unset-means.md | 21 + ...d-the-evm-deploy-scripts-do-not-call-it.md | 15 + ...re-proxy-rather-than-a-version-check-in.md | 16 + ...h-contracts-version-on-the-gateway-from.md | 17 + ...-the-relayer-rotated-only-by-governance.md | 19 + ...longs-to-the-token-that-needs-it-not-to.md | 14 + ...ation-s-version-initialize-arms-migrate.md | 29 + ...very-reaches-the-gateway-and-where-the.md} | 64 +- ...er-gate-and-why-the-base-token-has-none.md | 20 + .../the-simplexpaymaster-s-relayer-gate.md | 14 + ...gatewayv2-is-and-what-actually-reads-it.md | 21 + sdk/packages/indexer/docs/ai/ChangeLog.md | 278 --- sdk/packages/indexer/docs/ai/Decisions.md | 347 --- sdk/packages/indexer/docs/ai/Flow.md | 181 -- sdk/packages/indexer/docs/ai/README.md | 22 + ...-gateway-level-daily-filled-volume-1085.md | 5 + ...daily-rows-isolate-seed-failures-review.md | 5 + ...olume-from-existing-filler-history-1085.md | 5 + ...malize-by-the-leg-s-own-standard-amount.md | 13 + ...the-polytope-build-of-the-subquery-node.md | 5 + ...-phantom-bids-of-either-fillorder-shape.md | 26 + ...-pool-s-lp-balances-on-every-order-fill.md | 59 + ...row-releases-and-vault-events-and-value.md | 50 + ...-against-the-verified-solver-set-review.md | 18 + ...strate-schema-migrations-1163-follow-up.md | 8 + ...e-from-the-intentgatewayv3-abi-so-subql.md | 10 + ...rent-and-the-previous-solveraccount-per.md | 15 + ...exing-block-handler-rename-its-template.md | 4 + ...ow-evm-nodes-publish-inventory-readings.md | 19 + ...reads-permit2-sponsored-bids-as-well-as.md | 4 + ...ds-dropped-as-failed-to-process-bid-for.md | 4 + ...0-docs-ai-split-into-one-file-per-entry.md | 20 + ...-reuses-dailyvolumeusd-instead-of-a-new.md | 7 + ...-at-the-same-call-site-as-filler-volume.md | 9 + ...from-daily-rows-and-a-failed-seed-skips.md | 7 + ...uses-the-aggregate-cumulative-record-as.md | 7 + ...nt-check-bounds-plausibility-instead-of.md | 11 + ...nce-is-fixed-in-a-forked-node-image-not.md | 9 + ...-both-fillorder-shapes-and-the-new-test.md | 22 + ...sitions-live-in-one-row-per-solver-1159.md | 34 + ...t-events-refresh-by-provider-and-depths.md | 22 + ...ds-are-pinned-to-the-event-s-block-1159.md | 13 + ...s-its-shape-1159-3-is-left-as-a-comment.md | 9 + ...ads-balances-publishes-no-provenance-of.md | 65 + ...ation-on-restart-not-versioned-manifest.md | 17 + ...pool-row-evm-nodes-publish-readings-the.md | 57 + ...stays-in-the-sdk-the-indexer-tests-only.md | 16 + ...est-runs-the-real-bundle-inside-vm2-not.md | 22 + ...ent-gateway-volume-indexing-orderfilled.md | 25 + ...id-calldata-decoding-extractfilldatavm2.md | 23 + ...to-pool-rates-phantombidwindowexhausted.md | 25 + ...-orderfilled-partialfill-escrowreleased.md | 78 + ...rations-verified-2026-09-04-against-the.md | 22 + sdk/packages/sdk/docs/ai/ChangeLog.md | 266 --- sdk/packages/sdk/docs/ai/Decisions.md | 435 ---- sdk/packages/sdk/docs/ai/Flow.md | 62 - sdk/packages/sdk/docs/ai/README.md | 22 + ...gaccount-shrinks-to-signtypeddata-alone.md | 11 + ...he-refund-post-gas-pin-1144-left-behind.md | 5 + ...er-caches-responses-so-a-failed-phantom.md | 7 + ...ds-are-haircut-30bps-before-aggregation.md | 9 + ...aggregate-indexed-pool-rates-by-default.md | 5 + ...ate-querystorage-call-and-the-poll-caps.md | 11 + ...idge-calls-travel-as-one-json-rpc-batch.md | 11 + ...validuntil-and-fillorder-has-two-shapes.md | 34 + ...-scan-names-the-events-key-and-its-type.md | 21 + ...s-are-paced-and-a-block-scan-costs-half.md | 11 + ...-are-haircut-10bps-every-other-bid-5bps.md | 7 + ...er-v1-abi-for-consumers-that-cannot-use.md | 14 + ...eads-and-declared-v4-positions-reported.md | 24 + ...rs-can-skip-a-backlog-it-will-never-use.md | 31 + ...ves-to-the-2026-09-05-deployment-2-8-11.md | 13 + ...llerconfig-acceptedsourcechains-removed.md | 4 + ...om-bids-are-no-longer-haircut-only-pool.md | 4 + ...-bids-pay-the-protocol-fee-haircut-read.md | 4 + ...-are-utf-8-coded-by-hand-so-the-decoder.md | 4 + ...steranddata-decodes-in-both-shapes-bare.md | 4 + ...0-docs-ai-split-into-one-file-per-entry.md | 20 + ...count-describes-only-what-the-sdk-calls.md | 13 + ...ovider-runs-with-its-response-cache-off.md | 11 + ...t-keys-off-the-declaration-and-lands-on.md | 14 + ...lt-to-directional-indexed-rates-without.md | 14 + ...at-the-provider-and-only-the-block-hash.md | 16 + ...aircut-the-pool-tier-drops-to-10bps-and.md | 11 + ...lives-at-the-provider-keyed-by-endpoint.md | 14 + ...in-filloptions-not-in-the-bid-signature.md | 29 + ...runtime-version-and-drops-it-the-moment.md | 15 + ...omputed-and-the-ranged-reply-is-decoded.md | 12 + ...r-load-bearing-a-ranged-reply-that-will.md | 13 + ...ddress-identifies-the-filloptions-shape.md | 44 + ...with-state-querystorage-and-declines-it.md | 17 + ...-is-exported-not-re-declared-downstream.md | 17 + ...of-the-balance-read-and-a-per-chain-map.md | 12 + ...re-reported-out-of-the-aggregation-1159.md | 23 + ...limit-is-read-from-the-chain-and-always.md | 32 + ...rcut-is-the-gateway-s-protocol-fee-read.md | 43 + ...eranddata-is-read-as-a-bare-declaration.md | 28 + ...oder-anywhere-on-the-phantom-bid-decode.md | 32 + ...m-order-s-bids-become-one-price-per-leg.md | 12 + .../flows/how-a-solver-s-bid-gets-signed.md | 10 + ...equests-to-a-hyperbridge-node-are-paced.md | 11 + ...he-phantom-order-poll-reads-hyperbridge.md | 22 + sdk/packages/simplex/docs/ai/ChangeLog.md | 2086 ----------------- sdk/packages/simplex/docs/ai/Decisions.md | 2069 ---------------- sdk/packages/simplex/docs/ai/Flow.md | 753 ------ sdk/packages/simplex/docs/ai/README.md | 22 + ...-guards-the-signerless-one-way-door-and.md | 11 + ...it2-mode-for-the-simplex-paymaster-1071.md | 7 + ...-18-signer-return-contracts-spelled-out.md | 5 + ...-a-signer-interface-instead-of-a-signer.md | 23 + ...ger-rationed-by-the-pair-s-exposure-cap.md | 31 + ...leanly-instead-of-erroring-shared-cache.md | 7 + ...izing-reserves-the-paymaster-s-gas-pull.md | 7 + ...ds-rate-limited-endpoints-for-5-minutes.md | 7 + ...xes-stake-recovery-postop-gas-band-1071.md | 11 + ...olkadot-startup-noise-the-library-still.md | 11 + ...d-be-sized-past-what-the-wallet-can-pay.md | 9 + ...be-removed-from-the-ui-and-over-the-api.md | 27 + ...t-again-not-the-user-s-requested-amount.md | 21 + ...up-with-the-probe-and-paymaster-changes.md | 28 + ...r-s-signature-layout-batched-delegation.md | 9 + ...ression-test-fills-pay-the-curve-amount.md | 19 + ...payout-unconditionally-and-maxordersize.md | 33 + ...the-filler-only-takes-single-leg-orders.md | 13 + .../2026-08-24-pr-1147-review-fixes-1071.md | 15 + ...fall-back-to-the-asset-registry-instead.md | 23 + ...use-chain-probe-classification-batching.md | 11 + ...y-an-on-chain-expiry-bidvalidityseconds.md | 26 + ...-hyperbridge-branded-simplex-onboarding.md | 11 + ...dated-against-the-pallet-s-shape-before.md | 38 + ...name-their-call-and-execute-retries-the.md | 11 + ...-guard-abandoned-request-cleanup-logger.md | 16 + ...x-ui-cleanup-and-operator-market-module.md | 18 + ...r-selection-gated-on-entrypoint-deposit.md | 17 + ...-simplex-first-selection-pr-1196-review.md | 4 + ...plex-paymaster-over-circle-in-selection.md | 4 + ...egation-ops-may-use-simplex-permit-mode.md | 4 + ...09-03-clarify-setup-delegation-failures.md | 9 + .../2026-09-03-clarify-testnet-terminology.md | 8 + .../2026-09-03-correct-vault-save-feedback.md | 8 + ...e-accounts-in-polkadot-s-unified-format.md | 8 + ...ance-controls-and-seed-curated-defaults.md | 9 + ...e-shared-operator-sheet-complete-motion.md | 8 + ...3-improve-dashboard-token-balance-cards.md | 9 + ...ine-ui-and-complete-dashboard-liquidity.md | 17 + ...ake-new-web-markets-uncapped-by-default.md | 7 + ...preserve-vault-edits-made-during-a-save.md | 8 + ...-the-uniswap-pricing-view-from-crashing.md | 8 + ...ebrand-the-simplex-ui-header-as-hyperfx.md | 10 + ...09-03-recompose-operator-market-drawers.md | 11 + ...te-bsc-gas-warnings-from-the-simplex-ui.md | 9 + ...sc-paymaster-warning-from-network-setup.md | 7 + ...-03-seed-usdc-and-usdt-markets-for-cngn.md | 8 + ...igured-market-prices-in-setup-summaries.md | 8 + ...rent-market-prices-in-the-operator-list.md | 8 + ...-selected-asset-liquidity-in-send-funds.md | 9 + ...faults-and-remove-transfer-market-setup.md | 10 + ...-03-simplify-the-filler-wallet-guidance.md | 7 + ...-phantom-orders-whose-window-has-closed.md | 27 + ...uccessful-sends-to-their-block-explorer.md | 9 + ...eep-did-nothing-and-restore-the-restart.md | 35 + ...-09-05-mainnet-solveraccount-from-sdk-2.md | 7 + ...le-orders-from-the-on-chain-orderfilled.md | 19 + ...s-on-old-activity-rows-from-the-indexer.md | 19 + ...-legacy-ledger-rows-from-their-receipts.md | 12 + ...tracted-columns-link-to-the-hyperbridge.md | 14 + ...ow-links-referrer-without-a-copy-button.md | 8 + ...bypass-in-the-ui-server-s-loopback-host.md | 15 + ...olume-profit-line-under-a-filled-status.md | 5 + ...itor-per-chain-fold-chains-that-are-not.md | 14 + ...s-and-vault-treasury-to-the-wallet-page.md | 11 + ...kens-chains-user-referrer-and-links-per.md | 19 + ...6-09-05-order-rows-link-only-to-hyperfx.md | 6 + ...ther-solver-filled-read-outbid-not-lost.md | 6 + ...ory-and-fold-each-order-s-bids-into-its.md | 17 + ...phantom-bids-so-a-restart-retracts-them.md | 14 + ...9-05-rename-the-activity-page-to-orders.md | 6 + ...only-for-retractions-that-went-on-chain.md | 7 + ...he-vault-minimum-wallet-balance-tooltip.md | 7 + ...time-controls-live-on-the-overview-page.md | 7 + ...for-every-chain-in-the-treasury-editors.md | 14 + ...r-pages-have-urls-that-survive-a-reload.md | 8 + ...und-to-a-precision-that-fits-their-size.md | 7 + ...perbridge-favicon-in-the-simplex-web-ui.md | 6 + ...con-amount-in-and-amount-out-with-token.md | 17 + ...rders-are-recorded-as-skipped-at-intake.md | 8 + ...widen-the-dashboard-container-to-150rem.md | 7 + ...opy-a-hyperfx-solver-link-from-a-market.md | 14 + ...p-reason-names-each-fee-token-s-balance.md | 13 + ...ymaster-governance-approve-mode-removed.md | 34 + ...-15-0-not-0-14-0-main-took-that-version.md | 9 + ...rom-a-device-through-the-simplex-tunnel.md | 28 + ...ers-and-pagination-on-the-wallet-ledger.md | 10 + ...permit2-approve-into-a-direct-tx-before.md | 11 + ...relay-rows-from-the-remote-access-panel.md | 9 + ...x-the-double-rule-under-the-device-list.md | 7 + ...he-double-rule-under-the-operator-lists.md | 9 + ...s-switch-move-on-click-not-on-the-round.md | 13 + ...-declare-the-chains-the-filler-fills-on.md | 4 + ...-the-hosted-relay-s-host-key-by-default.md | 12 + ...emote-access-copy-says-device-not-phone.md | 5 + ...paste-the-phone-s-public-key-by-default.md | 11 + ...-panel-redesign-merged-with-main-0-13-2.md | 13 + ...n-a-dialog-instead-of-a-browser-confirm.md | 13 + ...on-the-token-and-its-own-bottom-padding.md | 9 + ...ion-fields-in-the-relay-connection-card.md | 13 + ...-09-07-the-dashboard-fills-the-viewport.md | 12 + ...vice-can-no-longer-manage-remote-access.md | 24 + ...ootstrap-has-to-reach-already-delegated.md | 43 + ...-audit-fixes-on-the-embedded-ssh-server.md | 46 + ...coped-to-the-first-time-delegation-on-a.md | 39 + ...s-authorized-through-permit2-the-circle.md | 42 + ...ation-ceiling-drops-from-32-blocks-to-5.md | 8 + ...view-fixes-on-the-remote-access-pr-1217.md | 29 + ...wallet-at-the-vault-s-floor-not-at-zero.md | 26 + ...w-ups-on-the-node-sqlite-migration-1236.md | 51 + ...framing-of-the-operator-ui-clickjacking.md | 19 + ...ypass-strict-signature-verification-key.md | 21 + ...sh-tunnel-s-publickey-guard-fail-closed.md | 21 + ...mat-json-for-running-the-solver-under-a.md | 41 + ...proved-fee-tokens-before-bootstrap-1223.md | 24 + ...ce-better-sqlite3-with-node-sqlite-1236.md | 58 + ...es-for-the-unix-socket-listen-mode-1245.md | 57 + ...can-listen-on-a-unix-domain-socket-1237.md | 49 + ...0-docs-ai-split-into-one-file-per-entry.md | 20 + ...izard-announcement-for-json-output-1237.md | 20 + ...-stdout-error-listener-or-a-broken-pipe.md | 41 + ...mplex-ui-with-no-value-stopped-erroring.md | 30 + ...signer-and-no-signer-argument-is-a-hard.md | 7 + ...pprove-waits-for-two-confirmations-1071.md | 3 + ...ayloads-must-list-eip712domain-in-types.md | 5 + ...required-and-digest-only-backends-get-a.md | 9 + ...eapprovemode-renamed-to-skippermit-1071.md | 3 + ...permit2-mode-is-not-erc-7562-clean-1071.md | 3 + ...n-a-permit2-probe-of-the-paymaster-1071.md | 3 + ...-form-string-not-a-union-of-the-shipped.md | 7 + ...-paymaster-allowance-bootstrap-approves.md | 3 + ...om-permit2-nonces-bounded-deadline-1071.md | 3 + ...types-accountfor-bridges-to-viem-inside.md | 9 + ...ainid-argument-dropped-in-both-packages.md | 9 + ...on-a-separate-fillerconfigfile-type-not.md | 9 + ...ement-moved-from-validateconfig-to-boot.md | 7 + ...s-signauthorization-and-rejects-only-an.md | 7 + ...ns-carry-the-wallet-reserve-rather-than.md | 13 + ...-failing-the-prefund-and-oracle-derived.md | 5 + ...000-contract-ceiling-stays-100-000-1071.md | 11 + ...s-are-gross-of-the-gateway-protocol-fee.md | 13 + ...dpoint-is-dropped-from-the-bar-not-just.md | 9 + ...h-state-retry-after-and-a-warn-on-bench.md | 5 + ...-recovery-path-and-addstake-is-treasury.md | 7 + ...ersal-above-rate-limit-suspension-never.md | 9 + ...classifier-stricter-than-the-diagnostic.md | 7 + ...ate-after-the-estimate-not-a-reserve-in.md | 11 + ...exposure-cap-governs-fills-never-probes.md | 22 + ...rs-its-quoted-output-and-that-must-stay.md | 22 + ...dits-the-liquidity-the-calldata-encodes.md | 13 + .../2026-08-20-maxordersize-is-optional.md | 29 + ...jected-at-the-monitor-not-deeper-in-the.md | 19 + ...-test-filler-not-a-new-unit-test-script.md | 18 + ...-as-v-r-s-delegation-batched-approve-is.md | 5 + ...s-own-endpoint-not-a-null-on-the-update.md | 30 + ...-the-fill-and-the-exposure-cap-does-not.md | 49 + ...tstrap-chain-keyed-probe-no-dead-config.md | 9 + ...ied-by-cause-chain-only-zero-allowances.md | 5 + ...fallback-for-decimals-not-the-source-of.md | 35 + ...igured-in-seconds-and-written-in-blocks.md | 47 + ...d-on-the-pallet-s-structural-invariants.md | 34 + ...hared-hyperbridge-brand-system-with-its.md | 17 + ...elper-because-fillsamechain-sits-on-the.md | 17 + ...8-31-createsigningrequest-gets-no-retry.md | 13 + ...ries-the-create-execute-race-instead-of.md | 16 + ...150-headroom-fail-open-reads-checked-at.md | 24 + ...-preference-order-simplex-before-circle.md | 5 + ...demote-to-a-skip-reason-not-a-selection.md | 5 + ...egation-ops-may-use-simplex-permit-mode.md | 7 + ...dropped-not-bid-on-and-the-drop-is-loud.md | 32 + ...lapping-vault-saves-to-the-latest-draft.md | 12 + ...ts-follow-the-vault-product-with-a-safe.md | 10 + ...-distinguishes-ownership-from-immediate.md | 18 + ...e-the-testnet-lane-as-evm-test-networks.md | 8 + ...-operator-sheet-motion-from-radix-state.md | 12 + ...e-accounts-once-at-the-keyring-boundary.md | 10 + ...s-desktop-first-generic-and-permanently.md | 11 + ...aster-guidance-out-of-network-selection.md | 9 + ...atal-but-make-setup-recovery-actionable.md | 10 + ...er-market-removal-at-the-setup-boundary.md | 10 + ...ummarize-prices-instead-of-order-limits.md | 10 + ...new-web-markets-default-to-no-order-cap.md | 8 + ...drawers-use-an-open-editorial-hierarchy.md | 15 + ...messaging-from-every-simplex-ui-surface.md | 8 + ...seed-a-second-stablecoin-quote-for-cngn.md | 9 + ...s-reuses-the-canonical-balance-snapshot.md | 11 + ...d-prices-without-inventing-venue-prices.md | 9 + ...phasize-usable-liquidity-without-hiding.md | 10 + ...at-a-persisted-vault-save-as-ui-success.md | 9 + ...ble-curve-point-and-market-cap-defaults.md | 8 + ...rdmark-with-a-light-surface-in-the-dark.md | 10 + ...ts-outcome-instead-of-resolving-to-void.md | 9 + ...er-links-to-completed-transaction-state.md | 8 + ...xdeposit-is-read-on-every-vault-refresh.md | 8 + ...-on-a-vault-save-is-shown-not-swallowed.md | 12 + ...-closed-deposit-window-debug-thereafter.md | 10 + ...perbridge-mark-not-the-hyperfx-wordmark.md | 10 + ...in-the-runtime-state-record-not-the-bid.md | 13 + ...on-parses-the-address-instead-of-prefix.md | 20 + ...ive-on-the-wallet-page-configuration-on.md | 10 + ...ackfilled-from-the-indexer-with-a-local.md | 16 + ...pages-server-side-by-order-not-by-event.md | 11 + ...ured-at-detection-and-denormalised-onto.md | 12 + ...om-observed-on-chain-fills-the-filler-s.md | 12 + ...-vaults-per-chain-and-folds-non-enabled.md | 11 + ...s-curated-vaults-for-every-chain-locked.md | 13 + ...ns-the-balances-it-read-instead-of-null.md | 19 + ...-sources-are-derived-from-the-chain-set.md | 22 + ...mode-removed-on-chain-and-in-the-client.md | 19 + ...e-goes-first-when-a-permit2-approval-is.md | 12 + ...ce-keys-live-in-a-plain-authorized-keys.md | 6 + ...off-by-default-enabled-from-the-ui-only.md | 6 + ...e-s-public-key-by-default-generation-is.md | 8 + ...open-while-unset-never-settable-to-zero.md | 22 + ...gured-pin-else-the-built-in-pin-for-the.md | 8 + ...-the-phone-s-ssh-session-inside-simplex.md | 8 + ...t-kind-plus-migrate-not-an-execute-kind.md | 7 + ...07-ssh2-is-imported-as-a-default-export.md | 7 + ...r-is-governance-relayer-and-the-release.md | 17 + ...ootstrap-authorization-and-nothing-else.md | 35 + ...5519-pair-is-parsed-before-it-is-stored.md | 11 + ...gin-every-other-non-publickey-method-is.md | 8 + ...nsfer-is-the-only-authorization-simplex.md | 28 + ...-exchange-list-is-ours-not-the-client-s.md | 10 + ...tstrap-keys-on-the-allowance-not-on-the.md | 26 + ...ine-lives-on-the-stream-not-on-the-ssh2.md | 15 + ...agged-in-process-and-only-remote-access.md | 18 + ...e-checked-with-lstat-before-anything-is.md | 20 + ...ted-by-connecting-and-an-over-long-path.md | 28 + ...ening-guard-the-coupling-is-pinned-by-a.md | 18 + ...ction-is-explicit-begin-commit-rollback.md | 36 + ...-removenodeprotocol-false-and-the-build.md | 31 + ...-tagged-on-the-socket-not-inferred-from.md | 27 + ...floor-is-22-16-0-not-22-13-0-or-24-1236.md | 25 + ...ness-takes-priority-over-bootstrap-1223.md | 25 + ...are-set-once-in-handle-not-per-response.md | 19 + ...ed-after-the-bind-succeeds-never-before.md | 18 + ...-raw-argv-and-json-mode-writes-straight.md | 56 + ...g-format-are-flags-only-with-no-env-var.md | 15 + ...are-restated-explicitly-and-its-feature.md | 43 + ...erify-declared-algorithm-must-match-the.md | 21 + ...k-is-skipped-for-socket-connections-not.md | 24 + ...ase-fixtures-are-committed-binaries-not.md | 26 + ...-branch-requires-both-halves-absent-not.md | 22 + ...600-by-umask-not-chmod-ed-to-0600-after.md | 32 + ...ot-equivalent-to-a-0600-socket-verified.md | 29 + ...ls-its-own-error-listener-on-stdout-and.md | 31 + ...n-src-cli-run-options-ts-not-in-the-bin.md | 16 + ...he-ui-placeholder-is-host-port-not-addr.md | 11 + .../docs/ai/flows/bsc-paymaster-messaging.md | 6 + ...banner-the-log-sinks-and-the-wizard-url.md | 48 + .../ai/flows/dashboard-balance-collection.md | 30 + ...ktop-pwa-installation-and-offline-shell.md | 12 + ...t-is-sized-and-who-else-spends-the-same.md | 33 + .../flows/market-setup-and-curve-editing.md | 18 + .../opening-an-operator-data-directory.md | 41 + .../docs/ai/flows/operator-market-drawer.md | 17 + .../ai/flows/operator-market-list-prices.md | 6 + .../order-history-on-the-activity-page.md | 38 + ...r-intake-what-reaches-the-filler-at-all.md | 21 + .../order-outcome-bids-fills-and-losses.md | 21 + .../simplex/docs/ai/flows/page-routes.md | 8 + ...master-selection-for-a-sponsored-userop.md | 20 + .../phantom-bid-deposits-across-restarts.md | 10 + ...antom-probe-curve-value-published-price.md | 50 + ...d-true-to-a-phone-loading-the-dashboard.md | 40 + .../flows/runtime-controls-on-the-overview.md | 7 + .../flows/send-transaction-explorer-link.md | 7 + .../ai/flows/setup-completion-failures.md | 8 + ...etup-wizard-presentation-and-navigation.md | 18 + ...ing-from-construction-to-each-signature.md | 83 + .../simplex-ui-branding-and-static-assets.md | 9 + .../simplex/docs/ai/flows/solver-links.md | 7 + ...listen-mode-and-how-a-request-reaches-a.md | 41 + .../ai/flows/vault-save-from-the-dashboard.md | 9 + .../vault-selection-and-balance-defaults.md | 41 + .../simplex/docs/ai/flows/vault-sweep-pass.md | 27 + .../venue-pricing-uniswap-v4-funded-pairs.md | 22 + .../simplex/docs/ai/flows/wallet-ledger.md | 14 + .../watch-only-orders-in-the-activity-feed.md | 7 + 417 files changed, 6834 insertions(+), 7146 deletions(-) delete mode 100644 .gitattributes delete mode 100644 sdk/packages/core/docs/ai/ChangeLog.md delete mode 100644 sdk/packages/core/docs/ai/Decisions.md create mode 100644 sdk/packages/core/docs/ai/README.md create mode 100644 sdk/packages/core/docs/ai/changelog/2026-08-27-iintentgatewayv2-brought-back-in-sync-with-the-gateway-1160.md create mode 100644 sdk/packages/core/docs/ai/changelog/2026-09-03-governance-deliveries-to-hostmanager-gated-on-the-same-relayer.md create mode 100644 sdk/packages/core/docs/ai/changelog/2026-09-03-host-manager-rotation-addressed-to-the-manager-the-host-still.md create mode 100644 sdk/packages/core/docs/ai/changelog/2026-09-03-relayer-allowlist-on-hyperfungibletoken-fail-closed-on-the.md create mode 100644 sdk/packages/core/docs/ai/changelog/2026-09-03-relayer-allowlist-on-the-intent-gateway.md create mode 100644 sdk/packages/core/docs/ai/changelog/2026-09-04-fresh-gateway-deployments-arm-the-relayer-in-the-deploy-script.md create mode 100644 sdk/packages/core/docs/ai/changelog/2026-09-05-gateway-initialize-refused-on-any-proxy-already-at-a-version.md create mode 100644 sdk/packages/core/docs/ai/changelog/2026-09-05-gateway-initialize-takes-the-relayer-and-lands-at-version-2.md create mode 100644 sdk/packages/core/docs/ai/changelog/2026-09-05-hostmanager-admin-is-the-governance-relayer-gateway-setrelayer.md create mode 100644 sdk/packages/core/docs/ai/changelog/2026-09-05-hostmanager-onaccept-checks-caller-and-relayer-through-one.md create mode 100644 sdk/packages/core/docs/ai/changelog/2026-09-05-relayer-gate-lifted-out-of-hyperfungibletoken-into-bridgetoken.md create mode 100644 sdk/packages/core/docs/ai/changelog/2026-09-05-upgradecontract-becomes-execute-one-governance-door-to-the.md create mode 100644 sdk/packages/core/docs/ai/changelog/2026-09-07-live-fork-gateway-test-asserts-the-armed-mainnet-proxy-instead.md create mode 100644 sdk/packages/core/docs/ai/changelog/2026-09-07-simplexpaymaster-governance-deliveries-gated-on-one-relayer.md create mode 100644 sdk/packages/core/docs/ai/changelog/2026-09-10-docs-ai-split-into-one-file-per-entry.md create mode 100644 sdk/packages/core/docs/ai/decisions/2026-08-27-declarations-here-are-kept-identical-to-intentsbase-not-merely.md create mode 100644 sdk/packages/core/docs/ai/decisions/2026-08-27-newdeploymentadded-was-renamed-rather-than-kept-for.md create mode 100644 sdk/packages/core/docs/ai/decisions/2026-09-03-hostmanager-deliveries-are-gated-too-and-that-does-not-touch.md create mode 100644 sdk/packages/core/docs/ai/decisions/2026-09-03-hyperfungibletoken-opts-in-to-the-relayer-gate-bridgetoken.md create mode 100644 sdk/packages/core/docs/ai/decisions/2026-09-03-ihyperfungibletoken-does-not-declare-setrelayer-or-relayer.md create mode 100644 sdk/packages/core/docs/ai/decisions/2026-09-03-onpostrequesttimeout-is-gated-on-the-token-unlike-the-gateway.md create mode 100644 sdk/packages/core/docs/ai/decisions/2026-09-03-setrelayer-accepts-the-host-not-address-this.md create mode 100644 sdk/packages/core/docs/ai/decisions/2026-09-03-the-paused-getter-was-dropped-to-stay-under-eip-170.md create mode 100644 sdk/packages/core/docs/ai/decisions/2026-09-03-the-relayer-is-a-separate-storage-variable-not-a-params-field.md create mode 100644 sdk/packages/core/docs/ai/decisions/2026-09-03-zero-relayer-fails-closed-and-the-upgrade-arms-it-atomically.md create mode 100644 sdk/packages/core/docs/ai/decisions/2026-09-05-execute-delegatecalls-the-implementation-an-upgrade-is-one-such.md create mode 100644 sdk/packages/core/docs/ai/decisions/2026-09-05-gateway-setrelayer-is-host-only-and-the-only-writer-unset-means.md create mode 100644 sdk/packages/core/docs/ai/decisions/2026-09-05-init-is-one-shot-and-the-evm-deploy-scripts-do-not-call-it.md create mode 100644 sdk/packages/core/docs/ai/decisions/2026-09-05-initialize-only-on-a-bare-proxy-rather-than-a-version-check-in.md create mode 100644 sdk/packages/core/docs/ai/decisions/2026-09-05-relayer-on-both-contracts-version-on-the-gateway-from.md create mode 100644 sdk/packages/core/docs/ai/decisions/2026-09-05-the-hostmanager-admin-is-the-relayer-rotated-only-by-governance.md create mode 100644 sdk/packages/core/docs/ai/decisions/2026-09-05-the-relayer-gate-belongs-to-the-token-that-needs-it-not-to.md create mode 100644 sdk/packages/core/docs/ai/decisions/2026-09-05-version-is-the-implementation-s-version-initialize-arms-migrate.md rename sdk/packages/core/docs/ai/{Flow.md => flows/how-a-cross-chain-delivery-reaches-the-gateway-and-where-the.md} (61%) create mode 100644 sdk/packages/core/docs/ai/flows/the-bridge-token-s-relayer-gate-and-why-the-base-token-has-none.md create mode 100644 sdk/packages/core/docs/ai/flows/the-simplexpaymaster-s-relayer-gate.md create mode 100644 sdk/packages/core/docs/ai/flows/what-iintentgatewayv2-is-and-what-actually-reads-it.md delete mode 100644 sdk/packages/indexer/docs/ai/ChangeLog.md delete mode 100644 sdk/packages/indexer/docs/ai/Decisions.md delete mode 100644 sdk/packages/indexer/docs/ai/Flow.md create mode 100644 sdk/packages/indexer/docs/ai/README.md create mode 100644 sdk/packages/indexer/docs/ai/changelog/2026-08-13-gateway-level-daily-filled-volume-1085.md create mode 100644 sdk/packages/indexer/docs/ai/changelog/2026-08-14-seed-cumulative-from-daily-rows-isolate-seed-failures-review.md create mode 100644 sdk/packages/indexer/docs/ai/changelog/2026-08-14-seed-gateway-volume-from-existing-filler-history-1085.md create mode 100644 sdk/packages/indexer/docs/ai/changelog/2026-08-19-pool-rates-renormalize-by-the-leg-s-own-standard-amount.md create mode 100644 sdk/packages/indexer/docs/ai/changelog/2026-08-26-substrate-chains-run-on-the-polytope-build-of-the-subquery-node.md create mode 100644 sdk/packages/indexer/docs/ai/changelog/2026-08-28-decode-phantom-bids-of-either-fillorder-shape.md create mode 100644 sdk/packages/indexer/docs/ai/changelog/2026-09-01-refresh-a-pool-s-lp-balances-on-every-order-fill.md create mode 100644 sdk/packages/indexer/docs/ai/changelog/2026-09-01-refresh-liquidity-on-escrow-releases-and-vault-events-and-value.md create mode 100644 sdk/packages/indexer/docs/ai/changelog/2026-09-02-reconcile-declarations-against-the-verified-solver-set-review.md create mode 100644 sdk/packages/indexer/docs/ai/changelog/2026-09-04-in-place-substrate-schema-migrations-1163-follow-up.md create mode 100644 sdk/packages/indexer/docs/ai/changelog/2026-09-05-drop-the-overloaded-quote-from-the-intentgatewayv3-abi-so-subql.md create mode 100644 sdk/packages/indexer/docs/ai/changelog/2026-09-05-recognise-both-the-current-and-the-previous-solveraccount-per.md create mode 100644 sdk/packages/indexer/docs/ai/changelog/2026-09-08-drop-the-handlepriceindexing-block-handler-rename-its-template.md create mode 100644 sdk/packages/indexer/docs/ai/changelog/2026-09-08-one-writer-per-pool-row-evm-nodes-publish-inventory-readings.md create mode 100644 sdk/packages/indexer/docs/ai/changelog/2026-09-09-phantom-bid-aggregation-reads-permit2-sponsored-bids-as-well-as.md create mode 100644 sdk/packages/indexer/docs/ai/changelog/2026-09-09-root-cause-of-phantom-bids-dropped-as-failed-to-process-bid-for.md create mode 100644 sdk/packages/indexer/docs/ai/changelog/2026-09-10-docs-ai-split-into-one-file-per-entry.md create mode 100644 sdk/packages/indexer/docs/ai/decisions/2026-08-13-gateway-daily-volume-reuses-dailyvolumeusd-instead-of-a-new.md create mode 100644 sdk/packages/indexer/docs/ai/decisions/2026-08-13-gateway-volume-recorded-at-the-same-call-site-as-filler-volume.md create mode 100644 sdk/packages/indexer/docs/ai/decisions/2026-08-14-cumulative-seed-derives-from-daily-rows-and-a-failed-seed-skips.md create mode 100644 sdk/packages/indexer/docs/ai/decisions/2026-08-14-gateway-volume-seeding-uses-the-aggregate-cumulative-record-as.md create mode 100644 sdk/packages/indexer/docs/ai/decisions/2026-08-19-the-standard-amount-check-bounds-plausibility-instead-of.md create mode 100644 sdk/packages/indexer/docs/ai/decisions/2026-08-26-substrate-node-resilience-is-fixed-in-a-forked-node-image-not.md create mode 100644 sdk/packages/indexer/docs/ai/decisions/2026-08-28-the-vm2-decoder-tries-both-fillorder-shapes-and-the-new-test.md create mode 100644 sdk/packages/indexer/docs/ai/decisions/2026-09-01-declared-v4-positions-live-in-one-row-per-solver-1159.md create mode 100644 sdk/packages/indexer/docs/ai/decisions/2026-09-01-escrow-releases-and-vault-events-refresh-by-provider-and-depths.md create mode 100644 sdk/packages/indexer/docs/ai/decisions/2026-09-01-per-event-reads-are-pinned-to-the-event-s-block-1159.md create mode 100644 sdk/packages/indexer/docs/ai/decisions/2026-09-01-the-balance-series-keeps-its-shape-1159-3-is-left-as-a-comment.md create mode 100644 sdk/packages/indexer/docs/ai/decisions/2026-09-01-the-fill-refresh-re-reads-balances-publishes-no-provenance-of.md create mode 100644 sdk/packages/indexer/docs/ai/decisions/2026-09-04-in-place-schema-migration-on-restart-not-versioned-manifest.md create mode 100644 sdk/packages/indexer/docs/ai/decisions/2026-09-08-one-writer-per-pool-row-evm-nodes-publish-readings-the.md create mode 100644 sdk/packages/indexer/docs/ai/decisions/2026-09-09-sponsored-bid-decoding-stays-in-the-sdk-the-indexer-tests-only.md create mode 100644 sdk/packages/indexer/docs/ai/decisions/2026-09-09-the-sandbox-regression-test-runs-the-real-bundle-inside-vm2-not.md create mode 100644 sdk/packages/indexer/docs/ai/flows/intent-gateway-volume-indexing-orderfilled.md create mode 100644 sdk/packages/indexer/docs/ai/flows/phantom-bid-calldata-decoding-extractfilldatavm2.md create mode 100644 sdk/packages/indexer/docs/ai/flows/phantom-price-snapshot-to-pool-rates-phantombidwindowexhausted.md create mode 100644 sdk/packages/indexer/docs/ai/flows/pool-liquidity-refresh-orderfilled-partialfill-escrowreleased.md create mode 100644 sdk/packages/indexer/docs/ai/flows/substrate-schema-migrations-verified-2026-09-04-against-the.md delete mode 100644 sdk/packages/sdk/docs/ai/ChangeLog.md delete mode 100644 sdk/packages/sdk/docs/ai/Decisions.md delete mode 100644 sdk/packages/sdk/docs/ai/Flow.md create mode 100644 sdk/packages/sdk/docs/ai/README.md create mode 100644 sdk/packages/sdk/docs/ai/changelog/2026-08-18-signingaccount-shrinks-to-signtypeddata-alone.md create mode 100644 sdk/packages/sdk/docs/ai/changelog/2026-08-19-fix-the-refund-post-gas-pin-1144-left-behind.md create mode 100644 sdk/packages/sdk/docs/ai/changelog/2026-08-21-http-provider-no-longer-caches-responses-so-a-failed-phantom.md create mode 100644 sdk/packages/sdk/docs/ai/changelog/2026-08-24-pool-priced-phantom-bids-are-haircut-30bps-before-aggregation.md create mode 100644 sdk/packages/sdk/docs/ai/changelog/2026-08-25-intent-quotes-use-aggregate-indexed-pool-rates-by-default.md create mode 100644 sdk/packages/sdk/docs/ai/changelog/2026-08-27-a-block-scan-is-one-state-querystorage-call-and-the-poll-caps.md create mode 100644 sdk/packages/sdk/docs/ai/changelog/2026-08-27-concurrent-hyperbridge-calls-travel-as-one-json-rpc-batch.md create mode 100644 sdk/packages/sdk/docs/ai/changelog/2026-08-27-filloptions-carries-a-validuntil-and-fillorder-has-two-shapes.md create mode 100644 sdk/packages/sdk/docs/ai/changelog/2026-08-27-fix-the-ranged-block-scan-names-the-events-key-and-its-type.md create mode 100644 sdk/packages/sdk/docs/ai/changelog/2026-08-27-hyperbridge-http-reads-are-paced-and-a-block-scan-costs-half.md create mode 100644 sdk/packages/sdk/docs/ai/changelog/2026-08-27-pool-priced-phantom-bids-are-haircut-10bps-every-other-bid-5bps.md create mode 100644 sdk/packages/sdk/docs/ai/changelog/2026-08-28-export-fill-order-v1-abi-for-consumers-that-cannot-use.md create mode 100644 sdk/packages/sdk/docs/ai/changelog/2026-09-01-block-tagged-balance-reads-and-declared-v4-positions-reported.md create mode 100644 sdk/packages/sdk/docs/ai/changelog/2026-09-03-pollphantomorders-can-skip-a-backlog-it-will-never-use.md create mode 100644 sdk/packages/sdk/docs/ai/changelog/2026-09-05-mainnet-solveraccount-moves-to-the-2026-09-05-deployment-2-8-11.md create mode 100644 sdk/packages/sdk/docs/ai/changelog/2026-09-07-fillerconfig-acceptedsourcechains-removed.md create mode 100644 sdk/packages/sdk/docs/ai/changelog/2026-09-07-wallet-funded-phantom-bids-are-no-longer-haircut-only-pool.md create mode 100644 sdk/packages/sdk/docs/ai/changelog/2026-09-07-wallet-funded-phantom-bids-pay-the-protocol-fee-haircut-read.md create mode 100644 sdk/packages/sdk/docs/ai/changelog/2026-09-09-declaration-chain-ids-are-utf-8-coded-by-hand-so-the-decoder.md create mode 100644 sdk/packages/sdk/docs/ai/changelog/2026-09-09-phantom-bid-paymasteranddata-decodes-in-both-shapes-bare.md create mode 100644 sdk/packages/sdk/docs/ai/changelog/2026-09-10-docs-ai-split-into-one-file-per-entry.md create mode 100644 sdk/packages/sdk/docs/ai/decisions/2026-08-18-signingaccount-describes-only-what-the-sdk-calls.md create mode 100644 sdk/packages/sdk/docs/ai/decisions/2026-08-21-the-coprocessor-s-http-provider-runs-with-its-response-cache-off.md create mode 100644 sdk/packages/sdk/docs/ai/decisions/2026-08-24-the-30bps-pool-haircut-keys-off-the-declaration-and-lands-on.md create mode 100644 sdk/packages/sdk/docs/ai/decisions/2026-08-25-intent-quotes-default-to-directional-indexed-rates-without.md create mode 100644 sdk/packages/sdk/docs/ai/decisions/2026-08-27-batching-is-transparent-at-the-provider-and-only-the-block-hash.md create mode 100644 sdk/packages/sdk/docs/ai/decisions/2026-08-27-every-phantom-quote-is-haircut-the-pool-tier-drops-to-10bps-and.md create mode 100644 sdk/packages/sdk/docs/ai/decisions/2026-08-27-request-pacing-lives-at-the-provider-keyed-by-endpoint.md create mode 100644 sdk/packages/sdk/docs/ai/decisions/2026-08-27-the-bid-expiry-rides-in-filloptions-not-in-the-bid-signature.md create mode 100644 sdk/packages/sdk/docs/ai/decisions/2026-08-27-the-block-scan-names-a-runtime-version-and-drops-it-the-moment.md create mode 100644 sdk/packages/sdk/docs/ai/decisions/2026-08-27-the-events-key-is-computed-and-the-ranged-reply-is-decoded.md create mode 100644 sdk/packages/sdk/docs/ai/decisions/2026-08-27-the-fast-path-is-never-load-bearing-a-ranged-reply-that-will.md create mode 100644 sdk/packages/sdk/docs/ai/decisions/2026-08-27-the-implementation-address-identifies-the-filloptions-shape.md create mode 100644 sdk/packages/sdk/docs/ai/decisions/2026-08-27-the-scan-reads-a-range-with-state-querystorage-and-declines-it.md create mode 100644 sdk/packages/sdk/docs/ai/decisions/2026-08-28-the-legacy-fillorder-abi-is-exported-not-re-declared-downstream.md create mode 100644 sdk/packages/sdk/docs/ai/decisions/2026-09-01-blocktag-is-a-parameter-of-the-balance-read-and-a-per-chain-map.md create mode 100644 sdk/packages/sdk/docs/ai/decisions/2026-09-01-verified-v4-positions-are-reported-out-of-the-aggregation-1159.md create mode 100644 sdk/packages/sdk/docs/ai/decisions/2026-09-03-the-phantom-order-lag-limit-is-read-from-the-chain-and-always.md create mode 100644 sdk/packages/sdk/docs/ai/decisions/2026-09-07-the-wallet-funded-haircut-is-the-gateway-s-protocol-fee-read.md create mode 100644 sdk/packages/sdk/docs/ai/decisions/2026-09-09-a-phantom-bid-s-paymasteranddata-is-read-as-a-bare-declaration.md create mode 100644 sdk/packages/sdk/docs/ai/decisions/2026-09-09-no-textencoder-textdecoder-anywhere-on-the-phantom-bid-decode.md create mode 100644 sdk/packages/sdk/docs/ai/flows/how-a-phantom-order-s-bids-become-one-price-per-leg.md create mode 100644 sdk/packages/sdk/docs/ai/flows/how-a-solver-s-bid-gets-signed.md create mode 100644 sdk/packages/sdk/docs/ai/flows/how-requests-to-a-hyperbridge-node-are-paced.md create mode 100644 sdk/packages/sdk/docs/ai/flows/how-the-phantom-order-poll-reads-hyperbridge.md delete mode 100644 sdk/packages/simplex/docs/ai/ChangeLog.md delete mode 100644 sdk/packages/simplex/docs/ai/Decisions.md delete mode 100644 sdk/packages/simplex/docs/ai/Flow.md create mode 100644 sdk/packages/simplex/docs/ai/README.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-18-audit-fixes-yparity-guards-the-signerless-one-way-door-and.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-18-permit2-mode-for-the-simplex-paymaster-1071.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-18-signer-return-contracts-spelled-out.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-18-simplex-start-takes-a-signer-interface-instead-of-a-signer.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-19-a-phantom-probe-is-no-longer-rationed-by-the-pair-s-exposure-cap.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-19-cross-lane-orders-skip-cleanly-instead-of-erroring-shared-cache.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-19-fill-sizing-reserves-the-paymaster-s-gas-pull.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-19-quorum-client-suspends-rate-limited-endpoints-for-5-minutes.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-19-security-review-fixes-stake-recovery-postop-gas-band-1071.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-19-the-binary-silences-polkadot-startup-noise-the-library-still.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-19-two-more-ways-a-fill-could-be-sized-past-what-the-wallet-can-pay.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-20-a-per-order-cap-can-be-removed-from-the-ui-and-over-the-api.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-20-fills-pay-the-curve-amount-again-not-the-user-s-requested-amount.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-20-pairs-test-ts-catches-up-with-the-probe-and-paymaster-changes.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-20-pr-1147-review-v-r-s-signature-layout-batched-delegation.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-20-regression-test-fills-pay-the-curve-amount.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-20-the-curve-amount-is-the-payout-unconditionally-and-maxordersize.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-20-the-filler-only-takes-single-leg-orders.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-24-pr-1147-review-fixes-1071.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-26-decimals-read-failures-fall-back-to-the-asset-registry-instead.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-26-final-review-fixes-cause-chain-probe-classification-batching.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-27-bids-carry-an-on-chain-expiry-bidvalidityseconds.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-27-hyperbridge-branded-simplex-onboarding.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-27-phantom-orders-are-validated-against-the-pallet-s-shape-before.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-31-mpcvault-rpc-failures-name-their-call-and-execute-retries-the.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-31-review-fixes-zero-code-guard-abandoned-request-cleanup-logger.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-08-31-simplex-ui-cleanup-and-operator-market-module.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-01-paymaster-selection-gated-on-entrypoint-deposit.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-02-harden-simplex-first-selection-pr-1196-review.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-02-prefer-simplex-paymaster-over-circle-in-selection.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-02-remove-skippermit-delegation-ops-may-use-simplex-permit-mode.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-03-clarify-setup-delegation-failures.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-03-clarify-testnet-terminology.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-03-correct-vault-save-feedback.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-03-display-hyperbridge-accounts-in-polkadot-s-unified-format.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-03-explain-vault-balance-controls-and-seed-curated-defaults.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-03-give-the-shared-operator-sheet-complete-motion.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-03-improve-dashboard-token-balance-cards.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-03-installable-offline-ui-and-complete-dashboard-liquidity.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-03-make-new-web-markets-uncapped-by-default.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-03-preserve-vault-edits-made-during-a-save.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-03-prevent-the-uniswap-pricing-view-from-crashing.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-03-rebrand-the-simplex-ui-header-as-hyperfx.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-03-recompose-operator-market-drawers.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-03-remove-obsolete-bsc-gas-warnings-from-the-simplex-ui.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-03-remove-the-bsc-paymaster-warning-from-network-setup.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-03-seed-usdc-and-usdt-markets-for-cngn.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-03-show-configured-market-prices-in-setup-summaries.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-03-show-current-market-prices-in-the-operator-list.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-03-show-selected-asset-liquidity-in-send-funds.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-03-simplify-market-setup-defaults-and-remove-transfer-market-setup.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-03-simplify-the-filler-wallet-guidance.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-03-stop-bidding-on-phantom-orders-whose-window-has-closed.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-04-link-successful-sends-to-their-block-explorer.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-04-say-why-a-vault-sweep-did-nothing-and-restore-the-restart.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-0-13-1-pick-up-the-2026-09-05-mainnet-solveraccount-from-sdk-2.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-a-bid-is-not-a-fill-settle-orders-from-the-on-chain-orderfilled.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-backfill-order-details-on-old-activity-rows-from-the-indexer.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-backfill-vault-amounts-on-legacy-ledger-rows-from-their-receipts.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-bid-placed-and-retracted-columns-link-to-the-hyperbridge.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-bids-as-one-column-of-arrow-links-referrer-without-a-copy-button.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-close-the-dns-rebinding-bypass-in-the-ui-server-s-loopback-host.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-drop-the-volume-profit-line-under-a-filled-status.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-group-the-treasury-editor-per-chain-fold-chains-that-are-not.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-move-send-funds-and-vault-treasury-to-the-wallet-page.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-order-history-amounts-tokens-chains-user-referrer-and-links-per.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-order-rows-link-only-to-hyperfx.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-orders-another-solver-filled-read-outbid-not-lost.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-paginate-the-order-history-and-fold-each-order-s-bids-into-its.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-persist-live-phantom-bids-so-a-restart-retracts-them.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-rename-the-activity-page-to-orders.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-retracted-column-only-for-retractions-that-went-on-chain.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-reword-the-vault-minimum-wallet-balance-tooltip.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-runtime-controls-live-on-the-overview-page.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-show-curated-vaults-for-every-chain-in-the-treasury-editors.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-sidebar-pages-have-urls-that-survive-a-reload.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-token-amounts-round-to-a-precision-that-fits-their-size.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-use-the-hyperbridge-favicon-in-the-simplex-web-ui.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-wallet-ledger-action-icon-amount-in-and-amount-out-with-token.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-watch-only-orders-are-recorded-as-skipped-at-intake.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-05-widen-the-dashboard-container-to-150rem.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-06-copy-a-hyperfx-solver-link-from-a-market.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-06-simplex-skip-reason-names-each-fee-token-s-balance.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-07-0-14-0-relayer-gated-paymaster-governance-approve-mode-removed.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-07-0-15-0-not-0-14-0-main-took-that-version.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-07-0-15-0-remote-access-from-a-device-through-the-simplex-tunnel.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-07-action-filters-and-pagination-on-the-wallet-ledger.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-07-delegation-batches-the-permit2-approve-into-a-direct-tx-before.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-07-drop-the-relay-rows-from-the-remote-access-panel.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-07-fix-the-double-rule-under-the-device-list.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-07-fix-the-double-rule-under-the-operator-lists.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-07-make-the-remote-access-switch-move-on-click-not-on-the-round.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-07-phantom-bids-declare-the-chains-the-filler-fills-on.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-07-pin-the-hosted-relay-s-host-key-by-default.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-07-remote-access-copy-says-device-not-phone.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-07-remote-access-pairing-paste-the-phone-s-public-key-by-default.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-07-remote-access-panel-redesign-merged-with-main-0-13-2.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-07-review-a-transfer-in-a-dialog-instead-of-a-browser-confirm.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-07-send-review-chain-badge-on-the-token-and-its-own-bottom-padding.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-07-show-the-ssh-connection-fields-in-the-relay-connection-card.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-07-the-dashboard-fills-the-viewport.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-08-a-paired-device-can-no-longer-manage-remote-access.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-08-audit-fix-the-permit2-bootstrap-has-to-reach-already-delegated.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-08-audit-fixes-on-the-embedded-ssh-server.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-08-eip-2612-comes-back-scoped-to-the-first-time-delegation-on-a.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-08-every-sponsored-op-is-authorized-through-permit2-the-circle.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-08-polygon-s-confirmation-ceiling-drops-from-32-blocks-to-5.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-08-review-fixes-on-the-remote-access-pr-1217.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-08-sends-leave-the-wallet-at-the-vault-s-floor-not-at-zero.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-09-audit-follow-ups-on-the-node-sqlite-migration-1236.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-09-deny-framing-of-the-operator-ui-clickjacking.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-09-fix-ssh-tunnel-auth-bypass-strict-signature-verification-key.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-09-make-the-ssh-tunnel-s-publickey-guard-fail-closed.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-09-no-open-and-log-format-json-for-running-the-solver-under-a.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-09-prefer-funded-permit2-approved-fee-tokens-before-bootstrap-1223.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-09-replace-better-sqlite3-with-node-sqlite-1236.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-09-security-audit-fixes-for-the-unix-socket-listen-mode-1245.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-09-uiserver-can-listen-on-a-unix-domain-socket-1237.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-10-docs-ai-split-into-one-file-per-entry.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-10-guard-the-socket-mode-wizard-announcement-for-json-output-1237.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-10-json-mode-needs-its-own-stdout-error-listener-or-a-broken-pipe.md create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-10-simplex-ui-with-no-value-stopped-erroring.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-18-a-config-with-simplex-signer-and-no-signer-argument-is-a-hard.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-18-bootstrap-approve-waits-for-two-confirmations-1071.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-18-eip-712-payloads-must-list-eip712domain-in-types.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-18-every-operation-is-required-and-digest-only-backends-get-a.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-18-forceapprovemode-renamed-to-skippermit-1071.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-18-known-permit2-mode-is-not-erc-7562-clean-1071.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-18-mode-2-gated-on-a-permit2-probe-of-the-paymaster-1071.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-18-mode-is-a-required-free-form-string-not-a-union-of-the-shipped.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-18-permit2-before-a-legacy-paymaster-allowance-bootstrap-approves.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-18-random-permit2-nonces-bounded-deadline-1071.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-18-signer-carries-no-viem-types-accountfor-bridges-to-viem-inside.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-18-signmessage-and-the-chainid-argument-dropped-in-both-packages.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-18-the-signer-block-lives-on-a-separate-fillerconfigfile-type-not.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-18-the-signer-requirement-moved-from-validateconfig-to-boot.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-18-viemsigner-derives-signauthorization-and-rejects-only-an.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-19-both-paymaster-tokens-carry-the-wallet-reserve-rather-than.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-19-not-adopted-soft-failing-the-prefund-and-oracle-derived.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-19-postop-gas-sdk-sends-40-000-contract-ceiling-stays-100-000-1071.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-19-published-phantom-prices-are-gross-of-the-gateway-protocol-fee.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-19-reversed-a-benched-endpoint-is-dropped-from-the-bar-not-just.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-19-review-round-shared-bench-state-retry-after-and-a-warn-on-bench.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-19-stake-gets-a-governance-recovery-path-and-addstake-is-treasury.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-19-superseded-see-the-reversal-above-rate-limit-suspension-never.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-19-suspension-has-its-own-classifier-stricter-than-the-diagnostic.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-19-the-dispatch-fee-is-a-gate-after-the-estimate-not-a-reserve-in.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-19-the-exposure-cap-governs-fills-never-probes.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-19-the-filler-floors-its-quoted-output-and-that-must-stay.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-19-the-v4-planner-credits-the-liquidity-the-calldata-encodes.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-20-maxordersize-is-optional.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-20-multi-leg-orders-are-rejected-at-the-monitor-not-deeper-in-the.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-20-pairs-test-ts-rides-in-test-filler-not-a-new-unit-test-script.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-20-permit2-signature-stored-as-v-r-s-delegation-batched-approve-is.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-20-removing-a-cap-is-its-own-endpoint-not-a-null-on-the-update.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-20-the-curve-amount-is-the-fill-and-the-exposure-cap-does-not.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-24-review-fixes-robust-bootstrap-chain-keyed-probe-no-dead-config.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-26-probe-failures-classified-by-cause-chain-only-zero-allowances.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-26-the-asset-registry-is-a-fallback-for-decimals-not-the-source-of.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-27-bid-tenor-is-configured-in-seconds-and-written-in-blocks.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-27-phantom-orders-are-gated-on-the-pallet-s-structural-invariants.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-27-simplex-setup-uses-the-shared-hyperbridge-brand-system-with-its.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-27-the-surplus-split-is-a-helper-because-fillsamechain-sits-on-the.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-31-createsigningrequest-gets-no-retry.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-08-31-mpcvault-execute-retries-the-create-execute-race-instead-of.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-01-paymaster-deposit-gate-150-headroom-fail-open-reads-checked-at.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-02-paymaster-preference-order-simplex-before-circle.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-02-simplex-builder-errors-demote-to-a-skip-reason-not-a-selection.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-02-skippermit-removed-delegation-ops-may-use-simplex-permit-mode.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-03-a-stale-phantom-order-is-dropped-not-bid-on-and-the-drop-is-loud.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-03-coalesce-overlapping-vault-saves-to-the-latest-draft.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-03-curated-vault-defaults-follow-the-vault-product-with-a-safe.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-03-dashboard-liquidity-distinguishes-ownership-from-immediate.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-03-describe-the-testnet-lane-as-evm-test-networks.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-03-drive-operator-sheet-motion-from-radix-state.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-03-encode-substrate-accounts-once-at-the-keyring-boundary.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-03-installation-stays-desktop-first-generic-and-permanently.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-03-keep-bsc-paymaster-guidance-out-of-network-selection.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-03-keep-delegation-failure-fatal-but-make-setup-recovery-actionable.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-03-keep-transfer-market-removal-at-the-setup-boundary.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-03-make-setup-market-rows-summarize-prices-instead-of-order-limits.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-03-new-web-markets-default-to-no-order-cap.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-03-operator-market-drawers-use-an-open-editorial-hierarchy.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-03-remove-bsc-native-gas-messaging-from-every-simplex-ui-surface.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-03-seed-a-second-stablecoin-quote-for-cngn.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-03-send-funds-reuses-the-canonical-balance-snapshot.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-03-show-configured-prices-without-inventing-venue-prices.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-03-token-balance-cards-emphasize-usable-liquidity-without-hiding.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-03-treat-a-persisted-vault-save-as-ui-success.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-03-use-stable-curve-point-and-market-cap-defaults.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-03-use-the-hyperfx-wordmark-with-a-light-surface-in-the-dark.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-04-a-sweep-pass-reports-its-outcome-instead-of-resolving-to-void.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-04-bind-send-explorer-links-to-completed-transaction-state.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-04-maxdeposit-is-read-on-every-vault-refresh.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-04-restartneeded-on-a-vault-save-is-shown-not-swallowed.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-04-warn-once-per-closed-deposit-window-debug-thereafter.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-05-browser-favicon-is-the-hyperbridge-mark-not-the-hyperfx-wordmark.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-05-live-phantom-bids-live-in-the-runtime-state-record-not-the-bid.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-05-loopback-host-detection-parses-the-address-instead-of-prefix.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-05-money-moving-tools-live-on-the-wallet-page-configuration-on.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-05-old-activity-rows-are-backfilled-from-the-indexer-with-a-local.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-05-order-history-pages-server-side-by-order-not-by-event.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-05-order-summaries-are-captured-at-detection-and-denormalised-onto.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-05-orders-settle-from-observed-on-chain-fills-the-filler-s.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-05-treasury-editor-groups-vaults-per-chain-and-folds-non-enabled.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-05-treasury-editor-shows-curated-vaults-for-every-chain-locked.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-06-the-simplex-builder-returns-the-balances-it-read-instead-of-null.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-07-a-phantom-bid-s-accepted-sources-are-derived-from-the-chain-set.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-07-approve-mode-removed-on-chain-and-in-the-client.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-07-batched-delegate-approve-goes-first-when-a-permit2-approval-is.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-07-device-keys-live-in-a-plain-authorized-keys.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-07-off-by-default-enabled-from-the-ui-only.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-07-pairing-pastes-the-phone-s-public-key-by-default-generation-is.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-07-paymaster-relayer-gate-open-while-unset-never-settable-to-zero.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-07-relay-host-key-configured-pin-else-the-built-in-pin-for-the.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-07-remote-access-terminates-the-phone-s-ssh-session-inside-simplex.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-07-setrelayer-request-kind-plus-migrate-not-an-execute-kind.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-07-ssh2-is-imported-as-a-default-export.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-07-the-paymaster-relayer-is-governance-relayer-and-the-release.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-08-eip-2612-is-the-bootstrap-authorization-and-nothing-else.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-08-every-generated-ed25519-pair-is-parsed-before-it-is-stored.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-08-none-is-not-a-failed-login-every-other-non-publickey-method-is.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-08-permit2-signaturetransfer-is-the-only-authorization-simplex.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-08-the-key-exchange-list-is-ours-not-the-client-s.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-08-the-permit2-bootstrap-keys-on-the-allowance-not-on-the.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-08-the-pre-auth-deadline-lives-on-the-stream-not-on-the-ssh2.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-08-tunnelled-requests-are-tagged-in-process-and-only-remote-access.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-09-a-socket-path-is-type-checked-with-lstat-before-anything-is.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-09-a-stale-socket-is-detected-by-connecting-and-an-over-long-path.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-09-accept-keeps-its-listening-guard-the-coupling-is-pinned-by-a.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-09-attachorder-s-transaction-is-explicit-begin-commit-rollback.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-09-both-tsup-entries-set-removenodeprotocol-false-and-the-build.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-09-connection-provenance-is-tagged-on-the-socket-not-inferred-from.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-09-engines-node-floor-is-22-16-0-not-22-13-0-or-24-1236.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-09-fee-token-readiness-takes-priority-over-bootstrap-1223.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-09-framing-headers-are-set-once-in-handle-not-per-response.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-09-listen-state-is-assigned-after-the-bind-succeeds-never-before.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-09-log-format-is-read-from-raw-argv-and-json-mode-writes-straight.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-09-no-open-and-log-format-are-flags-only-with-no-env-var.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-09-node-sqlite-s-defaults-are-restated-explicitly-and-its-feature.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-09-ssh-tunnel-auth-strict-verify-declared-algorithm-must-match-the.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-09-the-host-header-check-is-skipped-for-socket-connections-not.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-09-the-legacy-database-fixtures-are-committed-binaries-not.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-09-the-publickey-probe-branch-requires-both-halves-absent-not.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-09-the-socket-is-created-0600-by-umask-not-chmod-ed-to-0600-after.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-09-windows-named-pipes-are-not-equivalent-to-a-0600-socket-verified.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-10-json-mode-installs-its-own-error-listener-on-stdout-and.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-10-run-s-flags-live-in-src-cli-run-options-ts-not-in-the-bin.md create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-10-the-ui-placeholder-is-host-port-not-addr.md create mode 100644 sdk/packages/simplex/docs/ai/flows/bsc-paymaster-messaging.md create mode 100644 sdk/packages/simplex/docs/ai/flows/cli-stdout-the-banner-the-log-sinks-and-the-wizard-url.md create mode 100644 sdk/packages/simplex/docs/ai/flows/dashboard-balance-collection.md create mode 100644 sdk/packages/simplex/docs/ai/flows/desktop-pwa-installation-and-offline-shell.md create mode 100644 sdk/packages/simplex/docs/ai/flows/filling-how-a-fill-amount-is-sized-and-who-else-spends-the-same.md create mode 100644 sdk/packages/simplex/docs/ai/flows/market-setup-and-curve-editing.md create mode 100644 sdk/packages/simplex/docs/ai/flows/opening-an-operator-data-directory.md create mode 100644 sdk/packages/simplex/docs/ai/flows/operator-market-drawer.md create mode 100644 sdk/packages/simplex/docs/ai/flows/operator-market-list-prices.md create mode 100644 sdk/packages/simplex/docs/ai/flows/order-history-on-the-activity-page.md create mode 100644 sdk/packages/simplex/docs/ai/flows/order-intake-what-reaches-the-filler-at-all.md create mode 100644 sdk/packages/simplex/docs/ai/flows/order-outcome-bids-fills-and-losses.md create mode 100644 sdk/packages/simplex/docs/ai/flows/page-routes.md create mode 100644 sdk/packages/simplex/docs/ai/flows/paymaster-selection-for-a-sponsored-userop.md create mode 100644 sdk/packages/simplex/docs/ai/flows/phantom-bid-deposits-across-restarts.md create mode 100644 sdk/packages/simplex/docs/ai/flows/phantom-probe-curve-value-published-price.md create mode 100644 sdk/packages/simplex/docs/ai/flows/remote-access-from-enabled-true-to-a-phone-loading-the-dashboard.md create mode 100644 sdk/packages/simplex/docs/ai/flows/runtime-controls-on-the-overview.md create mode 100644 sdk/packages/simplex/docs/ai/flows/send-transaction-explorer-link.md create mode 100644 sdk/packages/simplex/docs/ai/flows/setup-completion-failures.md create mode 100644 sdk/packages/simplex/docs/ai/flows/setup-wizard-presentation-and-navigation.md create mode 100644 sdk/packages/simplex/docs/ai/flows/signing-from-construction-to-each-signature.md create mode 100644 sdk/packages/simplex/docs/ai/flows/simplex-ui-branding-and-static-assets.md create mode 100644 sdk/packages/simplex/docs/ai/flows/solver-links.md create mode 100644 sdk/packages/simplex/docs/ai/flows/ui-server-choosing-a-listen-mode-and-how-a-request-reaches-a.md create mode 100644 sdk/packages/simplex/docs/ai/flows/vault-save-from-the-dashboard.md create mode 100644 sdk/packages/simplex/docs/ai/flows/vault-selection-and-balance-defaults.md create mode 100644 sdk/packages/simplex/docs/ai/flows/vault-sweep-pass.md create mode 100644 sdk/packages/simplex/docs/ai/flows/venue-pricing-uniswap-v4-funded-pairs.md create mode 100644 sdk/packages/simplex/docs/ai/flows/wallet-ledger.md create mode 100644 sdk/packages/simplex/docs/ai/flows/watch-only-orders-in-the-activity-feed.md diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index adc36e6e0..000000000 --- a/.gitattributes +++ /dev/null @@ -1,9 +0,0 @@ -# ChangeLog.md and Decisions.md under docs/ai are append-only logs: every change adds -# its entry at the top of the file, so any two concurrent PRs collide on that same -# line. `union` is git's built-in "keep both sides" driver — it takes both entries -# instead of raising a conflict, which is what you want for a log. -# -# Flow.md is deliberately not listed. It is edited in place, so union would merge two -# contradictory edits into a file that silently states both, with no conflict shown. -**/docs/ai/ChangeLog.md merge=union -**/docs/ai/Decisions.md merge=union diff --git a/CLAUDE.md b/CLAUDE.md index bf21ce669..3dfca572a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,12 +37,28 @@ The Solidity test helper contract is `evm/tests/foundry/AbiEncodeTest.sol`. ## AI workflow docs -Each package under `sdk/packages/*` keeps AI workflow docs in `docs/ai/`: - -- `ChangeLog.md` — append an entry for every AI-assisted code change (date, what changed, files touched). -- `Decisions.md` — record non-obvious choices with the alternatives considered and why they lost. -- `Flow.md` — how the code paths actually execute; update it when documented control flow changes, and add flows as they are read and verified. Never document a flow speculatively. - -When changing code in a package that has `docs/ai/`, updating these files is part of the change, not optional follow-up. When starting substantial work in a package that has no `docs/ai/` yet (e.g. simplex, sdk, core, lz-endpoint), create the three files as the first step and seed them from that task's actual work — never with empty templates. - -These are not release notes. Package `CHANGELOG.md` files are changesets release logs managed separately; `ChangeLog.md` lives in `docs/ai/` partly because the two names collide on case-insensitive filesystems. +Each package under `sdk/packages/*` keeps AI workflow docs in `docs/ai/`, as one file per +entry. Every package's `docs/ai/README.md` states the conventions; in short: + +- `changelog/YYYY-MM-DD-short-title.md` — one file per AI-assisted code change: what changed and why, then a `Files:` line listing the files touched. +- `decisions/YYYY-MM-DD-short-title.md` — one file per non-obvious choice, with the alternatives considered and why they lost. +- `flows/.md` — one file per code path, no date in the name, describing how it actually executes. Update it when the documented control flow changes, and add flows as they are read and verified. Never document a flow speculatively. + +Always write a new file for a changelog or decision entry. Never append to an existing one, +and never gather entries back into a shared `ChangeLog.md`-style file — that is the layout +this replaced. Two concurrent PRs appending to one file collide on the same line, and GitHub +blocks the merge: it ignores the `merge=union` driver in `.gitattributes` that resolves the +collision locally, so the conflict is real as far as the PR is concerned. Separate files +cannot collide, so the question never arises. + +Flow files are the exception — they are edited in place, so two PRs revising the same flow +do conflict. That conflict is worth seeing, because it means two changes disagree about how +the code runs. + +When changing code in a package that has `docs/ai/`, writing these files is part of the +change, not optional follow-up. When starting substantial work in a package that has none +yet (lz-endpoint), create the three directories and the README as the first step and seed +them from that task's actual work — never with empty templates. + +These are not release notes. Package `CHANGELOG.md` files are changesets release logs, +managed separately. diff --git a/sdk/packages/core/docs/ai/ChangeLog.md b/sdk/packages/core/docs/ai/ChangeLog.md deleted file mode 100644 index cb2281074..000000000 --- a/sdk/packages/core/docs/ai/ChangeLog.md +++ /dev/null @@ -1,287 +0,0 @@ -# ChangeLog - -AI-maintained log of code changes in `sdk/packages/core`. Every AI-assisted change appends an entry here: date, what changed, and the files touched. This is not the release changelog — `sdk/packages/core/CHANGELOG.md` is the published release log and is managed separately. - -Entry format: - -``` -## YYYY-MM-DD — short title (issue/PR if any) -What changed and why, in a few sentences. -Files: list of files touched. -``` - -Newest entries first. - -## 2026-09-07 — Live-fork gateway test asserts the armed mainnet proxy instead of replaying its migration - -The mainnet IntentGatewayV2 proxy has been upgraded and armed (relayer set, version 2), so the -fork test that rehearsed that migration failed on its first precondition. It is replaced by -`testLiveProxyIsArmedAndGovernedOnlyByItsRelayer`: the live proxy reads armed and migrated with -the relayer packed behind an unset `_paused` in slot 13, refuses `initialize` and a second -`migrate`, refuses an `Execute` upgrade from anyone but its relayer, installs it with every -readable piece of state intact when the relayer delivers it, and rotates through `Execute`, after -which the previous relayer is out. - -Files: `evm/tests/foundry/IntentGatewayV2Test.sol`, `docs/ai/Flow.md`. - -## 2026-09-07 — SimplexPaymaster governance deliveries gated on one relayer; APPROVE mode removed - -`SimplexPaymaster.onAccept` checks `incoming.relayer` against a new `_relayer` (slot 8, gap 48) -after `onlyHost` and before the source check. Armed by a fifth `initialize` argument, by the -host-only `migrate(relayer)` as upgrade init data (version 1 to 2, `onlyFresh` on `initialize`), or -rotated by `RequestKind.SetRelayer`; unset means open, zero can never be set by governance. Mode -byte `0x01` is refused. Deploy script reads `GOVERNANCE_RELAYER`; new impl-only deploy script for the -governance upgrade of the live proxies. Pallet extrinsic `set_paymaster_relayer` added. - -Files: `evm/src/utils/SimplexPaymaster.sol`, `evm/script/DeploySimplexPaymaster.s.sol`, -`evm/script/DeploySimplexPaymasterImpl.s.sol`, `evm/script/SimplexPaymasterPermit2Probe.s.sol`, -`evm/tests/foundry/SimplexPaymaster*.t.sol`, `modules/pallets/intents-coprocessor/src/*.rs`, -`docs/ai/Flow.md`. - -## 2026-09-05 — `HostManager.onAccept` checks caller and relayer through one modifier - -`restrict` takes the address to check and the address it must equal, and `onAccept` carries it -twice: `restrict(msg.sender, _params.host)` and `restrict(incoming.relayer, _params.admin)`. -Both failures revert with `UnauthorizedAction`; the separate `UnauthorizedRelayer` error is gone, -and the Foundry and Rust tests expect `UnauthorizedAction` for a wrong relayer. `init` uses the -same two-argument form. `HostManager.json` regenerated. - -Files: `docs/ai/ChangeLog.md`. Outside the package: `evm/src/core/HostManager.sol`, -`evm/tests/foundry/HostManagerTest.sol`, `evm/tests/rust/src/tests/host_manager.rs`, -`evm/rust/abi/HostManager.json`. - -## 2026-09-05 — `UpgradeContract` becomes `Execute`, one governance door to the gateway's host-only functions - -`RequestKind.Execute` (discriminator 5, the old `UpgradeContract` slot) delegatecalls the proxy's -current implementation with `body[1:]` as calldata, the host still `msg.sender`. `upgradeToAndCall` -is now a host-only function on `ExtrinsicIntents` wrapping `ERC1967Utils.upgradeToAndCall`, so an -upgrade is `Execute` carrying that call, and a relayer rotation is `Execute` carrying `setRelayer` -with no implementation change. Reverts inside the call bubble out. The `(address, bytes)` body of -the old action selects no function on this implementation and reverts, and the live -implementation still reads it as `UpgradeContract`, so the pallet keeps `upgrade_gateway` for the -one upgrade that installs this code on a chain and gains `execute_on_gateway(data)` for -everything after, weighed as `upgrade_gateway`. The interface declares `upgradeToAndCall`. - -Tests: `_upgradeRequest` builds `Execute` + `upgradeToAndCall`, so the existing upgrade tests run -through the new path; new `testExecuteRotatesRelayerWithoutUpgrade`, -`testExecuteRejectsNonHyperbridgeSource`, `testExecuteBubblesReverts`, -`testLegacyUpgradeBodyIsRefused`, `testUpgradeToAndCallRejectsEveryoneButHost`; the live-fork -test migrates the mainnet proxy with the legacy body and then rotates it through `Execute`. -Pallet tests pin the `Execute` encoding and the new extrinsic. - -Files: `contracts/apps/IntentGatewayV2.sol`, `docs/ai/ChangeLog.md`, `docs/ai/Decisions.md`, -`docs/ai/Flow.md`. Outside the package: `evm/src/apps/intentsv2/IntentsBase.sol`, -`evm/src/apps/intentsv2/ExtrinsicIntents.sol`, `evm/src/apps/IntentGatewayV2.sol`, -`evm/script/DeployIntentGateway.s.sol`, `evm/script/DeployIntentGatewayImpl.s.sol`, -`evm/tests/foundry/IntentGatewayV2Test.sol`, `modules/pallets/intents-coprocessor/src/types.rs`, -`modules/pallets/intents-coprocessor/src/lib.rs`, `modules/pallets/intents-coprocessor/src/tests.rs`. - -## 2026-09-05 — Gateway `initialize` refused on any proxy already at a version - -`initialize` carries an `onlyFresh` modifier that reverts with `InvalidInitialization` unless the -`Initializable` version is 0. Without it, an upgrade that installed this implementation on a -version-1 proxy without running `migrate` would leave `initialize`, which has no caller -restriction, open to anyone until governance caught up. Now the host-only `migrate` is the only -way up for such a proxy. `testInitializeRefusedOnLegacyProxy` plays it. The interface NatSpec for -`migrate` says so. - -Files: `contracts/apps/IntentGatewayV2.sol`, `docs/ai/ChangeLog.md`, `docs/ai/Decisions.md`, -`docs/ai/Flow.md`. Outside the package: `evm/src/apps/IntentGatewayV2.sol`, -`evm/tests/foundry/IntentGatewayV2Test.sol`. - -## 2026-09-05 — Relayer gate lifted out of `HyperFungibleToken` into `BridgeToken` - -`HyperFungibleToken` no longer carries `_relayer`, `relayer()`, `setRelayer`, `RelayerUpdated`, -`UnauthorizedRelayer` or the `_checkRelayer` hook; `onAccept` and `onPostRequestTimeout` are -`public virtual` so a token can gate deliveries before calling `super`. `BridgeToken` owns all of -that now: it overrides both callbacks with `onlyHost`, then `_checkRelayer`, then `super`, and -`_checkRelayer` fails closed as before. The base-token relayer tests in -`HyperFungibleTokenTest.sol` are gone with the feature; `BridgeTokenTest.t.sol` references the -error and event on `BridgeToken`. Storage of non-upgradeable `HyperFungibleToken` deployments -shifts by one slot, which only matters for a contract that reads it by slot. - -Files: `contracts/apps/HyperFungibleToken.sol`, `docs/ai/ChangeLog.md`, `docs/ai/Decisions.md`, -`docs/ai/Flow.md`. Outside the package: `evm/src/apps/BridgeToken.sol`, -`evm/tests/foundry/HyperFungibleTokenTest.sol`, `evm/tests/foundry/BridgeTokenTest.t.sol`. - -## 2026-09-05 — Gateway `initialize` takes the relayer and lands at version 2; `migrate` for older proxies - -`IntentGatewayV2.initialize(Params, bytes[] peerChains, address relayer)` now arms the relayer -gate from the init data and runs under `reinitializer(VERSION)` with `VERSION = 2`, so a fresh -proxy comes out armed and at the version of the code it runs. `migrate(address relayer)`, host-only -and under the same `reinitializer(VERSION)`, is for proxies deployed before this implementation: -it arms them and takes them from 1 to 2, and reverts on a proxy `initialize` already took there. -`setRelayer` stays a plain host-only rotation that leaves the version alone; all three write -through `_setRelayer` in `ExtrinsicIntents`. The next implementation that needs a migration bumps -`VERSION` once. The interface documents `migrate` and `version` accordingly. - -`DeployIntentGateway.s.sol` always deploys the implementation and the solver account, deploys the -proxy only where `INTENT_GATEWAY_V2` is absent from the chain's config, reads the relayer from -`GATEWAY_RELAYER` for the init data, and records `INTENT_GATEWAY_V2_IMPL`. The relayer is now part -of what fixes a new proxy's address, as the implementation address already was. - -The reinitializer cost more than the 71 bytes of EIP-170 headroom, and every gateway getter and -event has a consumer in `sdk`, `simplex` or the indexer, so the room came from deduplicating -internal code with no behaviour change: `_sendValue` in `IntentsBase` for the native -send-and-check (the same-chain fill loop keeps its inline copy, being at the via-ir stack limit), -`_splitSurplus` moved to `IntentsBase` and used by the cross-chain fill, `_withdrawalBody` and -`_postToSource` in `ExtrinsicIntents` for the escrow messages, and `placeOrder` reusing its -`feeToken` read and hashing the order once. - -Tests: every `initialize` call gains the relayer argument, `address(0)` outside `setUp` so those -gateways stay open as before; `testInitializeArmsTheGate` pins the events and version; the -`migrate` tests run on a proxy written back to version 1 through the `Initializable` slot, since -this implementation cannot produce one; the live mainnet-fork upgrade migrates the real one. -`HostManager.onAccept` gained NatSpec. - -Files: `contracts/apps/IntentGatewayV2.sol`, `docs/ai/ChangeLog.md`, `docs/ai/Decisions.md`, -`docs/ai/Flow.md`. Outside the package: `evm/src/apps/IntentGatewayV2.sol`, -`evm/src/apps/intentsv2/ExtrinsicIntents.sol`, `evm/src/apps/intentsv2/IntrinsicIntents.sol`, -`evm/src/apps/intentsv2/IntentsBase.sol`, `evm/src/core/HostManager.sol`, -`evm/script/DeployIntentGateway.s.sol`, `evm/tests/foundry/IntentGatewayV2Test.sol`, -`evm/tests/foundry/IntentGatewayV2SameChainTest.sol`, -`evm/tests/foundry/IntrinsicIntentsReentrancyTest.sol`, -`evm/tests/foundry/account/SolverAccountTest.sol`. - -## 2026-09-05 — `HostManager` admin is the governance relayer; gateway `setRelayer` is host-only - -`HostManager` no longer has a separate relayer. Its `admin` survives initialization and is the only -relayer whose `onAccept` deliveries are accepted, so `_relayer`, `relayer()` and `setRelayer` are -gone. `setIsmpHost` is now `init`: admin-only, one-shot, and unnecessary when the host is passed to -the constructor. A new `SetAdmin` action (variant `2`, body `abi.encode(address)`) rotates the admin -through governance, delivered by the outgoing admin like every other message; zero is refused there -and in the constructor, since a manager with no admin could never be reached again. -`pallet-ismp-host-executive` gains `set_host_manager_admin`, which dispatches that action to the -manager on record, and `ismp-abi` gains `encode_set_admin`. `evm/rust/abi/HostManager.json` was -regenerated; it had not been since before the relayer gate. - -On the gateway, `setRelayer` moved from `IntentGatewayV2` to `ExtrinsicIntents` and is `onlyHost`, -so `_owner` can no longer rotate the relayer; the host reaches it only as `UpgradeContract` -migration calldata, and nothing else writes the relayer. `initialize` is unchanged, so a fresh -proxy starts with no relayer, and an unset relayer now gates nothing: the governance upgrade that -arms it has to be delivered first. `setRelayer(address(0))` reopens the gate rather than closing -it. `DeployIntentGateway.s.sol` no longer reads `GATEWAY_RELAYER` or calls `setRelayer`. -`DeployIsmp.s.sol` constructs the host before the manager and binds the manager at construction, -with `GOVERNANCE_RELAYER` as its admin; `DeployHostManager.s.sol` does the same for a replacement -manager. Both contracts expose `relayer()`, and the gateway exposes `version()`, the -`Initializable` version the proxy has reached (1 after `initialize`, higher only after a -`reinitializer` migration), so tooling can tell which relayer a deployment accepts and whether it -has the gate at all (a revert means it predates it). The gateway's `_relayer` and `_instances` -became internal to pay for the getters under EIP-170; `instance(bytes)` already covered the -latter. The interface in this package declares the two getters and updates its `setRelayer` -NatSpec. - -Files: `contracts/apps/IntentGatewayV2.sol`, `package.json`, `docs/ai/ChangeLog.md`, -`docs/ai/Decisions.md`, `docs/ai/Flow.md`. Outside the package: `evm/src/core/HostManager.sol`, -`evm/src/apps/IntentGatewayV2.sol`, `evm/src/apps/intentsv2/ExtrinsicIntents.sol`, -`evm/script/DeployIsmp.s.sol`, `evm/script/DeployHostManager.s.sol`, -`evm/script/DeployIntentGateway.s.sol`, `evm/tron/migrations/2_deploy_ismp.js`, -`evm/tron/README.md`, `evm/rust/src/host_params.rs`, `evm/rust/abi/HostManager.json`, -`evm/tests/foundry/HostManagerTest.sol`, `evm/tests/foundry/IntentGatewayV2Test.sol`, -`evm/tests/foundry/IntentGatewayV2SameChainTest.sol`, the foundry test setups that construct a -`HostManager` or initialize a gateway, `evm/tests/rust/src/tests/utils.rs`, -`evm/tests/rust/src/tests/host_manager.rs`, `modules/pallets/host-executive/src/lib.rs`, -`modules/pallets/testsuite/src/tests/pallet_ismp_host_executive.rs`. - -## 2026-09-04 — Fresh gateway deployments arm the relayer in the deploy script - -`DeployIntentGateway.s.sol` deployed the gated gateway with `_relayer` unset, so a proxy on a new -chain refused every delivery, including the `upgrade_gateway` message that could have armed it; -only the owner key could, and nothing called it. The script now reads `GATEWAY_RELAYER`, requires -the deploy key to be the admin (the only caller of `setRelayer`), calls `setRelayer` right after -the proxy is deployed, and asserts the relayer afterwards. `initialize` is unchanged so the -deterministic proxy address is unchanged. The gateway constructor now rejects a zero owner, since -a mis-set `ADMIN` would leave no key able to arm a fresh proxy; runtime size is unaffected. No -file in this package changed. - -Files: `evm/script/DeployIntentGateway.s.sol`, `evm/src/apps/IntentGatewayV2.sol`, -`evm/tests/foundry/IntentGatewayV2Test.sol`, `docs/ai/Flow.md`. - -## 2026-09-03 — Host manager rotation addressed to the manager the host still trusts - -`pallet-ismp-host-executive::update_host_params` applied the update before reading the request -recipient, so a rotation from one HostManager to another was addressed to the new one. The host -accepts `updateHostParams` only from its current manager, so the delivery reverted, Hyperbridge -recorded the new manager anyway, and every later host update and withdrawal for that chain was -sent to a contract the host did not trust. The recipient is now captured before the update; the -payload still installs the new manager. This is the path the HostManager relayer gate is rolled -out through. No file in this package changed. - -Files: `modules/pallets/host-executive/src/lib.rs`, -`modules/pallets/testsuite/src/tests/pallet_ismp_host_executive.rs`, -`evm/tests/foundry/HostManagerTest.sol`, `docs/ai/Flow.md`. - -## 2026-09-03 — Governance deliveries to `HostManager` gated on the same relayer - -Closes the route around the app-level gates: `HostManager.onAccept` accepted `SetHostParam` from -any relayer, and that request can replace the host's handler, the contract every app trusts to -report the relayer address. `HostManager` now holds `_relayer`, set by the host admin through -`setRelayer`, and `onAccept` reverts with `UnauthorizedRelayer` for any other relayer, zero -included. Only governance traffic reaches this contract, so ordinary relaying is unaffected. -No file in this package changed; the entry is here because the delivery flow documented in -`Flow.md` is what it corrects. - -Files: `evm/src/core/HostManager.sol`, `evm/script/DeployIsmp.s.sol`, -`evm/script/DeployHostManager.s.sol`, `evm/tests/foundry/HostManagerTest.sol`, -`evm/tests/foundry/BaseTest.sol`, `evm/tests/rust/src/tests/utils.rs`, `docs/ai/Decisions.md`, -`docs/ai/Flow.md`. - -## 2026-09-03 — Relayer allowlist on `HyperFungibleToken`, fail-closed on the BRIDGE token - -`HyperFungibleToken` gains `_relayer`, `relayer()`, `setRelayer(address)` (owner only), the -`RelayerUpdated(address previous, address current)` event, the `UnauthorizedRelayer` error, and a -virtual `_checkRelayer` that `onAccept` and `onPostRequestTimeout` call before anything else. Both -callbacks mint, so both are gated. In the base the check is opt-in: zero leaves deliveries open, so -tokens already deployed from this package behave as before until their owner sets a relayer. -`evm/src/apps/BridgeToken.sol` overrides `_checkRelayer` to fail closed, and its deploy script sets -the relayer from `GOVERNANCE_RELAYER` before `configure`, so the token is never live without one. -`IHyperFungibleToken` is unchanged: `supportsInterface` keys on its `interfaceId`, so adding the -new functions there would change what every existing deployment reports. - -Files: `contracts/apps/HyperFungibleToken.sol`, `docs/ai/ChangeLog.md`, `docs/ai/Decisions.md`, -`docs/ai/Flow.md`. Outside the package: `evm/src/apps/BridgeToken.sol`, -`evm/script/DeployBridgeToken.s.sol`, `evm/tests/foundry/BridgeTokenTest.t.sol`, -`evm/tests/foundry/HyperFungibleTokenTest.sol`. - -## 2026-09-03 — Relayer allowlist on the intent gateway - -The gateway now accepts `onAccept` and `onGetResponse` deliveries only from a single authorised -relayer stored at `_relayer` (slot 13, packed behind `_paused`). The check runs before the message -body is decoded, so escrow redemptions, refunds and every governance action, upgrades included, are -covered. A refused delivery reverts, which the host records as undelivered, so the authorised -relayer can submit the same message later. `setRelayer(address)` is callable by the immutable -`_owner` and by the host; the host branch exists so a governance `UpgradeContract` can carry the -call as its migration calldata and arm the relayer in the upgrade transaction (`upgradeToAndCall` -delegatecalls that calldata with the host still as `msg.sender`). - -The interface gains `RelayerUpdated(address previous, address current)` and `setRelayer`, keeping -its declarations identical to `IntentsBase`. The unused `_paused` getter was dropped from the gateway -to stay under the EIP-170 size limit; it was never declared here. - -Files: `contracts/apps/IntentGatewayV2.sol`, `package.json`, `docs/ai/ChangeLog.md`, -`docs/ai/Decisions.md`, `docs/ai/Flow.md`. Gateway side: `evm/src/apps/IntentGatewayV2.sol`, -`evm/src/apps/intentsv2/IntentsBase.sol`, `evm/src/apps/intentsv2/ExtrinsicIntents.sol`, -`evm/tests/foundry/IntentGatewayV2Test.sol`. - -## 2026-08-27 — `IIntentGatewayV2` brought back in sync with the gateway (#1160) - -`IIntentGatewayV2` had drifted from the deployed `IntentGatewayV2`. Every declaration in the -interface is now identical to the one in `evm/src/apps/intentsv2/IntentsBase.sol`, verified by -diffing the two declaration sets: - -- `OrderFilled`, `EscrowReleased` and `EscrowRefunded` were still the pre-`tokens` signatures. All - three take a `TokenInfo[]` the interface did not declare. -- `NewDeploymentAdded(bytes stateMachineId, address gateway)` does not exist. The gateway emits - `DeploymentAdded(string chain, address gateway)` — wrong name and wrong parameter type, so a - consumer filtering on it would have matched nothing. -- `PartialFill`, `DestinationProtocolFeeUpdated`, and the `UnknownInstance` and - `PartialFillNotAllowed` errors were absent. -- `OrderCancelled(bytes32 indexed commitment, address canceller)` was added, the event this issue - introduces on the gateway. - -Nothing else in the repo compiles against these declarations — `SolverAccount.sol` imports the -interface only for `select.selector` and `fillOrder.selector` — so the change is inert here and -matters to integrators who read the interface as the gateway's published surface. - -Files: `contracts/apps/IntentGatewayV2.sol`, `package.json`, `docs/ai/ChangeLog.md`, -`docs/ai/Decisions.md`, `docs/ai/Flow.md`. diff --git a/sdk/packages/core/docs/ai/Decisions.md b/sdk/packages/core/docs/ai/Decisions.md deleted file mode 100644 index 7225496b7..000000000 --- a/sdk/packages/core/docs/ai/Decisions.md +++ /dev/null @@ -1,301 +0,0 @@ -# Decisions - -AI-maintained record of non-obvious choices made in `sdk/packages/core`: what was decided, what the alternatives were, and why. Read this before changing related code so a later change does not silently undo a deliberate trade-off. - -Entry format: heading with the decision, then alternatives considered and the reasoning. Newest first. - -## 2026-09-05 — `Execute` delegatecalls the implementation; an upgrade is one such call - -Chosen: one governance action that delegatecalls the proxy's current implementation with the -body as calldata. `msg.sender` stays the host, so the existing `onlyHost` guards are the whole -access model, and `upgradeToAndCall` becomes an ordinary host-only function. A rotation no longer -has to be dressed up as an upgrade to the same implementation. - -Alternative rejected — an external self-call, `address(this).call(data)`. It makes the gateway -its own caller, so every governance target would need an `onlySelf` guard in place of -`onlyHost`, and the gateway as caller holds the escrow, which the host as caller does not. - -Alternative rejected — a `SetRelayer` action next to `UpgradeContract`. One more variant to -mirror in the pallet for one function; `Execute` covers it and anything host-only added later. - -Kept — `UpgradeContract` in the pallet, under the same discriminator. The live implementation -understands only that body, so it is the one message that can install this code; afterwards it -selects no function here and reverts, which `testLegacyUpgradeBodyIsRefused` pins so the mistake -is loud rather than silent. - -Cost accepted — `Execute` can call any function of the implementation with the host as sender, -not only the host-only ones. Governance can already install arbitrary code through an upgrade, -so this widens nothing. - -## 2026-09-05 — `initialize` only on a bare proxy, rather than a version check in the upgrade path - -Chosen: `initialize` is refused unless the proxy is at version 0. `migrate` is the only entry to -`VERSION` for a proxy that already has one, and it is host-only. - -The hole both options close: `initialize` has no caller restriction, because a bare proxy is -initialized atomically in its constructor. An `UpgradeContract` that installs this implementation -on a version-1 proxy without `migrate` calldata would leave the proxy below `VERSION` with -`initialize` callable by anyone, who could then set the params and the relayer. - -Alternative rejected — require the version to have risen after `upgradeToAndCall`. It forces -every upgrade to carry a migration, so a same-implementation upgrade carrying only `setRelayer`, -the rotation path, would be refused; scoping the check to implementation changes fixes that but -adds a branch and an implementation-slot read to every upgrade. And it cannot protect the first -upgrade off the live pre-gate implementation, whose `onAccept` has no such check. The `initialize` -guard covers that case too, since it is the new implementation's code that runs `initialize`. - -## 2026-09-05 — The relayer gate belongs to the token that needs it, not to `HyperFungibleToken` - -Chosen: `HyperFungibleToken` has no relayer state, setter, event, error or hook. Its two delivery -callbacks are `public virtual`, and `BridgeToken` wraps them with its own gate. A token that wants -a gate writes one; a token that does not gets nothing to configure and no extra storage slot. - -The opt-in gate in the base contract (zero means open, override to fail closed) was two policies -in one place: third-party tokens saw a setter they had no reason to call, and the one token that -needed the gate had to override the hook to invert its default. Moving the whole thing into -`BridgeToken` leaves one policy per contract. - -Alternative rejected — keep an empty `_checkRelayer` hook in the base for derived tokens to fill -in. It still names a relayer in a contract that has no opinion about one, and wrapping the -callbacks costs the derived token nothing more than a `super` call. - -## 2026-09-05 — `version()` is the implementation's `VERSION`; `initialize` arms, `migrate` catches up - -Chosen: one constant, `VERSION = 2`, that both `initialize` and `migrate` land on. `initialize` -takes the relayer in the init data, so a fresh proxy is armed from its first block and reports -the version of the code it runs. `migrate`, host-only and one-shot, exists for proxies deployed -before this implementation and reverts on any proxy already at `VERSION`. `setRelayer` is a -rotation and never touches the version. `onlyHost` on `migrate` is load-bearing: a proxy at 1 is -open, and without it anyone could arm it first. - -This reverses the earlier decision (below, same day) to keep the relayer out of the init data. -The reason given there, that the relayer would become part of what fixes the proxy's CREATE2 -address, still holds but no longer bites: the implementation address is already an input to -that address, so every new implementation changes it for chains deployed afterwards anyway, and -the deploy script now deploys a proxy only where none exists. Landing fresh proxies at 1 with an -open gate, the state before this change, left them reporting an older version than their code. - -Alternative rejected — `setRelayer` under `reinitializer(_getInitializedVersion() + 1)`, built -and tested first. It makes `version()` count key rotations, which says nothing about what code a -proxy has migrated to. - -Alternative rejected — a fixed `reinitializer(2)` on `setRelayer` itself: the second rotation -reverts until an implementation with `reinitializer(3)` ships. - -Alternative rejected — a separate `bumpVersion()` next to a plain `setRelayer`: an -`UpgradeContract` carries one migration call, so arming a fresh chain would take two deliveries. - -The bytes came from deduplicating internal code, not from dropping anything off-chain reads. The -one place `_sendValue` is not used is the `_fillSameChain` loop, which is at the via-ir stack -limit. - -## 2026-09-05 — The `HostManager` admin is the relayer, rotated only by governance - -Chosen: `HostManagerParams.admin` survives `init` and is the address `onAccept` compares the -relayer against. There is no separate relayer slot and no local setter; a `SetAdmin` request from -Hyperbridge, delivered by the outgoing admin, replaces it. - -The previous design gave the host admin a `setRelayer` on the manager. That key could re-route -governance at will, from a local transaction nobody on Hyperbridge sees. Folding the relayer into -the admin removes that path: the only way to change who may deliver governance is governance, and -the manager's admin has exactly one power after `init`, which is to deliver. - -Alternative rejected — keep `setRelayer` but restrict it to the manager's own admin. Same local -override, different key. - -Alternative rejected — a zero admin as a kill switch, as a zero relayer was. A zero admin can never -be rotated away: the rotation is itself a delivery the manager would refuse, and the host cannot be -re-pointed at a new manager except through the current one. Zero is refused in the constructor and -in `SetAdmin`, and the pallet refuses to dispatch it; the cost is a comparison each, the failure -they prevent is permanent. - -## 2026-09-05 — `init` is one-shot, and the EVM deploy scripts do not call it - -Chosen: `init` binds the host only while it is unset; a manager constructed with its host set is -bound already. - -With the admin surviving `init`, a repeatable `init` would let the governance relayer key re-point -the host and cut it off from its own governance. One-shot closes that. `DeployIsmp.s.sol` -therefore constructs the host first, which works because `EvmHost` takes its params in -`initialize` rather than its constructor, and passes the host to the manager's constructor, so the -relayer key never has to sign a deploy transaction. `TronHost` takes its params in the constructor, -so the Tron migration keeps the `init` route with the deployer as admin until governance rotates -it. - -Alternative rejected — precompute the host's CREATE2 address in the script. Works, but couples the -script to the CREATE2 deployer and the host's creation code for no gain over reordering. - -## 2026-09-05 — Gateway `setRelayer` is host-only and the only writer; unset means open - -Chosen: `setRelayer` lives in `ExtrinsicIntents` behind `onlyHost`, `initialize` does not touch the -relayer, and `_checkRelayer` passes every delivery while `_relayer` is zero. This supersedes the -2026-09-03 decision below that zero fails closed. - -The owner branch existed so a fresh proxy could be armed locally; it also let the owner key -redirect every cross-chain delivery without a governance message. Removing it leaves the host as -the only caller, reachable solely from `UpgradeContract` migration calldata. Carrying the relayer -in the init data was tried and rejected: the relayer is operational state that governance owns, -not part of what fixes a proxy's address. With no local or init-time arming left, the message that -arms a fresh proxy is a governance delivery, so the unarmed proxy has to accept it; an unset -relayer therefore gates nothing, and `setRelayer(address(0))` reopens the gate. The window is the -one between deployment and the `upgrade_gateway` that arms it, and closing it is governance's -first act on a new chain. - -Alternative rejected — a `RequestKind.SetRelayer` governance action instead of the host-only -function. Simpler to invoke, but it needs the `intents-coprocessor` pallet mirrored, and the -migration-calldata route already exists and is tested. - -`_owner` stays as the placeholder it was before the relayer work, with nothing to do. - -## 2026-09-05 — `relayer()` on both contracts, `version()` on the gateway from `Initializable` - -Chosen: the manager and the gateway both answer `relayer()`. The gateway's `version()` returns -`_getInitializedVersion()` from OpenZeppelin's `Initializable`: 1 once `initialize` has run, and -raised only by a `reinitializer(n)` migration, so it tracks the storage-level migrations a proxy -has been through rather than a number someone has to remember to bump. Implementations from -before the gate have no `version()` at all and revert. The manager is not upgradeable and gets no -`version()`; a manager without `relayer()` is one from before the admin became the relayer. - -The underscore-public convention in `IntentsBase` (`_filled`, `_orders`, ...) would have given -`_relayer()` for free, but a named getter is what the interface should publish, and the gateway -had 26 bytes of headroom: `relayer()` only fits once the auto-generated getter is dropped, and -`version()` needed the `_instances` getter dropped as well. That getter duplicated -`instance(bytes)` and nothing off-chain called it. - -Alternative rejected — a hand-maintained version constant, as a semver string or a number. The -string cost 82 bytes and did not fit; both would drift from what is actually deployed. - -## 2026-09-03 — `HostManager` deliveries are gated too, and that does not touch permissionless relaying - -Chosen: `HostManager.onAccept` refuses any relayer but the one the host admin set, zero included. - -The app gates check an address the host reports, and the host takes it from its handler. The -handler is a host parameter that a `SetHostParam` governance message can replace, and until now -any relayer could deliver that message once its consensus proof verified. Under a forged consensus -an attacker would swap in a handler that reports the whitelisted relayer on every message, and the -app gates would pass. Gating the HostManager closes that route. - -It does not weaken the open-relayer model because the HostManager never carries user traffic. Its -first check already rejects anything not sourced from Hyperbridge, so the only messages it ever -sees are Polytope's own `Withdraw` and `SetHostParam`. Third-party relayers keep delivering every -ordinary message to every ordinary app exactly as before. - -Alternative rejected — a delay on host parameter changes with the admin freeze as a veto. Keeps -delivery open but needs someone watching every chain, and adds latency to legitimate governance. - -Alternative rejected — move handler and consensus-client changes to the host admin key. Simplest, -but it takes that power away from cross-chain governance rather than protecting it. - -The setter authority is the host admin rather than the HostManager's own admin, which is zeroed -after `setIsmpHost`. The host admin is the key that can already freeze the host and reset its -consensus state, so no new trust is introduced. Zero is checked explicitly here since there is no -bytecode pressure; on the gateway the same guarantee comes from the handler never forwarding zero. - -## 2026-09-03 — `HyperFungibleToken` opts in to the relayer gate; `BridgeToken` fails closed - -Chosen: the base token's `_checkRelayer` only rejects when a relayer has been set, and -`BridgeToken` overrides it so that an unset relayer matches nobody. - -`HyperFungibleToken` is a library contract that third parties deploy from this package. Failing -closed there would leave every token deployed without a `setRelayer` call unable to receive -anything, with no compile-time signal. The BRIDGE token is ours, its supply is backed by the nexus -escrow, and a forged mint is exactly the attack the gate exists for, so it takes the strict -semantics of the intent gateway. The two behaviours live in one virtual function so the difference -is visible in one place rather than spread through the callbacks. - -Alternative rejected — override `onAccept` in `BridgeToken`. It is `external`, so an override -cannot call the parent body and would have to duplicate the mint logic. - -Alternative rejected — make the base fail closed and bump the package major. Correct in principle, -but the request was for the bridge token, and the base can be tightened later once every -deployment from this package has a relayer set. - -## 2026-09-03 — `onPostRequestTimeout` is gated on the token, unlike the gateway - -Timeouts mint a refund to the original sender, so a forged timeout proof mints. The intent gateway -does not gate its timeout callbacks because `HyperApp`'s defaults revert and it dispatches with no -timeout. - -## 2026-09-03 — `IHyperFungibleToken` does not declare `setRelayer` or `relayer` - -`supportsInterface` returns true for `type(IHyperFungibleToken).interfaceId`. Extending the -interface changes that id, so existing deployments would stop matching it and new ones would report -an id integrators have not seen. The relayer functions are reachable through the contract type. - -## 2026-09-03 — The relayer is a separate storage variable, not a `Params` field - -Chosen: `address _relayer` appended after `_paused` in `IntentsBase`, set through its own -`setRelayer` call. - -`Params` occupies slots 4 to 8 and `_orders` starts at slot 9. Adding a field to the struct would -push every mapping behind it and corrupt escrow on the live proxy. Reusing `UpdateParams` was -therefore never available, quite apart from it being a Hyperbridge-relayed message: the whole point -is to hold even if Hyperbridge's consensus is compromised, so the setter must not depend on it. - -Alternative rejected — a new `RequestKind` for governance to set the relayer. It needs the -`intents-coprocessor` pallet mirrored, and it is still a cross-chain message. Governance can already -rotate the relayer through `UpgradeContract` migration calldata when it wants to; the owner path -covers the case where it cannot. - -## 2026-09-03 — Zero relayer fails closed, and the upgrade arms it atomically - -Superseded on 2026-09-05: an unset relayer gates nothing, see above. - -Chosen: an unset `_relayer` matches no delivery, because the handler always forwards a real -`msg.sender`. The rollout sets it in the upgrade transaction via `upgradeToAndCall` calldata. - -Alternative rejected — treat zero as "allowlist disabled". Convenient for tests and a forgotten -init, but it makes the safe state opt-in, and a fresh proxy would run unguarded until someone -noticed. A refused delivery costs nothing: the host deletes the receipt and the authorised relayer -can resubmit. - -Alternative rejected — a `reinitializer(2)` taking the relayer. It is one-shot, so rotation would -need the setter anyway, and it does not solve who may call it. - -## 2026-09-03 — `setRelayer` accepts the host, not `address(this)` - -Chosen: `msg.sender == _owner || msg.sender == host()`. - -The first draft allowed `address(this)`, expecting `upgradeToAndCall` to call the proxy. It does -not: it delegatecalls the migration calldata, so `msg.sender` is still the host that invoked -`onAccept`. The fork test against the live mainnet proxy failed with `Unauthorized` and exposed it. -Accepting the host adds no trust: the host already gates every callback, and it never calls the -gateway with any selector other than the `IApp` callbacks, so the branch is reachable only from -governance migration calldata that has already passed the hyperbridge-source check and the relayer -gate. - -## 2026-09-03 — The `_paused` getter was dropped to stay under EIP-170 - -Chosen: `bool internal _paused`. The variable stays in slot 13 so the layout is unchanged. - -The gateway compiled to 10 bytes over the limit with the new storage, setter, event and checks. -Nothing reads `_paused()` anywhere in the repo, so removing its getter was the only free saving. -The revert reuses `Unauthorized()` instead of a dedicated error for the same reason; the second -selector cost 15 bytes. - -## 2026-08-27 — `NewDeploymentAdded` was renamed rather than kept for compatibility - -Chosen: the interface's `NewDeploymentAdded(bytes stateMachineId, address gateway)` was replaced -with the gateway's actual `DeploymentAdded(string chain, address gateway)`. - -Renaming a declaration in a published interface normally breaks consumers. It does not here, -because there is nothing to break: no deployed gateway has ever emitted `NewDeploymentAdded`, so -anyone filtering on that topic has been matching zero logs. Keeping it would preserve a name that -only ever produces silence, next to the real one. - -Alternative rejected — declare both. The interface would then advertise an event the contract -cannot emit, which is the state that caused this in the first place. - -## 2026-08-27 — Declarations here are kept identical to `IntentsBase`, not merely compatible - -Chosen: every event and error in `IIntentGatewayV2` matches `evm/src/apps/intentsv2/IntentsBase.sol` -exactly — same name, same parameter types, same `indexed` flags. - -Nothing compiles against these declarations (`SolverAccount.sol` uses the interface only for two -function selectors), so a mismatch produces no build error anywhere in the repo. That is exactly -why the drift went unnoticed through several signature changes. The only thing that can catch it -is the rule that the two lists are equal, which is cheap to check by diffing them. - -Alternative rejected — declare only the subset integrators are expected to use. It sounds tidier, -but it makes "missing from the interface" ambiguous: you cannot tell a deliberate omission from -another four events nobody updated. diff --git a/sdk/packages/core/docs/ai/README.md b/sdk/packages/core/docs/ai/README.md new file mode 100644 index 000000000..b0971d895 --- /dev/null +++ b/sdk/packages/core/docs/ai/README.md @@ -0,0 +1,22 @@ +# AI workflow docs — `sdk/packages/core` + +AI-maintained notes on this package. One file per entry: two concurrent PRs add +different files, so they never conflict. Never collect entries back into a +shared file. + +- `changelog/` — one file per AI-assisted code change, named + `YYYY-MM-DD-short-title.md`. Say what changed and why in a few sentences, then + a `Files:` line listing the files touched. +- `decisions/` — one file per non-obvious choice, named the same way. Give the + decision, the alternatives considered, and why they lost. Read these before + changing related code, so a later change does not silently undo a deliberate + trade-off. +- `flows/` — one file per code path, named for the flow itself and with no date, + because these are edited in place as the code changes. Only document a flow + you have read and verified. + +Each file opens with an `#` heading. Changelog and decision names sort +chronologically, so `ls changelog/` reads oldest first and `ls -r` newest first. + +This is not the release changelog — that is `CHANGELOG.md` at the package +root, generated by changesets. diff --git a/sdk/packages/core/docs/ai/changelog/2026-08-27-iintentgatewayv2-brought-back-in-sync-with-the-gateway-1160.md b/sdk/packages/core/docs/ai/changelog/2026-08-27-iintentgatewayv2-brought-back-in-sync-with-the-gateway-1160.md new file mode 100644 index 000000000..4976275bb --- /dev/null +++ b/sdk/packages/core/docs/ai/changelog/2026-08-27-iintentgatewayv2-brought-back-in-sync-with-the-gateway-1160.md @@ -0,0 +1,22 @@ +# 2026-08-27 — `IIntentGatewayV2` brought back in sync with the gateway (#1160) + +`IIntentGatewayV2` had drifted from the deployed `IntentGatewayV2`. Every declaration in the +interface is now identical to the one in `evm/src/apps/intentsv2/IntentsBase.sol`, verified by +diffing the two declaration sets: + +- `OrderFilled`, `EscrowReleased` and `EscrowRefunded` were still the pre-`tokens` signatures. All + three take a `TokenInfo[]` the interface did not declare. +- `NewDeploymentAdded(bytes stateMachineId, address gateway)` does not exist. The gateway emits + `DeploymentAdded(string chain, address gateway)` — wrong name and wrong parameter type, so a + consumer filtering on it would have matched nothing. +- `PartialFill`, `DestinationProtocolFeeUpdated`, and the `UnknownInstance` and + `PartialFillNotAllowed` errors were absent. +- `OrderCancelled(bytes32 indexed commitment, address canceller)` was added, the event this issue + introduces on the gateway. + +Nothing else in the repo compiles against these declarations — `SolverAccount.sol` imports the +interface only for `select.selector` and `fillOrder.selector` — so the change is inert here and +matters to integrators who read the interface as the gateway's published surface. + +Files: `contracts/apps/IntentGatewayV2.sol`, `package.json`, `docs/ai/ChangeLog.md`, +`docs/ai/Decisions.md`, `docs/ai/Flow.md`. diff --git a/sdk/packages/core/docs/ai/changelog/2026-09-03-governance-deliveries-to-hostmanager-gated-on-the-same-relayer.md b/sdk/packages/core/docs/ai/changelog/2026-09-03-governance-deliveries-to-hostmanager-gated-on-the-same-relayer.md new file mode 100644 index 000000000..c01f72655 --- /dev/null +++ b/sdk/packages/core/docs/ai/changelog/2026-09-03-governance-deliveries-to-hostmanager-gated-on-the-same-relayer.md @@ -0,0 +1,14 @@ +# 2026-09-03 — Governance deliveries to `HostManager` gated on the same relayer + +Closes the route around the app-level gates: `HostManager.onAccept` accepted `SetHostParam` from +any relayer, and that request can replace the host's handler, the contract every app trusts to +report the relayer address. `HostManager` now holds `_relayer`, set by the host admin through +`setRelayer`, and `onAccept` reverts with `UnauthorizedRelayer` for any other relayer, zero +included. Only governance traffic reaches this contract, so ordinary relaying is unaffected. +No file in this package changed; the entry is here because the delivery flow documented in +`Flow.md` is what it corrects. + +Files: `evm/src/core/HostManager.sol`, `evm/script/DeployIsmp.s.sol`, +`evm/script/DeployHostManager.s.sol`, `evm/tests/foundry/HostManagerTest.sol`, +`evm/tests/foundry/BaseTest.sol`, `evm/tests/rust/src/tests/utils.rs`, `docs/ai/Decisions.md`, +`docs/ai/Flow.md`. diff --git a/sdk/packages/core/docs/ai/changelog/2026-09-03-host-manager-rotation-addressed-to-the-manager-the-host-still.md b/sdk/packages/core/docs/ai/changelog/2026-09-03-host-manager-rotation-addressed-to-the-manager-the-host-still.md new file mode 100644 index 000000000..32d7c63de --- /dev/null +++ b/sdk/packages/core/docs/ai/changelog/2026-09-03-host-manager-rotation-addressed-to-the-manager-the-host-still.md @@ -0,0 +1,13 @@ +# 2026-09-03 — Host manager rotation addressed to the manager the host still trusts + +`pallet-ismp-host-executive::update_host_params` applied the update before reading the request +recipient, so a rotation from one HostManager to another was addressed to the new one. The host +accepts `updateHostParams` only from its current manager, so the delivery reverted, Hyperbridge +recorded the new manager anyway, and every later host update and withdrawal for that chain was +sent to a contract the host did not trust. The recipient is now captured before the update; the +payload still installs the new manager. This is the path the HostManager relayer gate is rolled +out through. No file in this package changed. + +Files: `modules/pallets/host-executive/src/lib.rs`, +`modules/pallets/testsuite/src/tests/pallet_ismp_host_executive.rs`, +`evm/tests/foundry/HostManagerTest.sol`, `docs/ai/Flow.md`. diff --git a/sdk/packages/core/docs/ai/changelog/2026-09-03-relayer-allowlist-on-hyperfungibletoken-fail-closed-on-the.md b/sdk/packages/core/docs/ai/changelog/2026-09-03-relayer-allowlist-on-hyperfungibletoken-fail-closed-on-the.md new file mode 100644 index 000000000..b6bb7d3c3 --- /dev/null +++ b/sdk/packages/core/docs/ai/changelog/2026-09-03-relayer-allowlist-on-hyperfungibletoken-fail-closed-on-the.md @@ -0,0 +1,16 @@ +# 2026-09-03 — Relayer allowlist on `HyperFungibleToken`, fail-closed on the BRIDGE token + +`HyperFungibleToken` gains `_relayer`, `relayer()`, `setRelayer(address)` (owner only), the +`RelayerUpdated(address previous, address current)` event, the `UnauthorizedRelayer` error, and a +virtual `_checkRelayer` that `onAccept` and `onPostRequestTimeout` call before anything else. Both +callbacks mint, so both are gated. In the base the check is opt-in: zero leaves deliveries open, so +tokens already deployed from this package behave as before until their owner sets a relayer. +`evm/src/apps/BridgeToken.sol` overrides `_checkRelayer` to fail closed, and its deploy script sets +the relayer from `GOVERNANCE_RELAYER` before `configure`, so the token is never live without one. +`IHyperFungibleToken` is unchanged: `supportsInterface` keys on its `interfaceId`, so adding the +new functions there would change what every existing deployment reports. + +Files: `contracts/apps/HyperFungibleToken.sol`, `docs/ai/ChangeLog.md`, `docs/ai/Decisions.md`, +`docs/ai/Flow.md`. Outside the package: `evm/src/apps/BridgeToken.sol`, +`evm/script/DeployBridgeToken.s.sol`, `evm/tests/foundry/BridgeTokenTest.t.sol`, +`evm/tests/foundry/HyperFungibleTokenTest.sol`. diff --git a/sdk/packages/core/docs/ai/changelog/2026-09-03-relayer-allowlist-on-the-intent-gateway.md b/sdk/packages/core/docs/ai/changelog/2026-09-03-relayer-allowlist-on-the-intent-gateway.md new file mode 100644 index 000000000..dfb9aa04e --- /dev/null +++ b/sdk/packages/core/docs/ai/changelog/2026-09-03-relayer-allowlist-on-the-intent-gateway.md @@ -0,0 +1,19 @@ +# 2026-09-03 — Relayer allowlist on the intent gateway + +The gateway now accepts `onAccept` and `onGetResponse` deliveries only from a single authorised +relayer stored at `_relayer` (slot 13, packed behind `_paused`). The check runs before the message +body is decoded, so escrow redemptions, refunds and every governance action, upgrades included, are +covered. A refused delivery reverts, which the host records as undelivered, so the authorised +relayer can submit the same message later. `setRelayer(address)` is callable by the immutable +`_owner` and by the host; the host branch exists so a governance `UpgradeContract` can carry the +call as its migration calldata and arm the relayer in the upgrade transaction (`upgradeToAndCall` +delegatecalls that calldata with the host still as `msg.sender`). + +The interface gains `RelayerUpdated(address previous, address current)` and `setRelayer`, keeping +its declarations identical to `IntentsBase`. The unused `_paused` getter was dropped from the gateway +to stay under the EIP-170 size limit; it was never declared here. + +Files: `contracts/apps/IntentGatewayV2.sol`, `package.json`, `docs/ai/ChangeLog.md`, +`docs/ai/Decisions.md`, `docs/ai/Flow.md`. Gateway side: `evm/src/apps/IntentGatewayV2.sol`, +`evm/src/apps/intentsv2/IntentsBase.sol`, `evm/src/apps/intentsv2/ExtrinsicIntents.sol`, +`evm/tests/foundry/IntentGatewayV2Test.sol`. diff --git a/sdk/packages/core/docs/ai/changelog/2026-09-04-fresh-gateway-deployments-arm-the-relayer-in-the-deploy-script.md b/sdk/packages/core/docs/ai/changelog/2026-09-04-fresh-gateway-deployments-arm-the-relayer-in-the-deploy-script.md new file mode 100644 index 000000000..5235ae7c7 --- /dev/null +++ b/sdk/packages/core/docs/ai/changelog/2026-09-04-fresh-gateway-deployments-arm-the-relayer-in-the-deploy-script.md @@ -0,0 +1,13 @@ +# 2026-09-04 — Fresh gateway deployments arm the relayer in the deploy script + +`DeployIntentGateway.s.sol` deployed the gated gateway with `_relayer` unset, so a proxy on a new +chain refused every delivery, including the `upgrade_gateway` message that could have armed it; +only the owner key could, and nothing called it. The script now reads `GATEWAY_RELAYER`, requires +the deploy key to be the admin (the only caller of `setRelayer`), calls `setRelayer` right after +the proxy is deployed, and asserts the relayer afterwards. `initialize` is unchanged so the +deterministic proxy address is unchanged. The gateway constructor now rejects a zero owner, since +a mis-set `ADMIN` would leave no key able to arm a fresh proxy; runtime size is unaffected. No +file in this package changed. + +Files: `evm/script/DeployIntentGateway.s.sol`, `evm/src/apps/IntentGatewayV2.sol`, +`evm/tests/foundry/IntentGatewayV2Test.sol`, `docs/ai/Flow.md`. diff --git a/sdk/packages/core/docs/ai/changelog/2026-09-05-gateway-initialize-refused-on-any-proxy-already-at-a-version.md b/sdk/packages/core/docs/ai/changelog/2026-09-05-gateway-initialize-refused-on-any-proxy-already-at-a-version.md new file mode 100644 index 000000000..888f9685d --- /dev/null +++ b/sdk/packages/core/docs/ai/changelog/2026-09-05-gateway-initialize-refused-on-any-proxy-already-at-a-version.md @@ -0,0 +1,12 @@ +# 2026-09-05 — Gateway `initialize` refused on any proxy already at a version + +`initialize` carries an `onlyFresh` modifier that reverts with `InvalidInitialization` unless the +`Initializable` version is 0. Without it, an upgrade that installed this implementation on a +version-1 proxy without running `migrate` would leave `initialize`, which has no caller +restriction, open to anyone until governance caught up. Now the host-only `migrate` is the only +way up for such a proxy. `testInitializeRefusedOnLegacyProxy` plays it. The interface NatSpec for +`migrate` says so. + +Files: `contracts/apps/IntentGatewayV2.sol`, `docs/ai/ChangeLog.md`, `docs/ai/Decisions.md`, +`docs/ai/Flow.md`. Outside the package: `evm/src/apps/IntentGatewayV2.sol`, +`evm/tests/foundry/IntentGatewayV2Test.sol`. diff --git a/sdk/packages/core/docs/ai/changelog/2026-09-05-gateway-initialize-takes-the-relayer-and-lands-at-version-2.md b/sdk/packages/core/docs/ai/changelog/2026-09-05-gateway-initialize-takes-the-relayer-and-lands-at-version-2.md new file mode 100644 index 000000000..eb427832f --- /dev/null +++ b/sdk/packages/core/docs/ai/changelog/2026-09-05-gateway-initialize-takes-the-relayer-and-lands-at-version-2.md @@ -0,0 +1,38 @@ +# 2026-09-05 — Gateway `initialize` takes the relayer and lands at version 2; `migrate` for older proxies + +`IntentGatewayV2.initialize(Params, bytes[] peerChains, address relayer)` now arms the relayer +gate from the init data and runs under `reinitializer(VERSION)` with `VERSION = 2`, so a fresh +proxy comes out armed and at the version of the code it runs. `migrate(address relayer)`, host-only +and under the same `reinitializer(VERSION)`, is for proxies deployed before this implementation: +it arms them and takes them from 1 to 2, and reverts on a proxy `initialize` already took there. +`setRelayer` stays a plain host-only rotation that leaves the version alone; all three write +through `_setRelayer` in `ExtrinsicIntents`. The next implementation that needs a migration bumps +`VERSION` once. The interface documents `migrate` and `version` accordingly. + +`DeployIntentGateway.s.sol` always deploys the implementation and the solver account, deploys the +proxy only where `INTENT_GATEWAY_V2` is absent from the chain's config, reads the relayer from +`GATEWAY_RELAYER` for the init data, and records `INTENT_GATEWAY_V2_IMPL`. The relayer is now part +of what fixes a new proxy's address, as the implementation address already was. + +The reinitializer cost more than the 71 bytes of EIP-170 headroom, and every gateway getter and +event has a consumer in `sdk`, `simplex` or the indexer, so the room came from deduplicating +internal code with no behaviour change: `_sendValue` in `IntentsBase` for the native +send-and-check (the same-chain fill loop keeps its inline copy, being at the via-ir stack limit), +`_splitSurplus` moved to `IntentsBase` and used by the cross-chain fill, `_withdrawalBody` and +`_postToSource` in `ExtrinsicIntents` for the escrow messages, and `placeOrder` reusing its +`feeToken` read and hashing the order once. + +Tests: every `initialize` call gains the relayer argument, `address(0)` outside `setUp` so those +gateways stay open as before; `testInitializeArmsTheGate` pins the events and version; the +`migrate` tests run on a proxy written back to version 1 through the `Initializable` slot, since +this implementation cannot produce one; the live mainnet-fork upgrade migrates the real one. +`HostManager.onAccept` gained NatSpec. + +Files: `contracts/apps/IntentGatewayV2.sol`, `docs/ai/ChangeLog.md`, `docs/ai/Decisions.md`, +`docs/ai/Flow.md`. Outside the package: `evm/src/apps/IntentGatewayV2.sol`, +`evm/src/apps/intentsv2/ExtrinsicIntents.sol`, `evm/src/apps/intentsv2/IntrinsicIntents.sol`, +`evm/src/apps/intentsv2/IntentsBase.sol`, `evm/src/core/HostManager.sol`, +`evm/script/DeployIntentGateway.s.sol`, `evm/tests/foundry/IntentGatewayV2Test.sol`, +`evm/tests/foundry/IntentGatewayV2SameChainTest.sol`, +`evm/tests/foundry/IntrinsicIntentsReentrancyTest.sol`, +`evm/tests/foundry/account/SolverAccountTest.sol`. diff --git a/sdk/packages/core/docs/ai/changelog/2026-09-05-hostmanager-admin-is-the-governance-relayer-gateway-setrelayer.md b/sdk/packages/core/docs/ai/changelog/2026-09-05-hostmanager-admin-is-the-governance-relayer-gateway-setrelayer.md new file mode 100644 index 000000000..a89b4e175 --- /dev/null +++ b/sdk/packages/core/docs/ai/changelog/2026-09-05-hostmanager-admin-is-the-governance-relayer-gateway-setrelayer.md @@ -0,0 +1,39 @@ +# 2026-09-05 — `HostManager` admin is the governance relayer; gateway `setRelayer` is host-only + +`HostManager` no longer has a separate relayer. Its `admin` survives initialization and is the only +relayer whose `onAccept` deliveries are accepted, so `_relayer`, `relayer()` and `setRelayer` are +gone. `setIsmpHost` is now `init`: admin-only, one-shot, and unnecessary when the host is passed to +the constructor. A new `SetAdmin` action (variant `2`, body `abi.encode(address)`) rotates the admin +through governance, delivered by the outgoing admin like every other message; zero is refused there +and in the constructor, since a manager with no admin could never be reached again. +`pallet-ismp-host-executive` gains `set_host_manager_admin`, which dispatches that action to the +manager on record, and `ismp-abi` gains `encode_set_admin`. `evm/rust/abi/HostManager.json` was +regenerated; it had not been since before the relayer gate. + +On the gateway, `setRelayer` moved from `IntentGatewayV2` to `ExtrinsicIntents` and is `onlyHost`, +so `_owner` can no longer rotate the relayer; the host reaches it only as `UpgradeContract` +migration calldata, and nothing else writes the relayer. `initialize` is unchanged, so a fresh +proxy starts with no relayer, and an unset relayer now gates nothing: the governance upgrade that +arms it has to be delivered first. `setRelayer(address(0))` reopens the gate rather than closing +it. `DeployIntentGateway.s.sol` no longer reads `GATEWAY_RELAYER` or calls `setRelayer`. +`DeployIsmp.s.sol` constructs the host before the manager and binds the manager at construction, +with `GOVERNANCE_RELAYER` as its admin; `DeployHostManager.s.sol` does the same for a replacement +manager. Both contracts expose `relayer()`, and the gateway exposes `version()`, the +`Initializable` version the proxy has reached (1 after `initialize`, higher only after a +`reinitializer` migration), so tooling can tell which relayer a deployment accepts and whether it +has the gate at all (a revert means it predates it). The gateway's `_relayer` and `_instances` +became internal to pay for the getters under EIP-170; `instance(bytes)` already covered the +latter. The interface in this package declares the two getters and updates its `setRelayer` +NatSpec. + +Files: `contracts/apps/IntentGatewayV2.sol`, `package.json`, `docs/ai/ChangeLog.md`, +`docs/ai/Decisions.md`, `docs/ai/Flow.md`. Outside the package: `evm/src/core/HostManager.sol`, +`evm/src/apps/IntentGatewayV2.sol`, `evm/src/apps/intentsv2/ExtrinsicIntents.sol`, +`evm/script/DeployIsmp.s.sol`, `evm/script/DeployHostManager.s.sol`, +`evm/script/DeployIntentGateway.s.sol`, `evm/tron/migrations/2_deploy_ismp.js`, +`evm/tron/README.md`, `evm/rust/src/host_params.rs`, `evm/rust/abi/HostManager.json`, +`evm/tests/foundry/HostManagerTest.sol`, `evm/tests/foundry/IntentGatewayV2Test.sol`, +`evm/tests/foundry/IntentGatewayV2SameChainTest.sol`, the foundry test setups that construct a +`HostManager` or initialize a gateway, `evm/tests/rust/src/tests/utils.rs`, +`evm/tests/rust/src/tests/host_manager.rs`, `modules/pallets/host-executive/src/lib.rs`, +`modules/pallets/testsuite/src/tests/pallet_ismp_host_executive.rs`. diff --git a/sdk/packages/core/docs/ai/changelog/2026-09-05-hostmanager-onaccept-checks-caller-and-relayer-through-one.md b/sdk/packages/core/docs/ai/changelog/2026-09-05-hostmanager-onaccept-checks-caller-and-relayer-through-one.md new file mode 100644 index 000000000..5d58e0b21 --- /dev/null +++ b/sdk/packages/core/docs/ai/changelog/2026-09-05-hostmanager-onaccept-checks-caller-and-relayer-through-one.md @@ -0,0 +1,11 @@ +# 2026-09-05 — `HostManager.onAccept` checks caller and relayer through one modifier + +`restrict` takes the address to check and the address it must equal, and `onAccept` carries it +twice: `restrict(msg.sender, _params.host)` and `restrict(incoming.relayer, _params.admin)`. +Both failures revert with `UnauthorizedAction`; the separate `UnauthorizedRelayer` error is gone, +and the Foundry and Rust tests expect `UnauthorizedAction` for a wrong relayer. `init` uses the +same two-argument form. `HostManager.json` regenerated. + +Files: `docs/ai/ChangeLog.md`. Outside the package: `evm/src/core/HostManager.sol`, +`evm/tests/foundry/HostManagerTest.sol`, `evm/tests/rust/src/tests/host_manager.rs`, +`evm/rust/abi/HostManager.json`. diff --git a/sdk/packages/core/docs/ai/changelog/2026-09-05-relayer-gate-lifted-out-of-hyperfungibletoken-into-bridgetoken.md b/sdk/packages/core/docs/ai/changelog/2026-09-05-relayer-gate-lifted-out-of-hyperfungibletoken-into-bridgetoken.md new file mode 100644 index 000000000..60c332443 --- /dev/null +++ b/sdk/packages/core/docs/ai/changelog/2026-09-05-relayer-gate-lifted-out-of-hyperfungibletoken-into-bridgetoken.md @@ -0,0 +1,14 @@ +# 2026-09-05 — Relayer gate lifted out of `HyperFungibleToken` into `BridgeToken` + +`HyperFungibleToken` no longer carries `_relayer`, `relayer()`, `setRelayer`, `RelayerUpdated`, +`UnauthorizedRelayer` or the `_checkRelayer` hook; `onAccept` and `onPostRequestTimeout` are +`public virtual` so a token can gate deliveries before calling `super`. `BridgeToken` owns all of +that now: it overrides both callbacks with `onlyHost`, then `_checkRelayer`, then `super`, and +`_checkRelayer` fails closed as before. The base-token relayer tests in +`HyperFungibleTokenTest.sol` are gone with the feature; `BridgeTokenTest.t.sol` references the +error and event on `BridgeToken`. Storage of non-upgradeable `HyperFungibleToken` deployments +shifts by one slot, which only matters for a contract that reads it by slot. + +Files: `contracts/apps/HyperFungibleToken.sol`, `docs/ai/ChangeLog.md`, `docs/ai/Decisions.md`, +`docs/ai/Flow.md`. Outside the package: `evm/src/apps/BridgeToken.sol`, +`evm/tests/foundry/HyperFungibleTokenTest.sol`, `evm/tests/foundry/BridgeTokenTest.t.sol`. diff --git a/sdk/packages/core/docs/ai/changelog/2026-09-05-upgradecontract-becomes-execute-one-governance-door-to-the.md b/sdk/packages/core/docs/ai/changelog/2026-09-05-upgradecontract-becomes-execute-one-governance-door-to-the.md new file mode 100644 index 000000000..76da26476 --- /dev/null +++ b/sdk/packages/core/docs/ai/changelog/2026-09-05-upgradecontract-becomes-execute-one-governance-door-to-the.md @@ -0,0 +1,25 @@ +# 2026-09-05 — `UpgradeContract` becomes `Execute`, one governance door to the gateway's host-only functions + +`RequestKind.Execute` (discriminator 5, the old `UpgradeContract` slot) delegatecalls the proxy's +current implementation with `body[1:]` as calldata, the host still `msg.sender`. `upgradeToAndCall` +is now a host-only function on `ExtrinsicIntents` wrapping `ERC1967Utils.upgradeToAndCall`, so an +upgrade is `Execute` carrying that call, and a relayer rotation is `Execute` carrying `setRelayer` +with no implementation change. Reverts inside the call bubble out. The `(address, bytes)` body of +the old action selects no function on this implementation and reverts, and the live +implementation still reads it as `UpgradeContract`, so the pallet keeps `upgrade_gateway` for the +one upgrade that installs this code on a chain and gains `execute_on_gateway(data)` for +everything after, weighed as `upgrade_gateway`. The interface declares `upgradeToAndCall`. + +Tests: `_upgradeRequest` builds `Execute` + `upgradeToAndCall`, so the existing upgrade tests run +through the new path; new `testExecuteRotatesRelayerWithoutUpgrade`, +`testExecuteRejectsNonHyperbridgeSource`, `testExecuteBubblesReverts`, +`testLegacyUpgradeBodyIsRefused`, `testUpgradeToAndCallRejectsEveryoneButHost`; the live-fork +test migrates the mainnet proxy with the legacy body and then rotates it through `Execute`. +Pallet tests pin the `Execute` encoding and the new extrinsic. + +Files: `contracts/apps/IntentGatewayV2.sol`, `docs/ai/ChangeLog.md`, `docs/ai/Decisions.md`, +`docs/ai/Flow.md`. Outside the package: `evm/src/apps/intentsv2/IntentsBase.sol`, +`evm/src/apps/intentsv2/ExtrinsicIntents.sol`, `evm/src/apps/IntentGatewayV2.sol`, +`evm/script/DeployIntentGateway.s.sol`, `evm/script/DeployIntentGatewayImpl.s.sol`, +`evm/tests/foundry/IntentGatewayV2Test.sol`, `modules/pallets/intents-coprocessor/src/types.rs`, +`modules/pallets/intents-coprocessor/src/lib.rs`, `modules/pallets/intents-coprocessor/src/tests.rs`. diff --git a/sdk/packages/core/docs/ai/changelog/2026-09-07-live-fork-gateway-test-asserts-the-armed-mainnet-proxy-instead.md b/sdk/packages/core/docs/ai/changelog/2026-09-07-live-fork-gateway-test-asserts-the-armed-mainnet-proxy-instead.md new file mode 100644 index 000000000..92eddd78a --- /dev/null +++ b/sdk/packages/core/docs/ai/changelog/2026-09-07-live-fork-gateway-test-asserts-the-armed-mainnet-proxy-instead.md @@ -0,0 +1,11 @@ +# 2026-09-07 — Live-fork gateway test asserts the armed mainnet proxy instead of replaying its migration + +The mainnet IntentGatewayV2 proxy has been upgraded and armed (relayer set, version 2), so the +fork test that rehearsed that migration failed on its first precondition. It is replaced by +`testLiveProxyIsArmedAndGovernedOnlyByItsRelayer`: the live proxy reads armed and migrated with +the relayer packed behind an unset `_paused` in slot 13, refuses `initialize` and a second +`migrate`, refuses an `Execute` upgrade from anyone but its relayer, installs it with every +readable piece of state intact when the relayer delivers it, and rotates through `Execute`, after +which the previous relayer is out. + +Files: `evm/tests/foundry/IntentGatewayV2Test.sol`, `docs/ai/Flow.md`. diff --git a/sdk/packages/core/docs/ai/changelog/2026-09-07-simplexpaymaster-governance-deliveries-gated-on-one-relayer.md b/sdk/packages/core/docs/ai/changelog/2026-09-07-simplexpaymaster-governance-deliveries-gated-on-one-relayer.md new file mode 100644 index 000000000..d102834cf --- /dev/null +++ b/sdk/packages/core/docs/ai/changelog/2026-09-07-simplexpaymaster-governance-deliveries-gated-on-one-relayer.md @@ -0,0 +1,13 @@ +# 2026-09-07 — SimplexPaymaster governance deliveries gated on one relayer; APPROVE mode removed + +`SimplexPaymaster.onAccept` checks `incoming.relayer` against a new `_relayer` (slot 8, gap 48) +after `onlyHost` and before the source check. Armed by a fifth `initialize` argument, by the +host-only `migrate(relayer)` as upgrade init data (version 1 to 2, `onlyFresh` on `initialize`), or +rotated by `RequestKind.SetRelayer`; unset means open, zero can never be set by governance. Mode +byte `0x01` is refused. Deploy script reads `GOVERNANCE_RELAYER`; new impl-only deploy script for the +governance upgrade of the live proxies. Pallet extrinsic `set_paymaster_relayer` added. + +Files: `evm/src/utils/SimplexPaymaster.sol`, `evm/script/DeploySimplexPaymaster.s.sol`, +`evm/script/DeploySimplexPaymasterImpl.s.sol`, `evm/script/SimplexPaymasterPermit2Probe.s.sol`, +`evm/tests/foundry/SimplexPaymaster*.t.sol`, `modules/pallets/intents-coprocessor/src/*.rs`, +`docs/ai/Flow.md`. diff --git a/sdk/packages/core/docs/ai/changelog/2026-09-10-docs-ai-split-into-one-file-per-entry.md b/sdk/packages/core/docs/ai/changelog/2026-09-10-docs-ai-split-into-one-file-per-entry.md new file mode 100644 index 000000000..2042b41e0 --- /dev/null +++ b/sdk/packages/core/docs/ai/changelog/2026-09-10-docs-ai-split-into-one-file-per-entry.md @@ -0,0 +1,20 @@ +# 2026-09-10 — docs/ai split into one file per entry + +`ChangeLog.md`, `Decisions.md` and `Flow.md` are gone. Their entries now live as individual +files under `changelog/`, `decisions/` and `flows/` — 14, 18 and 4 of them in this package. +The prose is unchanged; the only edit is that each entry's headings moved up one level, from +`##` to `#`. That was checked by demoting every new file back a level and comparing it to the +text it came from, byte for byte. + +Why. The three files were append-only with newest entries on top, so any two concurrent PRs +wrote the same line and GitHub marked both conflicted. #1251 declared `merge=union` for the two +log files, which does resolve the collision locally — but GitHub ignores merge drivers when it +computes a PR's mergeability, so the PRs stayed blocked however clean the local merge was. +A file that only one PR creates cannot collide at all, which is the same reason changesets +keeps one file per change rather than one shared changelog. + +Flows stay one file per flow and are still edited in place, so two PRs revising the same flow +still conflict — correctly, because that means they disagree about how the code runs. + +Files: docs/ai/{ChangeLog,Decisions,Flow}.md (removed), docs/ai/README.md (new), +docs/ai/{changelog,decisions,flows}/*.md (new). Repo root: CLAUDE.md, .gitattributes (removed). diff --git a/sdk/packages/core/docs/ai/decisions/2026-08-27-declarations-here-are-kept-identical-to-intentsbase-not-merely.md b/sdk/packages/core/docs/ai/decisions/2026-08-27-declarations-here-are-kept-identical-to-intentsbase-not-merely.md new file mode 100644 index 000000000..35aaf5673 --- /dev/null +++ b/sdk/packages/core/docs/ai/decisions/2026-08-27-declarations-here-are-kept-identical-to-intentsbase-not-merely.md @@ -0,0 +1,13 @@ +# 2026-08-27 — Declarations here are kept identical to `IntentsBase`, not merely compatible + +Chosen: every event and error in `IIntentGatewayV2` matches `evm/src/apps/intentsv2/IntentsBase.sol` +exactly — same name, same parameter types, same `indexed` flags. + +Nothing compiles against these declarations (`SolverAccount.sol` uses the interface only for two +function selectors), so a mismatch produces no build error anywhere in the repo. That is exactly +why the drift went unnoticed through several signature changes. The only thing that can catch it +is the rule that the two lists are equal, which is cheap to check by diffing them. + +Alternative rejected — declare only the subset integrators are expected to use. It sounds tidier, +but it makes "missing from the interface" ambiguous: you cannot tell a deliberate omission from +another four events nobody updated. diff --git a/sdk/packages/core/docs/ai/decisions/2026-08-27-newdeploymentadded-was-renamed-rather-than-kept-for.md b/sdk/packages/core/docs/ai/decisions/2026-08-27-newdeploymentadded-was-renamed-rather-than-kept-for.md new file mode 100644 index 000000000..198fbdd39 --- /dev/null +++ b/sdk/packages/core/docs/ai/decisions/2026-08-27-newdeploymentadded-was-renamed-rather-than-kept-for.md @@ -0,0 +1,12 @@ +# 2026-08-27 — `NewDeploymentAdded` was renamed rather than kept for compatibility + +Chosen: the interface's `NewDeploymentAdded(bytes stateMachineId, address gateway)` was replaced +with the gateway's actual `DeploymentAdded(string chain, address gateway)`. + +Renaming a declaration in a published interface normally breaks consumers. It does not here, +because there is nothing to break: no deployed gateway has ever emitted `NewDeploymentAdded`, so +anyone filtering on that topic has been matching zero logs. Keeping it would preserve a name that +only ever produces silence, next to the real one. + +Alternative rejected — declare both. The interface would then advertise an event the contract +cannot emit, which is the state that caused this in the first place. diff --git a/sdk/packages/core/docs/ai/decisions/2026-09-03-hostmanager-deliveries-are-gated-too-and-that-does-not-touch.md b/sdk/packages/core/docs/ai/decisions/2026-09-03-hostmanager-deliveries-are-gated-too-and-that-does-not-touch.md new file mode 100644 index 000000000..0ecb3b5b6 --- /dev/null +++ b/sdk/packages/core/docs/ai/decisions/2026-09-03-hostmanager-deliveries-are-gated-too-and-that-does-not-touch.md @@ -0,0 +1,25 @@ +# 2026-09-03 — `HostManager` deliveries are gated too, and that does not touch permissionless relaying + +Chosen: `HostManager.onAccept` refuses any relayer but the one the host admin set, zero included. + +The app gates check an address the host reports, and the host takes it from its handler. The +handler is a host parameter that a `SetHostParam` governance message can replace, and until now +any relayer could deliver that message once its consensus proof verified. Under a forged consensus +an attacker would swap in a handler that reports the whitelisted relayer on every message, and the +app gates would pass. Gating the HostManager closes that route. + +It does not weaken the open-relayer model because the HostManager never carries user traffic. Its +first check already rejects anything not sourced from Hyperbridge, so the only messages it ever +sees are Polytope's own `Withdraw` and `SetHostParam`. Third-party relayers keep delivering every +ordinary message to every ordinary app exactly as before. + +Alternative rejected — a delay on host parameter changes with the admin freeze as a veto. Keeps +delivery open but needs someone watching every chain, and adds latency to legitimate governance. + +Alternative rejected — move handler and consensus-client changes to the host admin key. Simplest, +but it takes that power away from cross-chain governance rather than protecting it. + +The setter authority is the host admin rather than the HostManager's own admin, which is zeroed +after `setIsmpHost`. The host admin is the key that can already freeze the host and reset its +consensus state, so no new trust is introduced. Zero is checked explicitly here since there is no +bytecode pressure; on the gateway the same guarantee comes from the handler never forwarding zero. diff --git a/sdk/packages/core/docs/ai/decisions/2026-09-03-hyperfungibletoken-opts-in-to-the-relayer-gate-bridgetoken.md b/sdk/packages/core/docs/ai/decisions/2026-09-03-hyperfungibletoken-opts-in-to-the-relayer-gate-bridgetoken.md new file mode 100644 index 000000000..58f49513c --- /dev/null +++ b/sdk/packages/core/docs/ai/decisions/2026-09-03-hyperfungibletoken-opts-in-to-the-relayer-gate-bridgetoken.md @@ -0,0 +1,18 @@ +# 2026-09-03 — `HyperFungibleToken` opts in to the relayer gate; `BridgeToken` fails closed + +Chosen: the base token's `_checkRelayer` only rejects when a relayer has been set, and +`BridgeToken` overrides it so that an unset relayer matches nobody. + +`HyperFungibleToken` is a library contract that third parties deploy from this package. Failing +closed there would leave every token deployed without a `setRelayer` call unable to receive +anything, with no compile-time signal. The BRIDGE token is ours, its supply is backed by the nexus +escrow, and a forged mint is exactly the attack the gate exists for, so it takes the strict +semantics of the intent gateway. The two behaviours live in one virtual function so the difference +is visible in one place rather than spread through the callbacks. + +Alternative rejected — override `onAccept` in `BridgeToken`. It is `external`, so an override +cannot call the parent body and would have to duplicate the mint logic. + +Alternative rejected — make the base fail closed and bump the package major. Correct in principle, +but the request was for the bridge token, and the base can be tightened later once every +deployment from this package has a relayer set. diff --git a/sdk/packages/core/docs/ai/decisions/2026-09-03-ihyperfungibletoken-does-not-declare-setrelayer-or-relayer.md b/sdk/packages/core/docs/ai/decisions/2026-09-03-ihyperfungibletoken-does-not-declare-setrelayer-or-relayer.md new file mode 100644 index 000000000..7f15a7c01 --- /dev/null +++ b/sdk/packages/core/docs/ai/decisions/2026-09-03-ihyperfungibletoken-does-not-declare-setrelayer-or-relayer.md @@ -0,0 +1,5 @@ +# 2026-09-03 — `IHyperFungibleToken` does not declare `setRelayer` or `relayer` + +`supportsInterface` returns true for `type(IHyperFungibleToken).interfaceId`. Extending the +interface changes that id, so existing deployments would stop matching it and new ones would report +an id integrators have not seen. The relayer functions are reachable through the contract type. diff --git a/sdk/packages/core/docs/ai/decisions/2026-09-03-onpostrequesttimeout-is-gated-on-the-token-unlike-the-gateway.md b/sdk/packages/core/docs/ai/decisions/2026-09-03-onpostrequesttimeout-is-gated-on-the-token-unlike-the-gateway.md new file mode 100644 index 000000000..939576263 --- /dev/null +++ b/sdk/packages/core/docs/ai/decisions/2026-09-03-onpostrequesttimeout-is-gated-on-the-token-unlike-the-gateway.md @@ -0,0 +1,5 @@ +# 2026-09-03 — `onPostRequestTimeout` is gated on the token, unlike the gateway + +Timeouts mint a refund to the original sender, so a forged timeout proof mints. The intent gateway +does not gate its timeout callbacks because `HyperApp`'s defaults revert and it dispatches with no +timeout. diff --git a/sdk/packages/core/docs/ai/decisions/2026-09-03-setrelayer-accepts-the-host-not-address-this.md b/sdk/packages/core/docs/ai/decisions/2026-09-03-setrelayer-accepts-the-host-not-address-this.md new file mode 100644 index 000000000..127ea7ed7 --- /dev/null +++ b/sdk/packages/core/docs/ai/decisions/2026-09-03-setrelayer-accepts-the-host-not-address-this.md @@ -0,0 +1,11 @@ +# 2026-09-03 — `setRelayer` accepts the host, not `address(this)` + +Chosen: `msg.sender == _owner || msg.sender == host()`. + +The first draft allowed `address(this)`, expecting `upgradeToAndCall` to call the proxy. It does +not: it delegatecalls the migration calldata, so `msg.sender` is still the host that invoked +`onAccept`. The fork test against the live mainnet proxy failed with `Unauthorized` and exposed it. +Accepting the host adds no trust: the host already gates every callback, and it never calls the +gateway with any selector other than the `IApp` callbacks, so the branch is reachable only from +governance migration calldata that has already passed the hyperbridge-source check and the relayer +gate. diff --git a/sdk/packages/core/docs/ai/decisions/2026-09-03-the-paused-getter-was-dropped-to-stay-under-eip-170.md b/sdk/packages/core/docs/ai/decisions/2026-09-03-the-paused-getter-was-dropped-to-stay-under-eip-170.md new file mode 100644 index 000000000..643c884f5 --- /dev/null +++ b/sdk/packages/core/docs/ai/decisions/2026-09-03-the-paused-getter-was-dropped-to-stay-under-eip-170.md @@ -0,0 +1,8 @@ +# 2026-09-03 — The `_paused` getter was dropped to stay under EIP-170 + +Chosen: `bool internal _paused`. The variable stays in slot 13 so the layout is unchanged. + +The gateway compiled to 10 bytes over the limit with the new storage, setter, event and checks. +Nothing reads `_paused()` anywhere in the repo, so removing its getter was the only free saving. +The revert reuses `Unauthorized()` instead of a dedicated error for the same reason; the second +selector cost 15 bytes. diff --git a/sdk/packages/core/docs/ai/decisions/2026-09-03-the-relayer-is-a-separate-storage-variable-not-a-params-field.md b/sdk/packages/core/docs/ai/decisions/2026-09-03-the-relayer-is-a-separate-storage-variable-not-a-params-field.md new file mode 100644 index 000000000..ada069564 --- /dev/null +++ b/sdk/packages/core/docs/ai/decisions/2026-09-03-the-relayer-is-a-separate-storage-variable-not-a-params-field.md @@ -0,0 +1,14 @@ +# 2026-09-03 — The relayer is a separate storage variable, not a `Params` field + +Chosen: `address _relayer` appended after `_paused` in `IntentsBase`, set through its own +`setRelayer` call. + +`Params` occupies slots 4 to 8 and `_orders` starts at slot 9. Adding a field to the struct would +push every mapping behind it and corrupt escrow on the live proxy. Reusing `UpdateParams` was +therefore never available, quite apart from it being a Hyperbridge-relayed message: the whole point +is to hold even if Hyperbridge's consensus is compromised, so the setter must not depend on it. + +Alternative rejected — a new `RequestKind` for governance to set the relayer. It needs the +`intents-coprocessor` pallet mirrored, and it is still a cross-chain message. Governance can already +rotate the relayer through `UpgradeContract` migration calldata when it wants to; the owner path +covers the case where it cannot. diff --git a/sdk/packages/core/docs/ai/decisions/2026-09-03-zero-relayer-fails-closed-and-the-upgrade-arms-it-atomically.md b/sdk/packages/core/docs/ai/decisions/2026-09-03-zero-relayer-fails-closed-and-the-upgrade-arms-it-atomically.md new file mode 100644 index 000000000..8c6d868f9 --- /dev/null +++ b/sdk/packages/core/docs/ai/decisions/2026-09-03-zero-relayer-fails-closed-and-the-upgrade-arms-it-atomically.md @@ -0,0 +1,14 @@ +# 2026-09-03 — Zero relayer fails closed, and the upgrade arms it atomically + +Superseded on 2026-09-05: an unset relayer gates nothing, see above. + +Chosen: an unset `_relayer` matches no delivery, because the handler always forwards a real +`msg.sender`. The rollout sets it in the upgrade transaction via `upgradeToAndCall` calldata. + +Alternative rejected — treat zero as "allowlist disabled". Convenient for tests and a forgotten +init, but it makes the safe state opt-in, and a fresh proxy would run unguarded until someone +noticed. A refused delivery costs nothing: the host deletes the receipt and the authorised relayer +can resubmit. + +Alternative rejected — a `reinitializer(2)` taking the relayer. It is one-shot, so rotation would +need the setter anyway, and it does not solve who may call it. diff --git a/sdk/packages/core/docs/ai/decisions/2026-09-05-execute-delegatecalls-the-implementation-an-upgrade-is-one-such.md b/sdk/packages/core/docs/ai/decisions/2026-09-05-execute-delegatecalls-the-implementation-an-upgrade-is-one-such.md new file mode 100644 index 000000000..fc2e637fb --- /dev/null +++ b/sdk/packages/core/docs/ai/decisions/2026-09-05-execute-delegatecalls-the-implementation-an-upgrade-is-one-such.md @@ -0,0 +1,22 @@ +# 2026-09-05 — `Execute` delegatecalls the implementation; an upgrade is one such call + +Chosen: one governance action that delegatecalls the proxy's current implementation with the +body as calldata. `msg.sender` stays the host, so the existing `onlyHost` guards are the whole +access model, and `upgradeToAndCall` becomes an ordinary host-only function. A rotation no longer +has to be dressed up as an upgrade to the same implementation. + +Alternative rejected — an external self-call, `address(this).call(data)`. It makes the gateway +its own caller, so every governance target would need an `onlySelf` guard in place of +`onlyHost`, and the gateway as caller holds the escrow, which the host as caller does not. + +Alternative rejected — a `SetRelayer` action next to `UpgradeContract`. One more variant to +mirror in the pallet for one function; `Execute` covers it and anything host-only added later. + +Kept — `UpgradeContract` in the pallet, under the same discriminator. The live implementation +understands only that body, so it is the one message that can install this code; afterwards it +selects no function here and reverts, which `testLegacyUpgradeBodyIsRefused` pins so the mistake +is loud rather than silent. + +Cost accepted — `Execute` can call any function of the implementation with the host as sender, +not only the host-only ones. Governance can already install arbitrary code through an upgrade, +so this widens nothing. diff --git a/sdk/packages/core/docs/ai/decisions/2026-09-05-gateway-setrelayer-is-host-only-and-the-only-writer-unset-means.md b/sdk/packages/core/docs/ai/decisions/2026-09-05-gateway-setrelayer-is-host-only-and-the-only-writer-unset-means.md new file mode 100644 index 000000000..bd4d5f636 --- /dev/null +++ b/sdk/packages/core/docs/ai/decisions/2026-09-05-gateway-setrelayer-is-host-only-and-the-only-writer-unset-means.md @@ -0,0 +1,21 @@ +# 2026-09-05 — Gateway `setRelayer` is host-only and the only writer; unset means open + +Chosen: `setRelayer` lives in `ExtrinsicIntents` behind `onlyHost`, `initialize` does not touch the +relayer, and `_checkRelayer` passes every delivery while `_relayer` is zero. This supersedes the +2026-09-03 decision below that zero fails closed. + +The owner branch existed so a fresh proxy could be armed locally; it also let the owner key +redirect every cross-chain delivery without a governance message. Removing it leaves the host as +the only caller, reachable solely from `UpgradeContract` migration calldata. Carrying the relayer +in the init data was tried and rejected: the relayer is operational state that governance owns, +not part of what fixes a proxy's address. With no local or init-time arming left, the message that +arms a fresh proxy is a governance delivery, so the unarmed proxy has to accept it; an unset +relayer therefore gates nothing, and `setRelayer(address(0))` reopens the gate. The window is the +one between deployment and the `upgrade_gateway` that arms it, and closing it is governance's +first act on a new chain. + +Alternative rejected — a `RequestKind.SetRelayer` governance action instead of the host-only +function. Simpler to invoke, but it needs the `intents-coprocessor` pallet mirrored, and the +migration-calldata route already exists and is tested. + +`_owner` stays as the placeholder it was before the relayer work, with nothing to do. diff --git a/sdk/packages/core/docs/ai/decisions/2026-09-05-init-is-one-shot-and-the-evm-deploy-scripts-do-not-call-it.md b/sdk/packages/core/docs/ai/decisions/2026-09-05-init-is-one-shot-and-the-evm-deploy-scripts-do-not-call-it.md new file mode 100644 index 000000000..b36e88461 --- /dev/null +++ b/sdk/packages/core/docs/ai/decisions/2026-09-05-init-is-one-shot-and-the-evm-deploy-scripts-do-not-call-it.md @@ -0,0 +1,15 @@ +# 2026-09-05 — `init` is one-shot, and the EVM deploy scripts do not call it + +Chosen: `init` binds the host only while it is unset; a manager constructed with its host set is +bound already. + +With the admin surviving `init`, a repeatable `init` would let the governance relayer key re-point +the host and cut it off from its own governance. One-shot closes that. `DeployIsmp.s.sol` +therefore constructs the host first, which works because `EvmHost` takes its params in +`initialize` rather than its constructor, and passes the host to the manager's constructor, so the +relayer key never has to sign a deploy transaction. `TronHost` takes its params in the constructor, +so the Tron migration keeps the `init` route with the deployer as admin until governance rotates +it. + +Alternative rejected — precompute the host's CREATE2 address in the script. Works, but couples the +script to the CREATE2 deployer and the host's creation code for no gain over reordering. diff --git a/sdk/packages/core/docs/ai/decisions/2026-09-05-initialize-only-on-a-bare-proxy-rather-than-a-version-check-in.md b/sdk/packages/core/docs/ai/decisions/2026-09-05-initialize-only-on-a-bare-proxy-rather-than-a-version-check-in.md new file mode 100644 index 000000000..6027ce645 --- /dev/null +++ b/sdk/packages/core/docs/ai/decisions/2026-09-05-initialize-only-on-a-bare-proxy-rather-than-a-version-check-in.md @@ -0,0 +1,16 @@ +# 2026-09-05 — `initialize` only on a bare proxy, rather than a version check in the upgrade path + +Chosen: `initialize` is refused unless the proxy is at version 0. `migrate` is the only entry to +`VERSION` for a proxy that already has one, and it is host-only. + +The hole both options close: `initialize` has no caller restriction, because a bare proxy is +initialized atomically in its constructor. An `UpgradeContract` that installs this implementation +on a version-1 proxy without `migrate` calldata would leave the proxy below `VERSION` with +`initialize` callable by anyone, who could then set the params and the relayer. + +Alternative rejected — require the version to have risen after `upgradeToAndCall`. It forces +every upgrade to carry a migration, so a same-implementation upgrade carrying only `setRelayer`, +the rotation path, would be refused; scoping the check to implementation changes fixes that but +adds a branch and an implementation-slot read to every upgrade. And it cannot protect the first +upgrade off the live pre-gate implementation, whose `onAccept` has no such check. The `initialize` +guard covers that case too, since it is the new implementation's code that runs `initialize`. diff --git a/sdk/packages/core/docs/ai/decisions/2026-09-05-relayer-on-both-contracts-version-on-the-gateway-from.md b/sdk/packages/core/docs/ai/decisions/2026-09-05-relayer-on-both-contracts-version-on-the-gateway-from.md new file mode 100644 index 000000000..ce3c90ce6 --- /dev/null +++ b/sdk/packages/core/docs/ai/decisions/2026-09-05-relayer-on-both-contracts-version-on-the-gateway-from.md @@ -0,0 +1,17 @@ +# 2026-09-05 — `relayer()` on both contracts, `version()` on the gateway from `Initializable` + +Chosen: the manager and the gateway both answer `relayer()`. The gateway's `version()` returns +`_getInitializedVersion()` from OpenZeppelin's `Initializable`: 1 once `initialize` has run, and +raised only by a `reinitializer(n)` migration, so it tracks the storage-level migrations a proxy +has been through rather than a number someone has to remember to bump. Implementations from +before the gate have no `version()` at all and revert. The manager is not upgradeable and gets no +`version()`; a manager without `relayer()` is one from before the admin became the relayer. + +The underscore-public convention in `IntentsBase` (`_filled`, `_orders`, ...) would have given +`_relayer()` for free, but a named getter is what the interface should publish, and the gateway +had 26 bytes of headroom: `relayer()` only fits once the auto-generated getter is dropped, and +`version()` needed the `_instances` getter dropped as well. That getter duplicated +`instance(bytes)` and nothing off-chain called it. + +Alternative rejected — a hand-maintained version constant, as a semver string or a number. The +string cost 82 bytes and did not fit; both would drift from what is actually deployed. diff --git a/sdk/packages/core/docs/ai/decisions/2026-09-05-the-hostmanager-admin-is-the-relayer-rotated-only-by-governance.md b/sdk/packages/core/docs/ai/decisions/2026-09-05-the-hostmanager-admin-is-the-relayer-rotated-only-by-governance.md new file mode 100644 index 000000000..4efc6d78a --- /dev/null +++ b/sdk/packages/core/docs/ai/decisions/2026-09-05-the-hostmanager-admin-is-the-relayer-rotated-only-by-governance.md @@ -0,0 +1,19 @@ +# 2026-09-05 — The `HostManager` admin is the relayer, rotated only by governance + +Chosen: `HostManagerParams.admin` survives `init` and is the address `onAccept` compares the +relayer against. There is no separate relayer slot and no local setter; a `SetAdmin` request from +Hyperbridge, delivered by the outgoing admin, replaces it. + +The previous design gave the host admin a `setRelayer` on the manager. That key could re-route +governance at will, from a local transaction nobody on Hyperbridge sees. Folding the relayer into +the admin removes that path: the only way to change who may deliver governance is governance, and +the manager's admin has exactly one power after `init`, which is to deliver. + +Alternative rejected — keep `setRelayer` but restrict it to the manager's own admin. Same local +override, different key. + +Alternative rejected — a zero admin as a kill switch, as a zero relayer was. A zero admin can never +be rotated away: the rotation is itself a delivery the manager would refuse, and the host cannot be +re-pointed at a new manager except through the current one. Zero is refused in the constructor and +in `SetAdmin`, and the pallet refuses to dispatch it; the cost is a comparison each, the failure +they prevent is permanent. diff --git a/sdk/packages/core/docs/ai/decisions/2026-09-05-the-relayer-gate-belongs-to-the-token-that-needs-it-not-to.md b/sdk/packages/core/docs/ai/decisions/2026-09-05-the-relayer-gate-belongs-to-the-token-that-needs-it-not-to.md new file mode 100644 index 000000000..750b1f91e --- /dev/null +++ b/sdk/packages/core/docs/ai/decisions/2026-09-05-the-relayer-gate-belongs-to-the-token-that-needs-it-not-to.md @@ -0,0 +1,14 @@ +# 2026-09-05 — The relayer gate belongs to the token that needs it, not to `HyperFungibleToken` + +Chosen: `HyperFungibleToken` has no relayer state, setter, event, error or hook. Its two delivery +callbacks are `public virtual`, and `BridgeToken` wraps them with its own gate. A token that wants +a gate writes one; a token that does not gets nothing to configure and no extra storage slot. + +The opt-in gate in the base contract (zero means open, override to fail closed) was two policies +in one place: third-party tokens saw a setter they had no reason to call, and the one token that +needed the gate had to override the hook to invert its default. Moving the whole thing into +`BridgeToken` leaves one policy per contract. + +Alternative rejected — keep an empty `_checkRelayer` hook in the base for derived tokens to fill +in. It still names a relayer in a contract that has no opinion about one, and wrapping the +callbacks costs the derived token nothing more than a `super` call. diff --git a/sdk/packages/core/docs/ai/decisions/2026-09-05-version-is-the-implementation-s-version-initialize-arms-migrate.md b/sdk/packages/core/docs/ai/decisions/2026-09-05-version-is-the-implementation-s-version-initialize-arms-migrate.md new file mode 100644 index 000000000..57c78cf88 --- /dev/null +++ b/sdk/packages/core/docs/ai/decisions/2026-09-05-version-is-the-implementation-s-version-initialize-arms-migrate.md @@ -0,0 +1,29 @@ +# 2026-09-05 — `version()` is the implementation's `VERSION`; `initialize` arms, `migrate` catches up + +Chosen: one constant, `VERSION = 2`, that both `initialize` and `migrate` land on. `initialize` +takes the relayer in the init data, so a fresh proxy is armed from its first block and reports +the version of the code it runs. `migrate`, host-only and one-shot, exists for proxies deployed +before this implementation and reverts on any proxy already at `VERSION`. `setRelayer` is a +rotation and never touches the version. `onlyHost` on `migrate` is load-bearing: a proxy at 1 is +open, and without it anyone could arm it first. + +This reverses the earlier decision (below, same day) to keep the relayer out of the init data. +The reason given there, that the relayer would become part of what fixes the proxy's CREATE2 +address, still holds but no longer bites: the implementation address is already an input to +that address, so every new implementation changes it for chains deployed afterwards anyway, and +the deploy script now deploys a proxy only where none exists. Landing fresh proxies at 1 with an +open gate, the state before this change, left them reporting an older version than their code. + +Alternative rejected — `setRelayer` under `reinitializer(_getInitializedVersion() + 1)`, built +and tested first. It makes `version()` count key rotations, which says nothing about what code a +proxy has migrated to. + +Alternative rejected — a fixed `reinitializer(2)` on `setRelayer` itself: the second rotation +reverts until an implementation with `reinitializer(3)` ships. + +Alternative rejected — a separate `bumpVersion()` next to a plain `setRelayer`: an +`UpgradeContract` carries one migration call, so arming a fresh chain would take two deliveries. + +The bytes came from deduplicating internal code, not from dropping anything off-chain reads. The +one place `_sendValue` is not used is the `_fillSameChain` loop, which is at the via-ir stack +limit. diff --git a/sdk/packages/core/docs/ai/Flow.md b/sdk/packages/core/docs/ai/flows/how-a-cross-chain-delivery-reaches-the-gateway-and-where-the.md similarity index 61% rename from sdk/packages/core/docs/ai/Flow.md rename to sdk/packages/core/docs/ai/flows/how-a-cross-chain-delivery-reaches-the-gateway-and-where-the.md index 0c526f055..6700a4285 100644 --- a/sdk/packages/core/docs/ai/Flow.md +++ b/sdk/packages/core/docs/ai/flows/how-a-cross-chain-delivery-reaches-the-gateway-and-where-the.md @@ -1,30 +1,4 @@ -# Flow - -AI-maintained map of how code paths in `sdk/packages/core` actually execute, so that when something breaks you can tell whether the fault is upstream or downstream of where the symptom appears. Only flows that have been read and verified are documented; coverage grows as areas of the package are touched. - -## What `IIntentGatewayV2` is, and what actually reads it - -`contracts/apps/IntentGatewayV2.sol` is a declaration-only file: structs, errors, events, and -external function signatures. There is no implementation of `IIntentGatewayV2` in this package — -the gateway lives at `evm/src/apps/IntentGatewayV2.sol` and inherits its events and errors from -`evm/src/apps/intentsv2/IntentsBase.sol`. - -Two consumers, and they use it very differently: - -1. **`evm/src/apps/intentsv2/SolverAccount.sol`** imports it and reads exactly two things: - `IIntentGatewayV2.select.selector` and `IIntentGatewayV2.fillOrder.selector`. It resolves via the - `@hyperbridge/core/` remapping in `evm/remappings.txt`, which points at - `node_modules/@hyperbridge/core/contracts/` — a workspace symlink, so an edit here is picked up - by `forge build` in `evm/` with no publish step. Only the two function signatures matter to it. -2. **Integrators**, who read the file as the gateway's published ABI surface. Everything else in it - — the events especially — exists for them alone. - -That split is the whole reason the events drift: nothing in the repo compiles against them, so a -wrong signature is silent locally and only wrong for whoever depends on the package. The -declaration lists in this file and in `IntentsBase.sol` are kept identical; diffing them is the -only check that exists. - -## How a cross-chain delivery reaches the gateway, and where the relayer gate sits +# How a cross-chain delivery reaches the gateway, and where the relayer gate sits Verified against `evm/src/core/HandlerV2.sol`, `evm/src/core/EvmHost.sol` and `evm/src/apps/intentsv2/ExtrinsicIntents.sol`, and exercised by @@ -107,39 +81,3 @@ alone. A revert from `version()` means an implementation from before the gate. `testInitializeArmsTheGate` pins the fresh path, `testMigrateArmsAndBumpsTheVersion` and `testMigrateRunsOnce` the migration, and the live-fork test reads 2 on the mainnet proxy, which has since been migrated, and shows it refuses `initialize` and a second `migrate`. - -## The BRIDGE token's relayer gate, and why the base token has none - -Verified against `contracts/apps/HyperFungibleToken.sol` and `evm/src/apps/BridgeToken.sol`, and -exercised by `evm/tests/foundry/BridgeTokenTest.t.sol`. - -Steps 1 and 2 above are identical; the token is just another `IApp`. Timeouts take a parallel route: -`HandlerV2.handlePostRequestTimeouts` calls `host.dispatchTimeOut(PostRequestTimeout(request, -_msgSender()), ...)`, and the host calls `onPostRequestTimeout` on the module. - -3. `HyperFungibleToken.onAccept` and `onPostRequestTimeout` are `public virtual`, run `onlyHost` - and `whenNotPaused`, then check the source against `_supportedChains`, decode the body, and - mint. The base token knows nothing about relayers: a third-party token accepts every relayer. - -`BridgeToken` overrides both callbacks: `onlyHost`, then `_checkRelayer(incoming.relayer)`, then -`super`. `_checkRelayer` reverts with `BridgeToken.UnauthorizedRelayer` whenever the incoming relayer -differs from `_relayer`, so with none set nothing can mint; the deploy script calls `setRelayer` -before `configure`, and before `configure` the token cannot be reached at all since `onlyHost` -compares against an unset `_host`. `setRelayer` is `onlyOwner`; the host is not the owner and -never calls the token with anything but the callback selectors. The token is not behind a proxy, -so there is no upgrade transaction to arm it in and no host-only setter like the gateway's. - -## The SimplexPaymaster's relayer gate - -Verified against `evm/src/utils/SimplexPaymaster.sol` and exercised by the relayer-gate cases in -`evm/tests/foundry/SimplexPaymasterTest.t.sol`. - -Steps 1 and 2 of the gateway route apply unchanged; the paymaster's only ISMP entry point is -`onAccept`, which carries governance (upgrades, params, token registry, withdrawals, stake). It runs -`onlyHost`, then `_checkRelayer(incoming.relayer)`, then the Hyperbridge source check, and reads the -kind byte only after all three. As on the gateway, an unset relayer gates nothing, so a proxy -upgraded without arming stays reachable; unlike the gateway, governance can never set zero. A bare -proxy is armed through the relayer argument of `initialize`; a proxy from before the gate through -`migrate(relayer)` as the init data of the upgrade request, host-only and one-shot; rotation through -the `SetRelayer` request kind. The client-side and rollout detail lives in -`sdk/packages/simplex/docs/ai/Flow.md`. diff --git a/sdk/packages/core/docs/ai/flows/the-bridge-token-s-relayer-gate-and-why-the-base-token-has-none.md b/sdk/packages/core/docs/ai/flows/the-bridge-token-s-relayer-gate-and-why-the-base-token-has-none.md new file mode 100644 index 000000000..91c9436f5 --- /dev/null +++ b/sdk/packages/core/docs/ai/flows/the-bridge-token-s-relayer-gate-and-why-the-base-token-has-none.md @@ -0,0 +1,20 @@ +# The BRIDGE token's relayer gate, and why the base token has none + +Verified against `contracts/apps/HyperFungibleToken.sol` and `evm/src/apps/BridgeToken.sol`, and +exercised by `evm/tests/foundry/BridgeTokenTest.t.sol`. + +Steps 1 and 2 above are identical; the token is just another `IApp`. Timeouts take a parallel route: +`HandlerV2.handlePostRequestTimeouts` calls `host.dispatchTimeOut(PostRequestTimeout(request, +_msgSender()), ...)`, and the host calls `onPostRequestTimeout` on the module. + +3. `HyperFungibleToken.onAccept` and `onPostRequestTimeout` are `public virtual`, run `onlyHost` + and `whenNotPaused`, then check the source against `_supportedChains`, decode the body, and + mint. The base token knows nothing about relayers: a third-party token accepts every relayer. + +`BridgeToken` overrides both callbacks: `onlyHost`, then `_checkRelayer(incoming.relayer)`, then +`super`. `_checkRelayer` reverts with `BridgeToken.UnauthorizedRelayer` whenever the incoming relayer +differs from `_relayer`, so with none set nothing can mint; the deploy script calls `setRelayer` +before `configure`, and before `configure` the token cannot be reached at all since `onlyHost` +compares against an unset `_host`. `setRelayer` is `onlyOwner`; the host is not the owner and +never calls the token with anything but the callback selectors. The token is not behind a proxy, +so there is no upgrade transaction to arm it in and no host-only setter like the gateway's. diff --git a/sdk/packages/core/docs/ai/flows/the-simplexpaymaster-s-relayer-gate.md b/sdk/packages/core/docs/ai/flows/the-simplexpaymaster-s-relayer-gate.md new file mode 100644 index 000000000..82818bba2 --- /dev/null +++ b/sdk/packages/core/docs/ai/flows/the-simplexpaymaster-s-relayer-gate.md @@ -0,0 +1,14 @@ +# The SimplexPaymaster's relayer gate + +Verified against `evm/src/utils/SimplexPaymaster.sol` and exercised by the relayer-gate cases in +`evm/tests/foundry/SimplexPaymasterTest.t.sol`. + +Steps 1 and 2 of the gateway route apply unchanged; the paymaster's only ISMP entry point is +`onAccept`, which carries governance (upgrades, params, token registry, withdrawals, stake). It runs +`onlyHost`, then `_checkRelayer(incoming.relayer)`, then the Hyperbridge source check, and reads the +kind byte only after all three. As on the gateway, an unset relayer gates nothing, so a proxy +upgraded without arming stays reachable; unlike the gateway, governance can never set zero. A bare +proxy is armed through the relayer argument of `initialize`; a proxy from before the gate through +`migrate(relayer)` as the init data of the upgrade request, host-only and one-shot; rotation through +the `SetRelayer` request kind. The client-side and rollout detail lives in +`sdk/packages/simplex/docs/ai/Flow.md`. diff --git a/sdk/packages/core/docs/ai/flows/what-iintentgatewayv2-is-and-what-actually-reads-it.md b/sdk/packages/core/docs/ai/flows/what-iintentgatewayv2-is-and-what-actually-reads-it.md new file mode 100644 index 000000000..bec598ab1 --- /dev/null +++ b/sdk/packages/core/docs/ai/flows/what-iintentgatewayv2-is-and-what-actually-reads-it.md @@ -0,0 +1,21 @@ +# What `IIntentGatewayV2` is, and what actually reads it + +`contracts/apps/IntentGatewayV2.sol` is a declaration-only file: structs, errors, events, and +external function signatures. There is no implementation of `IIntentGatewayV2` in this package — +the gateway lives at `evm/src/apps/IntentGatewayV2.sol` and inherits its events and errors from +`evm/src/apps/intentsv2/IntentsBase.sol`. + +Two consumers, and they use it very differently: + +1. **`evm/src/apps/intentsv2/SolverAccount.sol`** imports it and reads exactly two things: + `IIntentGatewayV2.select.selector` and `IIntentGatewayV2.fillOrder.selector`. It resolves via the + `@hyperbridge/core/` remapping in `evm/remappings.txt`, which points at + `node_modules/@hyperbridge/core/contracts/` — a workspace symlink, so an edit here is picked up + by `forge build` in `evm/` with no publish step. Only the two function signatures matter to it. +2. **Integrators**, who read the file as the gateway's published ABI surface. Everything else in it + — the events especially — exists for them alone. + +That split is the whole reason the events drift: nothing in the repo compiles against them, so a +wrong signature is silent locally and only wrong for whoever depends on the package. The +declaration lists in this file and in `IntentsBase.sol` are kept identical; diffing them is the +only check that exists. diff --git a/sdk/packages/indexer/docs/ai/ChangeLog.md b/sdk/packages/indexer/docs/ai/ChangeLog.md deleted file mode 100644 index 8ba0fe0bf..000000000 --- a/sdk/packages/indexer/docs/ai/ChangeLog.md +++ /dev/null @@ -1,278 +0,0 @@ -# ChangeLog - -AI-maintained log of code changes in `sdk/packages/indexer`. Every AI-assisted change appends an entry here: date, what changed, and the files touched. This is not the release changelog (there is none for the indexer; the published packages use changesets `CHANGELOG.md` files). - -Entry format: - -``` -## YYYY-MM-DD — short title (issue/PR if any) -What changed and why, in a few sentences. -Files: list of files touched. -``` - -Newest entries first. - -## 2026-09-09 — Root cause of phantom bids dropped as "Failed to process bid for price snapshot"; Gnosis SolverAccount - -From block 11823329 the Base snapshots counted one solver instead of three, and the EVM-1 and EVM-56 windows stopped producing snapshots entirely. The two missing solvers had upgraded simplex and begun sending a v1 source-chain declaration in `paymasterAndData` where they used to send nothing; the SDK's declaration decoder read each chain id through `TextDecoder` (via `@polkadot/util`'s `u8aToString`), which throws inside SubQuery's vm2 sandbox on a sandbox-created `Uint8Array`, and the per-bid catch in `aggregatePhantomBids` logged the warning and dropped the bid. The solver still counted declares only V4 positions, so its decode never reached a chain id. Fixed in the SDK (`utf8Decode`, no TextDecoder). A new indexer test, `phantom-decode.sandbox.test.ts`, bundles the shipped `intents-helpers` with esbuild and runs the decoder inside a vm2 NodeVM configured like `@subql/node-core`'s Sandbox; it fails on the previous build with the exact production error and passes now. Separately, the "No SolverAccount configured for chain, skipping price snapshot" warning was Gnosis: `config-mainnet.json` had no `solverAccount` for EVM-100 although the SDK config carries one, so every Gnosis phantom window was skipped; the entry now lists the same two accounts as the other chains. -Files: `src/utils/__tests__/phantom-decode.sandbox.test.ts`, `src/configs/config-mainnet.json`, `docs/ai/Decisions.md`, `docs/ai/Flow.md`. - -## 2026-09-09 — Phantom bid aggregation reads Permit2-sponsored bids as well as bare-declaration ones - -Simplex authorizes every sponsored UserOp through a per-op Permit2 signature now (#1223), so a phantom bid built on its real-bid path carries the Simplex paymaster's 234-byte PERMIT2-mode paymasterAndData with the declaration appended, where the aggregation previously expected the declaration alone. The decoding lives in the SDK's `aggregatePhantomBids`, which the indexer runs unchanged: `decodePhantomBidPaymasterAndData` now tries the bare declaration first and the sponsored shape second, and a sponsored bid with no tail is counted as having declared nothing. No handler code changed. Two indexer tests pin the parts that are the indexer's own: `recoverBidSignerVm2` (ethers) recovers the solver from a bid whose paymasterAndData is the long sponsored payload exactly as viem does, and the `intents-helpers` sub-path decodes a payload packed with ethers' `solidityPack` the way simplex packs it with viem. -Files: `src/utils/__tests__/phantom-decode.test.ts`, `src/utils/__tests__/phantom-decode.paymaster.test.ts`, `docs/ai/Decisions.md`, `docs/ai/Flow.md`. - -## 2026-09-08 — Drop the `handlePriceIndexing` block handler; rename its template flag - -The Hyperbridge node no longer runs `handlePriceIndexing`, the block handler that every ten blocks called `TokenPriceService.initializePriceIndexing` to refresh every registry token's CoinGecko price. The handler file, its export from `mappingHandlers.ts`, and its entry in the substrate manifest template are removed. Prices are still fetched on demand: `TokenPriceService.getPrice`, which the transfer and volume paths read through, fetches and stores a price when none is held. The template flag that gated it, `enablePriceIndexing`, is renamed `enableLiquidityIndexing`: what it now gates is the phantom order handlers and the inventory fold, the handlers that produce the liquidity pool rows, on the Hyperbridge node outside testnet. -Files: `scripts/generate-chain-yamls.ts`, `scripts/templates/substrate-chain.yaml.hbs`, `src/mappings/mappingHandlers.ts`, `src/handlers/events/price/handlePriceIndexing.event.handler.ts` (deleted), `docs/ai/Decisions.md`, `docs/ai/Flow.md`. - -## 2026-09-08 — One writer per pool row: EVM nodes publish inventory readings, Hyperbridge folds them (#1214) - -The pool family (`LiquidityPool`, `PoolChainLiquidity`, `PoolBidder`, `PoolRoute`, `LiquidityProviderBalanceV2`) is -now written by the Hyperbridge node only. An EVM event that moves a solver's inventory (order fill, partial fill, -escrow release, vault deposit or withdrawal) re-reads the solver's balance on its own chain, pinned to the event's -block, and writes a `SolverInventoryReading` row that only that chain's node writes. A new block handler on the -Hyperbridge node, `handleInventoryFold`, folds those readings into the pool rows every block; a reading applies to -a bidder row only if it postdates the row's snapshot time and the new nullable `PoolBidder.refreshedAt`, so the -fold is idempotent. This removes the concurrent read-modify-write of the same pool rows by several node -processes, each serving `get` from a process-local cache that no other node's write invalidates. The -`SolverV4Positions` read on the EVM side goes through a field query for the same reason. The EVM-side read of -Hyperbridge's head block is gone: the fold stamps balance-series rows with its own block. -Files: src/configs/schema.graphql, src/services/inventoryReading.service.ts (new), src/services/liquidityPool.service.ts, -src/services/solverPositions.service.ts, src/services/intentGatewayV3.service.ts, src/services/yieldVault.service.ts, -src/utils/solverBalance.ts, src/handlers/events/liquidity/inventoryFold.block.handler.ts (new), -src/handlers/events/intentGatewayV3/orderFilledV3.event.handler.ts, partialFilledV3.event.handler.ts, -escrowReleasedV3.event.handler.ts, src/mappings/mappingHandlers.ts, scripts/templates/substrate-chain.yaml.hbs, -src/services/__tests__/inventoryReading.service.test.ts (new), src/services/__tests__/liquidityPoolFold.service.test.ts (new), -src/services/__tests__/liquidityPoolRefresh.service.test.ts (removed), docs/ai/ChangeLog.md, docs/ai/Decisions.md, docs/ai/Flow.md. - -## 2026-09-04 — In-place substrate schema migrations (#1163 follow-up) - -Enabled additive schema migrations for the substrate node so a new field on an existing entity no longer forces a -rename that orphans the old table's data. The behaviour lives in the forked node image (`polytopelabs/subql-node-substrate`), -bumped to `v6.4.8-0`; this repo turns it on with `--allow-schema-migration` on the substrate node only (it is the -health-gated leader every EVM node waits on, and the shared `schema.graphql` covers all entities). Destructive changes -(drop or retype a field, drop an entity) stay refused unless `SUBQL_ALLOW_DESTRUCTIVE_MIGRATION=true`. -Files: scripts/generate-compose.ts, scripts/templates/partials/docker-command.hbs, docker/docker-compose.local.yml, docker/docker-compose.nexus-ci.yml, README.md, docs/ai/Decisions.md, docs/ai/Flow.md. - -## 2026-09-05 — Recognise both the current and the previous SolverAccount per chain - -`solverAccount` in `config-{mainnet,testnet}.json` accepts one address or a list; the generated -`SOLVER_ACCOUNT_ADDRESSES` is `Record`. `YieldVaultService.isDelegatedSolver` -and the phantom price handler count an account when it delegates to any listed address, the -latter by passing the list to the sdk's `aggregatePhantomBids`, which now accepts one or several. -Mainnet lists the 2026-09-05 SolverAccount `0x7cb55539d1144F62422099c3FA3405092022c88C` first and -the replaced `0xfCd233b937D7622AAc63ced3C9A1A12F4a6B64E3` second on the six chains that had an -entry, so solvers keep counting while they re-delegate. Drop the old address once they have. - -Files: `src/configs/index.ts`, `src/configs/config-mainnet.json`, `scripts/generate-chain-yamls.ts`, -`src/solver-account-addresses.ts`, `src/services/yieldVault.service.ts`, -`src/handlers/events/substrateChains/handlePhantomOrderPrices.handler.ts`, -`src/handlers/events/substrateChains/__tests__/phantomOrder.handlers.test.ts`, `docs/ai/ChangeLog.md`, -`docs/ai/Flow.md`. - -## 2026-09-05 — Drop the overloaded `quote` from the IntentGatewayV3 ABI so subql codegen compiles - -The gateway ABI regenerated in PR #1207 carried both `quote` overloads inherited from `HyperApp` -(`quote(DispatchPost)` and `quote(DispatchGet)`). subql's codegen names the generated transaction -type after the function alone, so it emitted `Quote_tuple_Transaction` twice and `subql build` -failed with TS2300 in CI. The indexer never calls `quote`, so both entries are removed from -`IntentGatewayV3.abi.json`; the events and the functions the handlers read are unchanged. -Verified with the `ENV=local` codegen chain CI runs. - -Files: `src/configs/abis/IntentGatewayV3.abi.json`, `docs/ai/ChangeLog.md`. - -## 2026-09-02 — Reconcile declarations against the verified solver set (review fixes on #1194) - -Review found the declaration reconciliation keyed off the wrong set. `recordDeclaredPositions` was passed the -solvers appearing in `lpBalances` or `positions`, and both are filtered: the sweep skips tokens a solver does not -hold, and `positions` only lists what was declared. A solver that bid while holding nothing anywhere and -declaring nothing was in neither, so its previous declaration was never emptied and the refresh kept valuing -positions it had stopped offering — and a V4-funded solver with a near-empty wallet is exactly that profile. - -`aggregatePhantomBids` now returns `solvers`, the verified bidders it already tracked internally to stop one -solver's bid counting once per funded filler, and the handler reconciles against that. - -Also drops `IntentGatewayV3Service.refreshLiquidityAfterVaultEvent`, which was never called: `recordLedger` -reaches `refreshProviderLiquidity` through `liquidityRefreshContext` directly, which is the right seam — a vault -event has nothing to do with the intent gateway. - -Files: `src/handlers/events/substrateChains/handlePhantomOrderPrices.handler.ts`, -`src/services/solverPositions.service.ts`, `src/services/intentGatewayV3.service.ts`, -`src/handlers/events/substrateChains/__tests__/phantomOrder.handlers.test.ts`. - -## 2026-09-01 — Refresh liquidity on escrow releases and vault events, and value declared V4 positions (#1159) - -Follow-up to #1192, which refreshed a pool's LPs on `OrderFilled`/`PartialFill`. Four things were still missing. - -**Uniswap V4 positions are recorded and re-read.** A bid's `paymasterAndData` is the only place a position is -ever named, so a balance re-read could not see that inventory at all — and carrying the last sweep's VALUE -forward would be worse, because simplex funds fills out of these positions: such a fill drains the position -inside the fill transaction while wallet and vault balances barely move. The aggregation now reports the -tokenIds it verified, and `handlePhantomOrderPrices` records them in a new `SolverV4Positions` entity: one row -per solver, keyed by its address, replaced wholesale each time that solver bids. The refresh reads that one row -and re-values each position at the event's block. A solver that bids without declaring has its row emptied; one -that skips a window keeps its row, and a position it has since burned or sold reads back as no longer owned, -which is the check every consumer applies before valuing one. - -**Reads are pinned to the event's block.** `getTotalSolverBalance` is exported from the SDK with a block tag and -`memoizedSolverBalance` takes a per-chain map of them, so an event's re-read returns the same value on a replay -as it did live. Only the event's own chain is pinned: a refresh reaches across every chain the pool is quoted on -and a block number means nothing on any other. - -**Escrow releases refresh too.** `EscrowReleased` on the source chain pays the filler the order's inputs back, so -its inventory there rose and every pool it backs in those tokens is understating depth. The event names no -filler, so the handler reads the gateway's `_filled(commitment)` mapping at that block — `_withdraw` writes the -beneficiary in the same call that emits the event, so it never depends on the destination chain's node having -indexed the fill first. - -**Vault deposits and withdrawals refresh too.** `YieldVaultService.recordLedger` ends with a refresh for -(chain, lp, underlying token). An LP moving its own principal only shifts inventory between the raw and vault -halves of one total, which the re-read confirms rather than changes, but the total does move when the -counterparty is someone else — a treasury funding the solver, or inventory leaving it — and no order event -reports that at all. - -Those last two name a solver and a token, never a pool, so they reach the refresh through a new -`refreshProviderLiquidity` entry point. It shares everything with the pool-scoped one, which meant one change to -the shared core: depths are now re-summed from the stored bidder rows of each (pool, chain) rather than from the -rows that were re-read, so refreshing one solver leaves the others contributing exactly what they were. - -The only schema change is the new `SolverV4Positions` entity — additive, and with no `@derivedFrom` field added -to `LiquidityProvider`, so nothing about an existing entity changes. Deliberately not done, per #1159 §3, for -that same reason: the `trigger` enum, the nullable `transactionHash`, the -`{chain}-{token}-{solver}-{blockNumber}` id shape for event-triggered rows, and ordering "current liquidity" by -`snapshotTime` instead of `blockNumber` all alter a live entity. Event rows keep borrowing Hyperbridge's head -block, and the design is left as a comment on `recordProviderBalances` for whenever a migration is on the table. - -Files: `src/configs/schema.graphql`, `src/services/liquidityPool.service.ts`, -`src/services/solverPositions.service.ts` (new), `src/services/intentGatewayV3.service.ts`, -`src/services/yieldVault.service.ts`, `src/utils/solverBalance.ts`, -`src/handlers/events/intentGatewayV3/escrowReleasedV3.event.handler.ts`, -`src/handlers/events/substrateChains/handlePhantomOrderPrices.handler.ts`, -`src/services/__tests__/liquidityPoolRefresh.service.test.ts`, -`src/handlers/events/substrateChains/__tests__/phantomOrder.handlers.test.ts`. - -## 2026-09-01 — Refresh a pool's LP balances on every order fill - -A pool's published depth is the sum of its bidders' output-token inventory, measured when the last phantom bid -window closed. A fill spends some of that inventory, so between windows the depth advertises capacity the -solvers no longer hold — and the depth is what a taker sizes an order against. - -`OrderFilled` now re-reads the balances of every LP recorded as backing the pools the fill traded through and -republishes the depths from them: `PoolBidder.liquidity`, `PoolChainLiquidity.depth`/`bidCount`/unrestricted -slice, `PoolRoute.depth`/`bidCount`, and the pool's own `sellDepth`/`buyDepth`/bid counts. A bidder left holding -nothing loses its row, so the "every row is a bidder with capacity" invariant survives, and the routes it alone -declared go with it. Rates are untouched — nothing here observes a quote — though a pool's merged rate can still -move, because the chains are weighted by the depths that just changed. - -`PartialFill` refreshes on the same terms, through the same service method: it spends inventory identically, and -being the same-chain path (`IntrinsicIntents`) its source and destination are one chain. Cross-chain fills are -all-or-nothing, so between the two handlers every fill that moves inventory now triggers a refresh. - -The pools are resolved from the order's own two sides: the input symbols on its source chain paired with the -output symbols on the destination chain, via the same token registry the snapshot path uses. An order in -untracked assets resolves to no pool and costs nothing, which is most of them. - -Two guards keep the cost bounded and the data honest. A pool whose last snapshot postdates the fill is skipped -entirely — that snapshot already read balances this fill had moved, and during a resync it is every pool, so -replaying history triggers no RPC at all. And a chain whose balances cannot be read in full is left exactly as -indexed rather than partially republished: a failed read is indistinguishable from a zero balance, so half a -chain would report the unread bidders as departed. - -The refresh also extends `LiquidityProviderBalanceV2` with what it read, so a provider's latest balance row does -not keep reporting inventory the pool rows already know is spent. That series is keyed by Hyperbridge block and a -fill has none, so it borrows Hyperbridge's head via `chain_getHeader` on the configured node — one read per -indexed block, shared by every fill in it. Two readings on one key resolve to the larger, the same rule the sweep -follows, because a refresh can never see a solver's Uniswap V4 positions and the smaller reading is the -incomplete one. An unreachable Hyperbridge node costs the data point, not the refresh. - -`lastUpdatedBlock`/`lastUpdatedAt` are deliberately not moved anywhere. They date the snapshot that priced the -pool, in Hyperbridge blocks; a fill carries an EVM block number of another chain, which is not comparable with -them and would wreck the `MAX_SAMPLE_AGE_BLOCKS` staleness filter. - -The balance-reader wiring (`setAggregationFetch(safeFetch)`, the per-chain RPC map, the per-block memo) moved out -of `handlePhantomOrderPrices.handler.ts` into `src/utils/solverBalance.ts` so both paths read balances the same -way; the memo key is now a string so it can identify a block of either chain. The pool-level merge and the -unrestricted-slice split were extracted into `mergeChainRowsIntoPool` and `bidderDepths`, shared by the snapshot -writer and the refresh — the two must publish the same numbers from the same bidders. - -Unrelated but in the way: `phantomOrder.handlers.test.ts` was red before this change — all 14 of its -`handlePhantomOrderPrices` cases threw `sortPoolSymbols is not a function`. Its `@hyperbridge/sdk/intents-helpers` -mock lists only the three entry points the handlers call, and the pool-token registry re-exports its symbol -ordering from that same module, so mocking it dropped `poolSlug`/`sortPoolSymbols` and every pool attribution -threw. The mock now spreads `jest.requireActual` and overrides only those three; importing the SDK under jest has -worked since `transformIgnorePatterns` was added. That suite covers the handler wiring this change moved, so it -had to run to verify the move — all 16 cases pass. - -Files: `src/services/liquidityPool.service.ts`, `src/services/intentGatewayV3.service.ts`, -`src/handlers/events/intentGatewayV3/orderFilledV3.event.handler.ts`, -`src/handlers/events/intentGatewayV3/partialFilledV3.event.handler.ts`, -`src/handlers/events/substrateChains/handlePhantomOrderPrices.handler.ts`, `src/utils/solverBalance.ts` (new), -`src/configs/schema.graphql` (descriptions only), -`src/services/__tests__/liquidityPoolRefresh.service.test.ts` (new), -`src/handlers/events/substrateChains/__tests__/phantomOrder.handlers.test.ts`. - -## 2026-08-28 — Decode phantom bids of either `fillOrder` shape - -`extractFillDataVm2` built one ethers `Interface` from `FILL_ORDER_ABI`. When `FillOptions` gained `validUntil` -that ABI moved to the v2 shape, and since ethers validates the selector before decoding, every v1-shaped bid -started throwing. The throw was swallowed by a `continue`, the function returned null, and `aggregatePhantomBids` -drops a null bid with no log line — so the loss was silent. - -That is not a future risk: `@hyperbridge/sdk` is a `workspace:*` dependency, so the indexer picked up the v2 ABI -immediately, while every mainnet gateway still runs the pre-`validUntil` implementation. `getFillOptionsVersion` -therefore returns 1 and simplex encodes v1 bids, all of which were being discarded — no bidder rows, no median, -no pool-rate snapshot from any of them. - -Now tries both shapes, mirroring the SDK's `decodeFillOrder`. The selectors differ (`0x5cfb1ea5` vs -`0xa5470064`), so neither can mis-decode the other's payload. The v1 ABI is imported from the SDK rather than -re-declared here, so the two cannot drift apart again. - -Verified by running the real function against both shapes: before the change v1 returned null and v2 decoded; -after, both decode, and a wrong target or non-`fillOrder` calldata still returns null. - -Also adds `transformIgnorePatterns` to the jest config. The SDK's CJS bundles `require()` ESM-only packages -(`p-queue` -> `eventemitter3`/`p-timeout`, `lodash-es`), which jest cannot parse untransformed, so *any* test -importing `@hyperbridge/sdk` or its `intents-helpers` sub-path failed to load at all against a freshly built SDK. -That was blocking the new test, and it was also silently keeping the existing `phantom-decode.test.ts` from -running — that file passes again now. - -Files: `src/utils/phantom-decode.ts`, `src/utils/__tests__/phantom-decode.fill.test.ts`, `jest.config.ts`. -## 2026-08-26 — Substrate chains run on the Polytope build of the SubQuery node (#1163) - -The stock `subquerynetwork/subql-node-substrate` image could not survive an RPC interruption: after a websocket drop it gave the endpoint five reconnect attempts and then exited, and any request that failed while the socket was down (a block fetch or a mapping handler read) took the process down immediately; both providers also cached rejected request promises, so retries replayed the stale error. Over http it never retried a 429 from the rate-limited hosted RPC. The substrate image is now `polytopelabs/subql-node-substrate:v6.4.7-0`, built from the `polytope-labs/subql` fork with those behaviours changed (no response caching, requests wait for the reconnect, unbounded reconnect with capped backoff, http retries honouring Retry-After with a client-wide pause). The EVM image is unchanged. - -Files: `scripts/generate-compose.ts`, `docker/docker-compose.local.yml`, `docker/docker-compose.nexus-ci.yml`. - -## 2026-08-19 — Pool rates renormalize by the leg's own standard amount - -`resolvePoolLeg` used to require `standardAmount === 10 ** inputDecimals` exactly, and `updateLiquidityPools` derived a chain sample as `medianPrice * scale`, which silently assumes that same one-unit probe. Both now work for whatever standard amount the phantom order carries: the rate is `medianPrice * scale * 10 ** inDecimals / standardAmount`, exact for any probe size, whole-token or not, and it collapses to the old expression when the probe is one unit. Verified against production: the four live one-unit rates (Base cNGN/USDC both directions, BSC cNGN/USDT both directions) reproduce byte-for-byte, and a 1000x probe with a 1000x quote yields the identical rate. - -Motivation is quote precision on the pallet side, not the indexer's: a leg's output integer IS the published price, so one whole cNGN priced into 6-decimal USDC quotes ~715 base units and the price grid is 1/715 = 0.14% coarse. A curve of 1398 cNGN/USDC cannot publish finer than 1398.6014. At a 1000-token probe the same quote is 715,307 and the error drops to 0.00008%. The indexer had to stop hard-coding the one-unit assumption before that bump was possible. - -The exact-value check was the only tripwire for a registry/pallet decimals disagreement, so it was replaced rather than deleted: the probe must now be plausible relative to one whole input token — at most `MAX_STANDARD_UNITS` (1e6) of them and at least `1/MAX_STANDARD_SUBDIVISIONS` (1/1e3) of one. Every realistic mismatch (6 vs 18, 6 vs 12, 8 vs 18) is a factor of 1e6 or more and still refuses attribution; the existing BSC test for an 18-decimal amount copied onto 6-decimal cNGN still passes, and a new test covers the same bug seen from the other side. - -`ResolvedPoolLeg` gained `inDecimals` for the renormalization. The SDK's `PhantomSnapshotQuoter` needed no change — it already divides by `snapshot.standardAmount`. - -The renormalization is exported as `poolRateFromQuote(medianPrice, resolved, standardAmount)` rather than living inline in `updateLiquidityPools`, which needs SubQuery store mocks to exercise. It is the one piece of arithmetic a wrong probe size would silently corrupt, so it is now a pure function pinned directly by tests: the four live mainnet rates at the deployed 1-unit probe, the identical rates at a 1000-unit probe, the extra digits the bump buys, and a non-whole 1.5-token probe. The 1-unit cases matter most — they are the currently deployed configuration, and at exactly one whole unit the two powers in the formula cancel, so those tests are the proof that raising the probe size elsewhere cannot move a published rate. - -Files: `src/services/liquidityPool.service.ts`, `src/services/__tests__/liquidityPool.service.test.ts`. - -## 2026-08-14 — Seed cumulative from daily rows; isolate seed failures (review fixes on #1085) - -Review of the seeding change found the cumulative seed was computed on a basis the running code does not maintain: `updateCumulativeVolume` drops same-timestamp updates per record, so the chain-wide `IntentGatewayV3.FILLED` cumulative loses the second of any two same-block fills (even by different fillers) while per-filler cumulatives only collide within one filler. Seeding from summed filler cumulatives therefore assumed an equality the guard breaks going forward. The seed now derives the cumulative from the guard-free daily buckets it already aggregates, deleting the second table scan; paging reuses the existing `readAllPages` helper instead of a hand-rolled loop. The seed call is also wrapped in its own try/catch so a store error cannot swallow the fill's status, points, and user activity — on failure only the gateway volume update is skipped, leaving the marker uncreated so the next fill retries the seed and recovers the skipped fill from the filler daily rows. Added a test pinning the known same-block divergence between the FILLED cumulative and the daily/per-filler series. - -Files: `src/services/volume.service.ts`, `src/services/intentGatewayV3.service.ts`, `src/services/__tests__/volume.service.test.ts`. - -## 2026-08-14 — Seed gateway volume from existing filler history (#1085) - -On a resumed database the new `IntentGatewayV3.FILLED` series would have started from zero while the per-filler series already carried history. Added `VolumeService.seedAggregateVolume(aggregateBaseId, componentIdPrefix)`: a one-time, per-chain initialization that sums every existing `CumulativeVolumeUSD` and `DailyVolumeUSD` record matching the component prefix into the aggregate's cumulative record and per-day buckets. The aggregate's cumulative record doubles as the done-marker. Called from `updateOrderStatus` before the fill's own volume updates so the seed never counts the triggering fill, and the seeded record keeps the components' max `lastUpdatedAt` so the cumulative same-timestamp guard does not swallow that fill. Extended the `VolumeService` tests to cover seeding. - -Files: `src/services/volume.service.ts`, `src/services/intentGatewayV3.service.ts`, `src/services/__tests__/volume.service.test.ts`. - -## 2026-08-13 — Gateway-level daily filled volume (#1085) - -Daily filled volumes were only recorded per filler (`IntentGatewayV3.FILLER.
..`), so there was no single entry showing total volume filled through the intent gateway per chain per day. Added one `VolumeService.updateVolume` call with the filler-independent base ID `IntentGatewayV3.FILLED`, in the same FILLED branch of `updateOrderStatus` that records the per-filler volume. This produces `DailyVolumeUSD` records like `IntentGatewayV3.FILLED.EVM-8453.2026-07-30` and a `CumulativeVolumeUSD` record `IntentGatewayV3.FILLED.EVM-8453`, queryable from the existing `dailyVolumeUSDs` query. Also added the first unit tests for `VolumeService`. - -Files: `src/services/intentGatewayV3.service.ts`, `src/services/__tests__/volume.service.test.ts` (new). diff --git a/sdk/packages/indexer/docs/ai/Decisions.md b/sdk/packages/indexer/docs/ai/Decisions.md deleted file mode 100644 index d978fea7a..000000000 --- a/sdk/packages/indexer/docs/ai/Decisions.md +++ /dev/null @@ -1,347 +0,0 @@ -# Decisions - -AI-maintained record of non-obvious choices made in `sdk/packages/indexer`: what was decided, what the alternatives were, and why. Read this before changing related code so a later change does not silently undo a deliberate trade-off. - -Entry format: heading with the decision, then alternatives considered and the reasoning. Newest first. - -## 2026-09-09 — The sandbox regression test runs the real bundle inside vm2, not a mock of the sandbox - -Chosen: `phantom-decode.sandbox.test.ts` rolls the shipped `intents-helpers` entry into one file with esbuild -(resolved through `@subql/cli`, which is how the indexer itself is built) and executes the decoder inside a -`NodeVM` from the same vm2 `@subql/node-core` uses, with the host's `TextEncoder`/`TextDecoder` injected into -the sandbox. The live 11-byte declaration that was being dropped is the fixture. - -Why the real thing. The failure is not "TextDecoder is undefined" — injecting it does not help — but that a -`Uint8Array` built inside the sandbox reaches host code as a proxy `ArrayBuffer.isView` rejects. Nothing short -of vm2 reproduces that: Node's own `vm` contexts hand over real typed arrays and decode fine, so a test built -on `vm.createContext` would have passed on the broken code. Injecting the codecs is the charitable setup — the -pre-fix bundle still threw the exact production error under it, and viem cannot load without `TextEncoder`. - -Why a single-file bundle. vm2's own loader cannot walk the pnpm module graph: it does not follow the -`.pnpm` symlinks, refuses the dynamic `import()` `@polkadot/x-fetch` runs at load, and cannot parse ESM-only -packages such as `lodash-es`. The deployed indexer never hits any of this because `subql build` webpacks it -into one file; esbuild does the same for the test. - -Not chosen — keeping the sandbox concern out of the SDK and injecting a VM2-safe declaration decoder from the -indexer like the fill and signature helpers. Those exist because viem is unusable in the sandbox and the SDK -must keep viem for every other consumer; the declaration codec needs nothing from any library, so making it -pure fixes the SDK for every consumer and leaves nothing to inject or drift. - -## 2026-09-09 — Sponsored-bid decoding stays in the SDK; the indexer tests only its own seams - -Chosen: the second paymasterAndData shape (Permit2 sponsorship, declaration appended) is handled inside -`aggregatePhantomBids` in the SDK, and the indexer changes no handler or util. Its tests cover the two places the -indexer substitutes its own implementation for the SDK's: the ethers userOpHash recovery, which now hashes a -234-byte-plus `paymasterAndData`, and the `intents-helpers` bundle it ships, checked against a payload packed with -ethers rather than viem. - -Alternative rejected — a VM2-safe copy of the decoder in `phantom-decode.ts`, next to the fill and signature -helpers. Those exist because viem's byte handling throws in the SubQuery sandbox; the declaration decoder uses -`@polkadot/util` only, which already runs there today for the bare shape, so a copy would duplicate a security- -relevant parser for no sandbox reason and drift from the SDK's. - -Alternative rejected — persisting the sponsorship fields (paymaster, fee token, permit nonce) on the bid or -snapshot rows. Nothing reads them: a phantom bid never executes, so which paymaster it named and when its permit -expires are not facts about the price. They are decoded for the record in the SDK and dropped here. - -## 2026-09-08 — One writer per pool row: EVM nodes publish readings, the Hyperbridge node folds them (#1214) - -The setting this answers: the multichain indexer is one SubQuery node process per chain, all writing one Postgres -schema. Each process serves `Entity.get` from a private LRU (500 entries, one hour, refreshed on every read) that -nothing another process writes ever invalidates, flushes whole rows per block with no locking, and, in the -historical timestamp mode every node here runs under, closes the previous row by range and inserts a new one with -no uniqueness on (id, range). Every cross-chain swap had the destination node (`OrderFilled`), the source node -(`EscrowReleased`) and the Hyperbridge node (snapshot) all re-deriving and saving the same `LiquidityPool` row -from private copies. Verified against `@subql/node-core` 19.3.1 (the EVM image) and the forked substrate node, -whose changes touch only RPC resilience and schema migration. - -Chosen: every row has exactly one writer. The pool family belongs to the Hyperbridge node. An EVM node publishes -what it measured into `SolverInventoryReading`, keyed by its own chain, and `foldInventoryReadings` on the -Hyperbridge node turns those into pool rows. A process that is the only writer of a row is always consistent with -its own cache, so both halves of the race disappear by construction rather than by coordination. - -Alternative rejected — derive the aggregate in the database (a view over the chain rows). Removes the pool-level -race but not the chain-row race between the snapshot writer and the EVM refresh, and needs raw SQL outside the -schema-driven migrations. - -Alternative rejected — advisory locks and direct SQL from the handlers. Possible (`--unsafe`, DB credentials in -the environment) but bypasses the store cache and the historical ranges, so it fights the framework everywhere. - -Alternative rejected — keep several writers and only switch cross-node reads to field queries. Fixes the stale -reads, leaves the concurrent whole-row overwrite; narrows the window without closing it. Field queries are still -the rule for every cross-node read (they go to Postgres; `get` does not), which is why `declaredV4Positions` now -reads through one, on the provider link rather than the id: the store only indexes `id` in historical mode. The FX-pricing read of `LiquidityPool` in `IntentGatewayV3Service` is a remaining -cross-node `get`, out of scope here. - -Chosen: an EVM node reads only its own chain, pinned to the event's block. A pool spans chains, but the event -moved inventory on one of them; the other chains' inventories are their own nodes' to publish, and the periodic -phantom sweep still corrects drift everywhere. This is also what keeps the reading rows single-writer. - -Chosen: the fold scans the whole reading table each block and filters in memory. The store's `getByFields` -offers only `=`, `!=`, `in` and `!in`, so "newer than a watermark" cannot be expressed; the table is bounded by -bidders times tokens times chains, and an in-process memo of what was folded makes a quiet block cost one page. -Idempotency rests on the per-row compare, not on the memo: a reading applies only if it postdates the bidder -row's `lastUpdatedAt` and the new nullable `refreshedAt`. Nullable because the in-place migration refuses a -non-null column, and separate from `lastUpdatedAt` because that pair records which snapshot priced the pool. -Bidder rows are matched to a reading by provider, chain and output token in memory, case-insensitively, so no -index on `outputToken` was added. - -Chosen: the fold runs on every Hyperbridge block. The Hyperbridge manifest uses no dictionary, so the node already -fetches every block, and the handler sits inside the `enableLiquidityIndexing` block of the substrate template — -placing it beside `handlePendingStatusFlush` would have run it on every substrate chain. - -Chosen: balance-series rows are keyed by the fold's own Hyperbridge block with the reading's observation time as -`snapshotTime`. The series keeps its clock without the EVM nodes reading Hyperbridge's head over RPC, which is -gone. Same-key collisions with a snapshot on the same block still resolve to the larger reading. - -Known limits, stated rather than solved: a node reads only rows whose historical range contains its own current -block time, so an EVM node running ahead of Hyperbridge writes readings the fold sees a block or two later, and -the mainnet template flushes the store asynchronously every five seconds, which adds to that lag. A lagging EVM -node's replayed readings are older than the rows' snapshot times and are ignored. Both schema changes are -additive; if the deployment has not restarted since in-place migration was enabled, the first restart records the -baseline and the DDL lands on the second. The disabled multichain rewind lock combined with unfinalized EVM blocks -is a separate race, not addressed here. - -## 2026-09-04 — In-place schema migration on restart, not versioned manifest upgrades (#1163) - -Chosen: run the substrate node with `--allow-schema-migration` and a forked node image that, on startup, diffs the -previously applied schema (persisted in an `appliedSchemaSDL` metadata key) against the current one and ALTERs existing -tables in place — add a nullable column, table, index, enum value, or relation. Editing `schema.graphql`, rebuilding, -and restarting is the whole workflow; existing rows are kept and there is no reindex. - -Alternative rejected — SubQuery's supported versioned-manifest upgrades (a `parent` deployment crossing an upgrade -block). It needs published/parent-CID bookkeeping we do not run (no IPFS), historical indexing (the substrate node runs -without it), and it reindexes from the upgrade block just to add a nullable column. The wrong cost curve for the actual -need, which is additive. - -The flag is on the substrate node only. It is the fork image that carries the in-place logic, and compose already makes -it the health-gated leader every EVM (official-image) node waits on; because the shared `schema.graphql` covers every -entity, the leader applies all DDL before any EVM node boots, so there is no multi-writer race. Destructive changes are -refused by default (they drop a column and its data); `SUBQL_ALLOW_DESTRUCTIVE_MIGRATION=true` is the explicit escape -hatch. First boot after enabling the flag only seeds the baseline; migration happens on subsequent edits. - -## 2026-09-01 — Declared V4 positions live in one row per solver (#1159) - -Chosen: `SolverV4Positions`, keyed by the solver's address, holding the tokenIds from its latest bid and the -chain that bid was for. Written by the phantom snapshot, read by the refresh with a single keyed `get`. - -Alternative rejected — a row per (chain, tokenId). It was implemented first and is the wrong grain: the refresh -asks "what does this solver declare", so a per-position table makes that a filtered scan per provider per chain, -and a position that changes hands or disappears needs its own reconciliation. One row answers the question the -readers actually ask, and replacing it wholesale each window is the reconciliation. - -Alternative rejected — no storage at all, decoding the declaration back out of `FillerBid.bidData` (which does -hold every bid's raw userOp). It was implemented too, and it needs no schema, but the read is a walk over recent -phantom orders, then their bids, SCALE-decoding each one to find the sender — per solver, per refresh. The -declaration is a small, current fact; storing it decoded is what makes reading it cheap. - -Alternative rejected — carry the last sweep's position VALUE forward instead of the tokenIds. Wrong in the case -that matters: simplex funds fills out of these positions, so a fill drains the position inside the fill -transaction while wallet and vault balances barely move, and a carried value keeps advertising precisely the -inventory the fill just spent. The tokenIds are stable; the value is not, so the value is always re-read. - -One row per solver assumes a solver declares on one chain, which holds while Uniswap V4 is configured for a -single chain (Base). That assumption is load-bearing, so the writer refuses to overwrite a row recorded on -another chain and warns instead — the key has to grow to (chain, solver) the day a second V4 chain is -configured, and a silent overwrite would have deleted real inventory in the meantime. - -Positions are recorded after the aggregation's ownership check, not as declared — a declaration is a pointer, -not a claim — and the refresh checks the owner again when it re-reads, because a row recorded last window cannot -know the position has since been sold. - -The reconciliation is driven by the aggregation's `solvers` — every verified bidder — and not by the solvers -appearing in `lpBalances` or `positions`. Both of those are filtered by what the solver turned out to hold or -declare, so a bidder holding nothing anywhere and declaring nothing appears in neither, and its previous -declaration would never be emptied. That bidder is precisely the V4-funded profile whose whole inventory sits in -the positions it just stopped offering, so it is the case that matters most (review of #1194). - -## 2026-09-01 — Escrow releases and vault events refresh by provider, and depths re-sum from the store (#1159) - -Chosen: `refreshProviderLiquidity(chain, provider, tokens)` beside the pool-scoped entry point, for the events -that move a solver's inventory without naming a pool. - -Alternative rejected — resolve those events to pools and reuse the pool-scoped path. An escrow release names the -order, so its pools are resolvable, but a vault event names only a token; and re-reading every bidder of every -pool the solver touches costs an RPC per bidder to learn what one solver's balance did. - -That entry point forced one change to the shared core, worth knowing about: the (pool, chain) depths are now -re-summed from the STORED bidder rows after the writes, not from the rows the refresh happened to re-read. -Summing the re-read subset was correct only because the pool-scoped path re-reads every bidder; with one solver's -rows in hand it would have erased everyone else's contribution. - -Chosen: the escrow release resolves its filler from the gateway's `_filled(commitment)` mapping with one -`eth_call` at the event's block. `_withdraw` writes the beneficiary in the same call that emits the event, so the -mapping is authoritative from that block onwards, and the source chain's node never has to wait for the -destination chain's node to have indexed the fill. - -Chosen: the vault refresh hangs off the end of `YieldVaultService.recordLedger` rather than off the handlers. The -"is this one of our solvers" gate and the duplicate-log guard already live there, and both are exactly the gates -the refresh wants. - -## 2026-09-01 — Per-event reads are pinned to the event's block (#1159) - -Chosen: the balance and position reads a refresh performs are pinned to the block of the event that triggered -them, via the SDK's `blockTag` parameter, and the read memo is keyed by that block so several events in one block -share one set of reads. - -Alternative rejected — read at the chain head, as the periodic sweep does. It is what the first cut did, and it -is not replayable: reindexing an old fill would stamp today's balance onto it. The pool guard (skip a pool whose -last snapshot postdates the event) hid that by making the refresh a no-op during a resync; pinning the reads -makes the guard a cost optimization rather than the only thing standing between a replay and wrong data. - -Only the event's own chain is pinned. Block numbers are per chain and a refresh reaches across every chain the -pool is quoted on, so the others stay at the head — the correct reading available for them. - -## 2026-09-01 — The balance series keeps its shape; #1159 §3 is left as a comment (#1159) - -Not done, deliberately: the `trigger` enum, the nullable `transactionHash`, the -`{chain}-{token}-{solver}-{blockNumber}` id shape for event-triggered rows, and ordering "current liquidity" by -`snapshotTime` rather than `blockNumber`. All four change `LiquidityProviderBalanceV2`, which is live, and the -value is provenance metadata rather than correctness. Event-triggered rows therefore keep borrowing Hyperbridge's -head block, and the design is recorded as a comment on `recordProviderBalances` so a later migration has it to -hand. `SolverV4Positions` is additive — a new table, and no `@derivedFrom` field on `LiquidityProvider` either — -which is why it is in this change and §3 is not. - -## 2026-09-01 — The fill refresh re-reads balances, publishes no provenance of its own, and skips replayed fills - -Chosen: on `OrderFilled`, re-read every recorded bidder's balance for the pools the fill traded through and -rewrite the depths from them, leaving rates and every `lastUpdatedBlock`/`lastUpdatedAt` alone. - -Alternative rejected — subtract the filled amount from the filler's row. No RPC, exact for the one solver that -filled, and wrong for everyone else: a fill is not the only thing that moves inventory between windows -(rebalances, other pools' fills, withdrawals), and the arithmetic would drift from the chain with nothing to -correct it until the next window. Re-reading measures the thing the depth is defined as. - -Alternative rejected — refresh only the fill's own chain. Cheaper, and it is the only chain this fill changed. -But the pool's depth is a cross-chain sum, and the request is for the pool's liquidity, not one chain's slice; -the per-chain memo already collapses the extra reads, and the two guards below mean this only ever runs at the -tip. - -Alternative rejected — stamp the refresh with the fill's block and timestamp. `lastUpdatedBlock` is a Hyperbridge -block number, and the staleness window (`MAX_SAMPLE_AGE_BLOCKS`) is measured in those; an EVM block number is -numerically unrelated and would make every row look either astronomically fresh or unusably stale. Writing only -`lastUpdatedAt` would leave the pair describing two different events. A dedicated `refreshedAt` field was the -honest version of this and was dropped deliberately: it buys provenance metadata at the price of a schema change -on live pool entities, and the depth being fresher than its timestamp claims is the safe direction to be wrong in. - -Chosen: a pool whose `lastUpdatedAt` is newer than the fill is skipped without reading a balance. Balances are -read at the chain head — as the snapshot path also reads them — so refreshing against a fill the pool has already -been sampled after would replace fresher data with a partial view of it. It also makes a historical resync free: -every replayed fill is older than the pools' current samples, so no RPC is issued at all. - -Chosen: a chain whose balances cannot be read in full is left exactly as indexed. A failed read and a zero -balance are indistinguishable downstream, so publishing the reads that succeeded would report the rest of that -chain's bidders as having withdrawn — worse than a stale number, because it looks like news. - -Accepted blind spot, documented at `refreshPoolLiquidity`: the refresh reads wallet ERC-20 plus redeemable -ERC-4626 positions, but a snapshot's weight can also include Uniswap V4 positions the bid declared, and only a -bid names those. A V4-funded bidder therefore shrinks to its liquid inventory until the next window restores it. -Errs downward, which costs a quote rather than a failed fill. The fix, if V4-funded solvers become material, is to -split the position share out in the SDK aggregation and persist it on `PoolBidder` so it can be carried forward — -which is a schema change, hence not done pre-emptively. - -Chosen: `PartialFill` refreshes on the same terms as `OrderFilled`, through the same service method. It spends -output-token inventory identically — the only difference is that it is emitted by the same-chain path -(`IntrinsicIntents`), where source and destination are one chain, so the pair resolves against a single registry. -Cross-chain fills are all-or-nothing (`ExtrinsicIntents` emits only `OrderFilled`), so between the two handlers -every fill that moves inventory now triggers a refresh. The per-block balance memo means a partial fill and the -full fill that follows it in the same block read balances once. - -Chosen: a refresh extends `LiquidityProviderBalanceV2` too, stamping its rows with Hyperbridge's head block read -live from the configured node. The series is keyed by Hyperbridge block and a fill has none of its own, so -something has to supply one, and the head is the only number that keeps the series monotonic — which is the single -property consumers read it for ("greatest blockNumber is the current balance"). - -Alternative rejected — leave the series to snapshots. It is what the first cut did, and it leaves the pool rows -and the balance rows disagreeing between windows: the depth knows the inventory is spent while the newest balance -row still reports it, and those two are meant to be the same measurement. - -Alternative rejected — overwrite the newest existing row in place. No RPC and no new key, but it rewrites history: -that row claims to be the balance at its Hyperbridge block, and after the overwrite it is not. - -Alternative rejected — a fill-shaped key (`…-fill-{evmBlock}-…`). Append-only and honest about provenance, but it -puts two unrelated block sequences in one column, so the greatest-blockNumber rule stops meaning "latest". - -The stamp is a borrowed clock, not a claim: the balance was read at the EVM chain's head, not reconstructed at -that Hyperbridge block, and the schema description now says so. Two readings landing on one key resolve to the -larger, the rule the sweep already follows — a refresh is always the V4-blind reading, so it must not replace a -complete one. The cost is that a second fill while Hyperbridge is still on the same block does not lower the row; -one block later it does. - -## 2026-08-28 — The VM2 decoder tries both `fillOrder` shapes, and the new test avoids the SDK root import - -Alternatives to trying both shapes: - -- **Track the gateway's version and pick one.** That is what `getFillOptionsVersion` does for *encoding*, where - you must choose. Decoding has no such constraint: the selectors differ, so attempting both is unambiguous and - needs no chain state, no RPC read, and no cache — all of which the SubQuery sandbox makes awkward. -- **Decode by selector lookup.** Equivalent in effect, more code, and it would duplicate the selector constants - that the ABIs already encode. - -Trying v2 then v1 mirrors `decodeFillOrder` exactly, which is the point: the two implementations of this decode -diverged once already, and keeping them structurally identical is what makes the next divergence visible. - -The regression test lives in its own file, `phantom-decode.fill.test.ts`, importing only the `intents-helpers` -sub-path. The sibling `phantom-decode.test.ts` also imports `@hyperbridge/sdk` root for `CryptoUtils`, which is -not available on the sub-path. Splitting keeps the new test on exactly the entry point the indexer uses at -runtime, so it exercises the same module graph the sandbox loads. - -The jest `transformIgnorePatterns` addition names the ESM-only packages explicitly rather than transforming all of -`node_modules`. The broad form is slower and pulls unrelated packages through ts-jest; the explicit list fails -loudly (an unparsed `export`) if the SDK's dependency graph grows another ESM-only package, which is the right -failure mode — silence here is what let the tests stop running unnoticed. -## 2026-08-26 — Substrate node resilience is fixed in a forked node image, not in the indexer - -Chosen: the substrate SubQuery node image comes from the `polytope-labs/subql` fork (`polytopelabs/subql-node-substrate`), where the websocket provider is wrapped so requests wait out a disconnect, response caching is disabled, reconnects are unbounded, and the http provider retries rate limits with a client-wide pause. The indexer package only changes the image reference. - -Alternative rejected — make the indexer tolerate it: retry handler RPC reads, lower `--workers`, rely on `restart: unless-stopped`. The exits come from inside the node (block fetcher and dispatcher), which handler code cannot reach, and a restart is not a reconnect: it drops the unfinalized cache and, under `--multi-chain`, forces a rewind. The rate limit on the hosted http RPC is per client, so per-call retries in handlers cannot coordinate with the node's own fetch traffic; only the provider can pause all of them. - -Alternative rejected — upstream the changes first and wait. Worth doing, but the deployment needs the behaviour now; the fork mirrors how `polytopelabs/subql-node-ethereum` is already produced from `polytope-labs/subql-ethereum`. - -Accepted: this moves substrate from the deployed node 5.9.1 to 6.4.7 (node-core 19.x), matching `polytope-labs/subql`'s main after it was synced to upstream. The fork PR is built on that main, so it carries only these behavioural changes as a single commit, not the version history. - -## 2026-08-19 — The standard-amount check bounds plausibility instead of pinning one unit - -Chosen: `resolvePoolLeg` accepts any standard amount within a plausibility window around one whole input token, and `updateLiquidityPools` renormalizes the rate by the leg's own standard amount. The pallet is then free to raise the probe size to buy quote precision without the indexer rescaling every published rate by that factor. - -Alternative rejected — keep `standardAmount === 10 ** inputDecimals`. It made the rate math a single multiplication, but it is what blocked the precision fix: a leg's quoted output integer IS the price, and one whole token of a 6-decimal asset priced into another 6-decimal asset only affords ~3 significant digits. - -Alternative rejected — accept any whole multiple of one unit and carry the multiple as a divisor. Simpler arithmetic, but it silently waves through the exact bug the old check caught: an 18-decimal amount read against a 6-decimal registry entry is a clean multiple (1e12 of them), so it would have been read as a trillion-token probe and published a rate off by 1e12. It also needlessly forbids a non-whole probe, which the renormalization prices correctly. - -Alternative rejected — drop the check entirely. The standard amount is the denominator of every published rate and nothing else in the pipeline notices when it disagrees with the registry's decimals; the failure is silent and needs a human to spot feed drift. The window is deliberately wide enough that no plausible probe size trips it and narrow enough that every realistic decimals mismatch does. - -Not changed, deliberately: the filler floors its quoted output (`computeLegPolicyOutput` in simplex). That truncation looks like a 0.14% pricing error at a one-unit probe and is tempting to "fix" by rounding to nearest — but it is load-bearing. Flooring keeps the published rate at or below the filler's true curve rate, which is what makes a quote built from the snapshot honourable; the SDK quoter derives `amountOut` from `medianPrice / standardAmount`, and the gateway's fully-filled check has zero tolerance, so a published rate even one base unit above the curve turns every order into a partial fill. Precision belongs to the probe size, not the rounding mode. - -## 2026-08-14 — Cumulative seed derives from daily rows, and a failed seed skips only the gateway update (#1085) - -Chosen: `seedAggregateVolume` scans only `DailyVolumeUSD` and derives the aggregate's cumulative record from the per-day sums (`lastUpdatedAt` from their max). The alternative — summing the component `CumulativeVolumeUSD` rows — was the original implementation and was dropped after review: `updateCumulativeVolume` skips same-timestamp updates per record, so a chain-wide aggregate's cumulative drops the second of any two same-block fills (even by different fillers), while per-filler cumulatives only collide within one filler. Seeding from summed filler cumulatives therefore bakes in the equality "FILLED cumulative equals the sum of FILLER cumulatives", which the guard breaks from the first multi-filler block onward. Daily rows have no such guard, count every fill, and are the series the aggregate is paired with. The forward divergence itself is accepted, not fixed — fixing it means removing the cumulative guard, which would change every existing volume series — and is pinned by a test. - -Also chosen: the seed call in `updateOrderStatus` has its own try/catch. A store error during the scan must not swallow the fill's status, points, and user activity (the handler's try/catch is around all of it). On failure, the gateway `updateVolume` call is skipped too, deliberately: writing it would create the cumulative record that doubles as the seed's done-marker, permanently preventing the backfill. Skipping leaves the marker absent so the next fill retries the seed, and the retry recovers the skipped fill's volume because it sums the filler daily rows, which include it. Nothing is lost or double-counted in either outcome. - -Accepted tradeoff, noted for future readers: the scan uses `getByFields([], ...)` with an empty filter, which returns zero rows with no diagnostic signal if something is wrong upstream (the reason `PendingStatusService` moved off empty-filter reads). Acceptable here because the seed runs once per chain per deployment and a wrongly-empty result degrades to a zero seed plus correct forward counting. - -## 2026-08-14 — Gateway volume seeding uses the aggregate cumulative record as its own done-marker (#1085) - -Chosen: `VolumeService.seedAggregateVolume` runs on every fill but returns immediately when the aggregate's `CumulativeVolumeUSD` record exists; the record itself is the marker. Seeding runs lazily on the first fill per chain after deploy, before that fill's own volume updates. - -Alternatives considered: a dedicated migration-marker entity (extra schema and codegen for one boolean, and a marker that can drift from the data it describes); a block handler or one-off script outside event flow (SubQuery has no migration hook, and a script against the store bypasses block-atomic writes). - -Why this won: no schema change, per-chain by construction, and correct in both deployment modes with no configuration — on a resumed database the first fill seeds the full filler history, on a fresh from-genesis reindex the first fill sees no history and seeds a zero marker. Two invariants make the lazy placement safe and are worth preserving: the seed must run before the triggering fill's own `updateVolume` calls (otherwise that fill's filler record is summed and then added again), and the seeded cumulative record must carry the components' max `lastUpdatedAt` rather than the current timestamp (otherwise `updateCumulativeVolume`'s same-timestamp guard drops the triggering fill). Enumeration pages the whole table with `getByFields([], ...)` and filters IDs in code because the volume entities have no filterable columns — acceptable because it runs once per chain per deployment and the tables are small (series count, not event count). - -## 2026-08-13 — Gateway daily volume reuses `DailyVolumeUSD` instead of a new indexed entity (#1085) - -Chosen: record gateway-level filled volume with base ID `IntentGatewayV3.FILLED` through the existing `VolumeService.updateVolume`, landing in the same `DailyVolumeUSD` / `CumulativeVolumeUSD` entities as the per-filler and per-user records. - -Alternative considered: a new `DailyIntentGatewayVolumeUSD` entity keyed `chain-volumeType-date`, written inside `IntentGatewayV3Service.recordOrderVolume` next to `CumulativeIntentGatewayVolumeUSD`. That would give typed, indexed `chain` and `date` columns (the `DailyVolumeUSD` ID is an opaque string with no filterable columns), following the pattern `BandwidthAppDailyConsumption` uses. - -Why the reuse won: the issue explicitly asked for the entry to appear in the existing `dailyVolumeUSDs` query alongside the FILLER and USER rows, it needs no schema change or codegen, and it exactly parallels the existing filler-independent `IntentGatewayV3.USER` record. If consumers later need to filter or sort daily gateway volume server-side, revisit the dedicated-entity alternative. - -## 2026-08-13 — Gateway volume recorded at the same call site as filler volume, not the unconditional handler site (#1085) - -Chosen: the new `IntentGatewayV3.FILLED` update sits directly next to the `IntentGatewayV3.FILLER.
` update inside `updateOrderStatus`, sharing its `getOutputValuesUSD` pricing. - -Alternative considered: `orderFilledV3.event.handler.ts` already calls `recordOrderVolume("FILLED", ...)` unconditionally, even when the fill is indexed before its `OrderPlaced`. Recording there would not miss fill-before-place races. - -Why the shared site won: it guarantees the invariant that the gateway entry equals the sum of the per-filler entries for a given chain and day, because both come from the same pricing call on the same code path. `recordOrderVolume` prices tokens itself and skips tokens with no known price, so its totals can disagree with the filler totals. The cost is inheriting a known gap: when a fill arrives before its `OrderPlaced`, `updateOrderStatus` stores `PendingStatusMetadata` and returns early, and the later replay (`flushPendingStatuses`) restores status only, never volume. The per-filler records already under-count that case; the gateway record under-counts it identically, which keeps the two consistent. - -Known pre-existing quirk (not changed): `updateCumulativeVolume` skips the addition when `lastUpdatedAt` equals the incoming timestamp, so two fills in the same block increment daily volume twice but cumulative volume once. Left as is; changing it would affect every existing volume series. diff --git a/sdk/packages/indexer/docs/ai/Flow.md b/sdk/packages/indexer/docs/ai/Flow.md deleted file mode 100644 index 401120c90..000000000 --- a/sdk/packages/indexer/docs/ai/Flow.md +++ /dev/null @@ -1,181 +0,0 @@ -# Flow - -AI-maintained map of how code paths in `sdk/packages/indexer` actually execute, so that when something breaks you can tell whether the fault is upstream or downstream of where the symptom appears. Only flows that have been read and verified are documented; coverage grows as areas of the package are touched. - -## Substrate schema migrations (verified 2026-09-04, against the forked node-core) - -How a change to `src/configs/schema.graphql` reaches the database. All nodes share one `schema.graphql` and one -`--db-schema=app`; the substrate node runs the forked `polytopelabs/subql-node-substrate` image with -`--allow-schema-migration`, the EVM nodes run the stock `subql-node-ethereum` image. - -1. On boot each node's main thread runs `StoreService.init` (node-core). With the flag off (the EVM nodes) it calls the - schema migration with a `null` baseline, which only emits `CREATE TABLE IF NOT EXISTS` — existing tables and their - columns are left untouched. -2. With the flag on (the substrate node), `init` reads the previously applied schema from the `appliedSchemaSDL` - metadata key, rebuilds it into a baseline `GraphQLSchema`, and diffs it against the current one. Additive changes - (add nullable column, table, index, enum value, relation) are ALTERed in place; the new schema is written back to - `appliedSchemaSDL` in the same transaction as the DDL. -3. A destructive diff (a removed or retyped field, a removed entity — a retype surfaces as remove+add) is refused with a - fatal error unless `SUBQL_ALLOW_DESTRUCTIVE_MIGRATION=true`, because it would drop the column and its data. -4. Compose gates every EVM node behind the substrate node's `/ready` healthcheck, and `init` (including the migration) - completes before `/ready`. So the substrate leader applies the whole shared schema's DDL before any EVM node starts; - the EVM nodes' `CREATE TABLE IF NOT EXISTS` is then a no-op. There is no multi-writer race. - -The engine that performs the ALTERs (`SchemaMigrationService`) already existed; the change was giving it a real baseline -on restart instead of `null`. Adding a field is therefore: edit `schema.graphql`, `pnpm build`, restart. No rename, no -data loss, no reindex. - -## Intent gateway volume indexing (OrderFilled) - -The indexer is a SubQuery project: per-network YAML files in `src/configs/` bind contract addresses and events to handler functions, and generated entity models in `src/configs/src/types/` persist via the SubQuery global `store`. - -1. An `OrderFilled` log from the IntentGatewayV3 contract triggers `handleOrderFilledV3Event` in `src/handlers/events/intentGatewayV3/orderFilledV3.event.handler.ts`. It decodes the log, then makes two independent calls, each in its own try/catch: - - `IntentGatewayV3Service.updateOrderStatus(commitment, FILLED, ..., filler)` — order status, points, user activity, and the per-fill volume records described below. - - `IntentGatewayV3Service.recordOrderVolume("FILLED", outputTokens, timestamp)` — a separate, unconditional cumulative volume path (see the parallel-paths note). - -2. `updateOrderStatus` (`src/services/intentGatewayV3.service.ts`) first loads the `OrderV3Placed` entity by commitment. - - If the order is not indexed yet (fill event arrived before the `OrderPlaced` event, possible across chains), it stores a `PendingStatusMetadata` row and returns early. `flushPendingStatuses` later replays the status onto the order once `OrderPlaced` arrives, but it replays only the status: none of the volume, points, or user-activity effects below happen for that fill. Volume records therefore under-count fill-before-place races, deliberately and equally for filler and gateway records. - - Otherwise it saves the new status and, when the status is FILLED and a filler address is present, gathers the order's output assets (`IOrderV3OutputAsset` rows keyed `commitment-output-N`), prices them with `getOutputValuesUSD` (unknown tokens price as zero), and records volume. - -3. Volume recording first calls `VolumeService.seedAggregateVolume("IntentGatewayV3.FILLED", "IntentGatewayV3.FILLER.")` — a one-time, per-chain initialization that backfills the gateway-level series from the already-indexed per-filler daily records, deriving the gateway cumulative from the same per-day sums (it no-ops once the gateway cumulative record exists, which is its marker). It must stay ahead of the updates below; reordering it after them double-counts the current fill. The seed has its own try/catch: on failure the fill's status, points, and user activity still proceed, and only the gateway volume update below is skipped — that leaves the marker uncreated, so the next fill retries the seed and recovers the skipped fill from the filler daily rows. Then come two `VolumeService.updateVolume` calls with the same USD total and timestamp, differing only in base ID: - - `IntentGatewayV3.FILLER.` — per-filler series. - - `IntentGatewayV3.FILLED` — gateway-level, filler-independent series (issue #1085). - (Order placement, elsewhere in the same service, records the analogous user-side series with base ID `IntentGatewayV3.USER`.) - -4. `VolumeService.updateVolume` (`src/services/volume.service.ts`) fans out to two upserts, both scoping the ID by chain: `getChainTypeId` appends the host state machine (for example `EVM-8453`) resolved from the SubQuery global `chainId`. - - `updateCumulativeVolume` upserts `CumulativeVolumeUSD` with ID `.`. It skips the addition when the record's `lastUpdatedAt` equals the incoming timestamp, so a second fill in the same block does not increment the cumulative counter. This guard fires per record: the chain-wide `IntentGatewayV3.FILLED` cumulative collides on any two same-block fills, even by different fillers, so it can lag the sum of the per-filler cumulatives; the daily series counts every fill and stays exact. - - `updateDailyVolume` upserts `DailyVolumeUSD` with ID `..` (UTC day bucket). It has no same-timestamp guard, so every call increments the daily counter. - - USD amounts are stored as bigints scaled by 1e18 (`toScaledUsd`). - -5. Back in the handler, a third independent try/catch calls `IntentGatewayV3Service.publishInventoryAfterFill`. Unlike the two above it is not a volume path: it re-reads the balances behind the pools this fill traded through on this chain and publishes them for the Hyperbridge node to fold into the pool rows (see the pool liquidity refresh flow below). Like `recordOrderVolume` it runs whether or not the order is indexed yet — but it needs the order row for the source chain, so a fill-before-place race resolves no pool and it returns immediately. - -Parallel paths that look similar but are not the same: `recordOrderVolume` (step 1) writes `IntentGatewayTokenVolume` and `CumulativeIntentGatewayVolumeUSD` (IDs keyed `chain-token-volumeType` / `chain-volumeType`). It does its own token pricing and skips tokens with no known price, while the `updateOrderStatus` path prices unknown tokens as zero through `getOutputValuesUSD`; their USD totals can therefore differ for the same fill. Do not expect `CumulativeIntentGatewayVolumeUSD` for FILLED to equal `CumulativeVolumeUSD` for `IntentGatewayV3.FILLED`: they also diverge on fill-before-place races (only `recordOrderVolume` runs) and same-block fills (only the `VolumeService` cumulative counter deduplicates). - -## Phantom price snapshot to pool rates (PhantomBidWindowExhausted) - -Verified 2026-08-19 against live mainnet data. - -1. `PhantomBidWindowExhausted` on Hyperbridge triggers `handlePhantomOrderPrices` (`src/handlers/events/substrateChains/handlePhantomOrderPrices.handler.ts`). It loads the `PhantomOrderV2` and its registered `PhantomOrderLeg` rows, then calls `aggregatePhantomBids` from the SDK, which fetches every bid for the commitment, verifies each one (solver signature over the userOp hash plus an EIP-7702 delegation check), and reduces them per leg. - - A bid's `paymasterAndData` arrives in one of two shapes, and the SDK's `decodePhantomBidPaymasterAndData` reads both before the declaration is used: the bare declaration blob (every bid until simplex moved to Permit2), or the 234-byte EntryPoint v0.8 payload for the Simplex paymaster's PERMIT2 mode with the declaration appended after the permit (a bid built on simplex's real-bid path since #1223). A sponsored bid with nothing appended counts as having declared nothing — null accepted sources, no positions — the same as an empty field. The solver signature covers the whole field in both shapes, so `recoverBidSignerVm2` is unchanged; `phantom-decode.test.ts` checks the ethers digest over the long payload matches viem's. - - The chain ids in a declaration are decoded in the SDK without `TextDecoder`. That matters here specifically: the handler runs inside SubQuery's vm2 sandbox, where `TextDecoder` is not defined and the `util` fallback rejects a sandbox-created `Uint8Array`, so a decoder reaching for it threw inside the per-bid try/catch of `aggregatePhantomBids` — logged as "Failed to process bid for price snapshot", bid dropped, run continues. That was the whole failure behind bids with a source-chain declaration vanishing from the snapshots (verified 2026-09-09 against the live bids and the deployed indexer's data); `phantom-decode.sandbox.test.ts` runs the shipped bundle inside vm2 to keep it from coming back. A chain with a phantom order but no `solverAccount` in `config-mainnet.json` is skipped with "No SolverAccount configured for chain" — Gnosis (EVM-100) was, until its entry was added. - -2. Per leg, a solver's quote is weighted by **its balance of that leg's OUTPUT token on the destination chain** — the inventory that actually backs the leg. Zero-weight quotes are dropped entirely, not down-weighted: they never reach the median, `bidCount`, or the bidder list. A leg where no bidder holds the output token is absent from the result, exactly as if nobody quoted it. - -3. The leg's price is `weightedMedian` of the backed quotes — a **selection**, not a blend. It returns one bidder's exact integer, so a solver holding over half the leg's weight sets the published price verbatim, and the result can never be a value nobody quoted. `lowestPrice` and `highestPrice` are deliberately overwritten with the median so consumers cannot read an outlier bid as a tradeable bound. - -4. `updateLiquidityPools` (`src/services/liquidityPool.service.ts`) turns those per-leg medians into pool rows. `resolvePoolLeg` maps a leg's tokens to a pool id and direction via the token registry, and the sample's rate is - - ``` - medianPrice * 10 ** (18 - outDecimals) * 10 ** inDecimals / standardAmount - ``` - - i.e. the quote renormalized from the probe size back to one whole input token. This holds for any standard amount the pallet configures; it collapses to `medianPrice * scale` when the probe is exactly one unit. Multiplications happen before the division, so only the last step truncates, by under one unit of 1e18 and downward. - -5. Chain rows (`PoolChainLiquidity`, one per pool/chain/direction) are merged into the pool's single `sellRate`/`buyRate` by `weightedRate` — a depth-weighted **mean**, which unlike the median in step 3 does produce values no filler quoted. Samples older than `MAX_SAMPLE_AGE_BLOCKS` are excluded unless every sample is stale. - -Precision note: a leg's quoted output integer *is* the price, to whatever resolution the output token's decimals allow. cNGN into 6-decimal USDC quotes ~715 base units, so the grid is 1/715 = 0.14% and the filler's floor rounding costs up to one full step. Chains whose output token has 18 decimals carry full precision on the same leg — which is why EVM-56 publishes `716845878136200` where Base publishes a bare `715`. The fix is a larger `standardAmount`, which step 4 now supports; see Decisions.md for why the filler's flooring must stay. - -## Pool liquidity refresh (OrderFilled, PartialFill, EscrowReleased, vault Deposit/Withdraw) - -Verified 2026-09-08 by unit tests against a mocked store (`inventoryReading.service.test.ts` for the EVM half, -`liquidityPoolFold.service.test.ts` for the Hyperbridge half); the store behaviour the split rests on was read in -`@subql/node-core` 19.3.1 and the forked substrate node. - -The snapshot flow measures a pool's depth once per bid window. This flow keeps it honest in between, when -fills have spent some of the inventory it is a sum of. It is split across two kinds of node because each -SubQuery node process serves `Entity.get` from a private cache that no other process's write invalidates and -flushes whole rows with no locking: the pool family is therefore written by the Hyperbridge node only, and the -EVM nodes publish readings for it to fold. - -**EVM side — publish (`src/services/inventoryReading.service.ts`)** - -1. Four events reach it, each in its own try/catch — they read external RPCs, and stale depth is recoverable, so a - failure must never stall indexing: - - `handleOrderFilledEventV3` and `handlePartialFilledEventV3` call `IntentGatewayV3Service.publishInventoryAfterFill`, - which loads the order row for its **source** chain (a fill carries the inputs' addresses but not the chain they - live on), resolves the pools with `poolsForFill`, and calls `publishPoolInventory`. No order row, or no - registry-tracked pair, means nothing to publish — the common case, and what keeps this off most fills' path. - - `handleEscrowReleasedEventV3` (source chain) calls `publishInventoryAfterEscrowRelease`: the solver was just paid - the order's inputs back, so its inventory there ROSE. The event names no filler, so the handler first reads - the gateway's `_filled(commitment)` at that block. - - `YieldVaultService.recordLedger` (vault `Deposit`/`Withdraw`) ends with the same call for (chain, lp, - underlying token), after its own known-solver gate and duplicate-log guard. - The last two name a solver and a token but no pool, so they enter through `publishProviderInventory`. -2. Both entry points select `PoolBidder` rows of **this chain only** with field queries (which go to Postgres; - `get` would serve the process cache), and collapse them to distinct (solver, output token) targets. A target - whose rows were all sampled (`lastUpdatedAt`) or refreshed (`refreshedAt`) after the event is skipped — that - reading already saw what the event moved, and during a resync this is true of every replayed event, so - backfilling costs no RPC. A target whose token the registry no longer tracks is skipped with a warning. -3. Each target's inventory is read pinned to the event's block on this chain: wallet ERC-20 plus ERC-4626 - `maxWithdraw` (`getTotalSolverBalance`) PLUS the Uniswap V4 positions the solver declared, each read on-chain, - owner-checked, and valued in the output token. **If any read fails, or the chain has no configured RPC, - nothing is published**: a failed read looks exactly like a zero balance, and a zero reading drops the bidder. -4. One `SolverInventoryReading` row per target is written, id `{chain}-{token}-{solver}`, holding the raw balance, - the pinned block, the event time as `observedAt`, and the trigger. Nothing else is written here — not the - pool, not the chain row, not the bidder, not the balance series. - -**Hyperbridge side — fold (`foldInventoryReadings` in `src/services/liquidityPool.service.ts`)** - -5. `handleInventoryFold` (`src/handlers/events/liquidity/inventoryFold.block.handler.ts`) runs on every Hyperbridge - block, registered inside the `enableLiquidityIndexing` block of the substrate manifest template so only the - Hyperbridge node runs it. -6. It pages the whole reading table (the store has no range operators) and drops readings an in-process memo has - already folded at that observation time. A quiet block ends here after one page. -7. Per chain with new readings, it loads every `PoolBidder` row of that chain and groups them by pool. A row takes - a reading when provider, chain and output token match (case-insensitively) and the reading's `observedAt` is - later than both the row's `lastUpdatedAt` and its `refreshedAt`. The raw balance is normalised to 1e18 through - the registry; a zero removes the row (every row is a bidder with capacity); otherwise `liquidity` and - `refreshedAt` are written. Readings older than the row's snapshot — a lagging or resyncing EVM node's — are - ignored, which is what makes the fold safe to repeat. -8. Every (pool, chain) that changed has its `PoolChainLiquidity` depth/bidCount/unrestricted slice and its - `PoolRoute` rows recomputed from the surviving bidder rows (`republishChainRows`), with the direction set - taken from before the removals so a direction whose bidders all vanished is zeroed rather than skipped. Routes - are never *created* here: declarations only come from bids, so the surviving set can only shrink. -9. Each pool touched is re-merged into `sellDepth`/`buyDepth` through the same `mergeChainRowsIntoPool` the - snapshot writer uses, with the fold's own block as the staleness reference — it is a Hyperbridge block, the - unit the rows are stamped in. -10. Each reading that applied to at least one row extends `LiquidityProviderBalanceV2` with its raw balance, keyed - by the fold's Hyperbridge block with the reading's `observedAt` as `snapshotTime`. A zero balance is not a row, - matching the sweep; an existing row for that key is only ever raised, never lowered. -11. Nothing here writes `lastUpdatedBlock` or `lastUpdatedAt`, and nothing re-derives a rate. A pool's merged - rate can still move, because the per-chain samples are depth-weighted and the depths just changed. - -Store facts this depends on: a node sees only rows whose historical range contains its own current block time, -so an EVM node ahead of Hyperbridge writes readings the fold sees once Hyperbridge catches up; the mainnet -template flushes the store asynchronously every five seconds, which adds to that lag; and every field used in a -`getByFields` filter must carry `@index` (`chain`, `provider`, `tokenAddress` and `observedAt` on the reading do). - -Where the V4 positions come from: `handlePhantomOrderPrices` calls `recordDeclaredPositions` -(`src/services/solverPositions.service.ts`), which writes one `SolverV4Positions` row per bidding solver — keyed -by the solver's address, holding the tokenIds `aggregatePhantomBids` verified and the chain the bid was for. A -bid is the only place a position is ever named. The EVM-side publication reads that row through a field query on -its provider link (`declaredV4Positions`) — it is written on another node, so `get` would serve a stale cached -copy, and the id itself is only indexed in historical mode — and a row recorded on another chain reads as none. A solver that bids without declaring has its row emptied; one that -skips a window keeps it. One row per solver assumes one V4 chain, and the writer warns rather than overwriting a -row from a different one. - -## Phantom bid calldata decoding (`extractFillDataVm2`) - -Verified by executing the function against both shapes; the selector check is what makes the two-interface -attempt necessary. - -1. `handlePhantomOrderPrices.handler.ts` injects `extractFillDataVm2` into `aggregatePhantomBids` as - `extractFill`. The SDK's own `extractFillData` is not used here: it decodes with viem, whose byte handling - throws inside SubQuery's VM2 sandbox. -2. A bid's `callData` is the solver account's ERC-7821 `execute(mode, executionData)` batch. The batch is decoded, - and each call whose `target` is the gateway is a `fillOrder` candidate. The bid's sender must be - EIP-7702-delegated to one of the chain's `SOLVER_ACCOUNT_ADDRESSES`; the list carries the current - SolverAccount and, during a redeployment, the one it replaced. The bid's sender must be - EIP-7702-delegated to one of the chain's `SOLVER_ACCOUNT_ADDRESSES`; the list carries the current - SolverAccount and, during a redeployment, the one it replaced. -3. `decodeFillOrderEither` tries the v2 interface (`FILL_ORDER_ABI`, with `validUntil`, selector `0xa5470064`) - and then the v1 one (`FILL_ORDER_V1_ABI`, selector `0x5cfb1ea5`). ethers validates the selector before - decoding, so exactly one can match and there is no payload that could be mis-decoded as the other shape. - Which one a bid carries depends on the gateway it targets — solvers encode for the deployment they bid - against, and gateways predating `validUntil` take v1. -4. A call matching neither shape is skipped, and if no call in the batch decodes the function returns null. -5. **Null is dropped silently upstream** — `aggregatePhantomBids` does `if (!fillData) continue` with no log. A - decoding regression therefore shows up as missing pool rates rather than as an error, which is why the shapes - are covered by tests rather than left to runtime observation. diff --git a/sdk/packages/indexer/docs/ai/README.md b/sdk/packages/indexer/docs/ai/README.md new file mode 100644 index 000000000..6ed560f59 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/README.md @@ -0,0 +1,22 @@ +# AI workflow docs — `sdk/packages/indexer` + +AI-maintained notes on this package. One file per entry: two concurrent PRs add +different files, so they never conflict. Never collect entries back into a +shared file. + +- `changelog/` — one file per AI-assisted code change, named + `YYYY-MM-DD-short-title.md`. Say what changed and why in a few sentences, then + a `Files:` line listing the files touched. +- `decisions/` — one file per non-obvious choice, named the same way. Give the + decision, the alternatives considered, and why they lost. Read these before + changing related code, so a later change does not silently undo a deliberate + trade-off. +- `flows/` — one file per code path, named for the flow itself and with no date, + because these are edited in place as the code changes. Only document a flow + you have read and verified. + +Each file opens with an `#` heading. Changelog and decision names sort +chronologically, so `ls changelog/` reads oldest first and `ls -r` newest first. + +This is not the release changelog — that is `CHANGELOG.md` at the package +root, generated by changesets. diff --git a/sdk/packages/indexer/docs/ai/changelog/2026-08-13-gateway-level-daily-filled-volume-1085.md b/sdk/packages/indexer/docs/ai/changelog/2026-08-13-gateway-level-daily-filled-volume-1085.md new file mode 100644 index 000000000..012df9a5f --- /dev/null +++ b/sdk/packages/indexer/docs/ai/changelog/2026-08-13-gateway-level-daily-filled-volume-1085.md @@ -0,0 +1,5 @@ +# 2026-08-13 — Gateway-level daily filled volume (#1085) + +Daily filled volumes were only recorded per filler (`IntentGatewayV3.FILLER.
..`), so there was no single entry showing total volume filled through the intent gateway per chain per day. Added one `VolumeService.updateVolume` call with the filler-independent base ID `IntentGatewayV3.FILLED`, in the same FILLED branch of `updateOrderStatus` that records the per-filler volume. This produces `DailyVolumeUSD` records like `IntentGatewayV3.FILLED.EVM-8453.2026-07-30` and a `CumulativeVolumeUSD` record `IntentGatewayV3.FILLED.EVM-8453`, queryable from the existing `dailyVolumeUSDs` query. Also added the first unit tests for `VolumeService`. + +Files: `src/services/intentGatewayV3.service.ts`, `src/services/__tests__/volume.service.test.ts` (new). diff --git a/sdk/packages/indexer/docs/ai/changelog/2026-08-14-seed-cumulative-from-daily-rows-isolate-seed-failures-review.md b/sdk/packages/indexer/docs/ai/changelog/2026-08-14-seed-cumulative-from-daily-rows-isolate-seed-failures-review.md new file mode 100644 index 000000000..58b48966d --- /dev/null +++ b/sdk/packages/indexer/docs/ai/changelog/2026-08-14-seed-cumulative-from-daily-rows-isolate-seed-failures-review.md @@ -0,0 +1,5 @@ +# 2026-08-14 — Seed cumulative from daily rows; isolate seed failures (review fixes on #1085) + +Review of the seeding change found the cumulative seed was computed on a basis the running code does not maintain: `updateCumulativeVolume` drops same-timestamp updates per record, so the chain-wide `IntentGatewayV3.FILLED` cumulative loses the second of any two same-block fills (even by different fillers) while per-filler cumulatives only collide within one filler. Seeding from summed filler cumulatives therefore assumed an equality the guard breaks going forward. The seed now derives the cumulative from the guard-free daily buckets it already aggregates, deleting the second table scan; paging reuses the existing `readAllPages` helper instead of a hand-rolled loop. The seed call is also wrapped in its own try/catch so a store error cannot swallow the fill's status, points, and user activity — on failure only the gateway volume update is skipped, leaving the marker uncreated so the next fill retries the seed and recovers the skipped fill from the filler daily rows. Added a test pinning the known same-block divergence between the FILLED cumulative and the daily/per-filler series. + +Files: `src/services/volume.service.ts`, `src/services/intentGatewayV3.service.ts`, `src/services/__tests__/volume.service.test.ts`. diff --git a/sdk/packages/indexer/docs/ai/changelog/2026-08-14-seed-gateway-volume-from-existing-filler-history-1085.md b/sdk/packages/indexer/docs/ai/changelog/2026-08-14-seed-gateway-volume-from-existing-filler-history-1085.md new file mode 100644 index 000000000..dd51b2174 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/changelog/2026-08-14-seed-gateway-volume-from-existing-filler-history-1085.md @@ -0,0 +1,5 @@ +# 2026-08-14 — Seed gateway volume from existing filler history (#1085) + +On a resumed database the new `IntentGatewayV3.FILLED` series would have started from zero while the per-filler series already carried history. Added `VolumeService.seedAggregateVolume(aggregateBaseId, componentIdPrefix)`: a one-time, per-chain initialization that sums every existing `CumulativeVolumeUSD` and `DailyVolumeUSD` record matching the component prefix into the aggregate's cumulative record and per-day buckets. The aggregate's cumulative record doubles as the done-marker. Called from `updateOrderStatus` before the fill's own volume updates so the seed never counts the triggering fill, and the seeded record keeps the components' max `lastUpdatedAt` so the cumulative same-timestamp guard does not swallow that fill. Extended the `VolumeService` tests to cover seeding. + +Files: `src/services/volume.service.ts`, `src/services/intentGatewayV3.service.ts`, `src/services/__tests__/volume.service.test.ts`. diff --git a/sdk/packages/indexer/docs/ai/changelog/2026-08-19-pool-rates-renormalize-by-the-leg-s-own-standard-amount.md b/sdk/packages/indexer/docs/ai/changelog/2026-08-19-pool-rates-renormalize-by-the-leg-s-own-standard-amount.md new file mode 100644 index 000000000..955b4ff67 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/changelog/2026-08-19-pool-rates-renormalize-by-the-leg-s-own-standard-amount.md @@ -0,0 +1,13 @@ +# 2026-08-19 — Pool rates renormalize by the leg's own standard amount + +`resolvePoolLeg` used to require `standardAmount === 10 ** inputDecimals` exactly, and `updateLiquidityPools` derived a chain sample as `medianPrice * scale`, which silently assumes that same one-unit probe. Both now work for whatever standard amount the phantom order carries: the rate is `medianPrice * scale * 10 ** inDecimals / standardAmount`, exact for any probe size, whole-token or not, and it collapses to the old expression when the probe is one unit. Verified against production: the four live one-unit rates (Base cNGN/USDC both directions, BSC cNGN/USDT both directions) reproduce byte-for-byte, and a 1000x probe with a 1000x quote yields the identical rate. + +Motivation is quote precision on the pallet side, not the indexer's: a leg's output integer IS the published price, so one whole cNGN priced into 6-decimal USDC quotes ~715 base units and the price grid is 1/715 = 0.14% coarse. A curve of 1398 cNGN/USDC cannot publish finer than 1398.6014. At a 1000-token probe the same quote is 715,307 and the error drops to 0.00008%. The indexer had to stop hard-coding the one-unit assumption before that bump was possible. + +The exact-value check was the only tripwire for a registry/pallet decimals disagreement, so it was replaced rather than deleted: the probe must now be plausible relative to one whole input token — at most `MAX_STANDARD_UNITS` (1e6) of them and at least `1/MAX_STANDARD_SUBDIVISIONS` (1/1e3) of one. Every realistic mismatch (6 vs 18, 6 vs 12, 8 vs 18) is a factor of 1e6 or more and still refuses attribution; the existing BSC test for an 18-decimal amount copied onto 6-decimal cNGN still passes, and a new test covers the same bug seen from the other side. + +`ResolvedPoolLeg` gained `inDecimals` for the renormalization. The SDK's `PhantomSnapshotQuoter` needed no change — it already divides by `snapshot.standardAmount`. + +The renormalization is exported as `poolRateFromQuote(medianPrice, resolved, standardAmount)` rather than living inline in `updateLiquidityPools`, which needs SubQuery store mocks to exercise. It is the one piece of arithmetic a wrong probe size would silently corrupt, so it is now a pure function pinned directly by tests: the four live mainnet rates at the deployed 1-unit probe, the identical rates at a 1000-unit probe, the extra digits the bump buys, and a non-whole 1.5-token probe. The 1-unit cases matter most — they are the currently deployed configuration, and at exactly one whole unit the two powers in the formula cancel, so those tests are the proof that raising the probe size elsewhere cannot move a published rate. + +Files: `src/services/liquidityPool.service.ts`, `src/services/__tests__/liquidityPool.service.test.ts`. diff --git a/sdk/packages/indexer/docs/ai/changelog/2026-08-26-substrate-chains-run-on-the-polytope-build-of-the-subquery-node.md b/sdk/packages/indexer/docs/ai/changelog/2026-08-26-substrate-chains-run-on-the-polytope-build-of-the-subquery-node.md new file mode 100644 index 000000000..13ba48bd8 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/changelog/2026-08-26-substrate-chains-run-on-the-polytope-build-of-the-subquery-node.md @@ -0,0 +1,5 @@ +# 2026-08-26 — Substrate chains run on the Polytope build of the SubQuery node (#1163) + +The stock `subquerynetwork/subql-node-substrate` image could not survive an RPC interruption: after a websocket drop it gave the endpoint five reconnect attempts and then exited, and any request that failed while the socket was down (a block fetch or a mapping handler read) took the process down immediately; both providers also cached rejected request promises, so retries replayed the stale error. Over http it never retried a 429 from the rate-limited hosted RPC. The substrate image is now `polytopelabs/subql-node-substrate:v6.4.7-0`, built from the `polytope-labs/subql` fork with those behaviours changed (no response caching, requests wait for the reconnect, unbounded reconnect with capped backoff, http retries honouring Retry-After with a client-wide pause). The EVM image is unchanged. + +Files: `scripts/generate-compose.ts`, `docker/docker-compose.local.yml`, `docker/docker-compose.nexus-ci.yml`. diff --git a/sdk/packages/indexer/docs/ai/changelog/2026-08-28-decode-phantom-bids-of-either-fillorder-shape.md b/sdk/packages/indexer/docs/ai/changelog/2026-08-28-decode-phantom-bids-of-either-fillorder-shape.md new file mode 100644 index 000000000..d29223341 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/changelog/2026-08-28-decode-phantom-bids-of-either-fillorder-shape.md @@ -0,0 +1,26 @@ +# 2026-08-28 — Decode phantom bids of either `fillOrder` shape + +`extractFillDataVm2` built one ethers `Interface` from `FILL_ORDER_ABI`. When `FillOptions` gained `validUntil` +that ABI moved to the v2 shape, and since ethers validates the selector before decoding, every v1-shaped bid +started throwing. The throw was swallowed by a `continue`, the function returned null, and `aggregatePhantomBids` +drops a null bid with no log line — so the loss was silent. + +That is not a future risk: `@hyperbridge/sdk` is a `workspace:*` dependency, so the indexer picked up the v2 ABI +immediately, while every mainnet gateway still runs the pre-`validUntil` implementation. `getFillOptionsVersion` +therefore returns 1 and simplex encodes v1 bids, all of which were being discarded — no bidder rows, no median, +no pool-rate snapshot from any of them. + +Now tries both shapes, mirroring the SDK's `decodeFillOrder`. The selectors differ (`0x5cfb1ea5` vs +`0xa5470064`), so neither can mis-decode the other's payload. The v1 ABI is imported from the SDK rather than +re-declared here, so the two cannot drift apart again. + +Verified by running the real function against both shapes: before the change v1 returned null and v2 decoded; +after, both decode, and a wrong target or non-`fillOrder` calldata still returns null. + +Also adds `transformIgnorePatterns` to the jest config. The SDK's CJS bundles `require()` ESM-only packages +(`p-queue` -> `eventemitter3`/`p-timeout`, `lodash-es`), which jest cannot parse untransformed, so *any* test +importing `@hyperbridge/sdk` or its `intents-helpers` sub-path failed to load at all against a freshly built SDK. +That was blocking the new test, and it was also silently keeping the existing `phantom-decode.test.ts` from +running — that file passes again now. + +Files: `src/utils/phantom-decode.ts`, `src/utils/__tests__/phantom-decode.fill.test.ts`, `jest.config.ts`. diff --git a/sdk/packages/indexer/docs/ai/changelog/2026-09-01-refresh-a-pool-s-lp-balances-on-every-order-fill.md b/sdk/packages/indexer/docs/ai/changelog/2026-09-01-refresh-a-pool-s-lp-balances-on-every-order-fill.md new file mode 100644 index 000000000..15bad4fa0 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/changelog/2026-09-01-refresh-a-pool-s-lp-balances-on-every-order-fill.md @@ -0,0 +1,59 @@ +# 2026-09-01 — Refresh a pool's LP balances on every order fill + +A pool's published depth is the sum of its bidders' output-token inventory, measured when the last phantom bid +window closed. A fill spends some of that inventory, so between windows the depth advertises capacity the +solvers no longer hold — and the depth is what a taker sizes an order against. + +`OrderFilled` now re-reads the balances of every LP recorded as backing the pools the fill traded through and +republishes the depths from them: `PoolBidder.liquidity`, `PoolChainLiquidity.depth`/`bidCount`/unrestricted +slice, `PoolRoute.depth`/`bidCount`, and the pool's own `sellDepth`/`buyDepth`/bid counts. A bidder left holding +nothing loses its row, so the "every row is a bidder with capacity" invariant survives, and the routes it alone +declared go with it. Rates are untouched — nothing here observes a quote — though a pool's merged rate can still +move, because the chains are weighted by the depths that just changed. + +`PartialFill` refreshes on the same terms, through the same service method: it spends inventory identically, and +being the same-chain path (`IntrinsicIntents`) its source and destination are one chain. Cross-chain fills are +all-or-nothing, so between the two handlers every fill that moves inventory now triggers a refresh. + +The pools are resolved from the order's own two sides: the input symbols on its source chain paired with the +output symbols on the destination chain, via the same token registry the snapshot path uses. An order in +untracked assets resolves to no pool and costs nothing, which is most of them. + +Two guards keep the cost bounded and the data honest. A pool whose last snapshot postdates the fill is skipped +entirely — that snapshot already read balances this fill had moved, and during a resync it is every pool, so +replaying history triggers no RPC at all. And a chain whose balances cannot be read in full is left exactly as +indexed rather than partially republished: a failed read is indistinguishable from a zero balance, so half a +chain would report the unread bidders as departed. + +The refresh also extends `LiquidityProviderBalanceV2` with what it read, so a provider's latest balance row does +not keep reporting inventory the pool rows already know is spent. That series is keyed by Hyperbridge block and a +fill has none, so it borrows Hyperbridge's head via `chain_getHeader` on the configured node — one read per +indexed block, shared by every fill in it. Two readings on one key resolve to the larger, the same rule the sweep +follows, because a refresh can never see a solver's Uniswap V4 positions and the smaller reading is the +incomplete one. An unreachable Hyperbridge node costs the data point, not the refresh. + +`lastUpdatedBlock`/`lastUpdatedAt` are deliberately not moved anywhere. They date the snapshot that priced the +pool, in Hyperbridge blocks; a fill carries an EVM block number of another chain, which is not comparable with +them and would wreck the `MAX_SAMPLE_AGE_BLOCKS` staleness filter. + +The balance-reader wiring (`setAggregationFetch(safeFetch)`, the per-chain RPC map, the per-block memo) moved out +of `handlePhantomOrderPrices.handler.ts` into `src/utils/solverBalance.ts` so both paths read balances the same +way; the memo key is now a string so it can identify a block of either chain. The pool-level merge and the +unrestricted-slice split were extracted into `mergeChainRowsIntoPool` and `bidderDepths`, shared by the snapshot +writer and the refresh — the two must publish the same numbers from the same bidders. + +Unrelated but in the way: `phantomOrder.handlers.test.ts` was red before this change — all 14 of its +`handlePhantomOrderPrices` cases threw `sortPoolSymbols is not a function`. Its `@hyperbridge/sdk/intents-helpers` +mock lists only the three entry points the handlers call, and the pool-token registry re-exports its symbol +ordering from that same module, so mocking it dropped `poolSlug`/`sortPoolSymbols` and every pool attribution +threw. The mock now spreads `jest.requireActual` and overrides only those three; importing the SDK under jest has +worked since `transformIgnorePatterns` was added. That suite covers the handler wiring this change moved, so it +had to run to verify the move — all 16 cases pass. + +Files: `src/services/liquidityPool.service.ts`, `src/services/intentGatewayV3.service.ts`, +`src/handlers/events/intentGatewayV3/orderFilledV3.event.handler.ts`, +`src/handlers/events/intentGatewayV3/partialFilledV3.event.handler.ts`, +`src/handlers/events/substrateChains/handlePhantomOrderPrices.handler.ts`, `src/utils/solverBalance.ts` (new), +`src/configs/schema.graphql` (descriptions only), +`src/services/__tests__/liquidityPoolRefresh.service.test.ts` (new), +`src/handlers/events/substrateChains/__tests__/phantomOrder.handlers.test.ts`. diff --git a/sdk/packages/indexer/docs/ai/changelog/2026-09-01-refresh-liquidity-on-escrow-releases-and-vault-events-and-value.md b/sdk/packages/indexer/docs/ai/changelog/2026-09-01-refresh-liquidity-on-escrow-releases-and-vault-events-and-value.md new file mode 100644 index 000000000..29c739fa7 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/changelog/2026-09-01-refresh-liquidity-on-escrow-releases-and-vault-events-and-value.md @@ -0,0 +1,50 @@ +# 2026-09-01 — Refresh liquidity on escrow releases and vault events, and value declared V4 positions (#1159) + +Follow-up to #1192, which refreshed a pool's LPs on `OrderFilled`/`PartialFill`. Four things were still missing. + +**Uniswap V4 positions are recorded and re-read.** A bid's `paymasterAndData` is the only place a position is +ever named, so a balance re-read could not see that inventory at all — and carrying the last sweep's VALUE +forward would be worse, because simplex funds fills out of these positions: such a fill drains the position +inside the fill transaction while wallet and vault balances barely move. The aggregation now reports the +tokenIds it verified, and `handlePhantomOrderPrices` records them in a new `SolverV4Positions` entity: one row +per solver, keyed by its address, replaced wholesale each time that solver bids. The refresh reads that one row +and re-values each position at the event's block. A solver that bids without declaring has its row emptied; one +that skips a window keeps its row, and a position it has since burned or sold reads back as no longer owned, +which is the check every consumer applies before valuing one. + +**Reads are pinned to the event's block.** `getTotalSolverBalance` is exported from the SDK with a block tag and +`memoizedSolverBalance` takes a per-chain map of them, so an event's re-read returns the same value on a replay +as it did live. Only the event's own chain is pinned: a refresh reaches across every chain the pool is quoted on +and a block number means nothing on any other. + +**Escrow releases refresh too.** `EscrowReleased` on the source chain pays the filler the order's inputs back, so +its inventory there rose and every pool it backs in those tokens is understating depth. The event names no +filler, so the handler reads the gateway's `_filled(commitment)` mapping at that block — `_withdraw` writes the +beneficiary in the same call that emits the event, so it never depends on the destination chain's node having +indexed the fill first. + +**Vault deposits and withdrawals refresh too.** `YieldVaultService.recordLedger` ends with a refresh for +(chain, lp, underlying token). An LP moving its own principal only shifts inventory between the raw and vault +halves of one total, which the re-read confirms rather than changes, but the total does move when the +counterparty is someone else — a treasury funding the solver, or inventory leaving it — and no order event +reports that at all. + +Those last two name a solver and a token, never a pool, so they reach the refresh through a new +`refreshProviderLiquidity` entry point. It shares everything with the pool-scoped one, which meant one change to +the shared core: depths are now re-summed from the stored bidder rows of each (pool, chain) rather than from the +rows that were re-read, so refreshing one solver leaves the others contributing exactly what they were. + +The only schema change is the new `SolverV4Positions` entity — additive, and with no `@derivedFrom` field added +to `LiquidityProvider`, so nothing about an existing entity changes. Deliberately not done, per #1159 §3, for +that same reason: the `trigger` enum, the nullable `transactionHash`, the +`{chain}-{token}-{solver}-{blockNumber}` id shape for event-triggered rows, and ordering "current liquidity" by +`snapshotTime` instead of `blockNumber` all alter a live entity. Event rows keep borrowing Hyperbridge's head +block, and the design is left as a comment on `recordProviderBalances` for whenever a migration is on the table. + +Files: `src/configs/schema.graphql`, `src/services/liquidityPool.service.ts`, +`src/services/solverPositions.service.ts` (new), `src/services/intentGatewayV3.service.ts`, +`src/services/yieldVault.service.ts`, `src/utils/solverBalance.ts`, +`src/handlers/events/intentGatewayV3/escrowReleasedV3.event.handler.ts`, +`src/handlers/events/substrateChains/handlePhantomOrderPrices.handler.ts`, +`src/services/__tests__/liquidityPoolRefresh.service.test.ts`, +`src/handlers/events/substrateChains/__tests__/phantomOrder.handlers.test.ts`. diff --git a/sdk/packages/indexer/docs/ai/changelog/2026-09-02-reconcile-declarations-against-the-verified-solver-set-review.md b/sdk/packages/indexer/docs/ai/changelog/2026-09-02-reconcile-declarations-against-the-verified-solver-set-review.md new file mode 100644 index 000000000..564257a20 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/changelog/2026-09-02-reconcile-declarations-against-the-verified-solver-set-review.md @@ -0,0 +1,18 @@ +# 2026-09-02 — Reconcile declarations against the verified solver set (review fixes on #1194) + +Review found the declaration reconciliation keyed off the wrong set. `recordDeclaredPositions` was passed the +solvers appearing in `lpBalances` or `positions`, and both are filtered: the sweep skips tokens a solver does not +hold, and `positions` only lists what was declared. A solver that bid while holding nothing anywhere and +declaring nothing was in neither, so its previous declaration was never emptied and the refresh kept valuing +positions it had stopped offering — and a V4-funded solver with a near-empty wallet is exactly that profile. + +`aggregatePhantomBids` now returns `solvers`, the verified bidders it already tracked internally to stop one +solver's bid counting once per funded filler, and the handler reconciles against that. + +Also drops `IntentGatewayV3Service.refreshLiquidityAfterVaultEvent`, which was never called: `recordLedger` +reaches `refreshProviderLiquidity` through `liquidityRefreshContext` directly, which is the right seam — a vault +event has nothing to do with the intent gateway. + +Files: `src/handlers/events/substrateChains/handlePhantomOrderPrices.handler.ts`, +`src/services/solverPositions.service.ts`, `src/services/intentGatewayV3.service.ts`, +`src/handlers/events/substrateChains/__tests__/phantomOrder.handlers.test.ts`. diff --git a/sdk/packages/indexer/docs/ai/changelog/2026-09-04-in-place-substrate-schema-migrations-1163-follow-up.md b/sdk/packages/indexer/docs/ai/changelog/2026-09-04-in-place-substrate-schema-migrations-1163-follow-up.md new file mode 100644 index 000000000..c14a040ab --- /dev/null +++ b/sdk/packages/indexer/docs/ai/changelog/2026-09-04-in-place-substrate-schema-migrations-1163-follow-up.md @@ -0,0 +1,8 @@ +# 2026-09-04 — In-place substrate schema migrations (#1163 follow-up) + +Enabled additive schema migrations for the substrate node so a new field on an existing entity no longer forces a +rename that orphans the old table's data. The behaviour lives in the forked node image (`polytopelabs/subql-node-substrate`), +bumped to `v6.4.8-0`; this repo turns it on with `--allow-schema-migration` on the substrate node only (it is the +health-gated leader every EVM node waits on, and the shared `schema.graphql` covers all entities). Destructive changes +(drop or retype a field, drop an entity) stay refused unless `SUBQL_ALLOW_DESTRUCTIVE_MIGRATION=true`. +Files: scripts/generate-compose.ts, scripts/templates/partials/docker-command.hbs, docker/docker-compose.local.yml, docker/docker-compose.nexus-ci.yml, README.md, docs/ai/Decisions.md, docs/ai/Flow.md. diff --git a/sdk/packages/indexer/docs/ai/changelog/2026-09-05-drop-the-overloaded-quote-from-the-intentgatewayv3-abi-so-subql.md b/sdk/packages/indexer/docs/ai/changelog/2026-09-05-drop-the-overloaded-quote-from-the-intentgatewayv3-abi-so-subql.md new file mode 100644 index 000000000..0bb0a7684 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/changelog/2026-09-05-drop-the-overloaded-quote-from-the-intentgatewayv3-abi-so-subql.md @@ -0,0 +1,10 @@ +# 2026-09-05 — Drop the overloaded `quote` from the IntentGatewayV3 ABI so subql codegen compiles + +The gateway ABI regenerated in PR #1207 carried both `quote` overloads inherited from `HyperApp` +(`quote(DispatchPost)` and `quote(DispatchGet)`). subql's codegen names the generated transaction +type after the function alone, so it emitted `Quote_tuple_Transaction` twice and `subql build` +failed with TS2300 in CI. The indexer never calls `quote`, so both entries are removed from +`IntentGatewayV3.abi.json`; the events and the functions the handlers read are unchanged. +Verified with the `ENV=local` codegen chain CI runs. + +Files: `src/configs/abis/IntentGatewayV3.abi.json`, `docs/ai/ChangeLog.md`. diff --git a/sdk/packages/indexer/docs/ai/changelog/2026-09-05-recognise-both-the-current-and-the-previous-solveraccount-per.md b/sdk/packages/indexer/docs/ai/changelog/2026-09-05-recognise-both-the-current-and-the-previous-solveraccount-per.md new file mode 100644 index 000000000..008f47815 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/changelog/2026-09-05-recognise-both-the-current-and-the-previous-solveraccount-per.md @@ -0,0 +1,15 @@ +# 2026-09-05 — Recognise both the current and the previous SolverAccount per chain + +`solverAccount` in `config-{mainnet,testnet}.json` accepts one address or a list; the generated +`SOLVER_ACCOUNT_ADDRESSES` is `Record`. `YieldVaultService.isDelegatedSolver` +and the phantom price handler count an account when it delegates to any listed address, the +latter by passing the list to the sdk's `aggregatePhantomBids`, which now accepts one or several. +Mainnet lists the 2026-09-05 SolverAccount `0x7cb55539d1144F62422099c3FA3405092022c88C` first and +the replaced `0xfCd233b937D7622AAc63ced3C9A1A12F4a6B64E3` second on the six chains that had an +entry, so solvers keep counting while they re-delegate. Drop the old address once they have. + +Files: `src/configs/index.ts`, `src/configs/config-mainnet.json`, `scripts/generate-chain-yamls.ts`, +`src/solver-account-addresses.ts`, `src/services/yieldVault.service.ts`, +`src/handlers/events/substrateChains/handlePhantomOrderPrices.handler.ts`, +`src/handlers/events/substrateChains/__tests__/phantomOrder.handlers.test.ts`, `docs/ai/ChangeLog.md`, +`docs/ai/Flow.md`. diff --git a/sdk/packages/indexer/docs/ai/changelog/2026-09-08-drop-the-handlepriceindexing-block-handler-rename-its-template.md b/sdk/packages/indexer/docs/ai/changelog/2026-09-08-drop-the-handlepriceindexing-block-handler-rename-its-template.md new file mode 100644 index 000000000..f3a25cef2 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/changelog/2026-09-08-drop-the-handlepriceindexing-block-handler-rename-its-template.md @@ -0,0 +1,4 @@ +# 2026-09-08 — Drop the `handlePriceIndexing` block handler; rename its template flag + +The Hyperbridge node no longer runs `handlePriceIndexing`, the block handler that every ten blocks called `TokenPriceService.initializePriceIndexing` to refresh every registry token's CoinGecko price. The handler file, its export from `mappingHandlers.ts`, and its entry in the substrate manifest template are removed. Prices are still fetched on demand: `TokenPriceService.getPrice`, which the transfer and volume paths read through, fetches and stores a price when none is held. The template flag that gated it, `enablePriceIndexing`, is renamed `enableLiquidityIndexing`: what it now gates is the phantom order handlers and the inventory fold, the handlers that produce the liquidity pool rows, on the Hyperbridge node outside testnet. +Files: `scripts/generate-chain-yamls.ts`, `scripts/templates/substrate-chain.yaml.hbs`, `src/mappings/mappingHandlers.ts`, `src/handlers/events/price/handlePriceIndexing.event.handler.ts` (deleted), `docs/ai/Decisions.md`, `docs/ai/Flow.md`. diff --git a/sdk/packages/indexer/docs/ai/changelog/2026-09-08-one-writer-per-pool-row-evm-nodes-publish-inventory-readings.md b/sdk/packages/indexer/docs/ai/changelog/2026-09-08-one-writer-per-pool-row-evm-nodes-publish-inventory-readings.md new file mode 100644 index 000000000..46028aad7 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/changelog/2026-09-08-one-writer-per-pool-row-evm-nodes-publish-inventory-readings.md @@ -0,0 +1,19 @@ +# 2026-09-08 — One writer per pool row: EVM nodes publish inventory readings, Hyperbridge folds them (#1214) + +The pool family (`LiquidityPool`, `PoolChainLiquidity`, `PoolBidder`, `PoolRoute`, `LiquidityProviderBalanceV2`) is +now written by the Hyperbridge node only. An EVM event that moves a solver's inventory (order fill, partial fill, +escrow release, vault deposit or withdrawal) re-reads the solver's balance on its own chain, pinned to the event's +block, and writes a `SolverInventoryReading` row that only that chain's node writes. A new block handler on the +Hyperbridge node, `handleInventoryFold`, folds those readings into the pool rows every block; a reading applies to +a bidder row only if it postdates the row's snapshot time and the new nullable `PoolBidder.refreshedAt`, so the +fold is idempotent. This removes the concurrent read-modify-write of the same pool rows by several node +processes, each serving `get` from a process-local cache that no other node's write invalidates. The +`SolverV4Positions` read on the EVM side goes through a field query for the same reason. The EVM-side read of +Hyperbridge's head block is gone: the fold stamps balance-series rows with its own block. +Files: src/configs/schema.graphql, src/services/inventoryReading.service.ts (new), src/services/liquidityPool.service.ts, +src/services/solverPositions.service.ts, src/services/intentGatewayV3.service.ts, src/services/yieldVault.service.ts, +src/utils/solverBalance.ts, src/handlers/events/liquidity/inventoryFold.block.handler.ts (new), +src/handlers/events/intentGatewayV3/orderFilledV3.event.handler.ts, partialFilledV3.event.handler.ts, +escrowReleasedV3.event.handler.ts, src/mappings/mappingHandlers.ts, scripts/templates/substrate-chain.yaml.hbs, +src/services/__tests__/inventoryReading.service.test.ts (new), src/services/__tests__/liquidityPoolFold.service.test.ts (new), +src/services/__tests__/liquidityPoolRefresh.service.test.ts (removed), docs/ai/ChangeLog.md, docs/ai/Decisions.md, docs/ai/Flow.md. diff --git a/sdk/packages/indexer/docs/ai/changelog/2026-09-09-phantom-bid-aggregation-reads-permit2-sponsored-bids-as-well-as.md b/sdk/packages/indexer/docs/ai/changelog/2026-09-09-phantom-bid-aggregation-reads-permit2-sponsored-bids-as-well-as.md new file mode 100644 index 000000000..3094ec857 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/changelog/2026-09-09-phantom-bid-aggregation-reads-permit2-sponsored-bids-as-well-as.md @@ -0,0 +1,4 @@ +# 2026-09-09 — Phantom bid aggregation reads Permit2-sponsored bids as well as bare-declaration ones + +Simplex authorizes every sponsored UserOp through a per-op Permit2 signature now (#1223), so a phantom bid built on its real-bid path carries the Simplex paymaster's 234-byte PERMIT2-mode paymasterAndData with the declaration appended, where the aggregation previously expected the declaration alone. The decoding lives in the SDK's `aggregatePhantomBids`, which the indexer runs unchanged: `decodePhantomBidPaymasterAndData` now tries the bare declaration first and the sponsored shape second, and a sponsored bid with no tail is counted as having declared nothing. No handler code changed. Two indexer tests pin the parts that are the indexer's own: `recoverBidSignerVm2` (ethers) recovers the solver from a bid whose paymasterAndData is the long sponsored payload exactly as viem does, and the `intents-helpers` sub-path decodes a payload packed with ethers' `solidityPack` the way simplex packs it with viem. +Files: `src/utils/__tests__/phantom-decode.test.ts`, `src/utils/__tests__/phantom-decode.paymaster.test.ts`, `docs/ai/Decisions.md`, `docs/ai/Flow.md`. diff --git a/sdk/packages/indexer/docs/ai/changelog/2026-09-09-root-cause-of-phantom-bids-dropped-as-failed-to-process-bid-for.md b/sdk/packages/indexer/docs/ai/changelog/2026-09-09-root-cause-of-phantom-bids-dropped-as-failed-to-process-bid-for.md new file mode 100644 index 000000000..2d8d3fff0 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/changelog/2026-09-09-root-cause-of-phantom-bids-dropped-as-failed-to-process-bid-for.md @@ -0,0 +1,4 @@ +# 2026-09-09 — Root cause of phantom bids dropped as "Failed to process bid for price snapshot"; Gnosis SolverAccount + +From block 11823329 the Base snapshots counted one solver instead of three, and the EVM-1 and EVM-56 windows stopped producing snapshots entirely. The two missing solvers had upgraded simplex and begun sending a v1 source-chain declaration in `paymasterAndData` where they used to send nothing; the SDK's declaration decoder read each chain id through `TextDecoder` (via `@polkadot/util`'s `u8aToString`), which throws inside SubQuery's vm2 sandbox on a sandbox-created `Uint8Array`, and the per-bid catch in `aggregatePhantomBids` logged the warning and dropped the bid. The solver still counted declares only V4 positions, so its decode never reached a chain id. Fixed in the SDK (`utf8Decode`, no TextDecoder). A new indexer test, `phantom-decode.sandbox.test.ts`, bundles the shipped `intents-helpers` with esbuild and runs the decoder inside a vm2 NodeVM configured like `@subql/node-core`'s Sandbox; it fails on the previous build with the exact production error and passes now. Separately, the "No SolverAccount configured for chain, skipping price snapshot" warning was Gnosis: `config-mainnet.json` had no `solverAccount` for EVM-100 although the SDK config carries one, so every Gnosis phantom window was skipped; the entry now lists the same two accounts as the other chains. +Files: `src/utils/__tests__/phantom-decode.sandbox.test.ts`, `src/configs/config-mainnet.json`, `docs/ai/Decisions.md`, `docs/ai/Flow.md`. diff --git a/sdk/packages/indexer/docs/ai/changelog/2026-09-10-docs-ai-split-into-one-file-per-entry.md b/sdk/packages/indexer/docs/ai/changelog/2026-09-10-docs-ai-split-into-one-file-per-entry.md new file mode 100644 index 000000000..73b471eb9 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/changelog/2026-09-10-docs-ai-split-into-one-file-per-entry.md @@ -0,0 +1,20 @@ +# 2026-09-10 — docs/ai split into one file per entry + +`ChangeLog.md`, `Decisions.md` and `Flow.md` are gone. Their entries now live as individual +files under `changelog/`, `decisions/` and `flows/` — 16, 16 and 5 of them in this package. +The prose is unchanged; the only edit is that each entry's headings moved up one level, from +`##` to `#`. That was checked by demoting every new file back a level and comparing it to the +text it came from, byte for byte. + +Why. The three files were append-only with newest entries on top, so any two concurrent PRs +wrote the same line and GitHub marked both conflicted. #1251 declared `merge=union` for the two +log files, which does resolve the collision locally — but GitHub ignores merge drivers when it +computes a PR's mergeability, so the PRs stayed blocked however clean the local merge was. +A file that only one PR creates cannot collide at all, which is the same reason changesets +keeps one file per change rather than one shared changelog. + +Flows stay one file per flow and are still edited in place, so two PRs revising the same flow +still conflict — correctly, because that means they disagree about how the code runs. + +Files: docs/ai/{ChangeLog,Decisions,Flow}.md (removed), docs/ai/README.md (new), +docs/ai/{changelog,decisions,flows}/*.md (new). Repo root: CLAUDE.md, .gitattributes (removed). diff --git a/sdk/packages/indexer/docs/ai/decisions/2026-08-13-gateway-daily-volume-reuses-dailyvolumeusd-instead-of-a-new.md b/sdk/packages/indexer/docs/ai/decisions/2026-08-13-gateway-daily-volume-reuses-dailyvolumeusd-instead-of-a-new.md new file mode 100644 index 000000000..e34baac2e --- /dev/null +++ b/sdk/packages/indexer/docs/ai/decisions/2026-08-13-gateway-daily-volume-reuses-dailyvolumeusd-instead-of-a-new.md @@ -0,0 +1,7 @@ +# 2026-08-13 — Gateway daily volume reuses `DailyVolumeUSD` instead of a new indexed entity (#1085) + +Chosen: record gateway-level filled volume with base ID `IntentGatewayV3.FILLED` through the existing `VolumeService.updateVolume`, landing in the same `DailyVolumeUSD` / `CumulativeVolumeUSD` entities as the per-filler and per-user records. + +Alternative considered: a new `DailyIntentGatewayVolumeUSD` entity keyed `chain-volumeType-date`, written inside `IntentGatewayV3Service.recordOrderVolume` next to `CumulativeIntentGatewayVolumeUSD`. That would give typed, indexed `chain` and `date` columns (the `DailyVolumeUSD` ID is an opaque string with no filterable columns), following the pattern `BandwidthAppDailyConsumption` uses. + +Why the reuse won: the issue explicitly asked for the entry to appear in the existing `dailyVolumeUSDs` query alongside the FILLER and USER rows, it needs no schema change or codegen, and it exactly parallels the existing filler-independent `IntentGatewayV3.USER` record. If consumers later need to filter or sort daily gateway volume server-side, revisit the dedicated-entity alternative. diff --git a/sdk/packages/indexer/docs/ai/decisions/2026-08-13-gateway-volume-recorded-at-the-same-call-site-as-filler-volume.md b/sdk/packages/indexer/docs/ai/decisions/2026-08-13-gateway-volume-recorded-at-the-same-call-site-as-filler-volume.md new file mode 100644 index 000000000..f9608a861 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/decisions/2026-08-13-gateway-volume-recorded-at-the-same-call-site-as-filler-volume.md @@ -0,0 +1,9 @@ +# 2026-08-13 — Gateway volume recorded at the same call site as filler volume, not the unconditional handler site (#1085) + +Chosen: the new `IntentGatewayV3.FILLED` update sits directly next to the `IntentGatewayV3.FILLER.
` update inside `updateOrderStatus`, sharing its `getOutputValuesUSD` pricing. + +Alternative considered: `orderFilledV3.event.handler.ts` already calls `recordOrderVolume("FILLED", ...)` unconditionally, even when the fill is indexed before its `OrderPlaced`. Recording there would not miss fill-before-place races. + +Why the shared site won: it guarantees the invariant that the gateway entry equals the sum of the per-filler entries for a given chain and day, because both come from the same pricing call on the same code path. `recordOrderVolume` prices tokens itself and skips tokens with no known price, so its totals can disagree with the filler totals. The cost is inheriting a known gap: when a fill arrives before its `OrderPlaced`, `updateOrderStatus` stores `PendingStatusMetadata` and returns early, and the later replay (`flushPendingStatuses`) restores status only, never volume. The per-filler records already under-count that case; the gateway record under-counts it identically, which keeps the two consistent. + +Known pre-existing quirk (not changed): `updateCumulativeVolume` skips the addition when `lastUpdatedAt` equals the incoming timestamp, so two fills in the same block increment daily volume twice but cumulative volume once. Left as is; changing it would affect every existing volume series. diff --git a/sdk/packages/indexer/docs/ai/decisions/2026-08-14-cumulative-seed-derives-from-daily-rows-and-a-failed-seed-skips.md b/sdk/packages/indexer/docs/ai/decisions/2026-08-14-cumulative-seed-derives-from-daily-rows-and-a-failed-seed-skips.md new file mode 100644 index 000000000..6bd597c83 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/decisions/2026-08-14-cumulative-seed-derives-from-daily-rows-and-a-failed-seed-skips.md @@ -0,0 +1,7 @@ +# 2026-08-14 — Cumulative seed derives from daily rows, and a failed seed skips only the gateway update (#1085) + +Chosen: `seedAggregateVolume` scans only `DailyVolumeUSD` and derives the aggregate's cumulative record from the per-day sums (`lastUpdatedAt` from their max). The alternative — summing the component `CumulativeVolumeUSD` rows — was the original implementation and was dropped after review: `updateCumulativeVolume` skips same-timestamp updates per record, so a chain-wide aggregate's cumulative drops the second of any two same-block fills (even by different fillers), while per-filler cumulatives only collide within one filler. Seeding from summed filler cumulatives therefore bakes in the equality "FILLED cumulative equals the sum of FILLER cumulatives", which the guard breaks from the first multi-filler block onward. Daily rows have no such guard, count every fill, and are the series the aggregate is paired with. The forward divergence itself is accepted, not fixed — fixing it means removing the cumulative guard, which would change every existing volume series — and is pinned by a test. + +Also chosen: the seed call in `updateOrderStatus` has its own try/catch. A store error during the scan must not swallow the fill's status, points, and user activity (the handler's try/catch is around all of it). On failure, the gateway `updateVolume` call is skipped too, deliberately: writing it would create the cumulative record that doubles as the seed's done-marker, permanently preventing the backfill. Skipping leaves the marker absent so the next fill retries the seed, and the retry recovers the skipped fill's volume because it sums the filler daily rows, which include it. Nothing is lost or double-counted in either outcome. + +Accepted tradeoff, noted for future readers: the scan uses `getByFields([], ...)` with an empty filter, which returns zero rows with no diagnostic signal if something is wrong upstream (the reason `PendingStatusService` moved off empty-filter reads). Acceptable here because the seed runs once per chain per deployment and a wrongly-empty result degrades to a zero seed plus correct forward counting. diff --git a/sdk/packages/indexer/docs/ai/decisions/2026-08-14-gateway-volume-seeding-uses-the-aggregate-cumulative-record-as.md b/sdk/packages/indexer/docs/ai/decisions/2026-08-14-gateway-volume-seeding-uses-the-aggregate-cumulative-record-as.md new file mode 100644 index 000000000..75f2b2b06 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/decisions/2026-08-14-gateway-volume-seeding-uses-the-aggregate-cumulative-record-as.md @@ -0,0 +1,7 @@ +# 2026-08-14 — Gateway volume seeding uses the aggregate cumulative record as its own done-marker (#1085) + +Chosen: `VolumeService.seedAggregateVolume` runs on every fill but returns immediately when the aggregate's `CumulativeVolumeUSD` record exists; the record itself is the marker. Seeding runs lazily on the first fill per chain after deploy, before that fill's own volume updates. + +Alternatives considered: a dedicated migration-marker entity (extra schema and codegen for one boolean, and a marker that can drift from the data it describes); a block handler or one-off script outside event flow (SubQuery has no migration hook, and a script against the store bypasses block-atomic writes). + +Why this won: no schema change, per-chain by construction, and correct in both deployment modes with no configuration — on a resumed database the first fill seeds the full filler history, on a fresh from-genesis reindex the first fill sees no history and seeds a zero marker. Two invariants make the lazy placement safe and are worth preserving: the seed must run before the triggering fill's own `updateVolume` calls (otherwise that fill's filler record is summed and then added again), and the seeded cumulative record must carry the components' max `lastUpdatedAt` rather than the current timestamp (otherwise `updateCumulativeVolume`'s same-timestamp guard drops the triggering fill). Enumeration pages the whole table with `getByFields([], ...)` and filters IDs in code because the volume entities have no filterable columns — acceptable because it runs once per chain per deployment and the tables are small (series count, not event count). diff --git a/sdk/packages/indexer/docs/ai/decisions/2026-08-19-the-standard-amount-check-bounds-plausibility-instead-of.md b/sdk/packages/indexer/docs/ai/decisions/2026-08-19-the-standard-amount-check-bounds-plausibility-instead-of.md new file mode 100644 index 000000000..e624b4f71 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/decisions/2026-08-19-the-standard-amount-check-bounds-plausibility-instead-of.md @@ -0,0 +1,11 @@ +# 2026-08-19 — The standard-amount check bounds plausibility instead of pinning one unit + +Chosen: `resolvePoolLeg` accepts any standard amount within a plausibility window around one whole input token, and `updateLiquidityPools` renormalizes the rate by the leg's own standard amount. The pallet is then free to raise the probe size to buy quote precision without the indexer rescaling every published rate by that factor. + +Alternative rejected — keep `standardAmount === 10 ** inputDecimals`. It made the rate math a single multiplication, but it is what blocked the precision fix: a leg's quoted output integer IS the price, and one whole token of a 6-decimal asset priced into another 6-decimal asset only affords ~3 significant digits. + +Alternative rejected — accept any whole multiple of one unit and carry the multiple as a divisor. Simpler arithmetic, but it silently waves through the exact bug the old check caught: an 18-decimal amount read against a 6-decimal registry entry is a clean multiple (1e12 of them), so it would have been read as a trillion-token probe and published a rate off by 1e12. It also needlessly forbids a non-whole probe, which the renormalization prices correctly. + +Alternative rejected — drop the check entirely. The standard amount is the denominator of every published rate and nothing else in the pipeline notices when it disagrees with the registry's decimals; the failure is silent and needs a human to spot feed drift. The window is deliberately wide enough that no plausible probe size trips it and narrow enough that every realistic decimals mismatch does. + +Not changed, deliberately: the filler floors its quoted output (`computeLegPolicyOutput` in simplex). That truncation looks like a 0.14% pricing error at a one-unit probe and is tempting to "fix" by rounding to nearest — but it is load-bearing. Flooring keeps the published rate at or below the filler's true curve rate, which is what makes a quote built from the snapshot honourable; the SDK quoter derives `amountOut` from `medianPrice / standardAmount`, and the gateway's fully-filled check has zero tolerance, so a published rate even one base unit above the curve turns every order into a partial fill. Precision belongs to the probe size, not the rounding mode. diff --git a/sdk/packages/indexer/docs/ai/decisions/2026-08-26-substrate-node-resilience-is-fixed-in-a-forked-node-image-not.md b/sdk/packages/indexer/docs/ai/decisions/2026-08-26-substrate-node-resilience-is-fixed-in-a-forked-node-image-not.md new file mode 100644 index 000000000..79a73d087 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/decisions/2026-08-26-substrate-node-resilience-is-fixed-in-a-forked-node-image-not.md @@ -0,0 +1,9 @@ +# 2026-08-26 — Substrate node resilience is fixed in a forked node image, not in the indexer + +Chosen: the substrate SubQuery node image comes from the `polytope-labs/subql` fork (`polytopelabs/subql-node-substrate`), where the websocket provider is wrapped so requests wait out a disconnect, response caching is disabled, reconnects are unbounded, and the http provider retries rate limits with a client-wide pause. The indexer package only changes the image reference. + +Alternative rejected — make the indexer tolerate it: retry handler RPC reads, lower `--workers`, rely on `restart: unless-stopped`. The exits come from inside the node (block fetcher and dispatcher), which handler code cannot reach, and a restart is not a reconnect: it drops the unfinalized cache and, under `--multi-chain`, forces a rewind. The rate limit on the hosted http RPC is per client, so per-call retries in handlers cannot coordinate with the node's own fetch traffic; only the provider can pause all of them. + +Alternative rejected — upstream the changes first and wait. Worth doing, but the deployment needs the behaviour now; the fork mirrors how `polytopelabs/subql-node-ethereum` is already produced from `polytope-labs/subql-ethereum`. + +Accepted: this moves substrate from the deployed node 5.9.1 to 6.4.7 (node-core 19.x), matching `polytope-labs/subql`'s main after it was synced to upstream. The fork PR is built on that main, so it carries only these behavioural changes as a single commit, not the version history. diff --git a/sdk/packages/indexer/docs/ai/decisions/2026-08-28-the-vm2-decoder-tries-both-fillorder-shapes-and-the-new-test.md b/sdk/packages/indexer/docs/ai/decisions/2026-08-28-the-vm2-decoder-tries-both-fillorder-shapes-and-the-new-test.md new file mode 100644 index 000000000..d881d3725 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/decisions/2026-08-28-the-vm2-decoder-tries-both-fillorder-shapes-and-the-new-test.md @@ -0,0 +1,22 @@ +# 2026-08-28 — The VM2 decoder tries both `fillOrder` shapes, and the new test avoids the SDK root import + +Alternatives to trying both shapes: + +- **Track the gateway's version and pick one.** That is what `getFillOptionsVersion` does for *encoding*, where + you must choose. Decoding has no such constraint: the selectors differ, so attempting both is unambiguous and + needs no chain state, no RPC read, and no cache — all of which the SubQuery sandbox makes awkward. +- **Decode by selector lookup.** Equivalent in effect, more code, and it would duplicate the selector constants + that the ABIs already encode. + +Trying v2 then v1 mirrors `decodeFillOrder` exactly, which is the point: the two implementations of this decode +diverged once already, and keeping them structurally identical is what makes the next divergence visible. + +The regression test lives in its own file, `phantom-decode.fill.test.ts`, importing only the `intents-helpers` +sub-path. The sibling `phantom-decode.test.ts` also imports `@hyperbridge/sdk` root for `CryptoUtils`, which is +not available on the sub-path. Splitting keeps the new test on exactly the entry point the indexer uses at +runtime, so it exercises the same module graph the sandbox loads. + +The jest `transformIgnorePatterns` addition names the ESM-only packages explicitly rather than transforming all of +`node_modules`. The broad form is slower and pulls unrelated packages through ts-jest; the explicit list fails +loudly (an unparsed `export`) if the SDK's dependency graph grows another ESM-only package, which is the right +failure mode — silence here is what let the tests stop running unnoticed. diff --git a/sdk/packages/indexer/docs/ai/decisions/2026-09-01-declared-v4-positions-live-in-one-row-per-solver-1159.md b/sdk/packages/indexer/docs/ai/decisions/2026-09-01-declared-v4-positions-live-in-one-row-per-solver-1159.md new file mode 100644 index 000000000..3eb27f7a9 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/decisions/2026-09-01-declared-v4-positions-live-in-one-row-per-solver-1159.md @@ -0,0 +1,34 @@ +# 2026-09-01 — Declared V4 positions live in one row per solver (#1159) + +Chosen: `SolverV4Positions`, keyed by the solver's address, holding the tokenIds from its latest bid and the +chain that bid was for. Written by the phantom snapshot, read by the refresh with a single keyed `get`. + +Alternative rejected — a row per (chain, tokenId). It was implemented first and is the wrong grain: the refresh +asks "what does this solver declare", so a per-position table makes that a filtered scan per provider per chain, +and a position that changes hands or disappears needs its own reconciliation. One row answers the question the +readers actually ask, and replacing it wholesale each window is the reconciliation. + +Alternative rejected — no storage at all, decoding the declaration back out of `FillerBid.bidData` (which does +hold every bid's raw userOp). It was implemented too, and it needs no schema, but the read is a walk over recent +phantom orders, then their bids, SCALE-decoding each one to find the sender — per solver, per refresh. The +declaration is a small, current fact; storing it decoded is what makes reading it cheap. + +Alternative rejected — carry the last sweep's position VALUE forward instead of the tokenIds. Wrong in the case +that matters: simplex funds fills out of these positions, so a fill drains the position inside the fill +transaction while wallet and vault balances barely move, and a carried value keeps advertising precisely the +inventory the fill just spent. The tokenIds are stable; the value is not, so the value is always re-read. + +One row per solver assumes a solver declares on one chain, which holds while Uniswap V4 is configured for a +single chain (Base). That assumption is load-bearing, so the writer refuses to overwrite a row recorded on +another chain and warns instead — the key has to grow to (chain, solver) the day a second V4 chain is +configured, and a silent overwrite would have deleted real inventory in the meantime. + +Positions are recorded after the aggregation's ownership check, not as declared — a declaration is a pointer, +not a claim — and the refresh checks the owner again when it re-reads, because a row recorded last window cannot +know the position has since been sold. + +The reconciliation is driven by the aggregation's `solvers` — every verified bidder — and not by the solvers +appearing in `lpBalances` or `positions`. Both of those are filtered by what the solver turned out to hold or +declare, so a bidder holding nothing anywhere and declaring nothing appears in neither, and its previous +declaration would never be emptied. That bidder is precisely the V4-funded profile whose whole inventory sits in +the positions it just stopped offering, so it is the case that matters most (review of #1194). diff --git a/sdk/packages/indexer/docs/ai/decisions/2026-09-01-escrow-releases-and-vault-events-refresh-by-provider-and-depths.md b/sdk/packages/indexer/docs/ai/decisions/2026-09-01-escrow-releases-and-vault-events-refresh-by-provider-and-depths.md new file mode 100644 index 000000000..5fbbe0b4d --- /dev/null +++ b/sdk/packages/indexer/docs/ai/decisions/2026-09-01-escrow-releases-and-vault-events-refresh-by-provider-and-depths.md @@ -0,0 +1,22 @@ +# 2026-09-01 — Escrow releases and vault events refresh by provider, and depths re-sum from the store (#1159) + +Chosen: `refreshProviderLiquidity(chain, provider, tokens)` beside the pool-scoped entry point, for the events +that move a solver's inventory without naming a pool. + +Alternative rejected — resolve those events to pools and reuse the pool-scoped path. An escrow release names the +order, so its pools are resolvable, but a vault event names only a token; and re-reading every bidder of every +pool the solver touches costs an RPC per bidder to learn what one solver's balance did. + +That entry point forced one change to the shared core, worth knowing about: the (pool, chain) depths are now +re-summed from the STORED bidder rows after the writes, not from the rows the refresh happened to re-read. +Summing the re-read subset was correct only because the pool-scoped path re-reads every bidder; with one solver's +rows in hand it would have erased everyone else's contribution. + +Chosen: the escrow release resolves its filler from the gateway's `_filled(commitment)` mapping with one +`eth_call` at the event's block. `_withdraw` writes the beneficiary in the same call that emits the event, so the +mapping is authoritative from that block onwards, and the source chain's node never has to wait for the +destination chain's node to have indexed the fill. + +Chosen: the vault refresh hangs off the end of `YieldVaultService.recordLedger` rather than off the handlers. The +"is this one of our solvers" gate and the duplicate-log guard already live there, and both are exactly the gates +the refresh wants. diff --git a/sdk/packages/indexer/docs/ai/decisions/2026-09-01-per-event-reads-are-pinned-to-the-event-s-block-1159.md b/sdk/packages/indexer/docs/ai/decisions/2026-09-01-per-event-reads-are-pinned-to-the-event-s-block-1159.md new file mode 100644 index 000000000..62bbb68d5 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/decisions/2026-09-01-per-event-reads-are-pinned-to-the-event-s-block-1159.md @@ -0,0 +1,13 @@ +# 2026-09-01 — Per-event reads are pinned to the event's block (#1159) + +Chosen: the balance and position reads a refresh performs are pinned to the block of the event that triggered +them, via the SDK's `blockTag` parameter, and the read memo is keyed by that block so several events in one block +share one set of reads. + +Alternative rejected — read at the chain head, as the periodic sweep does. It is what the first cut did, and it +is not replayable: reindexing an old fill would stamp today's balance onto it. The pool guard (skip a pool whose +last snapshot postdates the event) hid that by making the refresh a no-op during a resync; pinning the reads +makes the guard a cost optimization rather than the only thing standing between a replay and wrong data. + +Only the event's own chain is pinned. Block numbers are per chain and a refresh reaches across every chain the +pool is quoted on, so the others stay at the head — the correct reading available for them. diff --git a/sdk/packages/indexer/docs/ai/decisions/2026-09-01-the-balance-series-keeps-its-shape-1159-3-is-left-as-a-comment.md b/sdk/packages/indexer/docs/ai/decisions/2026-09-01-the-balance-series-keeps-its-shape-1159-3-is-left-as-a-comment.md new file mode 100644 index 000000000..733902857 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/decisions/2026-09-01-the-balance-series-keeps-its-shape-1159-3-is-left-as-a-comment.md @@ -0,0 +1,9 @@ +# 2026-09-01 — The balance series keeps its shape; #1159 §3 is left as a comment (#1159) + +Not done, deliberately: the `trigger` enum, the nullable `transactionHash`, the +`{chain}-{token}-{solver}-{blockNumber}` id shape for event-triggered rows, and ordering "current liquidity" by +`snapshotTime` rather than `blockNumber`. All four change `LiquidityProviderBalanceV2`, which is live, and the +value is provenance metadata rather than correctness. Event-triggered rows therefore keep borrowing Hyperbridge's +head block, and the design is recorded as a comment on `recordProviderBalances` so a later migration has it to +hand. `SolverV4Positions` is additive — a new table, and no `@derivedFrom` field on `LiquidityProvider` either — +which is why it is in this change and §3 is not. diff --git a/sdk/packages/indexer/docs/ai/decisions/2026-09-01-the-fill-refresh-re-reads-balances-publishes-no-provenance-of.md b/sdk/packages/indexer/docs/ai/decisions/2026-09-01-the-fill-refresh-re-reads-balances-publishes-no-provenance-of.md new file mode 100644 index 000000000..d36785bfd --- /dev/null +++ b/sdk/packages/indexer/docs/ai/decisions/2026-09-01-the-fill-refresh-re-reads-balances-publishes-no-provenance-of.md @@ -0,0 +1,65 @@ +# 2026-09-01 — The fill refresh re-reads balances, publishes no provenance of its own, and skips replayed fills + +Chosen: on `OrderFilled`, re-read every recorded bidder's balance for the pools the fill traded through and +rewrite the depths from them, leaving rates and every `lastUpdatedBlock`/`lastUpdatedAt` alone. + +Alternative rejected — subtract the filled amount from the filler's row. No RPC, exact for the one solver that +filled, and wrong for everyone else: a fill is not the only thing that moves inventory between windows +(rebalances, other pools' fills, withdrawals), and the arithmetic would drift from the chain with nothing to +correct it until the next window. Re-reading measures the thing the depth is defined as. + +Alternative rejected — refresh only the fill's own chain. Cheaper, and it is the only chain this fill changed. +But the pool's depth is a cross-chain sum, and the request is for the pool's liquidity, not one chain's slice; +the per-chain memo already collapses the extra reads, and the two guards below mean this only ever runs at the +tip. + +Alternative rejected — stamp the refresh with the fill's block and timestamp. `lastUpdatedBlock` is a Hyperbridge +block number, and the staleness window (`MAX_SAMPLE_AGE_BLOCKS`) is measured in those; an EVM block number is +numerically unrelated and would make every row look either astronomically fresh or unusably stale. Writing only +`lastUpdatedAt` would leave the pair describing two different events. A dedicated `refreshedAt` field was the +honest version of this and was dropped deliberately: it buys provenance metadata at the price of a schema change +on live pool entities, and the depth being fresher than its timestamp claims is the safe direction to be wrong in. + +Chosen: a pool whose `lastUpdatedAt` is newer than the fill is skipped without reading a balance. Balances are +read at the chain head — as the snapshot path also reads them — so refreshing against a fill the pool has already +been sampled after would replace fresher data with a partial view of it. It also makes a historical resync free: +every replayed fill is older than the pools' current samples, so no RPC is issued at all. + +Chosen: a chain whose balances cannot be read in full is left exactly as indexed. A failed read and a zero +balance are indistinguishable downstream, so publishing the reads that succeeded would report the rest of that +chain's bidders as having withdrawn — worse than a stale number, because it looks like news. + +Accepted blind spot, documented at `refreshPoolLiquidity`: the refresh reads wallet ERC-20 plus redeemable +ERC-4626 positions, but a snapshot's weight can also include Uniswap V4 positions the bid declared, and only a +bid names those. A V4-funded bidder therefore shrinks to its liquid inventory until the next window restores it. +Errs downward, which costs a quote rather than a failed fill. The fix, if V4-funded solvers become material, is to +split the position share out in the SDK aggregation and persist it on `PoolBidder` so it can be carried forward — +which is a schema change, hence not done pre-emptively. + +Chosen: `PartialFill` refreshes on the same terms as `OrderFilled`, through the same service method. It spends +output-token inventory identically — the only difference is that it is emitted by the same-chain path +(`IntrinsicIntents`), where source and destination are one chain, so the pair resolves against a single registry. +Cross-chain fills are all-or-nothing (`ExtrinsicIntents` emits only `OrderFilled`), so between the two handlers +every fill that moves inventory now triggers a refresh. The per-block balance memo means a partial fill and the +full fill that follows it in the same block read balances once. + +Chosen: a refresh extends `LiquidityProviderBalanceV2` too, stamping its rows with Hyperbridge's head block read +live from the configured node. The series is keyed by Hyperbridge block and a fill has none of its own, so +something has to supply one, and the head is the only number that keeps the series monotonic — which is the single +property consumers read it for ("greatest blockNumber is the current balance"). + +Alternative rejected — leave the series to snapshots. It is what the first cut did, and it leaves the pool rows +and the balance rows disagreeing between windows: the depth knows the inventory is spent while the newest balance +row still reports it, and those two are meant to be the same measurement. + +Alternative rejected — overwrite the newest existing row in place. No RPC and no new key, but it rewrites history: +that row claims to be the balance at its Hyperbridge block, and after the overwrite it is not. + +Alternative rejected — a fill-shaped key (`…-fill-{evmBlock}-…`). Append-only and honest about provenance, but it +puts two unrelated block sequences in one column, so the greatest-blockNumber rule stops meaning "latest". + +The stamp is a borrowed clock, not a claim: the balance was read at the EVM chain's head, not reconstructed at +that Hyperbridge block, and the schema description now says so. Two readings landing on one key resolve to the +larger, the rule the sweep already follows — a refresh is always the V4-blind reading, so it must not replace a +complete one. The cost is that a second fill while Hyperbridge is still on the same block does not lower the row; +one block later it does. diff --git a/sdk/packages/indexer/docs/ai/decisions/2026-09-04-in-place-schema-migration-on-restart-not-versioned-manifest.md b/sdk/packages/indexer/docs/ai/decisions/2026-09-04-in-place-schema-migration-on-restart-not-versioned-manifest.md new file mode 100644 index 000000000..2bd7468bd --- /dev/null +++ b/sdk/packages/indexer/docs/ai/decisions/2026-09-04-in-place-schema-migration-on-restart-not-versioned-manifest.md @@ -0,0 +1,17 @@ +# 2026-09-04 — In-place schema migration on restart, not versioned manifest upgrades (#1163) + +Chosen: run the substrate node with `--allow-schema-migration` and a forked node image that, on startup, diffs the +previously applied schema (persisted in an `appliedSchemaSDL` metadata key) against the current one and ALTERs existing +tables in place — add a nullable column, table, index, enum value, or relation. Editing `schema.graphql`, rebuilding, +and restarting is the whole workflow; existing rows are kept and there is no reindex. + +Alternative rejected — SubQuery's supported versioned-manifest upgrades (a `parent` deployment crossing an upgrade +block). It needs published/parent-CID bookkeeping we do not run (no IPFS), historical indexing (the substrate node runs +without it), and it reindexes from the upgrade block just to add a nullable column. The wrong cost curve for the actual +need, which is additive. + +The flag is on the substrate node only. It is the fork image that carries the in-place logic, and compose already makes +it the health-gated leader every EVM (official-image) node waits on; because the shared `schema.graphql` covers every +entity, the leader applies all DDL before any EVM node boots, so there is no multi-writer race. Destructive changes are +refused by default (they drop a column and its data); `SUBQL_ALLOW_DESTRUCTIVE_MIGRATION=true` is the explicit escape +hatch. First boot after enabling the flag only seeds the baseline; migration happens on subsequent edits. diff --git a/sdk/packages/indexer/docs/ai/decisions/2026-09-08-one-writer-per-pool-row-evm-nodes-publish-readings-the.md b/sdk/packages/indexer/docs/ai/decisions/2026-09-08-one-writer-per-pool-row-evm-nodes-publish-readings-the.md new file mode 100644 index 000000000..4cd11aa81 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/decisions/2026-09-08-one-writer-per-pool-row-evm-nodes-publish-readings-the.md @@ -0,0 +1,57 @@ +# 2026-09-08 — One writer per pool row: EVM nodes publish readings, the Hyperbridge node folds them (#1214) + +The setting this answers: the multichain indexer is one SubQuery node process per chain, all writing one Postgres +schema. Each process serves `Entity.get` from a private LRU (500 entries, one hour, refreshed on every read) that +nothing another process writes ever invalidates, flushes whole rows per block with no locking, and, in the +historical timestamp mode every node here runs under, closes the previous row by range and inserts a new one with +no uniqueness on (id, range). Every cross-chain swap had the destination node (`OrderFilled`), the source node +(`EscrowReleased`) and the Hyperbridge node (snapshot) all re-deriving and saving the same `LiquidityPool` row +from private copies. Verified against `@subql/node-core` 19.3.1 (the EVM image) and the forked substrate node, +whose changes touch only RPC resilience and schema migration. + +Chosen: every row has exactly one writer. The pool family belongs to the Hyperbridge node. An EVM node publishes +what it measured into `SolverInventoryReading`, keyed by its own chain, and `foldInventoryReadings` on the +Hyperbridge node turns those into pool rows. A process that is the only writer of a row is always consistent with +its own cache, so both halves of the race disappear by construction rather than by coordination. + +Alternative rejected — derive the aggregate in the database (a view over the chain rows). Removes the pool-level +race but not the chain-row race between the snapshot writer and the EVM refresh, and needs raw SQL outside the +schema-driven migrations. + +Alternative rejected — advisory locks and direct SQL from the handlers. Possible (`--unsafe`, DB credentials in +the environment) but bypasses the store cache and the historical ranges, so it fights the framework everywhere. + +Alternative rejected — keep several writers and only switch cross-node reads to field queries. Fixes the stale +reads, leaves the concurrent whole-row overwrite; narrows the window without closing it. Field queries are still +the rule for every cross-node read (they go to Postgres; `get` does not), which is why `declaredV4Positions` now +reads through one, on the provider link rather than the id: the store only indexes `id` in historical mode. The FX-pricing read of `LiquidityPool` in `IntentGatewayV3Service` is a remaining +cross-node `get`, out of scope here. + +Chosen: an EVM node reads only its own chain, pinned to the event's block. A pool spans chains, but the event +moved inventory on one of them; the other chains' inventories are their own nodes' to publish, and the periodic +phantom sweep still corrects drift everywhere. This is also what keeps the reading rows single-writer. + +Chosen: the fold scans the whole reading table each block and filters in memory. The store's `getByFields` +offers only `=`, `!=`, `in` and `!in`, so "newer than a watermark" cannot be expressed; the table is bounded by +bidders times tokens times chains, and an in-process memo of what was folded makes a quiet block cost one page. +Idempotency rests on the per-row compare, not on the memo: a reading applies only if it postdates the bidder +row's `lastUpdatedAt` and the new nullable `refreshedAt`. Nullable because the in-place migration refuses a +non-null column, and separate from `lastUpdatedAt` because that pair records which snapshot priced the pool. +Bidder rows are matched to a reading by provider, chain and output token in memory, case-insensitively, so no +index on `outputToken` was added. + +Chosen: the fold runs on every Hyperbridge block. The Hyperbridge manifest uses no dictionary, so the node already +fetches every block, and the handler sits inside the `enableLiquidityIndexing` block of the substrate template — +placing it beside `handlePendingStatusFlush` would have run it on every substrate chain. + +Chosen: balance-series rows are keyed by the fold's own Hyperbridge block with the reading's observation time as +`snapshotTime`. The series keeps its clock without the EVM nodes reading Hyperbridge's head over RPC, which is +gone. Same-key collisions with a snapshot on the same block still resolve to the larger reading. + +Known limits, stated rather than solved: a node reads only rows whose historical range contains its own current +block time, so an EVM node running ahead of Hyperbridge writes readings the fold sees a block or two later, and +the mainnet template flushes the store asynchronously every five seconds, which adds to that lag. A lagging EVM +node's replayed readings are older than the rows' snapshot times and are ignored. Both schema changes are +additive; if the deployment has not restarted since in-place migration was enabled, the first restart records the +baseline and the DDL lands on the second. The disabled multichain rewind lock combined with unfinalized EVM blocks +is a separate race, not addressed here. diff --git a/sdk/packages/indexer/docs/ai/decisions/2026-09-09-sponsored-bid-decoding-stays-in-the-sdk-the-indexer-tests-only.md b/sdk/packages/indexer/docs/ai/decisions/2026-09-09-sponsored-bid-decoding-stays-in-the-sdk-the-indexer-tests-only.md new file mode 100644 index 000000000..69e128675 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/decisions/2026-09-09-sponsored-bid-decoding-stays-in-the-sdk-the-indexer-tests-only.md @@ -0,0 +1,16 @@ +# 2026-09-09 — Sponsored-bid decoding stays in the SDK; the indexer tests only its own seams + +Chosen: the second paymasterAndData shape (Permit2 sponsorship, declaration appended) is handled inside +`aggregatePhantomBids` in the SDK, and the indexer changes no handler or util. Its tests cover the two places the +indexer substitutes its own implementation for the SDK's: the ethers userOpHash recovery, which now hashes a +234-byte-plus `paymasterAndData`, and the `intents-helpers` bundle it ships, checked against a payload packed with +ethers rather than viem. + +Alternative rejected — a VM2-safe copy of the decoder in `phantom-decode.ts`, next to the fill and signature +helpers. Those exist because viem's byte handling throws in the SubQuery sandbox; the declaration decoder uses +`@polkadot/util` only, which already runs there today for the bare shape, so a copy would duplicate a security- +relevant parser for no sandbox reason and drift from the SDK's. + +Alternative rejected — persisting the sponsorship fields (paymaster, fee token, permit nonce) on the bid or +snapshot rows. Nothing reads them: a phantom bid never executes, so which paymaster it named and when its permit +expires are not facts about the price. They are decoded for the record in the SDK and dropped here. diff --git a/sdk/packages/indexer/docs/ai/decisions/2026-09-09-the-sandbox-regression-test-runs-the-real-bundle-inside-vm2-not.md b/sdk/packages/indexer/docs/ai/decisions/2026-09-09-the-sandbox-regression-test-runs-the-real-bundle-inside-vm2-not.md new file mode 100644 index 000000000..4562a1b5f --- /dev/null +++ b/sdk/packages/indexer/docs/ai/decisions/2026-09-09-the-sandbox-regression-test-runs-the-real-bundle-inside-vm2-not.md @@ -0,0 +1,22 @@ +# 2026-09-09 — The sandbox regression test runs the real bundle inside vm2, not a mock of the sandbox + +Chosen: `phantom-decode.sandbox.test.ts` rolls the shipped `intents-helpers` entry into one file with esbuild +(resolved through `@subql/cli`, which is how the indexer itself is built) and executes the decoder inside a +`NodeVM` from the same vm2 `@subql/node-core` uses, with the host's `TextEncoder`/`TextDecoder` injected into +the sandbox. The live 11-byte declaration that was being dropped is the fixture. + +Why the real thing. The failure is not "TextDecoder is undefined" — injecting it does not help — but that a +`Uint8Array` built inside the sandbox reaches host code as a proxy `ArrayBuffer.isView` rejects. Nothing short +of vm2 reproduces that: Node's own `vm` contexts hand over real typed arrays and decode fine, so a test built +on `vm.createContext` would have passed on the broken code. Injecting the codecs is the charitable setup — the +pre-fix bundle still threw the exact production error under it, and viem cannot load without `TextEncoder`. + +Why a single-file bundle. vm2's own loader cannot walk the pnpm module graph: it does not follow the +`.pnpm` symlinks, refuses the dynamic `import()` `@polkadot/x-fetch` runs at load, and cannot parse ESM-only +packages such as `lodash-es`. The deployed indexer never hits any of this because `subql build` webpacks it +into one file; esbuild does the same for the test. + +Not chosen — keeping the sandbox concern out of the SDK and injecting a VM2-safe declaration decoder from the +indexer like the fill and signature helpers. Those exist because viem is unusable in the sandbox and the SDK +must keep viem for every other consumer; the declaration codec needs nothing from any library, so making it +pure fixes the SDK for every consumer and leaves nothing to inject or drift. diff --git a/sdk/packages/indexer/docs/ai/flows/intent-gateway-volume-indexing-orderfilled.md b/sdk/packages/indexer/docs/ai/flows/intent-gateway-volume-indexing-orderfilled.md new file mode 100644 index 000000000..6c8afe04e --- /dev/null +++ b/sdk/packages/indexer/docs/ai/flows/intent-gateway-volume-indexing-orderfilled.md @@ -0,0 +1,25 @@ +# Intent gateway volume indexing (OrderFilled) + +The indexer is a SubQuery project: per-network YAML files in `src/configs/` bind contract addresses and events to handler functions, and generated entity models in `src/configs/src/types/` persist via the SubQuery global `store`. + +1. An `OrderFilled` log from the IntentGatewayV3 contract triggers `handleOrderFilledV3Event` in `src/handlers/events/intentGatewayV3/orderFilledV3.event.handler.ts`. It decodes the log, then makes two independent calls, each in its own try/catch: + - `IntentGatewayV3Service.updateOrderStatus(commitment, FILLED, ..., filler)` — order status, points, user activity, and the per-fill volume records described below. + - `IntentGatewayV3Service.recordOrderVolume("FILLED", outputTokens, timestamp)` — a separate, unconditional cumulative volume path (see the parallel-paths note). + +2. `updateOrderStatus` (`src/services/intentGatewayV3.service.ts`) first loads the `OrderV3Placed` entity by commitment. + - If the order is not indexed yet (fill event arrived before the `OrderPlaced` event, possible across chains), it stores a `PendingStatusMetadata` row and returns early. `flushPendingStatuses` later replays the status onto the order once `OrderPlaced` arrives, but it replays only the status: none of the volume, points, or user-activity effects below happen for that fill. Volume records therefore under-count fill-before-place races, deliberately and equally for filler and gateway records. + - Otherwise it saves the new status and, when the status is FILLED and a filler address is present, gathers the order's output assets (`IOrderV3OutputAsset` rows keyed `commitment-output-N`), prices them with `getOutputValuesUSD` (unknown tokens price as zero), and records volume. + +3. Volume recording first calls `VolumeService.seedAggregateVolume("IntentGatewayV3.FILLED", "IntentGatewayV3.FILLER.")` — a one-time, per-chain initialization that backfills the gateway-level series from the already-indexed per-filler daily records, deriving the gateway cumulative from the same per-day sums (it no-ops once the gateway cumulative record exists, which is its marker). It must stay ahead of the updates below; reordering it after them double-counts the current fill. The seed has its own try/catch: on failure the fill's status, points, and user activity still proceed, and only the gateway volume update below is skipped — that leaves the marker uncreated, so the next fill retries the seed and recovers the skipped fill from the filler daily rows. Then come two `VolumeService.updateVolume` calls with the same USD total and timestamp, differing only in base ID: + - `IntentGatewayV3.FILLER.` — per-filler series. + - `IntentGatewayV3.FILLED` — gateway-level, filler-independent series (issue #1085). + (Order placement, elsewhere in the same service, records the analogous user-side series with base ID `IntentGatewayV3.USER`.) + +4. `VolumeService.updateVolume` (`src/services/volume.service.ts`) fans out to two upserts, both scoping the ID by chain: `getChainTypeId` appends the host state machine (for example `EVM-8453`) resolved from the SubQuery global `chainId`. + - `updateCumulativeVolume` upserts `CumulativeVolumeUSD` with ID `.`. It skips the addition when the record's `lastUpdatedAt` equals the incoming timestamp, so a second fill in the same block does not increment the cumulative counter. This guard fires per record: the chain-wide `IntentGatewayV3.FILLED` cumulative collides on any two same-block fills, even by different fillers, so it can lag the sum of the per-filler cumulatives; the daily series counts every fill and stays exact. + - `updateDailyVolume` upserts `DailyVolumeUSD` with ID `..` (UTC day bucket). It has no same-timestamp guard, so every call increments the daily counter. + - USD amounts are stored as bigints scaled by 1e18 (`toScaledUsd`). + +5. Back in the handler, a third independent try/catch calls `IntentGatewayV3Service.publishInventoryAfterFill`. Unlike the two above it is not a volume path: it re-reads the balances behind the pools this fill traded through on this chain and publishes them for the Hyperbridge node to fold into the pool rows (see the pool liquidity refresh flow below). Like `recordOrderVolume` it runs whether or not the order is indexed yet — but it needs the order row for the source chain, so a fill-before-place race resolves no pool and it returns immediately. + +Parallel paths that look similar but are not the same: `recordOrderVolume` (step 1) writes `IntentGatewayTokenVolume` and `CumulativeIntentGatewayVolumeUSD` (IDs keyed `chain-token-volumeType` / `chain-volumeType`). It does its own token pricing and skips tokens with no known price, while the `updateOrderStatus` path prices unknown tokens as zero through `getOutputValuesUSD`; their USD totals can therefore differ for the same fill. Do not expect `CumulativeIntentGatewayVolumeUSD` for FILLED to equal `CumulativeVolumeUSD` for `IntentGatewayV3.FILLED`: they also diverge on fill-before-place races (only `recordOrderVolume` runs) and same-block fills (only the `VolumeService` cumulative counter deduplicates). diff --git a/sdk/packages/indexer/docs/ai/flows/phantom-bid-calldata-decoding-extractfilldatavm2.md b/sdk/packages/indexer/docs/ai/flows/phantom-bid-calldata-decoding-extractfilldatavm2.md new file mode 100644 index 000000000..b1d9ab9ae --- /dev/null +++ b/sdk/packages/indexer/docs/ai/flows/phantom-bid-calldata-decoding-extractfilldatavm2.md @@ -0,0 +1,23 @@ +# Phantom bid calldata decoding (`extractFillDataVm2`) + +Verified by executing the function against both shapes; the selector check is what makes the two-interface +attempt necessary. + +1. `handlePhantomOrderPrices.handler.ts` injects `extractFillDataVm2` into `aggregatePhantomBids` as + `extractFill`. The SDK's own `extractFillData` is not used here: it decodes with viem, whose byte handling + throws inside SubQuery's VM2 sandbox. +2. A bid's `callData` is the solver account's ERC-7821 `execute(mode, executionData)` batch. The batch is decoded, + and each call whose `target` is the gateway is a `fillOrder` candidate. The bid's sender must be + EIP-7702-delegated to one of the chain's `SOLVER_ACCOUNT_ADDRESSES`; the list carries the current + SolverAccount and, during a redeployment, the one it replaced. The bid's sender must be + EIP-7702-delegated to one of the chain's `SOLVER_ACCOUNT_ADDRESSES`; the list carries the current + SolverAccount and, during a redeployment, the one it replaced. +3. `decodeFillOrderEither` tries the v2 interface (`FILL_ORDER_ABI`, with `validUntil`, selector `0xa5470064`) + and then the v1 one (`FILL_ORDER_V1_ABI`, selector `0x5cfb1ea5`). ethers validates the selector before + decoding, so exactly one can match and there is no payload that could be mis-decoded as the other shape. + Which one a bid carries depends on the gateway it targets — solvers encode for the deployment they bid + against, and gateways predating `validUntil` take v1. +4. A call matching neither shape is skipped, and if no call in the batch decodes the function returns null. +5. **Null is dropped silently upstream** — `aggregatePhantomBids` does `if (!fillData) continue` with no log. A + decoding regression therefore shows up as missing pool rates rather than as an error, which is why the shapes + are covered by tests rather than left to runtime observation. diff --git a/sdk/packages/indexer/docs/ai/flows/phantom-price-snapshot-to-pool-rates-phantombidwindowexhausted.md b/sdk/packages/indexer/docs/ai/flows/phantom-price-snapshot-to-pool-rates-phantombidwindowexhausted.md new file mode 100644 index 000000000..4538354d7 --- /dev/null +++ b/sdk/packages/indexer/docs/ai/flows/phantom-price-snapshot-to-pool-rates-phantombidwindowexhausted.md @@ -0,0 +1,25 @@ +# Phantom price snapshot to pool rates (PhantomBidWindowExhausted) + +Verified 2026-08-19 against live mainnet data. + +1. `PhantomBidWindowExhausted` on Hyperbridge triggers `handlePhantomOrderPrices` (`src/handlers/events/substrateChains/handlePhantomOrderPrices.handler.ts`). It loads the `PhantomOrderV2` and its registered `PhantomOrderLeg` rows, then calls `aggregatePhantomBids` from the SDK, which fetches every bid for the commitment, verifies each one (solver signature over the userOp hash plus an EIP-7702 delegation check), and reduces them per leg. + + A bid's `paymasterAndData` arrives in one of two shapes, and the SDK's `decodePhantomBidPaymasterAndData` reads both before the declaration is used: the bare declaration blob (every bid until simplex moved to Permit2), or the 234-byte EntryPoint v0.8 payload for the Simplex paymaster's PERMIT2 mode with the declaration appended after the permit (a bid built on simplex's real-bid path since #1223). A sponsored bid with nothing appended counts as having declared nothing — null accepted sources, no positions — the same as an empty field. The solver signature covers the whole field in both shapes, so `recoverBidSignerVm2` is unchanged; `phantom-decode.test.ts` checks the ethers digest over the long payload matches viem's. + + The chain ids in a declaration are decoded in the SDK without `TextDecoder`. That matters here specifically: the handler runs inside SubQuery's vm2 sandbox, where `TextDecoder` is not defined and the `util` fallback rejects a sandbox-created `Uint8Array`, so a decoder reaching for it threw inside the per-bid try/catch of `aggregatePhantomBids` — logged as "Failed to process bid for price snapshot", bid dropped, run continues. That was the whole failure behind bids with a source-chain declaration vanishing from the snapshots (verified 2026-09-09 against the live bids and the deployed indexer's data); `phantom-decode.sandbox.test.ts` runs the shipped bundle inside vm2 to keep it from coming back. A chain with a phantom order but no `solverAccount` in `config-mainnet.json` is skipped with "No SolverAccount configured for chain" — Gnosis (EVM-100) was, until its entry was added. + +2. Per leg, a solver's quote is weighted by **its balance of that leg's OUTPUT token on the destination chain** — the inventory that actually backs the leg. Zero-weight quotes are dropped entirely, not down-weighted: they never reach the median, `bidCount`, or the bidder list. A leg where no bidder holds the output token is absent from the result, exactly as if nobody quoted it. + +3. The leg's price is `weightedMedian` of the backed quotes — a **selection**, not a blend. It returns one bidder's exact integer, so a solver holding over half the leg's weight sets the published price verbatim, and the result can never be a value nobody quoted. `lowestPrice` and `highestPrice` are deliberately overwritten with the median so consumers cannot read an outlier bid as a tradeable bound. + +4. `updateLiquidityPools` (`src/services/liquidityPool.service.ts`) turns those per-leg medians into pool rows. `resolvePoolLeg` maps a leg's tokens to a pool id and direction via the token registry, and the sample's rate is + + ``` + medianPrice * 10 ** (18 - outDecimals) * 10 ** inDecimals / standardAmount + ``` + + i.e. the quote renormalized from the probe size back to one whole input token. This holds for any standard amount the pallet configures; it collapses to `medianPrice * scale` when the probe is exactly one unit. Multiplications happen before the division, so only the last step truncates, by under one unit of 1e18 and downward. + +5. Chain rows (`PoolChainLiquidity`, one per pool/chain/direction) are merged into the pool's single `sellRate`/`buyRate` by `weightedRate` — a depth-weighted **mean**, which unlike the median in step 3 does produce values no filler quoted. Samples older than `MAX_SAMPLE_AGE_BLOCKS` are excluded unless every sample is stale. + +Precision note: a leg's quoted output integer *is* the price, to whatever resolution the output token's decimals allow. cNGN into 6-decimal USDC quotes ~715 base units, so the grid is 1/715 = 0.14% and the filler's floor rounding costs up to one full step. Chains whose output token has 18 decimals carry full precision on the same leg — which is why EVM-56 publishes `716845878136200` where Base publishes a bare `715`. The fix is a larger `standardAmount`, which step 4 now supports; see Decisions.md for why the filler's flooring must stay. diff --git a/sdk/packages/indexer/docs/ai/flows/pool-liquidity-refresh-orderfilled-partialfill-escrowreleased.md b/sdk/packages/indexer/docs/ai/flows/pool-liquidity-refresh-orderfilled-partialfill-escrowreleased.md new file mode 100644 index 000000000..834f6214e --- /dev/null +++ b/sdk/packages/indexer/docs/ai/flows/pool-liquidity-refresh-orderfilled-partialfill-escrowreleased.md @@ -0,0 +1,78 @@ +# Pool liquidity refresh (OrderFilled, PartialFill, EscrowReleased, vault Deposit/Withdraw) + +Verified 2026-09-08 by unit tests against a mocked store (`inventoryReading.service.test.ts` for the EVM half, +`liquidityPoolFold.service.test.ts` for the Hyperbridge half); the store behaviour the split rests on was read in +`@subql/node-core` 19.3.1 and the forked substrate node. + +The snapshot flow measures a pool's depth once per bid window. This flow keeps it honest in between, when +fills have spent some of the inventory it is a sum of. It is split across two kinds of node because each +SubQuery node process serves `Entity.get` from a private cache that no other process's write invalidates and +flushes whole rows with no locking: the pool family is therefore written by the Hyperbridge node only, and the +EVM nodes publish readings for it to fold. + +**EVM side — publish (`src/services/inventoryReading.service.ts`)** + +1. Four events reach it, each in its own try/catch — they read external RPCs, and stale depth is recoverable, so a + failure must never stall indexing: + - `handleOrderFilledEventV3` and `handlePartialFilledEventV3` call `IntentGatewayV3Service.publishInventoryAfterFill`, + which loads the order row for its **source** chain (a fill carries the inputs' addresses but not the chain they + live on), resolves the pools with `poolsForFill`, and calls `publishPoolInventory`. No order row, or no + registry-tracked pair, means nothing to publish — the common case, and what keeps this off most fills' path. + - `handleEscrowReleasedEventV3` (source chain) calls `publishInventoryAfterEscrowRelease`: the solver was just paid + the order's inputs back, so its inventory there ROSE. The event names no filler, so the handler first reads + the gateway's `_filled(commitment)` at that block. + - `YieldVaultService.recordLedger` (vault `Deposit`/`Withdraw`) ends with the same call for (chain, lp, + underlying token), after its own known-solver gate and duplicate-log guard. + The last two name a solver and a token but no pool, so they enter through `publishProviderInventory`. +2. Both entry points select `PoolBidder` rows of **this chain only** with field queries (which go to Postgres; + `get` would serve the process cache), and collapse them to distinct (solver, output token) targets. A target + whose rows were all sampled (`lastUpdatedAt`) or refreshed (`refreshedAt`) after the event is skipped — that + reading already saw what the event moved, and during a resync this is true of every replayed event, so + backfilling costs no RPC. A target whose token the registry no longer tracks is skipped with a warning. +3. Each target's inventory is read pinned to the event's block on this chain: wallet ERC-20 plus ERC-4626 + `maxWithdraw` (`getTotalSolverBalance`) PLUS the Uniswap V4 positions the solver declared, each read on-chain, + owner-checked, and valued in the output token. **If any read fails, or the chain has no configured RPC, + nothing is published**: a failed read looks exactly like a zero balance, and a zero reading drops the bidder. +4. One `SolverInventoryReading` row per target is written, id `{chain}-{token}-{solver}`, holding the raw balance, + the pinned block, the event time as `observedAt`, and the trigger. Nothing else is written here — not the + pool, not the chain row, not the bidder, not the balance series. + +**Hyperbridge side — fold (`foldInventoryReadings` in `src/services/liquidityPool.service.ts`)** + +5. `handleInventoryFold` (`src/handlers/events/liquidity/inventoryFold.block.handler.ts`) runs on every Hyperbridge + block, registered inside the `enableLiquidityIndexing` block of the substrate manifest template so only the + Hyperbridge node runs it. +6. It pages the whole reading table (the store has no range operators) and drops readings an in-process memo has + already folded at that observation time. A quiet block ends here after one page. +7. Per chain with new readings, it loads every `PoolBidder` row of that chain and groups them by pool. A row takes + a reading when provider, chain and output token match (case-insensitively) and the reading's `observedAt` is + later than both the row's `lastUpdatedAt` and its `refreshedAt`. The raw balance is normalised to 1e18 through + the registry; a zero removes the row (every row is a bidder with capacity); otherwise `liquidity` and + `refreshedAt` are written. Readings older than the row's snapshot — a lagging or resyncing EVM node's — are + ignored, which is what makes the fold safe to repeat. +8. Every (pool, chain) that changed has its `PoolChainLiquidity` depth/bidCount/unrestricted slice and its + `PoolRoute` rows recomputed from the surviving bidder rows (`republishChainRows`), with the direction set + taken from before the removals so a direction whose bidders all vanished is zeroed rather than skipped. Routes + are never *created* here: declarations only come from bids, so the surviving set can only shrink. +9. Each pool touched is re-merged into `sellDepth`/`buyDepth` through the same `mergeChainRowsIntoPool` the + snapshot writer uses, with the fold's own block as the staleness reference — it is a Hyperbridge block, the + unit the rows are stamped in. +10. Each reading that applied to at least one row extends `LiquidityProviderBalanceV2` with its raw balance, keyed + by the fold's Hyperbridge block with the reading's `observedAt` as `snapshotTime`. A zero balance is not a row, + matching the sweep; an existing row for that key is only ever raised, never lowered. +11. Nothing here writes `lastUpdatedBlock` or `lastUpdatedAt`, and nothing re-derives a rate. A pool's merged + rate can still move, because the per-chain samples are depth-weighted and the depths just changed. + +Store facts this depends on: a node sees only rows whose historical range contains its own current block time, +so an EVM node ahead of Hyperbridge writes readings the fold sees once Hyperbridge catches up; the mainnet +template flushes the store asynchronously every five seconds, which adds to that lag; and every field used in a +`getByFields` filter must carry `@index` (`chain`, `provider`, `tokenAddress` and `observedAt` on the reading do). + +Where the V4 positions come from: `handlePhantomOrderPrices` calls `recordDeclaredPositions` +(`src/services/solverPositions.service.ts`), which writes one `SolverV4Positions` row per bidding solver — keyed +by the solver's address, holding the tokenIds `aggregatePhantomBids` verified and the chain the bid was for. A +bid is the only place a position is ever named. The EVM-side publication reads that row through a field query on +its provider link (`declaredV4Positions`) — it is written on another node, so `get` would serve a stale cached +copy, and the id itself is only indexed in historical mode — and a row recorded on another chain reads as none. A solver that bids without declaring has its row emptied; one that +skips a window keeps it. One row per solver assumes one V4 chain, and the writer warns rather than overwriting a +row from a different one. diff --git a/sdk/packages/indexer/docs/ai/flows/substrate-schema-migrations-verified-2026-09-04-against-the.md b/sdk/packages/indexer/docs/ai/flows/substrate-schema-migrations-verified-2026-09-04-against-the.md new file mode 100644 index 000000000..f69f306cb --- /dev/null +++ b/sdk/packages/indexer/docs/ai/flows/substrate-schema-migrations-verified-2026-09-04-against-the.md @@ -0,0 +1,22 @@ +# Substrate schema migrations (verified 2026-09-04, against the forked node-core) + +How a change to `src/configs/schema.graphql` reaches the database. All nodes share one `schema.graphql` and one +`--db-schema=app`; the substrate node runs the forked `polytopelabs/subql-node-substrate` image with +`--allow-schema-migration`, the EVM nodes run the stock `subql-node-ethereum` image. + +1. On boot each node's main thread runs `StoreService.init` (node-core). With the flag off (the EVM nodes) it calls the + schema migration with a `null` baseline, which only emits `CREATE TABLE IF NOT EXISTS` — existing tables and their + columns are left untouched. +2. With the flag on (the substrate node), `init` reads the previously applied schema from the `appliedSchemaSDL` + metadata key, rebuilds it into a baseline `GraphQLSchema`, and diffs it against the current one. Additive changes + (add nullable column, table, index, enum value, relation) are ALTERed in place; the new schema is written back to + `appliedSchemaSDL` in the same transaction as the DDL. +3. A destructive diff (a removed or retyped field, a removed entity — a retype surfaces as remove+add) is refused with a + fatal error unless `SUBQL_ALLOW_DESTRUCTIVE_MIGRATION=true`, because it would drop the column and its data. +4. Compose gates every EVM node behind the substrate node's `/ready` healthcheck, and `init` (including the migration) + completes before `/ready`. So the substrate leader applies the whole shared schema's DDL before any EVM node starts; + the EVM nodes' `CREATE TABLE IF NOT EXISTS` is then a no-op. There is no multi-writer race. + +The engine that performs the ALTERs (`SchemaMigrationService`) already existed; the change was giving it a real baseline +on restart instead of `null`. Adding a field is therefore: edit `schema.graphql`, `pnpm build`, restart. No rename, no +data loss, no reindex. diff --git a/sdk/packages/sdk/docs/ai/ChangeLog.md b/sdk/packages/sdk/docs/ai/ChangeLog.md deleted file mode 100644 index 31176e919..000000000 --- a/sdk/packages/sdk/docs/ai/ChangeLog.md +++ /dev/null @@ -1,266 +0,0 @@ -# ChangeLog - -AI-maintained log of code changes in `sdk/packages/sdk`. Every AI-assisted change appends an entry here: date, what changed, and the files touched. This is not the release changelog — `sdk/packages/sdk/CHANGELOG.md` is the published release log and is managed separately. - -Entry format: - -``` -## YYYY-MM-DD — short title (issue/PR if any) -What changed and why, in a few sentences. -Files: list of files touched. -``` - -Newest entries first. - -## 2026-09-09 — Declaration chain ids are UTF-8 coded by hand, so the decoder survives the SubQuery sandbox - -Every phantom bid that named a source chain was being dropped by the deployed indexer with "Failed to process bid for price snapshot", from the block its solver's simplex began declaring sources (#1216): `decodePhantomBidDeclaration` read each chain id through `@polkadot/util`'s `u8aToString`, which is `TextDecoder.decode`, and inside SubQuery's vm2 sandbox `TextDecoder` is not a global and the `util` fallback rejects a sandbox-created `Uint8Array` ("The \"list\" argument must be an instance of ... ArrayBufferView"). Bids declaring nothing, or only V4 positions, never reached that call and kept decoding, which is why one solver stayed in the Base snapshots while the two that had upgraded vanished. The codec now encodes and decodes UTF-8 with plain byte arithmetic (`utf8Encode`/`utf8Decode`), byte-identical to TextEncoder/TextDecoder and rejecting what they reject; a chain id that is not well-formed UTF-8 makes the whole declaration malformed. Reproduced and verified against the shipped bundle inside a vm2 NodeVM (indexer test `phantom-decode.sandbox.test.ts`). -Files: `src/protocols/intents/phantom-aggregation.ts`, `src/tests/phantomSourceDeclaration.test.ts`, `docs/ai/Decisions.md`, `docs/ai/Flow.md`. - -## 2026-09-09 — Phantom bid `paymasterAndData` decodes in both shapes: bare declaration, or Permit2 sponsorship with the declaration appended - -Simplex now authorizes every sponsored UserOp with a per-op Permit2 signature (#1223), so a phantom bid built on the real-bid path carries the EntryPoint v0.8 paymasterAndData for the Simplex paymaster's PERMIT2 mode — paymaster(20) ‖ two uint128 gas limits ‖ mode 0x02 ‖ token ‖ permitAmount ‖ nonce ‖ deadline ‖ v ‖ r ‖ s, 234 bytes — where the aggregation used to expect only the declaration blob. `decodePhantomBidPaymasterAndData` now reads either: a bare declaration first (byte-for-byte the previous behaviour), else a blob opening with a complete Permit2 sponsorship, whose tail is parsed as the declaration or, if empty, read as "declared nothing". It reports which shape it found and the sponsorship's fields (paymaster, fee token, permit amount, nonce, deadline). `decodePhantomBidDeclaration` delegates to it, so the aggregation and every other reader see the same declaration whichever shape a bid arrived in. `encodePhantomBidPaymasterAndData` builds the sponsored shape for a solver that has one to append, refusing anything but the Permit2-mode layout, and falls back to the bare declaration when there is no sponsorship. Both functions and `PERMIT2_SPONSORSHIP_BYTES` are exported from the root and the `intents-helpers` sub-path. -Files: `src/protocols/intents/phantom-aggregation.ts`, `src/protocols/intents/index.ts`, `src/intents-helpers.ts`, `src/tests/phantomSourceDeclaration.test.ts`, `src/tests/phantomAggregation.test.ts`, `docs/ai/Decisions.md`, `docs/ai/Flow.md`. - -## 2026-09-07 — `FillerConfig.acceptedSourceChains` removed - -The optional field is gone from `FillerConfig`: simplex now derives a bid's accepted sources from its configured chains and watch-only flags at bid time, so nothing reads it. `encodePhantomBidDeclaration` and the decoder are unchanged. -Files: `src/types/index.ts`. - -## 2026-09-05 — Mainnet SolverAccount moves to the 2026-09-05 deployment; 2.8.11 - -Every mainnet chain config points `SolverAccount` at `0x7cb55539d1144F62422099c3FA3405092022c88C`, -the account deployed alongside the new IntentGatewayV2 implementation on 2026-09-05 (PR #1207), -replacing `0xfCd233b937D7622AAc63ced3C9A1A12F4a6B64E3`. Solvers delegating through the SDK pick -up the new account on upgrade. The bid-verification fixture in `phantomAggregation.test.ts` uses -the same address. `aggregatePhantomBids` accepts `solverAccount` as one address or a list and -counts a bid whose sender delegates to any of them, so an indexer can keep the replaced account -listed while solvers re-delegate; the delegation reader reads the code once and checks membership. -Version 2.8.11; simplex 0.13.1 goes with it. - -Files: `src/configs/chain.ts`, `src/protocols/intents/phantom-aggregation.ts`, -`src/tests/phantomAggregation.test.ts`, `package.json`, `docs/ai/ChangeLog.md`. - -## 2026-09-07 — Wallet-funded phantom bids pay the protocol fee haircut, read from the gateway - -The wallet-funded haircut returns, but as a live value rather than a constant: `readProtocolFeeHaircutBps` calls `IntentGateway.params()` on the phantom order's own chain once per aggregation run and takes `protocolFeeBps` from it; `applyProtocolFeeHaircut` shades every bid that declares no Uniswap V4 positions by that amount. Pool-priced bids still pay the fixed 10bps `UNISWAP_QUOTE_HAIRCUT_BPS` instead, never both. A gateway that answers with no code or a fee at or above 100% throws a `PhantomRpcError`, so the run is retried and then abandoned rather than priced unhaircut. Both new functions are exported from `@/protocols/intents` and the `intents-helpers` sub-path. -Files: `src/protocols/intents/phantom-aggregation.ts`, `src/protocols/intents/index.ts`, `src/intents-helpers.ts`, `src/tests/phantomAggregation.test.ts`, `docs/ai/Flow.md`, `docs/ai/Decisions.md`. - -## 2026-09-07 — Wallet-funded phantom bids are no longer haircut; only pool-priced bids pay 10bps - -The 5bps `PHANTOM_QUOTE_HAIRCUT_BPS` introduced on 2026-08-27 is removed, along with `applyPhantomQuoteHaircut` and both exports. `aggregatePhantomBids` now haircuts a bid only when its declaration names Uniswap V4 positions, by the unchanged `UNISWAP_QUOTE_HAIRCUT_BPS` of 10bps; a bid with no declared positions is published exactly as quoted. The haircut still lands on the individual quote before the zero-check, the weighted median, and the bidder rows. -Files: `src/protocols/intents/phantom-aggregation.ts`, `src/protocols/intents/index.ts`, `src/intents-helpers.ts`, `src/tests/phantomAggregation.test.ts`, `docs/ai/Flow.md`, `docs/ai/Decisions.md`. - -## 2026-09-03 — `pollPhantomOrders` can skip a backlog it will never use - -A mainnet filler fell behind the head and stayed there: the cursor gains at most `maxBlocksPerPoll` per tick and -never skips, so a deficit accumulated while ticks were lost is only repaid if the sustained rate beats the -chain's. It kept delivering orders 3,448 blocks old, whose bid window had closed hours before, and the filler -dutifully bid on every one. - -The cursor now abandons a backlog it cannot use: once it is more than a generation cycle behind the head -(`bidWindowBlocks + max(intervalBlocks, bidWindowBlocks)`) it jumps to one window behind the head — keeping every -order that can still be bid on — and reports the range it dropped through `onSkip`. Not configurable — this -feed exists to be bid on, and an order that far behind cannot be bid on by anyone, so there is no caller for whom -walking that backlog is the right answer. The check runs only once the cursor is established, so a cold start — -exactly one block behind — is never read as a backlog. - -That bounds the property the cursor was built for. It still advances only past blocks whose events were really -read, so an outage delays orders rather than dropping them — up to the point where the delayed orders are dead -anyway. - -`lookbackBlocks` is gone with it. It existed so a restarting process could reach back and still bid on a window -already open, which is the same late bid this change is removing: by the time the process is up, that window has -all but closed. Nothing passed it, and a cold start now begins at the head. - -The threshold is the chain's, not a constant: `phantomTimings()` reads `PhantomBidWindow` — falling back to the -`PhantomOrderBidWindowBlocks` runtime constant when that storage value is zero, exactly as the pallet's own -`phantom_bid_window()` does — and `PhantomOrderInterval`, once per instance. Nexus runs a window of 15 against a -constant of 25 and an interval of 55, so anything hard-coded is wrong in one direction or the other. - -Also adds `latestBlockNumber()`. A phantom order carries the block it was registered at and no clock, so a -consumer deciding whether one is still biddable has to read the head, and nothing public exposed it. - -Files: `src/chains/intentsCoprocessor.ts`, `src/tests/pollPhantomOrders.test.ts`. - -## 2026-09-01 — Block-tagged balance reads, and declared V4 positions reported out of the aggregation (#1159) - -The indexer refreshes a solver's liquidity on every event that moves it, not only when a phantom bid window -closes. Three things it needs were locked inside `phantom-aggregation.ts`. - -`getTotalSolverBalance` is now exported and takes a `blockTag`. It is the definition of "a solver's balance" — -raw ERC-20 plus every configured ERC-4626 vault's `maxWithdraw` — and a per-event re-read has to use exactly it: -simplex funds fills straight out of a vault inside the fill transaction, so a wallet-only `balanceOf` misses such -a fill entirely. The block tag is what lets a re-read record the balance as of the event rather than stamping -today's balance onto a historical row. `memoizedSolverBalance` takes a per-chain `blockTags` map for the same -reason — block numbers are per chain, so an event handler can pin its own chain and leave the rest of the sweep -at the head, where a number from another chain would mean nothing. - -`readV4Position` is exported (now taking a params object, with a `blockTag`), `positionAmountOfToken` is -re-exported from `intents-helpers`, and `aggregatePhantomBids` reports the positions it verified as -`PhantomAggregation.positions` — solver, chain, tokenId, after the ownership check — alongside `solvers`, every -solver whose bid passed verification. `legs`, `lpBalances` and `positions` are each filtered by what a solver -turned out to hold or declare, so `solvers` is the only complete answer to "who bid this window", which is what -a consumer reconciling per-solver state needs. A bid is the only place a -Uniswap V4 position is ever named, so without this a consumer cannot re-value one between windows; and carrying -the last window's value forward is worse than not seeing it, because simplex funds fills from these positions -and such a fill drains the position while wallet and vault balances barely move. - -Files: `src/protocols/intents/phantom-aggregation.ts`, `src/intents-helpers.ts`, `src/tests/phantomAggregation.test.ts`. - -## 2026-08-28 — Export `FILL_ORDER_V1_ABI` for consumers that cannot use `decodeFillOrder` - -`decodeFillOrder` handles both `fillOrder` shapes, but it is viem-based and viem's byte handling throws inside -SubQuery's VM2 sandbox, so the indexer reimplements the decode with ethers. It was building its interface from -`FILL_ORDER_ABI` alone and therefore rejected every v1-shaped bid — which is every bid on mainnet today, since no -gateway has been upgraded past `validUntil` yet. - -The legacy shape is now exported (from `fillOrderCodec`, re-exported on the `intents-helpers` sub-path) so that -decoder can share this definition instead of keeping a second copy. A duplicated ABI that drifts out of step with -this one is exactly the failure being fixed, so the constant is exported rather than re-declared downstream. - -Only the constant is exported on the VM2-safe sub-path; `decodeFillOrder` itself stays off it. - -Files: `src/protocols/intents/fillOrderCodec.ts`, `src/protocols/intents/index.ts`, `src/intents-helpers.ts`. - -## 2026-08-27 — `FillOptions` carries a `validUntil`, and `fillOrder` has two shapes - -`FillOptions` gained `validUntil` (a block number; `0n` means unbounded), enforced by `fillOrder`, which now reverts -`FillExpired` past it. Adding a field changes the enclosing function's selector, so `fillOrder` exists in two -incompatible shapes — `0x5cfb1ea5` (v1) and `0xa5470064` (v2) — and gateways upgrade per chain, so both are on the -wire at once. - -New `protocols/intents/fillOrderCodec.ts` owns that: `getFillOptionsVersion` reads the gateway's ERC-1967 -implementation slot and matches the address against a set of known pre-`validUntil` implementations, defaulting to -v2; `encodeFillOrder` emits the matching shape and `decodeFillOrder` reads either. There is no version getter on -the contract — EIP-1967 has no version field either, and the implementation address is the value the proxy already -updates on upgrade. `GasEstimator` encodes through it so estimates do not revert on a missing function; `BidManager` and -`phantom-aggregation.extractFillData` decode through it so bids built against an older gateway are still priced in -rather than dropped. - -Why the field exists: a solver bidding through the coprocessor signs this calldata and then has no further say in -when it is used. The order's `deadline` is placer-chosen with no ceiling, retracting the bid on Hyperbridge does not -reach the destination chain, and the placer holds the session key — so a signed bid stayed executable indefinitely -and was taken up only once the rate had moved against the solver. `validUntil` rides in the calldata, which -`userOpHash` already covers, so it is tamper-proof without touching the signature format. - -`fillOrder` also no longer calls `IIntentPriceOracle.recordSpread`. It passed `order.inputs` and `options.outputs` -to a stateful oracle verbatim, and neither is validated against anything that costs the caller money — the escrow -lives on the source chain and is never consulted on the destination side. `Params.priceOracle` is left in place -because removing a field from a storage struct behind an upgradeable proxy shifts the layout. - -Found by the scheduled IntentGateway/Simplex security audit. - -Files: `src/protocols/intents/fillOrderCodec.ts` (new), `src/protocols/intents/GasEstimator.ts`, -`src/protocols/intents/BidManager.ts`, `src/protocols/intents/phantom-aggregation.ts`, -`src/protocols/intents/index.ts`, `src/abis/IntentGatewayV2.ts`, `src/types/index.ts`, -`src/tests/fillOrderCodec.test.ts` (new), `src/tests/phantomAggregation.test.ts`, plus -`evm/src/apps/IntentGatewayV2.sol`, `evm/src/apps/intentsv2/IntentsBase.sol` and -`sdk/packages/core/contracts/apps/IntentGatewayV2.sol`. - -## 2026-08-27 — Fix: the ranged block scan names the events key and its type outright - -The `state_queryStorage` change in the entry below broke the phantom-filler E2E: zero bids on every chain, because the poll delivered no orders at all. Verified against a live simnode; the fix is verified there too. - -The read asked for `api.query.system.events.key()`. polkadot-js builds a `StorageKey` from that argument and needs two things from it — key bytes, to match the change set the node returns, and `meta`, to decode that change set's value — and there are three accessors that each supply a different subset: - -| argument | key bytes | metadata | result | -|---|---|---|---| -| `entry.key()` | correct | none | value typed `Raw`: undecoded bytes | -| `entry` (decorated) | **`[object Promise]`** | yes | matches no change set; value falls back to the entry's empty default | -| `entry.creator` | correct | yes | correct | - -The first was the shipped bug: iterating `Raw` yields numbers, `phantomOrdersFrom` destructured `event` off one, and the tick threw on every tick forever. Nothing failed at the RPC layer, so it read as a poll that never found an order. The second was the first attempt at a fix, and is worse — it fails *silently*, returning an empty vector, because `StorageKey` derives key bytes by calling its argument and calling a decorated entry runs the query. - -Rather than depend on picking correctly between them, the read now computes the key itself — `twox_128("System") ++ twox_128("Events")`, mirroring `system_events_storage_key` in `parachain/simtests` — calls `queryStorage.raw` so polkadot-js does no formatting at all, and decodes with `registry.createType("Vec", value)`. A plain entry's key is a pure function of its pallet and item names, so there is nothing to look up and nothing to choose. - -Two changes so this class of fault cannot be silent or fatal again. `phantomOrdersFrom` now rejects anything that is not a vector of event records instead of quietly finding nothing in it — a block reading as empty is the silent miss the block cursor exists to rule out. And `scanRangeAtOnce` treats that rejection like a refused method: the ranged path is abandoned for the life of the process, the error is reported once through `onError` so the degradation is visible, and the per-block path — which decodes through `api.at` rather than from a passed key — carries the poll from there. Losing the fast path costs requests; losing every bid is what it cost before. - -The unit tests missed it because the harness handed back pre-decoded records whatever it was asked for. It now models what a node does: change sets are keyed by the real events key, and a request naming any other key gets no changes for it — silence, not an error. Nine tests fail if the key is wrong. `phantom-range.simnode.test.ts` covers the rest, against a real node, because the decisive behaviour is polkadot-js's handling of a real reply and no mock can stand in for it. - -Files: `src/chains/intentsCoprocessor.ts`, `src/tests/pollPhantomOrders.test.ts`, `docs/ai/ChangeLog.md`, `docs/ai/Decisions.md`, `docs/ai/Flow.md`. - -## 2026-08-27 — A block scan is one `state_queryStorage` call, and the poll caps at 10 blocks - -The previous entry left the scan at one request per block for events. `state_queryStorage(keys, from, to)` reads a key across a whole block range in one call, so the scan's request cost no longer depends on how many blocks it covers: a tick is now four requests flat — head, runtime version, the range's two bounding block hashes as one batched request, and the ranged read. `maxBlocksPerPoll` drops from 20 to 10 at the same time, because the cost moved rather than vanished (`sc-rpc` warns the method is `O(|keys| * dist(from, to))` in time and memory) and because it still bounds the per-block fallback. - -`getPhantomOrdersInRange` is the new read; the event decoding it shares with `getPhantomOrdersAtHash` moved into a `phantomOrdersFrom` helper. polkadot-js formats a `Vec` reply into `[blockHash, valuesPerKey]` pairs with each value already typed from the storage key's metadata, so nothing is decoded by hand. - -Two properties of the RPC drive the rest of the change. It answers with **diffs** — `query_storage_unfiltered` drops a block's change set when the value matches the previous block's — so a quiet chain's consecutive blocks come back as one entry. That is safe because a `PhantomOrderRegistered` commitment is derived from the block number, so a block that registered orders can never encode identically to another; absent provably means no orders. And it is **gated by `--rpc-methods`**, answered as `Method not found` when denied. The node here already runs unsafe RPC to serve `offchain_localStorageGet`, so it is normally available; a refusal switches the poll to the per-block path permanently and is handled rather than reported. - -`scanRangeAtOnce` also declines the ranged read when the tick has no confirmed runtime version, or when that version is no longer the one the api's registry was built for — `state_queryStorage` declares no historic block hash, so rpc-core decodes its reply against the connect-time registry, which an upgrade leaves stale. After an upgrade the poll stays on the per-block path, which resolves the right registry, until the process restarts. - -Files: `src/chains/intentsCoprocessor.ts`, `src/tests/pollPhantomOrders.test.ts`, `docs/ai/ChangeLog.md`, `docs/ai/Decisions.md`, `docs/ai/Flow.md`. - -## 2026-08-27 — Concurrent Hyperbridge calls travel as one JSON-RPC batch request - -Follow-up to the pacing change below. The endpoint's limit is counted in HTTP requests, and the reads here arrive as bursts of concurrent calls, so JSON-RPC 2.0 batching turns a burst into one request. Substrate supports it: `sc-rpc-server` sets `batch_request_config` on jsonrpsee, and `sc-cli` resolves `BatchRequestConfig::Unlimited` unless the operator passes `--rpc-disable-batch-requests` or `--rpc-max-batch-request-len`. polkadot-js does not — `HttpProvider.send` encodes exactly one call per POST and the package exports no batch API — so `http()` now builds a `BatchingHttpProvider` (`src/utils/batchingHttpProvider.ts`) instead of the `RateLimitedHttpProvider` it replaces. - -Calls queued within one macrotask go out together, capped at 32 per request. The token bucket is charged once per HTTP request rather than once per call, which is the whole point. A lone call is sent as a plain request object, not a one-element array, so the common case is byte-for-byte what the base provider sent and needs no batch support at all. Flushes are serialised — one request in flight at a time — because overlapping flushes interleave against a `maxBatchSize` a refusal can shrink underneath them, which fragmented a burst into more requests than it needed. Both refusals are handled without losing a call: `-32005` disables batching for the provider's life and retries the calls singly, `-32010` halves the batch size and retries. - -Batching only helps callers that have more than one call in flight, so the block scan now fetches a range's block hashes as one concurrent wave before reading events. `chain_getBlockHash` is the half of the pair that parallelises safely — it takes no historic block hash, so concurrent calls never trigger polkadot-js's per-hash registry resolution and cannot race each other's registry state. The events reads stay sequential for exactly that reason; see Decisions for why. `getPhantomOrdersAtHash` was split out of `getPhantomOrdersInBlock` to make that possible. - -A tick now costs `3 + n` HTTP requests for n blocks rather than `2 + 2n`: a 20-block catch-up is 23 requests where the previous entry left it at 42 and it began at 81. A single-block tick is unchanged at 4 — the hashes there are one call, batched or not. The fan-out on a phantom order interval is where it bites hardest: up to 16 concurrent `offchain_localStorageGet` reads become one request. - -Files: `src/chains/intentsCoprocessor.ts`, `src/utils/batchingHttpProvider.ts`, `src/tests/batchingHttpProvider.test.ts`, `src/tests/intentsCoprocessorRateLimit.test.ts`, `src/tests/pollPhantomOrders.test.ts`, `docs/ai/ChangeLog.md`, `docs/ai/Decisions.md`, `docs/ai/Flow.md`. - -## 2026-08-27 — Hyperbridge HTTP reads are paced, and a block scan costs half the requests - -Fillers were logging `[429]: Too Many Requests` from the Hyperbridge HTTP endpoint against a 10 req/s limit, on a poll whose interval is 15s. The interval was never the request rate. A tick costs one head read plus the cost of every block in its range, and `getPhantomOrdersInBlock` was costing four RPCs per block, not one: `api.at(hash)` with no version to go on resolves a registry by fetching `chain_getHeader(hash)` and then `state_getRuntimeVersion(parentHash)` — every block, because the `getUpgradeVersion` shortcut that would skip it only covers chains hardcoded in `@polkadot/types-known`. All of them go out back-to-back, so a tick averaging 0.4 req/s arrives as ~33 req/s. On a phantom order interval the fan-out in simplex's `handlePhantomOrders` adds one concurrent `offchain_localStorageGet` per configured chain (up to 16) on top. - -Three changes: - -- `http()` now builds a `RateLimitedHttpProvider`, an `HttpProvider` whose `send` waits on a `TokenBucket` (new, `src/utils/rateLimiter.ts`). Buckets are keyed by endpoint origin in a module-level map, so every coprocessor in a process pointed at the same node shares one budget. Default 8 req/s, `HYPERBRIDGE_RPC_MAX_RPS` to override. -- `getPhantomOrdersInBlock` takes an optional `knownVersion` and passes it to `api.at`, which drops both hidden reads. `pollPhantomOrders` establishes one per tick via `confirmedRuntimeVersion()` — read after the head, used only when it matches the previous reading, so a tick spanning a runtime upgrade falls back to exact per-block resolution. Steady state is 1 head + 1 version + 2 per block. -- `maxBlocksPerPoll` defaults to 20 rather than 500, and a 429 backs the poll off for a doubling number of ticks (capped at 8), reset by any tick that gets through. Ordinary failures still retry on the very next tick. - -Files: `src/chains/intentsCoprocessor.ts`, `src/utils/rateLimiter.ts`, `src/tests/rateLimiter.test.ts`, `src/tests/intentsCoprocessorRateLimit.test.ts`, `src/tests/pollPhantomOrders.test.ts`, `docs/ai/ChangeLog.md`, `docs/ai/Decisions.md`, `docs/ai/Flow.md`. -## 2026-08-27 — Pool-priced phantom bids are haircut 10bps, every other bid 5bps - -The pool haircut added on 2026-08-24 drops from 30bps to 10bps, and bids that declare no Uniswap V4 positions — previously untouched — now pay a 5bps haircut of their own. `aggregatePhantomBids` picks one of the two per bid from the same signed declaration it already read: `applyUniswapQuoteHaircut` when positions are named, `applyPhantomQuoteHaircut` otherwise. They never stack. Both still land on the individual quote before the zero-check, the weighted median, and the bidder rows, so the declined-leg path still catches a leg amount a haircut rounds to zero. - -`PHANTOM_QUOTE_HAIRCUT_BPS` and `applyPhantomQuoteHaircut` are exported alongside the Uniswap pair from `@/protocols/intents` and the `intents-helpers` sub-path, so the indexer and simplex keep reading both numbers from one place. - -Files: `src/protocols/intents/phantom-aggregation.ts`, `src/protocols/intents/index.ts`, `src/intents-helpers.ts`, `src/tests/phantomAggregation.test.ts`. - -## 2026-08-25 — Intent quotes use aggregate indexed pool rates by default - -`IntentGateway.quoteIntent` now prices orders from the pair-centric indexer's depth-weighted aggregate `LiquidityPool.buyRate` and `sellRate`. Source and destination chains resolve the configured token deployments, while the quote converts the pool's whole-token rate into raw amounts with configured decimals, applies the source gateway protocol fee, and exposes the selected rate and timestamp in metadata. Reverse sell-rate reciprocals round up so quotes do not overpromise output. Phantom snapshot and Uniswap V4 pricing remain explicit compatibility strategies. Live sequential tests cover exact-input USDC to cNGN and exact-output cNGN to USDC across BSC and Base, including their different token decimal scales. The dead `binance.llamarpc.com` BSC default was replaced with `bsc-rpc.publicnode.com` after it blocked those live checks. - -Files: `src/configs/chain.ts`, `src/protocols/intents/IntentGateway.ts`, `src/protocols/intents/LiquidityEngine.ts`, `src/protocols/intents/index.ts`, `src/protocols/intents/quote/index.ts`, `src/protocols/intents/quote/indexedRates.ts`, `src/protocols/intents/quote/types.ts`, `src/tests/sequential/intentGateway.test.ts`, `package.json`, `CHANGELOG.md`, `docs/ai/ChangeLog.md`, `docs/ai/Decisions.md`, `../../../docs/content/developers/sdk/api/intent-gateway.mdx`, `../../../docs/content/developers/evm/intent-gateway/placing-orders.mdx`. - -## 2026-08-24 — Pool-priced phantom bids are haircut 30bps before aggregation - -A phantom bid that declares Uniswap V4 positions is quoting off those pools, and a pool price is what a trade gets before the pool takes its fee — so the amount such a bid names is richer than what the solver clears once the sourcing swap goes through. `aggregatePhantomBids` now nets 30bps out of every leg amount on a bid whose declaration carries positions, before the quote reaches the zero-check, the weighted median, or the bidder rows. Non-declaring bids are untouched: wallet inventory has already paid its cost of goods. - -The haircut runs off the declaration rather than the positions that survive the on-chain ownership check, so a quote is priced on the same basis the solver priced it on — including on a chain with no V4 contracts configured, where declared positions contribute no weight. A leg whose amount the haircut rounds to zero falls into the existing declined-leg path. - -`UNISWAP_QUOTE_HAIRCUT_BPS` and `applyUniswapQuoteHaircut` are exported from both `@/protocols/intents` and the `intents-helpers` sub-path, so the indexer and simplex read the same number instead of restating it. - -Files: `src/protocols/intents/phantom-aggregation.ts`, `src/protocols/intents/index.ts`, `src/intents-helpers.ts`, `src/tests/phantomAggregation.test.ts`. - -## 2026-08-21 — HTTP provider no longer caches responses, so a failed phantom poll read is retried - -`IntentsCoprocessor.http()` built its `HttpProvider` with polkadot-js defaults, which cache every request that names a block hash by storing the request promise itself — rejected promises included — under a 30s TTL refreshed on every hit. `pollPhantomOrders` retries the block it failed on with identical parameters each tick, so after one reset connection (`fetch failed` / `ECONNRESET`) every later tick got the same rejection back from the cache in a few milliseconds — the same `state_getRuntimeVersion(parentHash)` hash in every log line — and the node never saw another request. With the 15s poll interval inside the 30s TTL the entries never expired, so a filler stayed wedged until restart, placing no phantom bids. The provider is now constructed with cache capacity 0, which sends every request to the node. The cache bought nothing for this api: the poll reads each block once, and `api.at(hash)` reuses registries at the api layer regardless. - -`intentsCoprocessorHttpCache.test.ts` pins the dependency's behaviour against a stub node that RSTs one request, and that the provider the coprocessor builds retries it. - -Files: `src/chains/intentsCoprocessor.ts`, `src/tests/intentsCoprocessorHttpCache.test.ts`. - -## 2026-08-19 — Fix the refund-POST gas pin #1144 left behind - -#1144 split `CANCEL_MESSAGE_GAS = 800_000n` into `SOURCE_GET_RESPONSE_GAS` and `REFUND_POST_GAS`, both 1M, but left `orderCanceller.test.ts` asserting the POST at 800k — main's own CI has failed the concurrent-sdk step since it merged, and every PR cut from it inherited the red check. The pin now matches the shipped constant, with a comment naming the origin so the next reprice updates both. - -Files: `src/tests/orderCanceller.test.ts`. - -## 2026-08-18 — `SigningAccount` shrinks to `signTypedData` alone - -`SigningAccount` is the contract a solver's signing backend satisfies to submit bids (`SubmitBidOptions.solverSigner`). It declared `signMessage(messageHash, chainId)`, which nothing in this package ever called: bids are signed as EIP-712 UserOperations in `BidManager.prepareSubmitBid` via `signTypedData`, and `GasEstimator`'s one `signMessage` call is viem's own method on a locally derived account, not this interface. The requirement propagated out to every implementer — including `@hyperbridge/simplex`, whose public `Signer` satisfied this type (it no longer extends it; simplex adapts with `sdkSigningAccount` at the two call sites) — so removing it here is what let that interface shrink to what a solver actually needs. - -`signTypedData`'s second parameter went the same way. EIP-712 carries the chain id in `domain.chainId` — that is what the digest covers — and `BidManager` was passing it alongside a payload that already contained it, for the benefit of no implementation in this package. - -`signRawHash` followed in the same day's follow-up: once simplex's `Signer` required `signAuthorization` and `signTransaction`, no caller in either package handed a raw digest to the interface, leaving `signTypedData` as the whole contract. - -Type-only narrowing: it can break a caller of a removed member, and there are none inside the workspace. - -Files: `src/types/index.ts`, `src/protocols/intents/BidManager.ts`. diff --git a/sdk/packages/sdk/docs/ai/Decisions.md b/sdk/packages/sdk/docs/ai/Decisions.md deleted file mode 100644 index acab7e250..000000000 --- a/sdk/packages/sdk/docs/ai/Decisions.md +++ /dev/null @@ -1,435 +0,0 @@ -# Decisions - -AI-maintained record of non-obvious choices made in `sdk/packages/sdk`: what was decided, what the alternatives were, and why. Read this before changing related code so a later change does not silently undo a deliberate trade-off. - -Entry format: heading with the decision, then alternatives considered and the reasoning. Newest first. - -## 2026-09-09 — No TextEncoder/TextDecoder anywhere on the phantom bid decode path - -Chosen: the declaration codec encodes and decodes its UTF-8 chain ids by hand, in the same file, with no -dependency on `TextEncoder`, `TextDecoder`, or the `@polkadot/util` wrappers over them (`stringToU8a`, -`u8aToString`). The rule this sets: nothing `aggregatePhantomBids` runs per bid may touch either API. - -Why. The indexer runs the aggregation inside SubQuery's vm2 sandbox, and that sandbox exposes neither API as a -global; `@polkadot/x-textdecoder` then falls back to Node's `util.TextDecoder`, whose argument check -(`ArrayBuffer.isView`) does not recognise a `Uint8Array` created inside the sandbox, because it arrives as a -proxy. Injecting the host's `TextDecoder` into the sandbox does not help — the failure is the realm of the -bytes, not the presence of the API — so this cannot be fixed from the sandbox configuration side. The same -class of failure is why `extractFillDataVm2` and `recoverBidSignerVm2` exist in the indexer, and this one hid -for a month because no live bid carried a source chain until simplex started declaring every configured -chain (#1216): the empty and positions-only declarations never reach the string decode. - -Alternatives considered: - -- Add `utf8Decode` to the indexer's VM2-safe injection set (a `decodeDeclaration` parameter on the - aggregation). It would work, but it puts a second copy of a security-relevant parser downstream and leaves - the SDK's own decoder silently unusable in the one environment it is mainly run in. A pure decoder in the - SDK fixes every consumer at once and needs nothing injected. -- Decode chain ids with `String.fromCharCode` over the bytes, since state machine ids are ASCII. It would pass - today and misread the first non-ASCII id ever declared, byte by byte, into a name that matches no chain — - a silent route loss rather than a rejected declaration. Real UTF-8, with the same rejections as - `TextDecoder`, keeps the wire format exactly what it was. -- Catch the decode error and treat the bid as declaring nothing. That reads a solver's explicit source list - as "any chain" and hands it routes it never offered. - -The test that guards this bundles the shipped `intents-helpers` into one file with esbuild (as `subql build` -does with webpack) and runs the decoder inside a NodeVM configured like `@subql/node-core`'s Sandbox, because -vm2 cannot load the pnpm module graph piecemeal (ESM-only packages, dynamic imports at load time). It lives in -the indexer, which has vm2 and esbuild through `@subql/node-core` and `@subql/cli`. - -## 2026-09-09 — A phantom bid's paymasterAndData is read as a bare declaration first, and as a Permit2 sponsorship with an optional tail second - -Chosen: `decodePhantomBidPaymasterAndData` tries the bare declaration parse over the whole blob; only when that -fails does it look for a complete 234-byte Permit2-mode paymasterAndData at the front and parse whatever follows -as the declaration. Both parsers demand exact consumption. A sponsored bid with nothing appended, or with a -malformed tail, is a bid that declared nothing — the absent declaration, still counted. - -Alternatives considered: - -- Move the declaration out of `paymasterAndData` for sponsored bids (a second field, or a side channel). There is - no other free signed field: `initCode` is spoken for under EIP-7702, and anything outside the userOpHash is not - authenticated. The tail is signed for free and the paymaster never parses a phantom bid, so it costs nothing. -- Detect the sponsored shape first. A bare declaration of 234+ bytes whose byte 52 happens to be `0x02` would - then be tried as a sponsorship and, if its tail did not parse, read as "declared nothing" — a silent change to - bids that decode today. Bare-first keeps every existing bid byte-identical in behaviour; the reverse false - positive needs a paymaster address opening with `0x01`/`0x02` AND the gas-limit words and permit bytes forming - length-prefixed entries that end exactly at the blob's end, which is not a shape any packer produces. -- Recognise the 2612 permit mode (0x00, 150 bytes) as a sponsorship too. Simplex confines that mode to a - first-time delegation — its sequential nonce serialises concurrent ops — so no bid carries it, and the retired - allowance mode 0x01 is refused by the paymaster. Accepting only 0x02 keeps the decoder to layouts that exist on - the wire; a bid in another mode decodes as "none", which is the absent declaration. -- Verify the Permit2 signature, or check its deadline. Neither is a fact about the quote: the bid's authenticity - is the solver signature over the userOpHash, which covers these bytes, and a phantom bid never executes so an - expired permit changes nothing. The fields are decoded for the record and the signature is not kept. - -The encoder is strict where the decoder is lenient: `encodePhantomBidPaymasterAndData` throws unless the -sponsorship is exactly the Permit2-mode layout, because a solver that signed some other shape would have its -declaration silently read as absent — the failure a lenient decoder cannot report and a strict encoder prevents. - -## 2026-09-07 — The wallet-funded haircut is the gateway's protocol fee, read live, not a constant - -Chosen: every bid that declares no Uniswap V4 positions is haircut by the `protocolFeeBps` the IntentGateway on -the phantom order's chain reports from `params()`, read once per aggregation run. Supersedes the entry below, -made earlier the same day, which had removed the flat 5bps. - -Why: the published rate is what a taker is told they can trade against, and the gateway deducts its protocol -fee from every order's input before the solver's quote applies. A flat 5bps happened to equal that fee today; -tying it to the contract makes the published rate track the fee takers actually pay, and moves with governance -instead of with a constant someone has to remember. The pool tier stays a constant because it stands in for a -Uniswap pool fee, which is not the gateway's to set. - -Alternatives considered: - -- **Keep a constant and document that it mirrors the fee.** Rejected: the two drift silently the first time the - fee changes, and nothing in the pipeline would notice. -- **Read the fee from a fixed chain (Base) regardless of the order's chain.** Rejected: the fee that applies to - fills of this rate is the one collected by the gateway on the chain the order lives on, and the aggregation - already holds that gateway's address and RPC. Reading elsewhere would price one chain's rate with another - chain's fee if they ever differed. Today every deployment charges the same 5bps, so the outcome is identical. -- **Default to zero (or the old 5bps) when the read fails.** Rejected: a gateway address with no code is a - misconfiguration, not a zero fee, and a window priced without the haircut on the back of it publishes a - rate nobody realizes. Failing the run keeps the previous window's rate standing, visibly old, instead. -- **Memoize the fee across runs alongside balances.** Rejected: it is one static call per window and it is - governance state; a stale value here outlives a stale balance by hours. - - - -Chosen: drop `PHANTOM_QUOTE_HAIRCUT_BPS` (5bps) and `applyPhantomQuoteHaircut`. A bid that declares Uniswap V4 -positions still pays `UNISWAP_QUOTE_HAIRCUT_BPS` (10bps); every other bid is published at the amount it named. -This reverses the 2026-08-27 decision below. - -Why: the 10bps pool haircut nets out a real cost — the pool fee a pool-priced quote has not yet paid. The 5bps -wallet haircut netted out nothing: a wallet-funded solver has already paid its cost of goods and names the amount -it will actually clear, so the shade only moved the published rate 5bps off the executable one and made the -snapshot disagree with what fills at. - -Alternatives considered: - -- **Keep a smaller wallet haircut (1–2bps).** Rejected: any nonzero value re-raises the question of what cost it - represents, and there is none; the margin between quote and fill belongs to the solver's own pricing. -- **Fold the two into one constant applied to every bid.** Rejected: it would either charge wallet bids a pool - fee they never pay or under-charge pool bids, and the two tiers exist precisely because the costs differ. - -## 2026-09-03 — The phantom-order lag limit is read from the chain, and always applies - -Chosen: the poll derives its threshold from `phantomTimings()` — the pallet's `PhantomBidWindow` (or the -`PhantomOrderBidWindowBlocks` constant behind it) and `PhantomOrderInterval` — and applies it unconditionally. - -Alternative rejected — a `maxLagBlocks` option, off by default. It was written that way first, to preserve the -cursor's existing property: it advances only past blocks whose events were really read, so an outage delays -orders instead of losing them, which is what made the cursor a fix for the dropped-subscription bug and what a -consumer reading the feed as history would want. But no such consumer exists, and the feed is not history: a -phantom order is a standing invitation to bid that expires with its window. Past that window there is no caller -for whom walking the backlog is right, so the option only offered every caller a way to get it wrong — and the -one that got it wrong on mainnet would have had to opt in to be fixed. - -The property is bounded rather than abandoned: inside the limit an outage still delays orders instead of dropping -them, and the tests pin both halves. - -Alternative rejected — expose the lag and let the caller reset the poll. It moves the same decision one layer out -while making every caller reimplement the jump, and the poll would still need the head it already reads. - -Alternative rejected — a fixed number of blocks. It was 60 for one commit and it was already wrong: Nexus's -window is 15 inside a 55-block interval, so the real threshold is 70, and on Gargantua (window 5) it is 10. Both -values are governance-set and neither is derivable from the other, so any constant is wrong on some chain or -after some referendum. - -The timings are read once per instance rather than per tick: a governance change to either is rare, a request per -tick forever is not free, and the cost of caching is that a change lands on the next restart. A failed read is -not cached, so it retries, and it fails the tick rather than guessing — leaving the cursor exactly where it was. - -Removed in the same breath: `lookbackBlocks`, which started a cold cursor some blocks behind the head so a -restarting process could still bid on a window already open. That is the same late bid the age gate downstream -now refuses — a process that has just come up is, by definition, near the end of any window it reaches back for — -so the option was buying exactly the behaviour this change exists to stop. No caller set it. - -## 2026-09-01 — Verified V4 positions are reported out of the aggregation (#1159) - -Chosen: `aggregatePhantomBids` returns the tokenIds it verified alongside the balances it swept, and -`readV4Position` (params object, with a `blockTag`) plus `positionAmountOfToken` are exported so a consumer can -re-value them later with the same reads and arithmetic a leg was weighted by. - -Alternative rejected — let the consumer decode the bids itself. It is possible (the indexer stores every bid's -raw payload) and was tried: everything needed is already done here once — fetch the bids, verify each signature -and delegation, decode `paymasterAndData`, check ownership on-chain — so redoing it downstream duplicates the -security-relevant half of this module, and the two copies would drift. - -Alternative rejected — have the consumer carry the last window's position VALUE forward instead of the tokenId. -It needs no new plumbing and is wrong in exactly the case that matters: simplex funds fills out of these -positions, so a fill drains the position inside the fill transaction while wallet and vault balances barely move, -and a carried value keeps advertising precisely the inventory the fill just spent. - -Positions are reported after the ownership check, not as declared. A declaration is a pointer, not a claim, and -recording an unowned one downstream would hand the fill path a position to value that the solver cannot spend. - -`solvers` is reported for the opposite reason: every other field is filtered by what the solver turned out to -hold or declare, so a verified bidder holding nothing anywhere is absent from all of them. A consumer -reconciling per-solver state ("this solver bid and declared nothing, so empty its row") cannot see it otherwise, -and that bidder is exactly the one whose inventory is all in positions it may have just stopped offering. - -## 2026-09-01 — `blockTag` is a parameter of the balance read, and a per-chain map on the memo (#1159) - -Chosen: `getTotalSolverBalance` takes a `blockTag` defaulting to `"latest"`, and `memoizedSolverBalance` takes a -`Record`. - -Alternative rejected — a single `blockTag` on the memo. A refresh reaches across every chain a pool is quoted on, -while the event that triggered it happened on one; block numbers are per chain, so one tag applied to all of them -would read some other chain at an arbitrary point in its history. The map pins the event's chain and leaves the -rest at the head, which is the only correct reading available. - -Alternative rejected — leave every read at the head. Simpler, and it is what a periodic sweep wants, but a -per-event re-read at the head is not replayable: reindexing an old fill would stamp today's balance onto it. - -## 2026-08-28 — The legacy `fillOrder` ABI is exported, not re-declared downstream - -The indexer needs the v1 shape to decode bids with ethers. Two ways to give it one: - -- **Re-declare it there.** No SDK change, but then two definitions of the same historical ABI have to stay - identical forever, with nothing enforcing it. The bug being fixed is precisely a decoder that fell out of step - with the shapes in the wild, so adding a second source of truth for those shapes is the wrong direction. -- **Export the existing constant.** One definition. Costs a public export of something that is otherwise an - implementation detail — acceptable, since the reason it exists (deployments predating `validUntil`) is a fact - about the network rather than about this package. - -Exported from `fillOrderCodec` and re-exported on the `intents-helpers` sub-path, which is the entry point that -exists for tools that cannot load the full bundle. Pulling `fillOrderCodec` onto that sub-path adds no new -runtime dependency — it imports only viem, the gateway ABI, and types, all already reachable there. - -`decodeFillOrder` is deliberately not exported alongside it: it is viem-based, and the callers that need this -constant are the ones that cannot run viem. - -## 2026-08-27 — The bid expiry rides in `FillOptions`, not in the bid signature - -Chosen: `validUntil` is a field on `FillOptions`, checked by `fillOrder` at execution. - -The alternative was to put the expiry in the ERC-4337 signature blob and return it from -`SolverAccount.validateUserOp` as a `validUntil` validation range — the mechanism 4337 provides for exactly this. -That was built first and then abandoned, for reasons worth recording: - -- **It needs a new signed field.** `userOpHash` does not cover `op.signature`, so an expiry carried there is - rewritable by whoever replays the bid. Making it tamper-proof meant changing what the solver signs (an EIP-712 - `BidValidity` digest) and widening the selection signature 162 → 168 bytes. -- **That is a `SolverAccount` redeploy.** The account is reached by EIP-7702 delegation, so a new version means a new - address and every solver re-delegating — and, since the old account keeps accepting the old format forever, it - retires nothing already signed. -- **`FillOptions` needs none of that.** The options are part of `callData`, which `userOpHash` *does* cover. The - expiry is authenticated for free, with no signature format change, no account redeploy, and no migration. -- **It covers more.** The signature-side check only bounds solver-selection bids. A check in `fillOrder` bounds every - path into it. - -The cost is that this fires at execution rather than validation: an expired bid is included, the nonce is consumed -and the account pays that op's gas, where a validation-time range would have had the bundler drop it for free. That -is a bounded, one-off cost per bid — and consuming the nonce permanently retires the bid, which the validation-time -version does not do. Fund loss, the thing that matters, is prevented either way. - -Denominated in blocks rather than a timestamp so it reads against the same clock as `order.deadline` (`_blockNumber()`, -the L2 block number where those differ), and so the two cannot disagree about what "expired" means. - -`0` means unbounded. That is the right default for a solver filling directly — it is only exposed to its own -staleness — and it keeps every existing caller working. The protection is opt-in by the party that needs it. - -## 2026-08-27 — The implementation address identifies the FillOptions shape - -Chosen: `getFillOptionsVersion` reads the ERC-1967 implementation slot and checks the address -against `LEGACY_FILL_OPTIONS_IMPLEMENTATIONS`, a set of implementations deployed before -`validUntil` existed. Anything else is v2. - -EIP-1967 standardises three slots — implementation, admin, beacon — all holding addresses. There -is no version field in the spec to read, and OZ `Initializable`'s `uint64` only moves under -`reinitializer(N)`, which this contract does not use. The implementation address is the only value -the proxy actually updates on upgrade, so it is what identifies the deployed code. - -The list is of **legacy** implementations, not current ones, so the default is v2. That direction -is the whole point: a newly shipped implementation needs no edit here, and once every deployment -is upgraded the set is vestigial and still correct. - -A single entry covers every chain that runs it. The protocol contracts are CREATE2-deployed, so -`0x976B268b06f545c4A2BF44866Aa2465bd8B3C67d` is the pre-`validUntil` implementation on those -chains — confirmed with the maintainers rather than inferred, since the CREATE2 claim in the tree -is about the proxies and does not by itself say anything about implementations. - -`CHAINS_WITHOUT_VALID_UNTIL` covers the rest. The testnets have not been redeployed and their -implementation addresses are not tracked here, so the address check alone would read them as -current and every fill would revert on a selector that does not exist. It is checked before the -slot read, both because the address is uninformative there and because it saves a round trip. -Delete a chain from that set as its gateway is redeployed; once it is empty the address check -covers everything on its own. Listing known-good implementations instead -would be the version constant this replaced wearing a different hat — a value someone must -remember to update on every upgrade, where forgetting breaks every fill on that chain. - -Only v2 answers are cached, keyed by proxy address. A deployment can move from legacy to current -but never back, so a v2 result is true forever; caching a v1 result would pin the old encoding -across the very upgrade that changes it, since the proxy address does not move and nothing would -invalidate it. A still-legacy gateway therefore costs one storage read per fill, an upgraded one -costs none. - -Also considered and dropped: scanning the implementation's runtime code for the v2 `fillOrder` -selector. It needs no address list and self-updates, and the selector does survive `via-ir` and -the optimizer — but it is a heuristic (a 4-byte sequence can appear in non-dispatcher data), and -both failure directions break every fill on the chain, since the two shapes cannot decode each -other. An address match is exact. - -Earlier still, and rejected: a `fillOptionsVersion()` getter on the contract. A hand-maintained -integer is a second source of truth that answers what a deployment claims rather than what it can -decode. - -## 2026-08-27 — The events key is computed, and the ranged reply is decoded explicitly - -Chosen: `SYSTEM_EVENTS_KEY = twox_128("System") ++ twox_128("Events")`, `queryStorage.raw`, and `registry.createType("Vec", value)`. - -Why not ask polkadot-js for the key: the formatted call builds a `StorageKey` from its argument and takes both the key bytes and the decoding metadata from it, and the three obvious accessors each get a different subset right — `entry.key()` has the bytes and no metadata (values arrive as `Raw`), the decorated `entry` has metadata but yields `[object Promise]` as its bytes (matches nothing, value silently empty), and only `entry.creator` has both. All three type-check, none fails loudly, and two of them ship a poll that finds no orders. Shipping the first cost a red E2E; the second was caught only by running against a real node. - -A plain entry's storage key is `twox_128(pallet) ++ twox_128(item)` and nothing else, which is why `parachain/simtests` computes it directly in `system_events_storage_key`. Computing it here removes the choice entirely, and `.raw` removes the formatting layer that made the choice matter — at the cost of naming the value type, which is stable and asserted against a live node. - -Alternatives considered: - -- **`entry.creator`.** Correct, and verified working. Rejected as the primary because it is one non-obvious accessor away from two that fail silently, and nothing in its shape says so — the next person to touch this line has the same three-way choice. -- **Keeping the formatted call and adding a test that the key matches.** That is what the unit test now does anyway, but it only constrains the call site; the decode still happens inside polkadot-js against metadata this code never sees. - -## 2026-08-27 — The fast path is never load-bearing: a ranged reply that will not decode falls back - -Chosen: `phantomOrdersFrom` throws `EventDecodeError` on anything that is not a vector of event records, and `scanRangeAtOnce` responds by abandoning the ranged read permanently, reporting once, and letting the per-block path take over. - -Why, concretely: the ranged read shipped asking for `system.events.key()` instead of the storage entry, so polkadot-js had no metadata to decode against and returned `Raw`. The poll found no orders in any block and no filler bid — for four minutes of E2E, and it would have been indefinite in production. The RPC never failed; only the decoding was wrong. - -Alternatives considered: - -- **Let the decode error propagate to `onError` and retry.** That is what happened, in effect, and it is a permanent outage: the next tick asks the same way and gets the same bytes. Retrying only helps a transient fault, and a type mismatch is not one. -- **Fall back silently.** Rejected: the fast path being off is worth knowing about, and this file's whole disposition is against silent degradation. Reported once, then quiet. -- **Validate the decoded value and skip just the bad block.** Rejected: it cannot distinguish "this block decoded to nothing" from "nothing decodes", and the cursor would advance past real orders either way. - -The general shape worth keeping: the ranged read is an optimisation over a path that already worked, so every way it can fail should end at that path rather than at a stopped poll. The metadata-carrying key is pinned by a test, and the harness now decodes only when the key it was handed carries `meta` — reproducing the failure rather than papering over it. - -## 2026-08-27 — The scan reads a range with `state_queryStorage`, and declines it rather than risk a stale registry - -Chosen: read every block's events in one `state_queryStorage(keys, from, to)` call, falling back to per-block reads under three conditions. - -This supersedes the "deferred" bullet in the entry below, which contemplated `state_getStorage.raw` per block with a hand-rolled decode. `state_queryStorage` is better on both counts it was deferred for: it is one call for the range rather than one per block, and polkadot-js decodes a `Vec` reply itself — `_formatOutput` types each value from the storage key's own metadata — so there is no hand-rolled decoding to get wrong. - -Alternatives considered: - -- **`state_getStorage` per block, batched.** Rejected once the ranged call was available: same request count only if the batch holds the whole range, and it asks the node for n storage reads instead of one range walk. -- **Trusting the ranged reply unconditionally.** Rejected. rpc-core decodes it against the *default* registry, because `state_queryStorage` declares no `isHistoric` parameter and so gets no registry swap. That registry is fixed at connect and an HTTP api has no `subscribeRuntimeVersion` to refresh it, so after a runtime upgrade the decode is silently wrong in exactly the way a stale registry always is — events read as a shape the scan does not recognise, and the block passes as carrying nothing. `scanRangeAtOnce` therefore requires the tick's confirmed version to equal `api.runtimeVersion`, and hands off to the per-block path otherwise. The cost is that an upgrade costs a process restart to get the cheap path back; the direction of failure is right. -- **Treating a refusal as an error.** Rejected: `--rpc-methods=safe` makes the method permanently unavailable, not intermittently. It is detected once (`Method not found`, which is also how `check_if_safe` denies) and the poll switches paths for good without reporting anything. - -What had to be reasoned about rather than assumed: `query_storage_unfiltered` in `sc-rpc` pushes a change set only when a key's value differs from the previous block in the range, and drops the set entirely when empty. So blocks are *missing* from the reply, not merely empty — on a quiet chain, consecutive blocks whose events are just the timestamp inherent's `ExtrinsicSuccess` encode identically and collapse to one entry. This is only safe because `phantom_order_commitment` derives the commitment from the block number: a block that registered orders cannot encode like any other block, so "absent" implies "no orders". A change to how commitments are built would break that, which is why it is written down here. - -Because one call covers the range, the cursor advances the whole way or not at all — there is no partial progress to preserve, unlike the per-block path. - -Why `maxBlocksPerPoll` went to 10 rather than up: the ranged read is one request but not free work. `sc-rpc` documents it as `O(|keys| * dist(from, to))` in time *and* memory, so a wide range is one request the node spends a long time on — and the same number still bounds the fallback, which is one request per block. - -## 2026-08-27 — Batching is transparent at the provider, and only the block-hash half of the scan was made concurrent - -Chosen: coalesce concurrent calls into a JSON-RPC batch inside the provider, and make the poll fetch a range's block hashes concurrently while leaving the events reads sequential. - -Why not batch at the call sites: the callers that burst are spread out — the offchain fan-out in simplex's `handlePhantomOrders`, the scan, balance reads — and an explicit batch API would have to be threaded through each. The provider is where a burst is already visible as concurrency, and coalescing there needs no call site to know it is happening. - -Why the events reads are still sequential, which is the non-obvious half. Making them concurrent looks like the bigger win — it would take a scan of n blocks to two requests instead of n+1 — but it silently undoes the `knownVersion` work. `api.at(hash, version)` resolves its registry through `_getBlockRegistryViaVersion`, which sets `lastBlockHash` on the shared registry; the subsequent `system.events` read goes through rpc-core's own registry swap, which calls `getBlockRegistry(hash)` *without* a version and finds it only by that `lastBlockHash`. Sequentially that always hits. Concurrently, n interleaved `at` calls each overwrite it, so all but one miss and fall through to `_getBlockRegistryViaHash` — the two RPCs per block that naming a version exists to avoid. Batching would hide that from the request counter while doubling the node's work, which is the opposite of the intent. - -Alternatives considered for the events half: - -- **Accept the registry re-resolution**, since the extra calls batch anyway. Rejected: it trades node-side work for a lower request count, and the limit exists to bound work. -- **`state_getStorage.raw(key, hash)` with one registry for the range.** `.raw` skips the swap (`isScale && blockHash && …` in rpc-core's `_createMethodSend`), so this really is two RPCs per block and one request for the range. Deferred, not rejected: it means decoding events by hand through `registry.createLookupType(meta.type.asPlain)` rather than `apiAt.query.system.events()`, and a wrong type there decodes to a shape the scan reads as "no orders" — a silent miss. Verifying it needs a running node, which the unit tests do not have. Worth doing behind the simnode test. - -Why one request in flight at a time: overlapping flushes interleave against a `maxBatchSize` that a `-32010` refusal shrinks underneath them, so the same burst fragmented into a different number of requests run to run. Serialising also makes each request as full as it can be. The cost is that a submission can wait a round trip behind a scan flush, which at the paced rate is smaller than the wait the bucket already imposes. - -Why a macrotask window rather than a microtask: `Promise.all` starts its calls in one synchronous run, but each then advances through several microtask turns before reaching the provider. A microtask flush fires between those turns and splits one burst across several requests. - -## 2026-08-27 — Request pacing lives at the provider, keyed by endpoint - -Chosen: an `HttpProvider` subclass whose `send` waits on a shared `TokenBucket`, with one bucket per endpoint origin in a module-level map. - -Alternatives considered: - -- **Pacing inside `pollPhantomOrders`** — a sleep between blocks, or a queue around the scan. Rejected: the poll is not the only caller. Balance polling, the offchain fan-out in simplex's `handlePhantomOrders`, and the HTTP submission fallback all hit the same endpoint, and the limit counts them together. Pacing the loudest caller leaves the sum unpaced, and it is the sum the node sees. -- **A bucket per `IntentsCoprocessor`.** Rejected: several fillers in one process each hold their own coprocessor, so N instances would each pace to the full budget and collectively exceed it by N. The limit is a property of the endpoint, so the bucket is too. -- **`p-queue` with `intervalCap`,** which the package already depends on. Rejected: its interval is a fixed window that refills all at once, so a burst arriving just after a boundary is passed straight through, which is the exact shape being defended against. A token bucket refills continuously. -- **Reacting to 429s only** (backoff, no pacing). Kept as well, but not instead: a 429 is already a request spent, and a limiter that is shedding load may be counting rejections too. Backoff recovers from a breach; the bucket is what stops causing them. - -Two consequences worth knowing. The bucket makes a large scan a *queue*, so anything sharing the endpoint waits behind it — which is why `maxBlocksPerPoll` dropped to 20 in the same change; at 500 a catch-up would have queued ~1000 requests, over two minutes of them, ahead of a bid submission that is worth nothing after five blocks. And the bucket is FIFO on purpose: without it, a caller arriving on an idle bucket takes the token a queued one was waiting for, and a steady arrival stream starves the queue. - -Why not simply raise the limit with the provider: the endpoint is derived from the websocket's, not configured (phantom orders live in that node's offchain storage), so operators do not necessarily control it. `HYPERBRIDGE_RPC_MAX_RPS` exists for those who do. - -## 2026-08-27 — The block scan names a runtime version, and drops it the moment it might be wrong - -Chosen: `getPhantomOrdersInBlock` takes an optional `knownVersion` for `api.at`; the poll establishes one per tick by reading `state_getRuntimeVersion` after the head read, and uses it only when it matches the previous reading. - -Why it matters: with nothing to go on, `api.at(hash)` resolves a registry through `_getBlockRegistryViaHash` — `chain_getHeader(hash)` plus `state_getRuntimeVersion(parentHash)` — on every block, because its cheap paths are a registry pinned to that exact hash (only ever the previous block's) or one matching a version the caller names. That is two of the four RPCs a block cost, and they are pure overhead for a scan walking consecutive blocks under one runtime. - -Alternatives considered: - -- **Pass `api.runtimeVersion` unconditionally.** Rejected: an HTTP `ApiPromise` has no `subscribeRuntimeVersion`, so that field is frozen at connect. After an upgrade it names a version whose registry is still in `#registries`, so `_getBlockRegistryViaVersion` matches it and decodes new blocks against old metadata — and the failure is silent. The events come back in a shape the scan does not recognise, the block reads as carrying no phantom orders, and the cursor advances past it. That is precisely the silent miss the block cursor exists to rule out. -- **Read the version once and refresh on a slow timer** (every few minutes). Rejected for the same reason at a smaller scale: it buys a cheaper check by accepting a window in which orders are silently dropped. One read per tick costs ~0.07 req/s. -- **Skip `api.at` entirely** — `rpc.state.getStorage.raw(eventsKey, hash)` decoded against `api.registry`. That is two RPCs per block with no per-tick read at all, but it decodes against the connect-time registry with no way to notice an upgrade, and it hand-rolls event decoding. Worse on the axis that matters to be cheaper on the one that does not. - -Why comparing two readings is sound: `specVersion` only increases, and the version is read *after* the head, so two equal readings mean no upgrade landed between them and therefore none in the range about to be scanned. A reading that differs means one did, and that tick falls back to per-block resolution, which is exact. The gap left is a backlog reaching back past an upgrade — recovering from an outage that long means those bid windows closed many upgrades ago. - -The per-tick read is skipped when there is nothing to scan, so a quiet tick still costs exactly one request. - -## 2026-08-25 — Intent quotes default to directional indexed rates without fallback - -Chosen: `quoteIntent` defaults to an `indexed_rates` strategy that selects the depth-weighted aggregate `LiquidityPool.buyRate` for base-to-quote orders and `sellRate` for quote-to-base orders. Source and destination chains resolve the configured token deployments; raw amounts are calculated from the indexer's 18-decimal whole-token pool rate and both tokens' configured decimals. A missing directional rate is an error. - -Alternatives considered: - -- **Keep defaulting to the legacy directional Phantom snapshot.** Rejected: those snapshots resolve through a canonical Base market and do not use the pair-centric pool rate, so quotes can disagree with the indexer's current market. -- **Quote directly from one source/destination pair of `PoolChainLiquidity` rows.** Rejected: those rows are inputs to the indexer's pool price. `LiquidityPool.buyRate` and `sellRate` are the maintained depth-weighted merge of fresh chain samples and are the intended market-level quote. -- **Silently fall back to Phantom or Uniswap when a rate is absent.** Rejected: an order would be priced from a different market than the caller requested, hiding stale or incomplete indexer coverage and producing another unfillable quote. -- **Remove the old strategies immediately.** Rejected for compatibility: callers that explicitly select them can continue doing so, while all calls without a strategy use the corrected path. - -The result uses a new `indexed_rates` discriminant and includes the selected rate side, value, timestamp, pair symbols, chains, and protocol fee. This makes the price used to construct the order inspectable without exposing indexer internals. - -The public sell rate is the reciprocal of the indexer's base-per-quote direction. That reciprocal rounds up at 18 decimals: rounding down would let a quote-token-to-base-token order request slightly more base output than the indexed direction supports. Buy rates are already indexer-floored outputs and remain unchanged. - -## 2026-08-27 — Every phantom quote is haircut; the pool tier drops to 10bps and wallet quotes pay 5bps - -Chosen: `UNISWAP_QUOTE_HAIRCUT_BPS` becomes 10bps and a new `PHANTOM_QUOTE_HAIRCUT_BPS` of 5bps is charged to every bid that does **not** declare Uniswap V4 positions. A bid pays one haircut or the other, never both — `poolPriced` selects which function runs, and the structure from 2026-08-24 (keyed off the signed declaration, applied to the quote before the zero-check and the median) is unchanged. - -Alternatives considered: - -- **Stacking the 5bps on top of the pool haircut.** Rejected: the two are the same kind of adjustment — margin between what a solver names and what the protocol is willing to publish — and the pool tier is already the larger of the two. Compounding them would price a pool-funded bid at 15bps for reasons no single rationale explains, and would make the shipped number harder to reconcile against the fee tier it is meant to track. -- **Leaving wallet-funded quotes unhaircut.** Rejected: a wallet quote is still the most optimistic number a solver names at bid time, and the median it feeds is published to takers as a rate they can trade against. A small uniform margin is what keeps the published rate on the executable side of the bid. -- **Applying the base haircut after the median instead of per quote.** Rejected for the same reason as before: it is now a haircut every bid pays, so applying it to the aggregate would be arithmetically similar but would split one rule across two places in the code. - -The 5bps also applies to a bid whose declaration is absent or unparseable, which is the correct reading — no declaration is no claim to be pricing off a pool. - -## 2026-08-24 — The 30bps pool haircut keys off the declaration, and lands on price only - -Chosen: in `aggregatePhantomBids`, haircut a bid's quoted leg amounts by 30bps when its paymasterAndData declaration names Uniswap V4 positions. - -Alternatives considered: - -- **Keying off the positions that pass the ownership check** (the `positions` array, not `declaration.uniswapV4Positions`). Rejected: those are filtered by owner and skipped entirely when the chain has no `positionManager`/`stateView` configured, so the same bid would be priced two different ways depending on indexer config. The declaration is what the solver signed, and it is what says "this quote came off a pool". -- **Haircutting the weight instead of the price.** Rejected: the weight is deliverable inventory, read on-chain, and it is also what the liquidity sweep reports — discounting it would make a provider's reported inventory and the depth attributed to it disagree, which the sweep exists to prevent. The fee is a cost of the trade, not a reduction in the size held. -- **Applying it after the median, to the leg's published price.** Rejected: the median is liquidity-weighted across bids, so haircutting the aggregate would also discount wallet-funded quotes that never pay a pool fee, and it would change which quote wins the median only by accident. The haircut belongs on the individual quote, before it competes. -- **Making the rate configurable per chain or pool.** Deferred: the positions these bids declare sit in 30bps-tier pools, and a knob invites the number to drift out of sync between the indexer and simplex. A single exported constant is easy to widen into a lookup if a different tier ever shows up. - -Why 30bps at all: without it, a pool-priced bid reads richer than a wallet-funded one on a fee it has not yet paid, so it wins the median and the published price is one nobody can actually execute at. - -A consequence worth knowing: a leg amount small enough that the haircut rounds it to zero now takes the "solver declined this leg" path. That is the correct reading — a quote that rounds away is not a price — and it only bites at dust amounts. - -## 2026-08-21 — The coprocessor's HTTP provider runs with its response cache off - -Chosen: `new HttpProvider(httpUrl, {}, 0)` — capacity 0 disables polkadot-js's per-provider LRU outright, so every `send` reaches the node. - -Alternatives considered: - -- A shorter TTL, or a poll interval longer than the TTL. The TTL slides — each hit refreshes it — so any poll faster than the TTL keeps a cached rejection alive indefinitely, and a slower poll would heal after one TTL only by coupling the cadence to a cache constant; the poll was made faster on purpose (#1138). -- A provider wrapper, or an upstream fix, that drops an entry when its promise rejects. Correct, but more code holding state this api has no use for, and an upstream fix still needs the workaround until it ships: polkadot-js master has the same `send` as 16.5.6. -- Keeping the cache and retrying inside the poll tick. A retry hits the same cache key and gets the same rejection; only a different request shape would get past it. - -Why: the HTTP api exists for one-shot reads whose whole value is that nothing persists between calls (see the `http()` and `pollPhantomOrders` doc comments). A promise cache is exactly such persistence, and its only effect on this api was the failure mode: the poll reads each block once, so there are no repeat hits to serve, and `api.at(hash)` already reuses decoded registries at the api layer. Disabling it removes the hazard without adding a mechanism. - -## 2026-08-18 — `SigningAccount` describes only what the SDK calls - -(Amended the same day: `signRawHash` was removed in a follow-up commit; see the closing paragraph.) - -Chosen: drop `signMessage` from `SigningAccount`, leaving `signRawHash` and `signTypedData`. - -Alternative considered: leaving it in place, since removing a member from a published interface is a public API change. - -Why: the interface exists so a caller can hand the SDK a signing backend, and every member it declares is a cost paid by every implementer. `signMessage` was never invoked — bid UserOperations are EIP-712-signed through `signTypedData` — so the cost bought nothing, and it forced downstream signer abstractions (notably `@hyperbridge/simplex`'s `Signer`) to carry a dead method into their own public surface. The narrowing is safe in the direction that matters: implementers with an extra method still satisfy the type, and only a caller of `solverSigner.signMessage` would break, of which there are none. - -`signTypedData`'s `chainId` parameter went for a sharper reason than disuse: it is redundant with EIP-712 itself. The digest covers `domain.chainId`, `BidManager` built payloads that always set it, and the one downstream implementation that needed a chain id (MPCVault, for its request envelope) could read it from the payload — and defaulted the argument to `1` when absent, which is a bad failure mode for a signature. - -`signRawHash` went the same day, in the follow-up that made `signAuthorization` and `signTransaction` required members of simplex's `Signer`: once those are guaranteed, nothing in either package computes an authorization digest for the signer to raw-sign, and keeping the member would have recreated the `signMessage` situation. `SigningAccount` is down to the one method this package invokes — `signTypedData`. diff --git a/sdk/packages/sdk/docs/ai/Flow.md b/sdk/packages/sdk/docs/ai/Flow.md deleted file mode 100644 index 330354062..000000000 --- a/sdk/packages/sdk/docs/ai/Flow.md +++ /dev/null @@ -1,62 +0,0 @@ -# Flow - -AI-maintained map of how code paths in `sdk/packages/sdk` actually execute, so that when something breaks you can tell whether the fault is upstream or downstream of where the symptom appears. Only flows that have been read and verified are documented; coverage grows as areas of the package are touched. - -## How a solver's bid gets signed - -`SubmitBidOptions.solverSigner` is a `SigningAccount` (`src/types/index.ts`), supplied by the caller. `@hyperbridge/simplex` does not pass its `Signer` directly: the payload parameter types differ (`unknown` here, `TypedDataPayload` there), so its `ContractInteractionService` adapts at the call site with `sdkSigningAccount(signer)`. - -1. The caller assembles the bid and calls `prepareSubmitBid` (`src/protocols/intents/BidManager.ts`), passing the solver account, nonce, entry point, gas limits, pre-built ERC-7821 `callData`, and any `paymasterAndData`. -2. `BidManager` builds the v0.7-packed `PackedUserOperation` with an empty signature, then checks the nonce key binds the order commitment and session key (`CryptoUtils.bidNonceKey`) — a mismatch is warned about, not thrown, and fails on-chain validation later. -3. It signs `CryptoUtils.packedUserOpTypedData(userOp, entryPointAddress, chainId)` with **`solverSigner.signTypedData`** — the only `SigningAccount` member this package calls, and it passes the payload alone: the chain id the backend might need is already in `domain.chainId`. Signing the typed data rather than the digest produces the same signature `SolverAccount._rawSignatureValidation` recovers, while leaving the payload legible to a custody backend's policy engine. -4. The returned signature is prefixed with the order id (`concat([order.id, solverSignature])`) — that concatenation, not the bare signature, is what goes on the UserOperation. - -`signTypedData` is the interface's only member: `signMessage`, `signRawHash`, and `signTypedData`'s chain-id argument were all removed on 2026-08-18 as uncalled. `GasEstimator`'s `signMessage` call is viem's method on a locally derived account, unrelated to `SigningAccount`. - -## How the phantom order poll reads Hyperbridge - -`IntentsCoprocessor.pollPhantomOrders` (`src/chains/intentsCoprocessor.ts`) drives every read over the HTTP api from `http()`, never the websocket. `http()` derives the endpoint from the websocket provider's own endpoint (`deriveHttpUrl`) and builds an `ApiPromise` on a `BatchingHttpProvider` — response cache off (capacity 0), concurrent calls coalesced into one JSON-RPC batch request, and one token taken from the endpoint's `TokenBucket` per request. The connect is `isReadyOrError` raced against `HTTP_CONNECT_TIMEOUT_MS`, and a failed connect is not cached. - -Each tick, skipped if the previous one is still running or if a 429 backoff is still counting down: - -1. `chain_getHeader()` for the head. No block hash, so polkadot-js never caches it — always a live request. -2. On the first successful head read the cursor is set to `head - 1`, so the head itself is the first block scanned and nothing before it is reached back for. On every later tick, if the cursor is more than `bidWindowBlocks + max(intervalBlocks, bidWindowBlocks)` behind the head it is forced to `head - 1 - bidWindowBlocks` and `onSkip` fires with the range dropped: that backlog holds no order that can still be bid on, and the cursor gains only `maxBlocksPerPoll` a tick, so it would otherwise never return to live. Jumping to one window behind rather than to the head keeps every order that is still biddable, and lands the cursor inside the threshold so the next tick scans instead of skipping again. The timings come from `phantomTimings()` — `PhantomBidWindow` storage, or the `PhantomOrderBidWindowBlocks` runtime constant when that is zero, plus `PhantomOrderInterval` — read once per instance and cached. Then, if `head <= cursor`, the tick ends before step 3, so a quiet tick costs exactly one request. -3. `confirmedRuntimeVersion()` reads `state_getRuntimeVersion()` and returns it only if it matches the previous tick's reading (or, on the first tick, `api.runtimeVersion` from connect). A mismatch means an upgrade landed inside this tick's range and yields `undefined`. -4. `scanRangeAtOnce` takes the range `cursor + 1` … `min(head, cursor + maxBlocksPerPoll)` (default 10) in one call, when it can: it needs a confirmed version from step 3, that version to still equal `api.runtimeVersion` (the ranged reply is decoded against the connect-time registry — `state_queryStorage` declares no `isHistoric` parameter, so rpc-core does no registry swap for it), and the node not to have refused the method before. It reads the range's two bounding hashes — one batched request — then `getPhantomOrdersInRange` issues `state_queryStorage.raw([SYSTEM_EVENTS_KEY], fromHash, toHash)` and decodes each change set's value with `registry.createType("Vec", …)`. The key is computed — `twox_128("System") ++ twox_128("Events")` — rather than taken from the decorated api, and `.raw` is used so polkadot-js does no formatting: its formatted path decodes from metadata hanging off whichever accessor it was handed, and the three that look interchangeable are not (see Decisions, 2026-08-27). The cursor moves the whole way, because one call either answered for the range or threw. -5. Otherwise the poll reads block by block: `chain_getBlockHash(n)` for every block concurrently, which the provider sends as a single batched request, then `getPhantomOrdersAtHash(hash, knownVersion)` → `api.at(hash, knownVersion)` → `system.events`, one at a time and in order. The hashes parallelise safely because `chain_getBlockHash` declares no `isHistoric` parameter; **the events reads must not**, because `at(hash, version)` sets `lastBlockHash` on the shared registry and the events read then finds it by that alone, so concurrent calls would have all but one miss and re-resolve at two RPCs a block. See Decisions, 2026-08-27. Here the cursor advances per block, so a failure partway re-scans only what was never read. -6. A failure leaves the cursor where it is and fires `onError`; the next tick re-reads. A 429 additionally sets a backoff of 1, 2, 4… ticks (capped at 8), cleared by the next tick that completes. - -Two things about the ranged reply are worth knowing. It is a **diff**: `query_storage_unfiltered` in `sc-rpc` emits a change set for a block only when the value differs from the block before it, and drops an empty set, so consecutive blocks with identical events — a quiet chain, where every block carries just the timestamp inherent — collapse into one entry. That is safe only because a phantom commitment is derived from the block number, so a block that registered orders can never encode like another one; absent therefore means no orders. And the method is gated by `--rpc-methods`, refused as `Method not found`; the node here already runs unsafe RPC to serve `offchain_localStorageGet`, and a refusal switches the poll to the per-block path permanently without being reported as an error. A reply that does not decode to event records does the same, except that it is reported once — the fast path is an optimisation over a path that already works, so no way of failing it should stop the poll. - -So a tick costs **4 HTTP requests regardless of the range** — head, version, the two bounding hashes batched together, and the ranged read — against `3 + n` before the ranged read landed and `1 + 4n` originally. The per-block fallback is `3 + n`: a 10-block catch-up is 13 requests there and 4 on the ranged path. - -Step 6 is what made the provider cache dangerous: with caching on, re-reading a block whose `state_getRuntimeVersion` had rejected was answered by the cached rejection rather than a fresh request (fixed 2026-08-21). In `@hyperbridge/simplex` one `HyperbridgeScanner` owns this poll and fans `onError` out to every subscribing filler, so a single failed tick logs once from the scanner and once per filler. - -The cadence is `intervalMs` when given, otherwise `phantomPollIntervalMs()`: 6s on Gargantua, 15s elsewhere, decided by the runtime's `specName` read over the same HTTP api, falling back to 15s if that read fails. - -The skip in step 2 bounds the property this poll was built for. The cursor still advances only past blocks whose events were really read, so an outage delays orders instead of dropping them — but only while they could still be bid on. Past that, catching up is spending requests to deliver orders the pallet will refuse a bid for (`PhantomOrderBidWindowClosed`), and the lag is self-sustaining: it was a spell of rate limiting that put one mainnet filler 3,448 blocks behind, where it stayed for nine hours, bidding the whole time. - -## How requests to a Hyperbridge node are paced - -`http()` calls `limiterFor(httpUrl)`, which returns the `TokenBucket` (`src/utils/rateLimiter.ts`) for that URL's origin from a module-level map, creating it at `HYPERBRIDGE_RPC_MAX_RPS` or 8 req/s. Every coprocessor in the process pointed at the same node therefore shares one bucket, because the limit being respected counts requests per address, not per connection. - -`BatchingHttpProvider.send` queues the call rather than sending it, and flushes on the next macrotask — so everything issued in one burst travels as a single JSON-RPC batch request, up to 32 calls. One flush is in flight at a time; whatever arrives meanwhile rides the next. Each flush takes exactly one token from the bucket before its HTTP request, because a request is what the endpoint counts. A lone call is sent as a plain request object rather than a one-element array. - -The bucket refills continuously and holds at most one second's worth, so a burst of requests up to that size goes straight out and the rest are granted at the configured rate. Waiters are strictly FIFO: a caller arriving on an idle bucket queues behind anyone already waiting rather than taking their token. - -Two server refusals are handled without a call being lost. `-32005` (`--rpc-disable-batch-requests`) disables batching for the provider's life and retries the calls one per request; `-32010` (`--rpc-max-batch-request-len`) halves the batch size and retries. Both come back as a single error object where an array was expected, which is how they are told apart from a per-call error. - -Everything reaching the node over HTTP passes through it — the poll's ranged or per-block scan, `fetchPhantomOrder` (fanned out one per configured chain by simplex's `handlePhantomOrders`), `queryApi()` consumers such as simplex's `BalanceProvider`, and `sendViaHttp` when the websocket is down at signing time. That is the point: the limit applies to their sum, and this is the only place their sum exists. It also means a long queue delays them all, which is why `maxBlocksPerPoll` is small. - -## How a phantom order's bids become one price per leg - -`aggregatePhantomBids` (`src/protocols/intents/phantom-aggregation.ts`) is the whole path, run by the indexer (`handlePhantomOrderPrices.handler.ts`) and by simplex's phantom E2E test against the same code. - -1. Resolve the destination chain's RPC, EVM chain id, and `SolverAccount`. Any of the three missing means no snapshot at all — an unverified quote must never reach the price — and `fetchBidsForOrder` then pulls the bid set from the Hyperbridge node. -2. Per bid: SCALE-decode the UserOperation, pull the inner `fillOrder` out of its ERC-7821 batch (`extractFill`), and reject it unless the order in that calldata commits to the order being priced. The solver's signature is checked over the EntryPoint userOpHash (`isVerifiedSolverBid`), and a solver already counted for this order is skipped, so one bid copied under N fillers still counts once. -3. Decode the paymasterAndData declaration — accepted source chains and declared V4 position tokenIds — which the userOpHash covers, so it is as authentic as the quote. `decodePhantomBidPaymasterAndData` reads it in either shape on the wire: the bare declaration blob, or a bid built on simplex's real-bid path, which opens with the 234-byte EntryPoint v0.8 paymasterAndData for the Simplex paymaster's PERMIT2 mode (`PERMIT2_SPONSORSHIP_BYTES`) and carries the declaration after the permit. A sponsored bid with nothing appended is counted as having declared nothing (null sources, no positions). The chain ids inside a declaration are UTF-8 decoded by hand (`utf8Decode`), never through `TextDecoder`: the indexer runs this step inside SubQuery's vm2 sandbox, where that API is absent and its `util` fallback rejects sandbox-created bytes, so a decoder that reached for it threw on every bid naming a source chain. -4. **Price adjustment**: every leg amount is haircut before it competes — by `UNISWAP_QUOTE_HAIRCUT_BPS` (10bps) when the declaration names positions, otherwise by the protocol fee haircut: `protocolFeeBps` read from `IntentGateway.params()` on the order's chain once per run (`readProtocolFeeHaircutBps`, currently 5bps on every deployment). One or the other, never both. A failed or implausible fee read throws `PhantomRpcError` and the run is retried. This happens before the `solverAmount !== 0n` filter, so a quote the haircut rounds away is read as a declined leg. -5. Weight each quoted leg by the solver's deliverable inventory in *that leg's* output token on the destination chain: ERC-20 balance + redeemable vault shares (`getBalance`) plus the withdrawable side of any declared position the solver actually owns on-chain (`readPosition`, filtered by owner). Then sweep the solver's whole inventory into `lpBalances` once per bid. -6. Per leg: drop every zero-weight quote — from the median, from `bidCount`, and from `bidders` alike — and drop the leg entirely if none is left. Otherwise `weightedMedian` picks the price, and `lowestPrice`/`highestPrice` are set to that same median rather than the raw bid extremes. - -A malformed bid is skipped and the rest are priced; a `PhantomRpcError` aborts the whole run instead, because a partial bid set publishes a confident price built from whichever bids happened to be readable. diff --git a/sdk/packages/sdk/docs/ai/README.md b/sdk/packages/sdk/docs/ai/README.md new file mode 100644 index 000000000..b0afb8c06 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/README.md @@ -0,0 +1,22 @@ +# AI workflow docs — `sdk/packages/sdk` + +AI-maintained notes on this package. One file per entry: two concurrent PRs add +different files, so they never conflict. Never collect entries back into a +shared file. + +- `changelog/` — one file per AI-assisted code change, named + `YYYY-MM-DD-short-title.md`. Say what changed and why in a few sentences, then + a `Files:` line listing the files touched. +- `decisions/` — one file per non-obvious choice, named the same way. Give the + decision, the alternatives considered, and why they lost. Read these before + changing related code, so a later change does not silently undo a deliberate + trade-off. +- `flows/` — one file per code path, named for the flow itself and with no date, + because these are edited in place as the code changes. Only document a flow + you have read and verified. + +Each file opens with an `#` heading. Changelog and decision names sort +chronologically, so `ls changelog/` reads oldest first and `ls -r` newest first. + +This is not the release changelog — that is `CHANGELOG.md` at the package +root, generated by changesets. diff --git a/sdk/packages/sdk/docs/ai/changelog/2026-08-18-signingaccount-shrinks-to-signtypeddata-alone.md b/sdk/packages/sdk/docs/ai/changelog/2026-08-18-signingaccount-shrinks-to-signtypeddata-alone.md new file mode 100644 index 000000000..8245dd1ba --- /dev/null +++ b/sdk/packages/sdk/docs/ai/changelog/2026-08-18-signingaccount-shrinks-to-signtypeddata-alone.md @@ -0,0 +1,11 @@ +# 2026-08-18 — `SigningAccount` shrinks to `signTypedData` alone + +`SigningAccount` is the contract a solver's signing backend satisfies to submit bids (`SubmitBidOptions.solverSigner`). It declared `signMessage(messageHash, chainId)`, which nothing in this package ever called: bids are signed as EIP-712 UserOperations in `BidManager.prepareSubmitBid` via `signTypedData`, and `GasEstimator`'s one `signMessage` call is viem's own method on a locally derived account, not this interface. The requirement propagated out to every implementer — including `@hyperbridge/simplex`, whose public `Signer` satisfied this type (it no longer extends it; simplex adapts with `sdkSigningAccount` at the two call sites) — so removing it here is what let that interface shrink to what a solver actually needs. + +`signTypedData`'s second parameter went the same way. EIP-712 carries the chain id in `domain.chainId` — that is what the digest covers — and `BidManager` was passing it alongside a payload that already contained it, for the benefit of no implementation in this package. + +`signRawHash` followed in the same day's follow-up: once simplex's `Signer` required `signAuthorization` and `signTransaction`, no caller in either package handed a raw digest to the interface, leaving `signTypedData` as the whole contract. + +Type-only narrowing: it can break a caller of a removed member, and there are none inside the workspace. + +Files: `src/types/index.ts`, `src/protocols/intents/BidManager.ts`. diff --git a/sdk/packages/sdk/docs/ai/changelog/2026-08-19-fix-the-refund-post-gas-pin-1144-left-behind.md b/sdk/packages/sdk/docs/ai/changelog/2026-08-19-fix-the-refund-post-gas-pin-1144-left-behind.md new file mode 100644 index 000000000..46131ddf0 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/changelog/2026-08-19-fix-the-refund-post-gas-pin-1144-left-behind.md @@ -0,0 +1,5 @@ +# 2026-08-19 — Fix the refund-POST gas pin #1144 left behind + +#1144 split `CANCEL_MESSAGE_GAS = 800_000n` into `SOURCE_GET_RESPONSE_GAS` and `REFUND_POST_GAS`, both 1M, but left `orderCanceller.test.ts` asserting the POST at 800k — main's own CI has failed the concurrent-sdk step since it merged, and every PR cut from it inherited the red check. The pin now matches the shipped constant, with a comment naming the origin so the next reprice updates both. + +Files: `src/tests/orderCanceller.test.ts`. diff --git a/sdk/packages/sdk/docs/ai/changelog/2026-08-21-http-provider-no-longer-caches-responses-so-a-failed-phantom.md b/sdk/packages/sdk/docs/ai/changelog/2026-08-21-http-provider-no-longer-caches-responses-so-a-failed-phantom.md new file mode 100644 index 000000000..1dc6ff53f --- /dev/null +++ b/sdk/packages/sdk/docs/ai/changelog/2026-08-21-http-provider-no-longer-caches-responses-so-a-failed-phantom.md @@ -0,0 +1,7 @@ +# 2026-08-21 — HTTP provider no longer caches responses, so a failed phantom poll read is retried + +`IntentsCoprocessor.http()` built its `HttpProvider` with polkadot-js defaults, which cache every request that names a block hash by storing the request promise itself — rejected promises included — under a 30s TTL refreshed on every hit. `pollPhantomOrders` retries the block it failed on with identical parameters each tick, so after one reset connection (`fetch failed` / `ECONNRESET`) every later tick got the same rejection back from the cache in a few milliseconds — the same `state_getRuntimeVersion(parentHash)` hash in every log line — and the node never saw another request. With the 15s poll interval inside the 30s TTL the entries never expired, so a filler stayed wedged until restart, placing no phantom bids. The provider is now constructed with cache capacity 0, which sends every request to the node. The cache bought nothing for this api: the poll reads each block once, and `api.at(hash)` reuses registries at the api layer regardless. + +`intentsCoprocessorHttpCache.test.ts` pins the dependency's behaviour against a stub node that RSTs one request, and that the provider the coprocessor builds retries it. + +Files: `src/chains/intentsCoprocessor.ts`, `src/tests/intentsCoprocessorHttpCache.test.ts`. diff --git a/sdk/packages/sdk/docs/ai/changelog/2026-08-24-pool-priced-phantom-bids-are-haircut-30bps-before-aggregation.md b/sdk/packages/sdk/docs/ai/changelog/2026-08-24-pool-priced-phantom-bids-are-haircut-30bps-before-aggregation.md new file mode 100644 index 000000000..e8cbe6a90 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/changelog/2026-08-24-pool-priced-phantom-bids-are-haircut-30bps-before-aggregation.md @@ -0,0 +1,9 @@ +# 2026-08-24 — Pool-priced phantom bids are haircut 30bps before aggregation + +A phantom bid that declares Uniswap V4 positions is quoting off those pools, and a pool price is what a trade gets before the pool takes its fee — so the amount such a bid names is richer than what the solver clears once the sourcing swap goes through. `aggregatePhantomBids` now nets 30bps out of every leg amount on a bid whose declaration carries positions, before the quote reaches the zero-check, the weighted median, or the bidder rows. Non-declaring bids are untouched: wallet inventory has already paid its cost of goods. + +The haircut runs off the declaration rather than the positions that survive the on-chain ownership check, so a quote is priced on the same basis the solver priced it on — including on a chain with no V4 contracts configured, where declared positions contribute no weight. A leg whose amount the haircut rounds to zero falls into the existing declined-leg path. + +`UNISWAP_QUOTE_HAIRCUT_BPS` and `applyUniswapQuoteHaircut` are exported from both `@/protocols/intents` and the `intents-helpers` sub-path, so the indexer and simplex read the same number instead of restating it. + +Files: `src/protocols/intents/phantom-aggregation.ts`, `src/protocols/intents/index.ts`, `src/intents-helpers.ts`, `src/tests/phantomAggregation.test.ts`. diff --git a/sdk/packages/sdk/docs/ai/changelog/2026-08-25-intent-quotes-use-aggregate-indexed-pool-rates-by-default.md b/sdk/packages/sdk/docs/ai/changelog/2026-08-25-intent-quotes-use-aggregate-indexed-pool-rates-by-default.md new file mode 100644 index 000000000..dacf519db --- /dev/null +++ b/sdk/packages/sdk/docs/ai/changelog/2026-08-25-intent-quotes-use-aggregate-indexed-pool-rates-by-default.md @@ -0,0 +1,5 @@ +# 2026-08-25 — Intent quotes use aggregate indexed pool rates by default + +`IntentGateway.quoteIntent` now prices orders from the pair-centric indexer's depth-weighted aggregate `LiquidityPool.buyRate` and `sellRate`. Source and destination chains resolve the configured token deployments, while the quote converts the pool's whole-token rate into raw amounts with configured decimals, applies the source gateway protocol fee, and exposes the selected rate and timestamp in metadata. Reverse sell-rate reciprocals round up so quotes do not overpromise output. Phantom snapshot and Uniswap V4 pricing remain explicit compatibility strategies. Live sequential tests cover exact-input USDC to cNGN and exact-output cNGN to USDC across BSC and Base, including their different token decimal scales. The dead `binance.llamarpc.com` BSC default was replaced with `bsc-rpc.publicnode.com` after it blocked those live checks. + +Files: `src/configs/chain.ts`, `src/protocols/intents/IntentGateway.ts`, `src/protocols/intents/LiquidityEngine.ts`, `src/protocols/intents/index.ts`, `src/protocols/intents/quote/index.ts`, `src/protocols/intents/quote/indexedRates.ts`, `src/protocols/intents/quote/types.ts`, `src/tests/sequential/intentGateway.test.ts`, `package.json`, `CHANGELOG.md`, `docs/ai/ChangeLog.md`, `docs/ai/Decisions.md`, `../../../docs/content/developers/sdk/api/intent-gateway.mdx`, `../../../docs/content/developers/evm/intent-gateway/placing-orders.mdx`. diff --git a/sdk/packages/sdk/docs/ai/changelog/2026-08-27-a-block-scan-is-one-state-querystorage-call-and-the-poll-caps.md b/sdk/packages/sdk/docs/ai/changelog/2026-08-27-a-block-scan-is-one-state-querystorage-call-and-the-poll-caps.md new file mode 100644 index 000000000..03689b03e --- /dev/null +++ b/sdk/packages/sdk/docs/ai/changelog/2026-08-27-a-block-scan-is-one-state-querystorage-call-and-the-poll-caps.md @@ -0,0 +1,11 @@ +# 2026-08-27 — A block scan is one `state_queryStorage` call, and the poll caps at 10 blocks + +The previous entry left the scan at one request per block for events. `state_queryStorage(keys, from, to)` reads a key across a whole block range in one call, so the scan's request cost no longer depends on how many blocks it covers: a tick is now four requests flat — head, runtime version, the range's two bounding block hashes as one batched request, and the ranged read. `maxBlocksPerPoll` drops from 20 to 10 at the same time, because the cost moved rather than vanished (`sc-rpc` warns the method is `O(|keys| * dist(from, to))` in time and memory) and because it still bounds the per-block fallback. + +`getPhantomOrdersInRange` is the new read; the event decoding it shares with `getPhantomOrdersAtHash` moved into a `phantomOrdersFrom` helper. polkadot-js formats a `Vec` reply into `[blockHash, valuesPerKey]` pairs with each value already typed from the storage key's metadata, so nothing is decoded by hand. + +Two properties of the RPC drive the rest of the change. It answers with **diffs** — `query_storage_unfiltered` drops a block's change set when the value matches the previous block's — so a quiet chain's consecutive blocks come back as one entry. That is safe because a `PhantomOrderRegistered` commitment is derived from the block number, so a block that registered orders can never encode identically to another; absent provably means no orders. And it is **gated by `--rpc-methods`**, answered as `Method not found` when denied. The node here already runs unsafe RPC to serve `offchain_localStorageGet`, so it is normally available; a refusal switches the poll to the per-block path permanently and is handled rather than reported. + +`scanRangeAtOnce` also declines the ranged read when the tick has no confirmed runtime version, or when that version is no longer the one the api's registry was built for — `state_queryStorage` declares no historic block hash, so rpc-core decodes its reply against the connect-time registry, which an upgrade leaves stale. After an upgrade the poll stays on the per-block path, which resolves the right registry, until the process restarts. + +Files: `src/chains/intentsCoprocessor.ts`, `src/tests/pollPhantomOrders.test.ts`, `docs/ai/ChangeLog.md`, `docs/ai/Decisions.md`, `docs/ai/Flow.md`. diff --git a/sdk/packages/sdk/docs/ai/changelog/2026-08-27-concurrent-hyperbridge-calls-travel-as-one-json-rpc-batch.md b/sdk/packages/sdk/docs/ai/changelog/2026-08-27-concurrent-hyperbridge-calls-travel-as-one-json-rpc-batch.md new file mode 100644 index 000000000..9c7eaba03 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/changelog/2026-08-27-concurrent-hyperbridge-calls-travel-as-one-json-rpc-batch.md @@ -0,0 +1,11 @@ +# 2026-08-27 — Concurrent Hyperbridge calls travel as one JSON-RPC batch request + +Follow-up to the pacing change below. The endpoint's limit is counted in HTTP requests, and the reads here arrive as bursts of concurrent calls, so JSON-RPC 2.0 batching turns a burst into one request. Substrate supports it: `sc-rpc-server` sets `batch_request_config` on jsonrpsee, and `sc-cli` resolves `BatchRequestConfig::Unlimited` unless the operator passes `--rpc-disable-batch-requests` or `--rpc-max-batch-request-len`. polkadot-js does not — `HttpProvider.send` encodes exactly one call per POST and the package exports no batch API — so `http()` now builds a `BatchingHttpProvider` (`src/utils/batchingHttpProvider.ts`) instead of the `RateLimitedHttpProvider` it replaces. + +Calls queued within one macrotask go out together, capped at 32 per request. The token bucket is charged once per HTTP request rather than once per call, which is the whole point. A lone call is sent as a plain request object, not a one-element array, so the common case is byte-for-byte what the base provider sent and needs no batch support at all. Flushes are serialised — one request in flight at a time — because overlapping flushes interleave against a `maxBatchSize` a refusal can shrink underneath them, which fragmented a burst into more requests than it needed. Both refusals are handled without losing a call: `-32005` disables batching for the provider's life and retries the calls singly, `-32010` halves the batch size and retries. + +Batching only helps callers that have more than one call in flight, so the block scan now fetches a range's block hashes as one concurrent wave before reading events. `chain_getBlockHash` is the half of the pair that parallelises safely — it takes no historic block hash, so concurrent calls never trigger polkadot-js's per-hash registry resolution and cannot race each other's registry state. The events reads stay sequential for exactly that reason; see Decisions for why. `getPhantomOrdersAtHash` was split out of `getPhantomOrdersInBlock` to make that possible. + +A tick now costs `3 + n` HTTP requests for n blocks rather than `2 + 2n`: a 20-block catch-up is 23 requests where the previous entry left it at 42 and it began at 81. A single-block tick is unchanged at 4 — the hashes there are one call, batched or not. The fan-out on a phantom order interval is where it bites hardest: up to 16 concurrent `offchain_localStorageGet` reads become one request. + +Files: `src/chains/intentsCoprocessor.ts`, `src/utils/batchingHttpProvider.ts`, `src/tests/batchingHttpProvider.test.ts`, `src/tests/intentsCoprocessorRateLimit.test.ts`, `src/tests/pollPhantomOrders.test.ts`, `docs/ai/ChangeLog.md`, `docs/ai/Decisions.md`, `docs/ai/Flow.md`. diff --git a/sdk/packages/sdk/docs/ai/changelog/2026-08-27-filloptions-carries-a-validuntil-and-fillorder-has-two-shapes.md b/sdk/packages/sdk/docs/ai/changelog/2026-08-27-filloptions-carries-a-validuntil-and-fillorder-has-two-shapes.md new file mode 100644 index 000000000..48a0798f4 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/changelog/2026-08-27-filloptions-carries-a-validuntil-and-fillorder-has-two-shapes.md @@ -0,0 +1,34 @@ +# 2026-08-27 — `FillOptions` carries a `validUntil`, and `fillOrder` has two shapes + +`FillOptions` gained `validUntil` (a block number; `0n` means unbounded), enforced by `fillOrder`, which now reverts +`FillExpired` past it. Adding a field changes the enclosing function's selector, so `fillOrder` exists in two +incompatible shapes — `0x5cfb1ea5` (v1) and `0xa5470064` (v2) — and gateways upgrade per chain, so both are on the +wire at once. + +New `protocols/intents/fillOrderCodec.ts` owns that: `getFillOptionsVersion` reads the gateway's ERC-1967 +implementation slot and matches the address against a set of known pre-`validUntil` implementations, defaulting to +v2; `encodeFillOrder` emits the matching shape and `decodeFillOrder` reads either. There is no version getter on +the contract — EIP-1967 has no version field either, and the implementation address is the value the proxy already +updates on upgrade. `GasEstimator` encodes through it so estimates do not revert on a missing function; `BidManager` and +`phantom-aggregation.extractFillData` decode through it so bids built against an older gateway are still priced in +rather than dropped. + +Why the field exists: a solver bidding through the coprocessor signs this calldata and then has no further say in +when it is used. The order's `deadline` is placer-chosen with no ceiling, retracting the bid on Hyperbridge does not +reach the destination chain, and the placer holds the session key — so a signed bid stayed executable indefinitely +and was taken up only once the rate had moved against the solver. `validUntil` rides in the calldata, which +`userOpHash` already covers, so it is tamper-proof without touching the signature format. + +`fillOrder` also no longer calls `IIntentPriceOracle.recordSpread`. It passed `order.inputs` and `options.outputs` +to a stateful oracle verbatim, and neither is validated against anything that costs the caller money — the escrow +lives on the source chain and is never consulted on the destination side. `Params.priceOracle` is left in place +because removing a field from a storage struct behind an upgradeable proxy shifts the layout. + +Found by the scheduled IntentGateway/Simplex security audit. + +Files: `src/protocols/intents/fillOrderCodec.ts` (new), `src/protocols/intents/GasEstimator.ts`, +`src/protocols/intents/BidManager.ts`, `src/protocols/intents/phantom-aggregation.ts`, +`src/protocols/intents/index.ts`, `src/abis/IntentGatewayV2.ts`, `src/types/index.ts`, +`src/tests/fillOrderCodec.test.ts` (new), `src/tests/phantomAggregation.test.ts`, plus +`evm/src/apps/IntentGatewayV2.sol`, `evm/src/apps/intentsv2/IntentsBase.sol` and +`sdk/packages/core/contracts/apps/IntentGatewayV2.sol`. diff --git a/sdk/packages/sdk/docs/ai/changelog/2026-08-27-fix-the-ranged-block-scan-names-the-events-key-and-its-type.md b/sdk/packages/sdk/docs/ai/changelog/2026-08-27-fix-the-ranged-block-scan-names-the-events-key-and-its-type.md new file mode 100644 index 000000000..d13b4790b --- /dev/null +++ b/sdk/packages/sdk/docs/ai/changelog/2026-08-27-fix-the-ranged-block-scan-names-the-events-key-and-its-type.md @@ -0,0 +1,21 @@ +# 2026-08-27 — Fix: the ranged block scan names the events key and its type outright + +The `state_queryStorage` change in the entry below broke the phantom-filler E2E: zero bids on every chain, because the poll delivered no orders at all. Verified against a live simnode; the fix is verified there too. + +The read asked for `api.query.system.events.key()`. polkadot-js builds a `StorageKey` from that argument and needs two things from it — key bytes, to match the change set the node returns, and `meta`, to decode that change set's value — and there are three accessors that each supply a different subset: + +| argument | key bytes | metadata | result | +|---|---|---|---| +| `entry.key()` | correct | none | value typed `Raw`: undecoded bytes | +| `entry` (decorated) | **`[object Promise]`** | yes | matches no change set; value falls back to the entry's empty default | +| `entry.creator` | correct | yes | correct | + +The first was the shipped bug: iterating `Raw` yields numbers, `phantomOrdersFrom` destructured `event` off one, and the tick threw on every tick forever. Nothing failed at the RPC layer, so it read as a poll that never found an order. The second was the first attempt at a fix, and is worse — it fails *silently*, returning an empty vector, because `StorageKey` derives key bytes by calling its argument and calling a decorated entry runs the query. + +Rather than depend on picking correctly between them, the read now computes the key itself — `twox_128("System") ++ twox_128("Events")`, mirroring `system_events_storage_key` in `parachain/simtests` — calls `queryStorage.raw` so polkadot-js does no formatting at all, and decodes with `registry.createType("Vec", value)`. A plain entry's key is a pure function of its pallet and item names, so there is nothing to look up and nothing to choose. + +Two changes so this class of fault cannot be silent or fatal again. `phantomOrdersFrom` now rejects anything that is not a vector of event records instead of quietly finding nothing in it — a block reading as empty is the silent miss the block cursor exists to rule out. And `scanRangeAtOnce` treats that rejection like a refused method: the ranged path is abandoned for the life of the process, the error is reported once through `onError` so the degradation is visible, and the per-block path — which decodes through `api.at` rather than from a passed key — carries the poll from there. Losing the fast path costs requests; losing every bid is what it cost before. + +The unit tests missed it because the harness handed back pre-decoded records whatever it was asked for. It now models what a node does: change sets are keyed by the real events key, and a request naming any other key gets no changes for it — silence, not an error. Nine tests fail if the key is wrong. `phantom-range.simnode.test.ts` covers the rest, against a real node, because the decisive behaviour is polkadot-js's handling of a real reply and no mock can stand in for it. + +Files: `src/chains/intentsCoprocessor.ts`, `src/tests/pollPhantomOrders.test.ts`, `docs/ai/ChangeLog.md`, `docs/ai/Decisions.md`, `docs/ai/Flow.md`. diff --git a/sdk/packages/sdk/docs/ai/changelog/2026-08-27-hyperbridge-http-reads-are-paced-and-a-block-scan-costs-half.md b/sdk/packages/sdk/docs/ai/changelog/2026-08-27-hyperbridge-http-reads-are-paced-and-a-block-scan-costs-half.md new file mode 100644 index 000000000..7e3771b9e --- /dev/null +++ b/sdk/packages/sdk/docs/ai/changelog/2026-08-27-hyperbridge-http-reads-are-paced-and-a-block-scan-costs-half.md @@ -0,0 +1,11 @@ +# 2026-08-27 — Hyperbridge HTTP reads are paced, and a block scan costs half the requests + +Fillers were logging `[429]: Too Many Requests` from the Hyperbridge HTTP endpoint against a 10 req/s limit, on a poll whose interval is 15s. The interval was never the request rate. A tick costs one head read plus the cost of every block in its range, and `getPhantomOrdersInBlock` was costing four RPCs per block, not one: `api.at(hash)` with no version to go on resolves a registry by fetching `chain_getHeader(hash)` and then `state_getRuntimeVersion(parentHash)` — every block, because the `getUpgradeVersion` shortcut that would skip it only covers chains hardcoded in `@polkadot/types-known`. All of them go out back-to-back, so a tick averaging 0.4 req/s arrives as ~33 req/s. On a phantom order interval the fan-out in simplex's `handlePhantomOrders` adds one concurrent `offchain_localStorageGet` per configured chain (up to 16) on top. + +Three changes: + +- `http()` now builds a `RateLimitedHttpProvider`, an `HttpProvider` whose `send` waits on a `TokenBucket` (new, `src/utils/rateLimiter.ts`). Buckets are keyed by endpoint origin in a module-level map, so every coprocessor in a process pointed at the same node shares one budget. Default 8 req/s, `HYPERBRIDGE_RPC_MAX_RPS` to override. +- `getPhantomOrdersInBlock` takes an optional `knownVersion` and passes it to `api.at`, which drops both hidden reads. `pollPhantomOrders` establishes one per tick via `confirmedRuntimeVersion()` — read after the head, used only when it matches the previous reading, so a tick spanning a runtime upgrade falls back to exact per-block resolution. Steady state is 1 head + 1 version + 2 per block. +- `maxBlocksPerPoll` defaults to 20 rather than 500, and a 429 backs the poll off for a doubling number of ticks (capped at 8), reset by any tick that gets through. Ordinary failures still retry on the very next tick. + +Files: `src/chains/intentsCoprocessor.ts`, `src/utils/rateLimiter.ts`, `src/tests/rateLimiter.test.ts`, `src/tests/intentsCoprocessorRateLimit.test.ts`, `src/tests/pollPhantomOrders.test.ts`, `docs/ai/ChangeLog.md`, `docs/ai/Decisions.md`, `docs/ai/Flow.md`. diff --git a/sdk/packages/sdk/docs/ai/changelog/2026-08-27-pool-priced-phantom-bids-are-haircut-10bps-every-other-bid-5bps.md b/sdk/packages/sdk/docs/ai/changelog/2026-08-27-pool-priced-phantom-bids-are-haircut-10bps-every-other-bid-5bps.md new file mode 100644 index 000000000..ecb19b0e5 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/changelog/2026-08-27-pool-priced-phantom-bids-are-haircut-10bps-every-other-bid-5bps.md @@ -0,0 +1,7 @@ +# 2026-08-27 — Pool-priced phantom bids are haircut 10bps, every other bid 5bps + +The pool haircut added on 2026-08-24 drops from 30bps to 10bps, and bids that declare no Uniswap V4 positions — previously untouched — now pay a 5bps haircut of their own. `aggregatePhantomBids` picks one of the two per bid from the same signed declaration it already read: `applyUniswapQuoteHaircut` when positions are named, `applyPhantomQuoteHaircut` otherwise. They never stack. Both still land on the individual quote before the zero-check, the weighted median, and the bidder rows, so the declined-leg path still catches a leg amount a haircut rounds to zero. + +`PHANTOM_QUOTE_HAIRCUT_BPS` and `applyPhantomQuoteHaircut` are exported alongside the Uniswap pair from `@/protocols/intents` and the `intents-helpers` sub-path, so the indexer and simplex keep reading both numbers from one place. + +Files: `src/protocols/intents/phantom-aggregation.ts`, `src/protocols/intents/index.ts`, `src/intents-helpers.ts`, `src/tests/phantomAggregation.test.ts`. diff --git a/sdk/packages/sdk/docs/ai/changelog/2026-08-28-export-fill-order-v1-abi-for-consumers-that-cannot-use.md b/sdk/packages/sdk/docs/ai/changelog/2026-08-28-export-fill-order-v1-abi-for-consumers-that-cannot-use.md new file mode 100644 index 000000000..937389774 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/changelog/2026-08-28-export-fill-order-v1-abi-for-consumers-that-cannot-use.md @@ -0,0 +1,14 @@ +# 2026-08-28 — Export `FILL_ORDER_V1_ABI` for consumers that cannot use `decodeFillOrder` + +`decodeFillOrder` handles both `fillOrder` shapes, but it is viem-based and viem's byte handling throws inside +SubQuery's VM2 sandbox, so the indexer reimplements the decode with ethers. It was building its interface from +`FILL_ORDER_ABI` alone and therefore rejected every v1-shaped bid — which is every bid on mainnet today, since no +gateway has been upgraded past `validUntil` yet. + +The legacy shape is now exported (from `fillOrderCodec`, re-exported on the `intents-helpers` sub-path) so that +decoder can share this definition instead of keeping a second copy. A duplicated ABI that drifts out of step with +this one is exactly the failure being fixed, so the constant is exported rather than re-declared downstream. + +Only the constant is exported on the VM2-safe sub-path; `decodeFillOrder` itself stays off it. + +Files: `src/protocols/intents/fillOrderCodec.ts`, `src/protocols/intents/index.ts`, `src/intents-helpers.ts`. diff --git a/sdk/packages/sdk/docs/ai/changelog/2026-09-01-block-tagged-balance-reads-and-declared-v4-positions-reported.md b/sdk/packages/sdk/docs/ai/changelog/2026-09-01-block-tagged-balance-reads-and-declared-v4-positions-reported.md new file mode 100644 index 000000000..1bde12ff8 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/changelog/2026-09-01-block-tagged-balance-reads-and-declared-v4-positions-reported.md @@ -0,0 +1,24 @@ +# 2026-09-01 — Block-tagged balance reads, and declared V4 positions reported out of the aggregation (#1159) + +The indexer refreshes a solver's liquidity on every event that moves it, not only when a phantom bid window +closes. Three things it needs were locked inside `phantom-aggregation.ts`. + +`getTotalSolverBalance` is now exported and takes a `blockTag`. It is the definition of "a solver's balance" — +raw ERC-20 plus every configured ERC-4626 vault's `maxWithdraw` — and a per-event re-read has to use exactly it: +simplex funds fills straight out of a vault inside the fill transaction, so a wallet-only `balanceOf` misses such +a fill entirely. The block tag is what lets a re-read record the balance as of the event rather than stamping +today's balance onto a historical row. `memoizedSolverBalance` takes a per-chain `blockTags` map for the same +reason — block numbers are per chain, so an event handler can pin its own chain and leave the rest of the sweep +at the head, where a number from another chain would mean nothing. + +`readV4Position` is exported (now taking a params object, with a `blockTag`), `positionAmountOfToken` is +re-exported from `intents-helpers`, and `aggregatePhantomBids` reports the positions it verified as +`PhantomAggregation.positions` — solver, chain, tokenId, after the ownership check — alongside `solvers`, every +solver whose bid passed verification. `legs`, `lpBalances` and `positions` are each filtered by what a solver +turned out to hold or declare, so `solvers` is the only complete answer to "who bid this window", which is what +a consumer reconciling per-solver state needs. A bid is the only place a +Uniswap V4 position is ever named, so without this a consumer cannot re-value one between windows; and carrying +the last window's value forward is worse than not seeing it, because simplex funds fills from these positions +and such a fill drains the position while wallet and vault balances barely move. + +Files: `src/protocols/intents/phantom-aggregation.ts`, `src/intents-helpers.ts`, `src/tests/phantomAggregation.test.ts`. diff --git a/sdk/packages/sdk/docs/ai/changelog/2026-09-03-pollphantomorders-can-skip-a-backlog-it-will-never-use.md b/sdk/packages/sdk/docs/ai/changelog/2026-09-03-pollphantomorders-can-skip-a-backlog-it-will-never-use.md new file mode 100644 index 000000000..374ed8323 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/changelog/2026-09-03-pollphantomorders-can-skip-a-backlog-it-will-never-use.md @@ -0,0 +1,31 @@ +# 2026-09-03 — `pollPhantomOrders` can skip a backlog it will never use + +A mainnet filler fell behind the head and stayed there: the cursor gains at most `maxBlocksPerPoll` per tick and +never skips, so a deficit accumulated while ticks were lost is only repaid if the sustained rate beats the +chain's. It kept delivering orders 3,448 blocks old, whose bid window had closed hours before, and the filler +dutifully bid on every one. + +The cursor now abandons a backlog it cannot use: once it is more than a generation cycle behind the head +(`bidWindowBlocks + max(intervalBlocks, bidWindowBlocks)`) it jumps to one window behind the head — keeping every +order that can still be bid on — and reports the range it dropped through `onSkip`. Not configurable — this +feed exists to be bid on, and an order that far behind cannot be bid on by anyone, so there is no caller for whom +walking that backlog is the right answer. The check runs only once the cursor is established, so a cold start — +exactly one block behind — is never read as a backlog. + +That bounds the property the cursor was built for. It still advances only past blocks whose events were really +read, so an outage delays orders rather than dropping them — up to the point where the delayed orders are dead +anyway. + +`lookbackBlocks` is gone with it. It existed so a restarting process could reach back and still bid on a window +already open, which is the same late bid this change is removing: by the time the process is up, that window has +all but closed. Nothing passed it, and a cold start now begins at the head. + +The threshold is the chain's, not a constant: `phantomTimings()` reads `PhantomBidWindow` — falling back to the +`PhantomOrderBidWindowBlocks` runtime constant when that storage value is zero, exactly as the pallet's own +`phantom_bid_window()` does — and `PhantomOrderInterval`, once per instance. Nexus runs a window of 15 against a +constant of 25 and an interval of 55, so anything hard-coded is wrong in one direction or the other. + +Also adds `latestBlockNumber()`. A phantom order carries the block it was registered at and no clock, so a +consumer deciding whether one is still biddable has to read the head, and nothing public exposed it. + +Files: `src/chains/intentsCoprocessor.ts`, `src/tests/pollPhantomOrders.test.ts`. diff --git a/sdk/packages/sdk/docs/ai/changelog/2026-09-05-mainnet-solveraccount-moves-to-the-2026-09-05-deployment-2-8-11.md b/sdk/packages/sdk/docs/ai/changelog/2026-09-05-mainnet-solveraccount-moves-to-the-2026-09-05-deployment-2-8-11.md new file mode 100644 index 000000000..c2c8856a7 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/changelog/2026-09-05-mainnet-solveraccount-moves-to-the-2026-09-05-deployment-2-8-11.md @@ -0,0 +1,13 @@ +# 2026-09-05 — Mainnet SolverAccount moves to the 2026-09-05 deployment; 2.8.11 + +Every mainnet chain config points `SolverAccount` at `0x7cb55539d1144F62422099c3FA3405092022c88C`, +the account deployed alongside the new IntentGatewayV2 implementation on 2026-09-05 (PR #1207), +replacing `0xfCd233b937D7622AAc63ced3C9A1A12F4a6B64E3`. Solvers delegating through the SDK pick +up the new account on upgrade. The bid-verification fixture in `phantomAggregation.test.ts` uses +the same address. `aggregatePhantomBids` accepts `solverAccount` as one address or a list and +counts a bid whose sender delegates to any of them, so an indexer can keep the replaced account +listed while solvers re-delegate; the delegation reader reads the code once and checks membership. +Version 2.8.11; simplex 0.13.1 goes with it. + +Files: `src/configs/chain.ts`, `src/protocols/intents/phantom-aggregation.ts`, +`src/tests/phantomAggregation.test.ts`, `package.json`, `docs/ai/ChangeLog.md`. diff --git a/sdk/packages/sdk/docs/ai/changelog/2026-09-07-fillerconfig-acceptedsourcechains-removed.md b/sdk/packages/sdk/docs/ai/changelog/2026-09-07-fillerconfig-acceptedsourcechains-removed.md new file mode 100644 index 000000000..2bcd88a74 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/changelog/2026-09-07-fillerconfig-acceptedsourcechains-removed.md @@ -0,0 +1,4 @@ +# 2026-09-07 — `FillerConfig.acceptedSourceChains` removed + +The optional field is gone from `FillerConfig`: simplex now derives a bid's accepted sources from its configured chains and watch-only flags at bid time, so nothing reads it. `encodePhantomBidDeclaration` and the decoder are unchanged. +Files: `src/types/index.ts`. diff --git a/sdk/packages/sdk/docs/ai/changelog/2026-09-07-wallet-funded-phantom-bids-are-no-longer-haircut-only-pool.md b/sdk/packages/sdk/docs/ai/changelog/2026-09-07-wallet-funded-phantom-bids-are-no-longer-haircut-only-pool.md new file mode 100644 index 000000000..92446c558 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/changelog/2026-09-07-wallet-funded-phantom-bids-are-no-longer-haircut-only-pool.md @@ -0,0 +1,4 @@ +# 2026-09-07 — Wallet-funded phantom bids are no longer haircut; only pool-priced bids pay 10bps + +The 5bps `PHANTOM_QUOTE_HAIRCUT_BPS` introduced on 2026-08-27 is removed, along with `applyPhantomQuoteHaircut` and both exports. `aggregatePhantomBids` now haircuts a bid only when its declaration names Uniswap V4 positions, by the unchanged `UNISWAP_QUOTE_HAIRCUT_BPS` of 10bps; a bid with no declared positions is published exactly as quoted. The haircut still lands on the individual quote before the zero-check, the weighted median, and the bidder rows. +Files: `src/protocols/intents/phantom-aggregation.ts`, `src/protocols/intents/index.ts`, `src/intents-helpers.ts`, `src/tests/phantomAggregation.test.ts`, `docs/ai/Flow.md`, `docs/ai/Decisions.md`. diff --git a/sdk/packages/sdk/docs/ai/changelog/2026-09-07-wallet-funded-phantom-bids-pay-the-protocol-fee-haircut-read.md b/sdk/packages/sdk/docs/ai/changelog/2026-09-07-wallet-funded-phantom-bids-pay-the-protocol-fee-haircut-read.md new file mode 100644 index 000000000..4820834e2 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/changelog/2026-09-07-wallet-funded-phantom-bids-pay-the-protocol-fee-haircut-read.md @@ -0,0 +1,4 @@ +# 2026-09-07 — Wallet-funded phantom bids pay the protocol fee haircut, read from the gateway + +The wallet-funded haircut returns, but as a live value rather than a constant: `readProtocolFeeHaircutBps` calls `IntentGateway.params()` on the phantom order's own chain once per aggregation run and takes `protocolFeeBps` from it; `applyProtocolFeeHaircut` shades every bid that declares no Uniswap V4 positions by that amount. Pool-priced bids still pay the fixed 10bps `UNISWAP_QUOTE_HAIRCUT_BPS` instead, never both. A gateway that answers with no code or a fee at or above 100% throws a `PhantomRpcError`, so the run is retried and then abandoned rather than priced unhaircut. Both new functions are exported from `@/protocols/intents` and the `intents-helpers` sub-path. +Files: `src/protocols/intents/phantom-aggregation.ts`, `src/protocols/intents/index.ts`, `src/intents-helpers.ts`, `src/tests/phantomAggregation.test.ts`, `docs/ai/Flow.md`, `docs/ai/Decisions.md`. diff --git a/sdk/packages/sdk/docs/ai/changelog/2026-09-09-declaration-chain-ids-are-utf-8-coded-by-hand-so-the-decoder.md b/sdk/packages/sdk/docs/ai/changelog/2026-09-09-declaration-chain-ids-are-utf-8-coded-by-hand-so-the-decoder.md new file mode 100644 index 000000000..2ed692e89 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/changelog/2026-09-09-declaration-chain-ids-are-utf-8-coded-by-hand-so-the-decoder.md @@ -0,0 +1,4 @@ +# 2026-09-09 — Declaration chain ids are UTF-8 coded by hand, so the decoder survives the SubQuery sandbox + +Every phantom bid that named a source chain was being dropped by the deployed indexer with "Failed to process bid for price snapshot", from the block its solver's simplex began declaring sources (#1216): `decodePhantomBidDeclaration` read each chain id through `@polkadot/util`'s `u8aToString`, which is `TextDecoder.decode`, and inside SubQuery's vm2 sandbox `TextDecoder` is not a global and the `util` fallback rejects a sandbox-created `Uint8Array` ("The \"list\" argument must be an instance of ... ArrayBufferView"). Bids declaring nothing, or only V4 positions, never reached that call and kept decoding, which is why one solver stayed in the Base snapshots while the two that had upgraded vanished. The codec now encodes and decodes UTF-8 with plain byte arithmetic (`utf8Encode`/`utf8Decode`), byte-identical to TextEncoder/TextDecoder and rejecting what they reject; a chain id that is not well-formed UTF-8 makes the whole declaration malformed. Reproduced and verified against the shipped bundle inside a vm2 NodeVM (indexer test `phantom-decode.sandbox.test.ts`). +Files: `src/protocols/intents/phantom-aggregation.ts`, `src/tests/phantomSourceDeclaration.test.ts`, `docs/ai/Decisions.md`, `docs/ai/Flow.md`. diff --git a/sdk/packages/sdk/docs/ai/changelog/2026-09-09-phantom-bid-paymasteranddata-decodes-in-both-shapes-bare.md b/sdk/packages/sdk/docs/ai/changelog/2026-09-09-phantom-bid-paymasteranddata-decodes-in-both-shapes-bare.md new file mode 100644 index 000000000..de2bbcef7 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/changelog/2026-09-09-phantom-bid-paymasteranddata-decodes-in-both-shapes-bare.md @@ -0,0 +1,4 @@ +# 2026-09-09 — Phantom bid `paymasterAndData` decodes in both shapes: bare declaration, or Permit2 sponsorship with the declaration appended + +Simplex now authorizes every sponsored UserOp with a per-op Permit2 signature (#1223), so a phantom bid built on the real-bid path carries the EntryPoint v0.8 paymasterAndData for the Simplex paymaster's PERMIT2 mode — paymaster(20) ‖ two uint128 gas limits ‖ mode 0x02 ‖ token ‖ permitAmount ‖ nonce ‖ deadline ‖ v ‖ r ‖ s, 234 bytes — where the aggregation used to expect only the declaration blob. `decodePhantomBidPaymasterAndData` now reads either: a bare declaration first (byte-for-byte the previous behaviour), else a blob opening with a complete Permit2 sponsorship, whose tail is parsed as the declaration or, if empty, read as "declared nothing". It reports which shape it found and the sponsorship's fields (paymaster, fee token, permit amount, nonce, deadline). `decodePhantomBidDeclaration` delegates to it, so the aggregation and every other reader see the same declaration whichever shape a bid arrived in. `encodePhantomBidPaymasterAndData` builds the sponsored shape for a solver that has one to append, refusing anything but the Permit2-mode layout, and falls back to the bare declaration when there is no sponsorship. Both functions and `PERMIT2_SPONSORSHIP_BYTES` are exported from the root and the `intents-helpers` sub-path. +Files: `src/protocols/intents/phantom-aggregation.ts`, `src/protocols/intents/index.ts`, `src/intents-helpers.ts`, `src/tests/phantomSourceDeclaration.test.ts`, `src/tests/phantomAggregation.test.ts`, `docs/ai/Decisions.md`, `docs/ai/Flow.md`. diff --git a/sdk/packages/sdk/docs/ai/changelog/2026-09-10-docs-ai-split-into-one-file-per-entry.md b/sdk/packages/sdk/docs/ai/changelog/2026-09-10-docs-ai-split-into-one-file-per-entry.md new file mode 100644 index 000000000..b8c34ecb7 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/changelog/2026-09-10-docs-ai-split-into-one-file-per-entry.md @@ -0,0 +1,20 @@ +# 2026-09-10 — docs/ai split into one file per entry + +`ChangeLog.md`, `Decisions.md` and `Flow.md` are gone. Their entries now live as individual +files under `changelog/`, `decisions/` and `flows/` — 20, 20 and 4 of them in this package. +The prose is unchanged; the only edit is that each entry's headings moved up one level, from +`##` to `#`. That was checked by demoting every new file back a level and comparing it to the +text it came from, byte for byte. + +Why. The three files were append-only with newest entries on top, so any two concurrent PRs +wrote the same line and GitHub marked both conflicted. #1251 declared `merge=union` for the two +log files, which does resolve the collision locally — but GitHub ignores merge drivers when it +computes a PR's mergeability, so the PRs stayed blocked however clean the local merge was. +A file that only one PR creates cannot collide at all, which is the same reason changesets +keeps one file per change rather than one shared changelog. + +Flows stay one file per flow and are still edited in place, so two PRs revising the same flow +still conflict — correctly, because that means they disagree about how the code runs. + +Files: docs/ai/{ChangeLog,Decisions,Flow}.md (removed), docs/ai/README.md (new), +docs/ai/{changelog,decisions,flows}/*.md (new). Repo root: CLAUDE.md, .gitattributes (removed). diff --git a/sdk/packages/sdk/docs/ai/decisions/2026-08-18-signingaccount-describes-only-what-the-sdk-calls.md b/sdk/packages/sdk/docs/ai/decisions/2026-08-18-signingaccount-describes-only-what-the-sdk-calls.md new file mode 100644 index 000000000..7b74bac66 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/decisions/2026-08-18-signingaccount-describes-only-what-the-sdk-calls.md @@ -0,0 +1,13 @@ +# 2026-08-18 — `SigningAccount` describes only what the SDK calls + +(Amended the same day: `signRawHash` was removed in a follow-up commit; see the closing paragraph.) + +Chosen: drop `signMessage` from `SigningAccount`, leaving `signRawHash` and `signTypedData`. + +Alternative considered: leaving it in place, since removing a member from a published interface is a public API change. + +Why: the interface exists so a caller can hand the SDK a signing backend, and every member it declares is a cost paid by every implementer. `signMessage` was never invoked — bid UserOperations are EIP-712-signed through `signTypedData` — so the cost bought nothing, and it forced downstream signer abstractions (notably `@hyperbridge/simplex`'s `Signer`) to carry a dead method into their own public surface. The narrowing is safe in the direction that matters: implementers with an extra method still satisfy the type, and only a caller of `solverSigner.signMessage` would break, of which there are none. + +`signTypedData`'s `chainId` parameter went for a sharper reason than disuse: it is redundant with EIP-712 itself. The digest covers `domain.chainId`, `BidManager` built payloads that always set it, and the one downstream implementation that needed a chain id (MPCVault, for its request envelope) could read it from the payload — and defaulted the argument to `1` when absent, which is a bad failure mode for a signature. + +`signRawHash` went the same day, in the follow-up that made `signAuthorization` and `signTransaction` required members of simplex's `Signer`: once those are guaranteed, nothing in either package computes an authorization digest for the signer to raw-sign, and keeping the member would have recreated the `signMessage` situation. `SigningAccount` is down to the one method this package invokes — `signTypedData`. diff --git a/sdk/packages/sdk/docs/ai/decisions/2026-08-21-the-coprocessor-s-http-provider-runs-with-its-response-cache-off.md b/sdk/packages/sdk/docs/ai/decisions/2026-08-21-the-coprocessor-s-http-provider-runs-with-its-response-cache-off.md new file mode 100644 index 000000000..daf25b9c0 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/decisions/2026-08-21-the-coprocessor-s-http-provider-runs-with-its-response-cache-off.md @@ -0,0 +1,11 @@ +# 2026-08-21 — The coprocessor's HTTP provider runs with its response cache off + +Chosen: `new HttpProvider(httpUrl, {}, 0)` — capacity 0 disables polkadot-js's per-provider LRU outright, so every `send` reaches the node. + +Alternatives considered: + +- A shorter TTL, or a poll interval longer than the TTL. The TTL slides — each hit refreshes it — so any poll faster than the TTL keeps a cached rejection alive indefinitely, and a slower poll would heal after one TTL only by coupling the cadence to a cache constant; the poll was made faster on purpose (#1138). +- A provider wrapper, or an upstream fix, that drops an entry when its promise rejects. Correct, but more code holding state this api has no use for, and an upstream fix still needs the workaround until it ships: polkadot-js master has the same `send` as 16.5.6. +- Keeping the cache and retrying inside the poll tick. A retry hits the same cache key and gets the same rejection; only a different request shape would get past it. + +Why: the HTTP api exists for one-shot reads whose whole value is that nothing persists between calls (see the `http()` and `pollPhantomOrders` doc comments). A promise cache is exactly such persistence, and its only effect on this api was the failure mode: the poll reads each block once, so there are no repeat hits to serve, and `api.at(hash)` already reuses decoded registries at the api layer. Disabling it removes the hazard without adding a mechanism. diff --git a/sdk/packages/sdk/docs/ai/decisions/2026-08-24-the-30bps-pool-haircut-keys-off-the-declaration-and-lands-on.md b/sdk/packages/sdk/docs/ai/decisions/2026-08-24-the-30bps-pool-haircut-keys-off-the-declaration-and-lands-on.md new file mode 100644 index 000000000..0384cbb96 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/decisions/2026-08-24-the-30bps-pool-haircut-keys-off-the-declaration-and-lands-on.md @@ -0,0 +1,14 @@ +# 2026-08-24 — The 30bps pool haircut keys off the declaration, and lands on price only + +Chosen: in `aggregatePhantomBids`, haircut a bid's quoted leg amounts by 30bps when its paymasterAndData declaration names Uniswap V4 positions. + +Alternatives considered: + +- **Keying off the positions that pass the ownership check** (the `positions` array, not `declaration.uniswapV4Positions`). Rejected: those are filtered by owner and skipped entirely when the chain has no `positionManager`/`stateView` configured, so the same bid would be priced two different ways depending on indexer config. The declaration is what the solver signed, and it is what says "this quote came off a pool". +- **Haircutting the weight instead of the price.** Rejected: the weight is deliverable inventory, read on-chain, and it is also what the liquidity sweep reports — discounting it would make a provider's reported inventory and the depth attributed to it disagree, which the sweep exists to prevent. The fee is a cost of the trade, not a reduction in the size held. +- **Applying it after the median, to the leg's published price.** Rejected: the median is liquidity-weighted across bids, so haircutting the aggregate would also discount wallet-funded quotes that never pay a pool fee, and it would change which quote wins the median only by accident. The haircut belongs on the individual quote, before it competes. +- **Making the rate configurable per chain or pool.** Deferred: the positions these bids declare sit in 30bps-tier pools, and a knob invites the number to drift out of sync between the indexer and simplex. A single exported constant is easy to widen into a lookup if a different tier ever shows up. + +Why 30bps at all: without it, a pool-priced bid reads richer than a wallet-funded one on a fee it has not yet paid, so it wins the median and the published price is one nobody can actually execute at. + +A consequence worth knowing: a leg amount small enough that the haircut rounds it to zero now takes the "solver declined this leg" path. That is the correct reading — a quote that rounds away is not a price — and it only bites at dust amounts. diff --git a/sdk/packages/sdk/docs/ai/decisions/2026-08-25-intent-quotes-default-to-directional-indexed-rates-without.md b/sdk/packages/sdk/docs/ai/decisions/2026-08-25-intent-quotes-default-to-directional-indexed-rates-without.md new file mode 100644 index 000000000..65c944a4e --- /dev/null +++ b/sdk/packages/sdk/docs/ai/decisions/2026-08-25-intent-quotes-default-to-directional-indexed-rates-without.md @@ -0,0 +1,14 @@ +# 2026-08-25 — Intent quotes default to directional indexed rates without fallback + +Chosen: `quoteIntent` defaults to an `indexed_rates` strategy that selects the depth-weighted aggregate `LiquidityPool.buyRate` for base-to-quote orders and `sellRate` for quote-to-base orders. Source and destination chains resolve the configured token deployments; raw amounts are calculated from the indexer's 18-decimal whole-token pool rate and both tokens' configured decimals. A missing directional rate is an error. + +Alternatives considered: + +- **Keep defaulting to the legacy directional Phantom snapshot.** Rejected: those snapshots resolve through a canonical Base market and do not use the pair-centric pool rate, so quotes can disagree with the indexer's current market. +- **Quote directly from one source/destination pair of `PoolChainLiquidity` rows.** Rejected: those rows are inputs to the indexer's pool price. `LiquidityPool.buyRate` and `sellRate` are the maintained depth-weighted merge of fresh chain samples and are the intended market-level quote. +- **Silently fall back to Phantom or Uniswap when a rate is absent.** Rejected: an order would be priced from a different market than the caller requested, hiding stale or incomplete indexer coverage and producing another unfillable quote. +- **Remove the old strategies immediately.** Rejected for compatibility: callers that explicitly select them can continue doing so, while all calls without a strategy use the corrected path. + +The result uses a new `indexed_rates` discriminant and includes the selected rate side, value, timestamp, pair symbols, chains, and protocol fee. This makes the price used to construct the order inspectable without exposing indexer internals. + +The public sell rate is the reciprocal of the indexer's base-per-quote direction. That reciprocal rounds up at 18 decimals: rounding down would let a quote-token-to-base-token order request slightly more base output than the indexed direction supports. Buy rates are already indexer-floored outputs and remain unchanged. diff --git a/sdk/packages/sdk/docs/ai/decisions/2026-08-27-batching-is-transparent-at-the-provider-and-only-the-block-hash.md b/sdk/packages/sdk/docs/ai/decisions/2026-08-27-batching-is-transparent-at-the-provider-and-only-the-block-hash.md new file mode 100644 index 000000000..d235ec016 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/decisions/2026-08-27-batching-is-transparent-at-the-provider-and-only-the-block-hash.md @@ -0,0 +1,16 @@ +# 2026-08-27 — Batching is transparent at the provider, and only the block-hash half of the scan was made concurrent + +Chosen: coalesce concurrent calls into a JSON-RPC batch inside the provider, and make the poll fetch a range's block hashes concurrently while leaving the events reads sequential. + +Why not batch at the call sites: the callers that burst are spread out — the offchain fan-out in simplex's `handlePhantomOrders`, the scan, balance reads — and an explicit batch API would have to be threaded through each. The provider is where a burst is already visible as concurrency, and coalescing there needs no call site to know it is happening. + +Why the events reads are still sequential, which is the non-obvious half. Making them concurrent looks like the bigger win — it would take a scan of n blocks to two requests instead of n+1 — but it silently undoes the `knownVersion` work. `api.at(hash, version)` resolves its registry through `_getBlockRegistryViaVersion`, which sets `lastBlockHash` on the shared registry; the subsequent `system.events` read goes through rpc-core's own registry swap, which calls `getBlockRegistry(hash)` *without* a version and finds it only by that `lastBlockHash`. Sequentially that always hits. Concurrently, n interleaved `at` calls each overwrite it, so all but one miss and fall through to `_getBlockRegistryViaHash` — the two RPCs per block that naming a version exists to avoid. Batching would hide that from the request counter while doubling the node's work, which is the opposite of the intent. + +Alternatives considered for the events half: + +- **Accept the registry re-resolution**, since the extra calls batch anyway. Rejected: it trades node-side work for a lower request count, and the limit exists to bound work. +- **`state_getStorage.raw(key, hash)` with one registry for the range.** `.raw` skips the swap (`isScale && blockHash && …` in rpc-core's `_createMethodSend`), so this really is two RPCs per block and one request for the range. Deferred, not rejected: it means decoding events by hand through `registry.createLookupType(meta.type.asPlain)` rather than `apiAt.query.system.events()`, and a wrong type there decodes to a shape the scan reads as "no orders" — a silent miss. Verifying it needs a running node, which the unit tests do not have. Worth doing behind the simnode test. + +Why one request in flight at a time: overlapping flushes interleave against a `maxBatchSize` that a `-32010` refusal shrinks underneath them, so the same burst fragmented into a different number of requests run to run. Serialising also makes each request as full as it can be. The cost is that a submission can wait a round trip behind a scan flush, which at the paced rate is smaller than the wait the bucket already imposes. + +Why a macrotask window rather than a microtask: `Promise.all` starts its calls in one synchronous run, but each then advances through several microtask turns before reaching the provider. A microtask flush fires between those turns and splits one burst across several requests. diff --git a/sdk/packages/sdk/docs/ai/decisions/2026-08-27-every-phantom-quote-is-haircut-the-pool-tier-drops-to-10bps-and.md b/sdk/packages/sdk/docs/ai/decisions/2026-08-27-every-phantom-quote-is-haircut-the-pool-tier-drops-to-10bps-and.md new file mode 100644 index 000000000..10c6ff2e4 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/decisions/2026-08-27-every-phantom-quote-is-haircut-the-pool-tier-drops-to-10bps-and.md @@ -0,0 +1,11 @@ +# 2026-08-27 — Every phantom quote is haircut; the pool tier drops to 10bps and wallet quotes pay 5bps + +Chosen: `UNISWAP_QUOTE_HAIRCUT_BPS` becomes 10bps and a new `PHANTOM_QUOTE_HAIRCUT_BPS` of 5bps is charged to every bid that does **not** declare Uniswap V4 positions. A bid pays one haircut or the other, never both — `poolPriced` selects which function runs, and the structure from 2026-08-24 (keyed off the signed declaration, applied to the quote before the zero-check and the median) is unchanged. + +Alternatives considered: + +- **Stacking the 5bps on top of the pool haircut.** Rejected: the two are the same kind of adjustment — margin between what a solver names and what the protocol is willing to publish — and the pool tier is already the larger of the two. Compounding them would price a pool-funded bid at 15bps for reasons no single rationale explains, and would make the shipped number harder to reconcile against the fee tier it is meant to track. +- **Leaving wallet-funded quotes unhaircut.** Rejected: a wallet quote is still the most optimistic number a solver names at bid time, and the median it feeds is published to takers as a rate they can trade against. A small uniform margin is what keeps the published rate on the executable side of the bid. +- **Applying the base haircut after the median instead of per quote.** Rejected for the same reason as before: it is now a haircut every bid pays, so applying it to the aggregate would be arithmetically similar but would split one rule across two places in the code. + +The 5bps also applies to a bid whose declaration is absent or unparseable, which is the correct reading — no declaration is no claim to be pricing off a pool. diff --git a/sdk/packages/sdk/docs/ai/decisions/2026-08-27-request-pacing-lives-at-the-provider-keyed-by-endpoint.md b/sdk/packages/sdk/docs/ai/decisions/2026-08-27-request-pacing-lives-at-the-provider-keyed-by-endpoint.md new file mode 100644 index 000000000..73f9b5ad7 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/decisions/2026-08-27-request-pacing-lives-at-the-provider-keyed-by-endpoint.md @@ -0,0 +1,14 @@ +# 2026-08-27 — Request pacing lives at the provider, keyed by endpoint + +Chosen: an `HttpProvider` subclass whose `send` waits on a shared `TokenBucket`, with one bucket per endpoint origin in a module-level map. + +Alternatives considered: + +- **Pacing inside `pollPhantomOrders`** — a sleep between blocks, or a queue around the scan. Rejected: the poll is not the only caller. Balance polling, the offchain fan-out in simplex's `handlePhantomOrders`, and the HTTP submission fallback all hit the same endpoint, and the limit counts them together. Pacing the loudest caller leaves the sum unpaced, and it is the sum the node sees. +- **A bucket per `IntentsCoprocessor`.** Rejected: several fillers in one process each hold their own coprocessor, so N instances would each pace to the full budget and collectively exceed it by N. The limit is a property of the endpoint, so the bucket is too. +- **`p-queue` with `intervalCap`,** which the package already depends on. Rejected: its interval is a fixed window that refills all at once, so a burst arriving just after a boundary is passed straight through, which is the exact shape being defended against. A token bucket refills continuously. +- **Reacting to 429s only** (backoff, no pacing). Kept as well, but not instead: a 429 is already a request spent, and a limiter that is shedding load may be counting rejections too. Backoff recovers from a breach; the bucket is what stops causing them. + +Two consequences worth knowing. The bucket makes a large scan a *queue*, so anything sharing the endpoint waits behind it — which is why `maxBlocksPerPoll` dropped to 20 in the same change; at 500 a catch-up would have queued ~1000 requests, over two minutes of them, ahead of a bid submission that is worth nothing after five blocks. And the bucket is FIFO on purpose: without it, a caller arriving on an idle bucket takes the token a queued one was waiting for, and a steady arrival stream starves the queue. + +Why not simply raise the limit with the provider: the endpoint is derived from the websocket's, not configured (phantom orders live in that node's offchain storage), so operators do not necessarily control it. `HYPERBRIDGE_RPC_MAX_RPS` exists for those who do. diff --git a/sdk/packages/sdk/docs/ai/decisions/2026-08-27-the-bid-expiry-rides-in-filloptions-not-in-the-bid-signature.md b/sdk/packages/sdk/docs/ai/decisions/2026-08-27-the-bid-expiry-rides-in-filloptions-not-in-the-bid-signature.md new file mode 100644 index 000000000..b2aeccbcc --- /dev/null +++ b/sdk/packages/sdk/docs/ai/decisions/2026-08-27-the-bid-expiry-rides-in-filloptions-not-in-the-bid-signature.md @@ -0,0 +1,29 @@ +# 2026-08-27 — The bid expiry rides in `FillOptions`, not in the bid signature + +Chosen: `validUntil` is a field on `FillOptions`, checked by `fillOrder` at execution. + +The alternative was to put the expiry in the ERC-4337 signature blob and return it from +`SolverAccount.validateUserOp` as a `validUntil` validation range — the mechanism 4337 provides for exactly this. +That was built first and then abandoned, for reasons worth recording: + +- **It needs a new signed field.** `userOpHash` does not cover `op.signature`, so an expiry carried there is + rewritable by whoever replays the bid. Making it tamper-proof meant changing what the solver signs (an EIP-712 + `BidValidity` digest) and widening the selection signature 162 → 168 bytes. +- **That is a `SolverAccount` redeploy.** The account is reached by EIP-7702 delegation, so a new version means a new + address and every solver re-delegating — and, since the old account keeps accepting the old format forever, it + retires nothing already signed. +- **`FillOptions` needs none of that.** The options are part of `callData`, which `userOpHash` *does* cover. The + expiry is authenticated for free, with no signature format change, no account redeploy, and no migration. +- **It covers more.** The signature-side check only bounds solver-selection bids. A check in `fillOrder` bounds every + path into it. + +The cost is that this fires at execution rather than validation: an expired bid is included, the nonce is consumed +and the account pays that op's gas, where a validation-time range would have had the bundler drop it for free. That +is a bounded, one-off cost per bid — and consuming the nonce permanently retires the bid, which the validation-time +version does not do. Fund loss, the thing that matters, is prevented either way. + +Denominated in blocks rather than a timestamp so it reads against the same clock as `order.deadline` (`_blockNumber()`, +the L2 block number where those differ), and so the two cannot disagree about what "expired" means. + +`0` means unbounded. That is the right default for a solver filling directly — it is only exposed to its own +staleness — and it keeps every existing caller working. The protection is opt-in by the party that needs it. diff --git a/sdk/packages/sdk/docs/ai/decisions/2026-08-27-the-block-scan-names-a-runtime-version-and-drops-it-the-moment.md b/sdk/packages/sdk/docs/ai/decisions/2026-08-27-the-block-scan-names-a-runtime-version-and-drops-it-the-moment.md new file mode 100644 index 000000000..62656e473 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/decisions/2026-08-27-the-block-scan-names-a-runtime-version-and-drops-it-the-moment.md @@ -0,0 +1,15 @@ +# 2026-08-27 — The block scan names a runtime version, and drops it the moment it might be wrong + +Chosen: `getPhantomOrdersInBlock` takes an optional `knownVersion` for `api.at`; the poll establishes one per tick by reading `state_getRuntimeVersion` after the head read, and uses it only when it matches the previous reading. + +Why it matters: with nothing to go on, `api.at(hash)` resolves a registry through `_getBlockRegistryViaHash` — `chain_getHeader(hash)` plus `state_getRuntimeVersion(parentHash)` — on every block, because its cheap paths are a registry pinned to that exact hash (only ever the previous block's) or one matching a version the caller names. That is two of the four RPCs a block cost, and they are pure overhead for a scan walking consecutive blocks under one runtime. + +Alternatives considered: + +- **Pass `api.runtimeVersion` unconditionally.** Rejected: an HTTP `ApiPromise` has no `subscribeRuntimeVersion`, so that field is frozen at connect. After an upgrade it names a version whose registry is still in `#registries`, so `_getBlockRegistryViaVersion` matches it and decodes new blocks against old metadata — and the failure is silent. The events come back in a shape the scan does not recognise, the block reads as carrying no phantom orders, and the cursor advances past it. That is precisely the silent miss the block cursor exists to rule out. +- **Read the version once and refresh on a slow timer** (every few minutes). Rejected for the same reason at a smaller scale: it buys a cheaper check by accepting a window in which orders are silently dropped. One read per tick costs ~0.07 req/s. +- **Skip `api.at` entirely** — `rpc.state.getStorage.raw(eventsKey, hash)` decoded against `api.registry`. That is two RPCs per block with no per-tick read at all, but it decodes against the connect-time registry with no way to notice an upgrade, and it hand-rolls event decoding. Worse on the axis that matters to be cheaper on the one that does not. + +Why comparing two readings is sound: `specVersion` only increases, and the version is read *after* the head, so two equal readings mean no upgrade landed between them and therefore none in the range about to be scanned. A reading that differs means one did, and that tick falls back to per-block resolution, which is exact. The gap left is a backlog reaching back past an upgrade — recovering from an outage that long means those bid windows closed many upgrades ago. + +The per-tick read is skipped when there is nothing to scan, so a quiet tick still costs exactly one request. diff --git a/sdk/packages/sdk/docs/ai/decisions/2026-08-27-the-events-key-is-computed-and-the-ranged-reply-is-decoded.md b/sdk/packages/sdk/docs/ai/decisions/2026-08-27-the-events-key-is-computed-and-the-ranged-reply-is-decoded.md new file mode 100644 index 000000000..b40c0c042 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/decisions/2026-08-27-the-events-key-is-computed-and-the-ranged-reply-is-decoded.md @@ -0,0 +1,12 @@ +# 2026-08-27 — The events key is computed, and the ranged reply is decoded explicitly + +Chosen: `SYSTEM_EVENTS_KEY = twox_128("System") ++ twox_128("Events")`, `queryStorage.raw`, and `registry.createType("Vec", value)`. + +Why not ask polkadot-js for the key: the formatted call builds a `StorageKey` from its argument and takes both the key bytes and the decoding metadata from it, and the three obvious accessors each get a different subset right — `entry.key()` has the bytes and no metadata (values arrive as `Raw`), the decorated `entry` has metadata but yields `[object Promise]` as its bytes (matches nothing, value silently empty), and only `entry.creator` has both. All three type-check, none fails loudly, and two of them ship a poll that finds no orders. Shipping the first cost a red E2E; the second was caught only by running against a real node. + +A plain entry's storage key is `twox_128(pallet) ++ twox_128(item)` and nothing else, which is why `parachain/simtests` computes it directly in `system_events_storage_key`. Computing it here removes the choice entirely, and `.raw` removes the formatting layer that made the choice matter — at the cost of naming the value type, which is stable and asserted against a live node. + +Alternatives considered: + +- **`entry.creator`.** Correct, and verified working. Rejected as the primary because it is one non-obvious accessor away from two that fail silently, and nothing in its shape says so — the next person to touch this line has the same three-way choice. +- **Keeping the formatted call and adding a test that the key matches.** That is what the unit test now does anyway, but it only constrains the call site; the decode still happens inside polkadot-js against metadata this code never sees. diff --git a/sdk/packages/sdk/docs/ai/decisions/2026-08-27-the-fast-path-is-never-load-bearing-a-ranged-reply-that-will.md b/sdk/packages/sdk/docs/ai/decisions/2026-08-27-the-fast-path-is-never-load-bearing-a-ranged-reply-that-will.md new file mode 100644 index 000000000..cf3c42cd5 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/decisions/2026-08-27-the-fast-path-is-never-load-bearing-a-ranged-reply-that-will.md @@ -0,0 +1,13 @@ +# 2026-08-27 — The fast path is never load-bearing: a ranged reply that will not decode falls back + +Chosen: `phantomOrdersFrom` throws `EventDecodeError` on anything that is not a vector of event records, and `scanRangeAtOnce` responds by abandoning the ranged read permanently, reporting once, and letting the per-block path take over. + +Why, concretely: the ranged read shipped asking for `system.events.key()` instead of the storage entry, so polkadot-js had no metadata to decode against and returned `Raw`. The poll found no orders in any block and no filler bid — for four minutes of E2E, and it would have been indefinite in production. The RPC never failed; only the decoding was wrong. + +Alternatives considered: + +- **Let the decode error propagate to `onError` and retry.** That is what happened, in effect, and it is a permanent outage: the next tick asks the same way and gets the same bytes. Retrying only helps a transient fault, and a type mismatch is not one. +- **Fall back silently.** Rejected: the fast path being off is worth knowing about, and this file's whole disposition is against silent degradation. Reported once, then quiet. +- **Validate the decoded value and skip just the bad block.** Rejected: it cannot distinguish "this block decoded to nothing" from "nothing decodes", and the cursor would advance past real orders either way. + +The general shape worth keeping: the ranged read is an optimisation over a path that already worked, so every way it can fail should end at that path rather than at a stopped poll. The metadata-carrying key is pinned by a test, and the harness now decodes only when the key it was handed carries `meta` — reproducing the failure rather than papering over it. diff --git a/sdk/packages/sdk/docs/ai/decisions/2026-08-27-the-implementation-address-identifies-the-filloptions-shape.md b/sdk/packages/sdk/docs/ai/decisions/2026-08-27-the-implementation-address-identifies-the-filloptions-shape.md new file mode 100644 index 000000000..3c3daf1e3 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/decisions/2026-08-27-the-implementation-address-identifies-the-filloptions-shape.md @@ -0,0 +1,44 @@ +# 2026-08-27 — The implementation address identifies the FillOptions shape + +Chosen: `getFillOptionsVersion` reads the ERC-1967 implementation slot and checks the address +against `LEGACY_FILL_OPTIONS_IMPLEMENTATIONS`, a set of implementations deployed before +`validUntil` existed. Anything else is v2. + +EIP-1967 standardises three slots — implementation, admin, beacon — all holding addresses. There +is no version field in the spec to read, and OZ `Initializable`'s `uint64` only moves under +`reinitializer(N)`, which this contract does not use. The implementation address is the only value +the proxy actually updates on upgrade, so it is what identifies the deployed code. + +The list is of **legacy** implementations, not current ones, so the default is v2. That direction +is the whole point: a newly shipped implementation needs no edit here, and once every deployment +is upgraded the set is vestigial and still correct. + +A single entry covers every chain that runs it. The protocol contracts are CREATE2-deployed, so +`0x976B268b06f545c4A2BF44866Aa2465bd8B3C67d` is the pre-`validUntil` implementation on those +chains — confirmed with the maintainers rather than inferred, since the CREATE2 claim in the tree +is about the proxies and does not by itself say anything about implementations. + +`CHAINS_WITHOUT_VALID_UNTIL` covers the rest. The testnets have not been redeployed and their +implementation addresses are not tracked here, so the address check alone would read them as +current and every fill would revert on a selector that does not exist. It is checked before the +slot read, both because the address is uninformative there and because it saves a round trip. +Delete a chain from that set as its gateway is redeployed; once it is empty the address check +covers everything on its own. Listing known-good implementations instead +would be the version constant this replaced wearing a different hat — a value someone must +remember to update on every upgrade, where forgetting breaks every fill on that chain. + +Only v2 answers are cached, keyed by proxy address. A deployment can move from legacy to current +but never back, so a v2 result is true forever; caching a v1 result would pin the old encoding +across the very upgrade that changes it, since the proxy address does not move and nothing would +invalidate it. A still-legacy gateway therefore costs one storage read per fill, an upgraded one +costs none. + +Also considered and dropped: scanning the implementation's runtime code for the v2 `fillOrder` +selector. It needs no address list and self-updates, and the selector does survive `via-ir` and +the optimizer — but it is a heuristic (a 4-byte sequence can appear in non-dispatcher data), and +both failure directions break every fill on the chain, since the two shapes cannot decode each +other. An address match is exact. + +Earlier still, and rejected: a `fillOptionsVersion()` getter on the contract. A hand-maintained +integer is a second source of truth that answers what a deployment claims rather than what it can +decode. diff --git a/sdk/packages/sdk/docs/ai/decisions/2026-08-27-the-scan-reads-a-range-with-state-querystorage-and-declines-it.md b/sdk/packages/sdk/docs/ai/decisions/2026-08-27-the-scan-reads-a-range-with-state-querystorage-and-declines-it.md new file mode 100644 index 000000000..0356ce9c7 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/decisions/2026-08-27-the-scan-reads-a-range-with-state-querystorage-and-declines-it.md @@ -0,0 +1,17 @@ +# 2026-08-27 — The scan reads a range with `state_queryStorage`, and declines it rather than risk a stale registry + +Chosen: read every block's events in one `state_queryStorage(keys, from, to)` call, falling back to per-block reads under three conditions. + +This supersedes the "deferred" bullet in the entry below, which contemplated `state_getStorage.raw` per block with a hand-rolled decode. `state_queryStorage` is better on both counts it was deferred for: it is one call for the range rather than one per block, and polkadot-js decodes a `Vec` reply itself — `_formatOutput` types each value from the storage key's own metadata — so there is no hand-rolled decoding to get wrong. + +Alternatives considered: + +- **`state_getStorage` per block, batched.** Rejected once the ranged call was available: same request count only if the batch holds the whole range, and it asks the node for n storage reads instead of one range walk. +- **Trusting the ranged reply unconditionally.** Rejected. rpc-core decodes it against the *default* registry, because `state_queryStorage` declares no `isHistoric` parameter and so gets no registry swap. That registry is fixed at connect and an HTTP api has no `subscribeRuntimeVersion` to refresh it, so after a runtime upgrade the decode is silently wrong in exactly the way a stale registry always is — events read as a shape the scan does not recognise, and the block passes as carrying nothing. `scanRangeAtOnce` therefore requires the tick's confirmed version to equal `api.runtimeVersion`, and hands off to the per-block path otherwise. The cost is that an upgrade costs a process restart to get the cheap path back; the direction of failure is right. +- **Treating a refusal as an error.** Rejected: `--rpc-methods=safe` makes the method permanently unavailable, not intermittently. It is detected once (`Method not found`, which is also how `check_if_safe` denies) and the poll switches paths for good without reporting anything. + +What had to be reasoned about rather than assumed: `query_storage_unfiltered` in `sc-rpc` pushes a change set only when a key's value differs from the previous block in the range, and drops the set entirely when empty. So blocks are *missing* from the reply, not merely empty — on a quiet chain, consecutive blocks whose events are just the timestamp inherent's `ExtrinsicSuccess` encode identically and collapse to one entry. This is only safe because `phantom_order_commitment` derives the commitment from the block number: a block that registered orders cannot encode like any other block, so "absent" implies "no orders". A change to how commitments are built would break that, which is why it is written down here. + +Because one call covers the range, the cursor advances the whole way or not at all — there is no partial progress to preserve, unlike the per-block path. + +Why `maxBlocksPerPoll` went to 10 rather than up: the ranged read is one request but not free work. `sc-rpc` documents it as `O(|keys| * dist(from, to))` in time *and* memory, so a wide range is one request the node spends a long time on — and the same number still bounds the fallback, which is one request per block. diff --git a/sdk/packages/sdk/docs/ai/decisions/2026-08-28-the-legacy-fillorder-abi-is-exported-not-re-declared-downstream.md b/sdk/packages/sdk/docs/ai/decisions/2026-08-28-the-legacy-fillorder-abi-is-exported-not-re-declared-downstream.md new file mode 100644 index 000000000..fab41ace8 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/decisions/2026-08-28-the-legacy-fillorder-abi-is-exported-not-re-declared-downstream.md @@ -0,0 +1,17 @@ +# 2026-08-28 — The legacy `fillOrder` ABI is exported, not re-declared downstream + +The indexer needs the v1 shape to decode bids with ethers. Two ways to give it one: + +- **Re-declare it there.** No SDK change, but then two definitions of the same historical ABI have to stay + identical forever, with nothing enforcing it. The bug being fixed is precisely a decoder that fell out of step + with the shapes in the wild, so adding a second source of truth for those shapes is the wrong direction. +- **Export the existing constant.** One definition. Costs a public export of something that is otherwise an + implementation detail — acceptable, since the reason it exists (deployments predating `validUntil`) is a fact + about the network rather than about this package. + +Exported from `fillOrderCodec` and re-exported on the `intents-helpers` sub-path, which is the entry point that +exists for tools that cannot load the full bundle. Pulling `fillOrderCodec` onto that sub-path adds no new +runtime dependency — it imports only viem, the gateway ABI, and types, all already reachable there. + +`decodeFillOrder` is deliberately not exported alongside it: it is viem-based, and the callers that need this +constant are the ones that cannot run viem. diff --git a/sdk/packages/sdk/docs/ai/decisions/2026-09-01-blocktag-is-a-parameter-of-the-balance-read-and-a-per-chain-map.md b/sdk/packages/sdk/docs/ai/decisions/2026-09-01-blocktag-is-a-parameter-of-the-balance-read-and-a-per-chain-map.md new file mode 100644 index 000000000..1945dd4ed --- /dev/null +++ b/sdk/packages/sdk/docs/ai/decisions/2026-09-01-blocktag-is-a-parameter-of-the-balance-read-and-a-per-chain-map.md @@ -0,0 +1,12 @@ +# 2026-09-01 — `blockTag` is a parameter of the balance read, and a per-chain map on the memo (#1159) + +Chosen: `getTotalSolverBalance` takes a `blockTag` defaulting to `"latest"`, and `memoizedSolverBalance` takes a +`Record`. + +Alternative rejected — a single `blockTag` on the memo. A refresh reaches across every chain a pool is quoted on, +while the event that triggered it happened on one; block numbers are per chain, so one tag applied to all of them +would read some other chain at an arbitrary point in its history. The map pins the event's chain and leaves the +rest at the head, which is the only correct reading available. + +Alternative rejected — leave every read at the head. Simpler, and it is what a periodic sweep wants, but a +per-event re-read at the head is not replayable: reindexing an old fill would stamp today's balance onto it. diff --git a/sdk/packages/sdk/docs/ai/decisions/2026-09-01-verified-v4-positions-are-reported-out-of-the-aggregation-1159.md b/sdk/packages/sdk/docs/ai/decisions/2026-09-01-verified-v4-positions-are-reported-out-of-the-aggregation-1159.md new file mode 100644 index 000000000..1650ff109 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/decisions/2026-09-01-verified-v4-positions-are-reported-out-of-the-aggregation-1159.md @@ -0,0 +1,23 @@ +# 2026-09-01 — Verified V4 positions are reported out of the aggregation (#1159) + +Chosen: `aggregatePhantomBids` returns the tokenIds it verified alongside the balances it swept, and +`readV4Position` (params object, with a `blockTag`) plus `positionAmountOfToken` are exported so a consumer can +re-value them later with the same reads and arithmetic a leg was weighted by. + +Alternative rejected — let the consumer decode the bids itself. It is possible (the indexer stores every bid's +raw payload) and was tried: everything needed is already done here once — fetch the bids, verify each signature +and delegation, decode `paymasterAndData`, check ownership on-chain — so redoing it downstream duplicates the +security-relevant half of this module, and the two copies would drift. + +Alternative rejected — have the consumer carry the last window's position VALUE forward instead of the tokenId. +It needs no new plumbing and is wrong in exactly the case that matters: simplex funds fills out of these +positions, so a fill drains the position inside the fill transaction while wallet and vault balances barely move, +and a carried value keeps advertising precisely the inventory the fill just spent. + +Positions are reported after the ownership check, not as declared. A declaration is a pointer, not a claim, and +recording an unowned one downstream would hand the fill path a position to value that the solver cannot spend. + +`solvers` is reported for the opposite reason: every other field is filtered by what the solver turned out to +hold or declare, so a verified bidder holding nothing anywhere is absent from all of them. A consumer +reconciling per-solver state ("this solver bid and declared nothing, so empty its row") cannot see it otherwise, +and that bidder is exactly the one whose inventory is all in positions it may have just stopped offering. diff --git a/sdk/packages/sdk/docs/ai/decisions/2026-09-03-the-phantom-order-lag-limit-is-read-from-the-chain-and-always.md b/sdk/packages/sdk/docs/ai/decisions/2026-09-03-the-phantom-order-lag-limit-is-read-from-the-chain-and-always.md new file mode 100644 index 000000000..d7aabc2a5 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/decisions/2026-09-03-the-phantom-order-lag-limit-is-read-from-the-chain-and-always.md @@ -0,0 +1,32 @@ +# 2026-09-03 — The phantom-order lag limit is read from the chain, and always applies + +Chosen: the poll derives its threshold from `phantomTimings()` — the pallet's `PhantomBidWindow` (or the +`PhantomOrderBidWindowBlocks` constant behind it) and `PhantomOrderInterval` — and applies it unconditionally. + +Alternative rejected — a `maxLagBlocks` option, off by default. It was written that way first, to preserve the +cursor's existing property: it advances only past blocks whose events were really read, so an outage delays +orders instead of losing them, which is what made the cursor a fix for the dropped-subscription bug and what a +consumer reading the feed as history would want. But no such consumer exists, and the feed is not history: a +phantom order is a standing invitation to bid that expires with its window. Past that window there is no caller +for whom walking the backlog is right, so the option only offered every caller a way to get it wrong — and the +one that got it wrong on mainnet would have had to opt in to be fixed. + +The property is bounded rather than abandoned: inside the limit an outage still delays orders instead of dropping +them, and the tests pin both halves. + +Alternative rejected — expose the lag and let the caller reset the poll. It moves the same decision one layer out +while making every caller reimplement the jump, and the poll would still need the head it already reads. + +Alternative rejected — a fixed number of blocks. It was 60 for one commit and it was already wrong: Nexus's +window is 15 inside a 55-block interval, so the real threshold is 70, and on Gargantua (window 5) it is 10. Both +values are governance-set and neither is derivable from the other, so any constant is wrong on some chain or +after some referendum. + +The timings are read once per instance rather than per tick: a governance change to either is rare, a request per +tick forever is not free, and the cost of caching is that a change lands on the next restart. A failed read is +not cached, so it retries, and it fails the tick rather than guessing — leaving the cursor exactly where it was. + +Removed in the same breath: `lookbackBlocks`, which started a cold cursor some blocks behind the head so a +restarting process could still bid on a window already open. That is the same late bid the age gate downstream +now refuses — a process that has just come up is, by definition, near the end of any window it reaches back for — +so the option was buying exactly the behaviour this change exists to stop. No caller set it. diff --git a/sdk/packages/sdk/docs/ai/decisions/2026-09-07-the-wallet-funded-haircut-is-the-gateway-s-protocol-fee-read.md b/sdk/packages/sdk/docs/ai/decisions/2026-09-07-the-wallet-funded-haircut-is-the-gateway-s-protocol-fee-read.md new file mode 100644 index 000000000..c64df4360 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/decisions/2026-09-07-the-wallet-funded-haircut-is-the-gateway-s-protocol-fee-read.md @@ -0,0 +1,43 @@ +# 2026-09-07 — The wallet-funded haircut is the gateway's protocol fee, read live, not a constant + +Chosen: every bid that declares no Uniswap V4 positions is haircut by the `protocolFeeBps` the IntentGateway on +the phantom order's chain reports from `params()`, read once per aggregation run. Supersedes the entry below, +made earlier the same day, which had removed the flat 5bps. + +Why: the published rate is what a taker is told they can trade against, and the gateway deducts its protocol +fee from every order's input before the solver's quote applies. A flat 5bps happened to equal that fee today; +tying it to the contract makes the published rate track the fee takers actually pay, and moves with governance +instead of with a constant someone has to remember. The pool tier stays a constant because it stands in for a +Uniswap pool fee, which is not the gateway's to set. + +Alternatives considered: + +- **Keep a constant and document that it mirrors the fee.** Rejected: the two drift silently the first time the + fee changes, and nothing in the pipeline would notice. +- **Read the fee from a fixed chain (Base) regardless of the order's chain.** Rejected: the fee that applies to + fills of this rate is the one collected by the gateway on the chain the order lives on, and the aggregation + already holds that gateway's address and RPC. Reading elsewhere would price one chain's rate with another + chain's fee if they ever differed. Today every deployment charges the same 5bps, so the outcome is identical. +- **Default to zero (or the old 5bps) when the read fails.** Rejected: a gateway address with no code is a + misconfiguration, not a zero fee, and a window priced without the haircut on the back of it publishes a + rate nobody realizes. Failing the run keeps the previous window's rate standing, visibly old, instead. +- **Memoize the fee across runs alongside balances.** Rejected: it is one static call per window and it is + governance state; a stale value here outlives a stale balance by hours. + + + +Chosen: drop `PHANTOM_QUOTE_HAIRCUT_BPS` (5bps) and `applyPhantomQuoteHaircut`. A bid that declares Uniswap V4 +positions still pays `UNISWAP_QUOTE_HAIRCUT_BPS` (10bps); every other bid is published at the amount it named. +This reverses the 2026-08-27 decision below. + +Why: the 10bps pool haircut nets out a real cost — the pool fee a pool-priced quote has not yet paid. The 5bps +wallet haircut netted out nothing: a wallet-funded solver has already paid its cost of goods and names the amount +it will actually clear, so the shade only moved the published rate 5bps off the executable one and made the +snapshot disagree with what fills at. + +Alternatives considered: + +- **Keep a smaller wallet haircut (1–2bps).** Rejected: any nonzero value re-raises the question of what cost it + represents, and there is none; the margin between quote and fill belongs to the solver's own pricing. +- **Fold the two into one constant applied to every bid.** Rejected: it would either charge wallet bids a pool + fee they never pay or under-charge pool bids, and the two tiers exist precisely because the costs differ. diff --git a/sdk/packages/sdk/docs/ai/decisions/2026-09-09-a-phantom-bid-s-paymasteranddata-is-read-as-a-bare-declaration.md b/sdk/packages/sdk/docs/ai/decisions/2026-09-09-a-phantom-bid-s-paymasteranddata-is-read-as-a-bare-declaration.md new file mode 100644 index 000000000..40aafc14a --- /dev/null +++ b/sdk/packages/sdk/docs/ai/decisions/2026-09-09-a-phantom-bid-s-paymasteranddata-is-read-as-a-bare-declaration.md @@ -0,0 +1,28 @@ +# 2026-09-09 — A phantom bid's paymasterAndData is read as a bare declaration first, and as a Permit2 sponsorship with an optional tail second + +Chosen: `decodePhantomBidPaymasterAndData` tries the bare declaration parse over the whole blob; only when that +fails does it look for a complete 234-byte Permit2-mode paymasterAndData at the front and parse whatever follows +as the declaration. Both parsers demand exact consumption. A sponsored bid with nothing appended, or with a +malformed tail, is a bid that declared nothing — the absent declaration, still counted. + +Alternatives considered: + +- Move the declaration out of `paymasterAndData` for sponsored bids (a second field, or a side channel). There is + no other free signed field: `initCode` is spoken for under EIP-7702, and anything outside the userOpHash is not + authenticated. The tail is signed for free and the paymaster never parses a phantom bid, so it costs nothing. +- Detect the sponsored shape first. A bare declaration of 234+ bytes whose byte 52 happens to be `0x02` would + then be tried as a sponsorship and, if its tail did not parse, read as "declared nothing" — a silent change to + bids that decode today. Bare-first keeps every existing bid byte-identical in behaviour; the reverse false + positive needs a paymaster address opening with `0x01`/`0x02` AND the gas-limit words and permit bytes forming + length-prefixed entries that end exactly at the blob's end, which is not a shape any packer produces. +- Recognise the 2612 permit mode (0x00, 150 bytes) as a sponsorship too. Simplex confines that mode to a + first-time delegation — its sequential nonce serialises concurrent ops — so no bid carries it, and the retired + allowance mode 0x01 is refused by the paymaster. Accepting only 0x02 keeps the decoder to layouts that exist on + the wire; a bid in another mode decodes as "none", which is the absent declaration. +- Verify the Permit2 signature, or check its deadline. Neither is a fact about the quote: the bid's authenticity + is the solver signature over the userOpHash, which covers these bytes, and a phantom bid never executes so an + expired permit changes nothing. The fields are decoded for the record and the signature is not kept. + +The encoder is strict where the decoder is lenient: `encodePhantomBidPaymasterAndData` throws unless the +sponsorship is exactly the Permit2-mode layout, because a solver that signed some other shape would have its +declaration silently read as absent — the failure a lenient decoder cannot report and a strict encoder prevents. diff --git a/sdk/packages/sdk/docs/ai/decisions/2026-09-09-no-textencoder-textdecoder-anywhere-on-the-phantom-bid-decode.md b/sdk/packages/sdk/docs/ai/decisions/2026-09-09-no-textencoder-textdecoder-anywhere-on-the-phantom-bid-decode.md new file mode 100644 index 000000000..c78fbe7de --- /dev/null +++ b/sdk/packages/sdk/docs/ai/decisions/2026-09-09-no-textencoder-textdecoder-anywhere-on-the-phantom-bid-decode.md @@ -0,0 +1,32 @@ +# 2026-09-09 — No TextEncoder/TextDecoder anywhere on the phantom bid decode path + +Chosen: the declaration codec encodes and decodes its UTF-8 chain ids by hand, in the same file, with no +dependency on `TextEncoder`, `TextDecoder`, or the `@polkadot/util` wrappers over them (`stringToU8a`, +`u8aToString`). The rule this sets: nothing `aggregatePhantomBids` runs per bid may touch either API. + +Why. The indexer runs the aggregation inside SubQuery's vm2 sandbox, and that sandbox exposes neither API as a +global; `@polkadot/x-textdecoder` then falls back to Node's `util.TextDecoder`, whose argument check +(`ArrayBuffer.isView`) does not recognise a `Uint8Array` created inside the sandbox, because it arrives as a +proxy. Injecting the host's `TextDecoder` into the sandbox does not help — the failure is the realm of the +bytes, not the presence of the API — so this cannot be fixed from the sandbox configuration side. The same +class of failure is why `extractFillDataVm2` and `recoverBidSignerVm2` exist in the indexer, and this one hid +for a month because no live bid carried a source chain until simplex started declaring every configured +chain (#1216): the empty and positions-only declarations never reach the string decode. + +Alternatives considered: + +- Add `utf8Decode` to the indexer's VM2-safe injection set (a `decodeDeclaration` parameter on the + aggregation). It would work, but it puts a second copy of a security-relevant parser downstream and leaves + the SDK's own decoder silently unusable in the one environment it is mainly run in. A pure decoder in the + SDK fixes every consumer at once and needs nothing injected. +- Decode chain ids with `String.fromCharCode` over the bytes, since state machine ids are ASCII. It would pass + today and misread the first non-ASCII id ever declared, byte by byte, into a name that matches no chain — + a silent route loss rather than a rejected declaration. Real UTF-8, with the same rejections as + `TextDecoder`, keeps the wire format exactly what it was. +- Catch the decode error and treat the bid as declaring nothing. That reads a solver's explicit source list + as "any chain" and hands it routes it never offered. + +The test that guards this bundles the shipped `intents-helpers` into one file with esbuild (as `subql build` +does with webpack) and runs the decoder inside a NodeVM configured like `@subql/node-core`'s Sandbox, because +vm2 cannot load the pnpm module graph piecemeal (ESM-only packages, dynamic imports at load time). It lives in +the indexer, which has vm2 and esbuild through `@subql/node-core` and `@subql/cli`. diff --git a/sdk/packages/sdk/docs/ai/flows/how-a-phantom-order-s-bids-become-one-price-per-leg.md b/sdk/packages/sdk/docs/ai/flows/how-a-phantom-order-s-bids-become-one-price-per-leg.md new file mode 100644 index 000000000..0b6a31c99 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/flows/how-a-phantom-order-s-bids-become-one-price-per-leg.md @@ -0,0 +1,12 @@ +# How a phantom order's bids become one price per leg + +`aggregatePhantomBids` (`src/protocols/intents/phantom-aggregation.ts`) is the whole path, run by the indexer (`handlePhantomOrderPrices.handler.ts`) and by simplex's phantom E2E test against the same code. + +1. Resolve the destination chain's RPC, EVM chain id, and `SolverAccount`. Any of the three missing means no snapshot at all — an unverified quote must never reach the price — and `fetchBidsForOrder` then pulls the bid set from the Hyperbridge node. +2. Per bid: SCALE-decode the UserOperation, pull the inner `fillOrder` out of its ERC-7821 batch (`extractFill`), and reject it unless the order in that calldata commits to the order being priced. The solver's signature is checked over the EntryPoint userOpHash (`isVerifiedSolverBid`), and a solver already counted for this order is skipped, so one bid copied under N fillers still counts once. +3. Decode the paymasterAndData declaration — accepted source chains and declared V4 position tokenIds — which the userOpHash covers, so it is as authentic as the quote. `decodePhantomBidPaymasterAndData` reads it in either shape on the wire: the bare declaration blob, or a bid built on simplex's real-bid path, which opens with the 234-byte EntryPoint v0.8 paymasterAndData for the Simplex paymaster's PERMIT2 mode (`PERMIT2_SPONSORSHIP_BYTES`) and carries the declaration after the permit. A sponsored bid with nothing appended is counted as having declared nothing (null sources, no positions). The chain ids inside a declaration are UTF-8 decoded by hand (`utf8Decode`), never through `TextDecoder`: the indexer runs this step inside SubQuery's vm2 sandbox, where that API is absent and its `util` fallback rejects sandbox-created bytes, so a decoder that reached for it threw on every bid naming a source chain. +4. **Price adjustment**: every leg amount is haircut before it competes — by `UNISWAP_QUOTE_HAIRCUT_BPS` (10bps) when the declaration names positions, otherwise by the protocol fee haircut: `protocolFeeBps` read from `IntentGateway.params()` on the order's chain once per run (`readProtocolFeeHaircutBps`, currently 5bps on every deployment). One or the other, never both. A failed or implausible fee read throws `PhantomRpcError` and the run is retried. This happens before the `solverAmount !== 0n` filter, so a quote the haircut rounds away is read as a declined leg. +5. Weight each quoted leg by the solver's deliverable inventory in *that leg's* output token on the destination chain: ERC-20 balance + redeemable vault shares (`getBalance`) plus the withdrawable side of any declared position the solver actually owns on-chain (`readPosition`, filtered by owner). Then sweep the solver's whole inventory into `lpBalances` once per bid. +6. Per leg: drop every zero-weight quote — from the median, from `bidCount`, and from `bidders` alike — and drop the leg entirely if none is left. Otherwise `weightedMedian` picks the price, and `lowestPrice`/`highestPrice` are set to that same median rather than the raw bid extremes. + +A malformed bid is skipped and the rest are priced; a `PhantomRpcError` aborts the whole run instead, because a partial bid set publishes a confident price built from whichever bids happened to be readable. diff --git a/sdk/packages/sdk/docs/ai/flows/how-a-solver-s-bid-gets-signed.md b/sdk/packages/sdk/docs/ai/flows/how-a-solver-s-bid-gets-signed.md new file mode 100644 index 000000000..8c744cd88 --- /dev/null +++ b/sdk/packages/sdk/docs/ai/flows/how-a-solver-s-bid-gets-signed.md @@ -0,0 +1,10 @@ +# How a solver's bid gets signed + +`SubmitBidOptions.solverSigner` is a `SigningAccount` (`src/types/index.ts`), supplied by the caller. `@hyperbridge/simplex` does not pass its `Signer` directly: the payload parameter types differ (`unknown` here, `TypedDataPayload` there), so its `ContractInteractionService` adapts at the call site with `sdkSigningAccount(signer)`. + +1. The caller assembles the bid and calls `prepareSubmitBid` (`src/protocols/intents/BidManager.ts`), passing the solver account, nonce, entry point, gas limits, pre-built ERC-7821 `callData`, and any `paymasterAndData`. +2. `BidManager` builds the v0.7-packed `PackedUserOperation` with an empty signature, then checks the nonce key binds the order commitment and session key (`CryptoUtils.bidNonceKey`) — a mismatch is warned about, not thrown, and fails on-chain validation later. +3. It signs `CryptoUtils.packedUserOpTypedData(userOp, entryPointAddress, chainId)` with **`solverSigner.signTypedData`** — the only `SigningAccount` member this package calls, and it passes the payload alone: the chain id the backend might need is already in `domain.chainId`. Signing the typed data rather than the digest produces the same signature `SolverAccount._rawSignatureValidation` recovers, while leaving the payload legible to a custody backend's policy engine. +4. The returned signature is prefixed with the order id (`concat([order.id, solverSignature])`) — that concatenation, not the bare signature, is what goes on the UserOperation. + +`signTypedData` is the interface's only member: `signMessage`, `signRawHash`, and `signTypedData`'s chain-id argument were all removed on 2026-08-18 as uncalled. `GasEstimator`'s `signMessage` call is viem's method on a locally derived account, unrelated to `SigningAccount`. diff --git a/sdk/packages/sdk/docs/ai/flows/how-requests-to-a-hyperbridge-node-are-paced.md b/sdk/packages/sdk/docs/ai/flows/how-requests-to-a-hyperbridge-node-are-paced.md new file mode 100644 index 000000000..a855612cb --- /dev/null +++ b/sdk/packages/sdk/docs/ai/flows/how-requests-to-a-hyperbridge-node-are-paced.md @@ -0,0 +1,11 @@ +# How requests to a Hyperbridge node are paced + +`http()` calls `limiterFor(httpUrl)`, which returns the `TokenBucket` (`src/utils/rateLimiter.ts`) for that URL's origin from a module-level map, creating it at `HYPERBRIDGE_RPC_MAX_RPS` or 8 req/s. Every coprocessor in the process pointed at the same node therefore shares one bucket, because the limit being respected counts requests per address, not per connection. + +`BatchingHttpProvider.send` queues the call rather than sending it, and flushes on the next macrotask — so everything issued in one burst travels as a single JSON-RPC batch request, up to 32 calls. One flush is in flight at a time; whatever arrives meanwhile rides the next. Each flush takes exactly one token from the bucket before its HTTP request, because a request is what the endpoint counts. A lone call is sent as a plain request object rather than a one-element array. + +The bucket refills continuously and holds at most one second's worth, so a burst of requests up to that size goes straight out and the rest are granted at the configured rate. Waiters are strictly FIFO: a caller arriving on an idle bucket queues behind anyone already waiting rather than taking their token. + +Two server refusals are handled without a call being lost. `-32005` (`--rpc-disable-batch-requests`) disables batching for the provider's life and retries the calls one per request; `-32010` (`--rpc-max-batch-request-len`) halves the batch size and retries. Both come back as a single error object where an array was expected, which is how they are told apart from a per-call error. + +Everything reaching the node over HTTP passes through it — the poll's ranged or per-block scan, `fetchPhantomOrder` (fanned out one per configured chain by simplex's `handlePhantomOrders`), `queryApi()` consumers such as simplex's `BalanceProvider`, and `sendViaHttp` when the websocket is down at signing time. That is the point: the limit applies to their sum, and this is the only place their sum exists. It also means a long queue delays them all, which is why `maxBlocksPerPoll` is small. diff --git a/sdk/packages/sdk/docs/ai/flows/how-the-phantom-order-poll-reads-hyperbridge.md b/sdk/packages/sdk/docs/ai/flows/how-the-phantom-order-poll-reads-hyperbridge.md new file mode 100644 index 000000000..e9c8e273b --- /dev/null +++ b/sdk/packages/sdk/docs/ai/flows/how-the-phantom-order-poll-reads-hyperbridge.md @@ -0,0 +1,22 @@ +# How the phantom order poll reads Hyperbridge + +`IntentsCoprocessor.pollPhantomOrders` (`src/chains/intentsCoprocessor.ts`) drives every read over the HTTP api from `http()`, never the websocket. `http()` derives the endpoint from the websocket provider's own endpoint (`deriveHttpUrl`) and builds an `ApiPromise` on a `BatchingHttpProvider` — response cache off (capacity 0), concurrent calls coalesced into one JSON-RPC batch request, and one token taken from the endpoint's `TokenBucket` per request. The connect is `isReadyOrError` raced against `HTTP_CONNECT_TIMEOUT_MS`, and a failed connect is not cached. + +Each tick, skipped if the previous one is still running or if a 429 backoff is still counting down: + +1. `chain_getHeader()` for the head. No block hash, so polkadot-js never caches it — always a live request. +2. On the first successful head read the cursor is set to `head - 1`, so the head itself is the first block scanned and nothing before it is reached back for. On every later tick, if the cursor is more than `bidWindowBlocks + max(intervalBlocks, bidWindowBlocks)` behind the head it is forced to `head - 1 - bidWindowBlocks` and `onSkip` fires with the range dropped: that backlog holds no order that can still be bid on, and the cursor gains only `maxBlocksPerPoll` a tick, so it would otherwise never return to live. Jumping to one window behind rather than to the head keeps every order that is still biddable, and lands the cursor inside the threshold so the next tick scans instead of skipping again. The timings come from `phantomTimings()` — `PhantomBidWindow` storage, or the `PhantomOrderBidWindowBlocks` runtime constant when that is zero, plus `PhantomOrderInterval` — read once per instance and cached. Then, if `head <= cursor`, the tick ends before step 3, so a quiet tick costs exactly one request. +3. `confirmedRuntimeVersion()` reads `state_getRuntimeVersion()` and returns it only if it matches the previous tick's reading (or, on the first tick, `api.runtimeVersion` from connect). A mismatch means an upgrade landed inside this tick's range and yields `undefined`. +4. `scanRangeAtOnce` takes the range `cursor + 1` … `min(head, cursor + maxBlocksPerPoll)` (default 10) in one call, when it can: it needs a confirmed version from step 3, that version to still equal `api.runtimeVersion` (the ranged reply is decoded against the connect-time registry — `state_queryStorage` declares no `isHistoric` parameter, so rpc-core does no registry swap for it), and the node not to have refused the method before. It reads the range's two bounding hashes — one batched request — then `getPhantomOrdersInRange` issues `state_queryStorage.raw([SYSTEM_EVENTS_KEY], fromHash, toHash)` and decodes each change set's value with `registry.createType("Vec", …)`. The key is computed — `twox_128("System") ++ twox_128("Events")` — rather than taken from the decorated api, and `.raw` is used so polkadot-js does no formatting: its formatted path decodes from metadata hanging off whichever accessor it was handed, and the three that look interchangeable are not (see Decisions, 2026-08-27). The cursor moves the whole way, because one call either answered for the range or threw. +5. Otherwise the poll reads block by block: `chain_getBlockHash(n)` for every block concurrently, which the provider sends as a single batched request, then `getPhantomOrdersAtHash(hash, knownVersion)` → `api.at(hash, knownVersion)` → `system.events`, one at a time and in order. The hashes parallelise safely because `chain_getBlockHash` declares no `isHistoric` parameter; **the events reads must not**, because `at(hash, version)` sets `lastBlockHash` on the shared registry and the events read then finds it by that alone, so concurrent calls would have all but one miss and re-resolve at two RPCs a block. See Decisions, 2026-08-27. Here the cursor advances per block, so a failure partway re-scans only what was never read. +6. A failure leaves the cursor where it is and fires `onError`; the next tick re-reads. A 429 additionally sets a backoff of 1, 2, 4… ticks (capped at 8), cleared by the next tick that completes. + +Two things about the ranged reply are worth knowing. It is a **diff**: `query_storage_unfiltered` in `sc-rpc` emits a change set for a block only when the value differs from the block before it, and drops an empty set, so consecutive blocks with identical events — a quiet chain, where every block carries just the timestamp inherent — collapse into one entry. That is safe only because a phantom commitment is derived from the block number, so a block that registered orders can never encode like another one; absent therefore means no orders. And the method is gated by `--rpc-methods`, refused as `Method not found`; the node here already runs unsafe RPC to serve `offchain_localStorageGet`, and a refusal switches the poll to the per-block path permanently without being reported as an error. A reply that does not decode to event records does the same, except that it is reported once — the fast path is an optimisation over a path that already works, so no way of failing it should stop the poll. + +So a tick costs **4 HTTP requests regardless of the range** — head, version, the two bounding hashes batched together, and the ranged read — against `3 + n` before the ranged read landed and `1 + 4n` originally. The per-block fallback is `3 + n`: a 10-block catch-up is 13 requests there and 4 on the ranged path. + +Step 6 is what made the provider cache dangerous: with caching on, re-reading a block whose `state_getRuntimeVersion` had rejected was answered by the cached rejection rather than a fresh request (fixed 2026-08-21). In `@hyperbridge/simplex` one `HyperbridgeScanner` owns this poll and fans `onError` out to every subscribing filler, so a single failed tick logs once from the scanner and once per filler. + +The cadence is `intervalMs` when given, otherwise `phantomPollIntervalMs()`: 6s on Gargantua, 15s elsewhere, decided by the runtime's `specName` read over the same HTTP api, falling back to 15s if that read fails. + +The skip in step 2 bounds the property this poll was built for. The cursor still advances only past blocks whose events were really read, so an outage delays orders instead of dropping them — but only while they could still be bid on. Past that, catching up is spending requests to deliver orders the pallet will refuse a bid for (`PhantomOrderBidWindowClosed`), and the lag is self-sustaining: it was a spell of rate limiting that put one mainnet filler 3,448 blocks behind, where it stayed for nine hours, bidding the whole time. diff --git a/sdk/packages/simplex/docs/ai/ChangeLog.md b/sdk/packages/simplex/docs/ai/ChangeLog.md deleted file mode 100644 index 00cdcefd2..000000000 --- a/sdk/packages/simplex/docs/ai/ChangeLog.md +++ /dev/null @@ -1,2086 +0,0 @@ -# ChangeLog - -AI-maintained log of code changes in `sdk/packages/simplex`. Every AI-assisted change appends an entry here: date, what changed, and the files touched. This is not the release changelog — `sdk/packages/simplex/CHANGELOG.md` is the published release log and is managed separately. - -Entry format: - -``` -## YYYY-MM-DD — short title (issue/PR if any) -What changed and why, in a few sentences. -Files: list of files touched. -``` - -Newest entries first. - -## 2026-09-10 — Guard the socket-mode wizard announcement for json output (#1237) - -Rebasing #1237 onto #1245 (Unix domain socket listen mode) landed the exact collision the audit of -this branch predicted. #1245 added an unguarded - - console.log(`\n No config found — the setup wizard is serving on ${uiSocket}\n`) - -on the socket wizard path. Git merged it without complaint — it sits about twenty lines from the -guarded TCP announcement, so the resolver got no signal — and `--ui-socket --log-format json` -would then emit one plain-text line into a stream a supervisor is parsing as NDJSON. That is the exact -promise `--log-format json` exists to make. - -Guarded it the same way as its TCP sibling: a `cli` record carrying a `socket` field in json mode, -the prose line otherwise. - -Worth noting for the next merge: this is now the second announcement on the wizard path, and both are -easy to add a third alongside without noticing the rule. A `console.log` reaching stdout anywhere in -`run` breaks json mode, and nothing in the suite catches it. - -Files: src/bin/simplex.ts. - - -## 2026-09-10 — `simplex --ui` with no value stopped erroring - -`run` declared the UI bind as `--ui [<[host:]port>]`. The `[...]` was meant to make the value -optional, but commander derives the two properties independently from the flags string: -`this.required = flags.includes("<")` and `this.optional = flags.includes("[")`. The placeholder -`<[host:]port>` contains a `<`, so the option came out both required and optional, and required -wins. `simplex --ui` and `simplex run --ui` each died with `error: option '--ui [<[host:]port>]' -argument missing`, even though the flag was declared and documented as taking an optional value. - -The placeholder is now `[host:port]`, which has no angle brackets. Nothing else about the option -changes — `--ui 9000`, `--ui 192.168.1.50:8686`, `--ui 0.0.0.0:8686`, `--no-ui` and passing nothing -at all parse exactly as they did, verified against the pinned commander 12.1.0 through the real -default-subcommand route. A valueless `--ui` now yields `true`, which the existing handler already -reads correctly: `uiEnabled = options.ui !== false` turns the UI on, and `typeof options.ui === -"string"` is false, so the bind stays at the default `127.0.0.1:8686`. `simplex --ui` therefore -means the same as `simplex`, said out loud, and reads as the counterpart of `--no-ui`. The -description gained one clause — "a bare port keeps the host at 127.0.0.1" — because the placeholder -no longer carries that nuance. - -The `run` flags moved into `src/cli/run-options.ts` so a test can parse the real declarations: -`src/bin/simplex.ts` calls `program.parse(process.argv)` at module load, so a test cannot import it. -`DEFAULT_UI_PORT` moved with them, and the action handler's inline options type — a hand-maintained -duplicate of the flags — is now `RunOptions`, declared beside the flags that produce it. The new -test fails against the old placeholder with `required: true`. - -Found while adding `--ui-socket` (#1237), which deliberately left `--ui` alone: its acceptance -criteria required the CLI's existing TCP behaviour to be unchanged. - -Files: src/cli/run-options.ts (new), src/bin/simplex.ts, src/tests/cli/run-options.test.ts (new), -and the flag table in the repo's docs/content/developers/evm/simplex/dashboard.mdx. -## 2026-09-09 — Audit follow-ups on the node:sqlite migration (#1236) - -A multi-lens adversarial audit of the migration commit turned up one real regression and a set of -smaller defects. All fixed here. - -**The regression: the busy timeout was silently lost.** better-sqlite3 defaults `timeout` to -5000ms; `node:sqlite` defaults it to 0. `new DatabaseSync(path)` with no options therefore turned -every lock contention into an instant `SQLITE_BUSY` throw where the old store waited five seconds. -Anything holding either file for a moment — an operator running `sqlite3 bids.db`, a backup, a -second process on the same `--data-dir` — would have failed the write racing it, and a dropped bid -write is a deposit the retraction sweep can no longer find. Both databases now pass -`PRAGMA busy_timeout = 5000`. Verified empirically: it reads 0 on a plain handle, and a contended -write now blocks ~5s before failing instead of ~28ms. - -The first fix used `DatabaseSync`'s `timeout` constructor option and was wrong — caught in review -(#1247). That option was added in v22.18.0 and v24.0.0, so it is *silently ignored* on 22.16, -22.17 and the entire 23 line, all of which `engines.node` admits; on Node 23.11 the contended write -still failed in 0.45ms. Note `@types/node` marks it `@since v22.16.0`, which is what the first fix -trusted; the version table on nodejs.org is authoritative and disagrees. The PRAGMA is plain -SQLite and works wherever `node:sqlite` does. Two tests cover it: one reads `busy_timeout` back off -the store's own connections (fast, and runtime-independent — the option-based version passes the -behavioural test on a lucky runtime), one holds a real write lock from a second connection. - -**Two guards failed unsafe on runtimes below the engines floor.** `if (!db.isOpen) continue` skips -the close entirely when the property is missing (it landed in 22.15), and -`if (this.db.isTransaction)` skips the ROLLBACK when *that* property is missing — the Node 23 line -never received it — leaving the connection wedged mid-transaction so no later write commits. Both -now compare against `false`, so a runtime lacking the property does the safe thing instead of the -convenient one. `engines` is advisory in npm and pnpm, so "our floor forbids it" is not a guard. - -**The compatibility suite was not wired into CI.** `test-sdk.yml` ran only `test:filler`, which -names four specific files, none under `src/tests/data` — so the safety net for operator databases -would never have run on a PR. Added a `test:data` script and a CI step; it needs no network or -secrets and takes seconds. - -Also: the CLI's static store import made `--help`, `--version` and `init` emit Node's SQLite -`ExperimentalWarning` on every run (Node 24.19 has dropped it, but 22.16–24.1x still emit it, and -the lazy import this migration deleted used to avoid it) — the bin shebang and the Docker -ENTRYPOINT now pass `--disable-warning=ExperimentalWarning`. The fixture generator's -`rmSync(OUT, { recursive: true })` deleted the `.gitignore` whose `!*.db` is what re-includes the -fixtures past the repo-wide `**/*.db` rule; it now removes only the database files. -`docs/.../sdk/simplex.mdx` still told readers "Node.js 22 or later" directly under the install -snippet, contradicting both `engines.node` and its own SQLite paragraph. `Decisions.md` claimed -tsup 9 had shipped and reversed the `removeNodeProtocol` default — it has not; 8.5.1 is latest. -A comment in `publish-simplex.yml` still gave "a native sqlite module" as a reason the arm64 image -needs a native runner. - -Files: src/data/sqlite/index.ts, src/data/sqlite/activity.ts, src/bin/simplex.ts, -src/tests/data/sqlite-compat.test.ts, scripts/Dockerfile, scripts/make-legacy-db-fixture.mjs, -package.json, docs/ai/Decisions.md, ../../../.github/workflows/test-sdk.yml, -../../../.github/workflows/publish-simplex.yml, ../../../docs/content/developers/sdk/simplex.mdx. - - -## 2026-09-09 — Replace better-sqlite3 with node:sqlite (#1236) - -The persistent store now uses Node's built-in `node:sqlite` instead of the `better-sqlite3` -native module. `better-sqlite3` only worked because `npm install` compiled it on each machine; -anything that ships without a compile step (a desktop bundle, most obviously) would have needed a -prebuilt copy per platform. SQLite is in the runtime, so the dependency is simply gone — along -with `@types/better-sqlite3`, the whole `optionalDependencies` block, `python3 make g++` from the -Dockerfile's builder stage, and the `ALWAYS_EXTERNAL` entry that kept tsup from inlining the -addon loader. - -Two APIs had no equivalent and were rewritten: - -- `db.pragma("table_info(...)")` — all three column-migration reads now go through a new - `columnNames(db, table)` helper in `src/data/sqlite/schema.ts`, which is what `activity.ts` - was already doing by hand with an `as any[]`. `activityDb.pragma("journal_mode = WAL")` became - `exec("PRAGMA journal_mode = WAL")`. -- `db.transaction(fn)` — `attachOrder` writes `BEGIN`/`COMMIT`/`ROLLBACK` explicitly, rolling - back only when `db.isTransaction` still reports one open (a failed COMMIT has already rolled - back, and a second ROLLBACK throws over the original error). - -Return-shape differences audited against the real runtime rather than assumed. Rows come back as -null-prototype objects (spread and property access are unaffected); `run()` yields plain numbers -for `changes`/`lastInsertRowid`; `COUNT` is a number and `SUM` is null on an empty table, which -the `|| 0`s in `stats()` already covered. Two behaviours did change and are handled: -`DatabaseSync.close()` throws on an already-closed handle where better-sqlite3 was a no-op, so -`SqliteDataStore.close()` checks `isOpen`; and reading an INTEGER above 2^53 throws instead of -silently losing precision, which no column in either schema can reach. - -`engines.node` moves from `>=22.0.0` to `>=22.16.0` — see Decisions. `openDataStore()` in the CLI -loses its lazy import and try/catch, which existed only to turn a failed native build into -readable advice. - -One bundling trap, found by running the built image rather than the test suite: tsup 8 rewrites -`node:foo` imports to bare `foo` by default (`removeNodeProtocol`). For `fs` and `path` that is -harmless, but `sqlite` has no unprefixed alias, so the shipped binary died on startup with -`Cannot find package 'sqlite'` — and both the CLI bundle and the library entry point were -affected. Both tsup entries now set `removeNodeProtocol: false`, and `scripts/build.sh` greps the -bundle afterwards, since nothing in the test suite can see this (tests run from source, where no -rewriting happens). - -Also removed the now-dead `better-sqlite3` entry from `sdk/pnpm-workspace.yaml`'s build allowlist, -and corrected `docs/content/developers/sdk/simplex.mdx` and `.../sdk/api/simplex.mdx`, which both -told readers the SQLite store needs an optional native module. - -Backwards compatibility is covered by `src/tests/data/sqlite-compat.test.ts`, which opens real -`bids.db`/`activity.db` files *written by better-sqlite3* at the pre-migration schema (committed -under `src/tests/data/fixtures/legacy-v0`, regenerated by `scripts/make-legacy-db-fixture.mjs`). -It asserts legacy rows read back, the in-place column migrations run, the retraction sweep still -finds a won bid's locked deposit, WAL survives, and writes land. Disabling the bids migration -fails five of its eleven cases, so it is not vacuous. - -Files: src/data/sqlite/index.ts, src/data/sqlite/bids.ts, src/data/sqlite/activity.ts, -src/data/sqlite/schema.ts (new), src/bin/simplex.ts, src/index.ts, tsup.config.ts, -scripts/Dockerfile, scripts/make-legacy-db-fixture.mjs (new), -src/tests/data/sqlite-compat.test.ts (new), src/tests/data/fixtures/legacy-v0/ (new), -src/tests/data/bid-store.test.ts, scripts/build.sh, package.json, ../../pnpm-lock.yaml, -../../pnpm-workspace.yaml, docs/content/developers/sdk/simplex.mdx, -docs/content/developers/sdk/api/simplex.mdx. -## 2026-09-09 — Security audit fixes for the Unix socket listen mode (#1245) - -A multi-agent security audit of the socket listen mode (8 independent lenses, findings deduped and put -through 3-lens adversarial refutation) produced two surviving findings and one ordering bug; all three -are fixed here, each pinned by a test that fails when the fix is reverted. - -**The socket is now created `0600` instead of being chmod'ed to `0600` after binding.** Binding first -and narrowing after left the file at `0777 & ~umask` — 0775 under the common `umask 002` — for roughly -a millisecond. That is not a theoretical window: Linux checks the mode at connect(2) and *never -re-checks*, so a local user who connects inside it keeps a fully privileged, unauthenticated session -for the life of the daemon — tightening the mode afterwards does not revoke an established connection. -The audit won that race in 7 of 8 attempts with a connect loop. `listenPrivate` now sets `umask 0177` -around the synchronous `listen` call, which makes libuv create the socket `0600` with no window at all; -verified directly (mode is `600` at bind, `775` without the guard). Because `process.umask` is -process-wide, it wraps only the synchronous call — libuv binds inside it, so no other JavaScript in the -process can run in between. - -The old code's comment asserted this window "cannot be closed from Node". That was wrong, and it is -corrected rather than merely superseded, since the claim is what would have kept the bug unfixed. - -`assertSocketIsPrivate` now **asserts** the mode rather than chmod-ing it. A repair there would restore -the mode only after the socket had been reachable at the wrong one — reintroducing the window it is -supposed to prove absent, and hiding the regression from the test. It also fails closed: the previous -version logged a warning and kept serving the fund-moving API on a group-writable socket. - -**`clearStaleSocket` no longer deletes whatever sits at the socket path.** The stated invariant — -"ECONNREFUSED means the file outlived its listener" — is false: connect(2) answers ECONNREFUSED for a -regular file, a FIFO and a directory too, so `--ui-socket ~/filler-config.toml` silently deleted it -(reproduced against a file holding a signer key). The type is now checked with `lstat` before any probe -or unlink, and only a socket is ever a removal candidate. `lstat` rather than `existsSync` also fixes a -DoS: `existsSync` follows symlinks, so a dangling one read as absent, nothing was cleaned up, and the -bind then failed with a bare `EADDRINUSE` naming no cause. - -**`listenProvenance` and `boundLoopback` are assigned inside the `listen` callback, not before the -bind.** Setting them first meant a rejected socket start on an already-listening server left a live TCP -listener with every connection tagged `unix` — and so exempt from the Host-header check, re-opening DNS -rebinding against `/api/send`. Reproduced against the real class. Not reachable from the CLI, which -picks exactly one transport, but `UiServer` is exported and the desktop app this feature exists for is -an embedder. `listenOnSocket` also refuses outright when the server is already listening. - -Smaller items from the same audit: the over-long-path error no longer suggests `$TMPDIR` (on Linux that -is `/tmp`, mode 1777, and this path is the daemon's address) and points at `$XDG_RUNTIME_DIR` instead; -`--ui-socket ""` is rejected rather than falling through to the TCP port the operator was avoiding; -`isWindowsPipe` is gated on the platform, so a `\\.\pipe\` string on Linux is treated as the ordinary -file it is; and the `--ui-socket` help text no longer claims owner-only access unconditionally, which -this change's own Windows research contradicts. - -Audit findings deliberately not acted on: path squatting (`--ui-socket` has no default, so there is no -well-known path to camp on, and the daemon is fail-closed on a live socket); chmod-through-symlink (the -lstat gate closes it, and the sticky bit forbids the precondition anyway); and TCP-to-socket bridging -(an operator who builds one has already converted a 0600 boundary back into an open port). Pre-existing -issues recorded, not fixed here: the server has no authentication in any mode, `serveStatic`'s traversal -guard uses a bare `startsWith`, and the stale `once("error")` handler swallows the first post-listen -server error. - -Files: src/services/server/UiServer.ts, src/bin/simplex.ts, src/tests/ui-server-socket.test.ts, -docs/ai/{ChangeLog,Decisions,Flow}.md. - - -## 2026-09-09 — UiServer can listen on a Unix domain socket (#1237) - -`UiServer.start()` now takes a listen target — `{ host, port }` as before, or `{ socketPath }` — and -`simplex --ui-socket ` selects the second from the CLI. Node's `listen(path)` speaks HTTP over a -Unix socket natively, so the route table, the handlers and the SSE stream are untouched; the daemon can -be embedded by a desktop app with no TCP port open at all. The CLI's default is unchanged: `--ui` and -`--no-ui` behave exactly as before, and `--ui-socket` conflicts with either an explicit `--ui ` -or `--no-ui` rather than silently winning. Verified at the parser level against the real commander -build: for every pre-existing argv, the parse result is identical with and without the new option. - -That check also turned up a pre-existing bug: `--ui`'s declared flags, `[<[host:]port>]`, contain a -`<`, and commander computes `required` as `flags.includes("<")`, so a valueless `--ui` was rejected -with "argument missing" and the optional-argument form had never worked. It was left out of this -change to keep `--ui` untouched, and fixed separately in #1249, which also moved the `run` flags into -`src/cli/run-options.ts`. `--ui-socket` is declared there with the rest of them. - -The socket is created `0600`. This is the point of the mode, and Node does not do it: `listen(path)` -creates the file `0777 & ~umask`, which is 0775 under the common `umask 002` — measured, not assumed — -so otherwise every member of the operator's group could connect and drive `/api/send`, an -unauthenticated token transfer. Linux and macOS both check write permission on the socket file at -connect(2), so the mode is enforced. (The first version of this change chmod'ed after binding; see the -security-audit entry below for why that was not sufficient.) - -Stale sockets are recovered rather than fatal. A `SIGKILL`ed run leaves the file behind and the next -bind fails `EADDRINUSE` (both verified against a real killed process), so the path is connect-tested -first: `ECONNREFUSED` means the file outlived its listener and is unlinked; anything that answers is a -running instance and is refused, which doubles as the single-instance lock. The path is also unlinked -on clean shutdown. Over-long paths are rejected up front with the limit and a suggested fallback — -without the guard they fail at bind with `listen EINVAL: invalid argument`, naming neither cause nor -limit. - -`http-util`'s `VIA_TUNNEL` marker is generalised into a `PROVENANCE` symbol carrying `tcp` / `unix` / -`tunnel`, stamped on sockets this process owns and therefore unforgeable in the same way. The -host-header (DNS-rebinding) check is now skipped for socket-arrived connections — there is no name to -rebind onto a socket and no browser that can open one — and is byte-for-byte unchanged for TCP. -`isTunnelled()` keeps its signature and meaning. - -Also reworded `EmbeddedSshServer`'s "No UI to serve behind the tunnel" warning, which fires whenever -the server declines a channel and therefore misreports a UI that exists but is not listening. - -New `src/tests/ui-server-socket.test.ts` (8 tests) covers the API, mutating routes, CSRF and SSE over a -socket; the host-header skip alongside proof that TCP still rejects the same Hosts; the 0600 mode and -unlink-on-stop; stale recovery and live refusal; the path-length error; and — the trap the issue calls -out — that a socket-only server still serves tunnelled connections, which arrive injected rather than -listened for. Each fix was mutation-checked: reverting it fails its test. - -Files: src/services/server/UiServer.ts, src/services/server/http-util.ts, -src/services/tunnel/EmbeddedSshServer.ts, src/bin/simplex.ts, src/tests/ui-server-socket.test.ts, -docs/ai/{ChangeLog,Decisions,Flow}.md. -## 2026-09-10 — json mode needs its own stdout `error` listener, or a broken pipe kills the filler (#1237) - -Found by auditing the branch that added `--log-format json`, and fixed on it before merge. - -`consoleSink()` returns a bare `process.stdout` in json mode. What that also removed, without anyone -noticing, was error handling: pino-pretty's `build()` ends in `pump(source, stream, destination)`, and -pump attaches `error` listeners to the destination — `process.stdout.listenerCount("error")` goes from -0 to 2 the moment `prettyStream({destination: process.stdout})` is constructed. A stream with no -listener turns a failed write into an unhandled `error` event, which Node turns into an uncaught -exception. - -The failure lands precisely on the case the flag was added for. Spawning the built CLI detached with -`stdio: ["ignore","pipe","pipe"]` and then destroying the read end — the desktop app quitting while -the filler it spawned keeps running — killed it with exit 1 and `Unhandled 'error' event`, 3/3 runs. -The same invocation without `--log-format json` survived, 3/3. `simplex run --log-format json | head` -is the same failure at a terminal. - -Fix: `if (logFormat === "json") process.stdout.on("error", () => {})`, once at module scope beside the -`logFormat` constant, because `consoleSink()` is called per writer. Swallowing matches the pretty path, -where such an error tears down pump's chain and logging goes quiet while the filler keeps filling — -`LoggerContext` already treats a broken sink as the host's problem rather than a reason to fail a fill. -Re-verified after the fix: json survives 3/3, and all five acceptance criteria still hold (`--no-open` -and `--log-format pretty` stdout byte-identical to a default run; json 2/2 valid lines, 0 ANSI bytes). - -The audit also corrected three inaccurate claims in the previous entry's docs. `init` and -`validateConfig` were named as consumers of the process-wide sink; neither resolves a logger, and the -real wizard-path consumers are `UiServer`'s `getLogger("ui")` and `setup-api`'s `getLogger("setup")`. -The Flow.md walk placed the filler's second sink before the config branch, when `startFiller` is only -*defined* there and called inside `if (configPath)`, and it omitted the `--no-ui` + no-config exit. -And "every line of stdout is one JSON object" is true of every writer this package controls but not of -the process: `@polkadot/util`'s logger routes `log` to `console.log`, and `bin/quiet.ts` patches only -`console.warn`, so a Hyperbridge runtime upgrade mid-run would print one plain-text line. The claims -are now scoped to what simplex itself writes. - -CI ran none of this. `.github/workflows/test-sdk.yml` gated simplex on `test:filler`, which pins four -network-backed files; `src/tests/cli` and `src/tests/logger.test.ts` were gated by nothing, so the -flags' own 15 tests never ran on a PR. Added a `test:unit` script over those two paths and a CI step -ahead of `test:filler`. It is network-free and takes ~20s. - -Files: src/bin/simplex.ts, package.json (test:unit), .github/workflows/test-sdk.yml, -docs/ai/Decisions.md, docs/ai/Flow.md, README.md. - - -## 2026-09-09 — `--no-open` and `--log-format json`, for running the solver under a supervisor (#1237) - -The desktop app spawns `simplex` as a detached child and captures its stdout to a log file. Two things -the CLI did unconditionally get in the way of that, so each now has a flag on `run`. - -`--no-open` suppresses the `openBrowser()` call on the setup-wizard path. The app renders the wizard in -its own window, so a system browser opening alongside it is wrong. Nothing else about that path changes: -the server still binds and the URL is still reported. This is not `--no-ui`, which turns the UI server -off entirely. - -`--log-format ` chooses how this process's logs reach stdout, and defaults to `pretty`, so -a terminal user sees what they saw before. In `json` mode `consoleSink()` returns `process.stdout` -itself and pino's NDJSON goes out untouched — no pino-pretty, so no ANSI escapes in the captured file. -The ASCII banner and the wizard's plain-text URL line are the only other writers to stdout: in json mode -the banner is dropped and the URL becomes a `cli` record carrying a `url` field. Every line of stdout is -then one JSON object. - -The format is read from raw argv by `logFormatFromArgv`, because the process-wide sink is registered -while `bin/simplex.ts` is still evaluating, before commander has parsed anything. Commander declares the -flag too, so `--help` lists it and an unknown value is rejected with the allowed choices. -`src/tests/cli/log-format.test.ts` pins the scan and checks it agrees with commander across the -invocations that reach the flag. - -Verified against the built bundle in a directory with no config. `--no-open` produced stdout -byte-identical to a default run and launched no browser (checked with a stub `xdg-open` on `PATH`). -`--log-format json` produced two records, both parsing as JSON, with zero ANSI escapes and no banner. -`--log-format pretty` was identical to passing nothing. A separate check ran two `LoggerContext`s into -one shared `process.stdout` for 4000 records and found no interleaved or partial lines. - -Rebased onto #1249, which split `run`'s option declarations into `addRunOptions` -(`src/cli/run-options.ts`). Both flags moved there, and `open` joined its `RunOptions` interface; -`logFormat` deliberately did not. The test now parses the real builder instead of a hand-copied mirror, -which is what makes the `--ui`-swallows-the-next-flag case (fixed by #1249) testable here at all — the -suite went from 15 to 22 tests, including the first coverage `--no-open` has had. - -Files: src/bin/simplex.ts, src/cli/run-options.ts, src/cli/log-format.ts (new), -src/tests/cli/log-format.test.ts (new), README.md. - -No version bump. Four simplex branches were open against #1237 at once and a bump in each would have -collided on the same field for nothing — publishing is tag-driven, so the version can be set once on -whatever lands. This is the exception to the usual "bump inside the feature PR" rule, not a change to it. -## 2026-09-09 — Deny framing of the operator UI (clickjacking) - -The UI server sent no `X-Frame-Options` and no `frame-ancestors` CSP, so any page could put the operator -UI in an invisible iframe — including a page in a phone's browser while the remote-access tunnel's local -forward is up on `127.0.0.1:8686`. The existing defences do not cover this and were never meant to: a -framing page can neither read nor script the cross-origin document, so the `X-Simplex-UI` preflight -requirement and the Host-header rebinding check both still hold, but an overlay can make the operator -click the real UI's own buttons, and those clicks are same-origin and carry the header. One click each -reaches `/api/pause`, `/api/reset-halt`, `/api/vault/sweep` and `/api/vault/redeem`. `/api/stop` sits -behind a `window.confirm`, which browsers suppress in cross-origin frames, and `/api/send` needs typed -input. - -`handle()` now sets `Content-Security-Policy: frame-ancestors 'none'` and `X-Frame-Options: DENY` on -every response, before any route can return, so rejections carry them too. Setting them with `setHeader` -rather than at each `writeHead` means the four existing header-writing sites (JSON, SSE, static, the -UI-not-built page) merge them in and a future route cannot forget them. Added a test asserting both -headers on HTML, on JSON, and on a 403. - -Files: src/services/server/UiServer.ts, src/tests/ui-server.test.ts. - - -## 2026-09-09 — Make the SSH tunnel's publickey guard fail closed - -The embedded SSH server treated a publickey request as an unsigned probe whenever *either* -`ctx.signature` or `ctx.blob` was missing (`||`), and answered it with `ctx.accept()`. That is not -neutral: ssh2's `PKAuthContext.accept()` sends PK_OK only when there is no signature, and -authenticates outright when there is one. So a request carrying a signature but no blob would have -been authenticated having never reached `key.verify()`. - -Not exploitable against the pinned `ssh2@1.17.0`: its USERAUTH_REQUEST parser builds `methodData` -with `signature` and `blob` together or not at all, and a signed request whose signature fails to -parse is a fatal protocol error that never raises an `authentication` event. The guard was therefore -resting entirely on an undocumented internal invariant of a dependency — the same kind of assumption -that produced the bypass fixed earlier the same day, where the trusted invariant was the return type -of `verify()`. The probe branch now requires *both* halves absent, and a half-populated request is -refused as malformed. - -Adds a regression test that drives the handler with each shape (signature only, blob only, neither) -and asserts the first two are rejected while an honest probe still gets its PK_OK; it fails against -the old `||` guard with `accepted: true`. - -Files: src/services/tunnel/EmbeddedSshServer.ts, src/tests/tunnel.test.ts, package.json (0.16.1). - - -## 2026-09-09 — Fix SSH tunnel auth bypass: strict signature verification + key-algorithm check - -The embedded SSH server's public-key auth accepted a forged signature. ssh2's `key.verify()` returns -`true` on success but an `Error` object (not `false`) on a critical failure such as an unsupported digest -for the key; the handler used `if (!key.verify(...))`, and `!errorObject` is `false`, so the rejection was -skipped and the client was authenticated. The Error path is reachable without any private key via -key-algorithm confusion: an attacker who knows a paired device's public key (public by design) offers that -ed25519 key blob tagged as an RSA signature algorithm — it parses as ed25519 so its fingerprint matches an -authorized device, while ssh2 drives `verify()` with a SHA-2 digest ed25519 cannot compute, which throws. -A tunnelled session has full operator privileges (`/api/send`, `/api/vault/*`), so this was a fund-drain -path over the public relay port. Reproduced end-to-end with a real ssh2 client holding no private key. - -Two-part fix in the `authentication` handler: (1) require the parsed key type to equal the declared -signature algorithm (`key.type === ctx.key.algo`; ssh2 already normalises rsa-sha2-256/512 to `ssh-rsa`), -rejecting the confusion up front and never rejecting an honest client; (2) require a strict boolean -`key.verify(...) !== true`, which closes the Error-return bypass for any key type. Added a regression test -that drives the exact forged-key exploit and asserts rejection — it fails against the old code and passes -against the fix. Also gave `FakeRelay`'s forwarded pipe `error` listeners so a rejected handshake's EPIPE -no longer surfaces as an unhandled error attributed to a later test. - -Files: src/services/tunnel/EmbeddedSshServer.ts, src/tests/tunnel.test.ts. - - -## 2026-09-09 — Prefer funded, Permit2-approved fee tokens before bootstrap (#1223) - -Fee-token selection now scans USDC then USDT for both a balance of at least one whole -token and a Permit2 allowance covering the existing $5 recommendation, scaled by each -token's decimals. Previously, funded but unapproved USDC could block approved, funded -USDT: a solver without native lost sponsorship, while one with native sent an unnecessary -USDC approval. Selection is read-only and stops at the first ready token. If neither is -ready, the first balance-qualified token retains the existing bootstrap path. An approved -token with no balance is not eligible. - -`buildSimplexPaymasterData` and `resolvePendingPermit2Approval` share this selection rule, -so delegation setup does not request a bootstrap when another funded token is already -ready. EIP-2612 bootstrap, native-funded approval, and zero-first reset behavior remain -unchanged when no ready token exists. - -Nine regression cases cover the real `buildPaymasterAndData` path with and without native, -USDC preference when both tokens are ready, balance and allowance boundaries, different -token decimals, native and permit bootstrap fallback, and delegation approval resolution. -Four cases failed before the fix; all 102 tests across seven focused files passed after it. -Lint and whitespace checks passed. Package-wide typechecking still reports unrelated -dependency/configuration errors; no on-chain testing was performed for this fix. - -Files: `src/services/paymaster/provider/simplex.ts`, -`src/tests/services/SimplexPaymaster.test.ts`, `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-08 — Audit fix: the Permit2 bootstrap has to reach already-delegated accounts - -An adversarial audit of the two entries below found the bootstrap unreachable exactly where it -was most needed. `permitBootstrap` was set in one place — the first-time delegation op — and -`setupDelegation` returns the moment `isDelegated(chain)` holds. Delegation and bootstrap are -separate facts: an account delegated by 0.15 has NO Permit2 allowance, because that release -took the 2612 branch before it ever read one and its `resolvePendingPermit2Approval` explicitly -skipped permit-capable tokens. So every already-running solver would upgrade into the state the -bootstrap exists to prevent — and with zero native, every fill, bid and sweep on that chain -would lose sponsorship with `send native dust`. That is the same trap the 2026-09-02 -`skipPermit` entry in Decisions.md records hitting in production on Base and Arbitrum, which -explicitly rejected "fund native dust on every chain" as the remedy. - -`ensurePermit2Allowance(chain)` now runs before that early return. It sends the same -permit-funded ERC-7821 `approve(Permit2, max)` op, without the authorization an already-delegated -account does not need, and both carriers share one `bootstrapCallData` encoder so they cannot -drift. It is best-effort: a failure logs a warn and leaves the first sponsored op to -`sendFundedApprove`, so a solver holding native is unaffected. - -Two more audit findings fixed. `sendFundedApprove` is now wrapped in `ensureFundedApprove`, -which dedups in-flight approvals by (chain, token, owner) — the bid path and the vault / -token-send path are scheduled independently, and routing every chain through Permit2 made the -missing-allowance state reachable on all of them, so two concurrent `writeContract` calls could -resolve the same pending nonce and drop one. And the bootstrap callData test asserted only that -two addresses appeared somewhere in the payload; it passed with the approve amount mutated to -1 wei, which would have stranded the chain permanently (`resolvePendingPermit2Approval` refuses -any non-zero allowance). It now asserts the exact bytes, and was checked against that mutation. - -The completeness critic also flagged that `signEip2612Permit` returned the signer's bytes raw -while `signPermit2Transfer` ran through `normalizeSignature65`, even though `buildPermitMode` -splits v straight out of the hex for a contract expecting v in {27,28}. Pre-existing — identical -at `ebe157c70` — but this change makes that path the sole bootstrap for every solver, so it is -normalized now too. A correct 65-byte signature passes through unchanged. - -Audit findings accepted without a code change: Optimism has a `CirclePaymaster` and no -`SimplexPaymaster`, so it degrades to native gas and an EntryPoint deposit — the intended -consequence of dropping Circle, already recorded below. A stale non-zero Permit2 allowance below -$5 still has no bootstrap route. `THRESHOLD_USD` is now dead. The `PERMIT2_DEADLINE_SECONDS` -(3600) bid expiry is not reconciled with the operator-configurable bid tenor. - -Files: `src/services/DelegationService.ts`, `src/services/paymaster/{permit,provider/simplex}.ts`. -Tests: `src/tests/services/DelegationService.ordering.test.ts` (4 new cases; the regression guard -was verified to fail against the pre-fix early return). Docs: `docs/ai/{ChangeLog,Flow}.md`. - -## 2026-09-08 — EIP-2612 comes back, scoped to the first-time delegation on a chain - -The Permit2-only change above left a hole: with the 2612 path gone, a solver holding -stablecoins and zero native could no longer bootstrap a chain at all. Permit2 prefunding -needs a standing `approve(Permit2, max)`, and installing it took a native tx. The permit -mode was the only thing that had ever covered that case. - -It is back, behind a `permitBootstrap` flag that exactly one caller sets. When -`DelegationService` finds no Permit2 allowance for the chain's fee token and that token -implements 2612, the delegation UserOp now does two things at once: it asks the paymaster -for PERMIT mode (`0x00`), and it carries `approve(Permit2, max)` in its own callData as an -ERC-7821 batch. The permit pays for the very op that installs the allowance every later op -authorizes against. One sponsored op, no native, and the account never signs a 2612 permit -again on that chain. - -The flag is a fallback, not a preference. Even with it set, an allowance that already -covers the recommendation takes the Permit2 path — the unordered nonce is strictly better -and there is nothing to bootstrap. And without it, a permit-capable token like USDC still -bootstraps via a funded approve rather than a permit, because a fill's nonce must not be a -shared counter. That is the whole point of the scoping: the delegation is the one op per -chain that provably has no concurrent sibling, so a sequential nonce costs nothing there. - -`resolvePendingPermit2Approval` now also reports `permitCapable`, which reorders -`setupDelegation`. A permit-capable token goes to the bundler first even when the EOA holds -native, since spending stablecoins beats spending native. Only a no-permit token (BNB Chain -pegged stables) still prefers the direct type-0x04 tx with the approve batched in — a native -tx is its only carrier. That probe also keeps the two approves from colliding: were the -callData approve attached on a no-permit token, the paymaster's own funded approve would land -first and turn it into the non-zero → non-zero change the USDT rule rejects. - -The deposit gate prices a bootstrap op against `VERIFICATION_GAS_LIMIT_PERMIT` (250k, -restored) instead of `VERIFICATION_GAS_LIMIT_PERMIT2` (200k), since the mode is not known -until the allowance is read. - -Files: `src/services/paymaster/permit.ts` and `src/config/abis/EIP2612.ts` (restored), -`src/services/paymaster/{index,types}.ts`, `src/services/paymaster/provider/simplex.ts`, -`src/services/{DelegationService,UserOpSender}.ts`, `src/cli/init/help-text.ts`. -Tests: `src/tests/services/{SimplexPaymaster,PaymasterSelection,DelegationService.ordering}.test.ts`. -Docs: `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-08 — Every sponsored op is authorized through Permit2; the Circle paymaster is gone - -Simplex no longer signs EIP-2612 permits. The Circle paymaster provider, the `permit.ts` signer -and the `EIP2612` ABI are deleted, and `buildPaymasterAndData` has one candidate left: the Simplex -paymaster in `PERMIT2` mode (`0x02`). The `tokenSupportsPermit` probe and `buildPermitMode` went -with them, so a permit-capable token like USDC now takes the same path as a BSC pegged stable. - -The reason is nonce shape. EIP-2612 keeps one sequential counter per owner, so two permits signed -back to back carry the same value and only one can land — a hard serialization point on the -solver's fee token, made worse by the fact that `buildPermitMode` signed a fresh permit for every -op (the standing-allowance mode `0x01` was retired from the contract, so nothing reused an -allowance). Permit2's `SignatureTransfer` nonces are an unordered bitmap: `randomPermit2Nonce` -draws 256 random bits, `_useUnorderedNonce` flips that one slot, and concurrent ops never collide. -That is the property worth having as soon as ops stop running single-file. - -Dropping Circle follows from the same decision — it is an EIP-2612-only paymaster, so there was -nothing left for it to do. Its verification/postOp constants and the `paymasterVerificationGasLimit` -override existed solely to tune it, and are removed from `PaymasterOptions`, -`SponsoredUserOpRequest` and the three call sites that passed a value. `PaymasterDataResult.type` -narrows to `"simplex" | "none"`, and `depositShortfall` loses its candidate label. The deposit gate -now prices against `VERIFICATION_GAS_LIMIT_PERMIT2` (200k) instead of the retired -`VERIFICATION_GAS_LIMIT_PERMIT` (250k), so a near-empty deposit sponsors slightly more often. - -Two consequences worth stating plainly. A solver on a chain it has never used needs native dust -once per fee token for `approve(Permit2, max)` — the txless 2612 path that used to cover a -zero-native bootstrap is gone; `resolvePendingPermit2Approval` no longer skips permit-capable -tokens, so the delegation tx batches that approve in for every token, which is the cheapest way to -pay it. And Optimism has a `CirclePaymaster` but no `SimplexPaymaster` in the SDK chain registry, -so it now falls back to native gas / the EntryPoint deposit until a Simplex paymaster is deployed -there. - -`evm/src/utils/SimplexPaymaster.sol` is untouched: mode `0x00` stays on the permissionless -contract for other integrators, simplex just never sends it. - -Files: `src/services/paymaster/{index,types}.ts`, `src/services/paymaster/provider/simplex.ts`, -deleted `src/services/paymaster/provider/circle.ts`, `src/services/paymaster/permit.ts`, -`src/config/abis/EIP2612.ts`; `src/services/{DelegationService,UserOpSender,TokenSender,FillerConfigService,ContractInteractionService}.ts`, -`src/funding/vault/VaultFundingPlanner.ts`, `src/core/{boot,filler}.ts`, `src/cli/init/help-text.ts`. -Tests: deleted `src/tests/services/CirclePaymaster.test.ts`; rewrote -`src/tests/services/PaymasterSelection.test.ts`; `src/tests/services/{SimplexPaymaster,SimplexPaymasterPermit2.probe,paymaster-reserve,DelegationService}.test.ts`, -`src/tests/pairs.test.ts`, `src/tests/strategies/fx.curve-payout.test.ts`. -Docs: `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-08 — Sends leave the wallet at the vault's floor, not at zero - -A send on Base reverted with `ERC20: transfer amount exceeds balance` inside a UserOp that the -outer transaction reported as successful (tx `0x737bdede…`, block 51039630). The batch was -`withdraw(3990.010227)` then `transfer(4000)`, against a wallet holding 9.989773 USDC — sized to -leave exactly zero. The SimplexPaymaster then debited 0.031564 USDC of gas from that same wallet -during validation, before the batch ran, so the transfer was short by exactly that and the whole -batch rolled back. The vault position was untouched (share balance identical either side of the -block), the paymaster still kept ~0.0097 USDC net, and a retry would fail the same way from a -slightly smaller wallet. - -`vaultWithdrawalCall` now takes the token's decimals and leaves headroom on top of the shortfall: -the larger of the matched vault's `minBalance` and `paymasterReserveForToken`, the helper the fill -path already uses for exactly this hazard — its own doc comment says the paymaster "pulls it from -the same wallet during validatePaymasterUserOp — before the UserOp's callData runs". TokenSender -never imported it. The reserve applies only when `userOpSender.canSponsor(chain)` is true, so an -unsponsored chain and the native-tx fallback are unaffected, and it covers the case a floor alone -cannot reach: a wallet that already covers the transfer but would be left with nothing for -validation to take. That send skipped the vault branch entirely and reverted the same way. A vault that cannot cover shortfall-plus-floor still funds the send with the bare -shortfall: a floor is a preference, not a reason to refuse a transfer the operator asked for. A -send the wallet already covers is unchanged — it does not start pulling from the vault to top -itself up. Withdraw-only vaults declare no `minBalance` and behave exactly as before, which means -they keep the original failure mode; the treasury docs now say so. - -Files: src/services/TokenSender.ts, src/tests/token-sender.test.ts (9 tests, 4 new — the first -replays the numbers from the reverted transaction), docs/content/developers/evm/simplex/treasury.mdx. - -## 2026-09-08 — A paired device can no longer manage remote access - -A second review pointed out that a tunnelled request is indistinguishable from one the operator -made at the keyboard: the embedded server dialled the UI on loopback, so `UiServer.handle` saw an -ordinary local request, and the only guards — the Host header and the constant `X-Simplex-UI` -header — are ones any client sets. A device holder could therefore `POST /api/tunnel/devices` and -pair a second key, which survives revoking the first: the revocation work from the last review -was defeated in one request. It could also repoint the relay at one it runs. - -The tunnel now hands the SSH channel straight to the UI server in-process — `deliver` on -`EmbeddedSshServer`, `UiServer.accept`, and `server.emit("connection", channel)` — instead of -dialling 127.0.0.1. The channel carries a `VIA_TUNNEL` symbol that nothing off the wire can -forge, so `handle` refuses any non-GET `/api/tunnel*` from a device, and `GET /api/tunnel` -reports `readOnly: true` so the panel renders itself read-only rather than failing on the first -click. This also removes a loopback TCP hop, and the device's real origin now reaches the HTTP -layer as `req.socket.remoteAddress`. - -Everything else a device can do is unchanged — Send, the treasury tools, pause, curve edits — -which is the documented model and Seun's call: the fix is that revoking a lost device now -actually takes everything away. - -Files: src/services/tunnel/EmbeddedSshServer.ts, src/services/tunnel/TunnelService.ts, -src/services/server/{UiServer,http-util,dto}.ts, src/bin/simplex.ts, -ui/src/operator/RemoteAccess.tsx, src/tests/tunnel.test.ts (25 tests). - -## 2026-09-08 — Audit fixes on the embedded SSH server - -An audit of the tunnel code (nine finder agents, then hand-verification with reproductions) -turned up eleven issues. No critical ones: nothing lets an unpaired key reach the dashboard. -Auth, the forward restriction, the CSRF header and the DNS-rebinding guard all held up, and the -claim that a revoked device keeps its live session was refuted by reproduction. What was wrong -was availability. - -- **`--ui ` broke remote access completely.** The panel hard-coded - `8686:127.0.0.1:` as the forward while `isTarget` accepted only the bound address, so - the device was told to open the one channel the server refuses. `connection()` now names the - real target host. -- **A UI on a non-`.1` loopback address was unreachable.** `createTunnel` collapsed every - loopback bind to 127.0.0.1 while `UiServer` bound exactly what it was given. Only wildcard - binds collapse now. -- **Three teardown paths did not hang up.** `conn.end()` sends DISCONNECT and closes our write - side only; a peer that ignores it kept the session, its protocol state and its slot in the - connection count. The auth timeout, the failure limit and `disconnectDevice` now destroy the - stream after a short grace. -- **A peer that never finished the SSH identification line was invisible and immortal.** ssh2 - raises its connection event at `onHeader`, so no per-connection timer ever armed. `inject()` - now puts the deadline on the stream itself, before ssh2 sees it. -- **The client chose the key exchange.** ssh2 offers group16/17/18 by default and negotiates by - the client's preference; group18 costs ~107ms of synchronous CPU per handshake, measured, on - the loop that fills orders — and a rekey flood needs no login at all. The server now offers - curve25519, the ECDH groups and group14. -- **Plain `zlib` was offered pre-authentication.** It starts compressing at NEWKEYS, before - auth. Only `none` and `zlib@openssh.com` are offered now. -- **`relayHostKey` was not scoped to a relay.** Changing relays carried the old pin over and - locked remote access out for good, with an error pointing at a file that path never reads. - Both the runtime config and the persisted block drop it when the relay changes. -- **The built-in relay pin matched one exact spelling.** Writing the hosted relay without its - port — which the config documents — skipped the shipped pin and fell back to trusting - whatever answered. Pins are keyed by normalised `host:port` now. -- **The first-contact pin was written before the relay proved it held the key.** ssh2 calls - `hostVerifier` during KEXDH_REPLY, before the signature over the exchange hash is checked. - The pin is committed on `ready`. -- **The panel ignored `persisted`.** Turning remote access off against an unwritable config - reported success and came back on restart — the lost-device path. -- **`inject()` assumed its stream was not a real socket.** `Object.assign` over getter-only - `remoteAddress` throws; it uses `defineProperty` now, and timer-driven destroys are wrapped - so a stream that refuses to close cannot take the process down. - -Files: src/services/tunnel/EmbeddedSshServer.ts, src/services/tunnel/TunnelService.ts, -src/services/server/UiServer.ts, src/bin/simplex.ts, ui/src/operator/RemoteAccess.tsx, -src/tests/tunnel.test.ts (24 tests, 8 new). - -## 2026-09-08 — Review fixes on the remote-access PR (#1217) - -Six review comments from @ddboy19912, plus one bug found while re-running the tests. - -- **The tunnel now connects only after the UI binds.** It used to start inside `startFiller`, - so `--no-ui` or a lost race for port 8686 left it forwarding devices to whatever else - answered on that port. `createTunnel` builds it (no network) and `startTunnel` connects it - after `uiServer.start()` resolves; a bind failure stops and drops it. -- **Neither building nor starting the tunnel can stop filling.** Both are wrapped: a failure - logs and leaves `tunnel` undefined. -- **Revoking a device now ends its live sessions.** `EmbeddedSshServer` tracks authenticated - connections per fingerprint and `disconnectDevice` hangs up on them; `tcpip` also re-checks - authorization per channel, so a revoked device opens nothing even before the hang-up lands. -- **Password and keyboard-interactive attempts count as failed logins.** They previously - bypassed `fail()` entirely. The `none` method is excluded — it is the probe every client - opens with, and counting it would have rate-limited legitimate devices out. -- **The failed-login table is swept and capped** (60s sweep, 10k sources, oldest-touched - evicted), so one-off scanner addresses cannot accumulate. -- **`known_relay` holds one line per relay address.** A single line meant moving from relay A - to B and back left A with no pin, trusting its next key blind. -- **Generated key pairs are parsed before use.** ssh2's ed25519 generator emits a pair its own - parser refuses roughly once in 256 (measured 0.4–0.6% over 5,000 pairs — a dropped leading - zero byte). For `operator_key` and `host_key` that is permanent breakage, not a transient - error, since the bad key is written to disk and reloaded every boot. `generateKeyPair` - retries up to 8 times. This was showing up as a ~1-in-3 flake across full runs of - tunnel.test.ts. - -Files: src/bin/simplex.ts, src/services/tunnel/EmbeddedSshServer.ts, -src/services/tunnel/TunnelService.ts, src/services/tunnel/keys.ts, src/tests/tunnel.test.ts. - -## 2026-09-08 — Polygon's confirmation ceiling drops from 32 blocks to 5 - -Seun reports Polygon finalizes in ~5s on average, so the built-in default's 32-block ceiling -(~64s at ~2s blocks) held $100k orders far longer than the chain needs. The max point is now 5 -blocks, ~10s, which leaves headroom over the observed finality without the old minute-long wait. -The min point (2 blocks at $1k) is unchanged, and every value between the two still interpolates. -Only the built-in default moved; a user `[confirmationPolicies."137"]` entry still overrides it. -Files: src/config/interpolated-curve.ts, docs/content/developers/evm/simplex/confirmations.mdx. - -## 2026-09-07 — 0.15.0, not 0.14.0: main took that version - -Merged `origin/main`, which released 0.14.0 for the relayer-gated paymaster work while this branch -was also sitting on 0.14.0 — both sides wrote the same string, so git merged it without a conflict -and the collision was only visible by comparing against main. Remote access ships as 0.15.0. The -only real conflicts were `docs/ai/ChangeLog.md` and `docs/ai/Decisions.md`, where both sides had -prepended entries; both sets are kept. - -Files: `package.json`, `docs/ai/{ChangeLog,Decisions}.md`. - -## 2026-09-07 — Send review: chain badge on the token, and its own bottom padding - -`.market-dialog-body` pads horizontally only, so the review's buttons sat flush against the dialog's -bottom edge; the review now supplies its own vertical padding rather than changing the shared body, -which every other dialog already compensates for in its own way. The network logo moved from a line -of its own onto the token icon as a bottom-right badge, the way wallets show it, leaving the network -as plain text beside the amount. - -Files: `ui/src/components/SendConfirmDialog.tsx`, `ui/src/styles/operator.css`, `docs/ai/ChangeLog.md`. - -## 2026-09-07 — Fix the double rule under the operator lists - -`.operator-section` draws a bottom rule and each of the tool, balance and market lists ends its -section, so a last row drawing its own border left two lines a section-padding apart — visible on -the Wallet page under Vault treasury, and on the Overview under both the balances and the markets. -Pre-existing; the last row in those lists no longer draws a border. Same defect as the remote-access -device list fixed earlier. - -Files: `ui/src/styles/operator.css`, `docs/ai/ChangeLog.md`. - -## 2026-09-07 — Action filters and pagination on the wallet ledger - -The ledger rendered up to 200 rows in one table. It now has the order history's pager and a pill row -filtering by action (all, fills, sends, sweeps, redeems), 20 rows a page, with the page resetting -when the filter changes. Both are client-side because `/api/wallet/history` returns one merged, -sorted page of wallet transactions and fills rather than a queryable table; the endpoint is -unchanged. `Pager` and `pageNumbers` moved out of `Orders.tsx` into `ui/src/components/Pager.tsx` -with a `noun` prop, so the two pages cannot drift, and it now says "1 order" rather than "1 orders". - -Files: `ui/src/components/Pager.tsx`, `ui/src/operator/{Wallet,Orders}.tsx`, `docs/ai/ChangeLog.md`. - -## 2026-09-07 — Review a transfer in a dialog instead of a browser confirm - -Send funds asked for confirmation through `window.confirm` with a one-line string, which the button -("Review transfer") had already promised more than. It now opens a summary: the token with its logo -and the amount, the network with its logo, the full recipient, the wallet balance, what is available, -and the split the sender will actually make — the wallet covers what it can and the vault covers the -rest, which is what `TokenSender` does. The vault line is a real check, not a restatement: a -withdrawal draws on one vault rather than several, so when the shortfall exceeds the largest single -vault the dialog says the transfer will fail before it is submitted. Everything is read from the -balance snapshot the dashboard already polls, so the review costs no extra call. - -Files: `ui/src/components/SendConfirmDialog.tsx`, `ui/src/operator/WalletTools.tsx`, -`ui/src/styles/{operator,responsive}.css`, `docs/ai/ChangeLog.md`. - -## 2026-09-07 — Remote access copy says "device", not "phone" - -Tablets and laptops pair the same way, so the panel and the Operations row now say device throughout. - -Files: `ui/src/operator/{RemoteAccess,Operations}.tsx`, `docs/ai/ChangeLog.md`. - -## 2026-09-07 — Make the remote-access switch move on click, not on the round trip - -The switch was controlled by the server's `enabled` flag and only moved once `PUT /api/tunnel` -returned. The dashboard shares an event loop with the filler, so any request queues behind block -scanning and vault polling: measured on a running filler, even `/health` answered in 88-328ms with -outliers over a second, and `/api/tunnel` and `/api/status` sit in the same band. The switch now -moves optimistically (1ms measured) and reconciles when the response lands; a poll arriving -mid-flight can no longer flip it back. The badge follows the same optimistic value, and polling -tightens to 1s while the state is `connecting`/`reconnecting` so it settles quickly. - -The underlying latency is process-wide and pre-existing, not specific to these routes. - -Files: `ui/src/operator/RemoteAccess.tsx`, `docs/ai/ChangeLog.md`. - -## 2026-09-07 — Fix the double rule under the device list - -`.sheet-content .card` gives every panel section its own bottom rule, and the last `.tunnel-device` -row drew one too, so two lines sat 25px apart between the device list and the pairing section. The -last row no longer draws its border. - -Files: `ui/src/styles/operator.css`, `docs/ai/ChangeLog.md`. - -## 2026-09-07 — Show the SSH connection fields in the Relay connection card - -The card showed a public endpoint, a host key and a session count; only the first two were useful and -they were half of what an SSH app asks for. It now renders the whole connection — host, port, -username, host key fingerprint, local port forward — each copyable on its own, which is how Termius -and friends want them entered. `TunnelConnectionDto` is a new shared shape returned by both -`GET /api/tunnel` (`connection`) and pairing, built once in `TunnelService.connection()`. The -post-pairing card no longer repeats the fields, keeping the key material and the app instructions; -the open-session count moved next to the device count ("2 paired · 1 connected"). - -Files: `src/services/server/dto.ts`, `src/services/tunnel/TunnelService.ts`, -`ui/src/operator/RemoteAccess.tsx`, `ui/src/types.ts`, `src/tests/ui-server-tunnel.test.ts`, -`docs/ai/ChangeLog.md`, `docs/ai/Flow.md`. - -## 2026-09-07 — Drop the relay rows from the Remote access panel - -The relay address and its host key were shown as facts alongside an editable disclosure. Neither is -something an operator acts on: the hosted relay is the default, its key is pinned in the binary, and -a self-hosted relay is configured in `[simplex.tunnel]`. The panel now shows only what a phone needs -(public endpoint, host key to pin) plus open sessions; the `PUT /api/tunnel` relay field is untouched -for config and API use. Removed the `.tunnel-advanced` and `.tunnel-relay-row` styles with it. - -Files: `ui/src/operator/RemoteAccess.tsx`, `ui/src/styles/{operator,responsive}.css`, `docs/ai/ChangeLog.md`. - -## 2026-09-07 — Remote access panel redesign, merged with main (0.13.2) - -Merged `origin/main` (UI improvements 0.13.2, paymaster skip reasons) into the branch; conflicts were -both-sides-appended docs, the version line (kept 0.14.0) and `operator.css`, where the merge dropped -one closing brace. The Remote access sheet is now `wide` and rebuilt on the dashboard's own pieces: -`card` sections with eyebrow/heading and a state `badge`, the `chain-enable-switch` toggle, a -two-column `tunnel-facts` definition list with copy buttons, the relay address behind a `details` -disclosure, a device list, and `PillTabs` for paste-vs-generate. Phone-width rules in `responsive.css` -collapse the grid and the QR/key row. Verified in a browser against the live relay: toggling on from the -panel connected and showed the leased public endpoint. - -Files: `ui/src/operator/{RemoteAccess,Operations}.tsx`, `ui/src/styles/{operator,responsive}.css`, -`docs/ai/ChangeLog.md`. - -## 2026-09-07 — Pin the hosted relay's host key by default - -`DEFAULT_TUNNEL_RELAY_HOST_KEY` (`SHA256:L6LT8Zu6Ke+k4cZLiDcUO/3EYWtH5vJXsVMPVnCy3ts`, read off the -deployed relay with `ssh-keyscan` and matched against the operator's record) is used whenever the -relay is the default and no `relayHostKey` is configured; `expectedRelayFingerprint` centralises -the precedence (config pin, built-in pin, first-contact pin). Verified end to end against the live -relay: OpenSSH operator got port 21047 and kept it across a reconnect, `TunnelService` connected -with the pin, an OpenSSH phone reached the local UI through `simplex.tunnel.polytope.technology` -with strict host-key checking, and a shell attempt was refused. - -Files: `src/services/tunnel/{TunnelService,index}.ts`, `src/tests/tunnel.test.ts`, `README.md`, -`filler-config-example.toml`, `docs/ai/*`. - -## 2026-09-07 — Remote access pairing: paste the phone's public key by default - -`TunnelKeyStore.addDevice(label, publicKey?)` accepts a pasted OpenSSH public-key line -(`normalizePublicKey`: rejects private keys and unparsable text, drops the app's comment, refuses a -key already paired) and returns no private key in that case; generation stays as the fallback. -`POST /api/tunnel/devices` takes an optional `publicKey`; `TunnelNewDeviceDto.privateKey` is now -optional. The Remote access sheet opens in paste mode with a "Generate a key pair instead" switch, -and the post-pairing panel omits the key/QR/acknowledgement when nothing secret was shown. - -Files: `src/services/tunnel/{keys,TunnelService,index}.ts`, `src/services/server/{UiServer,dto}.ts`, -`ui/src/operator/RemoteAccess.tsx`, `src/tests/{tunnel,ui-server-tunnel}.test.ts`, `README.md`, `docs/ai/*`. - -## 2026-09-07 — 0.15.0: remote access from a device through the simplex-tunnel relay - -New `[simplex.tunnel]` feature. `TunnelService` keeps an outbound `ssh2` session to the relay -(default `simplex.tunnel.polytope.technology:443`), requests a remote forward, and injects every -connection the relay hands back into `EmbeddedSshServer`: public-key auth against the paired -devices in `/tunnel/authorized_keys`, `direct-tcpip` only to the UI bind, everything -else refused, per-connection and per-source failure limits, 30s pre-auth timeout. The relay host -key is pinned from `relayHostKey` or on first contact (`tunnel/known_relay`). Reconnects with -backoff; never runs in init mode; a tunnel failure never touches filling. - -UI: `Operations > Remote access` sheet (`ui/src/operator/RemoteAccess.tsx`): status, enable -toggle, relay address, device list with revoke, pairing that shows the private key once with a -QR code and the connection details. Routes: `GET/PUT /api/tunnel`, `POST /api/tunnel/devices`, -`POST /api/tunnel/devices/revoke`; `ConfigDto.tunnel` summary. Config emission for the block. -`ssh2` added as a dependency and to tsup's external list (it probes for an optional native -binding); it is CommonJS, so the code uses default imports — named imports only work under -vitest. `qrcode` added for the UI. Not added to the CLI wizard by request. - -Verified: 13 new tests (`tunnel.test.ts` with an in-process fake relay, `ui-server-tunnel.test.ts`), -plus a smoke test against the real Rust relay with a stock OpenSSH client as the phone: page -served through `-L`, host key pinned matched, shell/other-port/stranger-key refused. - -Files: `src/services/tunnel/{TunnelService,EmbeddedSshServer,keys,index}.ts`, -`src/services/server/{UiServer,dto}.ts`, `src/bin/simplex.ts`, `src/config/filler-toml.ts`, -`src/cli/init/emit-toml.ts`, `tsup.config.ts`, `package.json`, `filler-config-example.toml`, -`README.md`, `ui/src/operator/{RemoteAccess,Operations}.tsx`, `ui/src/types.ts`, -`ui/src/styles/operator.css`, `src/tests/{tunnel,ui-server-tunnel}.test.ts`, `sdk/pnpm-workspace.yaml` -(ssh2/cpu-features build scripts declined), `sdk/pnpm-lock.yaml`. -## 2026-09-07 — Phantom bids declare the chains the filler fills on; `acceptedSourceChains` config removed - -Every phantom bid now carries an accepted-source declaration derived at bid time: every configured chain, watch-only ones included, as `EVM-` in ascending chain-id order (`acceptedSourceChainsFor` in `src/core/filler.ts`). The optional `simplex.acceptedSourceChains` TOML key is gone from the config type, its validation, the wizard's emitter, and the SDK's `FillerConfig`; `preparePhantomBidUserOp` now requires the list and always encodes a declaration, so a bid never leaves the field empty for consumers to read as "any chain". Motivated by a mainnet filler whose bids carried no declaration because the key was never set, so the indexer had no route rows for its depth. -Files: `src/core/filler.ts`, `src/services/ContractInteractionService.ts`, `src/core/boot.ts`, `src/config/filler-toml.ts`, `src/cli/init/emit-toml.ts`, `src/tests/core/accepted-source-chains.test.ts` (new), `src/tests/cli/update-run-preservation.test.ts`, `src/tests/phantom-filler.e2e.simnode.test.ts`, `../sdk/src/types/index.ts`, `docs/ai/Decisions.md`, `docs/ai/Flow.md`. - -## 2026-09-07 — Delegation batches the Permit2 approve into a direct tx before trying the bundler - -`DelegationService.setupDelegation` now resolves the pending Permit2 approval up front and, when -one exists and the EOA can pay for a set-code tx, sends the batched delegate+approve first; the -bundler path follows only if that fails or when nothing is pending or native is short. The -native-balance check moved into `nativeCoversDirectTx`, shared by the early batched attempt and -the final plain fallback. Unit tests in `DelegationService.ordering.test.ts` pin the three -orderings. - -Files: `src/services/DelegationService.ts`, `src/tests/services/DelegationService.ordering.test.ts`, -`docs/ai/Flow.md`, `docs/ai/Decisions.md`. - -## 2026-09-07 — 0.14.0: relayer-gated paymaster governance; APPROVE mode removed - -`SimplexPaymaster.onAccept` now refuses any delivery whose `incoming.relayer` is not the one -authorised relayer, checked right after `onlyHost` and before the Hyperbridge source check, so a -forged consensus proof alone can no longer reach governance (upgrades, params, withdrawals). The -relayer lives in a new storage slot 8 (`_relayer`, the gap shrinks to 48 words; slots 0 to 7 are -unchanged for the live proxies). It is armed by a fifth `initialize` argument on a bare proxy, by -the host-only `migrate(relayer)` delivered as the init data of an `UpgradeContract` request on a -proxy from before the gate (Initializable version 1 to 2, `onlyFresh` keeps `initialize` off such -a proxy), and rotated by the new `RequestKind.SetRelayer = 7`. An unset relayer leaves the gate -open; `migrate` and `SetRelayer` refuse zero. `version()` and `relayer()` views added. - -Mode byte `0x01` (a standing allowance to the paymaster) is refused with `InvalidMode(1)`; only -PERMIT (`0x00`) and PERMIT2 (`0x02`) remain. The client drops the APPROVE branch, the permit-mode -allowance short-circuit and the `approve(paymaster, $5)` bootstrap: a permit token always signs a -permit, a no-permit token needs Permit2 (bootstrapped once with `approve(Permit2, max)`), and when -Permit2 is unusable the builder throws an actionable error that `buildPaymasterAndData` demotes to -a skip reason. `VERIFICATION_GAS_LIMIT_APPROVE` is gone. - -Runtime: `pallet-intents-coprocessor` gains `RequestKind::PaymasterSetRelayer` and the -`set_paymaster_relayer` extrinsic (call index 20, refuses zero, weighed as `upgrade_paymaster`). -Deploy: `DeploySimplexPaymaster.s.sol` reads `GOVERNANCE_RELAYER` and asserts the arm; new -`DeploySimplexPaymasterImpl.s.sol` deploys an implementation only, for the governance upgrade of -the live proxies. Release ordering: publish this version only after the live proxies are upgraded -(see Decisions). - -Files: `evm/src/utils/SimplexPaymaster.sol`, `evm/script/DeploySimplexPaymaster.s.sol`, -`evm/script/DeploySimplexPaymasterImpl.s.sol`, `evm/script/SimplexPaymasterPermit2Probe.s.sol`, -`evm/tests/foundry/SimplexPaymasterTest.t.sol`, `evm/tests/foundry/SimplexPaymasterGasGriefTest.t.sol`, -`evm/tests/foundry/SimplexPaymasterPermit2ForkTest.t.sol`, -`modules/pallets/intents-coprocessor/src/{lib,types,tests}.rs`, -`src/services/paymaster/types.ts`, `src/services/paymaster/provider/simplex.ts`, -`src/services/UserOpSender.ts`, `src/tests/services/SimplexPaymaster.test.ts`, -`src/tests/services/UserOpSender.test.ts`, `package.json`, `CHANGELOG.md`, `docs/ai/*.md`. -## 2026-09-07 — The dashboard fills the viewport - -The operator view sat in a rounded, bordered card inside a padded page, capped at 150rem, so on a -wide monitor it floated with dark margins on every side. `App` now marks the shell with -`app-shell-operator` when the dashboard is showing; that strips the page padding and the width cap, -and `.operator-shell` becomes the viewport itself (100dvh, no border, radius or shadow; the tinted -background and blur stay). The layout height drops the 5rem the padding used to take -(`100dvh - 4.5rem` brandbar). The setup wizard keeps its card. The mobile override that removed the -card's border is gone since it is the default now. The side sheet (`.sheet-content`, shared by the -Environment drawer and the market sheets) widens from 32rem to 40rem so a Hyperbridge SS58 account -fits on one line beside its copy button; the wide variant stays at 58rem and mobile stays full width. -Files: `ui/src/App.tsx`, `ui/src/styles/{foundations,operator,responsive}.css`, `docs/ai/ChangeLog.md`. - -## 2026-09-06 — Copy a HyperFX solver link from a market - -Each FX market's sheet on the Overview gets a "Get link" entry that opens a dialog building the -HyperFX white-label "solver link" from the running settings: the filler's EVM address -(`status.addresses.evm`), the chosen chain, the market's first curve prices (ask as `rate` for -token0 → token1, bid as `reverse_rate`; a bid-only market flips direction with the reciprocal), -and a solver name (max 24, remembered in localStorage). The format was read from the app's bundle -(`app.hyperfx.finance/swap?wl=1&wlv=1&source&destination&from&to&rate_base&rate_quote&rate&reverse_rate&solver&solver_name`; -`from`/`to` must equal `rate_base`/`rate_quote`, path must be `/swap`). `ui/src/lib/solver-link.ts` -holds the builder and the per-market plan; `SolverLinkDialog` shows the summary and URL and copies -it. Same-asset, reference and venue-priced markets are refused with a reason. Unit-tested. -Files: `ui/src/lib/solver-link.ts`, `ui/src/components/SolverLinkDialog.tsx`, -`ui/src/operator/{OperatorMarkets,OperatorOverview}.tsx`, `ui/src/styles/operator.css`, -`src/tests/solver-link.test.ts`, `docs/ai/{ChangeLog,Flow}.md`. - -## 2026-09-06 — Simplex skip reason names each fee token's balance - -Selection logged a bare `simplex: insufficient stablecoin balance` when the solver held under one -whole token of every fee token, while the Circle branch already logged `circle: solver USDC balance -0 < 1000000`. Diagnosing a delegation that fell back to a native tx on Ethereum meant reading the -balances by hand. `buildSimplexPaymasterData` now returns the balances `selectToken` already read — -`{ insufficient: [{ symbol, balance, required }] }` in selection order — instead of `null`, and -`buildPaymasterAndData` records them as `simplex: solver USDC balance 0 < 1000000, USDT balance -0 < 1000000` (or `simplex: no fee token configured` when the chain lists neither). The builder and -`resolvePendingPermit2Approval` share a new `configuredFeeTokens` helper, which is where the token -symbols now live. - -Files: `src/services/paymaster/index.ts`, `src/services/paymaster/provider/simplex.ts`, `src/services/paymaster/types.ts`, `src/tests/services/{PaymasterSelection,SimplexPaymaster}.test.ts`, `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-05 — Backfill vault amounts on legacy ledger rows from their receipts - -Sweep and redeem rows recorded before the ledger carried amounts showed dashes. At boot (15 s -after start, once vault states have hydrated), `backfillVaultLedger` (`src/data/ledger-backfill.ts`) -lists such rows (`ActivityStore.walletTxsWithoutAmounts`), reads each transaction's receipt through -`VaultFundingPlanner.describeTransaction`, which parses ERC-4626 `Deposit`/`Withdraw` events -(added to the ABI) from configured vaults (`vaultMovementsFromLogs` in `src/funding/vault/ledger.ts`), -and writes the underlying and share amounts back (`updateWalletTx`); a receipt touching several -vaults adds a row per extra vault. Best effort like the order backfill. -Files: `src/config/abis/Erc4626.ts`, `src/funding/vault/{VaultFundingPlanner,ledger}.ts`, -`src/data/{ledger-backfill,types,memory}.ts`, `src/data/sqlite/activity.ts`, `src/core/boot.ts`, -`src/tests/ledger-backfill.test.ts`, `docs/ai/{ChangeLog,Flow}.md`. - -## 2026-09-05 — Wallet ledger: action icon, Amount in and Amount out with token logos - -The transaction history was time / chain / "order fill" / hash. Each row now leads with an action -icon coloured by kind (fill green, vault sweep and redeem blue, send amber), then Amount in (green, "+") and Amount out (red, "−") with token logos: a fill shows -the order's input received and output paid; a sweep shows the underlying out and the vault shares -in (share tokens wear a vault badge over the underlying's logo, e.g. stataUSDC over USDC; the share -symbol names the vault, so no counterparty text); a redeem the reverse; a send the token out to -the address. The sweep icon points up (sent into the vault), the redeem icon down. Chain gets its logo, the transaction is an open-in-explorer icon (hash on hover), time is clock + date. Backing this: -`VaultMovement` carries `shares`/`shareSymbol`/`shareDecimals` (`previewDeposit` for a sweep, -`previewRedeem` for a redeem; share-token ERC-20 metadata cached per vault; `previewDeposit` added -to the ERC-4626 ABI), `WalletTx` gains `tokenIn`/`amountIn` (SQLite migration adds `token_in`, -`amount_in`), boot records one row per movement, and `/api/wallet/history` returns `in`/`out` -`LedgerLeg`s plus `label`. Rows recorded before this keep the plain action label. -Files: `src/config/abis/Erc4626.ts`, `src/funding/vault/VaultFundingPlanner.ts`, `src/core/boot.ts`, -`src/data/{types,sqlite/activity}.ts`, `src/services/server/{UiServer,dto}.ts`, -`ui/src/operator/Wallet.tsx`, `ui/src/types.ts`, `ui/src/styles/operator.css`, -`docs/ai/{ChangeLog,Flow}.md`. - -## 2026-09-05 — Token amounts round to a precision that fits their size - -`formatTokenAmount` picked four fraction digits regardless of magnitude, so 19,990.9995 USDC and -27,393,956.4843 CNGN filled the amount cells. It now rounds half-up to 0 places at 10,000 and above, -2 places at 100 and above, and 4 below that (an explicit `maxFraction` still overrides), and the -order row's leg carries the full-precision amount as a tooltip. -Files: `ui/src/lib/format.ts`, `ui/src/operator/Orders.tsx`, `docs/ai/ChangeLog.md`. - -## 2026-09-05 — Orders another solver filled read "Outbid", not "Lost" - -The amber "Lost · filled by 0x…" status read as a fault. It is now a neutral "Outbid" badge (same -tone as Detected) with the winner's short address beneath in mono, full address on hover. Chosen -from three mocked options; the maintainer declined a link on the winner. -Files: `ui/src/operator/Orders.tsx`, `ui/src/styles/operator.css`, `docs/ai/{ChangeLog,Flow}.md`. - -## 2026-09-05 — Sidebar pages have URLs that survive a reload - -Each dashboard page has a path (`/`, `/orders`, `/wallet`, `/operations`). `ui/src/lib/route.ts` -holds the map and a `useTabRoute` hook that reads the path on load, pushes a history entry on -navigation and follows back/forward; `Operator` uses it in place of its tab state. No server change: -`serveStatic` already returns index.html for any path that is not a file. Paths stay single-segment -because index.html loads assets relatively. -Files: `ui/src/lib/route.ts`, `ui/src/operator/Operator.tsx`, `docs/ai/{ChangeLog,Flow}.md`. - -## 2026-09-05 — Bids as one column of arrow links; referrer without a copy button - -The "Bid placed" and "Retracted" columns became a single "Bids" column holding two icon links for -the latest bid: an up arrow to the bid extrinsic and a down arrow to the retraction extrinsic on -Statescan (green and red respectively), each with time and short hash in the tooltip; a missing one -renders as a grey arrow, a failed bid as "Failed" with its error on hover. The referrer cell is plain text (full tag on hover) -instead of a copy control. -Files: `ui/src/operator/Orders.tsx`, `ui/src/styles/operator.css`, `docs/ai/{ChangeLog,Flow}.md`. - -## 2026-09-05 — Order rows link only to HyperFX - -Dropped the placement- and fill-transaction explorer links from each order row; the HyperFX order -page already shows both, and the two extra arrow icons crowded the row. The links cell keeps the -single HyperFX link. `explorerTxUrl` and the per-row fill lookup are gone with them. -Files: `ui/src/operator/Orders.tsx`, `docs/ai/{ChangeLog,Flow}.md`. - -## 2026-09-05 — Widen the dashboard container to 150rem - -Two nested caps limited the UI: `.app-container` at 80rem (1280px) for everything, and inside it -`.operator-container` at 96rem for the dashboard, which is the one that bound on wide screens and -left the order history about 900px of usable width after the 16rem sidebar and column padding. -Both are now 150rem (2400px); the setup wizard shares the outer container and widens with it. -Files: `ui/src/styles/{foundations,operator}.css`, `docs/ai/ChangeLog.md`. - -## 2026-09-05 — 0.13.1: pick up the 2026-09-05 mainnet SolverAccount from sdk 2.8.11 - -No code change in this package. The filler reads the SolverAccount per chain from the sdk chain -config, which now points every mainnet chain at `0x7cb55539d1144F62422099c3FA3405092022c88C` -(PR #1207). Bumped together with the sdk so the published simplex resolves the matching sdk. - -Files: `package.json`, `docs/ai/ChangeLog.md`. - -## 2026-09-05 — Close the DNS-rebinding bypass in the UI server's loopback Host check - -`isLoopbackHost` decided loopback with `host.startsWith("127.")`, a string-prefix test on a -hostname. A leading-digit DNS label is legal, so `127.0.0.1.evil.com` (and `127.evil.com`, -`127.0.0.1.nip.io`, the bare `127.`) passed it. That predicate is the whole of the UI server's -auth — `hostHeaderAllowed` delegates to it on the `boundLoopback` path, and there is no -Origin/CORS check — so a page an operator visits could rebind DNS to `127.0.0.1`, become -same-origin, and reach `POST /api/send` (drains the solver wallet + vault positions) and the -unmasked keys in `GET /api/chains`. Now the host must parse as an IPv4 literal via `node:net` -`isIP` before its `127.` octet is trusted; `localhost`, `::1`, and the IPv4-mapped `::ffff:127.0.0.1` -stay allowed. The same change fixes the `--ui 127.x.evil.com` bind-gate bypass (same function) and -replaces the non-loopback branch's `hostname.includes(":")` IPv6 test with `isIP`, which also -rejects a stray non-numeric port like `evil.com:abc`. Extended the rebinding test with the bypass -vectors; it fails on the old prefix code and passes now (60/60). -Files: src/services/server/http-util.ts, src/tests/ui-server.test.ts. - -## 2026-09-05 — Rename the Activity page to Orders - -The sidebar tab, page title and component are now "Orders" (`ui/src/operator/Orders.tsx`, tab -value `orders`); the API paths under `/api/activity/*` and the `ActivityRecorder` are unchanged. -Earlier entries below refer to the file by its new name. -Files: `ui/src/operator/{Operator,Orders}.tsx`, `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-05 — Runtime controls live on the Overview page - -The "Runtime controls" header button and its sheet are gone; the Overview renders the state line -("Filling is active" / "New fills are paused") with the Pause/Resume and Stop buttons beside it, -directly under the metrics strip. `OperatorOverview` takes a `runtime` prop from `Operator`. -Files: `ui/src/operator/{Operator,OperatorOverview}.tsx`, `ui/src/styles/{operator,responsive}.css`, -`docs/ai/{ChangeLog,Flow}.md`. - -## 2026-09-05 — Persist live phantom bids so a restart retracts them - -Each phantom interval's batch retracts the previous interval's bid on that chain, refunding its -0.01 BRIDGE deposit — but the previous commitment lived only in `IntentFiller`'s memory, so the -first batch after every restart carried no retraction and stranded a deposit (the account had -0.5 BRIDGE reserved from fifty of them). `RuntimeState` gains `phantomBids` (chain → commitment); -the filler takes a `StateStore` and persists the map whenever a phantom bid lands or is pooled -(`rememberPhantomBid`), boot seeds it with `restorePhantomBids(restoredState.phantomBids)` before -`start()`, and `livePhantomBids()` exposes it. `StateStore.set` replaces the whole record, so all -writers now go through `patchRuntimeState` (`src/data/state.ts`), including pause/resume in -`Simplex` and the CLI's `setPaused` — a pause no longer wipes the phantom bids. Added tests for -persistence, restore precedence and merge-safe pauses. -Files: `src/data/{state,types}.ts`, `src/core/{filler,boot}.ts`, `src/simplex.ts`, -`src/bin/simplex.ts`, `src/tests/phantom-bid-persistence.test.ts`, `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-05 — Retracted column only for retractions that went on chain - -A bid the pallet no longer holds (`BidNotFound`: our fill consumed it, or it never landed) is -marked retracted with a null extrinsic hash; the Retracted column showed a bare time for it. It now -shows a dash unless `retractExtrinsicHash` is set, so every entry in that column links to a real -retraction on Statescan. -Files: `ui/src/operator/Orders.tsx`, `docs/ai/{ChangeLog,Flow}.md`. - -## 2026-09-05 — Bid placed and Retracted columns link to the Hyperbridge explorer - -The Bids summary cell became two columns: "Bid placed" (time of the latest bid and its extrinsic -hash linking to `https://.statescan.io/#/extrinsics/`; a failed bid shows -"Failed" with its error) and "Retracted" (retraction time and its extrinsic link, or a dash). -`OrderHistoryDto` gains `network` (from a shared `runningNetwork` helper in `UiServer`) so the UI -picks the explorer; `BidDto` declares the `retractedAt` and `retractExtrinsicHash` fields the API -already returned. Also settles bids and retypes legacy bid-time "filled" rows at boot (the -settlement pass added to `backfillOrderSummaries`, keyed on `volumeUsd` being set only on bid-time -rows), using `ActivityStore.unsettledOrders` / `retypeLegacyBid`. -Files: `src/services/server/{UiServer,dto}.ts`, `src/data/{backfill,types,memory}.ts`, -`src/data/sqlite/activity.ts`, `src/core/boot.ts`, `src/tests/{ui-server,activity-backfill}.test.ts`, -`ui/src/operator/Orders.tsx`, `ui/src/lib/format.ts`, `ui/src/styles/operator.css`, -`docs/ai/{ChangeLog,Flow}.md`. - -## 2026-09-05 — A bid is not a fill: settle orders from the on-chain OrderFilled log - -Under solver selection the filler emits `orderFilled` when Hyperbridge accepts its bid, so the -history called every bid "Filled" — including one a rival then filled. `orderFilled` now carries -`commitment`; the recorder ignores it for bids and records the accepted bid from `orderExecuted` -as a new `bid` activity type (txHash = extrinsic hash). `EventMonitor.handleFill` emits a new -`orderFillObserved` `{ commitment, filler, chainId, txHash, ours }` for every OrderFilled log on a -configured chain (`ScannedFill.transactionHash` added, from the log); the recorder records `filled` -when `ours`, and `lost` (reason = the winner's address) when not, but only for orders it has rows -for (`ActivityStore.knowsOrder`). Public `Simplex` events gain `order:fill-observed`. The UI status -now reads Filled > Lost ("filled by 0x…") > Bid placed / Bid retracted > Executed/Failed > Skipped > -Detected; the Bids cell shows the latest bid's standing (Accepted / Retracted / Failed, no counts) -with its extrinsic hash or error; the fill link uses the observed fill's tx hash (or a direct -attempt's UserOp hash, which the explorer's /tx page resolves) and never a bid's extrinsic hash. -Rows recorded before this change keep their bid-time "filled" rows. -Files: `src/scanner/{types,chain-scanner}.ts`, `src/core/{event-monitor,filler}.ts`, -`src/simplex.ts`, `src/data/{types,recorder,memory}.ts`, `src/data/sqlite/activity.ts`, -`src/services/server/dto.ts`, `src/tests/activity-recorder.test.ts`, -`ui/src/operator/Orders.tsx`, `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-05 — Watch-only orders are recorded as skipped at intake - -`IntentFiller`'s intake queue checks the destination's watch-only flag before `evaluateOrder` and -returned after a debug log, so a watch-only order reached the activity feed as "detected" with no -reason; `evaluateOrder`'s own watch-only check, which records `orderSkipped` with reason -"watch-only", never ran for it. The intake check now logs at info and emits the same skip. Found -while explaining why a paused-then-resumed watch-only instance never bid on a Base order. -Files: `src/core/filler.ts`, `docs/ai/{ChangeLog,Flow}.md`. - -## 2026-09-05 — Drop the volume/profit line under a Filled status - -The Filled badge no longer carries "$1 · +$0.01" beneath it; the amount columns already show what -was filled and the maintainer asked for the line to go. Skips and failures keep their reason. -Files: `ui/src/operator/Orders.tsx`, `docs/ai/{ChangeLog,Flow}.md`. - -## 2026-09-05 — Paginate the order history and fold each order's bids into its row - -Added `GET /api/activity/history?page&pageSize` (`OrderHistoryDto`): one page of orders, newest -activity first, each with its rows and the Hyperbridge bids submitted for its commitment (bid -`commitment` equals the order id), plus the newest order-less events (rebalances) for the first -page's footer. Backed by `ActivityStore.orderHistory(page, pageSize)` (SQLite: `GROUP BY order_id -ORDER BY MAX(id)` with `COUNT(DISTINCT order_id)`; memory mirrors it) and `BidStore.byCommitments`. -`Orders.tsx` now pages (20 per page, numbered pager with ellipses, "Showing x–y of n"), re-reads -the current page on SSE activity (400 ms coalesced) instead of merging rows client-side, and shows a -Bids column ("2 bids · 1 accepted · 1 retracted", tooltip listing each bid) in place of the separate -Submitted bids table; the bid metrics strip stays, with pending retractions as a badge. `OperatorContext.bids` -gains `byCommitments`; the test operator now wires `bids`. Added a ui-server test for paging and -bid folding. -Files: `src/data/{types,memory,recorder}.ts`, `src/data/sqlite/{activity,bids}.ts`, -`src/services/server/{UiServer,dto}.ts`, `src/tests/ui-server.test.ts`, -`ui/src/operator/Orders.tsx`, `ui/src/types.ts`, `ui/src/styles/operator.css`, -`docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-05 — Backfill order details on old activity rows from the indexer; decode referrer names - -Rows recorded before order summaries existed showed only an id. `src/data/backfill.ts` now runs at -boot (fire-and-forget) and, for the newest 500 distinct order ids without a summary, queries the -Hyperbridge indexer's `iOrderV3s` entity (the SDK's `ORDER_STATUS` query targets `orders`/ -`orderPlaceds`, which this indexer no longer serves), builds the same `OrderSummary` the recorder -would have, attaches it to every row of that order (`ActivityStore.attachOrder`, new alongside -`orderIdsMissingSummary`; SQLite and memory implementations), and re-emits the rows through the -recorder so open dashboards refresh over SSE. The endpoint defaults per network -(`DEFAULT_INDEXER_URLS`: nexus for mainnet, gargantua for testnet) and can be overridden with -`simplex.indexerUrl`, which `emit-toml` preserves. The referrer is now stored as the full 32-byte -graffiti tag: apps write their name as padded ASCII (the live indexer returns "HyperFX" that way), -so `ui/src/lib/format.ts` `describeReferrer` renders printable tags as text, address-shaped tags as -a short address, and anything else as short hex. Added tests for the backfill (indexed order -attached to every row, unknown order left alone, indexer failure touches nothing). -Files: `src/data/{backfill,recorder,types,memory}.ts`, `src/data/sqlite/activity.ts`, -`src/core/boot.ts`, `src/config/filler-toml.ts`, `src/cli/init/emit-toml.ts`, -`src/tests/{activity-backfill,activity-recorder}.test.ts`, `ui/src/lib/format.ts`, -`ui/src/operator/Orders.tsx`, `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-05 — Order history: amounts, tokens, chains, user, referrer, and links per order - -The Activity page's order feed showed only a time, a truncated id and an event badge. Each activity -event now carries an `order` summary (`OrderSummary` in `src/data/types.ts`): user, source and -destination chain, placement tx hash, referrer (the order's graffiti tag, 20 bytes, null when -absent or equal to the placer, mirroring the indexer's rule), input and output legs (token address, -raw amount, symbol, decimals) and deadline. The scanner passes `graffiti` from the `OrderPlaced` -log through `ScannedOrder` and the monitor's `newOrder` event; `ActivityRecorder` builds the -summary on detection (token symbol from the asset registry, decimals via -`ContractInteractionService.getTokenDecimals`, both injected from `boot.ts` as `describeToken`), -caches it per order id, and attaches it to the order's later filled/executed/skipped rows. SQLite -gains an `order_json` column by migration; the memory store mirrors it. The UI groups events per -order and renders a HyperFX-style history table (referrer, status with detail, amount in/out with -token icon + chain badge, user, placed time and date, links to the HyperFX order page and to the -placement and fill transactions on the block explorers). Rebalance events list below the table. -Files: `src/scanner/{reconstruct,types}.ts`, `src/core/{event-monitor,boot}.ts`, -`src/data/{types,recorder,memory}.ts`, `src/data/sqlite/activity.ts`, `src/services/server/dto.ts`, -`ui/src/operator/{Activity,Operator}.tsx`, `ui/src/lib/format.ts`, `ui/src/types.ts`, -`ui/src/styles/operator.css`, `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-05 — Move Send funds and Vault treasury to the Wallet page - -Split `Operations.tsx`: the Send sheet, the vault treasury sheet, their save/sweep logic and -helpers now live in `ui/src/operator/WalletTools.tsx`, rendered by `Wallet.tsx` above the -transaction history under a "Funds" heading; `OperationLink` moved to -`ui/src/components/OperationLink.tsx` so both pages share it. Operations keeps the allowlist and -chain sheets and accepts `initialPanel`/`onInitialPanelShown`, which `Operator` uses to open the -Chains sheet when the vault editor's Enable chain link is clicked from the Wallet page. Nav and -page copy updated (Wallet: "Funds and history"; Operations: "Live configuration"). -Files: `ui/src/operator/{Operator,Operations,Wallet,WalletTools}.tsx`, -`ui/src/components/OperationLink.tsx`, `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-05 — Group the treasury editor per chain, fold chains that are not enabled - -Rebuilt `VaultRowsEditor` around per-chain groups: enabled chains first, each with a header (chain -logo, vault count, "Enabled" pill) and compact token-only rows; custom vaults live inside their -chain's group. Chains the filler does not run fold into one "Other networks" collapsible whose -groups carry a "Not enabled" pill and an "Enable chain" link (`onEnableChain`; the dashboard opens -the Chains & endpoints sheet, the wizard jumps to the Chains step through a new `goToStep` on -`StepProps`). The per-row disabled hint and the `disabledHint` prop are gone. The dashboard drawer -lost its duplicate heading; it now shows a summary line of connected vaults with Sweep now / Redeem -all beside it, and the restart caveat only when the filler booted without a vault venue. Layout -chosen from a design canvas of three options. -Files: `ui/src/components/VaultRowsEditor.tsx`, `ui/src/operator/Operations.tsx`, -`ui/src/wizard/{Wizard.tsx,steps/Treasury.tsx}`, `ui/src/styles/{treasury,responsive}.css`, -`docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-05 — Show curated vaults for every chain in the treasury editors - -The dashboard's Vault treasury drawer and the wizard's Treasury step now list the registry's Aave -stata (and other curated) vaults for every chain on the running network. `UiServer.knownVaultCatalog` -returns the catalog for all `INIT_CHAINS` on that network instead of only running chains; -`VaultRowsEditor` orders enabled chains first and renders the others locked (disabled checkbox, -`data-disabled`, hint naming where to enable the chain: Chains & endpoints, or the wizard's Chains -step). Select all only covers selectable rows. The minimum-balance tooltip's paymaster note now -covers USDT as well as USDC. Added a ui-server test for the widened catalog. Cross-checked the -registry against the indexer's `yieldVaults` (identical where they overlap) and the Aave address -book: Aave v3 Base lists no USDT, so there is no Base stataUSDT to add. -Files: `src/services/server/{UiServer,dto}.ts`, `ui/src/components/VaultRowsEditor.tsx`, -`ui/src/operator/Operations.tsx`, `ui/src/wizard/steps/Treasury.tsx`, `ui/src/styles/treasury.css`, -`src/tests/ui-server.test.ts`, `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-05 — Reword the vault "Minimum wallet balance" tooltip - -The tooltip claimed the wallet float keeps liquidity available for fills, but fills draw from the -vault position atomically; the float is simply what Simplex never sweeps into the vault. Rewrote it -to say that, and added a USDC-only sentence noting USDC also pays paymaster gas, since that is the -real reason to hold USDC back. Requested by the maintainer while reviewing the redesign. -Files: `ui/src/components/VaultRowsEditor.tsx`, `docs/ai/ChangeLog.md`. - -## 2026-09-05 — Use the Hyperbridge favicon in the Simplex web UI - -Replaced the HyperFX favicon with a copy of the docs site's Hyperbridge favicon so the browser tab -shows the Hyperbridge mark. Bumped the service worker precache name (`simplex-shell-v4` → -`simplex-shell-v5`) so existing installs refetch `favicon.ico` instead of serving the old icon. -Files: `ui/public/favicon.ico`, `ui/public/sw.js`, `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-04 — Say why a vault sweep did nothing, and restore the restart notice for vault saves - -Diagnosed on a running solver: the periodic sweep ran every five minutes against a wallet holding -165k cNGN over a threshold of 10, and never deposited. The ycNGN vault is a `StreamingYieldVault`, -whose `maxDeposit` returns 0 while a tranche vests (22h of every 24h cycle); the sweep clamped the -deposit to 0 and `continue`d with no log line, and the dashboard's Sweep now button reported -"Sweep executed" on the same silent path. - -`VaultFundingPlanner.sweepExcessToVault` now returns a `VaultSweepResult`: the batches it submitted -(with per-vault deposit amounts) and every vault it skipped with a reason — `sweeping-disabled`, -`below-threshold`, or `deposits-closed` with the wallet balance, threshold and `maxDeposit` it saw. -A `deposits-closed` skip logs a warning the first time per closure and debug on every repeat until -a deposit goes through or the venue is reconfigured. `VaultLiquidityState.refresh` reads -`maxDeposit(solver)` alongside `maxWithdraw`, so the balance snapshot carries `acceptsDeposits` per -vault and the overview shows "Deposits closed" under the asset's In vault figure. - -`POST /api/vault/sweep` returns the pass as `VaultSweepDto` with amounts formatted in token units. -The vault panel turns that into one sentence — what was deposited, or which vault refused and how -far over its threshold the wallet is, or that balances are simply below their triggers. - -Vault saves: the 2026-09-03 change that dropped the `restartNeeded` handling is reverted in -substance. The server sends `restartNeeded: true` only when the filler booted without a vault venue; -the rows are persisted but nothing in the process uses them until a restart, so the panel now shows a -warning notice and toast saying so, and the hint copy once again says edits re-hydrate the running -venue "after a restart" when no venue exists. - -Also fixed the branch's declaration build: `InitChainMeta` gained the optional `note` the setup API -and terminal wizard were already reading. - -Files: `src/funding/types.ts`, `src/funding/vault/{VaultFundingPlanner,VaultLiquidityState}.ts`, -`src/services/BalanceProvider.ts`, `src/services/server/{UiServer,dto}.ts`, `src/simplex.ts`, -`src/index.ts`, `src/cli/init/chains.ts`, `ui/src/types.ts`, -`ui/src/operator/{Operations,OperatorOverview}.tsx`, `ui/src/styles/{controls,operator}.css`. -Tests: `src/tests/funding/vault.test.ts`, `src/tests/ui-server.test.ts`. Docs: -`docs/content/developers/sdk/{simplex,api/simplex}.mdx`, `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-04 — Link successful sends to their block explorer - -The Send funds success row now renders the transaction hash and an external-link icon as one link -that opens the selected network's block explorer in a new tab. The explorer URL is captured with the -completed send so changing the form's network afterward cannot redirect the prior hash to the wrong -chain. - -Files: `ui/src/operator/Operations.tsx`, `ui/src/styles/operator.css`, and -`docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-03 — Correct vault save feedback - -Vault saves no longer turn a successful persisted response into an error instructing the operator to -restart the filler. A persisted save now emits a clear success toast, while the vault editor limits -its explanatory copy to the configuration persistence it can accurately promise. Runtime, server, -and vault lifecycle behavior are unchanged. - -Files: `ui/src/operator/Operations.tsx` and `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-03 — Preserve vault edits made during a save - -Vault saving now queues one latest-draft retry when the operator clicks Save again while an earlier -request is still in flight, so the shared action guard cannot silently discard newer values. The UI -also treats `persisted: false` as a save failure and renders the result inside the open vault drawer. - -Files: `ui/src/operator/{Operations.tsx,Operations.test.tsx}` and -`docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-03 — Show selected-asset liquidity in Send funds - -The Send funds form now displays the selected chain and asset's available balance beside the Amount -label. ERC-20 values reuse the canonical wallet-reserve and vault-aware balance snapshot, native gas -uses the chain's native balance, unavailable reads remain explicit, and a successful transfer triggers -an immediate dashboard refresh. - -Files: `ui/src/operator/{Operator,Operations}.tsx`, `ui/src/styles/operator.css`, and -`docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-03 — Remove obsolete BSC gas warnings from the Simplex UI - -Removed the chain-card warning renderer from setup and operator network screens, deleted the obsolete -chain-note metadata, and removed the BNB-specific native-gas text from the setup review. BSC paymaster -support is now represented consistently throughout the Simplex UI; runtime paymaster behavior is -unchanged. - -Files: `src/cli/init/chains.ts`, `ui/src/{operator/Chains.tsx,wizard/steps/{Chains,Review}.tsx}`, and -`docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-03 — Give the shared operator sheet complete motion - -Kept the shared shadcn-style Radix sheet used by every operator drawer and replaced its mount-only -effect with state-aware motion. The panel now enters from fully off canvas, exits before Radix removes -the portal, and coordinates both directions with an overlay fade; reduced-motion users receive the -same state change without animation. - -Files: `ui/src/styles/operator.css` and `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-03 — Show current market prices in the operator list - -Operator market rows now show the first valid configured buy and sell prices with their price unit. -Missing sides, venue-priced markets, and reference-only entries remain free of fabricated values; the -underlying market data and pricing behavior are unchanged. - -Files: `ui/src/operator/OperatorMarkets.tsx`, `ui/src/styles/{operator,responsive}.css`, and -`docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-03 — Seed USDC and USDT markets for CNGN - -The setup wizard now seeds both USDC/CNGN and USDT/CNGN markets when CNGN and USDT are available in -the selected network's token catalog. Networks without that catalog combination retain the existing -single-market default, and user-created or existing markets are unchanged. - -Files: `ui/src/wizard/state.ts`, `ui/src/wizard/strategies/useStrategiesModel.ts`, and -`docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-03 — Recompose operator market drawers - -Reworked the shared drawer spacing and live-market editor around the onboarding UI's open editorial -hierarchy. Market identity, risk limits, pricing directions, and actions now flow as flat sections -separated by restrained rules instead of nested cards; only the price chart retains a quiet visual -canvas. Order-limit controls align on one baseline, each direction uses the full width, previews and -point inputs share a balanced desktop row, and disabled sides use an inline action. Create-market and -standard operator drawers inherit the same header spacing and overflow-safe shell. - -Files: `ui/src/operator/markets/StrategyMarketEditor.tsx`, -`ui/src/styles/{operator,responsive}.css`, and `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-03 — Make new web markets uncapped by default - -New markets created from the operator form or setup wizard now leave the optional maximum-order field -blank, which emits no `maxOrderSize` and therefore creates an uncapped market. - -Files: `ui/src/operator/markets/{CreateMarketForm.tsx,useCreateMarket.ts}`, `ui/src/wizard/state.ts`, and -`docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-03 — Show configured market prices in setup summaries - -Updated setup-wizard market rows to show the first configured Buy and Sell prices, including their -`token1/token0` unit, as soon as either curve has a value. The order-cap summary was removed from the -row while the cap input remains available in the Configure editor. - -Files: `ui/src/wizard/steps/Strategies.tsx`, `ui/src/styles/{markets,responsive}.css`, and -`docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-03 — Remove the BSC paymaster warning from network setup - -Removed the stale BSC and BSC Chapel paymaster caveats from the onboarding chain catalog so selecting -those networks no longer displays the native-gas warning in the network setup step. Runtime and review -funding behavior remain unchanged. - -Files: `src/cli/init/chains.ts` and `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-03 — Improve dashboard token balance cards - -Reworked each network's token balances into image-led cards using the existing Simplex token asset -library. The new hierarchy separates total ownership from wallet and vault balances, highlights the -amount currently available to fill, and keeps partial or unavailable data visibly distinct without -presenting it as zero. Network gas remains visible in a compact network header. - -Files: `ui/src/operator/OperatorOverview.tsx`, `ui/src/styles/{operator,responsive}.css`, and -`docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-03 — Clarify setup delegation failures - -Setup now translates the internal all-chain EIP-7702 shutdown message into network-aware funding, -RPC/bundler, retry, and image-version guidance while retaining raw messages for unrelated startup -failures. The failed-state action is labelled Retry startup and makes clear that the configuration -was already saved. - -Files: `ui/src/wizard/startError.ts`, `ui/src/wizard/steps/Review.tsx`, -`src/tests/setup-completion.test.ts`, and `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-03 — Installable offline UI and complete dashboard liquidity accounting - -Made the Simplex UI installable as a desktop PWA with a manifest, branded FX icons, an offline app -shell, and a permanent install entry in both setup and the operator dashboard. The desktop-only -guide now walks through one generic three-step flow with visual examples; cancelled or unavailable -native prompts report through toasts rather than altering dialog layout. - -Replaced the dashboard's wallet-only stablecoin total with an explicit liquidity model. The vault -planner now exposes a mutex-consistent read-only snapshot, and the balance API reports wallet, -vault position, vault availability, wallet reserve, total holdings, and actual available liquidity -per asset. Initial balances are loaded during startup instead of after a five-second timer, failed -reads are surfaced as partial/unavailable state rather than silently rendered as zero or a dash, -and the dashboard presents the full breakdown per network. - -Files: `src/{core/boot,funding/types,funding/vault/{VaultFundingPlanner,VaultLiquidityState},index,services/BalanceProvider,services/server/{dto,static}}.ts`, -`src/tests/{balance-provider,funding/vault,ui-server}.test.ts`, `ui/{index.html,public,src}`, and -`docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-03 — Explain vault balance controls and seed curated defaults - -Added focusable info icons beside the sweep-threshold and minimum-wallet-balance labels using the -shared `@hyperbridge/ui` tooltip primitives. Newly selected Aave stataUSDC vaults now start at -`20`/`10`, and Yield Bearing cNGN starts at `1000`/`1`, matching the supplied reference; custom and -unknown vaults retain the generic fallback values. - -Files: `ui/src/components/VaultRowsEditor.tsx`, `ui/src/styles/treasury.css`, `package.json`, -`../../pnpm-lock.yaml`, `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-03 — Simplify market setup defaults and remove transfer-market setup - -Removed the setup wizard's dedicated same-token transfer section and its prefab, state, prefill, and -stylesheet plumbing. Setup and operator market creation now use the normal cross-asset editor, reject -same-asset creation, default new order caps to `50000`, and prefill newly added curve-point sizes with -`1`. Optional field labels now use brackets for clarity. - -Files: `ui/src/{components/CurveEditor,operator/markets/{CreateMarketForm,StrategyMarketEditor,useCreateMarket},wizard/state,wizard/steps/Strategies,wizard/strategies/{MarketRow,UniswapPositionsDialog,useStrategiesModel}}`, -`ui/src/styles/{markets,responsive}.css`, `src/cli/init/steps/strategies.ts`, -`src/services/server/{dto,setup-api}.ts`, `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-03 — Prevent the Uniswap pricing view from crashing - -Restored the missing Uniswap icon import used by the selected-position summary. Added UI typechecking -to the standard check command so unresolved runtime identifiers fail before the bundle reaches a -browser, and repaired two stale identifiers in the live chain editor uncovered by that check. - -Files: `ui/src/wizard/steps/Strategies.tsx`, `ui/src/operator/{Chains.tsx,chains/useChainSettings.ts}`, -`package.json`, `docs/ai/ChangeLog.md`. - -## 2026-09-03 — Display Hyperbridge accounts in Polkadot's unified format - -Configured the shared Substrate keyring to encode every derived account with Polkadot's unified -SS58 prefix. Setup, review, operator status, copied addresses, balance snapshots, and logs now all -receive the same unified account string without component-specific conversion. - -Files: `src/services/substrate-key.ts`, `src/tests/balance-provider.test.ts`, -`docs/ai/{ChangeLog,Decisions}.md`. - -## 2026-09-03 — Clarify testnet terminology - -Replaced the inaccurate “Sepolia-family” wording in the CLI initializer and web setup wizard with -“EVM test networks”. The supported testnet catalog also includes Polygon Amoy and BSC Chapel, which -are EVM-compatible but are not Sepolia-family chains. - -Files: `src/cli/init/steps/chains.ts`, `ui/src/wizard/steps/Signer.tsx`, -`docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-09-03 — Simplify the filler wallet guidance - -Shortened the onboarding explanation for the filler wallet and replaced the Unix-specific -`permissions 600` wording with a plain-language assurance that credentials stay private on the -operator's machine. - -Files: `ui/src/wizard/steps/Signer.tsx`, `docs/ai/ChangeLog.md`. - -## 2026-09-03 — Rebrand the Simplex UI header as HyperFX - -Replaced the Hyperbridge mark beside the Simplex product name in both the setup wizard and live -operator dashboard with the supplied white HyperFX wordmark. The transparent wordmark now sits -directly on the dark UI without a backing surface. Added the HyperFX website favicon to the UI build -and taught the local static server to serve bundled WebP assets with the correct MIME type. - -Files: `ui/src/{operator/Operator,wizard/Wizard}.tsx`, `ui/src/styles/{operator,foundations,responsive}.css`, -`ui/src/{assets/hyperfx-logo.webp,vite-env.d.ts}`, `ui/index.html`, `ui/public/favicon.ico`, -`src/services/server/static.ts`. -## 2026-09-03 — Stop bidding on phantom orders whose window has closed - -A mainnet filler (`0xb98306ac…`, Hyperbridge `12KyapjPpm2fK62gepzZKEEk3xP9vzEJdWBspagx8DxZaj2k`) spent nine hours -bidding on phantom orders that had expired hours earlier. Its bids landed, nothing errored, and it backed no pool -the whole time: the indexer shows 0 `PoolBidder` rows against 692 balance rows, the newest of which stops at -03:03 while healthy fillers keep writing them. - -The lag grew monotonically — 4 blocks behind the order it bid on, then 147, 1611, 2228, 2765, 3448 — which is the -signature of a cursor that cannot catch up rather than a one-off stall. `pollPhantomOrders` advances the cursor -by at most `maxBlocksPerPoll` (10) per tick and never skips ahead, so a deficit accumulated while ticks were lost -(a scan overrunning the 15s interval, or the rate-limit backoff sitting out up to 8 ticks) is only repaid if the -sustained rate beats the chain's. Below that, the filler stays behind forever, and every order it then sees is -already dead: the bid window is tens of blocks. - -Two fixes, at the two places the delay can come from: - -- The SDK's poll now abandons a backlog older than one generation cycle rather than walking it, and the scanner - logs each skip: a filler that keeps skipping is one falling behind the chain. -- `handlePhantomOrders` drops events older than the pallet's own bid window (read from chain, plus a 2-block - margin) against one head read per batch. That also covers the delay the scanner cannot see — the global queue this runs on, and the - quoting inside `preparePhantomBid` — and turns a silent outage into a warning naming the lag. A failed head - read keeps every event: one flaky endpoint must not stop the filler bidding. - -Neither is a substitute for the other: the first stops the backlog forming, the second refuses to act on one that -does. - -Files: `src/scanner/hyperbridge-scanner.ts`, `src/core/filler.ts`, `src/tests/core/phantom-bid-staleness.test.ts` (new). - -## 2026-09-02 — Remove `skipPermit`; delegation ops may use Simplex PERMIT mode - -Deleted the `skipPermit` flag end to end: `SponsoredUserOpRequest.skipPermit`, `PaymasterOptions.skipPermit`, the `SimplexPaymasterOptions` interface and `buildSimplexPaymasterData`'s trailing options parameter, and the `skipPermit: true` that `DelegationService.setupDelegationViaBundler` passed. `hasPermit` is now just `await tokenSupportsPermit(client, tokenAddress)`. Delegation ops therefore reach EIP-2612 PERMIT mode on permit-capable tokens instead of being routed past it into the PERMIT2/APPROVE branch, whose bootstrap needs a native-funded `approve` — unsendable by a solver holding zero native, which left delegation with no sponsored path at all (observed on Base and Arbitrum). Test call sites drop the argument; every one of them already mocked a no-permit token, so mode selection is unchanged there. -Files: `src/services/DelegationService.ts`, `src/services/UserOpSender.ts`, `src/services/paymaster/index.ts`, `src/services/paymaster/types.ts`, `src/services/paymaster/provider/simplex.ts`, `src/tests/services/SimplexPaymaster.test.ts`, `src/tests/services/UserOpSender.test.ts`, `src/tests/services/SimplexPaymasterPermit2.probe.test.ts`, `docs/ai/Flow.md`, `docs/ai/Decisions.md`, `CHANGELOG.md`. - -## 2026-09-02 — Harden Simplex-first selection (PR #1196 review) - -Wrapped the `buildSimplexPaymasterData` call in `buildPaymasterAndData` in try/catch: a builder failure warns, joins `skipReasons` as `simplex: `, and falls through to Circle instead of aborting selection (previously a throw lost the bid on the `prepareBidUserOp` path). `tokenSupportsPermit` now discriminates contract reverts from transport errors like `paymasterSupportsPermit2` — a transport error propagates (and demotes to Circle) instead of reading as "no permit". Test mock for `version()` updated to throw viem-shaped errors; added selection-level throw tests and a permit-probe transport test. -Files: `src/services/paymaster/index.ts`, `src/services/paymaster/provider/simplex.ts`, `src/tests/services/PaymasterSelection.test.ts`, `src/tests/services/SimplexPaymaster.test.ts`, `docs/ai/Decisions.md`, `CHANGELOG.md`. - -## 2026-09-02 — Prefer Simplex paymaster over Circle in selection - -Flipped the candidate order in `buildPaymasterAndData`: Simplex is evaluated first (deposit gate, then builder — the gate still precedes the builder because of its bootstrap approve tx), Circle second (USDC balance, then gate, then builder), `type: "none"` fallthrough unchanged. Each branch's internal gate semantics, the 150% headroom, and the fail-open deposit reads are untouched; only the order and the order-describing docs changed. `paymasterVerificationGasLimit` stays Circle-only, so it now only bites when Circle is the survivor (see Decisions). -Files: `src/services/paymaster/index.ts`, `src/services/paymaster/types.ts`, `src/services/UserOpSender.ts`, `src/services/ContractInteractionService.ts`, `src/services/DelegationService.ts`, `src/core/boot.ts`, `src/cli/init/help-text.ts`, `src/tests/services/PaymasterSelection.test.ts`, `docs/ai/Flow.md`, `docs/ai/Decisions.md`. - -## 2026-09-01 — Paymaster selection gated on EntryPoint deposit - -`buildPaymasterAndData` now skips a candidate paymaster whose EntryPoint deposit cannot cover the -op's max prefund with 150% headroom, falling through Circle to Simplex to `type: "none"` with a -per-candidate reason. Motivated by a live incident: on Base the Circle paymaster's deposit was -drained (5.04e12 wei against a 1.49e13 prefund) and the Simplex paymaster's deposit was zero, so -every bid was signed against a paymaster the bundler was bound to reject -("precheck failed: paymaster deposit is X but must be at least Y") — the paymaster is baked into -the signed bid UserOp, so nothing could re-select at execution. Callers pass the op's gas terms: -`prepareBidUserOp` from the cached estimate, `trySendSponsored` from the caller's fixed limits or -the fallbacks — for which `getGasPrice` moved above paymaster selection (also turning a gas-price -failure into the safe never-submitted null). The Simplex gate runs before its builder, which can -send a bootstrap approve tx. Deposit-read failures fail open. Skips are warn-logged with deposit -and required figures. -Files: src/services/paymaster/types.ts, src/services/paymaster/index.ts, -src/services/UserOpSender.ts, src/services/ContractInteractionService.ts, -src/tests/services/PaymasterSelection.test.ts. - -## 2026-08-31 — Review fixes: zero-code guard, abandoned-request cleanup, logger and credentials injection, unit tests - -Four fixes from review of the MPCVault retry change. `apiErrorText` no longer treats enum value 0 -as an error — both code fields define 0 as UNSPECIFIED, so the previous `!== undefined` check would -have failed every signature against a server that sends an explicit zero; the guard is also now -framed as defensive, since the observed `{"code":16,"message":""}` was a gRPC status envelope, not -the in-band `Error` message. A signing request whose execute fails terminally is best-effort -rejected in the vault (`rejectSigningRequest`) instead of staying pending forever — a pending -request nobody will execute is a standing authorization to sign a stale payload. `MpcVaultClientConfig` -and `MpcVaultSignerConfig` accept an injected `logger` (the default process-wide context is -invisible to embedded fillers that pass `SimplexOptions.logger`) and `credentials` (the TLS -hardcoding left no test seam). New unit tests run `MpcVaultService` against an in-process gRPC -server: retry-then-sign, retries-exhausted with reject and error naming, code-only errors, -UNSPECIFIED-zero, and create-failure naming. Create deliberately gets no retry — see Decisions.md. -Files: src/services/wallet/mpcvault.ts, src/services/wallet/types.ts, -src/services/wallet/accounts/mpc.ts, src/tests/wallet/mpcvault.test.ts. - -## 2026-08-31 — MPCVault RPC failures name their call, and execute retries the create/execute race - -`MpcVaultService` rethrows gRPC failures naming the RPC, the signing-request uuid and MPCVault's -x-request-id (previously the bare grpc-js error propagated, so a production -`3 INVALID_ARGUMENT: Invalid uuid` could not be attributed to create vs execute from the order -log). The app-level error guard now also trips on code-only errors — MPCVault sends `message: ""` -with only a code set. `executeSigningRequest` retries INVALID_ARGUMENT/NOT_FOUND twice with short -backoff because MPCVault intermittently rejects a uuid its own createSigningRequest just returned, -before the callback co-signer is contacted. Created uuids are logged at debug for dashboard -correlation. -Files: src/services/wallet/mpcvault.ts. - -## 2026-08-31 — Simplex UI cleanup and operator-market module - -Reviewed the onboarding and operator UI against the agreed design system and extracted live market -administration from the dashboard shell into a dedicated module. Runtime market mutations now reject -duplicate submissions synchronously, token selection uses the shared image-rich control, mobile -operator navigation remains fixed in view, and the wizard lists every unresolved requirement instead -of hiding additional blockers. The live chain editor now shares the onboarding logos, collapsibles, -plain-language endpoint labels, and toast feedback. Added a clean UI check command and corrected the -setup flow notes to match the current navigation and validation ownership. - -Follow-up cleanup moved application boot state into a discriminated-state hook, extracted the render -error boundary, replaced native dashboard drawers and wizard dialogs with Radix-backed shadcn-style -Sheet/Dialog primitives, split the monolithic stylesheet into ordered domain files, and separated -market and chain business logic from their view modules. - -Files: `ui/src/app/`, `ui/src/operator/`, `ui/src/wizard/{steps,strategies}/`, -`ui/src/components/{ui/,OperatorSheet,ScreenErrorBoundary}.tsx`, `ui/src/lib/hooks.ts`, -`ui/src/styles/`, `package.json`, `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-08-27 — Hyperbridge-branded Simplex onboarding - -Restyled the setup wizard around the shared Hyperbridge visual language: Aeonik and Aeonik Mono, -the Hyperbridge mark and spectrum accent, the `#131417` canvas, blue-black surface layers, muted -`#929daa` copy, white primary actions, and rounded controls. The wizard now presents a persistent -desktop journey rail, a compact horizontally scrollable mobile rail, a step-level progress header, -and a local-credentials reassurance without changing any setup state or API behavior. The app shell -also anchors the brand gradient to the top edge and centers all states in a max-width container. - -Files: `ui/src/App.tsx`, `ui/src/styles.css`, `ui/src/wizard/Wizard.tsx`, -`ui/src/assets/hyperbridge-logo.svg`, `ui/src/assets/fonts/*.woff2`, `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-08-27 — Phantom orders are validated against the pallet's shape before they are quoted - -`IntentFiller.preparePhantomBid` now refuses any phantom order that does not look like one -`phantom_order_commitment` built: a non-zero `session`, a non-zero output amount, or a -`source`/`destination` that is not the announced chain. Those checks are pure field comparisons — -no RPC. On top of them, the destination head is read and an order that is still fillable is -refused; that read is best-effort, and a failure warns and continues rather than stopping the -filler bidding. - -The reason any of it is needed: `quotePhantomFill` deliberately runs with no budget, no -wallet-balance read and neither profit gate, so the amounts signed are bounded only by the order -body. The body is read from a single Hyperbridge node's offchain storage, and `fetchPhantomOrder` -assigns `id` from the event rather than re-deriving the commitment from the bytes — so it is -unauthenticated. A forged body would have turned that unbounded quote into a signed, executable -authorization to fill an order of someone else's choosing, paying out to a beneficiary named in -the same body. - -Each invariant independently breaks that. `session` is the direct one: `_select` recovers a key -with `ECDSA.recover`, which can never return the zero address, so a genuine phantom order can -never have a solver selected at all. - -Found by the scheduled IntentGateway/Simplex security audit. - -The unit tests now also rebuild an order the way `phantom_order_commitment` does, encode it with -the real `placeOrder` ABI and decode it the way `fetchPhantomOrder` does, before running the guard -over it. The hand-built fixtures otherwise bake in the assumption the guard depends on — that a -pallet-generated order presents `source`/`destination` as the same string the event carries in -`chain` — and getting that wrong would refuse every genuine phantom order and silently stop the -filler bidding, which only the simnode E2E would have caught. - -The simnode E2E was seeding `LatestStateMachineHeight` with `createType("u64", h).toHex()`. -polkadot-js renders integers big-endian in hex while SCALE stores them little-endian, so the -runtime read `0x00000000000f4240` back as 4.6e18: every phantom order in that suite carried a -far-future deadline and was, contrary to the entire point of a phantom order, genuinely fillable. -Nothing noticed until this guard refused to quote them. Fixed to seed `toU8a()`. - -Files: `src/core/filler.ts`, `src/tests/core/phantom-order-validation.test.ts`, -`src/tests/phantom-filler.e2e.simnode.test.ts`. - -## 2026-08-27 — Bids carry an on-chain expiry (`bidValiditySeconds`) - -Every bid this filler signs now sets `FillOptions.validUntil`, so `fillOrder` reverts `FillExpired` once the quote -has gone stale. Configured as `simplex.bidValiditySeconds`, default 300 (5 minutes). - -A bid is a firm price the order placer takes up whenever they choose, and nothing bounded that window: -`order.deadline` is placer-chosen with no ceiling, and `enqueueRetraction` only clears the bid on Hyperbridge, which -has no effect on the destination chain. A bid signed at one rate stayed executable indefinitely and was exercised only -if the rate moved against us — a written option on this filler's inventory, at no premium. Volatile pairs -(USDC/CNGN) are the worst case, since the naira reprices in steps rather than drifting. - -Operators configure seconds because that is the unit the risk is in; the contract compares block numbers, so the -value is converted per destination chain from the chain's nominal block time (`Chain.blockTime`, milliseconds in -viem), with a 30-second discovery allowance added before the conversion and the result rounded up — seconds rather -than a block count, because the lag between reading the head and the fill landing is wall-clock and does not scale -with block time. - -`buildApprovalAndFillCalldata` encodes through the SDK's version-aware codec, since gateways predating the field take -a differently-selectored `fillOrder`. On such a chain the bound is dropped — there is nowhere to put it — and the -filler warns once per chain rather than silently believing itself protected. - -Found by the scheduled IntentGateway/Simplex security audit. - -Files: `src/services/ContractInteractionService.ts`, `src/services/FillerConfigService.ts`, -`src/config/filler-toml.ts`, `src/config/abis/IntentGatewayV2.ts`, `src/core/boot.ts`, -`filler-config-example.toml`, `src/tests/services/bid-validity-config.test.ts`. - -## 2026-08-26 — `decimals()` read failures fall back to the asset registry instead of guessing 18 - -`ContractInteractionService.getTokenDecimals` previously swallowed a failed on-chain `decimals()` -read and returned a hardcoded 18. That value flows into `computeLegPolicyOutput`, which scales -`policyMaxOutput` by `10 ** decimals` — so a 6-decimal token (USDC/USDT/cNGN) misread as 18 -inflates the computed payout by 10^12. Since the overfill clamp is disabled, nothing bounds the -result back to the user's requested output, and the filler would size the leg against its whole -wallet balance. - -The correct values were already in the tree: `chain.ts` carries a per-chain `tokenDecimals` table -and `ChainConfigService.getAssetMetadataByAddress` resolves it by address. Nothing in simplex -consulted it — `CacheService.tokenDecimals` starts empty and its only writer is the success path -of the very read that just failed. The catch branch now consults the registry through a new -`FillerConfigService.getAssetDecimalsByAddress` delegator, and raises a hard error when neither -the RPC nor the registry can supply a value — there is no safe guess, so the order is skipped -instead. Every caller was audited to confirm a throw skips one order rather than escaping: -`initCache` runs unawaited from the constructor and now swallows its own failures, so a boot-time -RPC hiccup cannot become an unhandled rejection. - -Found by the scheduled IntentGateway/Simplex security audit. - -Files: `src/services/ContractInteractionService.ts`, `src/services/FillerConfigService.ts`, -`src/tests/services/ContractInteractionService.decimals.test.ts`. - -## 2026-08-26 — Final-review fixes: cause-chain probe classification, batching guards (#1071) - -Second review round on the 08-24 fixes; the transport-error fix (F4) did not survive contact with viem, and the new batching/zero-first code had gaps. - -- **Probe classification actually works now.** viem 2.47.6's `readContract` wraps _every_ failure — HTTP 429/timeout included — in `ContractFunctionExecutionError` (verified empirically against the installed package), so the 08-24 `instanceof` check still cached transport errors as "unsupported". `paymasterSupportsPermit2` now classifies by the cause chain: `error.walk(e => e instanceof ContractFunctionRevertedError || e instanceof ContractFunctionZeroDataError)` marks a genuine revert; everything else propagates uncached. Both unit-test mocks were reshaped to throw what viem actually throws (the old transport mock threw a bare `Error`, which real viem never does — the test was validating a fantasy), and the transport test now also proves the negative was not cached by probing again with a healthy client. -- **Zero-only batching.** `resolvePendingPermit2Approval` returns null for any non-zero allowance, not just one at the recommendation: the batched delegation tx approves max directly and skips simulation (explicit gas), so a stale partial allowance on a USDT-rule token was a deterministic on-chain revert. Stale-allowance cases defer to `sendFundedApprove`'s zero-first path. -- **Delegation retry checks `isDelegated` first.** EIP-7702 applies authorization tuples before execution and keeps them applied when execution reverts, so a batched tx that reverted on the approve usually still delegated — the retry now costs one `eth_getCode` instead of a full second tx. -- **Pre-check budgets the whole sequence.** `sendFundedApprove` reads the allowance up front and requires native for two txs when a zero-first reset is needed; previously dust for exactly one tx passed the check, landed the reset, and died mid-sequence with the allowance stuck at zero. -- **The 100k ceiling is now tested for profitability.** The gas-grief loop covers {100k, 40k, 30k}; 100k is where the EntryPoint penalty actually applies and is the case the restored ceiling's safety argument rests on. Stale "cap 40k" comments/labels in the same file and the `POST_OP_GAS_LIMIT_SIMPLEX` comment ("matching its on-chain MAX") corrected; the Permit2 fork test's `_maxCost` now sums the 40k postOp limit `_opWithData` actually packs (was 100k, overstating requiredPrefund). - -Files: `src/services/paymaster/provider/simplex.ts`, `src/services/paymaster/types.ts`, `src/services/DelegationService.ts`, `src/tests/services/SimplexPaymaster.test.ts`, `evm/tests/foundry/{SimplexPaymasterGasGriefTest,SimplexPaymasterPermit2ForkTest}.t.sol`, `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-08-24 — PR #1147 review fixes (#1071) - -Ten inline findings from Seun's review, all addressed. - -- **F1 postOp rollout** — reverted `MAX_POST_OP_GAS_LIMIT` to 100k (kept `MIN`=30k); only the SDK value stays at 40k. An in-place upgrade of a live proxy (needed for stake recovery) no longer rejects clients still sending 100k. Contract + Solidity tests + CHANGELOG/Decisions updated. -- **F2 delegation fail-open** — the native-fallback batched delegate+approve now retries as a plain self-call on any revert (`trySendDelegation`), so a token that rejects the approve can't block delegation forever. -- **F3 zero-first approve** — `sendFundedApprove` resets a stale non-zero allowance to zero before approving max (Ethereum USDT rule). -- **F4 narrowed probe catch** — `paymasterSupportsPermit2` only caches a real contract revert as "unsupported"; a transport error propagates instead of silently reverting to a native paymaster allowance. -- **F5 chain-keyed probe cache** — keyed by `${chainId}:${address}` so a shared CREATE2 address can't leak support across chains. -- **F8 dropped `permit2DeadlineSeconds`** — unreachable config removed; fixed 1h deadline documented. -- **F6/F7 test rigor** — added assertions to the gas-band token test and named the exact reverts (`InvalidPostOpGasLimit`, `InvalidSigner`/`InvalidAmount`/`InvalidNonce`/`AllowanceExpired`) in the gas-grief and compromise fork tests. -- **F9 doc fix** — `sendFundedApprove` docstring and `Flow.md` now say two confirmations. -- **F10** — mode-0x02 `_prefund` uses the base's `prefunder_` instead of re-reading `userOp.sender`. - -Files: `evm/src/utils/SimplexPaymaster.sol`, `evm/tests/foundry/{SimplexPaymasterTest,SimplexPaymasterGasGriefTest,Permit2CompromiseForkTest}.t.sol`, `src/services/paymaster/{provider/simplex.ts,types.ts,index.ts}`, `src/services/DelegationService.ts`, `src/tests/services/SimplexPaymaster.test.ts`, `modules/pallets/intents-coprocessor/src/{benchmarking,tests}.rs`, `parachain/runtimes/{gargantua,nexus}/src/weights/pallet_intents_coprocessor.rs`. - -## 2026-08-20 — PR #1147 review: v,r,s signature layout + batched delegation approve (#1071) - -Two changes from Seun's review of the PERMIT2 PR. - -**Signature layout.** Mode 0x02's `paymasterData` now carries the Permit2 signature as explicit `uint8(v), bytes32(r), bytes32(s)` fields instead of a 65-byte `bytes` blob, mirroring the EIP-2612 mode 0x00 layout. Same 182-byte length; the contract reconstructs `abi.encodePacked(r, s, v)` for the Permit2 call. Client (`permit2.ts` / `provider/simplex.ts`) splits the signature before packing; fork/unit tests updated. Note: this keeps mode 0x02 65-byte-ECDSA-only, which the fixed length already enforced — no ERC-1271-length flexibility is lost. - -**Batched delegation approve.** When Simplex falls back to a native EIP-7702 delegation (bundler/sponsored path unavailable), it now folds the one-time `approve(Permit2, max)` into that same set-code tx on no-permit chains, so the bootstrap costs one native tx (delegate + approve) instead of two. New `resolvePendingPermit2Approval` in `provider/simplex.ts` decides the token (null when a permit token, an approval already in place, no Permit2/PERMIT2-mode, or no fee-token balance yet); `DelegationService.sendDelegationTransaction` takes an optional approval and sets `to = token, data = approve(...)` with the authorization list attached (the delegation lands regardless of `to`). Best-effort: any resolver failure falls back to the plain self-call delegation. Scope is the native fallback only (per review). - -Files: `evm/src/utils/SimplexPaymaster.sol`, `evm/tests/foundry/SimplexPaymasterPermit2ForkTest.t.sol`, `src/services/paymaster/provider/simplex.ts`, `src/services/paymaster/permit2.ts`, `src/services/DelegationService.ts`, `src/tests/services/SimplexPaymaster.test.ts`. - -## 2026-08-19 — Security-review fixes: stake recovery, postOp gas band (#1071) - -A threat-model audit of the paymaster (fork-executed, not just read) produced three fixes carried here. - -**Stake recovery.** `PaymasterCore.addStake` was ungated while `unlockStake`/`withdrawStake` route through `_authorizeWithdraw()`, which reverts unconditionally, and no governance request kind covered stake — so staked native was permanently unrecoverable. Verified against the live deployments: 0.1 native was already stuck on each of Ethereum, BSC and Arbitrum, and any unprivileged address could call `addStake{value: 1 wei}(type(uint32).max)` to stretch the unstake delay from one day to 136 years, defeating even a future upgrade. Added `RequestKind.UnlockStake` / `RequestKind.WithdrawStake` (discriminators 5 and 6, both empty-payload, destination always the treasury) and gated `addStake` to the treasury. - -**postOp gas band.** The EntryPoint penalises the unused part of `paymasterPostOpGasLimit` after fixing the cost handed to `postOp`, so that penalty is never billed to the user; it waives the penalty entirely while `gasLimit <= gasUsed + 40_000`. The SDK pinned the limit at the contract's 100k cap while postOp actually needs ~8-12k, so roughly half the per-op margin was being burned. `MAX_POST_OP_GAS_LIMIT` is now 40_000 — the largest unconditionally penalty-free value — and a `MIN_POST_OP_GAS_LIMIT` of 30_000 closes the refund-underflow window that an unbounded-below limit left open. Fork-measured margin at the cap rose from 9,053,104,589,778 to 21,354,113,277,112 wei per op, and is now identical at the cap and the floor. - -The SDK's shared `POST_OP_GAS_LIMIT` split into `POST_OP_GAS_LIMIT_SIMPLEX` (40k) and `POST_OP_GAS_LIMIT_CIRCLE` (100k) — it was feeding both paymasters, and Circle's is a different contract whose postOp was never measured here. - -Files: `src/services/paymaster/types.ts`, `src/services/paymaster/provider/simplex.ts`, `src/services/paymaster/provider/circle.ts`, `src/tests/services/UserOpSender.test.ts`, plus `evm/src/utils/SimplexPaymaster.sol`, `evm/tests/foundry/{SimplexPaymasterTest,SimplexPaymasterPermit2ForkTest,SimplexPaymasterGasGriefTest,SimplexPaymasterStakeLockForkTest}.t.sol` and `modules/pallets/intents-coprocessor/src/{lib,types,weights}.rs`. - -## 2026-08-18 — Permit2 mode for the Simplex paymaster (#1071) - -The Simplex paymaster client gained mode `0x02 PERMIT2`. On chains whose fee token has no EIP-2612 permit (BSC pegged USDC/USDT), the solver previously kept a $5 standing allowance to the paymaster and refilled it with a native-funded `approve` every time it dipped under $2. Now the bootstrap is a single funded `approve(Permit2, max)` per token, and every subsequent op carries a per-op Permit2 `PermitTransferFrom` signature (spender = paymaster, amount = the same $5 cap, random 256-bit nonce, 1 hour deadline) packed as `mode(1) + token(20) + permitAmount(32) + nonce(32) + deadline(32) + signature(65)`. Selection order in `buildSimplexPaymasterData`: EIP-2612 PERMIT when the token supports it (unless `skipPermit`), then PERMIT2 when the token is already approved to Permit2, then APPROVE while a legacy paymaster allowance is still in place, then the bootstrap approve. Mode 2 is only used when the paymaster deployment exposes `PERMIT2()` (older deployments reject it with `InvalidMode`), probed once per paymaster address and cached. `PaymasterOptions.forceApproveMode` was renamed to `skipPermit` since PERMIT2 stays allowed for delegation ops; `ensureCappedApproval` became the generic `sendFundedApprove`. - -Live probe on Base Sepolia through Alchemy's bundler (deployment script `evm/script/SimplexPaymasterPermit2Probe.s.sol`, env-gated suite `src/tests/services/SimplexPaymasterPermit2.probe.test.ts`): a fresh EOA's sponsored EIP-7702 delegation and a follow-up no-op from the delegated account were both accepted in mode 2 (`Permit2Executed` on-chain), answering the ERC-7562 question for that bundler. The very first op right after the bootstrap approve was rejected `AA33` twice in a row until the approve was one more block old, so `sendFundedApprove` now waits for two confirmations. - -Files: `src/services/paymaster/permit2.ts` (new), `src/services/paymaster/provider/simplex.ts`, `src/services/paymaster/types.ts`, `src/services/paymaster/index.ts`, `src/services/UserOpSender.ts`, `src/services/DelegationService.ts`, `src/config/abis/SimplexPaymaster.ts`, `src/tests/services/SimplexPaymaster.test.ts`, `src/tests/services/UserOpSender.test.ts`, `src/tests/services/SimplexPaymasterPermit2.probe.test.ts` (new). - -## 2026-08-20 — The filler only takes single-leg orders - -`EventMonitor.handleOrder` now forwards an order only when it has exactly one input asset and one -output asset. Anything else is dropped at the door with an `orderSkipped` event carrying the reason -`Multi-leg order`, so the operator sees it in the activity feed rather than losing it silently. - -The check runs before the de-duplication set is touched: a rejected order id is never marked as -seen, so a later single-leg order sharing that id is still delivered. - -Downstream multi-leg handling is untouched — `FXFiller`'s per-leg loops and the leg-splitting in -`filler.ts` still run for phantom orders, which do not come through this path. - -Files: `src/core/event-monitor.ts`, `src/tests/core/chain-lifecycle.test.ts`, `package.json`. - -## 2026-08-20 — Regression test: fills pay the curve amount - -The payout fix below restored `targetOutput = policyMaxOutput`, but nothing asserted the payout — -the original regression landed silently precisely because no test pinned `calculateProfitability`'s -cached outputs (the figure `prepareBidUserOp` signs into the bid) against the curve. -`fx.curve-payout.test.ts` drives `calculateProfitability` with mocked chain access and pins the -three sizing outcomes: an uncapped leg pays the curve amount, not the user's requested amount; a -capped leg pays the capped slice's worth at the curve, not the user's pro-rata ask; and a -balance-limited leg pays what the wallet covers — a full fill when that still clears the ask. -Verified to fail on the pre-fix clamp: reintroducing `min(policyMaxOutput, desiredOutput)` fails -all three cases with exactly the clamped amounts. - -The file is added to `test:filler`, the script CI actually runs — a payout test the CI never -executes would repeat the original failure mode. (`pnpm test` runs the full suite, but CI does not; -`pairs.test.ts`, which exercises the profit gates, is in no CI script at all and currently fails 12 -of its cases on main — 9 predating #1154 and 3 from #1154 unrationing phantom probes without -updating the cap expectations. Repairing that suite and wiring it into CI is a separate task.) - -Files: src/tests/strategies/fx.curve-payout.test.ts (new), package.json, docs/ai/ChangeLog.md. - -## 2026-08-20 — A per-order cap can be removed, from the UI and over the API - -The previous entry made `maxOrderSize` optional in config but left it one-way at runtime: an -operator could add a cap to an uncapped market, and could not take one off without editing the TOML -by hand. Both halves of that are now closed. - -`AdminStrategy` gains `clearMaxOrderSize?: () => void` alongside `setMaxOrderSize`, implemented in -`adminStrategyFor` by setting the live `TradingPair.maxOrderSize` to `undefined` — the engine reads -the cap per order, so removal binds on the next evaluation exactly as a resize does. - -`DELETE /api/strategies/:index/max-order-size` exposes it, on its own route rather than as a null on -the existing `PUT /api/strategies/:index`. `DELETE /api/strategies/:index` already means "remove the -market"; a cap removal one typo away from a market removal is not worth one fewer endpoint. The -handler is idempotent and refuses reference-only markets, matching the PUT. - -`Simplex.clearMaxOrderSize(index)` is the library equivalent, shaped after the existing -`clearCurve`. - -In the operator dashboard, the cap field is now blankable: emptying it turns the button into -"Remove cap" and issues the DELETE, while any other edit still PUTs. The button enables on any -change from the persisted value, including set-to-blank, which the old `!maxOrderSize.trim()` guard -disabled. The setup wizard's cap fields accept blank too and omit the key entirely rather than -emitting `""`, which config validation would reject as a malformed decimal rather than read as "no -cap". - -Files: src/core/boot.ts, src/services/server/UiServer.ts, src/simplex.ts, -ui/src/operator/Operator.tsx, ui/src/wizard/state.ts, ui/src/wizard/steps/Strategies.tsx. - -## 2026-08-20 — Fills pay the curve amount again, not the user's requested amount - -`FXFiller` had stopped overfilling entirely: every fill paid out exactly -`order.output.assets[i].amount`. `targetOutput` was `min(policyMaxOutput, desiredOutput)`, and -`desiredOutput` is `output.amount` whenever the leg is not exposure-capped. Combined with the -acceptance gate just below it (`if (policyMaxOutput < desiredOutput) return 0` — skip when the -curve pays less than asked), the two form a pincer: any order that survives to a fill has -`policyMaxOutput >= desiredOutput`, so `targetOutput` was always `desiredOutput`. Not an edge -case — 100% of non-balance-limited fills paid the requested amount and nothing more, whatever the -configured exchange rate said. Observed on Base fill `0x60b299e8...`: 25.469 ycNGN redeemed to -1,380 cNGN and exactly 1,380 cNGN forwarded, no surplus transfer and no `DustCollected`. - -Introduced by #1123, which added `capFraction`/`desiredOutput` for the `maxOrderSize` exposure cap -and then reused `desiredOutput` as the general fill target — conflating "the slice the cap allows" -with "the amount to pay". Before #1123 the target was `policyMaxOutput` outright. No test asserts -the payout against the curve, so it landed silently. - -`targetOutput` is now `policyMaxOutput` in every case — see the entry below, which took the cap -branch back out after this landed. - -Files: src/strategies/fx.ts. - -## 2026-08-20 — The curve amount is the payout unconditionally, and `maxOrderSize` is optional - -Two changes to the same sizing decision. - -**`targetOutput = policyMaxOutput`, capped or not.** The fix above still let the exposure cap clamp -the payout down to the user's pro-rata ask on a capped leg. It no longer does. `maxOrderSize` still -binds where it always did — `computeLegPolicyOutput` rations `token0ForLeg` against the pair's -remaining budget before the rate is applied, so a capped leg's curve amount is already the capped -slice's worth. What changes is the escrow side: paying above the pro-rata ask draws down more input -than the cap fraction nominally allots. That is more of the user's token for the same outlay, and -the outlay itself is still capped. - -`capLimited` had to follow. It was `desiredOutput < output.amount` — true whenever a cap was active -at all — and it forces the partial-fill eligibility gate. With the payout no longer clamped to -`desiredOutput`, a curve running far enough above the order's rate can cover the whole ask out of a -capped slice; that is a full fill and gating it as a partial would reject cross-chain and calldata -orders the filler can actually serve. The condition is now `capFraction.lt(1) && policyMaxOutput < -output.amount` — the cap is active _and_ it actually shortens the fill. - -**`maxOrderSize` is now optional.** `TradingPair.maxOrderSize` is `Decimal | undefined`; absent -means uncapped, and the pair fills every order at its full notional. `validatePairConfigs` no -longer requires it (a malformed value is still rejected), `tradingPairFrom` maps an absent TOML -value to `undefined` instead of the placeholder `new Decimal(0)`, and `sizeOrder` budgets an -uncapped pair against the order's own total so the per-pair ration still stops sibling legs -double-spending the same token0 without ever binding below the order. `FXFiller`'s constructor -check accepts absence and keeps exempting reference-only pairs, which never fill and whose callers -still pass a placeholder `0`. - -Test updated: `validatePairConfigs > requires a positive maxOrderSize` asserted the removed throw; -it is now `accepts an omitted maxOrderSize, and rejects a malformed one`. - -Files: src/strategies/fx.ts, src/config/pairs.ts, src/core/boot.ts, src/simplex.ts, -src/tests/pairs.test.ts. - -## 2026-08-20 — pairs.test.ts catches up with the probe and paymaster changes, and CI now runs it - -`src/tests/pairs.test.ts` failed 12 of its 55 tests on main, unnoticed because no CI script ran -the file. Both failure groups were stale test expectations, not product regressions — each was -verified against the intent recorded in the 2026-08-19 entries before editing: - -- Three phantom-probe tests still expected `quotePhantomFill` to cap its quote at the pair's - `maxOrderSize`. The cap no longer rations probes (see "A phantom probe is no longer rationed by - the pair's exposure cap", and the Decisions entry "The exposure cap governs fills, never - probes") — that change updated `fx.one-sided-lp.test.ts` only and left this file behind. The - assertions now expect the full unrationed quotes (e.g. 200,000 ZARP × 100 = 20,000,000 CNGN - where the old cap produced 10,000,000), and the test names/comments no longer claim probes are - capped. -- Nine profit-gates tests scored 0 where they expected a positive result (or the partial-fill - flag). The leg loop now calls `paymasterReserveForToken` (see "Fill sizing reserves the - paymaster's gas pull"), whose `hasPaymaster` check calls - `configService.getCirclePaymasterAddress` / `getSimplexPaymasterAddress` — absent on the - suite's `cfg` mock, so every evaluation threw `TypeError` into `calculateProfitability`'s catch - and returned 0. Root cause pinned with a throwaway probe test against the exact mock shape. The - mock now defines both getters as `() => undefined` (no paymaster configured → zero reserve), - which restores the suite's exact spread/fee arithmetic. - -`test:filler` now includes `src/tests/pairs.test.ts`, so the file runs in CI (the "Run simplex -test" step of `.github/workflows/test-sdk.yml`). It is pure-unit — no network, no env. Verified: -55/55 pass via `pnpm vitest run --maxConcurrency=1 src/tests/pairs.test.ts`, biome lint clean on -the file, and `vitest list` collects all four `test:filler` files after codegen. - -Files: `src/tests/pairs.test.ts`, `package.json`, `docs/ai/{ChangeLog,Decisions}.md`. - -## 2026-08-19 — A phantom probe is no longer rationed by the pair's exposure cap - -`quotePhantomFill` fed the pair's per-order exposure budget into `computeLegPolicyOutput`, which -clamps the priced quantity to `min(legMaxToken0, remainingToken0)`. A probe is a price quote, not -an allocation — it commits no capital — so the clamp had no exposure to protect, and when it bound -it corrupted the published price: the output covered less than the standard amount while every -consumer still divides by the FULL standard amount. A pair capped below the probe therefore -published a proportionally worse rate with nothing to signal it. At `maxOrderSize` 10 against a -100-token probe that is a rate 10x too low. - -`computeLegPolicyOutput`'s `remainingToken0` is now `Decimal | null`; `null` means "price the whole -input, unbudgeted" and only `quotePhantomFill` passes it. `fill()` passes the real budget exactly -as before, which is where exposure is actually taken. - -Two related changes in the same path: - -- The rate is now sampled at the leg's OWN notional (`sized.legNotionals[i]`) rather than the - pair's exposure-capped budget. On a sloped curve, sampling at a smaller notional advertises a - tighter rate than this filler would give at the probe's size, and optimistic is the one - direction a published rate must never be — a quote built from it has to stay fillable. -- A probe whose notional exceeds the pair's `maxOrderSize` now logs a warning. The price is - honest, but no order that size can clear it, so the operator needs to see the config gap. - -This mattered because the coprocessor pallet's standard amount is moving from 1 to 1000 tokens to -buy quote precision. At one token the clamp effectively never bound (a pair's whole budget is -~2 tokens against a `maxOrderSize` of thousands); at 1000 it plausibly does. - -Files: `src/strategies/fx.ts`, `src/tests/strategies/fx.one-sided-lp.test.ts`. - -Not verified: `node_modules` was not installed in this worktree, so `tsc` and `vitest` were not -run. Needs a normal CI run. - -## 2026-08-19 — Two more ways a fill could be sized past what the wallet can pay - -Both found while tracing the paymaster shortfall below, both with the same failure signature — a credit or a balance that the sizing believed in and the chain did not. - -**Uniswap V4 credited liquidity it was not going to remove.** `removeCallParameters` re-derives the decrease as `liquidityPercentage.multiply(position.liquidity).quotient`, which truncates, while the planner priced the fill from the untruncated liquidity it asked for. The gap is always in the reverting direction and scales with the withdrawal: at the old 1e6 denominator, a slice of ~0.0159% of a position shed 0.63% of it, roughly $93 of phantom credit on a $14.8k draw. A new `liquidityRemoval` helper resolves the percentage and the liquidity it actually encodes together, at a 1e18 denominator, and the planner now credits and consumes that figure and hands the same `Percent` to the SDK. It also returns null for a slice too thin to register rather than letting the SDK's ZERO_LIQUIDITY invariant throw. - -**The escrow-release dispatch fee was unaccounted for.** `HyperApp.dispatchWithFeeToken` pulls `dispatchFee` from the solver's wallet in the destination host's fee token — USDC on Base, the same token most fills pay out. `buildApprovalAndFillCalldata` already added it to the approval; nothing subtracted it from the balance. It is priced by `estimateGasFillPost`, which depends on the funding calls the leg loop produces and so cannot run before it, so `evaluateOrder` now checks affordability straight after the estimate: for a cross-chain order it requires the fee token's post-fill residue to cover the dispatch fee plus the paymaster reserve, and skips otherwise. Shrinking the fill is not an option — cross-chain orders cannot be partially filled. - -Files: `src/funding/uniswapV4/UniswapV4FundingPlanner.ts`, `src/strategies/fx.ts`, `src/tests/funding/uniswapV4Removal.test.ts`, `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-08-19 — Fill sizing reserves the paymaster's gas pull - -A partial fill on Base reverted with `ERC20: transfer amount exceeds balance` after being short by 28,993 units — $0.029 on a $14,808.699383 fill. The fill was sized as wallet balance plus the Uniswap V4 credit and bid to the last unit, but the paymaster charges gas in the same USDC and pulls it via `transferFrom` during `validatePaymasterUserOp`, before the batch runs. The bid amount was bit-exact `balance + credited`, and the revert delta was bit-exact the prefund. - -The only reserve the sizing loop knew about came from `FundingVenue.walletReserveForToken`, which is the vault's `minBalance`. `UniswapV4FundingPlanner` returns `0n` there by design (LP positions have no wallet float), and a filler configured with no venue at all never enters that loop, so both setups sized fills with zero headroom. A new `paymasterReserveForToken` in the paymaster module now contributes a reserve for the chain's USDC and USDT whenever a paymaster is configured, scaled by each token's own decimals, and the leg loop seeds `reserve` with it. - -Files: `src/services/paymaster/index.ts`, `src/strategies/fx.ts`, `src/tests/services/paymaster-reserve.test.ts`, `docs/ai/{ChangeLog,Decisions,Flow}.md`. - -## 2026-08-19 — Cross-lane orders skip cleanly instead of erroring "Shared cache is not initialized" - -An operator running only Base saw `ERROR: [intent-filler]: Shared cache is not initialized` whenever an order destined to a chain their filler does not run scrolled past. The solver-selection cache is only ever populated for configured, non-watch-only chains (boot's `solverSelectionChains`), so any other destination fell through `handleNewOrder`'s cache check and was dropped with an error that reads like the filler is broken. The behavior (drop) was correct — there is no client, bundler or float for an unconfigured destination — the diagnosis was not, and it dates to #287. - -`handleNewOrder` now checks the destination first: not a configured chain, or configured but watch-only → debug-level skip, cache untouched. The "Shared cache is not initialized" error survives for what it actually means now — a configured, filling destination with a genuinely absent entry, which is an initialization bug. Pinned by `order-destination.test.ts`: unconfigured / non-EVM / watch-only destinations never touch the cache, a filling destination proceeds to the allowlist, and the configured-but-uncached case still errors. - -Files: `src/core/filler.ts`, `src/tests/core/order-destination.test.ts` (new). - -## 2026-08-19 — The binary silences @polkadot/\* startup noise; the library still never touches the console - -Every start of the bundled CLI printed a wall of `@polkadot/util has multiple versions` warnings, `REGISTRY: Unknown signed extensions` / `API/INIT: RPC methods not decorated` logger chatter, and Node's punycode deprecation. `src/bin/quiet.ts` — the entry's first import, since most of this fires during `@polkadot/*` module init — sets polkadot's official `POLKADOTJS_DISABLE_ESM_CJS_WARNING=1` (silencing the same-version dual-instantiation the single-file bundle necessarily produces), sets `process.noDeprecation`, and wraps `console.warn` with a narrow filter for the remaining known patterns. Only `console.warn` is touched (both noise sources write there); `console.error` is untouched, and real polkadot output — connection failures included — passes through, pinned by test. - -Strictly bin-scoped: `dist/index.js`/`dist/sqlite.js` contain none of it (verified by grep), because a library consumer seeing duplicate-package warnings has a real dedupe to do in their own tree. - -Two gotchas worth recording: `package.json`'s `"sideEffects": false` silently tree-shook the bare `import "./quiet"` out of the bundle — it is now an array listing `src/bin/quiet.ts` as the one side-effectful module — and vitest's own console interception makes the patched `console.warn` unobservable through stderr, which is why the test asserts through the exported `filteringWarn` factory instead. - -The genuine version skew is fixed at the source in the same change, at the maintainer's call: the sdk's `@polkadot/api: "latest"` (and its `types`/`util`/`util-crypto`/`keyring` "latest" pins) became concrete `^16.5.6`/`^14.0.3` ranges, simplex's direct `@polkadot/util{,-crypto} ^13.5.6` moved to `^14.0.3` to match what api 16.x requires, and both packages' `resolutions` blocks — which pnpm warned were ineffective — are deleted rather than moved (they were doing nothing; nothing changed by removing them). Verified structurally, not just by silence: the rebuilt binary's bundle contains zero `13.5.9` occurrences where it previously carried both versions, `pnpm why @polkadot/util` resolves a single 14.0.3 in both package trees, and substrate key derivation (`balance-provider.test.ts`) passes on util-crypto 14. - -Files: `src/bin/quiet.ts` (new), `src/bin/simplex.ts`, `package.json`, `sdk/packages/sdk/package.json`, `src/tests/cli/quiet.test.ts` (new). - -## 2026-08-19 — Quorum client suspends rate-limited endpoints for 5 minutes - -`QuorumPublicClient` previously re-queried a 429ing endpoint on every call — `isRateLimited` existed but only labelled diagnostics — which both wastes the call and deepens the provider's throttle. An endpoint whose failure is unambiguously a request-rate limit (`isSuspendableRateLimit` — stricter than the diagnostic `isRateLimited` label: `-32005` alone never benches, since Infura returns it for deterministic getLogs result caps, and the free-text match excludes URL-bearing metaMessages) is now suspended for `RATE_LIMIT_SUSPENSION_MS` (5 minutes) and dropped by `participants()` — from the query set and from the quorum bar both: each call's threshold is `quorumThreshold(endpoints actually queried)`, so the remaining endpoints keep serving reads while a provider throttles (first shipped with a fixed full-set threshold; reversed by the maintainer — a throttled endpoint answers nothing either way, and counting it only makes the scanner miss orders). With every endpoint benched, all are queried again. Suspension is recorded even from stragglers that settle after a call already decided, `suspended()` exposes the benched URLs, and QuorumError messages carry `responders: N/M queried (K/S suspended for rate limiting)` — the skipped count snapshotted at endpoint selection, not re-sampled at throw time (a long call can outlive a suspension window). `settleUntilQuorum` now takes `{ idx, task }` pairs so failures map to real endpoint indices (`getTransactionConfirmations` previously reported `unknown` URLs in failure detail). - -Tested on the real-HTTP harness in `rate-limit-detection.test.ts` (genuine 429/500/-32005 responses through viem). The traffic-stops assertion runs through uncached `getLogs`, not `getBlockNumber` — viem caches eth_blockNumber for 4s, and an adversarial reviewer proved the naive version passed with suspension disabled entirely; the hardened suite fails 2 tests under that regression (verified by probe). Also covered: re-query after expiry via a `Date.now` spy, the small-set availability rule, threshold non-shrinkage at n=5, -32005 result caps not benching, and 500s staying stateless. - -Files: `src/services/QuorumPublicClient.ts`, `src/tests/rate-limit-detection.test.ts`. - -## 2026-08-18 — Signer return contracts spelled out - -The docs showed `Promise` twice and `Promise` once without saying that the three mean different things: `signTypedData` returns a bare 65-byte `r ‖ s ‖ v` signature (`v` 27/28, the `eth_signTypedData_v4` form), `signAuthorization` returns split components with `yParity` strictly 0/1, and `signTransaction` returns the whole signed transaction as typed-envelope RLP — not a signature at all. An implementer had to reverse-engineer that from the adapters. The contracts now live in the `Signer` and `Signature` docblocks (what an IDE shows), a "Return exactly" table on both doc pages, and the sdk's `SigningAccount.signTypedData` docblock. - -Files: `src/services/wallet/types.ts`, `sdk/packages/sdk/src/types/index.ts`, `docs/content/developers/sdk/{simplex,api/simplex}.mdx`. - -## 2026-08-18 — Audit fixes: yParity guards, the signerless one-way door, and honest tests - -A six-dimension adversarial audit of the signer branch confirmed 21 distinct issues; this change fixes all of them except the two deliberate semver calls (patch releases carrying breaking surface changes — restated in the PR and left to the maintainer). - -Two code defects. `digestSigner` now validates the backend's signature once for every operation — most HSM docs return the legacy v (27/28), viem encodes any truthy `yParity` as parity 1, and EIP-7702 skips an invalid tuple without reverting, so an unguarded 27 meant "Delegation successful" in the logs and an undelegated solver on chain; `DelegationService.buildAuthorization` carries the same guard so hand-written signers are covered. And a signerless boot is now recorded (`FillerRuntime.signerless`) and enforced: boot's new per-chain watch-only exemption had made it possible to start an observer and later flip it into filling with the generated throwaway key via `chains.add`/`setWatchOnly(false)` — both now refuse, and `add` defaults new chains to watch-only. The setup API's gate also stops crashing on a signerless watch-only config (`TypeError` on an absent block) and instead mirrors run's rule. - -Test integrity. `assertSignsForItsAddress`'s transaction leg asserted shape only — a signer signing the digest of a _different_ transaction passed — and now parses the signed bytes and recovers the signer, matching the gated integration tests. The refactor had migrated the code but not several fixtures: `UserOpSender`, `ContractInteractionService.rpc`, `pairs`, `fx.price-guard` and `fx.one-sided-lp` tests still stubbed `{ account: { address } }`, so the paths under test ran with the solver address `undefined` (hidden by `as unknown as Signer` casts); all swept to `{ address }`, and the UserOp tests now assert `op.sender`. The deleted `validateConfig` signer-requirement tests are replaced at the layer that owns the rule now: `boot-signer.test.ts` pins the boot rejection through a mock RPC, unit-tests `allChainsWatchOnly` (exported for the purpose), and pins the signerless guards. The shared `TYPED_DATA` fixture lists `EIP712Domain`, honouring the branch's own contract; one ungated assertion now recovers an authorization against the hand-built `keccak256(0x05 ‖ rlp(...))` preimage, so sign and verify no longer share viem's hasher; and a persist-roundtrip test asserts the `[simplex.signer]` block survives a dashboard rewrite — the regression the `FillerConfigFile` split exists to prevent. - -Docs drift from the design's three iterations corrected across both packages' `docs/ai` (final interface shape, `signRawHash`'s removal, `validateConfig` does not read the signer block, `UserOpSender.buildSignedUserOp`), the README's quick-start now compiles, and the `digestSigner` docs state the split-signature contract and the new yParity rejection. The API reference documents the signerless one-way door. - -Files: `src/services/wallet/account.ts`, `src/services/DelegationService.ts`, `src/core/boot.ts`, `src/simplex.ts`, `src/services/server/setup-api.ts`. Tests: `src/tests/core/boot-signer.test.ts` (new), `src/tests/wallet/signer.test.ts`, `src/tests/services/{UserOpSender,ContractInteractionService.rpc,SimplexPaymaster}.test.ts`, `src/tests/{pairs,ui-server}.test.ts`, `src/tests/strategies/{fx.price-guard,fx.one-sided-lp}.test.ts`. Docs: both packages' `docs/ai/*`, `README.md`, `docs/content/developers/sdk/{simplex,api/simplex}.mdx`. - -## 2026-08-18 — `Simplex.start` takes a Signer interface instead of a signer config - -Signing was reachable only through `config.simplex.signer`, a tagged union over the three backends simplex happens to ship, so a consumer embedding the library could not sign with anything else. `Simplex.start` now takes `signer: Signer` — the interface the solver already used internally — and the config block is the binary's TOML format only. - -The interface carries no viem types at all, and names operations rather than digests: `address`, `mode`, `signTypedData`, `signAuthorization`, `signTransaction` — all required. `signRawHash` is gone, from both packages: once a signer must be able to sign an authorization and a transaction, nothing in either package calls it. `digestSigner({ address, mode, sign })` is the escape hatch for backends that only see 32 bytes; it does the EIP-712 hashing, the EIP-7702 authorization hashing and the transaction serialising, so that work stays in one tested place instead of in every integration. The `account: LocalAccount` field is gone — `accountFor(signer)` builds the viem account wallet clients need on our side of the boundary, so a consumer no longer has to match this package's viem version to satisfy the type (the `pnpm.overrides` warning that used to head `types.ts`). `TypedDataPayload`, `Signature`, `SignerTransaction` and `Eip7702Authorization` are ours, defined from the specs rather than from viem. - -Two members went as unused or misplaced. `signMessage`, along with its declaration in the SDK's `SigningAccount` — bids are signed as EIP-712 UserOperations, and no path in either package ever invoked it. And the `chainId` argument on `signTypedData`, in both packages: EIP-712 carries it in `domain.chainId`, every payload simplex signs sets it, and MPCVault (the only backend that reads one, for its request envelope) now takes it from the payload instead of an argument that defaulted to mainnet. - -`sendEip7702DelegationTransaction` was replaced by the general `signTransaction`: `DelegationService` sends every set-code transaction through the wallet client, and a backend that takes transactions rather than digests implements `signTransaction`. MPCVault does, including the set-code case its structured `evmSendCustom` request cannot express (no authorization-list field), which it serialises and raw-signs itself. It no longer builds a viem account at all. - -Added `viemSigner(account)`, which derives the whole interface from any viem local account, and rebuilt `privateKeySigner` and `turnkeySigner` on top of it (Turnkey keeps its structured `signAuthorization`). Factories renamed to their public names: `createSimplexSigner` → `createSigner`, `create*SigningAccount` → `privateKeySigner` / `mpcVaultSigner` / `turnkeySigner`, `initializeSignerFromToml` → `signerFromToml`, and the `SigningAccount` type → `Signer`. - -`FillerTomlConfig` (exported as `SimplexConfig`) no longer declares `simplex.signer` at all. The block moved to a new `FillerConfigFile extends FillerTomlConfig`, the on-disk shape the binary parses, which the CLI, the setup API, the TOML writer, the wizard and the dashboard's config type now use. The binary passes the parsed file straight to `Simplex.start`, extra key and all — `UiServer.persistConfig` regenerates the TOML from the running config object, so stripping the block there would erase the operator's signer from their file on the next curve edit. - -Boot now rejects a missing signer unless every chain is watch-only, replacing the `[simplex.signer]` presence check inside `validateConfig` (a config-shape validator has no business requiring — or reading — an argument that is no longer part of the config; a present block is validated by its consumers: `signerFromToml`, the wizard's write step, the setup API's gate). `Simplex.start` throws when an object carries `simplex.signer` and no `signer` was passed — a runtime check, since the type no longer has the field — rather than silently ignoring it or resolving it. The CLI resolves the TOML block into a `Signer` itself and keeps its file-oriented error message. - -Verified against a live MPCVault vault (10/10, chain 1). Every signature is checked by recovery, transactions included: the signed bytes are parsed back and asserted to carry the chain id, nonce, recipient and value we asked for, and to recover to the solver's address — the vault builds the transaction itself on the structured path, so "it returned hex" proves nothing. The set-code branch additionally asserts the authorization list survives into the signed transaction. `createMpcVaultAccount` is deleted — with the interface no longer carrying a viem account and `accountFor` building one from any `Signer`, nothing called it; an MPC-backed viem account is `accountFor(mpcVaultSigner(config))`. - -Turnkey verified live too: `signTypedData`, `signAuthorization` and `signTransaction` all recover to the wallet address, with the transaction parsed back and checked field by field. The delegate-then-revoke case on Sepolia still needs a funded wallet. Adding those cases is what turned up the EIP-712 finding below — it is not an MPCVault quirk, it is how both remote-hashing backends behave. - -Rig facts, since they cost a round of guessing: the vault uuid in the dev machine's `~/.zshrc` is rejected by the API, and the live one is the `vault-uuid` in `~/mpcvault/config.yml` (the value the running client-signer container serves); the vault accepts chain 1 only, so `MPCVAULT_TEST_CHAIN_ID=11155111` fails every chain-scoped call with `Invalid chain id`. - -Files: `sdk/packages/sdk/src/types/index.ts` (SigningAccount narrowed), `src/services/wallet/types.ts`, `src/services/wallet/mpcvault.ts`, `src/services/wallet/signer.ts`, `src/services/wallet/index.ts`, `src/services/wallet/accounts/viem.ts` (new), `src/services/wallet/account.ts` (new: `accountFor`, `sdkSigningAccount`), `src/services/paymaster/{types,permit}.ts`, `src/services/paymaster/provider/{circle,simplex}.ts`, `src/services/rebalancers/{binance,usdt0}.ts`, `src/services/RebalancingService.ts`, `src/services/wallet/accounts/privatekey.ts`, `src/services/wallet/accounts/mpc.ts`, `src/services/wallet/accounts/turnkey.ts`, `src/services/DelegationService.ts`, `src/services/ChainClientManager.ts`, `src/core/boot.ts`, `src/simplex.ts`, `src/index.ts`, `src/bin/simplex.ts`, `src/config/filler-toml.ts`, `src/services/server/{UiServer,setup-api}.ts`, `src/cli/init/{index,state,emit-toml}.ts`, `src/cli/init/steps/write.ts`, `ui/src/types.ts`, plus type-only renames across `src/core/filler.ts`, `src/services/{ContractInteractionService,PaymasterKeeperService,UserOpSender}.ts`, `src/strategies/fx.ts`. Tests: `src/tests/wallet/signer.test.ts` (new), `src/tests/wallet/turnkey.test.ts`, `src/tests/wallet/mpcvault.integration.test.ts`, `src/tests/cli/filler-toml-validate.test.ts`. Docs: `README.md`, `docs/content/developers/sdk/simplex.mdx`, `docs/content/developers/sdk/api/simplex.mdx`, `docs/content/developers/evm/intent-gateway/simplex.mdx`. diff --git a/sdk/packages/simplex/docs/ai/Decisions.md b/sdk/packages/simplex/docs/ai/Decisions.md deleted file mode 100644 index f393fb6e9..000000000 --- a/sdk/packages/simplex/docs/ai/Decisions.md +++ /dev/null @@ -1,2069 +0,0 @@ -# Decisions - -AI-maintained record of non-obvious choices made in `sdk/packages/simplex`: what was decided, what the alternatives were, and why. Read this before changing related code so a later change does not silently undo a deliberate trade-off. - -Entry format: heading with the decision, then alternatives considered and the reasoning. Newest first. - -## 2026-09-10 — `run`'s flags live in src/cli/run-options.ts, not in the bin - -Decided: the option declarations for `simplex run` sit in their own module, and `src/bin/simplex.ts` -applies them with `addRunOptions(program.command("run", { isDefault: true }))`. - -Why: the bin parses `process.argv` at module load, so importing it from a test runs the CLI. Testing -the flags meant either copying the flag strings into the test — where they would have kept passing -after someone edited the real declaration back — or putting them somewhere importable. The `--ui` -bug this now guards survived precisely because nothing ever parsed the CLI's own flags. - -Rejected: extracting the whole program into a `buildProgram()`. It reindents ~280 lines of action -handlers for a fix that changes one string, and those handler bodies are the part a unit test cannot -exercise anyway. - -Rejected: leaving the declarations in the bin and asserting on a `new Option("--ui [host:port]")` -built inside the test. That tests commander, not simplex, and stays green when the bin regresses. - - -## 2026-09-10 — The `--ui` placeholder is `[host:port]`, not `[addr]` - -Decided: spell the optional value `[host:port]`. - -Why not `[addr]`: the placeholder is what `--help` shows, and the flag takes either a bare port -(`--ui 9000`) or `host:port`. `[host:port]` keeps that shape visible; `[addr]` hides it. - -Why not keep the old `[host:]port` nuance, where the *host* half is the optional one: any angle -bracket anywhere in the flags string sets commander's `required`, which is the bug being fixed, and -commander has no notation for "optional value whose host half is also optional". The description -carries that instead. -## 2026-09-09 — node:sqlite's defaults are restated explicitly, and its feature guards compare against `false` (#1236) - -Decided: pass `{ timeout: 5000 }` to both `DatabaseSync` constructors, and write every -capability guard as `x === false` / `x !== false` rather than `!x` / `x`. - -**The timeout, and why the PRAGMA rather than the constructor option.** `DatabaseSync` takes a -`timeout` option, and using it was the obvious move — but it only landed in v22.18.0 and v24.0.0, -so it is accepted and ignored on 22.16, 22.17 and every 23.x, which `engines.node` allows. A -reviewer measured 0.45ms to failure on 23.11 against the option-based version. `@types/node` -declares it `@since v22.16.0`, which is simply wrong; nodejs.org's version table is the source to -trust when the two disagree. `PRAGMA busy_timeout` is ordinary SQLite, so it applies on every -runtime that has `node:sqlite` at all, and it can be read back for a direct assertion instead of -inferred from a stopwatch. - -Swapping libraries silently swaps defaults, and this one is load-bearing: -better-sqlite3 sets a 5000ms busy timeout unless told otherwise (`lib/database.js`: -`'timeout' in options ? options.timeout : 5000`), while `node:sqlite` leaves it at 0. Nothing in -the diff mentioned locking, which is exactly why it slipped through — the migration was audited -for return shapes and API equivalence, not for constructor defaults. 5000 is chosen to match what -the store already had, not because the number is special; the point is not to change locking -behaviour in a commit that is not about locking. - -Rejected: catching `SQLITE_BUSY` and retrying in the store. That reimplements, worse, what -SQLite's own busy handler does in C, and it would have to be added at ten call sites. - -Rejected: leaving it at 0 and treating contention as a caller problem. The callers are the fill -loop and the retraction sweep; neither has anywhere to put the error, and the bid store is the -record that makes a locked deposit reclaimable. - -**The guards.** `db.isOpen` (Node 22.15+) and `db.isTransaction` (22.16+, and never backported to -the 23 line) are read to decide whether to close and whether to roll back. Written as `!db.isOpen` -and `if (db.isTransaction)`, a runtime *missing* the property takes the convenient branch: the -close is skipped for every database, and a failed transaction is never rolled back — which leaves -the connection inside an open transaction so nothing commits again for the life of the process. -Comparing against `false` inverts that: absent means "do the work", and the existing try/catch -absorbs the outcome. The rollback path additionally swallows a ROLLBACK error, because `err` — the -original failure — is what propagates either way, and losing it to `cannot rollback - no -transaction is active` would be strictly worse. - -Rejected: relying on `engines.node` to make the missing-property case unreachable. `engines` is -advisory — npm prints `EBADENGINE` and installs anyway, pnpm likewise by default — so it documents -intent, it does not enforce it. A guard that is only correct when a manifest field is obeyed is -not a guard. - - -## 2026-09-09 — Both tsup entries set `removeNodeProtocol: false`, and the build greps for it (#1236) - -Decided: turn tsup's `node:`-prefix stripping off, and verify the bundle afterwards in -`scripts/build.sh`. - -tsup 8 rewrites `import ... from "node:sqlite"` to `from "sqlite"` by default. The flip to `false` -is announced for the next major but has not shipped — 8.5.1 is the latest release — so this is a -default to live with, not something an upgrade clears today. The rewrite is -harmless for most builtins because `fs`, `path` and friends resolve with or without the prefix. -`node:sqlite` does not: there is no bare `sqlite` builtin, so the import fails with -`ERR_MODULE_NOT_FOUND: Cannot find package 'sqlite'` the moment the process starts. Both the CLI -bundle and the `@hyperbridge/simplex/sqlite` library entry were broken this way, i.e. it would -have shipped. - -Why a grep in the build rather than a test: this failure only exists *after* bundling. The vitest -suite imports from source, where nothing rewrites specifiers, so every test can pass against a -bundle that cannot start — which is exactly what happened here (25/25 green, image dead on -`--version`). The check runs inside `pnpm build`, so the Docker image and any release build both -fail loudly instead of shipping. - -Rejected: setting an esbuild `target` of `node22`. The stripping is tsup's own plugin, not -esbuild's — plain esbuild at `--target=es2020` preserves the prefix — so a target change would not -have fixed it, and would have silently changed downleveling for the whole bundle. - -Rejected: importing `sqlite` through `createRequire` to sidestep specifier rewriting. It defeats -the rewrite but replaces a plain static import with indirection that no longer type-checks -naturally, to work around a bundler default that upstream has already agreed to reverse. - -Rejected: relying on the Docker build alone to catch it. That is what caught it this time, but -only because the image was actually run; a build that merely succeeds proves nothing here, and -`pnpm build` is the narrower place to assert the invariant. - - -## 2026-09-09 — `engines.node` floor is `>=22.16.0`, not `>=22.13.0` or `>=24` (#1236) - -Decided: `>=22.16.0`. That is the first release carrying *every* `node:sqlite` API this store -calls, which is a stricter bar than the first release where the module is unflagged. - -The two dates differ. `node:sqlite` stopped needing `--experimental-sqlite` in v23.4.0 and -v22.13.0. But `database.isTransaction`, which `attachOrder`'s rollback branch reads, was added in -v24.0.0 and v22.16.0; `database.isOpen`, which `close()` reads, in v23.11.0 and v22.15.0. So -22.16.0 is the floor, and picking 22.13.0 because that is when the flag went away would have -declared support for versions where two APIs we call do not exist. - -Note the consequence: the whole v23 line is nominally inside `>=22.16.0` but never received -`isTransaction` (it went to v22.16.0 and v24.0.0, skipping 23). This is accepted rather than -worked around — v23 was never an LTS line and went end-of-life on 2025-06-01, and `engines` is -advisory in npm and pnpm by default anyway. - -Rejected: keep `>=22` and put `--experimental-sqlite` in the bin's shebang. The bin already ships -`#!/usr/bin/env -S node --enable-source-maps`, so it is not a new pattern, but a shebang flag only -covers the binary. A library consumer importing `@hyperbridge/simplex/sqlite` runs in their own -process with their own flags, and would get a module-not-found they could do nothing about from -inside our code. - -Rejected: raise to `>=24`. The Docker image is on 24.19.0 either way, so this costs the container -nothing — but it would exclude Node 22, which is still in maintenance LTS until 2027-04-30, from -a package many operators install globally. Nothing here needs a 24-only API. - - -## 2026-09-09 — `attachOrder`'s transaction is explicit BEGIN/COMMIT/ROLLBACK, guarded by `isTransaction` (#1236) - -Decided: `node:sqlite` has no `db.transaction(fn)` wrapper, so the one call site that needed one -writes the boundary out: - -```ts -this.db.exec("BEGIN") -try { - ... - this.db.exec("COMMIT") -} catch (err) { - if (this.db.isTransaction) this.db.exec("ROLLBACK") - throw err -} -``` - -The transaction is load-bearing, not decoration: `attachOrder` SELECTs the event ids that lack an -order summary and then UPDATEs them, and it returns those ids to the caller as the rows it -changed. Without one boundary around both, a concurrent writer could fill a row in between and the -returned ids would name rows this call did not touch. - -Why the `isTransaction` guard rather than an unconditional ROLLBACK: SQLite rolls back -automatically when a COMMIT fails, and a ROLLBACK with no transaction open throws -`cannot rollback - no transaction is active` — which would replace the real error with a -misleading one on the way out. `isTransaction` is a thin wrapper over `sqlite3_get_autocommit()` -and answers exactly the question being asked. Note BEGIN sits *outside* the `try` on purpose: if -BEGIN itself throws there is nothing to roll back, and catching it would attempt one. - -Rejected: `try { rollback } catch {}` — swallowing whatever the ROLLBACK throws. Same behaviour in -the common case, but it also hides a genuine rollback failure, which is exactly the failure worth -seeing. It would have avoided the 22.16.0 floor (see above); that was not worth trading a real -diagnostic for. - -Rejected: reimplementing a generic `transaction(fn)` helper over BEGIN/COMMIT. There is one call -site. A helper would have to decide about nesting (`node:sqlite` throws on a nested BEGIN) and -savepoints for a case that does not exist here. - - -## 2026-09-09 — The legacy-database fixtures are committed binaries, not generated in the test (#1236) - -Decided: `src/tests/data/fixtures/legacy-v0/{bids.db,activity.db}` are real SQLite files, written -by better-sqlite3 at the schema that shipped before the in-place column migrations, checked into -git. `scripts/make-legacy-db-fixture.mjs` regenerates them and carries the original DDL, copied -verbatim from the commits that introduced it (9149fc52, b3af77e3). - -Why committed rather than built in `beforeEach`: the risk being tested is an operator's existing -data directory failing to open under a different driver. A database the test creates through -`node:sqlite` would have been written by the same library that reads it back, so it could not -detect a cross-driver problem even in principle. The generator is not wired into any package -script, because better-sqlite3 is no longer installed — regenerating is a deliberate act that -needs it added back temporarily, which is the right friction for a file whose value is being -*old*. - -The root `.gitignore` has a repo-wide `**/*.db`, so the fixture directory carries a `.gitignore` -with `!*.db` to re-include them. That works because no parent directory is excluded, only the -files. - -Rejected: a SQL dump replayed at test time. It keeps git free of binaries, but a dump is -re-executed by the current driver, which puts it back in the same category as generating the -database in the test. - -Rejected: skipping the fixture and trusting that SQLite's file format is driver-independent. It is -— but the migrations, the WAL header on `activity.db`, and the row values are the parts that -actually break, and none of them are guaranteed by the format. -## 2026-09-09 — The socket is created 0600 by umask, not chmod'ed to 0600 after binding - -Decided: `listenPrivate` sets `umask 0177` around the synchronous `listen` call so libuv creates the -socket file `0600`. The mode is never briefly wider. - -Why not bind then chmod, which is the obvious shape and what this change originally did: AF_UNIX -permissions are checked at connect(2) and never re-checked, so the window is not "a moment of exposure" -— a connection opened inside it is served for the life of the daemon, and tightening the mode does not -revoke it. Measured at roughly a millisecond, and won 7 times out of 8 by a connect loop. On an -unauthenticated API where `/api/send` moves funds and init mode holds private keys, that is a real -local privilege boundary, not a hardening nicety. - -Rejected: documenting the window and telling embedders to use a private parent directory. That was the -original decision here and it was wrong on the facts — it rested on the claim that the window "cannot be -closed from Node", which is simply false. A private parent directory is still good advice, but it is -defence in depth, not the fix, and stating it as the fix is what kept the bug in place. - -Rejected: bind to a temporary name in the same directory, chmod it, then rename over the real path. -Also race-free and it avoids touching a process-wide setting. But libuv records the bound name and -unlinks *that* on close, so after a rename the real socket file would survive every clean shutdown — -trading a security bug for a litter bug that the stale-socket path would then have to clean up. - -On `process.umask` being process-wide: it wraps only the synchronous `listen` call. libuv binds inside -that call, so no other JavaScript in this process can run between the set and the restore. It throws on -a worker thread, hence the guard; the UI server runs on the main thread. - -Consequence for `assertSocketIsPrivate`: it asserts the resulting mode and does NOT chmod. A repair -there would restore the mode only after the socket had been reachable at the wrong one — recreating the -exact window, while also hiding the regression from the test that is supposed to catch it. A wrong mode -now refuses to serve (a default ACL on the containing directory is the likely cause, and the error says -so). Failing closed is the same rule applied to a chmod failure, which an earlier version downgraded to -a warning while continuing to serve. - - -## 2026-09-09 — A socket path is type-checked with lstat before anything is probed or removed - -Decided: `clearStaleSocket` calls `lstatSync` first and refuses anything that is not a socket. Only a -socket is ever a candidate for the connect probe or for `unlink`. - -Why the probe cannot come first: the invariant the previous version rested on — "ECONNREFUSED means the -file outlived its listener and is a corpse" — is false. connect(2) answers ECONNREFUSED for a regular -file, a FIFO and a directory exactly as it does for an orphaned socket. So a probe-only test classified -an operator's file as stale and deleted it; `--ui-socket ~/filler-config.toml` destroyed the config, -reproduced against a file holding a signer key. Only a live socket is distinguishable by probing, which -is the one thing `lstat` cannot tell us — so each check does the part the other cannot. - -Why `lstat` and not `existsSync`/`stat`: `existsSync` follows symlinks, so a dangling one read as -absent, nothing was cleaned up, and the bind then failed with a bare `EADDRINUSE` naming no cause — a -permanent, unrecoverable start failure that the stale-socket recovery was specifically meant to prevent. -Not following the link also means a symlink planted at the path can never redirect a later operation. - -Rejected: unlinking a non-socket after warning. The path is operator-supplied and typo-prone, and the -value of what might be there (a config with a signing key) is far higher than the convenience of -auto-clearing it. Refusing costs one manual `rm` in the rare legitimate case. - - -## 2026-09-09 — Listen state is assigned after the bind succeeds, never before - -Decided: `boundLoopback` and `listenProvenance` are set inside the `listen` callback in both -`listenOnPort` and `listenOnSocket`, and `listenOnSocket` refuses outright when the server is already -listening. - -Why: `listenProvenance` decides whether the DNS-rebinding Host check runs at all. Assigned before a -fallible bind, a rejected socket start on an already-listening server left a live TCP listener with -every connection tagged `unix`, and therefore exempt from that check — full DNS rebinding re-opened -against `/api/send`, with `start()` having reported failure. Reproduced against the real class. - -Not reachable from the shipped CLI, which selects exactly one transport and constructs one server per -address — but `UiServer` is an exported class and the desktop app this listen mode exists for is -precisely an embedder that might retry or attach-or-spawn. A security toggle that is fail-open by -statement ordering is worth fixing on reachability grounds alone, and the fix is to move two lines. - -The callback runs before the event loop can deliver a connection, so there is no interval in which a -request is served under stale values. - - -## 2026-09-09 — Connection provenance is tagged on the socket, not inferred from the listener - -Decided: every socket carries a `PROVENANCE` symbol holding `tcp`, `unix` or `tunnel`. The listener -stamps what it accepted (prepended to `connection`, so the stamp lands before http attaches a parser), -`accept()` stamps what the tunnel injected, and `markProvenance` keeps the first stamp so an injected -channel is never relabelled by the listener's blanket one. `handle` reads `provenanceOf(req.socket)`. - -This generalises `VIA_TUNNEL`, which answered one boolean question. A Unix socket makes it three, and -the request rules genuinely differ per provenance: a socket reaches only the owning user, a tunnel -connection is an authenticated device with *fewer* rights than a local caller (it cannot manage remote -access), and a TCP port reaches every local user and every web page on the machine. - -Why a symbol set on our own socket, rather than a header or a flag: it is unforgeable by construction. -The value is set on an object this process created, and nothing a client can put on the wire reaches -it. That was already the reason `VIA_TUNNEL` was a symbol; widening the value does not weaken it. - -Rejected: branching on what the server is listening on (`this.listenProvenance` read at request time, -with no per-socket tag). It is one field instead of a stamp, and it is right today because each server -listens one way. But tunnel connections are *injected* and never listened for, so the listening mode is -already not the truth for them — the tunnel would have to keep its own marker anyway, leaving two -mechanisms answering the same question. Tagging also makes `provenanceOf` total, so a socket arriving -by some future path with no stamp is a visible bug rather than one that silently inherits whatever the -listener happens to be. - -Rejected: keeping `VIA_TUNNEL` alongside a new socket flag. Two symbols, two call sites to keep in -step, and the pair can disagree; `isTunnelled()` is now one line over the single tag and keeps its -signature, so no caller changed. - - -## 2026-09-09 — The host-header check is skipped for socket connections, not relaxed - -Decided: `hostHeaderAllowed` is not consulted at all when `provenanceOf(req.socket) === "unix"`. Its -signature and its logic are untouched, and the TCP path through it is byte-for-byte what it was — it is -still the DNS-rebinding defense, and the existing rebinding test still passes unchanged. - -Why skip rather than widen the allowed set: the check exists because an attacker page can resolve its -own domain to a loopback address and become same-origin with the dashboard. Nothing about that applies -to a Unix socket. A browser cannot open a socket file at all, so there is no origin to rebind and -nothing for the check to defend; and an HTTP client over a socket puts whatever it likes in `Host`, -since there is no authority to derive one from. Measured on Node 24: `node:http` with `socketPath` -sends `Host: localhost`, which the existing rule happens to accept — so the breakage is not universal, -but it is arbitrary. A client built on any other base URL (`http://simplex/`, `http://unix/`) is a 403 -for a reason that protects nothing, and that is a trap for the embedding app rather than a defense. - -Rejected: adding `unix` to the accepted host set, or accepting any Host when the server is -socket-bound. Both leave a rule running that cannot fail usefully and can still fail wrongly — the -next client with an unusual base URL hits it again. If a check defends nothing on a transport, not -running it is clearer than tuning it forever. - -Rejected: keying the skip off `this.boundLoopback` or the listen mode instead of the socket's -provenance. A tunnelled connection arriving at a socket-only server would then also skip the check, -which is a change to the tunnel's rules made by accident. `boundLoopback` stays `true` in socket mode -precisely so tunnelled connections keep the behaviour they had. - - -## 2026-09-09 — Windows named pipes are NOT equivalent to a 0600 socket (verified) - -Finding, verified rather than assumed, because the security argument for this listen mode rests on it. - -`libuv/src/win/pipe.c` `pipe_alloc_accept` makes the pipe with -`CreateNamedPipeW(..., PIPE_UNLIMITED_INSTANCES, 65536, 65536, 0, NULL)` — the final -`lpSecurityAttributes` is `NULL`, and there is no libuv or Node API to supply one. Microsoft documents -what `NULL` means, verbatim: "the named pipe gets a default security descriptor ... The ACLs in the -default security descriptor for a named pipe grant full control to the LocalSystem account, -administrators, and the creator owner. They also grant read access to members of the Everyone group and -the anonymous account." - -So the pipe is **not owner-only**. Everyone and anonymous get read access, and Administrators get full -control, in a machine-global namespace (`\\.\pipe\`) rather than a directory the owner controls. - -Practically, driving the HTTP API needs write access to send a request, which Everyone does not get, so -a non-administrator local user cannot reach `/api/send` over the pipe. Administrators can — but an -administrator already reads `filler-config.toml` and takes the signing key without touching any API, so -that is not a boundary this could have held. libuv also passes `FILE_FLAG_FIRST_PIPE_INSTANCE`, so a -hostile process cannot pre-create the name and impersonate the daemon; our bind fails loudly instead. - -The gap cannot be closed from Node. The only pipe-ACL control Node surfaces is `listen({ readableAll, -writableAll })`, which reaches `uv_pipe_chmod`; that sets an ACE for the Everyone SID and Node invokes -it only when one of those flags is set, so the single knob available *widens* access and none narrows -it. `restrictSocketToOwner` therefore returns early on win32 rather than pretending. - -Stated plainly so it is not papered over: on Unix the kernel is the access control; on Windows the -claim is weaker — read-open by any local user, full control for administrators — and an embedder that -needs owner-only on Windows must supply the pipe from native code, not from Node. - - -## 2026-09-09 — A stale socket is detected by connecting, and an over-long path is an error - -Decided: before binding a socket path that exists, dial it. `ECONNREFUSED` means the file outlived its -listener, so unlink and rebind. Anything that answers is a live instance and the start fails. `ENOENT` -means it vanished under us; proceed. Any other error (`EACCES` on somebody else's socket) fails with a -message saying we could not tell, because deleting it would be guessing. - -Why not an existence test: a live socket has a file too, so existence cannot distinguish the two and -the check would either be useless or would steal a running instance's path. Verified against a real -`SIGKILL`ed process: the file survives, connecting to it gives `ECONNREFUSED`, and binding over it -gives `EADDRINUSE` — which `start()`'s `once("error", reject)` turns into a rejected start. - -Refusing a live socket is deliberate and not merely defensive: the socket file is also the desktop -app's discovery mechanism and single-instance lock, so "someone is already serving here" is the answer -it needs, not something to overwrite. - -Decided: a socket path over `sun_path` (103 bytes on macOS, 107 on Linux, NUL included) is a clear -error naming the size and the limit. - -Superseded 2026-09-09 (same day): that error originally suggested `$TMPDIR` as the fallback. On Linux -`os.tmpdir()` is `/tmp`, mode 1777 — anything on the machine can create names there, and this path is -the daemon's address. It now points at `$XDG_RUNTIME_DIR` and says to avoid a shared directory. - -Rejected: silently relocating to a short path under `$TMPDIR`. The caller uses this path to find the -daemon again — it is the discovery mechanism — so moving it trades a legible startup error for a -daemon nothing can attach to, which is strictly worse. The caller can implement that fallback itself; -it cannot recover from a socket at an address it was never told about. Without the guard the failure is -`listen EINVAL: invalid argument `, which names neither the limit nor the reason. - - -## 2026-09-09 — `accept()` keeps its `listening` guard; the coupling is pinned by a test instead - -Decided: `accept()` still returns false when `!this.server.listening`, unchanged. A test asserts a -socket-only server serves tunnelled connections, and that `accept()` refuses them when nothing is bound. - -Tunnel connections are dialled outbound to the relay and injected with `server.emit("connection")`; -nothing listens for them, so the guard tests a condition the path does not logically need. It is -satisfied by a Unix listener today, so socket-only remote access works and there is no bug to fix — -which is exactly why it is worth a test: the trap springs later, when someone removes a listener that -appears unused and silently takes remote access with it. - -Rejected: dropping the guard now. It is not dead — it is what stops a channel being handed to a server -that has been stopped, and `stop()` clears `_handle` synchronously so the check is meaningful. Removing -it as part of this change would trade a pinned, harmless coupling for an untested behaviour change in -the tunnel's hot path. - -`EmbeddedSshServer`'s log line was reworded, though: it said "No UI to serve behind the tunnel" for -every refusal, which misdescribes the case where the UI exists and is merely unbound. -## 2026-09-10 — json mode installs its own `error` listener on stdout, and swallows (#1237) - -Decided: when the format is `json`, `bin/simplex.ts` registers `process.stdout.on("error", () => {})` -once at module scope, next to where `logFormat` is resolved. - -Why it is needed at all: the thing pino-pretty was providing was not only formatting. Its `build()` -ends in `pump(source, stream, destination)`, and pump attaches error handling to the destination — -measurably, `process.stdout.listenerCount("error")` goes from 0 to 2 the moment -`prettyStream({destination: process.stdout})` is constructed. json mode returns the bare stream, so it -dropped those listeners along with the transform. A stream with no `error` listener turns the first -failed write into an unhandled `error` event, and Node turns that into an uncaught exception. - -That is not theoretical, and it lands exactly on the case the flag exists for. Spawned the built CLI -detached with `stdio: ["ignore", "pipe", "pipe"]`, let it bind, then destroyed the read end — the -parent going away while the filler keeps running, which is the whole point of the detached child in -[#1237]. `--log-format json` died with exit 1 and `Unhandled 'error' event`, 3/3. The same run without -the flag survived, 3/3. `simplex run --log-format json | head` is the same failure in a terminal. - -Why swallow rather than re-throw non-EPIPE errors: parity with the path this replaces. On the pretty -path an stdout error tears down pump's chain and logging simply goes quiet; the filler keeps filling. -`LoggerContext`'s destination already states the principle — "A broken sink is the host's problem, not -a reason to fail a fill". Re-throwing anything other than EPIPE would make json mode strictly more -fragile than pretty, which inverts the point of the flag. - -Rejected: registering the listener inside `consoleSink()`. It is called once per writer — the -process-wide sink and again per filler — so that adds a duplicate listener per Simplex and drifts -toward Node's max-listeners warning. The format is a module-level constant; the listener belongs with it. - -Rejected: `process.stdout.on("error")` unconditionally, for both formats. On the pretty path pino-pretty -already installs its own, and adding a second changes existing behaviour for every current user — which -the "default behaviour is completely unchanged" requirement rules out. - - -## 2026-09-09 — `--log-format` is read from raw argv, and json mode writes straight to stdout (#1237) - -Decided: `bin/simplex.ts` calls `logFormatFromArgv(process.argv)` at module scope and builds its -console sink from the result. Commander declares `--log-format ` on `run` as well, but -only so that `--help` lists it and a bad value is rejected; the action never reads the parsed value. - -Why the scan rather than the parsed option: `addLogSink(consoleSink(...))` runs while the module is -still evaluating, so the sink exists before anything can log. Commander has not run at that point. The -sink has to exist before the parse, so its format has to be known before the parse. - -Why the action ignores `options.logFormat` even though it is available by then: the sink is already -writing, and a command line the two readers disagree about (`simplex run -c --log-format json`, where -commander binds `--log-format` to `-c`) would then put an ASCII banner in the middle of a stream a -supervisor is parsing as NDJSON. One reader, one answer. `RunOptions` in `src/cli/run-options.ts` -therefore omits `logFormat`, with the reason recorded on the field it would have occupied. - -Both flags live in `addRunOptions` (`src/cli/run-options.ts`) with the rest of `run`'s options, after -#1249 split that builder out of the bin. That is also what lets the tests parse the *real* declarations -rather than a hand-copied mirror that could drift. - -Rejected: moving the `addLogSink` call into each command's action, after the parse. It would remove -the double read, but the process-wide sink is what the setup wizard logs to — `UiServer`'s -`getLogger("ui")` and `setup-api`'s `getLogger("setup")`, which produce the first record of a wizard -run — as well as the keeper command, so each would need its own copy of the wiring. That is a real -cost paid for a cosmetic gain. (`init` is not in that list: nothing under `src/cli/init/**` resolves a -logger, and it spawns `simplex run` as a child rather than sharing the process.) - -Decided: in json mode `consoleSink()` returns `process.stdout` itself. pino hands a destination one -finished NDJSON record per write, newline included, so there is nothing left to format. - -Why not pino-pretty with `colorize: false`: pino-pretty would still reformat, drop fields and reorder -them. The point of json mode is that a parser downstream sees exactly what pino produced. - -This also removes the interleaving hazard that forces one pino-pretty transform per writer on the -pretty path. Two pino instances sharing one pino-pretty transform interleave their chunks, and it -echoes the unparseable remainder as raw NDJSON. With no transform in between there is nothing -reassembling anything, so the process context and a running filler can share one `process.stdout` -safely. Checked by running two `LoggerContext`s into a shared stdout for 4000 records with 4 KB -payloads: no partial or interleaved lines. - -Decided: in json mode the ASCII banner is not printed and the wizard's URL line becomes a log record -with a `url` field. Those two are the only writers to stdout outside the logger *in this package*, and -either one would break the promise that every line is a JSON object. The URL is more useful as a field -than as prose a parser would have to scrape. - -The promise is therefore about what simplex itself writes, not about the process. `@polkadot/util`'s -logger routes `log` to `console.log`, and `@polkadot/api`'s `Init.js` uses it to announce "Runtime -version updated to spec=…"; `bin/quiet.ts` patches only `console.warn`, so that one line would reach -stdout as plain text on a genuine Hyperbridge runtime upgrade. Rejected for now: widening `quiet.ts` to -redirect that logger's `console.log` to stderr. It is the right fix if json mode ever needs to be -absolute, but it changes behaviour on the pretty path too, and this change was scoped to two flags. - -Rejected: deciding the format by whether stdout is a TTY, the way many tools do. It is the more -convenient default, but it silently changes what an existing user gets the moment they pipe simplex -into a file — and `colorize: true` is currently explicit, so today they get colour there on purpose. -An explicit flag keeps "no flags" meaning exactly what it meant before. - - -## 2026-09-09 — `--no-open` and `--log-format` are flags only, with no env-var equivalent (#1237) - -Decided: neither flag has a `SIMPLEX_*` counterpart. The consumer in #1237 is Electron's `spawn`, -which builds argv directly, so there is nothing to make easier. Docker is the other supervisor, and it -already passes CLI arguments through `CMD` — the image's default command carries `--ui` for the same -reason. - -Rejected: adding `SIMPLEX_NO_OPEN` / `SIMPLEX_LOG_FORMAT` alongside. Two ways to say one thing needs a -precedence rule, and that rule needs a test and a place in the docs, all for a caller that does not -exist yet. `SIMPLEX_HOME` is the package's one env var and it earns its place: config discovery has to -work before any argument is parsed. These do not have that problem — `--log-format` is read early, but -it is read from argv, which is available just as early. - -If a supervisor that cannot construct argv does turn up, adding an env fallback inside -`logFormatFromArgv` is a couple of lines and breaks nothing. -## 2026-09-09 — Framing headers are set once in `handle()`, not per response - -Decided: set `Content-Security-Policy: frame-ancestors 'none'` and `X-Frame-Options: DENY` with -`setHeader` at the top of `UiServer.handle()`, before the Host and CSRF checks run. - -Why there rather than at each `writeHead`: the server writes headers from four places (`sendJson`, the -SSE stream, `serveStatic`, the UI-not-built fallback), and Node merges `setHeader` values into a later -`writeHead`, so one assignment covers all four and every route added later. Setting them ahead of the -Host and `X-Simplex-UI` checks also means the 403s carry them — a rejected response is still a document -a page can frame. - -Why both headers: `frame-ancestors` is the specified control and what current browsers honour; -`X-Frame-Options: DENY` costs one line and still covers older WebViews that ignore CSP, which is a -plausible way for an operator to open this UI from a phone. - -Rejected: a full CSP (`default-src`, `script-src`, …). Stronger, but the SPA is a Vite bundle with -inline styles and an SSE connection, so a usable policy has to be derived from the build output and -checked against the running UI. That is worth doing on its own terms rather than as a side effect of -closing the framing hole, which is the one gap the existing defences left open. - - -## 2026-09-09 — The publickey probe branch requires both halves absent, not either - -Decided: `ctx.signature === undefined && ctx.blob === undefined` selects the unsigned-probe path; -anything with exactly one of the two is refused as malformed. Previously the test was `||`, so a -half-populated request reached `ctx.accept()` — which authenticates rather than sending PK_OK once a -signature is present. - -Why change something not currently reachable: `ssh2@1.17.0` always populates both fields together, -so no wire input produces the dangerous shape today. But the safety of the guard came entirely from -that internal parser detail, not from anything in this file, and the version is a floating `^1.17.0`. -The bypass fixed earlier the same day had the identical shape — correct-looking code whose safety -depended on an undocumented ssh2 behaviour (`verify()` returning a boolean) that turned out not to -hold. Making the fallback refusal costs nothing and removes the dependency on that invariant. - -Rejected: asserting the invariant instead (e.g. throwing if ssh2 ever hands over one without the -other). It is the same branch either way, and refusing an unverifiable request is the behaviour we -actually want; a thrown error inside the authentication handler would be a less predictable path -through ssh2 than a plain rejection. - -Rejected: pinning ssh2 to an exact version to preserve the invariant. Version pinning is worth doing -on its own merits, but it protects this guard only until the next deliberate upgrade, and it does -not make the authentication path correct in isolation. - - -## 2026-09-09 — SSH tunnel auth: strict verify + declared-algorithm must match the key - -Decided: in the embedded SSH server, reject a login unless `key.verify(...) === true` (strict) AND the -parsed key type equals the client-declared signature algorithm (`ctx.key.algo`). This fixes an auth bypass -(see ChangeLog 2026-09-09). - -Why the strict `!== true` and not just fixing the confusion vector: ssh2 documents `verify()` as returning -`false` OR an `Error` on failure, and its own examples use `!== true`. Treating only the confusion case -would leave any other Error-returning failure (future ssh2 versions, other digest edge cases) as a bypass. -The strict check is the load-bearing fix; it alone closes the demonstrated exploit. - -Why also the key-type/algorithm match, given the strict check already covers it: defence in depth and a -clear rejection reason, and it stops the confused request before `verify()` is even attempted. It is safe -for every legitimate key type — ssh2 normalises rsa-sha2-256 and rsa-sha2-512 to `ssh-rsa`, so an honest -RSA client's `ctx.key.algo` (`ssh-rsa`) still equals its parsed `key.type` (`ssh-rsa`), and an ed25519 -client matches trivially. - -Rejected: pinning device keys to ed25519 only. It would also close the specific exploit, but the pairing -flow (`addDevice`) accepts any OpenSSH public key an operator pastes, so pinning would lock out a device -legitimately paired with an RSA or ECDSA key — a functional regression for a weaker reason than the two -checks above, which are correct for all key types. - - -## 2026-09-09 — Fee-token readiness takes priority over bootstrap (#1223) - -Chosen: scan configured fee tokens in USDC-then-USDT order and select the first with both -at least one whole token of balance and Permit2 allowance covering the existing $5 -recommendation. Both thresholds use that token's decimals. Stop when a ready token is -found; otherwise retain the first balance-qualified token as the bootstrap fallback. -Use the same read-only selector for sponsorship and delegation approval resolution. - -Alternatives considered: keep selecting the first funded token and approve it before -checking another token; or prefer an EIP-2612-capable bootstrap token over one already -usable through Permit2. - -Why: an unapproved USDC balance must not hide funded, approved USDT. Approving USDC in -that state either unnecessarily spends native gas or prevents sponsorship when native is -absent. A permit-funded bootstrap is also unnecessary when another token is ready. -Sharing selection with delegation setup prevents the two paths from disagreeing about -whether an approval is needed. Approval alone is insufficient: empty or sub-minimum -balances remain ineligible even with unlimited allowance. - -This is a readiness preference, not a new bootstrap policy. USDC still wins when both -tokens are ready. When neither is ready, the existing first-funded-token fallback and -its permit/native approval rules remain, including the native-funded zero-first reset -for stale non-zero allowances. The existing one-token balance minimum and $5 allowance -recommendation are unchanged; they are selection thresholds, not a guarantee that every -operation can be sponsored. - -## 2026-09-08 — The Permit2 bootstrap keys on the allowance, not on the delegation - -Chosen: `setupDelegation` calls `ensurePermit2Allowance(chain)` before its already-delegated -early return, so the permit-funded `approve(Permit2, max)` op runs whenever the allowance is -missing — not only on the boot where the account happens to be undelegated. - -Alternative considered: leave the bootstrap folded into the first-time delegation op only, and -tell operators to fund native dust once per chain on upgrade. - -Why: that alternative is the workaround the 2026-09-02 `skipPermit` entry already considered and -rejected, and this time it would be worse. It is not "once per fresh solver" — an account -delegated by 0.15 has no Permit2 allowance on any chain, because that release short-circuited to -PERMIT mode before reading one. So every existing deployment would need native everywhere, and -the zero-native solver the paymaster exists to serve would lose sponsorship entirely, silently, -on its first order after the upgrade. - -The deeper point is that delegation and bootstrap were conflated. Being delegated says the EOA -has SolverAccount code; it says nothing about whether the fee token is approved to Permit2. Two -independent facts were being read off one check. Keying the bootstrap on the allowance — -`resolvePendingPermit2Approval`, which reads it — makes the op idempotent and self-healing: it -runs on any boot where the allowance is missing and skips otherwise, whatever the delegation -state. - -Kept best-effort rather than fatal: a bundler outage during the bootstrap must not turn a -correctly delegated account into a setup failure, and the `sendFundedApprove` fallback still -serves any solver holding native. - -## 2026-09-08 — EIP-2612 is the bootstrap authorization, and nothing else - -Chosen: the `permitBootstrap` flag, set only by `DelegationService`'s first-time delegation -op, lets that one op authorize with an EIP-2612 permit when the fee token has no Permit2 -allowance yet. The same op carries `approve(Permit2, max)` in its callData. Every other -sponsored op — fills, bids, vault sweeps, token sends — authorizes through Permit2 with no -way to reach the permit path. - -Alternatives considered: dropping 2612 outright (the previous entry) and accepting that a -fresh solver needs native dust per chain; or restoring 2612 as the general preference for -permit-capable tokens, as it was before. - -Why the scoping is the whole design. The objection to 2612 is its nonce: one sequential -counter per owner, so two permits signed for the same solver carry the same value and only -one lands. That is fatal for fills, which are the ops that actually run concurrently. It is -free for the delegation, which happens once per chain and provably has no concurrent -sibling — the account is not even delegated yet. Confining the permit to that op keeps the -serialization hazard away from everything that could hit it. - -Dropping it outright lost more than it looked. The permit is the only authorization that -needs no prior on-chain state, so it is the only way an account with stablecoins and zero -native can pay for anything. Requiring native dust per chain sounds minor until it is the -operator's first run on a new chain and the failure is "send ETH here" rather than "it -worked". - -Putting the approve in the same op's callData is what makes it worth doing. A permit-only -delegation would leave the account delegated but still without a Permit2 allowance, so the -next op would need the native approve anyway — the permit would have bought one op's delay, -not a bootstrap. Because the paymaster prefunds during validation and callData runs after, -the permit covers gas without the allowance existing, and the allowance exists by the time -anything else needs it. - -Making it a fallback rather than a preference matters too: with the flag set and an -allowance already in place, the builder still picks Permit2. The permit is for the state -where Permit2 cannot work, not for tokens that happen to support it. - -## 2026-09-08 — Permit2 `SignatureTransfer` is the only authorization simplex signs - -Chosen: every sponsored UserOp carries a per-op Permit2 `PermitTransferFrom` signature, including -on tokens that implement EIP-2612. - -Alternatives considered: keeping the 2612 branch for permit-capable tokens (the status quo), or -keeping it only as a zero-native bootstrap path for a fresh solver. - -Why: 2612 nonces are one sequential counter per owner, so two permits signed for the same solver -carry the same nonce and only one survives. Nothing in simplex reused an allowance either — mode -`0x01` was retired from the contract, so `buildPermitMode` signed a fresh permit per op and burned -a counter slot each time. Permit2's unordered bitmap gives each op an independent nonce for the -same cost, which is what lets ops on one chain stop running single-file. Two authorization paths -for the same paymaster also meant two gas profiles, two sets of constants and a `version()` probe -whose transport errors had to be classified — all of which is now one path. - -The bootstrap variant was the tempting one to keep: 2612 is the only mode that needs no -`approve(Permit2, max)`, so it let a solver with zero native and some USDC delegate and start -filling. It loses anyway. Keeping it means keeping the whole branch, the probe and the second gas -limit for a case that arises once per chain in an account's lifetime, and the batched approve in -`DelegationService.setupDelegation` already folds that approval into the delegation transaction the -solver has to send. The cost is one-time native dust on a genuinely new chain, stated in the CLI's -setup guidance. - -Dropping the Circle paymaster is downstream of this, not a separate decision: it accepts EIP-2612 -permits and nothing else. Its one remaining edge was Optimism, where the SDK registry has a -`CirclePaymaster` and no `SimplexPaymaster`; that chain now pays native until a Simplex paymaster -is deployed on it, which is the same fallback every unconfigured chain uses. - -## 2026-09-08 — Tunnelled requests are tagged in-process, and only remote-access routes are refused - -Two ways to tell a tunnelled request apart were on the table. Dialling the UI from a distinct -loopback source (`localAddress: "127.0.0.2"`) needs no plumbing, but only Linux hands out -127.0.0.0/8 freely — it would silently fail to tag on macOS, and a guard that silently stops -guarding is worse than none. So the channel is handed to the HTTP server directly with -`server.emit("connection", channel)` and a symbol on the socket. The cost is a few no-op socket -methods the HTTP server calls (`setTimeout`, `setNoDelay`, `setKeepAlive`, `ref`, `unref`, -`destroySoon`); the gain is a tag that cannot be forged and one less TCP hop. - -What to refuse was Seun's call, and the answer was `/api/tunnel*` only. The alternative was to -also block the money and lifecycle routes (`/api/send`, `/api/vault/*`, `/api/stop`, -`/api/config`, `/api/log-level`), which would make a lost phone harmless — but remote access -exists so an operator can run the filler from their phone, and the UI and docs already say -plainly that a paired key opens the whole dashboard. The specific defect was narrower than "the -phone is powerful": pairing a second key over the tunnel outlives revoking the first, so -revocation did not mean what it says. Reads stay allowed so the panel can render itself -read-only, which is friendlier than a button that 403s. - -## 2026-09-08 — The pre-auth deadline lives on the stream, not on the ssh2 connection - -ssh2 raises its connection event from `onHeader`, after a complete SSH identification line. A -peer that sends a partial line reaches no handler in `EmbeddedSshServer` at all, so the 30s auth -timer never armed and the stream was held for as long as the peer liked — invisible, because -`live` is also incremented there. The timer therefore moved into `inject()`, which sees every -stream. - -Cancelling it needs the reverse link, connection → stream, and ssh2 gives the connection handler -only `(conn, info)`. The options were to parse packets, to wrap every stream in a proxy (which -does not help — the correlation problem is identical), or to read `conn._sock`, which ssh2 sets -in its Connection constructor. We read `_sock`, with a guard: the first time the lookup misses, -every armed deadline is disarmed and an error is logged. Failing to reap connections is a leak; -reaping the wrong stream would cut a live operator session. Two tests drive the paths that -depend on the correlation, so an ssh2 upgrade that renames the field fails loudly. - -## 2026-09-08 — The key exchange list is ours, not the client's - -ssh2 offers diffie-hellman-group16/17/18-sha512 by default and negotiates by the client's -preference order, which makes the algorithm an unauthenticated peer's choice. Measured on this -machine: group14 2.2ms, group16 14.4ms, group18 107.2ms of synchronous server-side DH — on the -same event loop that prices and fills orders, and repeatable via rekey without ever attempting -to log in. Rate-limiting was the alternative, but the failure counter only sees login attempts, -and a per-source connection cap would still leave the first handshake expensive. Restricting the -offer removes the lever instead of policing it: curve25519 and the ECDH groups cover every SSH -app anyone pairs, and group14 stays as a floor at ~2ms. - -## 2026-09-08 — Every generated ed25519 pair is parsed before it is stored - -ssh2's `generateKeyPairSync("ed25519")` returns a pair its own `parseKey` rejects about once in -256 — 28 of 5,000 in a direct measurement, which is the rate you get from a dropped leading zero -byte. The alternative was to treat it as a rare transient and let the caller retry, but the -operator and host keys are written to disk on first boot and re-read on every start: a bad one is -not transient, it is remote access permanently broken with "Malformed OpenSSH private key" until -someone deletes the file by hand. Generating our own keys with `node:crypto` and encoding the -OpenSSH format ourselves would remove the dependency on ssh2's generator entirely, but that is a -lot of format code to own for a bug a round-trip check catches. `generateKeyPair` therefore -generates, parses both halves, and retries up to 8 times; 8 consecutive failures is (1/256)^8. - -## 2026-09-08 — `none` is not a failed login, every other non-publickey method is - -Routing all non-publickey attempts through the failure counter was the review's suggestion, and -it is right for password and keyboard-interactive. It is wrong for `none`: that is the probe -every SSH client opens with to ask which methods the server accepts, so counting it would spend -one of three per-connection failures on the handshake itself and, worse, would push a device that -reconnects ten times in ten minutes past the per-source limit and lock it out. `none` is refused -without being counted; everything else counts. - -## 2026-09-07 — Pairing pastes the phone's public key by default; generation is the fallback - -The first cut generated every device key in simplex and showed the private half on the desktop -screen for the phone to import. That puts a money key on a screen and a clipboard. Seun asked for -the reverse as the default: the phone's SSH app makes the key and the operator pastes the `.pub` -line, so the private half never exists anywhere but the phone. Generation stays behind a switch -for apps that cannot create keys. Pasted keys are normalised to ` ` (comment -dropped, private keys and duplicates refused) so the `authorized_keys` line format stays uniform. - -## 2026-09-07 — Remote access terminates the phone's SSH session inside simplex, not sshd - -The phone's SSH session ends in an `ssh2` server embedded in the process, fed by the relay's -forwarded channels through `injectSocket`, so no port is opened and the host's sshd is never -exposed. Alternatives: exposing sshd (off by default on macOS/Windows, and a full shell on the -operator's box for whoever holds the key) or having the relay terminate the session (then the -relay sees the UI traffic, which moves funds). The embedded server accepts only paired keys and -`direct-tcpip` to the UI bind; the UI's loopback binding and Host guard stay as they are. - -## 2026-09-07 — Off by default, enabled from the UI only - -Enabling remote access makes the embedded SSH server reachable by anyone who scans the relay, so -it is opt-in, and pairing lives in the operator UI rather than the `simplex init` wizard (Seun's -call: the wizard stays focused on the filler config). The UI writes `[simplex.tunnel]` back to -the config file so the choice survives restarts. - -## 2026-09-07 — Relay host key: configured pin, else the built-in pin for the hosted relay, else trust on first use - -`relayHostKey` pins explicitly; for the hosted relay the deployed key's fingerprint is compiled in; -otherwise the key seen on first contact with that relay address is stored in `tunnel/known_relay` -and enforced afterwards. A mismatch is refused and reported in -the UI, not retried silently. A relay is zero-trust by construction (it only sees ciphertext), so -this pin protects availability rather than confidentiality, which is why TOFU is acceptable as -the default. - -## 2026-09-07 — `ssh2` is imported as a default export - -`ssh2` is CommonJS. `import { Client } from "ssh2"` type-checks and passes under vitest (vite's -CJS interop) but the shipped ESM binary throws "does not provide an export named" at load, which -the smoke test against the real relay caught. The tunnel modules destructure from the default -import; types come from `import type`. `ssh2` is also external in tsup because it probes for an -optional native crypto binding relative to its package directory. - -## 2026-09-07 — Device keys live in a plain `authorized_keys` - -One OpenSSH line per device with the label URL-encoded in the comment (`simplex-device: