diff --git a/docs/binance-orderbook-trade-ui-automation.md b/docs/binance-orderbook-trade-ui-automation.md index d84cfbd..83b782a 100644 --- a/docs/binance-orderbook-trade-ui-automation.md +++ b/docs/binance-orderbook-trade-ui-automation.md @@ -182,14 +182,35 @@ The minimum L3 browser evidence is: 1. the exact Tampermonkey script id is present in a `Debugger.scriptParsed` URL; 2. that parse event precedes `Page.domContentEventFired` after a hard reload; -3. `Debugger.getScriptSource` contains the exact generated artifact; +3. `Debugger.getScriptSource` contains the exact generated artifact once inside + the recognized Tampermonkey runtime wrapper; 4. the injected panel is visible; and 5. one reversible panel interaction changes state and restores the original state. +The reversible interaction is the quantity multiplier increment-and-restore path: +read the current multiplier, click the panel increment control once, require an +exact `+1` value change, click decrement once, and require the original value. +The evidence gate also requires zero observed order-placement or leverage-change +requests throughout this interaction. This replaces the removed Maker-section +collapse toggle and keeps L3 independent of positions, orders, and account balance. + +Persist the artifact, complete MCP read-back, CDP loaded source, and the strict +evidence JSON from one navigation, then verify their shared identity with: + +```bash +npm run verify:binance-orderbook-stage3 -- \ + scripts/binance-orderbook-trade.user.js \ + /path/to/tampermonkey-readback.txt \ + /path/to/cdp-loaded-source.user.js \ + /path/to/stage3-evidence.json +``` + Enable only the CDP domains needed for this evidence. Disable them immediately after -collection when the browser bridge supports the corresponding command. A domain that -the bridge cannot disable remains scoped to the claimed tab session and must not be -used as a reason to retain event buffers or persistent probes. +collection when the browser bridge supports the corresponding command. Record +`unsupported-by-bridge` when the bridge rejects a domain's disable command instead +of claiming it was disabled. Discard the event cursor and destroy any page probe in +all cases; an unsupported disable command is not permission to retain event buffers +or persistent probes. ### Stage 4: Live Binance Acceptance and Baselines diff --git a/e2e/binance-orderbook/helpers/stage3-evidence.js b/e2e/binance-orderbook/helpers/stage3-evidence.js new file mode 100644 index 0000000..0dcdbac --- /dev/null +++ b/e2e/binance-orderbook/helpers/stage3-evidence.js @@ -0,0 +1,480 @@ +import { createHash } from 'node:crypto'; + +import { createUserscriptReleaseContract } from '../../../scripts/userscript-release-contract.mjs'; + +const CONTRACT_FIELDS = [ + 'artifact', + 'name', + 'namespace', + 'version', + 'runAt', + 'updateURL', + 'downloadURL', + 'matches', + 'sha256', + 'bytes', + 'characters', +]; +const SOURCE_IDENTITY_FIELDS = ['sha256', 'bytes', 'characters']; +const LOADED_SCRIPT_FIELDS = [ + 'scriptId', + 'url', + 'executionContextId', + 'frameId', + 'isDefault', + 'loadedSourceIdentity', + 'embeddedArtifactIdentity', + 'wrapper', +]; +const EVENT_ORDER_FIELDS = [ + 'domainsEnabled', + 'eventCursorCaptured', + 'reloadRequested', + 'mainFrameNavigated', + 'scriptParsed', + 'domContentLoaded', + 'interactionStarted', + 'interactionChanged', + 'interactionRestored', + 'finalState', +]; +const REQUIRED_DOMAINS = ['Debugger', 'Network', 'Page', 'Runtime']; + +export const STAGE3_PANEL_SELECTOR = '#jh-binance-close-qty-multiplier-panel'; +export const STAGE3_REVERSIBLE_CONTROL_SELECTORS = Object.freeze({ + value: '#jh-binance-close-qty-multiplier-input', + increment: '#jh-binance-close-qty-multiplier-inc', + decrement: '#jh-binance-close-qty-multiplier-dec', +}); +export const STAGE3_FINANCIAL_REQUEST_PATTERNS = Object.freeze([ + '/bapi/futures/v1/private/future/order/', + '/bapi/futures/v1/private/future/user-data/adjustLeverage', +]); + +function assertRecord(value, path) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${path} must be an object`); + } +} + +function assertExactKeys(record, expectedKeys, path) { + const actual = Object.keys(record).sort(); + const expected = [...expectedKeys].sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${path} keys must be exactly: ${expected.join(', ')}`); + } +} + +function assertNonEmptyString(value, path) { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${path} must be a non-empty string`); + } +} + +function assertIsoTimestamp(value, path) { + assertNonEmptyString(value, path); + if (Number.isNaN(Date.parse(value))) throw new Error(`${path} must be an ISO timestamp`); +} + +function assertPositiveInteger(value, path) { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${path} must be a positive integer`); + } +} + +function sourceIdentity(source) { + if (typeof source !== 'string' || source.length === 0) { + throw new Error('source must be a non-empty string'); + } + return Object.freeze({ + sha256: createHash('sha256').update(source, 'utf8').digest('hex'), + bytes: Buffer.byteLength(source, 'utf8'), + characters: source.length, + }); +} + +function extractTampermonkeyArtifact(loadedSource, artifactSource, scriptUrl, tampermonkeyScriptId) { + for (const [value, path] of [ + [loadedSource, 'loadedSource'], + [artifactSource, 'artifactSource'], + [scriptUrl, 'scriptUrl'], + [tampermonkeyScriptId, 'tampermonkeyScriptId'], + ]) assertNonEmptyString(value, path); + const parsedScriptUrl = new URL(scriptUrl); + if ( + parsedScriptUrl.protocol !== 'chrome-extension:' + || parsedScriptUrl.hostname !== 'dhdgffkkebhmkfjojejmpbldmpobfkfo' + || parsedScriptUrl.pathname !== '/userscript.html' + ) { + throw new Error('CDP loaded script URL must belong to the Tampermonkey userscript runtime'); + } + if (parsedScriptUrl.searchParams.get('id') !== tampermonkeyScriptId) { + throw new Error('CDP loaded script URL must identify the exact Tampermonkey script id'); + } + const artifactIndex = loadedSource.indexOf(artifactSource); + if (artifactIndex < 0) { + throw new Error('CDP loaded source does not contain the exact generated artifact'); + } + if (loadedSource.indexOf(artifactSource, artifactIndex + artifactSource.length) >= 0) { + throw new Error('CDP loaded source must contain the generated artifact exactly once'); + } + const prefix = loadedSource.slice(0, artifactIndex); + const suffix = loadedSource.slice(artifactIndex + artifactSource.length); + if ( + !prefix.startsWith('window["__f__') + || !prefix.endsWith('(async function(define,module,exports,GM_info,GM) {\n') + ) { + throw new Error('CDP loaded source has an unrecognized Tampermonkey wrapper prefix'); + } + if (suffix !== `\n}, this)}\n//# sourceURL=${scriptUrl}\n}`) { + throw new Error('CDP loaded source has an unrecognized Tampermonkey wrapper suffix'); + } + return Object.freeze({ + artifactSource, + wrapper: Object.freeze({ + prefixCharacters: prefix.length, + suffixCharacters: suffix.length, + }), + }); +} + +function validateSourceIdentity(identity, path) { + assertRecord(identity, path); + assertExactKeys(identity, SOURCE_IDENTITY_FIELDS, path); + if (!/^[a-f0-9]{64}$/.test(identity.sha256)) { + throw new Error(`${path}.sha256 must be a lowercase SHA-256 digest`); + } + for (const field of ['bytes', 'characters']) assertPositiveInteger(identity[field], `${path}.${field}`); +} + +function validateReleaseContract(contract, path) { + assertRecord(contract, path); + assertExactKeys(contract, CONTRACT_FIELDS, path); + for (const field of [ + 'artifact', + 'name', + 'namespace', + 'version', + 'runAt', + 'updateURL', + 'downloadURL', + ]) { + assertNonEmptyString(contract[field], `${path}.${field}`); + } + if (!Array.isArray(contract.matches) || contract.matches.length === 0) { + throw new Error(`${path}.matches must be a non-empty array`); + } + contract.matches.forEach((match, index) => assertNonEmptyString(match, `${path}.matches[${index}]`)); + validateSourceIdentity({ + sha256: contract.sha256, + bytes: contract.bytes, + characters: contract.characters, + }, `${path}.sourceIdentity`); +} + +function validateEventOrder(eventOrder) { + assertRecord(eventOrder, 'evidence.browser.eventOrder'); + assertExactKeys(eventOrder, EVENT_ORDER_FIELDS, 'evidence.browser.eventOrder'); + const values = EVENT_ORDER_FIELDS.map((field) => { + assertPositiveInteger(eventOrder[field], `evidence.browser.eventOrder.${field}`); + return eventOrder[field]; + }); + for (let index = 1; index < values.length; index += 1) { + if (values[index] <= values[index - 1]) { + throw new Error(`evidence.browser.eventOrder.${EVENT_ORDER_FIELDS[index]} must follow ${EVENT_ORDER_FIELDS[index - 1]}`); + } + } +} + +function validatePanel(panel) { + assertRecord(panel, 'evidence.browser.panel'); + assertExactKeys(panel, ['selector', 'visible', 'rect'], 'evidence.browser.panel'); + if (panel.selector !== STAGE3_PANEL_SELECTOR) { + throw new Error(`evidence.browser.panel.selector must equal ${STAGE3_PANEL_SELECTOR}`); + } + if (panel.visible !== true) throw new Error('evidence.browser.panel.visible must equal true'); + assertRecord(panel.rect, 'evidence.browser.panel.rect'); + assertExactKeys(panel.rect, ['x', 'y', 'width', 'height'], 'evidence.browser.panel.rect'); + for (const field of ['x', 'y', 'width', 'height']) { + if (!Number.isFinite(panel.rect[field])) { + throw new Error(`evidence.browser.panel.rect.${field} must be finite`); + } + } + if (panel.rect.width <= 0 || panel.rect.height <= 0) { + throw new Error('evidence.browser.panel.rect must have positive dimensions'); + } +} + +function validateInteraction(interaction) { + assertRecord(interaction, 'evidence.interaction'); + assertExactKeys(interaction, [ + 'name', + 'controls', + 'before', + 'after', + 'restored', + 'financialNetworkObservation', + ], 'evidence.interaction'); + if (interaction.name !== 'multiplier-increment-restore') { + throw new Error('evidence.interaction.name must equal multiplier-increment-restore'); + } + assertRecord(interaction.controls, 'evidence.interaction.controls'); + assertExactKeys( + interaction.controls, + Object.keys(STAGE3_REVERSIBLE_CONTROL_SELECTORS), + 'evidence.interaction.controls', + ); + if (!Object.entries(STAGE3_REVERSIBLE_CONTROL_SELECTORS).every( + ([name, selector]) => interaction.controls[name] === selector, + )) { + throw new Error('evidence.interaction.controls must match the Stage 3 reversible controls'); + } + for (const state of ['before', 'after', 'restored']) { + assertRecord(interaction[state], `evidence.interaction.${state}`); + assertExactKeys(interaction[state], ['value'], `evidence.interaction.${state}`); + if (!/^[1-9]\d*$/.test(interaction[state].value)) { + throw new Error(`evidence.interaction.${state}.value must be a positive integer string`); + } + } + if (Number(interaction.after.value) !== Number(interaction.before.value) + 1) { + throw new Error('evidence.interaction.after must increase before by exactly one'); + } + if (interaction.restored.value !== interaction.before.value) { + throw new Error('evidence.interaction.restored must equal before'); + } + const network = interaction.financialNetworkObservation; + assertRecord(network, 'evidence.interaction.financialNetworkObservation'); + assertExactKeys(network, ['patterns', 'requests'], 'evidence.interaction.financialNetworkObservation'); + if (JSON.stringify(network.patterns) !== JSON.stringify(STAGE3_FINANCIAL_REQUEST_PATTERNS)) { + throw new Error('evidence.interaction.financialNetworkObservation.patterns must match the Stage 3 contract'); + } + if (!Array.isArray(network.requests) || network.requests.length !== 0) { + throw new Error('Stage 3 reversible interaction must not emit financial requests'); + } +} + +function validateCleanup(cleanup) { + assertRecord(cleanup, 'evidence.cleanup'); + assertExactKeys(cleanup, [ + 'pageProbeStatus', + 'eventCaptureStatus', + 'sessionReleased', + 'domains', + ], 'evidence.cleanup'); + if (!['not-installed', 'destroyed'].includes(cleanup.pageProbeStatus)) { + throw new Error('evidence.cleanup.pageProbeStatus must equal not-installed or destroyed'); + } + if (cleanup.eventCaptureStatus !== 'cursor-discarded') { + throw new Error('evidence.cleanup.eventCaptureStatus must equal cursor-discarded'); + } + if (typeof cleanup.sessionReleased !== 'boolean') { + throw new Error('evidence.cleanup.sessionReleased must be boolean'); + } + if (!Array.isArray(cleanup.domains) || cleanup.domains.length !== REQUIRED_DOMAINS.length) { + throw new Error('evidence.cleanup.domains must cover every required CDP domain'); + } + const names = cleanup.domains.map((domain, index) => { + const path = `evidence.cleanup.domains[${index}]`; + assertRecord(domain, path); + assertExactKeys(domain, ['name', 'status'], path); + if (!REQUIRED_DOMAINS.includes(domain.name)) throw new Error(`${path}.name is not a required domain`); + if (!['disabled', 'session-released', 'unsupported-by-bridge'].includes(domain.status)) { + throw new Error(`${path}.status must equal disabled, session-released, or unsupported-by-bridge`); + } + if (domain.status === 'session-released' && cleanup.sessionReleased !== true) { + throw new Error(`${path} cannot claim session release while the session remains active`); + } + return domain.name; + }); + if (new Set(names).size !== REQUIRED_DOMAINS.length) { + throw new Error('evidence.cleanup.domains must contain each required domain exactly once'); + } +} + +export function validateStage3Evidence(evidence) { + assertRecord(evidence, 'evidence'); + assertExactKeys(evidence, [ + 'schemaVersion', + 'capturedAt', + 'artifact', + 'tampermonkey', + 'browser', + 'interaction', + 'cleanup', + ], 'evidence'); + if (evidence.schemaVersion !== 1) throw new Error('evidence.schemaVersion must equal 1'); + assertIsoTimestamp(evidence.capturedAt, 'evidence.capturedAt'); + validateReleaseContract(evidence.artifact, 'evidence.artifact'); + + const tampermonkey = evidence.tampermonkey; + assertRecord(tampermonkey, 'evidence.tampermonkey'); + assertExactKeys(tampermonkey, [ + 'namespaceMatchCount', + 'scriptId', + 'path', + 'lastModified', + 'readbackTransport', + 'sourceIdentity', + ], 'evidence.tampermonkey'); + if (tampermonkey.namespaceMatchCount !== 1) { + throw new Error('evidence.tampermonkey.namespaceMatchCount must equal 1'); + } + assertNonEmptyString(tampermonkey.scriptId, 'evidence.tampermonkey.scriptId'); + if (tampermonkey.path !== `${tampermonkey.scriptId}/source`) { + throw new Error('evidence.tampermonkey.path must identify the same scriptId source'); + } + if ( + !(Number.isInteger(tampermonkey.lastModified) && tampermonkey.lastModified >= 0) + && !(typeof tampermonkey.lastModified === 'string' && !Number.isNaN(Date.parse(tampermonkey.lastModified))) + ) { + throw new Error('evidence.tampermonkey.lastModified must be a Unix token or ISO timestamp'); + } + if (!['json', 'text-footer'].includes(tampermonkey.readbackTransport)) { + throw new Error('evidence.tampermonkey.readbackTransport must equal json or text-footer'); + } + validateSourceIdentity(tampermonkey.sourceIdentity, 'evidence.tampermonkey.sourceIdentity'); + + const browser = evidence.browser; + assertRecord(browser, 'evidence.browser'); + assertExactKeys(browser, [ + 'tabId', + 'pageUrl', + 'mainFrameId', + 'loaderId', + 'eventOrder', + 'matchingScripts', + 'panel', + ], 'evidence.browser'); + for (const field of ['tabId', 'pageUrl', 'mainFrameId', 'loaderId']) { + assertNonEmptyString(browser[field], `evidence.browser.${field}`); + } + validateEventOrder(browser.eventOrder); + if (!Array.isArray(browser.matchingScripts) || browser.matchingScripts.length !== 1) { + throw new Error('evidence.browser.matchingScripts must contain exactly one main-frame script'); + } + const script = browser.matchingScripts[0]; + assertRecord(script, 'evidence.browser.matchingScripts[0]'); + assertExactKeys(script, LOADED_SCRIPT_FIELDS, 'evidence.browser.matchingScripts[0]'); + assertNonEmptyString(script.scriptId, 'evidence.browser.matchingScripts[0].scriptId'); + assertNonEmptyString(script.url, 'evidence.browser.matchingScripts[0].url'); + assertPositiveInteger(script.executionContextId, 'evidence.browser.matchingScripts[0].executionContextId'); + if (script.frameId !== browser.mainFrameId || script.isDefault !== true) { + throw new Error('matching script must belong to the default main-frame context'); + } + validateSourceIdentity( + script.loadedSourceIdentity, + 'evidence.browser.matchingScripts[0].loadedSourceIdentity', + ); + validateSourceIdentity( + script.embeddedArtifactIdentity, + 'evidence.browser.matchingScripts[0].embeddedArtifactIdentity', + ); + assertRecord(script.wrapper, 'evidence.browser.matchingScripts[0].wrapper'); + assertExactKeys( + script.wrapper, + ['prefixCharacters', 'suffixCharacters'], + 'evidence.browser.matchingScripts[0].wrapper', + ); + for (const field of ['prefixCharacters', 'suffixCharacters']) { + assertPositiveInteger(script.wrapper[field], `evidence.browser.matchingScripts[0].wrapper.${field}`); + } + validatePanel(browser.panel); + validateInteraction(evidence.interaction); + validateCleanup(evidence.cleanup); + return evidence; +} + +export function createStage3Evidence(input) { + assertRecord(input, 'input'); + assertExactKeys(input, [ + 'capturedAt', + 'artifactPath', + 'artifactSource', + 'tampermonkey', + 'browser', + 'interaction', + 'cleanup', + ], 'input'); + const artifact = createUserscriptReleaseContract(input.artifactSource, input.artifactPath); + if (input.tampermonkey.source !== input.artifactSource) { + throw new Error('Tampermonkey read-back source does not exactly match the artifact'); + } + const loadedArtifact = extractTampermonkeyArtifact( + input.browser.matchingScript.source, + input.artifactSource, + input.browser.matchingScript.url, + input.tampermonkey.scriptId, + ); + const evidence = { + schemaVersion: 1, + capturedAt: input.capturedAt, + artifact, + tampermonkey: { + namespaceMatchCount: input.tampermonkey.namespaceMatchCount, + scriptId: input.tampermonkey.scriptId, + path: input.tampermonkey.path, + lastModified: input.tampermonkey.lastModified, + readbackTransport: input.tampermonkey.readbackTransport, + sourceIdentity: sourceIdentity(input.tampermonkey.source), + }, + browser: { + tabId: input.browser.tabId, + pageUrl: input.browser.pageUrl, + mainFrameId: input.browser.mainFrameId, + loaderId: input.browser.loaderId, + eventOrder: structuredClone(input.browser.eventOrder), + matchingScripts: [{ + scriptId: input.browser.matchingScript.scriptId, + url: input.browser.matchingScript.url, + executionContextId: input.browser.matchingScript.executionContextId, + frameId: input.browser.matchingScript.frameId, + isDefault: input.browser.matchingScript.isDefault, + loadedSourceIdentity: sourceIdentity(input.browser.matchingScript.source), + embeddedArtifactIdentity: sourceIdentity(loadedArtifact.artifactSource), + wrapper: loadedArtifact.wrapper, + }], + panel: structuredClone(input.browser.panel), + }, + interaction: structuredClone(input.interaction), + cleanup: structuredClone(input.cleanup), + }; + return Object.freeze(validateStage3Evidence(evidence)); +} + +export function verifyStage3EvidenceAgainstSources(evidence, sources) { + validateStage3Evidence(evidence); + assertRecord(sources, 'sources'); + assertExactKeys(sources, ['artifactSource', 'installedSource', 'loadedSource'], 'sources'); + if (sources.installedSource !== sources.artifactSource) { + throw new Error('Tampermonkey read-back source does not exactly match the artifact'); + } + const loadedArtifact = extractTampermonkeyArtifact( + sources.loadedSource, + sources.artifactSource, + evidence.browser.matchingScripts[0].url, + evidence.tampermonkey.scriptId, + ); + const artifact = createUserscriptReleaseContract(sources.artifactSource, evidence.artifact.artifact); + if (JSON.stringify(artifact) !== JSON.stringify(evidence.artifact)) { + throw new Error('Stage 3 artifact contract does not match the provided artifact source'); + } + const installedIdentity = sourceIdentity(sources.installedSource); + if (JSON.stringify(installedIdentity) !== JSON.stringify(evidence.tampermonkey.sourceIdentity)) { + throw new Error('Stage 3 Tampermonkey source identity does not match the read-back source'); + } + const loadedIdentity = sourceIdentity(sources.loadedSource); + if (JSON.stringify(loadedIdentity) !== JSON.stringify(evidence.browser.matchingScripts[0].loadedSourceIdentity)) { + throw new Error('Stage 3 loaded source identity does not match the CDP source'); + } + const embeddedIdentity = sourceIdentity(loadedArtifact.artifactSource); + if ( + JSON.stringify(embeddedIdentity) + !== JSON.stringify(evidence.browser.matchingScripts[0].embeddedArtifactIdentity) + ) { + throw new Error('Stage 3 embedded artifact identity does not match the generated artifact'); + } + if (JSON.stringify(loadedArtifact.wrapper) !== JSON.stringify(evidence.browser.matchingScripts[0].wrapper)) { + throw new Error('Stage 3 Tampermonkey wrapper dimensions do not match the CDP source'); + } + return evidence; +} diff --git a/package.json b/package.json index 104f8e8..db19c45 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "import:brooks-media-index": "node scripts/brooks-media-import-index.mjs", "download:brooks-media": "node scripts/brooks-media-download.mjs", "inspect:binance-orderbook-trade": "node scripts/userscript-release-contract.mjs scripts/binance-orderbook-trade.user.js", + "verify:binance-orderbook-stage3": "node scripts/binance-stage3-evidence.mjs", "summarize:binance-orderbook-live": "node scripts/binance-live-performance.mjs", "check:binance-userscripts": "node --check scripts/binance-orderbook-trade.user.js && node --check scripts/binance-trading-data.user.js && node --check scripts/binance-coinmarketcap-data.user.js", "check:binance-orderbook-trade": "node --check scripts/binance-orderbook-trade.user.js", diff --git a/scripts/binance-stage3-evidence.mjs b/scripts/binance-stage3-evidence.mjs new file mode 100644 index 0000000..a5e6bfd --- /dev/null +++ b/scripts/binance-stage3-evidence.mjs @@ -0,0 +1,50 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { + verifyStage3EvidenceAgainstSources, +} from '../e2e/binance-orderbook/helpers/stage3-evidence.js'; +import { parseTampermonkeyMcpReadback } from './userscript-release-contract.mjs'; + +function parseArguments(args) { + if (args.length !== 4) { + throw new Error('Usage: node scripts/binance-stage3-evidence.mjs '); + } + return { + artifact: resolve(args[0]), + readback: resolve(args[1]), + loadedSource: resolve(args[2]), + evidence: resolve(args[3]), + }; +} + +export async function runStage3EvidenceVerification(args) { + const paths = parseArguments(args); + const [artifactSource, readbackText, loadedSource, evidenceText] = await Promise.all([ + readFile(paths.artifact, 'utf8'), + readFile(paths.readback, 'utf8'), + readFile(paths.loadedSource, 'utf8'), + readFile(paths.evidence, 'utf8'), + ]); + const installedSource = parseTampermonkeyMcpReadback(readbackText).source; + const evidence = JSON.parse(evidenceText); + verifyStage3EvidenceAgainstSources(evidence, { + artifactSource, + installedSource, + loadedSource, + }); + return Object.freeze({ + verified: true, + evidence: paths.evidence, + artifactSha256: evidence.artifact.sha256, + userscriptVersion: evidence.artifact.version, + pageUrl: evidence.browser.pageUrl, + reversibleInteraction: evidence.interaction.name, + }); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + const result = await runStage3EvidenceVerification(process.argv.slice(2)); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} diff --git a/test/unit/binance-stage3-evidence.test.js b/test/unit/binance-stage3-evidence.test.js new file mode 100644 index 0000000..4eb62ef --- /dev/null +++ b/test/unit/binance-stage3-evidence.test.js @@ -0,0 +1,285 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { + createStage3Evidence, + STAGE3_FINANCIAL_REQUEST_PATTERNS, + STAGE3_REVERSIBLE_CONTROL_SELECTORS, + verifyStage3EvidenceAgainstSources, +} from '../../e2e/binance-orderbook/helpers/stage3-evidence.js'; +import { runStage3EvidenceVerification } from '../../scripts/binance-stage3-evidence.mjs'; + +const source = `// ==UserScript== +// @name Stage 3 fixture +// @namespace binance.orderbook.trade +// @version 1.2.3 +// @match https://www.binance.com/*/futures/* +// @updateURL https://example.com/script.user.js +// @downloadURL https://example.com/script.user.js +// @run-at document-start +// ==/UserScript== +console.log('fixture'); +`; +const tampermonkeyScriptId = 'script-uuid'; +const loadedScriptUrl = `chrome-extension://dhdgffkkebhmkfjojejmpbldmpobfkfo/userscript.html?name=fixture.user.js&id=${tampermonkeyScriptId}`; + +function wrapTampermonkeySource(value, scriptUrl = loadedScriptUrl) { + return `window["__f__fixture.mtp"] = function(){with (this.s) {(async (u, { p, r, s }) => {try {r(u, s, [undefined,undefined,undefined,p.GM_info,p.GM]);} catch (e) {console.error(e);}})(async function(define,module,exports,GM_info,GM) {\n${value}\n}, this)}\n//# sourceURL=${scriptUrl}\n}`; +} + +function stage3Input(overrides = {}) { + const base = { + capturedAt: '2026-08-26T10:00:00.000Z', + artifactPath: 'scripts/binance-orderbook-trade.user.js', + artifactSource: source, + tampermonkey: { + namespaceMatchCount: 1, + scriptId: tampermonkeyScriptId, + path: `${tampermonkeyScriptId}/source`, + lastModified: 1_787_738_000, + readbackTransport: 'json', + source, + }, + browser: { + tabId: '79865117', + pageUrl: 'https://www.binance.com/zh-CN/futures/HYPEUSDT', + mainFrameId: 'frame-1', + loaderId: 'loader-1', + eventOrder: { + domainsEnabled: 1, + eventCursorCaptured: 2, + reloadRequested: 3, + mainFrameNavigated: 4, + scriptParsed: 5, + domContentLoaded: 6, + interactionStarted: 7, + interactionChanged: 8, + interactionRestored: 9, + finalState: 10, + }, + matchingScript: { + scriptId: 'cdp-script-1', + url: loadedScriptUrl, + executionContextId: 7, + frameId: 'frame-1', + isDefault: true, + source: wrapTampermonkeySource(source), + }, + panel: { + selector: '#jh-binance-close-qty-multiplier-panel', + visible: true, + rect: { x: 100, y: 120, width: 280, height: 500 }, + }, + }, + interaction: { + name: 'multiplier-increment-restore', + controls: { ...STAGE3_REVERSIBLE_CONTROL_SELECTORS }, + before: { value: '2' }, + after: { value: '3' }, + restored: { value: '2' }, + financialNetworkObservation: { + patterns: [...STAGE3_FINANCIAL_REQUEST_PATTERNS], + requests: [], + }, + }, + cleanup: { + pageProbeStatus: 'not-installed', + eventCaptureStatus: 'cursor-discarded', + sessionReleased: false, + domains: [ + { name: 'Debugger', status: 'disabled' }, + { name: 'Network', status: 'disabled' }, + { name: 'Page', status: 'unsupported-by-bridge' }, + { name: 'Runtime', status: 'disabled' }, + ], + }, + }; + return { + ...base, + ...overrides, + tampermonkey: { ...base.tampermonkey, ...overrides.tampermonkey }, + browser: { ...base.browser, ...overrides.browser }, + interaction: { ...base.interaction, ...overrides.interaction }, + cleanup: { ...base.cleanup, ...overrides.cleanup }, + }; +} + +test('Stage 3 evidence binds exact artifact, MCP, CDP, interaction, and cleanup state', () => { + const evidence = createStage3Evidence(stage3Input()); + + assert.equal(evidence.artifact.namespace, 'binance.orderbook.trade'); + assert.equal(evidence.artifact.sha256, evidence.tampermonkey.sourceIdentity.sha256); + assert.equal( + evidence.artifact.sha256, + evidence.browser.matchingScripts[0].embeddedArtifactIdentity.sha256, + ); + assert.equal(verifyStage3EvidenceAgainstSources(evidence, { + artifactSource: source, + installedSource: source, + loadedSource: wrapTampermonkeySource(source), + }), evidence); +}); + +test('Stage 3 evidence rejects duplicate MCP namespace matches and source drift', () => { + assert.throws( + () => createStage3Evidence(stage3Input({ + tampermonkey: { namespaceMatchCount: 2 }, + })), + /namespaceMatchCount must equal 1/, + ); + assert.throws( + () => createStage3Evidence(stage3Input({ + tampermonkey: { source: `${source}\n// drift` }, + })), + /does not exactly match/, + ); + assert.throws( + () => createStage3Evidence(stage3Input({ + browser: { + matchingScript: { + ...stage3Input().browser.matchingScript, + source: wrapTampermonkeySource(source.replace("console.log('fixture')", "console.log('drift')")), + }, + }, + })), + /does not contain the exact generated artifact/, + ); + assert.throws( + () => createStage3Evidence(stage3Input({ + browser: { + matchingScript: { + ...stage3Input().browser.matchingScript, + source, + }, + }, + })), + /wrapper prefix/, + ); + const wrongScriptUrl = loadedScriptUrl.replace(tampermonkeyScriptId, 'other-script'); + assert.throws( + () => createStage3Evidence(stage3Input({ + browser: { + matchingScript: { + ...stage3Input().browser.matchingScript, + url: wrongScriptUrl, + source: wrapTampermonkeySource(source, wrongScriptUrl), + }, + }, + })), + /exact Tampermonkey script id/, + ); +}); + +test('Stage 3 evidence rejects stale navigation ordering and non-main-frame scripts', () => { + assert.throws( + () => createStage3Evidence(stage3Input({ + browser: { + eventOrder: { + ...stage3Input().browser.eventOrder, + scriptParsed: 7, + }, + }, + })), + /domContentLoaded must follow scriptParsed/, + ); + assert.throws( + () => createStage3Evidence(stage3Input({ + browser: { + matchingScript: { ...stage3Input().browser.matchingScript, frameId: 'iframe-1' }, + }, + })), + /default main-frame context/, + ); +}); + +test('Stage 3 evidence allows only the non-financial multiplier increment and restore interaction', () => { + assert.throws( + () => createStage3Evidence(stage3Input({ + interaction: { name: 'leverage-toggle' }, + })), + /multiplier-increment-restore/, + ); + assert.throws( + () => createStage3Evidence(stage3Input({ + interaction: { + controls: { + ...STAGE3_REVERSIBLE_CONTROL_SELECTORS, + increment: '#removed-control', + }, + }, + })), + /controls must match/, + ); + assert.throws( + () => createStage3Evidence(stage3Input({ + interaction: { + after: { value: '4' }, + }, + })), + /increase before by exactly one/, + ); + assert.throws( + () => createStage3Evidence(stage3Input({ + interaction: { + financialNetworkObservation: { + patterns: [...STAGE3_FINANCIAL_REQUEST_PATTERNS], + requests: ['https://www.binance.com/bapi/futures/v1/private/future/order/place-order'], + }, + }, + })), + /must not emit financial requests/, + ); +}); + +test('Stage 3 evidence requires every CDP domain to be cleaned up', () => { + assert.throws( + () => createStage3Evidence(stage3Input({ + cleanup: { + domains: [ + { name: 'Debugger', status: 'disabled' }, + { name: 'Network', status: 'disabled' }, + { name: 'Page', status: 'disabled' }, + { name: 'Page', status: 'disabled' }, + ], + }, + })), + /exactly once/, + ); +}); + +test('Stage 3 evidence rejects retained event capture state', () => { + assert.throws( + () => createStage3Evidence(stage3Input({ + cleanup: { eventCaptureStatus: 'retained' }, + })), + /cursor-discarded/, + ); +}); + +test('Stage 3 CLI verifies saved MCP and CDP source evidence', async () => { + const directory = await mkdtemp(join(tmpdir(), 'stage3-evidence-')); + const artifact = join(directory, 'artifact.user.js'); + const readback = join(directory, 'readback.txt'); + const loaded = join(directory, 'loaded.user.js'); + const evidencePath = join(directory, 'evidence.json'); + const evidence = createStage3Evidence(stage3Input({ artifactPath: artifact })); + await Promise.all([ + writeFile(artifact, source), + writeFile(readback, `${JSON.stringify({ value: source, lastModified: 1_787_738_000 })}\n`), + writeFile(loaded, wrapTampermonkeySource(source)), + writeFile(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`), + ]); + + const result = await runStage3EvidenceVerification([ + artifact, + readback, + loaded, + evidencePath, + ]); + + assert.equal(result.verified, true); + assert.equal(result.userscriptVersion, '1.2.3'); +});