diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a631146..877ff72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -254,4 +254,34 @@ jobs: -D clippy::missing_errors_doc \ -D clippy::missing_panics_doc \ -D clippy::redundant_closure_for_method_calls \ - -D clippy::cloned_instead_of_copied \ No newline at end of file + -D clippy::cloned_instead_of_copied + + differential-tests: + name: TypeScript Differential Tests + runs-on: ubuntu-latest + needs: quality + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-diff-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Run TypeScript differential tests + run: make diff-test \ No newline at end of file diff --git a/.gitignore b/.gitignore index 43f2829..4de9801 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ Cargo.lock /bindings/ node_modules/ +# Differential test fixtures are checked in (not generated) +!ts-differential-tests/ + # Deployment artifacts /deployments/ *.wasm diff --git a/Makefile b/Makefile index 25b404b..3d69787 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ FUTURENET_DRY_RUN ?= false .PHONY: help build build-legacy test test-rehearsal fuzz bench bench-export bench-username bench-double-verify bench-register-budget fmt lint docs docs-check abi check ci clean \ deploy-testnet deploy-mainnet bindings bindings-build invoke-version require-contract-id \ invoke-register invoke-lookup invoke-init invoke-stats install-target invoke-extend-ttl \ - export-registry validate-registry dr-test futurenet-smoke + export-registry validate-registry dr-test futurenet-smoke xdr-fixtures diff-test help: ## Show this help @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-25s\033[0m %s\n", $$1, $$2}' @@ -363,3 +363,12 @@ export-registry: require-contract-id ## Export full registry to JSON (admin) — validate-registry: require-contract-id ## Validate a registry export JSON against live state, no writes (CONTRACT_ID, EXPORT_FILE, ADMIN_SOURCE=admin for full diff) CONTRACT_ID=$(CONTRACT_ID) SOURCE=$(SOURCE) ADMIN_SOURCE=$(ADMIN_SOURCE) NETWORK=$(NETWORK) ./scripts/validate_registry.sh $(EXPORT_FILE) + +xdr-fixtures: ## Generate XDR fixtures for TypeScript differential testing + @echo "Generating XDR fixtures from Rust contract tests..." + @cargo test generate_xdr_fixtures -- --ignored --exact --nocapture + @echo "Copy the XDR output to ts-differential-tests/fixtures/get_address_octocat.xdr" + @echo "Copy the Stellar address to ts-differential-tests/fixtures/get_address_octocat.address" + +diff-test: ## Run TypeScript differential tests against XDR fixtures + @cd ts-differential-tests && npm install && npm test diff --git a/docs/ABI.md b/docs/ABI.md index 8f7a1f4..7e11d8c 100644 --- a/docs/ABI.md +++ b/docs/ABI.md @@ -9,6 +9,40 @@ updating this document, then include both files in the same change. Related docs: [README](../README.md) · [ARCHITECTURE](ARCHITECTURE.md) · [DEPLOYMENT](DEPLOYMENT.md) +## Differential Testing: TypeScript Bindings vs Contract Reads + +To prevent drift between TypeScript SDK bindings and the contract ABI after Rust changes, +differential tests verify that TypeScript XDR decoding matches Rust contract outputs. + +### How It Works + +1. **Generate XDR fixtures**: Run `make xdr-fixtures` to execute a Rust test that + calls contract functions (e.g., `get_address`) and outputs the XDR-encoded results. + +2. **Check in fixtures**: Copy the XDR output and expected values to + `ts-differential-tests/fixtures/*.xdr` and `*.address` files. + +3. **TypeScript decode test**: Run `make diff-test` (or CI job `differential-tests`) + to decode the XDR using the Stellar TypeScript SDK and compare against the golden + Rust values. + +### Updating Fixtures + +When the contract ABI changes (e.g., field reordering, type changes): + +1. Run `make xdr-fixtures` to regenerate XDR from the updated contract. +2. Update the fixture files in `ts-differential-tests/fixtures/`. +3. Commit both the Rust changes and updated fixtures together. + +If TypeScript decode fails, the bindings have drifted and need regeneration +(`make bindings`) or the TypeScript test needs updating to match the new ABI. + +### CI Integration + +The `differential-tests` CI job runs after the main quality gate and verifies +TypeScript decode matches Rust golden values. This catches ABI drift early in +the PR pipeline. + --- ## Types diff --git a/tests/generate_xdr_fixtures.rs b/tests/generate_xdr_fixtures.rs new file mode 100644 index 0000000..8b7476c --- /dev/null +++ b/tests/generate_xdr_fixtures.rs @@ -0,0 +1,56 @@ +//! Generate XDR fixtures for TypeScript differential testing. +//! +//! This test creates golden XDR outputs from contract invocations +//! that TypeScript clients can decode to verify bindings correctness. +//! +//! Run with: cargo test generate_xdr_fixtures -- --ignored --exact + +#![cfg(test)] + +use soroban_sdk::{testutils::Address as _, Address, Env, String}; +use trustbridge_contract::TrustBridgeContract; + +fn s(env: &Env, text: &str) -> String { + String::from_str(env, text) +} + +#[test] +#[ignore] +fn generate_xdr_fixtures() { + let env = Env::default(); + let admin = Address::generate(&env); + let user = Address::generate(&env); + let contract_id = env.register(TrustBridgeContract, ()); + + // Initialize contract + env.as_contract(&contract_id, || { + TrustBridgeContract::initialize(env.clone(), admin.clone()).unwrap(); + }); + + // Register a test user + let username = s(&env, "octocat"); + env.mock_all_auths(); + env.as_contract(&contract_id, || { + TrustBridgeContract::register( + env.clone(), + username.clone(), + user.clone(), + Vec::new(&env), + ) + .unwrap(); + }); + + // Get address and serialize to XDR + env.as_contract(&contract_id, || { + let record = TrustBridgeContract::get_address(env.clone(), username.clone()) + .expect("record should exist"); + + // Serialize the record to XDR for TypeScript to decode + let xdr = record.to_xdr(&env); + + println!("=== XDR Fixture for get_address('octocat') ==="); + println!("{}", xdr); + println!("=== Stellar Address ==="); + println!("{}", record.stellar_address.to_string()); + }); +} diff --git a/ts-differential-tests/README.md b/ts-differential-tests/README.md new file mode 100644 index 0000000..7d8cb53 --- /dev/null +++ b/ts-differential-tests/README.md @@ -0,0 +1,61 @@ +# TypeScript Differential Tests + +This directory contains differential tests that verify TypeScript SDK XDR decoding matches Rust contract outputs, preventing ABI drift after Rust changes. + +## Purpose + +After a wave of Rust changes, the TypeScript bindings generated by `stellar contract bindings typescript` can drift from the actual contract ABI. These tests catch that drift by: + +1. Generating XDR fixtures from Rust contract tests +2. Decoding those XDR fixtures using the Stellar TypeScript SDK +3. Comparing decoded values against golden Rust values + +## Setup + +```bash +npm install +``` + +## Running Tests + +```bash +# Run differential tests +npm test + +# Or via Makefile +make diff-test +``` + +## Generating New Fixtures + +When the contract ABI changes: + +1. Run the Rust fixture generator: + ```bash + make xdr-fixtures + ``` + +2. Copy the XDR output from the terminal to the appropriate fixture file: + - XDR output → `fixtures/get_address_octocat.xdr` + - Stellar address → `fixtures/get_address_octocat.address` + +3. Commit the updated fixtures with the Rust changes + +## Fixture Files + +- `fixtures/get_address_octocat.xdr` - Base64-encoded XDR ScVal for `get_address("octocat")` +- `fixtures/get_address_octocat.address` - Expected Stellar address string (G...) + +## How It Works + +The test in `fixtures.test.js`: +1. Loads the XDR fixture file +2. Parses it using `@stellar/stellar-sdk` XDR decoder +3. Extracts the Stellar address from the decoded `ContributorRecord` +4. Compares against the golden address value + +If the contract ABI changes (field reordering, type changes), the decode will fail, alerting you to update the TypeScript bindings or the test. + +## CI Integration + +The `differential-tests` job in `.github/workflows/ci.yml` runs these tests automatically on every PR, ensuring TypeScript bindings stay in sync with the contract. diff --git a/ts-differential-tests/fixtures.test.js b/ts-differential-tests/fixtures.test.js new file mode 100644 index 0000000..48922ad --- /dev/null +++ b/ts-differential-tests/fixtures.test.js @@ -0,0 +1,85 @@ +/** + * Differential tests: decode XDR fixtures using TypeScript SDK + * and compare against Rust contract golden values. + */ + +import { xdr, Address } from '@stellar/stellar-sdk'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +// Load XDR fixture +function loadFixture(name) { + const path = join(__dirname, 'fixtures', `${name}.xdr`); + const content = readFileSync(path, 'utf-8').trim(); + + // Skip placeholder comments + if (content.startsWith('#')) { + throw new Error( + `Fixture ${name} is a placeholder. Run 'make xdr-fixtures' to generate real fixtures.` + ); + } + + return content; +} + +// Load golden address value +function loadGoldenAddress(name) { + const path = join(__dirname, 'fixtures', `${name}.address`); + const content = readFileSync(path, 'utf-8').trim(); + + // Skip placeholder comments + if (content.startsWith('#')) { + throw new Error( + `Golden address ${name} is a placeholder. Run 'make xdr-fixtures' to generate real fixtures.` + ); + } + + return content; +} + +// Parse XDR and extract address +function parseAddressFromXdr(xdrString) { + const scVal = xdr.ScVal.fromXDR(xdrString, 'base64'); + + // Handle Option - Some(record) or None + if (scVal.switch().name === 'ScValSome') { + const record = scVal.value(); + + // ContributorRecord is a struct with stellar_address as first field + const stellarAddressBytes = record.value()[0].value().value(); + const address = Address.fromScAddress(stellarAddressBytes); + return address.toString(); + } + + return null; +} + +// Test: get_address fixture should decode to expected address +export default { + async test() { + console.log('Running differential tests for TypeScript bindings...'); + + // Test get_address fixture + const get_address_xdr = loadFixture('get_address_octocat'); + const decodedAddress = parseAddressFromXdr(get_address_xdr); + + if (!decodedAddress) { + throw new Error('Failed to decode address from XDR fixture'); + } + + console.log(`Decoded address: ${decodedAddress}`); + + // This address should match the golden value in the fixture + const goldenAddress = loadGoldenAddress('get_address_octocat'); + if (decodedAddress !== goldenAddress) { + throw new Error( + `Address mismatch: TS decoded ${decodedAddress} but golden is ${goldenAddress}` + ); + } + + console.log('✓ TypeScript decode matches Rust golden value'); + } +}; diff --git a/ts-differential-tests/fixtures/README.md b/ts-differential-tests/fixtures/README.md new file mode 100644 index 0000000..78d4a1d --- /dev/null +++ b/ts-differential-tests/fixtures/README.md @@ -0,0 +1,22 @@ +# XDR Fixtures for Differential Testing + +These XDR fixtures are generated from Rust contract tests and used to verify TypeScript bindings decode correctly. + +## Generating Fixtures + +Run the Rust test to generate fixtures: + +```bash +cargo test generate_xdr_fixtures -- --ignored --exact --nocapture +``` + +Copy the output XDR and address values into the corresponding `.xdr` and `.address` files. + +## Fixture Files + +- `get_address_octocat.xdr` - XDR-encoded ContributorRecord for username "octocat" +- `get_address_octocat.address` - Expected Stellar address string (G...) + +## Purpose + +These fixtures ensure that TypeScript SDK XDR decoding matches the contract's actual output format. If the contract ABI changes (e.g., field reordering, type changes), the TypeScript decode will fail, catching drift between bindings and contract. diff --git a/ts-differential-tests/fixtures/get_address_octocat.address b/ts-differential-tests/fixtures/get_address_octocat.address new file mode 100644 index 0000000..06e2dcc --- /dev/null +++ b/ts-differential-tests/fixtures/get_address_octocat.address @@ -0,0 +1,2 @@ +# Placeholder: Run `cargo test generate_xdr_fixtures -- --ignored --exact --nocapture` +# and paste the Stellar address string here (G...) diff --git a/ts-differential-tests/fixtures/get_address_octocat.xdr b/ts-differential-tests/fixtures/get_address_octocat.xdr new file mode 100644 index 0000000..952fec5 --- /dev/null +++ b/ts-differential-tests/fixtures/get_address_octocat.xdr @@ -0,0 +1,2 @@ +# Placeholder: Run `cargo test generate_xdr_fixtures -- --ignored --exact --nocapture` +# and paste the XDR output here (base64-encoded ScVal) diff --git a/ts-differential-tests/package.json b/ts-differential-tests/package.json new file mode 100644 index 0000000..ec638bc --- /dev/null +++ b/ts-differential-tests/package.json @@ -0,0 +1,13 @@ +{ + "name": "trustbridge-differential-tests", + "version": "1.0.0", + "description": "Differential tests between TypeScript bindings and contract reads", + "type": "module", + "scripts": { + "test": "node --test", + "test:fixtures": "node --test fixtures.test.js" + }, + "devDependencies": { + "@stellar/stellar-sdk": "^13.0.0" + } +}