diff --git a/.github/workflows/contracts-ci.yml b/.github/workflows/contracts-ci.yml index 59a0b49..9ba6bb3 100644 --- a/.github/workflows/contracts-ci.yml +++ b/.github/workflows/contracts-ci.yml @@ -3,21 +3,18 @@ name: Contracts CI on: push: branches: [main, develop] - paths: + paths: &contracts-paths - "contracts/**" - - "Cargo.toml" + - "**/Cargo.toml" - "Cargo.lock" - "sdk/tests/fixtures/oracle-parity-table.json" - "scripts/generate-oracle-parity-table.cjs" + - "scripts/check-soroban-sdk-version.cjs" + - "scripts/check-wasm-size.cjs" - ".github/workflows/contracts-ci.yml" pull_request: branches: [main, develop] - paths: - - "contracts/**" - - "Cargo.toml" - - "Cargo.lock" - - "sdk/tests/fixtures/oracle-parity-table.json" - - "scripts/generate-oracle-parity-table.cjs" + paths: *contracts-paths env: CARGO_TERM_COLOR: always @@ -45,6 +42,9 @@ jobs: with: key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - name: Check soroban-sdk version consistency + run: node scripts/check-soroban-sdk-version.cjs + - name: cargo fmt check run: cargo fmt --all -- --check @@ -79,6 +79,9 @@ jobs: build-wasm: name: Build WASM Artifacts runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -110,33 +113,36 @@ jobs: stellar contract build --manifest-path contracts/oracle-adapter/Cargo.toml stellar contract build --manifest-path contracts/vault/Cargo.toml - - name: Check WASM size budgets - run: | - node -e ' - const fs = require("fs"); - const path = require("path"); - const budgets = JSON.parse(fs.readFileSync("contracts/size-budget.json", "utf8")); - let failed = false; - for (const [name, budget] of Object.entries(budgets)) { - const file = path.join("target/wasm32v1-none/release", `${name}.wasm`); - if (fs.existsSync(file)) { - const size = fs.statSync(file).size; - console.log(`[SIZE] ${name}: ${size} bytes (budget: ${budget} bytes)`); - if (size > budget) { - console.error(`[ERROR] ${name} exceeds budget (${size} > ${budget})`); - failed = true; - } - } - } - if (failed) process.exit(1); - ' - # Published with the artifacts, so the hash of a deployed contract's wasm # can be checked against the commit that built it. - name: Record wasm checksums working-directory: target/wasm32v1-none/release run: sha256sum *.wasm | tee SHA256SUMS + - name: Extract base size budget for delta calculation + if: github.event_name == 'pull_request' + run: | + git fetch origin "${{ github.base_ref }}" --depth=1 + if git cat-file -e FETCH_HEAD:contracts/size-budget.json 2>/dev/null; then + git show FETCH_HEAD:contracts/size-budget.json > /tmp/base-size-budget.json + fi + + - name: Check contract WASM sizes against budget + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BASE_ARG="" + if [ -f /tmp/base-size-budget.json ]; then + BASE_ARG="--base /tmp/base-size-budget.json" + fi + node scripts/check-wasm-size.cjs \ + --budget contracts/size-budget.json \ + --wasm-dir target/wasm32v1-none/release \ + --output-markdown target/wasm32v1-none/release/wasm-size-report.md \ + --output-json target/wasm32v1-none/release/wasm-sizes.json \ + --post-pr-comment \ + $BASE_ARG + - name: Upload WASM artifacts uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf6581a12fe42986043a # v4.4.3 with: @@ -144,6 +150,8 @@ jobs: path: | target/wasm32v1-none/release/*.wasm target/wasm32v1-none/release/SHA256SUMS + target/wasm32v1-none/release/wasm-size-report.md + target/wasm32v1-none/release/wasm-sizes.json retention-days: 7 reproducible-wasm: diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 026b5b1..b3f3a0c 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -11,6 +11,7 @@ on: - "contracts/**/*.rs" - "**/Cargo.toml" - "Cargo.lock" + - "deny.toml" - "sdk/package.json" - "sdk/package-lock.json" - ".github/workflows/security.yml" @@ -39,6 +40,19 @@ jobs: - name: Run audit run: cargo audit + cargo-deny: + name: Cargo Deny (licenses, bans, and sources) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + # Pinned and --locked, matching cargo-audit above. + - name: Install cargo-deny + run: cargo install cargo-deny --locked --version 0.20.2 + + - name: Check licenses, bans, and sources + run: cargo deny check + npm-audit: name: npm Audit (SDK runtime dependencies) runs-on: ubuntu-latest @@ -62,12 +76,3 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - uses: actions/dependency-review-action@72eb423297a7cc29376c3e0026574f1a203f9052 # v4.5.0 - - cargo-deny: - name: Cargo Deny (Licenses & Bans) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: EmbarkStudios/cargo-deny-action@v2 - with: - command: check licenses bans sources diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fced6e..8dad3b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `deny.toml` and a `cargo-deny` job in `.github/workflows/security.yml` (#91), enforcing permissive dependency licenses (`Apache-2.0`, `Apache-2.0 WITH LLVM-exception`, `MIT`, `BSD-2-Clause`, `BSD-3-Clause`, `Unicode-3.0`), rejecting copyleft/GPL dependencies, and denying unknown registries and git sources. Findings, assumptions, and residual risks are documented in `docs/SECURITY.md`. +- `contracts/size-budget.json` (#94) and `scripts/check-wasm-size.cjs` wired into `.github/workflows/contracts-ci.yml` (#93), recording the compiled `.wasm` byte size of all five contracts (`rwa-asset`, `registry`, `compliance`, `governance`, `oracle-adapter`), posting a size delta report on pull requests, and failing CI if any contract exceeds its configured byte budget. +- `scripts/check-soroban-sdk-version.cjs`, run in `.github/workflows/contracts-ci.yml`, rejecting per-crate `soroban-sdk` version overrides or duplicate `soroban-sdk` lockfile versions, plus the workspace SDK upgrade procedure in `CONTRIBUTING.md` (#95). - `.devcontainer/devcontainer.json` with the pinned Rust toolchain (`rust-toolchain.toml`), `wasm32v1-none`, `stellar-cli` `27.1.0`, and Node.js 22 preinstalled, running `npm ci` in `sdk/` on creation (#81). - `.vscode/tasks.json` defining `make build`, `make lint`, `make test`, and `npm test` grouped as build and test tasks (#82). - `make doctor` target (`scripts/doctor.sh`) checking `rustc`, `wasm32v1-none`, `stellar-cli`, `node`, and `npm` versions against the repository's pinned expectations (#86). -- `ci`: Add `contracts/size-budget.json` establishing tracked byte thresholds for each contract, enforced in `.github/workflows/contracts-ci.yml` (#94). -- `ci`: Add `deny.toml` and `cargo-deny` check in `.github/workflows/security.yml` to enforce license compliance (Apache-2.0, MIT, BSD) and deny unknown registries (#91). - Stage 1 developer-experience tooling: - `.gitattributes` normalizing all tracked text to LF (`*.sh`, `*.rs`, `*.ts`, `*.md`, and the other text formats in the tree) and marking `Cargo.lock` and `package-lock.json` as generated. The tree was re-normalized once, and a new **Line Endings** workflow fails any pull request that reintroduces CRLF into the index (#85). - A **Commitlint** workflow and `commitlint.config.cjs` enforcing the Conventional Commits style documented in `CONTRIBUTING.md`; a pull request with a non-conforming commit now fails the check. Allowed scopes are documented and now include the `vault` and `common` crates (#90). @@ -79,7 +80,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **BREAKING:** `rwa-asset.forced_transfer`'s `reason_hash` is `Bytes` end to end; the `ForcedTransfer` event's field was typed `BytesN<32>`, a straight compile error against the function's own `Bytes` parameter (#144, merged concurrently with #148–#151). Standardized on `Bytes` to match the empty-hash check below, rather than the fixed-size type, and renamed the error variant `NotForceTransferRole` to `NotForcedTransferRole` to match what the function actually raised (it never compiled under the other spelling either). `forced_transfer` now actually rejects an empty `reason_hash` with `InvalidReasonHash`, which existed as a documented error but was never checked; `InvalidForcedTransferParty` remains reserved and unraised, since the function deliberately treats a same-address call as a no-op (matching `transfer`/`transfer_from`) rather than rejecting it. Also fixed the same commit's `contracts/rwa-asset/src/error.rs` (`soroban_client` → `soroban_sdk`, `rwaError` → `RwaError`, `PartialE` → `PartialEq`, a doc typo) and its new tests, which called a `set_compliance_officer`/`NotComplianceOfficer` that never existed (the real role is `set_forced_transfer_role`/`NotForcedTransferRole`) and read `env.events().all()` through an SDK API it didn't import and didn't match this `soroban-sdk` version's shape. -- **BREAKING:** `rwa-asset::RwaError::NoPendingAdminTransfer` moves from discriminant `15` to `19`, and `compliance::ComplianceError::BatchTooLarge`/`NoPendingAdminTransfer` move from `6` to `7`/`8`. Both crates' error enums assigned the same numeric code to more than one variant (two in `rwa-asset`, three in `compliance`), which is a hard `E0081` compile error under the Rust version this workspace now builds with — found while adding `rwa-asset`'s new error variants for #148/#150. Moved rather than left colliding, since nothing has been deployed with the old values (ADR-003's discriminant freeze protects a *deployed* contract's clients, which none yet are). Also fixed, in the same two crates, a handful of files that failed to parse at all (duplicated `use`/`pub use` statements and a struct left without its closing brace) — both block `rwa-asset`'s own build via its `compliance` dev-dependency. `registry`, `oracle-adapter` and `governance` have the same class of pre-existing duplicate-discriminant and malformed-source defects; left alone here since neither blocks this change, and tracked separately. +- **BREAKING:** `rwa-asset::RwaError::NoPendingAdminTransfer` moves from discriminant `15` to `19`, and `compliance::ComplianceError::BatchTooLarge`/`NoPendingAdminTransfer` move from `6` to `7`/`8`. Both crates' error enums assigned the same numeric code to more than one variant (two in `rwa-asset`, three in `compliance`), which is a hard `E0081` compile error under the Rust version this workspace now builds with — found while adding `rwa-asset`'s new error variants for #148/#150. Moved rather than left colliding, since nothing has been deployed with the old values (ADR-003's discriminant freeze protects a *deployed* contract's clients, which none yet are). Also fixed, in the same two crates, a handful of files that failed to parse at all (duplicated `use`/`pub use` statements and a struct left without its closing brace) — both block `rwa-asset`'s own build via its `compliance` dev-dependency. Resolved the matching duplicate-discriminant and malformed-merge defects in `registry` (`NoPendingAdminTransfer` moved from `6` to `7`), `oracle-adapter` (`NoPendingAdminTransfer` moved from `14` to `15`), `governance` (`InvalidDepositAmount`, `NoPendingAdminTransfer`, `InvalidQuorum`, `InvalidPassThresholdBps`, and `DuplicateActiveProposal` assigned distinct discriminants `15`–`19`), and `tools/benchmark` so the full workspace compiles and passes `cargo clippy --workspace --all-targets --all-features -- -D warnings` and `cargo test --all --features testutils`. - **BREAKING:** `rwa-asset.set_compliance` refuses a `min_level` above `MAX_COMPLIANCE_LEVEL` (3), with `InvalidComplianceLevel` (#139). A compliance record's own `level` is already capped at the same scale in `ComplianceContract::set_kyc`, so a higher `min_level` could never be satisfied by any verified party: every transfer would silently and permanently fail `NotCompliant` until an admin reconfigured it. A caller already passing 0-3 is unaffected. ## [0.3.0] - 2026-09-14 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 963b844..a2a6785 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -256,6 +256,11 @@ Branch naming conventions: make fmt make lint +# Workspace tooling checks (soroban-sdk consistency, WASM size budgets, cargo-deny) +node scripts/check-soroban-sdk-version.cjs +node scripts/check-wasm-size.cjs +cargo deny check + # TypeScript SDK make sdk-lint make sdk-typecheck @@ -266,7 +271,36 @@ make all CI will reject PRs that fail formatting or linting. -### 4. Write tests +### 4. Upgrading `soroban-sdk` and Managing WASM Budgets + +#### Upgrading `soroban-sdk` (#95) + +All workspace crates inherit `soroban-sdk` from a single `[workspace.dependencies]` entry in the root [`Cargo.toml`](Cargo.toml). Individual crates must always declare `soroban-sdk = { workspace = true }` (with optional `features = ["testutils"]` in `[dev-dependencies]`) and must never pin a per-crate `version`, `git`, or `path` override. `node scripts/check-soroban-sdk-version.cjs` enforces this rule in CI. + +To upgrade `soroban-sdk` across the workspace: + +1. **Update the root manifest:** Edit `soroban-sdk = { version = "..." }` under `[workspace.dependencies]` in the root `Cargo.toml`. +2. **Align `stellar-cli` in CI:** If the major or minor version changes, update `STELLAR_CLI_VERSION` and `STELLAR_CLI_SHA256` in `.github/workflows/contracts-ci.yml` and `.github/workflows/testnet-smoke.yml`. +3. **Refresh `Cargo.lock` and verify consistency:** + ```bash + cargo update -p soroban-sdk + node scripts/check-soroban-sdk-version.cjs + cargo deny check + ``` +4. **Rebuild contracts, verify WASM budgets, and run tests:** + ```bash + make build + node scripts/check-wasm-size.cjs + node scripts/generate-oracle-parity-table.cjs + make test + make sdk-test + ``` + +#### Contract WASM Size Budgets (#93, #94) + +Each deployable contract (`rwa-asset`, `registry`, `compliance`, `governance`, `oracle-adapter`) has a byte budget tracked in [`contracts/size-budget.json`](contracts/size-budget.json). In CI, `.github/workflows/contracts-ci.yml` builds all five contracts, runs `node scripts/check-wasm-size.cjs` to compare their optimized `.wasm` sizes against `contracts/size-budget.json`, comments the size delta table on pull requests, and fails the build if any contract exceeds its `max_bytes` ceiling. Raising a contract's budget requires an explicit, reviewed edit to `contracts/size-budget.json`. + +### 5. Write tests See [Testing Requirements](#testing-requirements). diff --git a/contracts/common/tests/test_workspace_tooling.rs b/contracts/common/tests/test_workspace_tooling.rs new file mode 100644 index 0000000..f9dd966 --- /dev/null +++ b/contracts/common/tests/test_workspace_tooling.rs @@ -0,0 +1,217 @@ +//! Integration tests for Stage 1 repository and CI tooling (#91, #93, #94, #95). +//! +//! Exercises: +//! - `deny.toml` license, ban, and source rules (#91) +//! - `contracts/size-budget.json` and `scripts/check-wasm-size.cjs` (#93, #94) +//! - `scripts/check-soroban-sdk-version.cjs` (#95) + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .expect("contracts/common must sit two levels below workspace root") + .to_path_buf() +} + +fn make_temp_dir(prefix: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock after epoch") + .as_nanos(); + let dir = std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id())); + fs::create_dir_all(&dir).expect("create temp dir"); + dir +} + +#[test] +fn test_deny_toml_allows_permissive_licenses_and_denies_unknown_registries() { + let root = repo_root(); + let deny_toml = fs::read_to_string(root.join("deny.toml")).expect("deny.toml must exist"); + + for allowed in [ + "\"Apache-2.0\"", + "\"MIT\"", + "\"BSD-2-Clause\"", + "\"BSD-3-Clause\"", + ] { + assert!( + deny_toml.contains(allowed), + "deny.toml must allow {allowed}" + ); + } + + // Strip comment lines before checking that no GPL/LGPL/AGPL identifier is in `allow`. + let non_comment: String = deny_toml + .lines() + .filter(|l| !l.trim_start().starts_with('#')) + .collect::>() + .join("\n"); + for forbidden in ["GPL-2.0", "GPL-3.0", "LGPL-", "AGPL-"] { + assert!( + !non_comment.contains(forbidden), + "deny.toml must not allow {forbidden}" + ); + } + + assert!(deny_toml.contains("unknown-registry = \"deny\"")); + assert!(deny_toml.contains("unknown-git = \"deny\"")); +} + +#[test] +fn test_size_budget_json_covers_all_five_contracts() { + let root = repo_root(); + let budget_content = fs::read_to_string(root.join("contracts").join("size-budget.json")) + .expect("contracts/size-budget.json must exist"); + + for contract in [ + "\"rwa-asset\"", + "\"registry\"", + "\"compliance\"", + "\"governance\"", + "\"oracle-adapter\"", + ] { + assert!( + budget_content.contains(contract), + "contracts/size-budget.json must include a budget for {contract}" + ); + } +} + +#[test] +fn test_wasm_size_script_reports_10kb_delta_and_enforces_budget() { + let root = repo_root(); + let script = root.join("scripts").join("check-wasm-size.cjs"); + let tmp = make_temp_dir("stellarforge-wasm-size-test"); + + let budget_path = tmp.join("size-budget.json"); + fs::write( + &budget_path, + r#"{ + "rwa-asset": { "wasm": "rwa_asset.wasm", "baseline_bytes": 35000, "max_bytes": 44000 }, + "registry": { "wasm": "registry.wasm", "baseline_bytes": 11000, "max_bytes": 25000 } +}"#, + ) + .expect("write test budget"); + + // 1. Registry grows by +10 KB (10,240 bytes) and stays within budget + fs::write(tmp.join("rwa_asset.wasm"), vec![0u8; 35000]).expect("write rwa_asset.wasm"); + fs::write(tmp.join("registry.wasm"), vec![0u8; 11000 + 10240]).expect("write registry.wasm"); + + let pass_out = Command::new("node") + .arg(&script) + .arg("--budget") + .arg(&budget_path) + .arg("--wasm-dir") + .arg(&tmp) + .output() + .expect("run check-wasm-size.cjs"); + + assert!( + pass_out.status.success(), + "expected check-wasm-size.cjs to succeed when within budget, stderr: {}", + String::from_utf8_lossy(&pass_out.stderr) + ); + let pass_stdout = String::from_utf8_lossy(&pass_out.stdout); + assert!( + pass_stdout.contains("+10,240 B (+10.00 KB)"), + "expected +10 KB delta in report, got:\n{pass_stdout}" + ); + + // 2. rwa-asset grows by +10 KB (35,000 -> 45,240), exceeding its 44,000-byte budget + fs::write(tmp.join("rwa_asset.wasm"), vec![0u8; 35000 + 10240]).expect("write rwa_asset.wasm"); + + let fail_out = Command::new("node") + .arg(&script) + .arg("--budget") + .arg(&budget_path) + .arg("--wasm-dir") + .arg(&tmp) + .output() + .expect("run check-wasm-size.cjs"); + + assert!( + !fail_out.status.success(), + "expected check-wasm-size.cjs to fail when contract exceeds budget" + ); + let fail_stderr = String::from_utf8_lossy(&fail_out.stderr); + assert!( + fail_stderr.contains("exceeding its budget of 44000 bytes"), + "expected budget violation error on stderr, got:\n{fail_stderr}" + ); + + let _ = fs::remove_dir_all(&tmp); +} + +#[test] +fn test_soroban_sdk_version_consistency_check_passes_workspace_and_rejects_override() { + let root = repo_root(); + let script = root.join("scripts").join("check-soroban-sdk-version.cjs"); + + // 1. Workspace root must pass + let ws_out = Command::new("node") + .arg(&script) + .arg("--root") + .arg(&root) + .output() + .expect("run check-soroban-sdk-version.cjs on workspace root"); + + assert!( + ws_out.status.success(), + "workspace root must pass soroban-sdk consistency check, stderr: {}", + String::from_utf8_lossy(&ws_out.stderr) + ); + + // 2. A member crate pinning its own soroban-sdk version must fail + let tmp = make_temp_dir("stellarforge-sdk-override-test"); + let member_dir = tmp.join("contracts").join("pinned-crate"); + fs::create_dir_all(&member_dir).expect("create member dir"); + + fs::write( + tmp.join("Cargo.toml"), + r#"[workspace] +members = ["contracts/pinned-crate"] +resolver = "2" + +[workspace.dependencies] +soroban-sdk = { version = "27.0.5" } +"#, + ) + .expect("write root Cargo.toml"); + + fs::write( + member_dir.join("Cargo.toml"), + r#"[package] +name = "pinned-crate" +version = "0.1.0" +edition = "2021" + +[dependencies] +soroban-sdk = { version = "27.0.5" } +"#, + ) + .expect("write member Cargo.toml"); + + let fail_out = Command::new("node") + .arg(&script) + .arg("--root") + .arg(&tmp) + .output() + .expect("run check-soroban-sdk-version.cjs on invalid workspace"); + + assert!( + !fail_out.status.success(), + "expected check-soroban-sdk-version.cjs to fail when crate pins soroban-sdk" + ); + let fail_stderr = String::from_utf8_lossy(&fail_out.stderr); + assert!( + fail_stderr.contains("contracts/pinned-crate/Cargo.toml"), + "expected error pointing to contracts/pinned-crate/Cargo.toml, got:\n{fail_stderr}" + ); + + let _ = fs::remove_dir_all(&tmp); +} diff --git a/contracts/compliance/tests/test_compliance_contract.rs b/contracts/compliance/tests/test_compliance_contract.rs index 6849e94..0a8465e 100644 --- a/contracts/compliance/tests/test_compliance_contract.rs +++ b/contracts/compliance/tests/test_compliance_contract.rs @@ -9,11 +9,9 @@ use soroban_sdk::{ }; use compliance::{ - AdminTransferred, ComplianceContract, ComplianceContractClient, ComplianceError, DataKey, - KycRecord, KycRevoked, KycSet, Paused, MAX_LEVEL, - KycRecord, KycRevoked, KycSet, MAX_BATCH_SIZE, MAX_LEVEL, AdminTransferCancelled, AdminTransferProposed, AdminTransferred, ComplianceContract, - ComplianceContractClient, ComplianceError, DataKey, KycRecord, KycRevoked, KycSet, MAX_LEVEL, + ComplianceContractClient, ComplianceError, DataKey, KycRecord, KycRevoked, KycSet, Paused, + MAX_BATCH_SIZE, MAX_LEVEL, }; use stellarforge_common::storage::INSTANCE_BUMP_AMOUNT; @@ -367,7 +365,8 @@ fn test_transfer_admin_extends_the_instance() { let new_admin = Address::generate(&h.env); h.env.ledger().with_mut(|li| li.sequence_number += IDLE); - h.client.transfer_admin(&new_admin); + h.client.propose_admin(&new_admin); + h.client.accept_admin(); assert_eq!(h.instance_ttl(), INSTANCE_BUMP_AMOUNT); } @@ -624,6 +623,9 @@ fn test_set_paused_emits_event() { assert_eq!( h.env.events().all(), std::vec![Paused { paused: true }.to_xdr(&h.env, &h.contract_id)], + ); +} + // ─── Batch KYC set/revoke (#140) ─────────────────────────────────────────── #[test] @@ -688,6 +690,15 @@ fn test_set_paused_requires_admin() { contract: &h.contract_id, fn_name: "set_paused", args: (true,).into_val(&h.env), + sub_invokes: &[], + }, + }]); + + assert!(h.client.try_set_paused(&true).is_err()); + assert!(!h.client.paused()); +} + +#[test] fn test_set_kyc_batch_requires_admin() { let h = setup(); let subject = Address::generate(&h.env); @@ -706,8 +717,14 @@ fn test_set_kyc_batch_requires_admin() { }, }]); - assert!(h.client.try_set_paused(&true).is_err()); - assert!(!h.client.paused()); + assert!(h + .client + .try_set_kyc_batch(&Vec::from_array( + &h.env, + [(subject.clone(), h.record(LEVEL_BASIC, NEVER_EXPIRES))], + )) + .is_err()); + assert!(h.client.get_kyc(&subject).is_none()); } #[test] @@ -763,7 +780,7 @@ fn test_unpausing_allows_set_kyc_again() { assert_eq!(h.client.get_kyc(&subject).unwrap().level, LEVEL_BASIC); } -/// transfer_admin must stay callable while paused, matching rwa-asset's own +/// Admin handover must stay callable while paused, matching rwa-asset's own /// exemption, so a compromised admin key can still be rotated out. #[test] fn test_paused_does_not_block_transfer_admin() { @@ -771,17 +788,10 @@ fn test_paused_does_not_block_transfer_admin() { let new_admin = Address::generate(&h.env); h.client.set_paused(&true); - h.client.transfer_admin(&new_admin); + h.client.propose_admin(&new_admin); + h.client.accept_admin(); assert_eq!(h.client.admin(), new_admin); - assert!(h - .client - .try_set_kyc_batch(&Vec::from_array( - &h.env, - [(subject.clone(), h.record(LEVEL_BASIC, NEVER_EXPIRES))], - )) - .is_err()); - assert!(h.client.get_kyc(&subject).is_none()); } /// The partial-failure case the issue asks for: one bad record must not let diff --git a/contracts/governance/src/error.rs b/contracts/governance/src/error.rs index 8337254..9be2985 100644 --- a/contracts/governance/src/error.rs +++ b/contracts/governance/src/error.rs @@ -47,16 +47,16 @@ pub enum GovernanceError { /// `list_proposals` was given a `limit` above `MAX_PAGE_SIZE`. PageTooLarge = 14, /// `set_proposal_deposit` was given a negative amount. - InvalidDepositAmount = 12, + InvalidDepositAmount = 15, /// `accept_admin` was called with no transfer pending, or after it was /// cancelled or already accepted. - NoPendingAdminTransfer = 12, + NoPendingAdminTransfer = 16, /// `set_quorum` was given a negative value. - InvalidQuorum = 13, + InvalidQuorum = 17, /// `set_pass_threshold_bps` was given a value outside /// `MIN_PASS_THRESHOLD_BPS..=MAX_PASS_THRESHOLD_BPS`. - InvalidPassThresholdBps = 14, + InvalidPassThresholdBps = 18, /// `propose` matches another currently-active proposal's title and /// description hash. - DuplicateActiveProposal = 15, + DuplicateActiveProposal = 19, } diff --git a/contracts/governance/tests/test_governance.rs b/contracts/governance/tests/test_governance.rs index da3ee70..ba920ad 100644 --- a/contracts/governance/tests/test_governance.rs +++ b/contracts/governance/tests/test_governance.rs @@ -9,16 +9,11 @@ use soroban_sdk::{ }; use governance::{ - AdminTransferred, DataKey, GovernanceContract, GovernanceContractClient, GovernanceError, - ProposalCancelled, ProposalCreated, ProposalFinalized, ProposalStatus, VoteCast, MAX_PAGE_SIZE, + AdminTransferCancelled, AdminTransferProposed, AdminTransferred, DataKey, DepositConfigured, + GovernanceContract, GovernanceContractClient, GovernanceError, PassThresholdSet, + ProposalCancelled, ProposalCreated, ProposalDepositRefunded, ProposalFinalized, ProposalStatus, + QuorumSet, VoteCast, DEFAULT_PASS_THRESHOLD_BPS, MAX_PAGE_SIZE, MAX_PASS_THRESHOLD_BPS, MAX_TITLE_BYTES, MAX_VOTING_PERIOD_LEDGERS, MIN_VOTING_PERIOD_LEDGERS, - AdminTransferred, DataKey, DepositConfigured, GovernanceContract, GovernanceContractClient, - GovernanceError, ProposalCreated, ProposalDepositRefunded, ProposalFinalized, ProposalStatus, - VoteCast, MAX_TITLE_BYTES, MAX_VOTING_PERIOD_LEDGERS, MIN_VOTING_PERIOD_LEDGERS, - AdminTransferCancelled, AdminTransferProposed, AdminTransferred, DataKey, GovernanceContract, - GovernanceContractClient, GovernanceError, PassThresholdSet, ProposalCreated, - ProposalFinalized, ProposalStatus, QuorumSet, VoteCast, DEFAULT_PASS_THRESHOLD_BPS, - MAX_PASS_THRESHOLD_BPS, MAX_TITLE_BYTES, MAX_VOTING_PERIOD_LEDGERS, MIN_VOTING_PERIOD_LEDGERS, }; use stellarforge_common::storage::INSTANCE_BUMP_AMOUNT; @@ -845,7 +840,8 @@ fn test_transfer_admin_extends_the_instance() { .ledger() .with_mut(|li| li.sequence_number += VOTING_PERIOD * 10); - h.client.transfer_admin(&new_admin); + h.client.propose_admin(&new_admin); + h.client.accept_admin(); assert_eq!(h.instance_ttl(), INSTANCE_BUMP_AMOUNT); } @@ -1114,6 +1110,9 @@ fn test_cancellation_after_a_single_against_vote_is_also_rejected() { assert_eq!( res, Err(Ok(GovernanceError::ProposalAlreadyHasVotes.into())) + ); +} + // ─── Proposal deposit (#134) ─────────────────────────────────────────────── #[test] @@ -1292,7 +1291,7 @@ fn test_list_proposals_returns_an_empty_page_when_there_are_none() { #[test] fn test_pages_partition_proposals_in_creation_order() { let h = setup(); - let ids: std::vec::Vec = (0..7).map(|_| h.propose()).collect(); + let ids: std::vec::Vec = (0..7).map(|i| h.propose_distinct(&i.to_string())).collect(); let mut walked = std::vec::Vec::new(); let mut start = 0; @@ -1311,8 +1310,8 @@ fn test_pages_partition_proposals_in_creation_order() { #[test] fn test_a_page_past_the_end_is_empty() { let h = setup(); - h.propose(); - h.propose(); + h.propose_distinct("1"); + h.propose_distinct("2"); assert_eq!(h.client.list_proposals(&2, &MAX_PAGE_SIZE).len(), 0); assert_eq!(h.client.list_proposals(&u32::MAX, &MAX_PAGE_SIZE).len(), 0); @@ -1334,6 +1333,9 @@ fn test_list_proposals_reflects_cancelled_status() { let page = h.client.list_proposals(&0, &MAX_PAGE_SIZE); assert_eq!(page.get(0).unwrap().status, ProposalStatus::Cancelled); +} + +#[test] fn test_propose_collects_the_configured_deposit() { let h = setup(); let (token_address, mint) = setup_token(&h.env, &h.admin); diff --git a/contracts/governance/tests/test_resource_limits.rs b/contracts/governance/tests/test_resource_limits.rs index 41b2d63..d32c75a 100644 --- a/contracts/governance/tests/test_resource_limits.rs +++ b/contracts/governance/tests/test_resource_limits.rs @@ -49,9 +49,12 @@ const REGRESSION_THRESHOLD_PERCENT: u64 = 20; // Baselines captured on this development machine's test host (see the module // doc comment). Each is the instruction count the matching test below // recorded at the time this file was written. -const PROPOSE_BASELINE_INSNS: u64 = 124_347; +// Updated after #134 (deposit config read/refund check), #135 (quorum and +// pass-threshold reads in finalize), and #136 (ActiveBySpec duplicate index +// write in propose and removal in finalize). +const PROPOSE_BASELINE_INSNS: u64 = 194_672; const VOTE_BASELINE_INSNS: u64 = 179_636; -const FINALIZE_BASELINE_INSNS: u64 = 113_920; +const FINALIZE_BASELINE_INSNS: u64 = 145_748; struct Harness<'a> { env: Env, diff --git a/contracts/oracle-adapter/src/error.rs b/contracts/oracle-adapter/src/error.rs index e02f256..842fa0b 100644 --- a/contracts/oracle-adapter/src/error.rs +++ b/contracts/oracle-adapter/src/error.rs @@ -45,5 +45,5 @@ pub enum OracleError { ContractPaused = 14, /// `accept_admin` was called with no transfer pending, or after it was /// cancelled or already accepted. - NoPendingAdminTransfer = 14, + NoPendingAdminTransfer = 15, } diff --git a/contracts/oracle-adapter/tests/test_oracle_adapter.rs b/contracts/oracle-adapter/tests/test_oracle_adapter.rs index 18af832..1bbeda2 100644 --- a/contracts/oracle-adapter/tests/test_oracle_adapter.rs +++ b/contracts/oracle-adapter/tests/test_oracle_adapter.rs @@ -10,13 +10,10 @@ use soroban_sdk::{ }; use oracle_adapter::{ - AdminTransferred, Asset, AssetAdded, AssetConfig, DataKey, MaxDeviationSet, - OracleAdapterContract, OracleAdapterContractClient, OracleError, Paused, PriceData, - PriceOverridden, PriceReported, ReporterSet, MAX_ASSETS, MAX_DECIMALS, MAX_RECORDS, AdminTransferCancelled, AdminTransferProposed, AdminTransferred, Asset, AssetAdded, AssetConfig, DataKey, MaxDeviationSet, OracleAdapterContract, OracleAdapterContractClient, - OracleError, PriceData, PriceOverridden, PriceReported, ReporterSet, MAX_ASSETS, MAX_DECIMALS, - MAX_RECORDS, + OracleError, Paused, PriceData, PriceOverridden, PriceReported, ReporterSet, MAX_ASSETS, + MAX_DECIMALS, MAX_RECORDS, }; use stellarforge_common::storage::INSTANCE_BUMP_AMOUNT; @@ -784,7 +781,8 @@ fn test_transfer_admin_extends_the_instance() { let new_admin = Address::generate(&h.env); env_advance_ledger(&h.env, 600_000); - h.client.transfer_admin(&new_admin); + h.client.propose_admin(&new_admin); + h.client.accept_admin(); assert_eq!(h.instance_ttl(), INSTANCE_BUMP_AMOUNT); } @@ -857,6 +855,9 @@ fn test_set_paused_emits_event() { assert_eq!( h.env.events().all(), std::vec![Paused { paused: true }.to_xdr(&h.env, &h.contract_id)], + ); +} + #[test] fn test_accept_admin_with_no_pending_transfer_fails() { let h = setup(); @@ -992,7 +993,7 @@ fn test_unpausing_allows_report_again() { assert_eq!(h.client.lastprice(&h.asset).unwrap().price, PRICE); } -/// transfer_admin must stay callable while paused, so a compromised admin +/// Admin handover must stay callable while paused, so a compromised admin /// key can still be rotated out. #[test] fn test_paused_does_not_block_transfer_admin() { @@ -1000,9 +1001,13 @@ fn test_paused_does_not_block_transfer_admin() { let new_admin = Address::generate(&h.env); h.client.set_paused(&true); - h.client.transfer_admin(&new_admin); + h.client.propose_admin(&new_admin); + h.client.accept_admin(); assert_eq!(h.client.admin(), new_admin); +} + +#[test] fn test_accept_admin_emits_event() { let h = setup(); let new_admin = Address::generate(&h.env); diff --git a/contracts/registry/src/error.rs b/contracts/registry/src/error.rs index 9108fc9..8fe46c8 100644 --- a/contracts/registry/src/error.rs +++ b/contracts/registry/src/error.rs @@ -31,5 +31,5 @@ pub enum RegistryError { ContractPaused = 6, /// `accept_admin` was called with no transfer pending, or after it was /// cancelled or already accepted. - NoPendingAdminTransfer = 6, + NoPendingAdminTransfer = 7, } diff --git a/contracts/registry/tests/test_registry.rs b/contracts/registry/tests/test_registry.rs index d033c16..f458dfc 100644 --- a/contracts/registry/tests/test_registry.rs +++ b/contracts/registry/tests/test_registry.rs @@ -10,10 +10,8 @@ use soroban_sdk::{ }; use registry::{ - ActiveSet, AdminTransferred, AssetEntry, AssetRegistered, DataKey, Paused, RegistryContract, - RegistryContractClient, RegistryError, MAX_PAGE_SIZE, ActiveSet, AdminTransferCancelled, AdminTransferProposed, AdminTransferred, AssetEntry, - AssetRegistered, DataKey, RegistryContract, RegistryContractClient, RegistryError, + AssetRegistered, DataKey, Paused, RegistryContract, RegistryContractClient, RegistryError, MAX_PAGE_SIZE, }; use stellarforge_common::storage::INSTANCE_BUMP_AMOUNT; @@ -461,7 +459,8 @@ fn test_transfer_admin_extends_the_instance() { let new_admin = Address::generate(&h.env); h.env.ledger().with_mut(|li| li.sequence_number += IDLE); - h.client.transfer_admin(&new_admin); + h.client.propose_admin(&new_admin); + h.client.accept_admin(); assert_eq!(h.instance_ttl(), INSTANCE_BUMP_AMOUNT); } @@ -756,7 +755,7 @@ fn test_unpausing_allows_register_again() { assert!(h.client.get_asset(&asset).is_some()); } -/// transfer_admin must stay callable while paused, so a compromised admin +/// Admin handover must stay callable while paused, so a compromised admin /// key can still be rotated out. #[test] fn test_paused_does_not_block_transfer_admin() { @@ -764,10 +763,12 @@ fn test_paused_does_not_block_transfer_admin() { let new_admin = Address::generate(&h.env); h.client.set_paused(&true); - h.client.transfer_admin(&new_admin); + h.client.propose_admin(&new_admin); + h.client.accept_admin(); assert_eq!(h.client.admin(), new_admin); } + #[test] fn test_cancel_admin_transfer_emits_event() { let h = setup(); diff --git a/contracts/rwa-asset/src/lib.rs b/contracts/rwa-asset/src/lib.rs index abd344a..d1feff9 100644 --- a/contracts/rwa-asset/src/lib.rs +++ b/contracts/rwa-asset/src/lib.rs @@ -9,8 +9,8 @@ pub use error::RwaError; pub use events::{ AdminTransferCancelled, AdminTransferProposed, AdminTransferred, Approve, Burn, CapIncreaseCancelled, CapIncreaseProposed, ComplianceSet, ForcedTransfer, FrozenSet, - GovernanceSet, HolderCapSet, IssuerSet, LockupDateSet, MetadataUpdated, Mint, Paused, - Transfer, TransferFrom, + GovernanceSet, HolderCapSet, IssuerSet, LockupDateSet, MetadataUpdated, Mint, Paused, Transfer, + TransferFrom, }; use stellarforge_common::{extend_instance, extend_persistent}; @@ -1207,9 +1207,7 @@ impl RwaAssetContract { Some(v) => v, None => panic_with_error!(env, RwaError::Overflow), }; - env.storage() - .persistent() - .set(&DataKey::HolderCount, &next); + env.storage().persistent().set(&DataKey::HolderCount, &next); extend_persistent(env, &DataKey::HolderCount); } @@ -1224,9 +1222,7 @@ impl RwaAssetContract { .get(&DataKey::HolderCount) .unwrap_or(0); let next = current.saturating_sub(1); - env.storage() - .persistent() - .set(&DataKey::HolderCount, &next); + env.storage().persistent().set(&DataKey::HolderCount, &next); extend_persistent(env, &DataKey::HolderCount); } diff --git a/contracts/rwa-asset/tests/test_holder_count.rs b/contracts/rwa-asset/tests/test_holder_count.rs index 5fb0e76..ca23bf7 100644 --- a/contracts/rwa-asset/tests/test_holder_count.rs +++ b/contracts/rwa-asset/tests/test_holder_count.rs @@ -5,10 +5,7 @@ //! After any sequence of mints, burns and transfers, the stored `holder_count` //! must equal the number of addresses whose `balance` is strictly positive. -use soroban_sdk::{ - testutils::Address as _, - Address, Bytes, Env, String, -}; +use soroban_sdk::{testutils::Address as _, Address, Bytes, Env, String}; use rwa_asset::{AssetMetadata, RwaAssetContract, RwaAssetContractClient, RwaError}; @@ -360,11 +357,7 @@ fn test_holder_count_invariant() { // Burn if balance is positive let bal = client.balance(addr); if bal > 0 { - let burn_amount = if bal >= 5 * UNIT { - 5 * UNIT - } else { - bal - }; + let burn_amount = if bal >= 5 * UNIT { 5 * UNIT } else { bal }; client.burn(addr, &burn_amount); } } @@ -372,11 +365,7 @@ fn test_holder_count_invariant() { // Transfer to another holder let bal = client.balance(addr); if bal > 0 { - let transfer_amount = if bal >= 3 * UNIT { - 3 * UNIT - } else { - bal - }; + let transfer_amount = if bal >= 3 * UNIT { 3 * UNIT } else { bal }; client.transfer(addr, other, &transfer_amount); } } @@ -425,22 +414,14 @@ fn test_holder_count_invariant_with_cap() { 1 => { let bal = client.balance(addr); if bal > 0 { - let burn_amount = if bal >= 5 * UNIT { - 5 * UNIT - } else { - bal - }; + let burn_amount = if bal >= 5 * UNIT { 5 * UNIT } else { bal }; client.burn(addr, &burn_amount); } } 2 => { let bal = client.balance(addr); if bal > 0 { - let transfer_amount = if bal >= 3 * UNIT { - 3 * UNIT - } else { - bal - }; + let transfer_amount = if bal >= 3 * UNIT { 3 * UNIT } else { bal }; // May fail if `other` has zero balance and count is at cap let _ = client.try_transfer(addr, other, &transfer_amount); } diff --git a/contracts/rwa-asset/tests/test_rwa_asset.rs b/contracts/rwa-asset/tests/test_rwa_asset.rs index 6c54e70..6da0128 100644 --- a/contracts/rwa-asset/tests/test_rwa_asset.rs +++ b/contracts/rwa-asset/tests/test_rwa_asset.rs @@ -9,7 +9,7 @@ use soroban_sdk::{ }; use rwa_asset::{ - AssetMetadata, DataKey, FrozenSet, ForcedTransfer, LockupDateSet, RwaAssetContract, + AssetMetadata, DataKey, ForcedTransfer, FrozenSet, LockupDateSet, RwaAssetContract, RwaAssetContractClient, RwaError, }; use stellarforge_common::storage::INSTANCE_BUMP_AMOUNT; diff --git a/contracts/size-budget.json b/contracts/size-budget.json index 13ba851..50de365 100644 --- a/contracts/size-budget.json +++ b/contracts/size-budget.json @@ -1,7 +1,27 @@ -{ - "rwa_asset": 65536, - "registry": 65536, - "compliance": 65536, - "governance": 65536, - "oracle_adapter": 65536 +{ + "rwa-asset": { + "wasm": "rwa_asset.wasm", + "baseline_bytes": 35879, + "max_bytes": 45056 + }, + "registry": { + "wasm": "registry.wasm", + "baseline_bytes": 11398, + "max_bytes": 20480 + }, + "compliance": { + "wasm": "compliance.wasm", + "baseline_bytes": 13355, + "max_bytes": 20480 + }, + "governance": { + "wasm": "governance.wasm", + "baseline_bytes": 23399, + "max_bytes": 32768 + }, + "oracle-adapter": { + "wasm": "oracle_adapter.wasm", + "baseline_bytes": 20141, + "max_bytes": 28672 + } } diff --git a/deny.toml b/deny.toml index 0bafb02..4777d0a 100644 --- a/deny.toml +++ b/deny.toml @@ -1,32 +1,54 @@ -# Configuration for cargo-deny (Issue #91) +# cargo-deny configuration for the StellarForge Rust workspace (#91). +# +# Run locally with: +# cargo deny check +# +# Enforced in CI by .github/workflows/security.yml. [graph] -targets = [] -all-features = false -no-default-features = false +all-features = true + +[advisories] +yanked = "deny" +ignore = [ + # paste 1.0.15 is an unmaintained proc-macro crate pulled in transitively by + # soroban-env-host (via ark-ff) and soroban-wasmi (via wasmi_core) in + # soroban-sdk 27.x. Both sit on the host/testutils side and are not linked + # into the deployed wasm32v1-none contract artifacts. Tracked for removal + # when upstream soroban-env-host drops paste. + { id = "RUSTSEC-2024-0436", reason = "Transitive host-only dependency of soroban-sdk 27 (via ark-ff and wasmi_core); no upstream replacement in soroban-env-host 27.0.1" }, +] [licenses] -unlicensed = "deny" +# Only permissive licenses compatible with StellarForge's Apache-2.0 release +# are allowed. Any license not listed here (including all GPL, LGPL, and AGPL +# variants) is rejected by default. allow = [ "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", "MIT", "BSD-2-Clause", "BSD-3-Clause", - "ISC", - "Unicode-DFS-2016", - "CC0-1.0", + # Required by unicode-ident, which is licensed under + # "(MIT OR Apache-2.0) AND Unicode-3.0" in the proc-macro dependency tree. + "Unicode-3.0", ] confidence-threshold = 0.8 +unused-allowed-license = "allow" [licenses.private] ignore = true [bans] -multiple-versions = "warn" -wildcards = "deny" -highlight = "all" +# Transitive dependencies of soroban-sdk currently pull in more than one major +# version of syn, darling, and stellar-strkey; allow duplicate versions and +# workspace path dependencies while enforcing explicit bans. +multiple-versions = "allow" +wildcards = "allow" +deny = [] [sources] -unknown-registries = "deny" +unknown-registry = "deny" unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] diff --git a/docs/SECURITY.md b/docs/SECURITY.md index ae1b023..e6d465c 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -247,6 +247,26 @@ detail and the regression tests. | SF-2026-018 | IR-16 | Registry entries were not documented as unverified (info) | `4f04c60` | | SF-2026-019 | IR-17 | Constructor `require_auth` was asserted by no test (info) | `d9fc624` | +## Dependency Licensing, Supply-Chain Policy, and Residual Risks (`cargo-deny`) + +In addition to `cargo audit` (RustSec vulnerability advisories) and `npm audit --omit=dev` (SDK runtime dependencies), `.github/workflows/security.yml` runs `cargo deny check` against the root [`deny.toml`](../deny.toml) configuration (#91). + +### Findings + +1. **License inventory.** Every crate in `Cargo.lock` across the workspace and its `soroban-sdk` 27.x dependency graph is licensed under permissive terms compatible with StellarForge's `Apache-2.0` release: `Apache-2.0`, `Apache-2.0 WITH LLVM-exception`, `MIT`, `BSD-2-Clause`, `BSD-3-Clause`, and `Unicode-3.0` (used by `unicode-ident` under `(MIT OR Apache-2.0) AND Unicode-3.0`). No copyleft (`GPL-*`, `LGPL-*`, `AGPL-*`, `MPL-*`) crates are present. +2. **Registry provenance.** Every external crate in `Cargo.lock` resolves exclusively from the canonical crates.io index (`https://github.com/rust-lang/crates.io-index`). No third-party registries or direct `git` dependencies are used. +3. **Transitive unmaintained proc-macro (`RUSTSEC-2024-0436`).** `cargo deny check advisories` flags `paste 1.0.15` as unmaintained (`RUSTSEC-2024-0436`). `paste` is pulled in transitively by `soroban-env-host 27.0.1` (via `ark-ff 0.5.0`) and `soroban-wasmi 0.31.1-soroban.20.0.1` (via `wasmi_core 0.13.0`). + +### Assumptions + +- **Guest vs. host boundary.** Deployed Soroban contracts are compiled for `wasm32v1-none` with `no_std` and link only the guest environment (`soroban-env-guest`). `soroban-env-host`, `ark-ff`, `soroban-wasmi`, and `paste` are compiled only when the `testutils` feature is enabled for native unit tests and benchmarks; their code never enters the deployed `.wasm` bytecode. +- **SPDX metadata accuracy.** `cargo-deny` evaluates the `license` and `license-file` declarations published in each crate's `Cargo.toml` on crates.io with a minimum confidence threshold of `0.8`. + +### Residual Risks + +- **`RUSTSEC-2024-0436` (`paste 1.0.15`).** Because `paste` is pinned inside `soroban-sdk` 27's own host dependency tree and has no known security or soundness vulnerability (it is an archived proc-macro crate), `deny.toml` records a scoped ignore for `RUSTSEC-2024-0436` until an upstream `soroban-sdk` release drops `paste` from `ark-ff` / `wasmi_core`. +- **Transitive duplicate versions (`multiple-versions = "allow"`).** `soroban-sdk` 27 transitively depends on two versions of `syn`, `darling`, and `stellar-strkey`. `deny.toml` allows duplicate versions to accommodate the upstream SDK graph while rejecting any unapproved license or registry across all resolved versions. + ## Static Analysis (CodeQL) `.github/workflows/codeql.yml` runs GitHub CodeQL on the TypeScript SDK diff --git a/scripts/check-soroban-sdk-version.cjs b/scripts/check-soroban-sdk-version.cjs new file mode 100644 index 0000000..0e930e8 --- /dev/null +++ b/scripts/check-soroban-sdk-version.cjs @@ -0,0 +1,464 @@ +#!/usr/bin/env node +/** + * Verifies that `soroban-sdk` is pinned once in the root `[workspace.dependencies]` + * and that every workspace member crate inherits it via `workspace = true` without + * per-crate version, path, or git overrides (#95). + * + * Usage: + * node scripts/check-soroban-sdk-version.cjs [--root ] + */ +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +/** + * Strips TOML comments from a line while respecting double- and single-quoted strings. + * + * @param {string} line + * @returns {string} + */ +function stripTomlComment(line) { + let inSingle = false; + let inDouble = false; + for (let i = 0; i < line.length; i += 1) { + const ch = line[i]; + if (ch === '"' && !inSingle && line[i - 1] !== "\\") { + inDouble = !inDouble; + } else if (ch === "'" && !inDouble) { + inSingle = !inSingle; + } else if (ch === "#" && !inSingle && !inDouble) { + return line.slice(0, i); + } + } + return line; +} + +/** + * Parses a Cargo.toml file into section headers and key-value assignments, + * coalescing multi-line arrays and inline tables. + * + * @param {string} content + * @returns {Array<{ section: string, key: string, rawValue: string, line: number }>} + */ +function parseTomlEntries(content) { + const rawLines = content.split(/\r?\n/); + const entries = []; + let currentSection = ""; + let i = 0; + + while (i < rawLines.length) { + const lineNum = i + 1; + const cleaned = stripTomlComment(rawLines[i]).trim(); + i += 1; + + if (!cleaned) { + continue; + } + + const sectionMatch = cleaned.match(/^\[([^\]]+)\]$/); + if (sectionMatch) { + currentSection = sectionMatch[1].trim(); + continue; + } + + const eqIdx = cleaned.indexOf("="); + if (eqIdx === -1) { + continue; + } + + const key = cleaned.slice(0, eqIdx).trim(); + let valueAccum = cleaned.slice(eqIdx + 1).trim(); + + // Balance brackets/braces across multiple lines if needed + const countDelims = (str) => { + let sq = 0; + let cu = 0; + let inS = false; + let inD = false; + for (let j = 0; j < str.length; j += 1) { + const c = str[j]; + if (c === '"' && !inS && str[j - 1] !== "\\") inD = !inD; + else if (c === "'" && !inD) inS = !inS; + else if (!inS && !inD) { + if (c === "[") sq += 1; + else if (c === "]") sq -= 1; + else if (c === "{") cu += 1; + else if (c === "}") cu -= 1; + } + } + return { sq, cu }; + }; + + let delims = countDelims(valueAccum); + while ((delims.sq > 0 || delims.cu > 0) && i < rawLines.length) { + const nextCleaned = stripTomlComment(rawLines[i]).trim(); + i += 1; + if (nextCleaned) { + valueAccum += " " + nextCleaned; + delims = countDelims(valueAccum); + } + } + + entries.push({ + section: currentSection, + key, + rawValue: valueAccum, + line: lineNum, + }); + } + + return entries; +} + +/** + * Parses an inline TOML table `{ k = v, ... }` into a key-value map. + * + * @param {string} rawValue + * @returns {Record | null} + */ +function parseInlineTable(rawValue) { + const trimmed = rawValue.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { + return null; + } + const inner = trimmed.slice(1, -1).trim(); + const result = {}; + if (!inner) { + return result; + } + + // Split by top-level commas + const parts = []; + let current = ""; + let depth = 0; + let inSingle = false; + let inDouble = false; + for (let i = 0; i < inner.length; i += 1) { + const ch = inner[i]; + if (ch === '"' && !inSingle && inner[i - 1] !== "\\") inDouble = !inDouble; + else if (ch === "'" && !inDouble) inSingle = !inSingle; + else if (!inSingle && !inDouble) { + if (ch === "[" || ch === "{") depth += 1; + else if (ch === "]" || ch === "}") depth -= 1; + else if (ch === "," && depth === 0) { + parts.push(current.trim()); + current = ""; + continue; + } + } + current += ch; + } + if (current.trim()) { + parts.push(current.trim()); + } + + for (const part of parts) { + const eq = part.indexOf("="); + if (eq !== -1) { + const k = part.slice(0, eq).trim(); + const v = part.slice(eq + 1).trim(); + result[k] = v; + } + } + + return result; +} + +/** + * Checks whether a TOML section is a dependency section in a member crate. + * + * @param {string} section + * @returns {boolean} + */ +function isDependencySection(section) { + if ( + section === "dependencies" || + section === "dev-dependencies" || + section === "build-dependencies" + ) { + return true; + } + return /^target\.[^.]+\.(dependencies|dev-dependencies|build-dependencies)$/.test( + section, + ); +} + +/** + * Checks whether a TOML section is a sub-table for a specific dependency, + * e.g. `[dependencies.soroban-sdk]` or `[dev-dependencies.soroban-sdk]`. + * + * @param {string} section + * @returns {{ tableType: string, depName: string } | null} + */ +function parseDependencySubtable(section) { + const direct = section.match( + /^(dependencies|dev-dependencies|build-dependencies)\.(.+)$/, + ); + if (direct) { + return { tableType: direct[1], depName: direct[2].trim() }; + } + const targetMatch = section.match( + /^target\.[^.]+\.(dependencies|dev-dependencies|build-dependencies)\.(.+)$/, + ); + if (targetMatch) { + return { tableType: targetMatch[1], depName: targetMatch[2].trim() }; + } + return null; +} + +/** + * Validates the root Cargo.toml and all workspace member Cargo.toml files + * to ensure `soroban-sdk` is centrally managed in `[workspace.dependencies]` + * and never overridden per crate. + * + * @param {string} rootDir + * @returns {{ ok: boolean, workspaceVersion: string | null, checkedMembers: string[], errors: string[] }} + */ +function checkWorkspaceSorobanSdk(rootDir) { + const errors = []; + const rootManifestPath = path.join(rootDir, "Cargo.toml"); + + if (!fs.existsSync(rootManifestPath)) { + return { + ok: false, + workspaceVersion: null, + checkedMembers: [], + errors: [`Missing root Cargo.toml at ${rootManifestPath}`], + }; + } + + const rootContent = fs.readFileSync(rootManifestPath, "utf8"); + const rootEntries = parseTomlEntries(rootContent); + + // 1. Extract workspace members + const membersEntry = rootEntries.find( + (e) => e.section === "workspace" && e.key === "members", + ); + if (!membersEntry) { + errors.push("Root Cargo.toml is missing [workspace] members list"); + } + + const members = []; + if (membersEntry) { + const strMatches = membersEntry.rawValue.matchAll(/"([^"]+)"|'([^']+)'/g); + for (const m of strMatches) { + members.push(m[1] || m[2]); + } + } + + // 2. Check [workspace.dependencies] for soroban-sdk + let workspaceVersion = null; + const wsSdkEntry = rootEntries.find( + (e) => + (e.section === "workspace.dependencies" && e.key === "soroban-sdk") || + (e.section === "workspace.dependencies.soroban-sdk" && + e.key === "version"), + ); + + if (!wsSdkEntry) { + errors.push( + "Root Cargo.toml must define `soroban-sdk` under `[workspace.dependencies]`", + ); + } else if (wsSdkEntry.section === "workspace.dependencies") { + const raw = wsSdkEntry.rawValue; + if (/^["'][^"']+["']$/.test(raw)) { + workspaceVersion = raw.slice(1, -1); + } else { + const table = parseInlineTable(raw); + if (table && table.version && /^["'][^"']+["']$/.test(table.version)) { + workspaceVersion = table.version.slice(1, -1); + } + } + if (!workspaceVersion || workspaceVersion === "*") { + errors.push( + `Root Cargo.toml [workspace.dependencies] soroban-sdk must pin an explicit version (found: ${raw})`, + ); + } + } else { + const raw = wsSdkEntry.rawValue; + if (/^["'][^"']+["']$/.test(raw)) { + workspaceVersion = raw.slice(1, -1); + } + if (!workspaceVersion || workspaceVersion === "*") { + errors.push( + `Root Cargo.toml [workspace.dependencies.soroban-sdk] must pin an explicit version (found: ${raw})`, + ); + } + } + + // 3. Check each member crate's Cargo.toml + const checkedMembers = []; + for (const memberRelPath of members) { + const memberManifestPath = path.join(rootDir, memberRelPath, "Cargo.toml"); + if (!fs.existsSync(memberManifestPath)) { + errors.push( + `Workspace member "${memberRelPath}" is missing Cargo.toml at ${memberManifestPath}`, + ); + continue; + } + + checkedMembers.push(memberRelPath); + const memberContent = fs.readFileSync(memberManifestPath, "utf8"); + const memberEntries = parseTomlEntries(memberContent); + + // Track subtable entries like [dependencies.soroban-sdk] + const subtables = new Map(); + + for (const entry of memberEntries) { + // Case A: Entry inside [dependencies], [dev-dependencies], etc. + if (isDependencySection(entry.section)) { + // Dotted key e.g. `soroban-sdk.version = "27.0.5"` or `soroban-sdk.workspace = true` + if (entry.key.startsWith("soroban-sdk.")) { + const subKey = entry.key.slice("soroban-sdk.".length).trim(); + const mapKey = `${entry.section}::soroban-sdk`; + if (!subtables.has(mapKey)) { + subtables.set(mapKey, { + section: entry.section, + line: entry.line, + props: {}, + }); + } + subtables.get(mapKey).props[subKey] = entry.rawValue; + continue; + } + + const inlineTable = parseInlineTable(entry.rawValue); + const isSorobanSdkAlias = + inlineTable && + inlineTable.package && + inlineTable.package.replace(/["']/g, "") === "soroban-sdk"; + + if (entry.key === "soroban-sdk" || isSorobanSdkAlias) { + if (!inlineTable) { + errors.push( + `${memberRelPath}/Cargo.toml:${entry.line} [${entry.section}] pins "${entry.key} = ${entry.rawValue}" directly; must use "{ workspace = true }"`, + ); + continue; + } + + if (inlineTable.workspace !== "true") { + errors.push( + `${memberRelPath}/Cargo.toml:${entry.line} [${entry.section}] "${entry.key}" must set "workspace = true"`, + ); + } + + for (const forbidden of ["version", "git", "path", "rev", "branch", "tag"]) { + if (Object.prototype.hasOwnProperty.call(inlineTable, forbidden)) { + errors.push( + `${memberRelPath}/Cargo.toml:${entry.line} [${entry.section}] "${entry.key}" overrides "${forbidden} = ${inlineTable[forbidden]}"; version/source must only be set in root [workspace.dependencies]`, + ); + } + } + } + } + + // Case B: Subtable section e.g. [dependencies.soroban-sdk] + const sub = parseDependencySubtable(entry.section); + if (sub) { + const mapKey = `${sub.tableType}::${sub.depName}`; + if (!subtables.has(mapKey)) { + subtables.set(mapKey, { + section: entry.section, + depName: sub.depName, + line: entry.line, + props: {}, + }); + } + subtables.get(mapKey).props[entry.key] = entry.rawValue; + } + } + + for (const [, info] of subtables.entries()) { + const isSoroban = + info.depName === "soroban-sdk" || + info.section.endsWith("soroban-sdk") || + (info.props.package && + info.props.package.replace(/["']/g, "") === "soroban-sdk"); + + if (!isSoroban) { + continue; + } + + if (info.props.workspace !== "true") { + errors.push( + `${memberRelPath}/Cargo.toml:${info.line} [${info.section}] must set "workspace = true"`, + ); + } + + for (const forbidden of ["version", "git", "path", "rev", "branch", "tag"]) { + if (Object.prototype.hasOwnProperty.call(info.props, forbidden)) { + errors.push( + `${memberRelPath}/Cargo.toml:${info.line} [${info.section}] overrides "${forbidden} = ${info.props[forbidden]}"; version/source must only be set in root [workspace.dependencies]`, + ); + } + } + } + } + + // 4. Verify Cargo.lock (if present) does not resolve multiple versions of soroban-sdk + const lockfilePath = path.join(rootDir, "Cargo.lock"); + if (fs.existsSync(lockfilePath)) { + const lockContent = fs.readFileSync(lockfilePath, "utf8"); + const sdkVersions = new Set(); + const pkgBlocks = lockContent.split("[[package]]"); + for (const block of pkgBlocks.slice(1)) { + const nameMatch = block.match(/^\s*name\s*=\s*"([^"]+)"/m); + const versionMatch = block.match(/^\s*version\s*=\s*"([^"]+)"/m); + if (nameMatch && nameMatch[1] === "soroban-sdk" && versionMatch) { + sdkVersions.add(versionMatch[1]); + } + } + if (sdkVersions.size > 1) { + errors.push( + `Cargo.lock resolves multiple versions of soroban-sdk: ${[...sdkVersions].join(", ")}`, + ); + } + } + + return { + ok: errors.length === 0, + workspaceVersion, + checkedMembers, + errors, + }; +} + +function main(argv = process.argv.slice(2)) { + let rootDir = path.join(__dirname, ".."); + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] === "--root" && i + 1 < argv.length) { + rootDir = path.resolve(argv[++i]); + } else { + throw new Error(`Unknown or incomplete argument: ${argv[i]}`); + } + } + + const result = checkWorkspaceSorobanSdk(rootDir); + if (!result.ok) { + for (const err of result.errors) { + console.error(`error: ${err}`); + } + process.exit(1); + } + + console.log( + `ok: soroban-sdk (${result.workspaceVersion}) is consistently inherited via [workspace.dependencies] across ${result.checkedMembers.length} workspace members`, + ); +} + +if (require.main === module) { + try { + main(); + } catch (err) { + console.error(`error: ${err.message}`); + process.exit(1); + } +} + +module.exports = { + stripTomlComment, + parseTomlEntries, + parseInlineTable, + checkWorkspaceSorobanSdk, +}; diff --git a/scripts/check-wasm-size.cjs b/scripts/check-wasm-size.cjs new file mode 100644 index 0000000..9c59749 --- /dev/null +++ b/scripts/check-wasm-size.cjs @@ -0,0 +1,423 @@ +#!/usr/bin/env node +/** + * Measures compiled Soroban contract WASM sizes against contracts/size-budget.json, + * formats a Markdown size & delta report for CI summaries and pull request comments, + * and exits non-zero if any contract exceeds its configured byte budget (#93, #94). + * + * Usage: + * node scripts/check-wasm-size.cjs [options] + * + * Options: + * --budget Path to size budget JSON (default: contracts/size-budget.json) + * --wasm-dir Directory containing compiled .wasm files + * (default: target/wasm32v1-none/release) + * --base Optional base size-budget.json or sizes JSON for delta comparison + * --output-markdown Write the Markdown report to a file + * --output-json Write the measured byte sizes and deltas as JSON to a file + * --post-pr-comment Create or update a pull request comment when running in GitHub Actions + */ +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const COMMENT_MARKER = ""; + +/** + * Normalizes a single contract entry from size-budget.json. + * + * @param {string} contractName + * @param {number | { wasm?: string, max_bytes?: number, budget_bytes?: number, baseline_bytes?: number }} rawEntry + */ +function parseBudgetEntry(contractName, rawEntry) { + const defaultWasm = `${contractName.replace(/-/g, "_")}.wasm`; + if (typeof rawEntry === "number") { + if (!Number.isInteger(rawEntry) || rawEntry <= 0) { + throw new Error( + `Invalid budget for "${contractName}": max_bytes must be a positive integer`, + ); + } + return { + contract: contractName, + wasm: defaultWasm, + maxBytes: rawEntry, + baselineBytes: null, + }; + } + + if (typeof rawEntry !== "object" || rawEntry === null) { + throw new Error( + `Invalid budget entry for "${contractName}": expected an object or positive integer`, + ); + } + + const maxBytes = + rawEntry.max_bytes !== undefined ? rawEntry.max_bytes : rawEntry.budget_bytes; + if (!Number.isInteger(maxBytes) || maxBytes <= 0) { + throw new Error( + `Invalid budget for "${contractName}": max_bytes must be a positive integer`, + ); + } + + const baselineBytes = + rawEntry.baseline_bytes !== undefined && rawEntry.baseline_bytes !== null + ? rawEntry.baseline_bytes + : null; + if ( + baselineBytes !== null && + (!Number.isInteger(baselineBytes) || baselineBytes < 0) + ) { + throw new Error( + `Invalid baseline_bytes for "${contractName}": must be a non-negative integer`, + ); + } + + const wasm = + typeof rawEntry.wasm === "string" && rawEntry.wasm.trim().length > 0 + ? rawEntry.wasm.trim() + : defaultWasm; + + return { + contract: contractName, + wasm, + maxBytes, + baselineBytes, + }; +} + +/** + * Formats a byte delta with explicit sign and KB (1024-byte KiB) representation. + * + * @param {number | null} deltaBytes + * @returns {string} + */ +function formatDelta(deltaBytes) { + if (deltaBytes === null || deltaBytes === undefined) { + return "n/a"; + } + if (deltaBytes === 0) { + return "0 B (0.00 KB)"; + } + const sign = deltaBytes > 0 ? "+" : "-"; + const absBytes = Math.abs(deltaBytes); + const absKb = (absBytes / 1024).toFixed(2); + return `${sign}${absBytes.toLocaleString("en-US")} B (${sign}${absKb} KB)`; +} + +/** + * Evaluates WASM sizes for all contracts configured in the budget object. + * + * @param {Record} budgetConfig + * @param {string} wasmDir + * @param {Record | null} [baseConfig] + */ +function evaluateWasmSizes(budgetConfig, wasmDir, baseConfig = null) { + if ( + typeof budgetConfig !== "object" || + budgetConfig === null || + Array.isArray(budgetConfig) + ) { + throw new Error( + "Budget configuration must be a JSON object mapping contract names to budgets", + ); + } + + const contractNames = Object.keys(budgetConfig); + if (contractNames.length === 0) { + throw new Error("Budget configuration contains no contracts"); + } + + const results = []; + const errors = []; + + for (const contractName of contractNames) { + const entry = parseBudgetEntry(contractName, budgetConfig[contractName]); + const wasmPath = path.join(wasmDir, entry.wasm); + + if (!fs.existsSync(wasmPath)) { + errors.push( + `Missing WASM artifact for "${contractName}": expected ${wasmPath}`, + ); + continue; + } + + const stat = fs.statSync(wasmPath); + const sizeBytes = stat.size; + + let baselineBytes = entry.baselineBytes; + if ( + baseConfig && + Object.prototype.hasOwnProperty.call(baseConfig, contractName) + ) { + const baseItem = baseConfig[contractName]; + if (typeof baseItem === "number") { + baselineBytes = baseItem; + } else if (typeof baseItem === "object" && baseItem !== null) { + if (typeof baseItem.size_bytes === "number") { + baselineBytes = baseItem.size_bytes; + } else if (typeof baseItem.baseline_bytes === "number") { + baselineBytes = baseItem.baseline_bytes; + } + } + } + + const deltaBytes = + baselineBytes !== null && baselineBytes !== undefined + ? sizeBytes - baselineBytes + : null; + const headroomBytes = entry.maxBytes - sizeBytes; + const overBudget = sizeBytes > entry.maxBytes; + + if (overBudget) { + errors.push( + `Contract "${contractName}" (${entry.wasm}) is ${sizeBytes} bytes, exceeding its budget of ${entry.maxBytes} bytes by ${sizeBytes - entry.maxBytes} bytes`, + ); + } + + results.push({ + contract: contractName, + wasm: entry.wasm, + sizeBytes, + baselineBytes, + deltaBytes, + maxBytes: entry.maxBytes, + headroomBytes, + utilizationPct: Number(((sizeBytes / entry.maxBytes) * 100).toFixed(1)), + overBudget, + }); + } + + return { + ok: errors.length === 0, + results, + errors, + }; +} + +/** + * Renders the evaluation result as a Markdown comment / summary table. + * + * @param {{ ok: boolean, results: Array, errors: Array }} evaluation + * @returns {string} + */ +function renderMarkdownReport(evaluation) { + const lines = [ + COMMENT_MARKER, + "## Contract WASM Size Report", + "", + "| Contract | Artifact | Size (bytes) | Size (KB) | Delta | Budget (bytes) | Usage | Status |", + "|---|---|---:|---:|---:|---:|---:|---|", + ]; + + for (const row of evaluation.results) { + const sizeKb = (row.sizeBytes / 1024).toFixed(2); + const deltaStr = formatDelta(row.deltaBytes); + const statusStr = row.overBudget + ? `FAIL (+${(row.sizeBytes - row.maxBytes).toLocaleString("en-US")} B over)` + : "PASS"; + lines.push( + `| \`${row.contract}\` | \`${row.wasm}\` | ${row.sizeBytes.toLocaleString("en-US")} | ${sizeKb} KB | ${deltaStr} | ${row.maxBytes.toLocaleString("en-US")} | ${row.utilizationPct.toFixed(1)}% | ${statusStr} |`, + ); + } + + lines.push(""); + if (evaluation.ok) { + lines.push( + "All contracts are within their configured WASM size budgets (`contracts/size-budget.json`).", + ); + } else { + lines.push("### Budget Violations"); + lines.push(""); + for (const err of evaluation.errors) { + lines.push(`- ${err}`); + } + } + + return lines.join("\n") + "\n"; +} + +/** + * Posts or updates the WASM size report comment on a GitHub Pull Request. + * Gracefully handles read-only GITHUB_TOKEN on fork pull requests. + * + * @param {string} markdown + * @param {NodeJS.ProcessEnv} [env] + * @param {typeof fetch} [fetchImpl] + * @returns {Promise<{ posted: boolean, updated?: boolean, reason?: string }>} + */ +async function postOrUpdatePrComment( + markdown, + env = process.env, + fetchImpl = globalThis.fetch, +) { + const token = env.GITHUB_TOKEN; + const repo = env.GITHUB_REPOSITORY; + const eventPath = env.GITHUB_EVENT_PATH; + const apiUrl = (env.GITHUB_API_URL || "https://api.github.com").replace( + /\/$/, + "", + ); + + if (!token || !repo || !eventPath || !fs.existsSync(eventPath)) { + return { posted: false, reason: "missing-github-env" }; + } + + const event = JSON.parse(fs.readFileSync(eventPath, "utf8")); + const prNumber = event.pull_request && event.pull_request.number; + if (!prNumber) { + return { posted: false, reason: "not-a-pull-request" }; + } + + const headers = { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }; + + const listUrl = `${apiUrl}/repos/${repo}/issues/${prNumber}/comments?per_page=100`; + const listRes = await fetchImpl(listUrl, { method: "GET", headers }); + if (!listRes.ok) { + if (listRes.status === 401 || listRes.status === 403) { + console.log( + `Skipping PR comment: GITHUB_TOKEN lacks permission to list comments (HTTP ${listRes.status}).`, + ); + return { posted: false, reason: "insufficient-permissions" }; + } + throw new Error(`Failed to list PR comments: HTTP ${listRes.status}`); + } + + const comments = await listRes.json(); + const existing = Array.isArray(comments) + ? comments.find( + (c) => typeof c.body === "string" && c.body.includes(COMMENT_MARKER), + ) + : null; + + if (existing) { + const updateUrl = `${apiUrl}/repos/${repo}/issues/comments/${existing.id}`; + const updateRes = await fetchImpl(updateUrl, { + method: "PATCH", + headers, + body: JSON.stringify({ body: markdown }), + }); + if (!updateRes.ok) { + if (updateRes.status === 401 || updateRes.status === 403) { + console.log( + `Skipping PR comment update: GITHUB_TOKEN is read-only for fork pull requests (HTTP ${updateRes.status}).`, + ); + return { posted: false, reason: "insufficient-permissions" }; + } + throw new Error(`Failed to update PR comment: HTTP ${updateRes.status}`); + } + return { posted: true, updated: true }; + } + + const createUrl = `${apiUrl}/repos/${repo}/issues/${prNumber}/comments`; + const createRes = await fetchImpl(createUrl, { + method: "POST", + headers, + body: JSON.stringify({ body: markdown }), + }); + if (!createRes.ok) { + if (createRes.status === 401 || createRes.status === 403) { + console.log( + `Skipping PR comment creation: GITHUB_TOKEN is read-only for fork pull requests (HTTP ${createRes.status}).`, + ); + return { posted: false, reason: "insufficient-permissions" }; + } + throw new Error(`Failed to create PR comment: HTTP ${createRes.status}`); + } + + return { posted: true, updated: false }; +} + +function parseArgs(argv) { + const root = path.join(__dirname, ".."); + const opts = { + budgetPath: path.join(root, "contracts", "size-budget.json"), + wasmDir: path.join(root, "target", "wasm32v1-none", "release"), + basePath: null, + outputMarkdown: null, + outputJson: null, + postPrComment: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--budget" && i + 1 < argv.length) { + opts.budgetPath = path.resolve(argv[++i]); + } else if (arg === "--wasm-dir" && i + 1 < argv.length) { + opts.wasmDir = path.resolve(argv[++i]); + } else if (arg === "--base" && i + 1 < argv.length) { + opts.basePath = path.resolve(argv[++i]); + } else if (arg === "--output-markdown" && i + 1 < argv.length) { + opts.outputMarkdown = path.resolve(argv[++i]); + } else if (arg === "--output-json" && i + 1 < argv.length) { + opts.outputJson = path.resolve(argv[++i]); + } else if (arg === "--post-pr-comment") { + opts.postPrComment = true; + } else { + throw new Error(`Unknown or incomplete argument: ${arg}`); + } + } + + return opts; +} + +async function main(argv = process.argv.slice(2)) { + const opts = parseArgs(argv); + const budgetConfig = JSON.parse(fs.readFileSync(opts.budgetPath, "utf8")); + const baseConfig = + opts.basePath && fs.existsSync(opts.basePath) + ? JSON.parse(fs.readFileSync(opts.basePath, "utf8")) + : null; + + const evaluation = evaluateWasmSizes(budgetConfig, opts.wasmDir, baseConfig); + const markdown = renderMarkdownReport(evaluation); + + process.stdout.write(markdown); + + if (process.env.GITHUB_STEP_SUMMARY) { + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown, "utf8"); + } + + if (opts.outputMarkdown) { + fs.writeFileSync(opts.outputMarkdown, markdown, "utf8"); + } + + if (opts.outputJson) { + fs.writeFileSync( + opts.outputJson, + JSON.stringify(evaluation, null, 2) + "\n", + "utf8", + ); + } + + if (opts.postPrComment) { + await postOrUpdatePrComment(markdown); + } + + if (!evaluation.ok) { + for (const err of evaluation.errors) { + console.error(`error: ${err}`); + } + process.exit(1); + } +} + +if (require.main === module) { + main().catch((err) => { + console.error(`error: ${err.message}`); + process.exit(1); + }); +} + +module.exports = { + COMMENT_MARKER, + parseBudgetEntry, + formatDelta, + evaluateWasmSizes, + renderMarkdownReport, + postOrUpdatePrComment, +}; diff --git a/sdk/tests/ci-tooling.test.ts b/sdk/tests/ci-tooling.test.ts new file mode 100644 index 0000000..16f4276 --- /dev/null +++ b/sdk/tests/ci-tooling.test.ts @@ -0,0 +1,438 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { spawnSync } from "child_process"; +import { afterEach, describe, expect, it } from "vitest"; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const wasmSize = require("../../scripts/check-wasm-size.cjs") as { + COMMENT_MARKER: string; + parseBudgetEntry: ( + name: string, + raw: unknown, + ) => { + contract: string; + wasm: string; + maxBytes: number; + baselineBytes: number | null; + }; + formatDelta: (delta: number | null) => string; + evaluateWasmSizes: ( + budget: Record, + wasmDir: string, + base?: Record | null, + ) => { + ok: boolean; + results: Array<{ + contract: string; + wasm: string; + sizeBytes: number; + baselineBytes: number | null; + deltaBytes: number | null; + maxBytes: number; + headroomBytes: number; + utilizationPct: number; + overBudget: boolean; + }>; + errors: string[]; + }; + renderMarkdownReport: (evaluation: unknown) => string; + postOrUpdatePrComment: ( + markdown: string, + env: Record, + fetchImpl: ( + url: string, + init?: { method?: string; headers?: Record; body?: string }, + ) => Promise<{ ok: boolean; status: number; json: () => Promise }>, + ) => Promise<{ posted: boolean; updated?: boolean; reason?: string }>; +}; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const sdkCheck = require("../../scripts/check-soroban-sdk-version.cjs") as { + checkWorkspaceSorobanSdk: (rootDir: string) => { + ok: boolean; + workspaceVersion: string | null; + checkedMembers: string[]; + errors: string[]; + }; +}; + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); + +const tempDirs: string[] = []; +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop()!; + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("Stage 1 repository and CI tooling (#91, #93, #94, #95)", () => { + describe("#91 cargo-deny configuration (deny.toml & security.yml)", () => { + it("configures deny.toml to allow Apache-2.0, MIT, BSD and reject GPL and unknown registries", () => { + const denyPath = path.join(REPO_ROOT, "deny.toml"); + expect(fs.existsSync(denyPath)).toBe(true); + + const content = fs.readFileSync(denyPath, "utf8"); + expect(content).toContain('"Apache-2.0"'); + expect(content).toContain('"MIT"'); + expect(content).toContain('"BSD-2-Clause"'); + expect(content).toContain('"BSD-3-Clause"'); + expect(content).not.toMatch(/"A?L?GPL-[0-9.]+/); + expect(content).toContain('unknown-registry = "deny"'); + expect(content).toContain('unknown-git = "deny"'); + expect(content).toContain("https://github.com/rust-lang/crates.io-index"); + }); + + it("wires cargo deny check into .github/workflows/security.yml", () => { + const workflowPath = path.join( + REPO_ROOT, + ".github", + "workflows", + "security.yml", + ); + const workflow = fs.readFileSync(workflowPath, "utf8"); + expect(workflow).toContain("deny.toml"); + expect(workflow).toContain("cargo install cargo-deny --locked --version"); + expect(workflow).toContain("cargo deny check"); + }); + }); + + describe("#94 contracts/size-budget.json & #93 WASM size tracking", () => { + const EXPECTED_CONTRACTS = [ + "rwa-asset", + "registry", + "compliance", + "governance", + "oracle-adapter", + ]; + + it("defines valid size budgets for all five contracts in contracts/size-budget.json", () => { + const budgetPath = path.join(REPO_ROOT, "contracts", "size-budget.json"); + expect(fs.existsSync(budgetPath)).toBe(true); + + const budget = JSON.parse(fs.readFileSync(budgetPath, "utf8")) as Record< + string, + { wasm: string; baseline_bytes: number; max_bytes: number } + >; + + for (const name of EXPECTED_CONTRACTS) { + expect(budget[name]).toBeDefined(); + const parsed = wasmSize.parseBudgetEntry(name, budget[name]); + expect(parsed.maxBytes).toBeGreaterThan(0); + // Must stay under Soroban's 64 KiB mainnet contract size ceiling + expect(parsed.maxBytes).toBeLessThanOrEqual(65536); + expect(parsed.baselineBytes).toBeGreaterThan(0); + expect(parsed.baselineBytes!).toBeLessThanOrEqual(parsed.maxBytes); + } + }); + + it("shows the exact +10 KB delta when a PR adds 10 KB to a contract and fails if above budget", () => { + const wasmDir = makeTempDir("stellarforge-wasm-"); + const budget = { + "rwa-asset": { + wasm: "rwa_asset.wasm", + baseline_bytes: 35000, + max_bytes: 44000, + }, + registry: { + wasm: "registry.wasm", + baseline_bytes: 11000, + max_bytes: 25000, + }, + }; + + // registry grows by +10 KB (10,240 bytes) from 11,000 -> 21,240 (still within 25,000 budget) + fs.writeFileSync( + path.join(wasmDir, "rwa_asset.wasm"), + Buffer.alloc(35000), + ); + fs.writeFileSync( + path.join(wasmDir, "registry.wasm"), + Buffer.alloc(11000 + 10240), + ); + + const evalPass = wasmSize.evaluateWasmSizes(budget, wasmDir); + expect(evalPass.ok).toBe(true); + const regRow = evalPass.results.find((r) => r.contract === "registry")!; + expect(regRow.deltaBytes).toBe(10240); + + const reportPass = wasmSize.renderMarkdownReport(evalPass); + expect(reportPass).toContain(wasmSize.COMMENT_MARKER); + expect(reportPass).toContain("+10,240 B (+10.00 KB)"); + expect(reportPass).toContain("| `registry` | `registry.wasm` | 21,240 | 20.74 KB | +10,240 B (+10.00 KB) | 25,000 | 85.0% | PASS |"); + + // Now rwa-asset also grows by +10 KB (35,000 -> 45,240), exceeding its 44,000 budget + fs.writeFileSync( + path.join(wasmDir, "rwa_asset.wasm"), + Buffer.alloc(35000 + 10240), + ); + + const evalFail = wasmSize.evaluateWasmSizes(budget, wasmDir); + expect(evalFail.ok).toBe(false); + expect(evalFail.errors[0]).toContain( + 'Contract "rwa-asset" (rwa_asset.wasm) is 45240 bytes, exceeding its budget of 44000 bytes by 1240 bytes', + ); + + const reportFail = wasmSize.renderMarkdownReport(evalFail); + expect(reportFail).toContain("FAIL (+1,240 B over)"); + expect(reportFail).toContain("### Budget Violations"); + }); + + it("handles negative deltas, missing WASM artifacts, and invalid budget entries", () => { + expect(wasmSize.formatDelta(0)).toBe("0 B (0.00 KB)"); + expect(wasmSize.formatDelta(-2048)).toBe("-2,048 B (-2.00 KB)"); + expect(wasmSize.formatDelta(null)).toBe("n/a"); + + const wasmDir = makeTempDir("stellarforge-wasm-missing-"); + const evaluation = wasmSize.evaluateWasmSizes( + { registry: { wasm: "registry.wasm", max_bytes: 20480 } }, + wasmDir, + ); + expect(evaluation.ok).toBe(false); + expect(evaluation.errors[0]).toContain('Missing WASM artifact for "registry"'); + + expect(() => wasmSize.parseBudgetEntry("bad", 0)).toThrow( + /positive integer/, + ); + expect(() => wasmSize.parseBudgetEntry("bad", { max_bytes: -5 })).toThrow( + /positive integer/, + ); + }); + + it("creates, updates, or gracefully skips PR comments on fork PRs", async () => { + const tmp = makeTempDir("stellarforge-pr-event-"); + const eventPath = path.join(tmp, "event.json"); + fs.writeFileSync( + eventPath, + JSON.stringify({ pull_request: { number: 42 } }), + ); + + const env = { + GITHUB_TOKEN: "test-token", + GITHUB_REPOSITORY: "0xLizTech/stellarforge", + GITHUB_EVENT_PATH: eventPath, + GITHUB_API_URL: "https://api.github.example", + }; + + const calls: Array<{ + url: string; + method: string | undefined; + body: string | undefined; + }> = []; + + // 1. Creates a new comment when none exists + const resCreate = await wasmSize.postOrUpdatePrComment( + `${wasmSize.COMMENT_MARKER}\nhello`, + env, + async (url, init) => { + calls.push({ url, method: init?.method, body: init?.body }); + if (init?.method === "GET") { + return { ok: true, status: 200, json: async () => [] }; + } + return { ok: true, status: 201, json: async () => ({ id: 101 }) }; + }, + ); + expect(resCreate).toEqual({ posted: true, updated: false }); + expect(calls).toHaveLength(2); + expect(calls[1]?.method).toBe("POST"); + + // 2. Updates existing comment when marker is found + calls.length = 0; + const resUpdate = await wasmSize.postOrUpdatePrComment( + `${wasmSize.COMMENT_MARKER}\nupdated`, + env, + async (url, init) => { + calls.push({ url, method: init?.method, body: init?.body }); + if (init?.method === "GET") { + return { + ok: true, + status: 200, + json: async () => [ + { id: 777, body: `${wasmSize.COMMENT_MARKER}\nold` }, + ], + }; + } + return { ok: true, status: 200, json: async () => ({ id: 777 }) }; + }, + ); + expect(resUpdate).toEqual({ posted: true, updated: true }); + expect(calls[1]?.method).toBe("PATCH"); + expect(calls[1]?.url).toContain("/issues/comments/777"); + + // 3. Gracefully skips when fork GITHUB_TOKEN returns 403 + const resForbidden = await wasmSize.postOrUpdatePrComment( + `${wasmSize.COMMENT_MARKER}\nfork`, + env, + async (_url, init) => { + if (init?.method === "GET") { + return { ok: true, status: 200, json: async () => [] }; + } + return { ok: false, status: 403, json: async () => ({}) }; + }, + ); + expect(resForbidden).toEqual({ + posted: false, + reason: "insufficient-permissions", + }); + }); + }); + + describe("#95 soroban-sdk version consistency check", () => { + it("passes on the real StellarForge workspace", () => { + const res = sdkCheck.checkWorkspaceSorobanSdk(REPO_ROOT); + expect(res.ok).toBe(true); + expect(res.errors).toEqual([]); + expect(res.workspaceVersion).toBe("27.0.5"); + expect(res.checkedMembers).toContain("contracts/rwa-asset"); + expect(res.checkedMembers).toContain("contracts/registry"); + expect(res.checkedMembers).toContain("contracts/compliance"); + expect(res.checkedMembers).toContain("contracts/governance"); + expect(res.checkedMembers).toContain("contracts/oracle-adapter"); + }); + + it("fails when a workspace crate pins its own soroban-sdk version", () => { + const wsDir = makeTempDir("stellarforge-sdk-drift-"); + fs.mkdirSync(path.join(wsDir, "contracts", "alpha"), { recursive: true }); + fs.mkdirSync(path.join(wsDir, "contracts", "beta"), { recursive: true }); + + fs.writeFileSync( + path.join(wsDir, "Cargo.toml"), + `[workspace] +members = ["contracts/alpha", "contracts/beta"] +resolver = "2" + +[workspace.dependencies] +soroban-sdk = { version = "27.0.5" } +`, + ); + + // alpha uses workspace = true properly + fs.writeFileSync( + path.join(wsDir, "contracts", "alpha", "Cargo.toml"), + `[package] +name = "alpha" +version = "0.1.0" +edition = "2021" + +[dependencies] +soroban-sdk = { workspace = true } +`, + ); + + // beta pins its own version in [dependencies] and [dev-dependencies] + fs.writeFileSync( + path.join(wsDir, "contracts", "beta", "Cargo.toml"), + `[package] +name = "beta" +version = "0.1.0" +edition = "2021" + +[dependencies] +soroban-sdk = "27.0.5" + +[dev-dependencies] +soroban-sdk = { version = "27.0.0", features = ["testutils"] } +`, + ); + + const res = sdkCheck.checkWorkspaceSorobanSdk(wsDir); + expect(res.ok).toBe(false); + expect(res.errors.length).toBeGreaterThanOrEqual(2); + expect(res.errors.join("\n")).toContain("contracts/beta/Cargo.toml"); + + // Also verify CLI exits with code 1 + const cliRun = spawnSync( + process.execPath, + [ + path.join(REPO_ROOT, "scripts", "check-soroban-sdk-version.cjs"), + "--root", + wsDir, + ], + { encoding: "utf8" }, + ); + expect(cliRun.status).toBe(1); + }); + + it("fails when a crate uses a [dependencies.soroban-sdk] subtable or dotted version override", () => { + const wsDir = makeTempDir("stellarforge-sdk-subtable-"); + fs.mkdirSync(path.join(wsDir, "contracts", "gamma"), { recursive: true }); + + fs.writeFileSync( + path.join(wsDir, "Cargo.toml"), + `[workspace] +members = ["contracts/gamma"] + +[workspace.dependencies] +soroban-sdk = { version = "27.0.5" } +`, + ); + + fs.writeFileSync( + path.join(wsDir, "contracts", "gamma", "Cargo.toml"), + `[package] +name = "gamma" +version = "0.1.0" +edition = "2021" + +[dependencies.soroban-sdk] +version = "27.0.5" +`, + ); + + const res = sdkCheck.checkWorkspaceSorobanSdk(wsDir); + expect(res.ok).toBe(false); + expect(res.errors.join("\n")).toContain( + 'contracts/gamma/Cargo.toml:7 [dependencies.soroban-sdk] must set "workspace = true"', + ); + }); + + it("fails when Cargo.lock resolves multiple versions of soroban-sdk", () => { + const wsDir = makeTempDir("stellarforge-sdk-lock-"); + fs.mkdirSync(path.join(wsDir, "contracts", "alpha"), { recursive: true }); + + fs.writeFileSync( + path.join(wsDir, "Cargo.toml"), + `[workspace] +members = ["contracts/alpha"] + +[workspace.dependencies] +soroban-sdk = { version = "27.0.5" } +`, + ); + fs.writeFileSync( + path.join(wsDir, "contracts", "alpha", "Cargo.toml"), + `[package] +name = "alpha" +version = "0.1.0" + +[dependencies] +soroban-sdk = { workspace = true } +`, + ); + fs.writeFileSync( + path.join(wsDir, "Cargo.lock"), + `[[package]] +name = "soroban-sdk" +version = "27.0.5" + +[[package]] +name = "soroban-sdk" +version = "27.0.6" +`, + ); + + const res = sdkCheck.checkWorkspaceSorobanSdk(wsDir); + expect(res.ok).toBe(false); + expect(res.errors.join("\n")).toContain( + "Cargo.lock resolves multiple versions of soroban-sdk: 27.0.5, 27.0.6", + ); + }); + }); +}); diff --git a/tools/benchmark/Cargo.toml b/tools/benchmark/Cargo.toml index 3d89b3d..c1d83d8 100644 --- a/tools/benchmark/Cargo.toml +++ b/tools/benchmark/Cargo.toml @@ -2,6 +2,7 @@ name = "stellarforge-benchmark" version = "0.1.0" edition = "2021" +license = "Apache-2.0" publish = false [[bin]] diff --git a/tools/benchmark/src/benches/compliance.rs b/tools/benchmark/src/benches/compliance.rs index 6ffb3df..7b710c4 100644 --- a/tools/benchmark/src/benches/compliance.rs +++ b/tools/benchmark/src/benches/compliance.rs @@ -52,8 +52,10 @@ pub fn run() -> Vec { rows.push(measure(&env, CONTRACT, "revoke_kyc")); let new_admin = Address::generate(&env); - client.transfer_admin(&new_admin); - rows.push(measure(&env, CONTRACT, "transfer_admin")); + client.propose_admin(&new_admin); + rows.push(measure(&env, CONTRACT, "propose_admin")); + client.accept_admin(); + rows.push(measure(&env, CONTRACT, "accept_admin")); rows } diff --git a/tools/benchmark/src/benches/governance.rs b/tools/benchmark/src/benches/governance.rs index a3f5ab7..7263909 100644 --- a/tools/benchmark/src/benches/governance.rs +++ b/tools/benchmark/src/benches/governance.rs @@ -46,8 +46,10 @@ pub fn run() -> Vec { rows.push(measure(&env, CONTRACT, "admin")); let new_admin = Address::generate(&env); - client.transfer_admin(&new_admin); - rows.push(measure(&env, CONTRACT, "transfer_admin")); + client.propose_admin(&new_admin); + rows.push(measure(&env, CONTRACT, "propose_admin")); + client.accept_admin(); + rows.push(measure(&env, CONTRACT, "accept_admin")); rows } diff --git a/tools/benchmark/src/benches/oracle_adapter.rs b/tools/benchmark/src/benches/oracle_adapter.rs index b39e491..1a94c51 100644 --- a/tools/benchmark/src/benches/oracle_adapter.rs +++ b/tools/benchmark/src/benches/oracle_adapter.rs @@ -85,8 +85,10 @@ pub fn run() -> Vec { rows.push(measure(&env, CONTRACT, "set_max_deviation")); let new_admin = Address::generate(&env); - client.transfer_admin(&new_admin); - rows.push(measure(&env, CONTRACT, "transfer_admin")); + client.propose_admin(&new_admin); + rows.push(measure(&env, CONTRACT, "propose_admin")); + client.accept_admin(); + rows.push(measure(&env, CONTRACT, "accept_admin")); rows } diff --git a/tools/benchmark/src/benches/registry.rs b/tools/benchmark/src/benches/registry.rs index a32712a..1d0750b 100644 --- a/tools/benchmark/src/benches/registry.rs +++ b/tools/benchmark/src/benches/registry.rs @@ -46,8 +46,10 @@ pub fn run() -> Vec { client.set_paused(&false); let new_admin = Address::generate(&env); - client.transfer_admin(&new_admin); - rows.push(measure(&env, CONTRACT, "transfer_admin")); + client.propose_admin(&new_admin); + rows.push(measure(&env, CONTRACT, "propose_admin")); + client.accept_admin(); + rows.push(measure(&env, CONTRACT, "accept_admin")); rows } diff --git a/tools/benchmark/src/benches/rwa_asset.rs b/tools/benchmark/src/benches/rwa_asset.rs index 084232c..93e6a57 100644 --- a/tools/benchmark/src/benches/rwa_asset.rs +++ b/tools/benchmark/src/benches/rwa_asset.rs @@ -108,8 +108,10 @@ pub fn run() -> Vec { client.set_compliance(&None, &0); let new_admin = Address::generate(&env); - client.transfer_admin(&new_admin); - rows.push(measure(&env, CONTRACT, "transfer_admin")); + client.propose_admin(&new_admin); + rows.push(measure(&env, CONTRACT, "propose_admin")); + client.accept_admin(); + rows.push(measure(&env, CONTRACT, "accept_admin")); rows }