Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
-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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ Cargo.lock
/bindings/
node_modules/

# Differential test fixtures are checked in (not generated)
!ts-differential-tests/

# Deployment artifacts
/deployments/
*.wasm
Expand Down
11 changes: 10 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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}'
Expand Down Expand Up @@ -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
34 changes: 34 additions & 0 deletions docs/ABI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions tests/generate_xdr_fixtures.rs
Original file line number Diff line number Diff line change
@@ -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());
});
}
61 changes: 61 additions & 0 deletions ts-differential-tests/README.md
Original file line number Diff line number Diff line change
@@ -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.
85 changes: 85 additions & 0 deletions ts-differential-tests/fixtures.test.js
Original file line number Diff line number Diff line change
@@ -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<ContributorRecord> - 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');
}
};
22 changes: 22 additions & 0 deletions ts-differential-tests/fixtures/README.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions ts-differential-tests/fixtures/get_address_octocat.address
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Placeholder: Run `cargo test generate_xdr_fixtures -- --ignored --exact --nocapture`
# and paste the Stellar address string here (G...)
2 changes: 2 additions & 0 deletions ts-differential-tests/fixtures/get_address_octocat.xdr
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Placeholder: Run `cargo test generate_xdr_fixtures -- --ignored --exact --nocapture`
# and paste the XDR output here (base64-encoded ScVal)
13 changes: 13 additions & 0 deletions ts-differential-tests/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}