diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c5ae61..53ce3ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,50 +26,6 @@ jobs: node-version: 20 - run: npm run validate - verify-manifest: - name: Verify provenance manifest - needs: secret-check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Verify evaluation-manifest.json - run: npm run verify-manifest - # Catch a manifest that was committed without being regenerated after an - # input file changed. Regenerate, then compare only hash-bearing fields - # (excluding generatedAt and sourceCommit which legitimately differ on - # every regeneration). Any diff means the manifest is stale. - - name: Detect stale manifest (inputs changed without regenerating) - run: | - # Capture the committed manifest for comparison. - node -e " - const fs = require('fs'); - const m = JSON.parse(fs.readFileSync('evaluation-manifest.json','utf-8')); - // Extract only the fields that must match: inputs hashes, expectedCounts, - // fixtureRelease, manifestVersion, mappingVersion, tierThresholds. - const sig = { inputs: m.inputs, expectedCounts: m.expectedCounts, - fixtureRelease: m.fixtureRelease, manifestVersion: m.manifestVersion, - mappingVersion: m.mappingVersion, tierThresholds: m.tierThresholds }; - fs.writeFileSync('manifest-sig-before.json', JSON.stringify(sig, null, 2) + '\n'); - " - npm run generate-manifest - node -e " - const fs = require('fs'); - const m = JSON.parse(fs.readFileSync('evaluation-manifest.json','utf-8')); - const sig = { inputs: m.inputs, expectedCounts: m.expectedCounts, - fixtureRelease: m.fixtureRelease, manifestVersion: m.manifestVersion, - mappingVersion: m.mappingVersion, tierThresholds: m.tierThresholds }; - fs.writeFileSync('manifest-sig-after.json', JSON.stringify(sig, null, 2) + '\n'); - " - if ! diff -q manifest-sig-before.json manifest-sig-after.json > /dev/null; then - echo "::error file=evaluation-manifest.json::evaluation-manifest.json is stale — an input file changed but the manifest was not regenerated. Run 'npm run generate-manifest' and commit the result." - diff manifest-sig-before.json manifest-sig-after.json - exit 1 - fi - echo "Manifest signature unchanged — manifest is up to date." - validate-changelog: name: Changelog updated runs-on: ubuntu-latest @@ -102,3 +58,4 @@ jobs: echo "::error file=CHANGELOG.md::This PR modifies fixture files but the [Unreleased] section of CHANGELOG.md is empty." exit 1 fi + echo "CHANGELOG.md [Unreleased] section is populated check passed." diff --git a/EVALUATION_RESULT_SCHEMA.json b/EVALUATION_RESULT_SCHEMA.json deleted file mode 100644 index c8462b0..0000000 --- a/EVALUATION_RESULT_SCHEMA.json +++ /dev/null @@ -1,188 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Gryd-lock/grydlock-testkit/blob/main/EVALUATION_RESULT_SCHEMA.json", - "title": "Gryd Lock Evaluation Result", - "description": "Schema for the result document that grydlock-research must produce when evaluating a testkit fixture release. Every evaluation run must identify its dataset, implementation, and mapping versions so results can be compared across runs without ambiguity.", - "type": "object", - "required": [ - "schemaVersion", - "fixtureRelease", - "sourceCommit", - "evaluatedAt", - "evaluatorVersion", - "mappingVersion", - "tierThresholds", - "summary", - "perDestination" - ], - "additionalProperties": false, - "properties": { - "schemaVersion": { - "type": "string", - "description": "Version of this result schema (semver). Must match the manifestVersion in the evaluation-manifest.json used for this run.", - "pattern": "^\\d+\\.\\d+\\.\\d+$", - "examples": ["1.0.0"] - }, - "fixtureRelease": { - "type": "string", - "description": "The grydlock-testkit release tag evaluated (semver). Must match evaluation-manifest.json fixtureRelease.", - "pattern": "^\\d+\\.\\d+\\.\\d+$", - "examples": ["0.1.0"] - }, - "sourceCommit": { - "type": "string", - "description": "Full SHA-1 git commit of grydlock-testkit at evaluation time. Must match evaluation-manifest.json sourceCommit.", - "pattern": "^[0-9a-f]{40}$", - "examples": ["4707242b30d5c2c3539d8ec90cd3612779c8b3b2"] - }, - "evaluatedAt": { - "type": "string", - "description": "ISO-8601 datetime when this evaluation run was performed.", - "format": "date-time", - "examples": ["2026-08-18T00:00:00.000Z"] - }, - "evaluatorVersion": { - "type": "string", - "description": "Version of grydlock-research that produced this result (semver).", - "pattern": "^\\d+\\.\\d+\\.\\d+$", - "examples": ["0.1.0"] - }, - "mappingVersion": { - "type": "string", - "description": "Version of the score-to-tier mapping used (semver). Must match evaluation-manifest.json mappingVersion. Bump this whenever tier threshold boundaries change.", - "pattern": "^\\d+\\.\\d+\\.\\d+$", - "examples": ["1.0.0"] - }, - "tierThresholds": { - "type": "object", - "description": "The exact tier threshold configuration applied during scoring. Copied from evaluation-manifest.json so the result is self-contained.", - "required": ["clean", "suspicious", "malicious"], - "additionalProperties": false, - "properties": { - "clean": { - "type": "object", - "required": ["max"], - "properties": { - "max": { "type": "integer", "minimum": 0, "maximum": 100 } - } - }, - "suspicious": { - "type": "object", - "required": ["min", "max"], - "properties": { - "min": { "type": "integer", "minimum": 0, "maximum": 100 }, - "max": { "type": "integer", "minimum": 0, "maximum": 100 } - } - }, - "malicious": { - "type": "object", - "required": ["min"], - "properties": { - "min": { "type": "integer", "minimum": 0, "maximum": 100 } - } - } - } - }, - "summary": { - "type": "object", - "description": "Aggregate accuracy metrics across the full dataset.", - "required": ["total", "correct", "accuracy", "byLabel"], - "additionalProperties": false, - "properties": { - "total": { - "type": "integer", - "description": "Total number of destinations evaluated. Must match expectedCounts.total in the manifest.", - "minimum": 1 - }, - "correct": { - "type": "integer", - "description": "Number of destinations where the assigned tier matched the label.", - "minimum": 0 - }, - "accuracy": { - "type": "number", - "description": "Fraction of correct tier assignments (correct / total). Range [0, 1].", - "minimum": 0, - "maximum": 1 - }, - "byLabel": { - "type": "object", - "description": "Per-label breakdown of evaluation counts and accuracy.", - "required": ["clean", "suspicious", "malicious"], - "additionalProperties": false, - "properties": { - "clean": { "$ref": "#/$defs/labelMetrics" }, - "suspicious": { "$ref": "#/$defs/labelMetrics" }, - "malicious": { "$ref": "#/$defs/labelMetrics" } - } - } - } - }, - "perDestination": { - "type": "array", - "description": "One entry per evaluated destination, ordered by destination ID.", - "minItems": 1, - "items": { "$ref": "#/$defs/destinationResult" } - } - }, - "$defs": { - "labelMetrics": { - "type": "object", - "required": ["total", "correct", "accuracy"], - "additionalProperties": false, - "properties": { - "total": { - "type": "integer", - "description": "Number of destinations with this label.", - "minimum": 0 - }, - "correct": { - "type": "integer", - "description": "Number correctly classified.", - "minimum": 0 - }, - "accuracy": { - "type": "number", - "description": "Fraction correct for this label.", - "minimum": 0, - "maximum": 1 - } - } - }, - "destinationResult": { - "type": "object", - "required": ["id", "label", "score", "assignedTier", "correct"], - "additionalProperties": false, - "properties": { - "id": { - "type": "string", - "description": "Destination ID, matching destinations.json." - }, - "label": { - "type": "string", - "description": "Ground-truth label from destinations.json.", - "enum": ["clean", "suspicious", "malicious"] - }, - "score": { - "type": "integer", - "description": "Stub score from scores.json.", - "minimum": 0, - "maximum": 100 - }, - "assignedTier": { - "type": "string", - "description": "Tier computed by applying tierThresholds to score.", - "enum": ["clean", "suspicious", "malicious"] - }, - "correct": { - "type": "boolean", - "description": "Whether assignedTier matches label." - }, - "notes": { - "type": "string", - "description": "Optional free-text annotation for this result (e.g. edge-case explanation)." - } - } - } - } -} diff --git a/README.md b/README.md index 8c420b3..6261d3f 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,7 @@ grydlock-testkit/ README.md LICENSE package.json - evaluation-manifest.json - EVALUATION_RESULT_SCHEMA.json .github/workflows/ci.yml - .github/workflows/consumer-contract-test.yml destinations.json scores.json scripts/ diff --git a/docs/spike-transaction-portability.md b/docs/spike-transaction-portability.md deleted file mode 100644 index 0834e6f..0000000 --- a/docs/spike-transaction-portability.md +++ /dev/null @@ -1,239 +0,0 @@ -# Spike: Transaction Fixture Network and Envelope Portability - -**Status:** Complete -**Date:** 2026-08-18 -**Spike question:** Should fixtures remain TESTNET-only, or support multiple passphrases, signed envelopes, fee bumps, and network variants? - ---- - -## 1. Current state inventory - -All three existing fixtures share the same assumptions. None of these assumptions are written into code in this repo — they live only in `transactions/README.md`. - -| Property | Current value | -|----------|--------------| -| Network | TESTNET only (`"Test SDF Network ; September 2015"`) | -| Passphrase recorded in fixture | No — README prose only | -| Envelope type | `envelopeTypeTx` (v1) on all three | -| Signed | No — 0 signatures in every envelope | -| Fee-bump | No | -| Sequence numbers | Hardcoded (2, 3, 4) | -| Memo | None | -| Time bounds | None | -| Fixture metadata | Only in `transactions/README.md`; not machine-readable | -| Hash recorded in manifest | No — XDR files are hashed by `evaluation-manifest.json` but their transaction hashes are not recorded | - -### What the SDK actually does with the passphrase - -`TransactionBuilder.fromXDR(xdr, passphrase)` does **not** validate the passphrase against anything inside the binary envelope. The passphrase is recorded in the in-memory `Transaction` object and used only for hash computation. A decoder that passes the wrong passphrase will parse the envelope identically, extract the same operations, but compute a wrong transaction hash — silently, with no error. - -Verified empirically: - -``` -payment.xdr decoded under TESTNET: hash = f8310e15... -payment.xdr decoded under PUBLIC: hash = 80b951be... ← different, no error thrown -Operations: identical in both cases -Source: identical in both cases -``` - -This means the network tag is a caller responsibility, not an envelope property. Any consumer that omits the passphrase, or defaults to PUBLIC, will get wrong hashes from TESTNET fixtures. - ---- - -## 2. Areas investigated - -### 2a. PUBLIC vs TESTNET passphrases - -| Concern | TESTNET only | Multi-network | -|---------|-------------|---------------| -| XDR encoding | Passphrase-agnostic — same bytes regardless | Same: XDR does not embed passphrase | -| Hash | TESTNET hash ≠ PUBLIC hash for same XDR | Each variant needs its own recorded hash | -| Signature validity | A TESTNET signature is invalid on PUBLIC | Separate signed fixtures per network | -| Decoder assumption | Consumer must know to pass `Networks.TESTNET` | Consumer must read metadata to pick passphrase | -| Risk of silent bug | Consumer defaults to PUBLIC → wrong hash | Explicit metadata prevents this | -| Scope | Contained to this repo | Requires grydlock-extension to handle both | - -**Finding:** Supporting PUBLIC addresses is out of scope (all fixture addresses are synthetic testnet-only; they have no meaning on PUBLIC). Supporting an alternative TESTNET-like passphrase (FUTURENET) adds no evaluation value. The correct action is to make TESTNET explicit in a machine-readable fixture index rather than adding a second network. - -### 2b. Signed vs unsigned envelopes - -Currently all fixtures are unsigned. The extension's decode step only needs the envelope structure (operation type, source, destination, asset) — it does not verify signatures before warning the user. Signing requires a secret key, which is explicitly excluded from the repo by `scripts/check-secrets.mjs`. - -| Concern | Unsigned (current) | Signed | -|---------|-------------------|--------| -| Decode step | Fully exercised | Fully exercised | -| Signature verification | Not tested | Testable | -| Secret key required | No | Yes — cannot commit | -| Reproducibility | Deterministic | Non-deterministic without fixed keypair | -| Security risk | None | Low, but seeding a test keypair is boilerplate | -| Extension relevance | Covers the warn-before-sign path | Would cover the already-signed path | - -**Finding:** Unsigned fixtures are correct for the decode/warn use case. A signed fixture would only add value if the extension gains a "verify before relay" feature. For now, unsigned is the right constraint. - -### 2c. Fee-bump envelopes (`envelopeTypeFeeBump`) - -Fee-bump transactions are a distinct envelope type (`envelopeTypeTxFeeBump` in XDR). The Stellar SDK's `TransactionBuilder.fromXDR` returns a `FeeBumpTransaction` object (a different class from `Transaction`). A decoder that only handles `Transaction` will fail or silently misclassify a fee-bump envelope. - -Prototype produced during this spike (wrapping `payment.xdr`): - -``` -XDR: AAAABQ... -Envelope type: envelopeTypeTxFeeBump -Fee-bump source (outer): GA4HFFSHZ7PADQWOFCZGYV2HE437LQ2WDILWCGC33BMJUQ6OBO5HKI5D -Inner tx source: GCRRYBV5IY7DSI54DKW33ZELC2LWYCAHC43TXAM2A2HTFN5GWOFWXPC2 -Hash (TESTNET): 7ab7ae0df710a6d37484f403f7d2ccd14b88832c843b961cd5f52311a027876f -``` - -The fee-bump source and the inner-transaction source are different accounts. From a risk-scoring perspective: -- The **inner** transaction's source is the account the user controls. -- The **outer** fee-bump source pays the fee — this can be any third party. -- The **inner** transaction's operations contain the destinations that need scoring. - -A decoder that extracts `tx.source` from a fee-bump will get the fee-bump source (outer), not the inner transaction's source — a silent, semantically wrong result if the two differ. - -### 2d. Transaction hash implications - -The transaction hash is the canonical identifier on the Stellar network. It is determined by: - -``` -hash = SHA-256(passphrase_hash || tx_xdr_bytes) -``` - -Where `passphrase_hash = SHA-256(passphrase_string)`. - -Consequences: -- Changing the passphrase changes the hash even with identical XDR. -- A fee-bump transaction has its own hash, distinct from its inner transaction's hash. -- The fixture XDR files are hashed by `evaluation-manifest.json` (file content SHA-256), which is independent of the Stellar transaction hash. - -| Hash type | What it covers | Where it lives | -|-----------|---------------|----------------| -| File SHA-256 | Exact bytes of the `.xdr` file | `evaluation-manifest.json` | -| Stellar tx hash (TESTNET) | Canonical identifier on the Stellar TESTNET ledger | Not recorded anywhere currently | -| Stellar tx hash (PUBLIC) | Different value — meaningless for synthetic fixtures | N/A | - -**Finding:** Recording the Stellar transaction hash alongside each fixture in a `transactions/index.json` would allow consumers to verify they are decoding the fixture correctly (compute hash with their passphrase, compare to recorded value). This is a low-cost addition. - -### 2e. Fixture metadata and naming - -Currently: -- Fixture content is documented in `transactions/README.md` (human-readable only). -- No machine-readable index exists. -- The `evaluation-manifest.json` hashes the XDR files but records nothing about their semantics (envelope type, network, operations, expected hashes). - -A machine-readable `transactions/index.json` would let consumers: -- Know the passphrase to use before decoding. -- Detect the envelope type without parsing the XDR. -- Verify the Stellar transaction hash after decoding. -- Understand which fixture addresses correspond to which semantic scenarios. - -### 2f. Deterministic generation - -All three existing fixtures appear to have been hand-constructed (no generation script exists despite `CONTRIBUTING.md` requiring one for new XDR fixtures). For reproducibility: -- The XDR itself is deterministic given fixed inputs (source, sequence, operations, fee). -- A script that generates fixtures from a JSON spec would make re-creation auditable. -- Fee bumps and alternative envelope types should be generated, not hand-edited. - ---- - -## 3. Portability strategy comparison - -### Strategy A — TESTNET-only, extend coverage explicitly - -Keep TESTNET as the sole network. Add a machine-readable `transactions/index.json` that records the passphrase, envelope type, Stellar transaction hash, and per-operation metadata. Add a fee-bump fixture. Do not add signed fixtures yet. - -| Criterion | Score | Notes | -|-----------|-------|-------| -| Realism | ✅ | TESTNET is the correct context for all fixture addresses | -| Reproducibility | ✅ | Deterministic: passphrase is fixed, hashes are recorded | -| Security | ✅ | No secret keys; `check-secrets.mjs` continues to apply | -| Consumer complexity | ✅ Low | One passphrase, one code path | -| Storage | ✅ Minimal | One XDR file per fixture type | -| Compatibility | ✅ | No downstream changes needed for current consumers | -| Extension decoder | ⚠️ | Still needs fee-bump handling; index makes the gap detectable | -| Migration | None needed | Index and fee-bump fixture are additive | - -### Strategy B — Multi-network support - -Add a `network` field to each fixture (TESTNET or PUBLIC). Ship both TESTNET and PUBLIC variants of each fixture. - -| Criterion | Score | Notes | -|-----------|-------|-------| -| Realism | ❌ | Fixture addresses are synthetic testnet addresses — they are meaningless on PUBLIC | -| Reproducibility | ⚠️ | Requires maintaining parallel fixture sets | -| Security | ✅ | Still unsigned | -| Consumer complexity | ❌ High | Two code paths, disambiguation logic, risk of wrong passphrase being used | -| Storage | ❌ | Doubles fixture count with no evaluation benefit | -| Compatibility | ❌ | Breaking: evaluation-manifest.json would need network tagging; downstream consumers need updates | -| Migration | Significant | All consumers need passphrase-selection logic | - -**Recommendation: Strategy A.** Multi-network support adds complexity and storage with no evaluation benefit given that fixture addresses are synthetic TESTNET data. The correct path is making TESTNET explicit in machine-readable metadata and adding the missing envelope type coverage. - ---- - -## 4. Prototype - -A fee-bump envelope wrapping `payment.xdr` was generated during this spike: - -**`transactions/fee_bump_payment.xdr`** (prototype — not committed yet): - -``` -AAAABQAAAAA4cpZHz94Bws4osmxXRyc39cNWGhdhGFvYWJpDzgu6dQAAAAAAAAPoAAAAAgAAAACjHAa9 -Rj45I7warb3kixaXbAgHFzc7gZoGjzK3prOLawAAAGQAAAAAAAAAAgAAAAEAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAEAAAAAAAAAAQAAAACi1V0pXlo1q0Sz766lEpuiK4i6Pil2YUX97KOwjjivUwAAAAAAAAAAH -c1lAAAAAAAAAAAAAAAAAAAAAAA= -``` - -| Field | Value | -|-------|-------| -| Envelope type | `envelopeTypeTxFeeBump` | -| Fee-bump source (outer) | `GA4HFFSHZ7PADQWOFCZGYV2HE437LQ2WDILWCGC33BMJUQ6OBO5HKI5D` (`clean_wallet_2`) | -| Inner tx source | `GCRRYBV5IY7DSI54DKW33ZELC2LWYCAHC43TXAM2A2HTFN5GWOFWXPC2` (`clean_wallet_1`) | -| Inner operation | `payment` to `GCRNKXJJLZNDLK2EWPX25JISTORCXCF2HYUXMYKF7XWKHMEOHCXVGP4J` (`suspicious_wallet_1`) | -| Outer fee | 500 stroops | -| Inner fee | 100 stroops | -| Signed | No | -| Stellar hash (TESTNET) | `7ab7ae0df710a6d37484f403f7d2ccd14b88832c843b961cd5f52311a027876f` | - -This prototype exercises the decoder's fee-bump path: the destination being scored is inside the inner transaction, not at the top level of the outer envelope. - ---- - -## 5. Hash / signature / passphrase implications summary - -| Scenario | What changes | What breaks if wrong | -|----------|-------------|---------------------| -| Wrong passphrase on decode | Transaction hash is wrong | Hash-based ledger lookups, signature verification | -| Decoder reads `.source` on a fee-bump | Gets outer fee source, not inner tx source | Wrong account attributed as the transaction initiator | -| Decoder skips `.operations` traversal in fee-bump | Misses inner operations | Risk scoring fails silently — no destinations extracted | -| XDR file modified | File SHA-256 changes → `verify-manifest` catches it | N/A (manifest hash catches this) | -| Fixture used without consulting index | Consumer guesses passphrase | Wrong hash on a TESTNET fixture decoded as PUBLIC | - ---- - -## 6. Recommendations - -1. **Add `transactions/index.json`** — machine-readable index recording for each fixture: filename, `network`, `passphrase`, `envelopeType`, `signed`, `stellarHashTestnet`, a `description`, and a `sources`/`destinations` summary. This is the minimal change that closes the silent-wrong-passphrase risk. - -2. **Add a fee-bump fixture** (`transactions/fee_bump_payment.xdr`) using the prototype above. Update `transactions/README.md` and `transactions/index.json`. This is the highest-value missing envelope type — it exercises a decoder code path that currently has no test coverage. - -3. **Add `transactions/index.json` to `evaluation-manifest.json` inputs** — hash it alongside the XDR files so the manifest covers the metadata too. - -4. **Do not add PUBLIC fixtures** — the fixture addresses are synthetic TESTNET data. A PUBLIC variant would produce a different hash but identical operations, adding no evaluation signal and significant consumer confusion. - -5. **Do not add signed fixtures yet** — signed fixtures require a test keypair (or a deterministic derivation), add `check-secrets.mjs` complexity, and do not cover any extension decode path that unsigned fixtures miss. Revisit when the extension gains a verify-before-relay feature. - -6. **Add a generation script** — CONTRIBUTING.md already requires new XDR fixtures to come from a script. A `scripts/generate-transactions.mjs` driven by a JSON spec would make all fixtures reproducible from scratch without the Stellar SDK being installed permanently. - ---- - -## 7. Follow-up issues - -| Issue | Priority | Owner | -|-------|----------|-------| -| Add `transactions/index.json` with passphrase and hash fields | High | testkit | -| Add `fee_bump_payment.xdr` fixture | High | testkit | -| Add fee-bump decode path to grydlock-extension decoder | High | extension | -| Add `scripts/generate-transactions.mjs` | Medium | testkit | -| Document fee-bump inner-vs-outer source in extension decoder | Medium | extension | -| Investigate muxed account (`M...`) address handling in decoder | Low | extension | diff --git a/evaluation-manifest.json b/evaluation-manifest.json deleted file mode 100644 index d97530c..0000000 --- a/evaluation-manifest.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "_comment": "Provenance manifest for the grydlock-testkit evaluation dataset. Regenerate with: npm run generate-manifest. Verify with: npm run verify-manifest.", - "manifestVersion": "1.0.0", - "fixtureRelease": "0.1.0", - "sourceCommit": "c8a50e05ee5fc469606d6341da438dda9280b6e8", - "generatedAt": "2026-08-18T00:40:12.159Z", - "inputs": { - "destinations": { - "path": "destinations.json", - "sha256": "6f8fd8a08337490dc381a60b73a241b902e14d8dbd8e6e9e2b8a678710569e3c" - }, - "scores": { - "path": "scores.json", - "sha256": "bd32acb0e615b3e708983929faec92df314d074a4d1a611f386e96e325358a77" - }, - "transaction_payment": { - "path": "transactions/payment.xdr", - "sha256": "1005779f853e22f8c9ee4ab5dcfd3dc5e5317367e98c9a9da4b88308729cfe88" - }, - "transaction_path_payment": { - "path": "transactions/path_payment.xdr", - "sha256": "a2a544b83a7e56fb821ed035cab54303d69f5d861ccf8733dd0759df83639e84" - }, - "transaction_change_trust": { - "path": "transactions/change_trust.xdr", - "sha256": "5f773b38f2edd7622769cce0affce59f0f66c20e59e9ba3957f9c369590cc0f6" - }, - "transaction_fee_bump": { - "path": "transactions/fee_bump_payment.xdr", - "sha256": "f1ce585df3473ee1a254980d36022f5245d2e90eed554ec3d31d4d06c769a4cc" - }, - "transaction_index": { - "path": "transactions/index.json", - "sha256": "dbd4ba71abea2e3bc496c831a67be894efa1fa850527a1c4411ba694758d66ae" - } - }, - "mappingVersion": "1.0.0", - "tierThresholds": { - "clean": { - "max": 29 - }, - "suspicious": { - "min": 30, - "max": 69 - }, - "malicious": { - "min": 70 - } - }, - "expectedCounts": { - "total": 12, - "clean": 4, - "suspicious": 3, - "malicious": 5 - }, - "evaluatorVersion": ">=0.1.0", - "resultSchema": { - "$ref": "EVALUATION_RESULT_SCHEMA.json" - } -} diff --git a/scripts/generate-manifest.mjs b/scripts/generate-manifest.mjs deleted file mode 100644 index 1948fd4..0000000 --- a/scripts/generate-manifest.mjs +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env node -/** - * generate-manifest.mjs - * - * Computes SHA-256 hashes for all input files and writes evaluation-manifest.json. - * - * Usage: - * npm run generate-manifest - * - * The generated manifest is committed to the repository. It must be regenerated - * (and the updated file committed) whenever any input file changes. - * - * Versioning rules: - * manifestVersion — bump the major when the manifest schema changes shape. - * fixtureRelease — mirrors package.json "version"; bump with dataset releases. - * mappingVersion — bump whenever tier threshold boundaries change. - * evaluatorVersion — semver range; tighten when evaluation methodology requires it. - */ - -import { createHash } from 'node:crypto'; -import { readFileSync, writeFileSync } from 'node:fs'; -import { execSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; -import { resolve } from 'node:path'; - -const root = fileURLToPath(new URL('..', import.meta.url)); - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Compute the SHA-256 hex digest of a file on disk. */ -function sha256File(relPath) { - const abs = resolve(root, relPath); - const buf = readFileSync(abs); - return createHash('sha256').update(buf).digest('hex'); -} - -/** Resolve a value from the current manifest if it exists, else return the fallback. */ -function preserve(current, key, fallback) { - return current?.[key] ?? fallback; -} - -// --------------------------------------------------------------------------- -// Read current state -// --------------------------------------------------------------------------- - -const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf-8')); - -let currentManifest = null; -const manifestPath = resolve(root, 'evaluation-manifest.json'); -try { - currentManifest = JSON.parse(readFileSync(manifestPath, 'utf-8')); -} catch { - // No existing manifest — first generation. -} - -// Determine git HEAD commit (falls back gracefully outside a git repo). -let sourceCommit = 'unknown'; -try { - sourceCommit = execSync('git rev-parse HEAD', { cwd: root }).toString().trim(); -} catch { - // Running outside a git checkout (e.g. downloaded zip). -} - -// --------------------------------------------------------------------------- -// Input files -// --------------------------------------------------------------------------- - -const INPUT_FILES = [ - { key: 'destinations', path: 'destinations.json' }, - { key: 'scores', path: 'scores.json' }, - { key: 'transaction_payment', path: 'transactions/payment.xdr' }, - { key: 'transaction_path_payment', path: 'transactions/path_payment.xdr' }, - { key: 'transaction_change_trust', path: 'transactions/change_trust.xdr' }, - { key: 'transaction_fee_bump', path: 'transactions/fee_bump_payment.xdr' }, - { key: 'transaction_index', path: 'transactions/index.json' }, -]; - -const inputs = {}; -for (const { key, path } of INPUT_FILES) { - inputs[key] = { path, sha256: sha256File(path) }; -} - -// --------------------------------------------------------------------------- -// Derive expected counts from the current destinations.json -// --------------------------------------------------------------------------- - -const destinations = JSON.parse( - readFileSync(resolve(root, 'destinations.json'), 'utf-8') -).destinations; - -const labelCounts = { clean: 0, suspicious: 0, malicious: 0 }; -for (const d of destinations) { - if (labelCounts[d.label] !== undefined) labelCounts[d.label]++; -} - -const expectedCounts = { - total: destinations.length, - clean: labelCounts.clean, - suspicious: labelCounts.suspicious, - malicious: labelCounts.malicious, -}; - -// --------------------------------------------------------------------------- -// Assemble manifest -// --------------------------------------------------------------------------- - -// Preserve version fields that require human decision when changed. -// New fields use sensible defaults that match the initial release baseline. -const manifest = { - _comment: - 'Provenance manifest for the grydlock-testkit evaluation dataset. ' + - 'Regenerate with: npm run generate-manifest. Verify with: npm run verify-manifest.', - - manifestVersion: preserve(currentManifest, 'manifestVersion', '1.0.0'), - fixtureRelease: pkg.version, - sourceCommit, - generatedAt: new Date().toISOString(), - - inputs, - - // mappingVersion tracks tier threshold changes independently of the fixture release. - mappingVersion: preserve(currentManifest, 'mappingVersion', '1.0.0'), - tierThresholds: preserve(currentManifest, 'tierThresholds', { - clean: { max: 29 }, - suspicious: { min: 30, max: 69 }, - malicious: { min: 70 }, - }), - - expectedCounts, - - // Minimum grydlock-research version required to evaluate this dataset. - evaluatorVersion: preserve(currentManifest, 'evaluatorVersion', '>=0.1.0'), - - resultSchema: { $ref: 'EVALUATION_RESULT_SCHEMA.json' }, -}; - -// --------------------------------------------------------------------------- -// Write -// --------------------------------------------------------------------------- - -writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); - -console.log('evaluation-manifest.json written.'); -console.log(' fixtureRelease : ' + manifest.fixtureRelease); -console.log(' sourceCommit : ' + manifest.sourceCommit); -console.log(' generatedAt : ' + manifest.generatedAt); -console.log(' expectedCounts : ' + JSON.stringify(manifest.expectedCounts)); -console.log(' inputs hashed : ' + Object.keys(inputs).join(', ')); diff --git a/scripts/validate-fixtures.mjs b/scripts/validate-fixtures.mjs index c12c544..045a8bb 100644 --- a/scripts/validate-fixtures.mjs +++ b/scripts/validate-fixtures.mjs @@ -1,5 +1,4 @@ -import { readFileSync, existsSync } from 'node:fs'; -import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { VALID_LABELS, VALID_RISK_PATTERNS } from './lib/taxonomy.mjs'; @@ -85,101 +84,6 @@ if (expectedCounts) { } } -// --------------------------------------------------------------------------- -// Manifest cross-check -// Verify that evaluation-manifest.json exists and its expectedCounts agree -// with the destinations we just validated. Full hash verification is done by -// scripts/verify-manifest.mjs; here we only check the counts to keep the -// validate step fast and free of I/O on every binary fixture. -// --------------------------------------------------------------------------- - -const manifestPath = `${root}/evaluation-manifest.json`; -if (!existsSync(manifestPath)) { - errors.push( - 'evaluation-manifest.json is missing. Run: npm run generate-manifest' - ); -} else { - let manifest; - try { - manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')); - } catch (err) { - errors.push(`evaluation-manifest.json is not valid JSON: ${err.message}`); - manifest = null; - } - - if (manifest) { - // fixtureRelease must match package.json version. - let pkgVersion; - try { - pkgVersion = JSON.parse(readFileSync(`${root}/package.json`, 'utf-8')).version; - } catch { - /* ignore — package.json absence is caught elsewhere */ - } - if (pkgVersion && manifest.fixtureRelease !== pkgVersion) { - errors.push( - `evaluation-manifest.json fixtureRelease="${manifest.fixtureRelease}" ` + - `does not match package.json version="${pkgVersion}". ` + - 'Run: npm run generate-manifest' - ); - } - - // expectedCounts must agree with the current label distribution. - if (manifest.expectedCounts) { - const ec = manifest.expectedCounts; - const labelCounts = { clean: 0, suspicious: 0, malicious: 0 }; - for (const d of destinations) { - if (labelCounts[d.label] !== undefined) labelCounts[d.label]++; - } - - if (typeof ec.total === 'number' && ec.total !== destinations.length) { - errors.push( - `evaluation-manifest.json expectedCounts.total=${ec.total} but ` + - `destinations.json has ${destinations.length} entries. ` + - 'Run: npm run generate-manifest' - ); - } - for (const label of ['clean', 'suspicious', 'malicious']) { - if (typeof ec[label] === 'number' && ec[label] !== labelCounts[label]) { - errors.push( - `evaluation-manifest.json expectedCounts.${label}=${ec[label]} but ` + - `destinations.json has ${labelCounts[label]} ${label} entries. ` + - 'Run: npm run generate-manifest' - ); - } - } - } else { - errors.push('evaluation-manifest.json is missing expectedCounts field.'); - } - - // Verify SHA-256 hashes for the two JSON fixture files (fast, text-only). - // XDR binary files are covered by verify-manifest (run separately in CI). - const HASH_TARGETS = [ - { key: 'destinations', path: 'destinations.json' }, - { key: 'scores', path: 'scores.json' }, - ]; - for (const { key, path } of HASH_TARGETS) { - const entry = manifest.inputs?.[key]; - if (!entry) { - errors.push(`evaluation-manifest.json missing inputs.${key}`); - continue; - } - try { - const actual = createHash('sha256') - .update(readFileSync(`${root}/${path}`)) - .digest('hex'); - if (actual !== entry.sha256) { - errors.push( - `inputs.${key} hash mismatch for "${path}" — file changed since manifest was generated. ` + - 'Run: npm run generate-manifest' - ); - } - } catch (err) { - errors.push(`Could not hash "${path}": ${err.message}`); - } - } - } -} - if (errors.length > 0) { console.error('Fixture validation failed:\n' + errors.map((e) => ' - ' + e).join('\n')); process.exit(1); diff --git a/scripts/verify-manifest.mjs b/scripts/verify-manifest.mjs deleted file mode 100644 index 1e275fd..0000000 --- a/scripts/verify-manifest.mjs +++ /dev/null @@ -1,241 +0,0 @@ -#!/usr/bin/env node -/** - * verify-manifest.mjs - * - * Validates evaluation-manifest.json against the current state of the repo. - * - * Checks: - * 1. Manifest file exists and is valid JSON. - * 2. Required top-level fields are present and well-typed. - * 3. fixtureRelease matches package.json version. - * 4. Every input file listed in manifest.inputs exists on disk and its - * SHA-256 matches the recorded hash (altered-input detection). - * 5. expectedCounts match the actual label distribution in destinations.json. - * 6. tierThresholds are internally consistent (no gap or overlap). - * - * Exit codes: - * 0 — manifest is valid and all hashes match. - * 1 — one or more checks failed (details printed to stderr). - * - * Usage: - * npm run verify-manifest - */ - -import { createHash } from 'node:crypto'; -import { readFileSync, existsSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { resolve } from 'node:path'; - -const root = fileURLToPath(new URL('..', import.meta.url)); -const errors = []; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function fail(msg) { - errors.push(msg); -} - -function sha256File(absPath) { - const buf = readFileSync(absPath); - return createHash('sha256').update(buf).digest('hex'); -} - -// --------------------------------------------------------------------------- -// 1. Load manifest -// --------------------------------------------------------------------------- - -const manifestPath = resolve(root, 'evaluation-manifest.json'); -if (!existsSync(manifestPath)) { - console.error('FAIL: evaluation-manifest.json not found. Run: npm run generate-manifest'); - process.exit(1); -} - -let manifest; -try { - manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')); -} catch (err) { - console.error('FAIL: evaluation-manifest.json is not valid JSON: ' + err.message); - process.exit(1); -} - -// --------------------------------------------------------------------------- -// 2. Required fields presence and types -// --------------------------------------------------------------------------- - -const SEMVER_RE = /^\d+\.\d+\.\d+$/; - -const REQUIRED_FIELDS = [ - ['manifestVersion', 'string'], - ['fixtureRelease', 'string'], - ['sourceCommit', 'string'], - ['generatedAt', 'string'], - ['inputs', 'object'], - ['mappingVersion', 'string'], - ['tierThresholds', 'object'], - ['expectedCounts', 'object'], - ['evaluatorVersion', 'string'], - ['resultSchema', 'object'], -]; - -for (const [field, type] of REQUIRED_FIELDS) { - if (manifest[field] === undefined) { - fail(`Missing required field: ${field}`); - } else if (typeof manifest[field] !== type) { - fail(`Field "${field}" must be a ${type}, got ${typeof manifest[field]}`); - } -} - -if (manifest.manifestVersion && !SEMVER_RE.test(manifest.manifestVersion)) { - fail(`manifestVersion "${manifest.manifestVersion}" is not a valid semver string`); -} -if (manifest.fixtureRelease && !SEMVER_RE.test(manifest.fixtureRelease)) { - fail(`fixtureRelease "${manifest.fixtureRelease}" is not a valid semver string`); -} -if (manifest.mappingVersion && !SEMVER_RE.test(manifest.mappingVersion)) { - fail(`mappingVersion "${manifest.mappingVersion}" is not a valid semver string`); -} -if (manifest.generatedAt && isNaN(Date.parse(manifest.generatedAt))) { - fail(`generatedAt "${manifest.generatedAt}" is not a valid ISO-8601 datetime`); -} - -// --------------------------------------------------------------------------- -// 3. fixtureRelease matches package.json -// --------------------------------------------------------------------------- - -let pkg; -try { - pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf-8')); -} catch (err) { - fail('Could not read package.json: ' + err.message); -} - -if (pkg && manifest.fixtureRelease !== pkg.version) { - fail( - `fixtureRelease "${manifest.fixtureRelease}" does not match package.json version "${pkg.version}". ` + - 'Run: npm run generate-manifest' - ); -} - -// --------------------------------------------------------------------------- -// 4. Input file hashes (altered-input detection) -// --------------------------------------------------------------------------- - -if (manifest.inputs && typeof manifest.inputs === 'object') { - for (const [key, entry] of Object.entries(manifest.inputs)) { - if (typeof entry !== 'object' || !entry.path || !entry.sha256) { - fail(`inputs.${key}: entry must have "path" and "sha256" fields`); - continue; - } - - const absPath = resolve(root, entry.path); - - if (!existsSync(absPath)) { - fail(`inputs.${key}: file not found at "${entry.path}"`); - continue; - } - - let actualHash; - try { - actualHash = sha256File(absPath); - } catch (err) { - fail(`inputs.${key}: could not hash "${entry.path}": ${err.message}`); - continue; - } - - if (actualHash !== entry.sha256) { - fail( - `inputs.${key}: hash mismatch for "${entry.path}"\n` + - ` expected : ${entry.sha256}\n` + - ` actual : ${actualHash}\n` + - ' → file has been modified since the manifest was generated. ' + - 'Run: npm run generate-manifest' - ); - } - } -} else { - fail('manifest.inputs must be a non-null object'); -} - -// --------------------------------------------------------------------------- -// 5. expectedCounts match actual destinations.json label distribution -// --------------------------------------------------------------------------- - -let destinations = []; -try { - destinations = JSON.parse( - readFileSync(resolve(root, 'destinations.json'), 'utf-8') - ).destinations; -} catch (err) { - fail('Could not read destinations.json: ' + err.message); -} - -if (destinations.length > 0 && manifest.expectedCounts) { - const labelCounts = { clean: 0, suspicious: 0, malicious: 0 }; - for (const d of destinations) { - if (labelCounts[d.label] !== undefined) labelCounts[d.label]++; - } - - const ec = manifest.expectedCounts; - if (ec.total !== destinations.length) { - fail( - `expectedCounts.total=${ec.total} but destinations.json has ${destinations.length} entries. ` + - 'Run: npm run generate-manifest' - ); - } - for (const label of ['clean', 'suspicious', 'malicious']) { - if (ec[label] !== labelCounts[label]) { - fail( - `expectedCounts.${label}=${ec[label]} but destinations.json has ${labelCounts[label]} ${label} entries. ` + - 'Run: npm run generate-manifest' - ); - } - } -} - -// --------------------------------------------------------------------------- -// 6. tierThresholds internal consistency -// --------------------------------------------------------------------------- - -if (manifest.tierThresholds) { - const t = manifest.tierThresholds; - if ( - t.clean && t.suspicious && - typeof t.clean.max === 'number' && typeof t.suspicious.min === 'number' && - t.suspicious.min !== t.clean.max + 1 - ) { - fail( - `tierThresholds gap/overlap: clean.max=${t.clean.max} but suspicious.min=${t.suspicious.min}. ` + - 'Expected suspicious.min === clean.max + 1 for contiguous tiers.' - ); - } - if ( - t.suspicious && t.malicious && - typeof t.suspicious.max === 'number' && typeof t.malicious.min === 'number' && - t.malicious.min !== t.suspicious.max + 1 - ) { - fail( - `tierThresholds gap/overlap: suspicious.max=${t.suspicious.max} but malicious.min=${t.malicious.min}. ` + - 'Expected malicious.min === suspicious.max + 1 for contiguous tiers.' - ); - } -} - -// --------------------------------------------------------------------------- -// Report -// --------------------------------------------------------------------------- - -if (errors.length > 0) { - console.error('Manifest verification FAILED:\n' + errors.map((e) => ' ✗ ' + e).join('\n')); - process.exit(1); -} - -console.log('Manifest verification passed.'); -console.log(' manifestVersion : ' + manifest.manifestVersion); -console.log(' fixtureRelease : ' + manifest.fixtureRelease); -console.log(' sourceCommit : ' + manifest.sourceCommit); -console.log(' generatedAt : ' + manifest.generatedAt); -console.log(' mappingVersion : ' + manifest.mappingVersion); -console.log(' expectedCounts : ' + JSON.stringify(manifest.expectedCounts)); -console.log(' inputs verified : ' + Object.keys(manifest.inputs).join(', ')); diff --git a/transactions/README.md b/transactions/README.md index e052e5a..c19fd8c 100644 --- a/transactions/README.md +++ b/transactions/README.md @@ -36,8 +36,6 @@ A fixture subsection has: ## Current fixtures -See [`index.json`](./index.json) for a machine-readable record of each fixture's passphrase, envelope type, Stellar transaction hash, and per-operation metadata. Always consult `index.json` before decoding — the XDR encoding does not embed the network passphrase. - ### `payment.xdr` **Envelope** @@ -88,22 +86,3 @@ See [`index.json`](./index.json) for a machine-readable record of each fixture's | # | Operation | Op Source | Params | | --- | --- | --- | --- | | 0 | `changeTrust` (SCAM asset) | — | no destination — trustline change only | - -### `fee_bump_payment.xdr` - -**Envelope** - -| Field | Value | -| --- | --- | -| Fee Bump | yes (outer fee source: `clean_wallet_2`) | -| Source | `clean_wallet_1` (inner transaction source) | -| Memo | none | -| Time Bounds | none | - -**Operations** - -| # | Operation | Op Source | Params | -| --- | --- | --- | --- | -| 0 | `payment` (native XLM) | — | destination: `suspicious_wallet_1` | - -**Notes:** This is an `envelopeTypeTxFeeBump` envelope. The outer fee-bump source (`clean_wallet_2`) pays the network fee on behalf of the inner transaction. The inner transaction source (`clean_wallet_1`) is the actual initiator and the account whose operations must be decoded and risk-scored. A decoder that reads `tx.source` on a `FeeBumpTransaction` object receives the fee-bump source, not the inner transaction source — it must call `tx.innerTransaction.source` and `tx.innerTransaction.operations` to reach the relevant data. diff --git a/transactions/fee_bump_payment.xdr b/transactions/fee_bump_payment.xdr deleted file mode 100644 index 0690ae0..0000000 --- a/transactions/fee_bump_payment.xdr +++ /dev/null @@ -1 +0,0 @@ -AAAABQAAAAA4cpZHz94Bws4osmxXRyc39cNWGhdhGFvYWJpDzgu6dQAAAAAAAAPoAAAAAgAAAACjHAa9Rj45I7warb3kixaXbAgHFzc7gZoGjzK3prOLawAAAGQAAAAAAAAAAgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAACi1V0pXlo1q0Sz766lEpuiK4i6Pil2YUX97KOwjjivUwAAAAAAAAAAHc1lAAAAAAAAAAAAAAAAAAAAAAA= \ No newline at end of file diff --git a/transactions/index.json b/transactions/index.json deleted file mode 100644 index a07a007..0000000 --- a/transactions/index.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "_comment": "Machine-readable index of all transaction XDR fixtures. Consumers must pass the recorded passphrase to TransactionBuilder.fromXDR — the XDR encoding does not embed the network passphrase and decoding under a wrong passphrase will succeed silently but produce a wrong transaction hash.", - "network": "TESTNET", - "passphrase": "Test SDF Network ; September 2015", - "fixtures": [ - { - "file": "transactions/payment.xdr", - "envelopeType": "envelopeTypeTx", - "signed": false, - "stellarHashTestnet": "f8310e15760cc1c233efef19dec99146cad5ba9f8e61947d3e36d76d5d7e6636", - "source": "GCRRYBV5IY7DSI54DKW33ZELC2LWYCAHC43TXAM2A2HTFN5GWOFWXPC2", - "description": "Plain payment from clean_wallet_1 to suspicious_wallet_1. Tests basic payment decode.", - "operations": [ - { - "index": 0, - "type": "payment", - "destination": "GCRNKXJJLZNDLK2EWPX25JISTORCXCF2HYUXMYKF7XWKHMEOHCXVGP4J", - "asset": "native", - "amount": "50.0000000" - } - ] - }, - { - "file": "transactions/path_payment.xdr", - "envelopeType": "envelopeTypeTx", - "signed": false, - "stellarHashTestnet": "1877a9c875a93739f36ea36edb55d9a65dd8848f046ba9339fb6569d9c3feec0", - "source": "GCRRYBV5IY7DSI54DKW33ZELC2LWYCAHC43TXAM2A2HTFN5GWOFWXPC2", - "description": "Path payment strict-send (XLM → USDC) from clean_wallet_1 to malicious_wallet_1. Tests multi-hop decode.", - "operations": [ - { - "index": 0, - "type": "pathPaymentStrictSend", - "destination": "GD7XPB2A7CG5Z4ICV24B3LXCRHAEJRFEK4OEW3ZIOQAPJOHAXBB7QHGE", - "sendAsset": "native", - "sendAmount": "10.0000000", - "destAsset": "USDC:GBWPMNXNRLA3VMBTWZHWN7VLUZPXBZUEOMPD6OIQKYCZNDJ2SY4ACC5B", - "destMin": "1.0000000", - "path": [] - } - ] - }, - { - "file": "transactions/change_trust.xdr", - "envelopeType": "envelopeTypeTx", - "signed": false, - "stellarHashTestnet": "2cfea90bd307d103e0dec8c999ffdc224d2eff89f0c022cafc2e0a187ed0272f", - "source": "GCRNKXJJLZNDLK2EWPX25JISTORCXCF2HYUXMYKF7XWKHMEOHCXVGP4J", - "description": "Change trust to add the SCAM asset trustline from suspicious_wallet_1. Tests trustline-establishment decode.", - "operations": [ - { - "index": 0, - "type": "changeTrust", - "asset": "SCAM:GAJLLIIPHII6OCG4KQJIGPCHVN6DNCRBXHX6DEUTPE7MQ6OONAYBRLET", - "limit": "922337203685.4775807" - } - ] - }, - { - "file": "transactions/fee_bump_payment.xdr", - "envelopeType": "envelopeTypeTxFeeBump", - "signed": false, - "stellarHashTestnet": "7ab7ae0df710a6d37484f403f7d2ccd14b88832c843b961cd5f52311a027876f", - "feeBumpSource": "GA4HFFSHZ7PADQWOFCZGYV2HE437LQ2WDILWCGC33BMJUQ6OBO5HKI5D", - "source": "GCRRYBV5IY7DSI54DKW33ZELC2LWYCAHC43TXAM2A2HTFN5GWOFWXPC2", - "description": "Fee-bump wrapping payment.xdr. Outer fee source is clean_wallet_2; inner tx source is clean_wallet_1. Tests fee-bump envelope decode — a decoder must traverse into the inner transaction to extract operations and source; reading the top-level source gives the fee-bump source, not the transaction initiator.", - "innerTransaction": { - "stellarHashTestnet": "f8310e15760cc1c233efef19dec99146cad5ba9f8e61947d3e36d76d5d7e6636", - "source": "GCRRYBV5IY7DSI54DKW33ZELC2LWYCAHC43TXAM2A2HTFN5GWOFWXPC2" - }, - "operations": [ - { - "index": 0, - "type": "payment", - "destination": "GCRNKXJJLZNDLK2EWPX25JISTORCXCF2HYUXMYKF7XWKHMEOHCXVGP4J", - "asset": "native", - "amount": "50.0000000" - } - ] - } - ] -}