diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53ce3ea..7c5ae61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,50 @@ 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 @@ -58,4 +102,3 @@ 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 new file mode 100644 index 0000000..c8462b0 --- /dev/null +++ b/EVALUATION_RESULT_SCHEMA.json @@ -0,0 +1,188 @@ +{ + "$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 6261d3f..8c420b3 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,10 @@ 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 new file mode 100644 index 0000000..0834e6f --- /dev/null +++ b/docs/spike-transaction-portability.md @@ -0,0 +1,239 @@ +# 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 new file mode 100644 index 0000000..d97530c --- /dev/null +++ b/evaluation-manifest.json @@ -0,0 +1,60 @@ +{ + "_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 new file mode 100644 index 0000000..1948fd4 --- /dev/null +++ b/scripts/generate-manifest.mjs @@ -0,0 +1,150 @@ +#!/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 045a8bb..c12c544 100644 --- a/scripts/validate-fixtures.mjs +++ b/scripts/validate-fixtures.mjs @@ -1,4 +1,5 @@ -import { readFileSync } from 'node:fs'; +import { readFileSync, existsSync } from 'node:fs'; +import { createHash } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { VALID_LABELS, VALID_RISK_PATTERNS } from './lib/taxonomy.mjs'; @@ -84,6 +85,101 @@ 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 new file mode 100644 index 0000000..1e275fd --- /dev/null +++ b/scripts/verify-manifest.mjs @@ -0,0 +1,241 @@ +#!/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 c19fd8c..e052e5a 100644 --- a/transactions/README.md +++ b/transactions/README.md @@ -36,6 +36,8 @@ 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** @@ -86,3 +88,22 @@ A fixture subsection has: | # | 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 new file mode 100644 index 0000000..0690ae0 --- /dev/null +++ b/transactions/fee_bump_payment.xdr @@ -0,0 +1 @@ +AAAABQAAAAA4cpZHz94Bws4osmxXRyc39cNWGhdhGFvYWJpDzgu6dQAAAAAAAAPoAAAAAgAAAACjHAa9Rj45I7warb3kixaXbAgHFzc7gZoGjzK3prOLawAAAGQAAAAAAAAAAgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAACi1V0pXlo1q0Sz766lEpuiK4i6Pil2YUX97KOwjjivUwAAAAAAAAAAHc1lAAAAAAAAAAAAAAAAAAAAAAA= \ No newline at end of file diff --git a/transactions/index.json b/transactions/index.json new file mode 100644 index 0000000..a07a007 --- /dev/null +++ b/transactions/index.json @@ -0,0 +1,82 @@ +{ + "_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" + } + ] + } + ] +}