Skip to content
Closed
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
59 changes: 33 additions & 26 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,14 @@ jobs:
~/.cargo/git
target
key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.lock') }}
- name: Build receipt-shard wasm
# receipt-anchor's test/clippy target imports this wasm via
# `contractimport!` to test the shard factory end-to-end.
run: cargo build -p receipt-shard --target wasm32v1-none --release
- name: Build wasm for cross-contract tests
# Cross-contract tests import these wasms via `contractimport!`:
# receipt-anchor imports receipt_shard; refund-vault tests import
# refund_vault; refund-vault-factory tests import refund_vault. The
# refund-window-policy wasm is deployed in-process for policy tests.
run: |
cargo build -p receipt-shard --target wasm32v1-none --release
cargo build -p refund-vault -p refund-vault-factory -p refund-window-policy --target wasm32v1-none --release
- name: Run clippy
run: cargo clippy --all-targets -- -D warnings

Expand All @@ -114,10 +118,13 @@ jobs:
~/.cargo/git
target
key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }}
- name: Build receipt-shard wasm
# receipt-anchor's tests import this wasm via `contractimport!` to
# test the shard factory end-to-end.
run: cargo build -p receipt-shard --target wasm32v1-none --release
- name: Build wasm for cross-contract tests
# Cross-contract tests import these wasms via `contractimport!`:
# receipt-anchor imports receipt_shard; refund-vault tests import
# refund_vault; refund-vault-factory tests import refund_vault.
run: |
cargo build -p receipt-shard --target wasm32v1-none --release
cargo build -p refund-vault -p refund-vault-factory -p refund-window-policy --target wasm32v1-none --release
- name: Run Tests
run: cargo test --workspace

Expand Down Expand Up @@ -165,30 +172,28 @@ jobs:

- name: Report WASM sizes & enforce size budget
run: |
RECEIPT_SIZE=$(stat -c%s target/wasm32v1-none/release/receipt_anchor.wasm 2>/dev/null || stat -f%z target/wasm32v1-none/release/receipt_anchor.wasm)
REFUND_SIZE=$(stat -c%s target/wasm32v1-none/release/refund_vault.wasm 2>/dev/null || stat -f%z target/wasm32v1-none/release/refund_vault.wasm)

RECEIPT_BUDGET=$(jq -r '.receipt_anchor' .wasm-budget.json)
REFUND_BUDGET=$(jq -r '.refund_vault' .wasm-budget.json)
for C in receipt_anchor refund_vault refund_vault_factory refund_window_policy; do
SIZE=$(stat -c%s target/wasm32v1-none/release/${C}.wasm 2>/dev/null || stat -f%z target/wasm32v1-none/release/${C}.wasm)
BUDGET=$(jq -r ".${C}" .wasm-budget.json)
declare "$C"_SIZE=$SIZE
declare "$C"_BUDGET=$BUDGET
done

echo "### WASM Build Sizes" >> $GITHUB_STEP_SUMMARY
echo "| Contract | Size (bytes) | Budget (bytes) | Status |" >> $GITHUB_STEP_SUMMARY
echo "|---|---|---|---|" >> $GITHUB_STEP_SUMMARY

FAILED=0
if [ "$RECEIPT_SIZE" -gt "$RECEIPT_BUDGET" ]; then
echo "| \`receipt_anchor.wasm\` | $RECEIPT_SIZE | $RECEIPT_BUDGET | ❌ Exceeded |" >> $GITHUB_STEP_SUMMARY
FAILED=1
else
echo "| \`receipt_anchor.wasm\` | $RECEIPT_SIZE | $RECEIPT_BUDGET | ✅ Within Budget |" >> $GITHUB_STEP_SUMMARY
fi

if [ "$REFUND_SIZE" -gt "$REFUND_BUDGET" ]; then
echo "| \`refund_vault.wasm\` | $REFUND_SIZE | $REFUND_BUDGET | ❌ Exceeded |" >> $GITHUB_STEP_SUMMARY
FAILED=1
else
echo "| \`refund_vault.wasm\` | $REFUND_SIZE | $REFUND_BUDGET | ✅ Within Budget |" >> $GITHUB_STEP_SUMMARY
fi
for C in receipt_anchor refund_vault refund_vault_factory refund_window_policy; do
eval "SIZE=\${${C}_SIZE}"
eval "BUDGET=\${${C}_BUDGET}"
if [ "$SIZE" -gt "$BUDGET" ]; then
echo "| \`${C}.wasm\` | $SIZE | $BUDGET | ❌ Exceeded |" >> $GITHUB_STEP_SUMMARY
FAILED=1
else
echo "| \`${C}.wasm\` | $SIZE | $BUDGET | ✅ Within Budget |" >> $GITHUB_STEP_SUMMARY
fi
done

if [ "$FAILED" -ne 0 ]; then
echo "Error: WASM size budget exceeded."
Expand All @@ -202,3 +207,5 @@ jobs:
path: |
target/wasm32v1-none/release/receipt_anchor.wasm
target/wasm32v1-none/release/refund_vault.wasm
target/wasm32v1-none/release/refund_vault_factory.wasm
target/wasm32v1-none/release/refund_window_policy.wasm
4 changes: 3 additions & 1 deletion .wasm-budget.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
{
"receipt_anchor": 24576,
"refund_vault": 37376
"refund_vault": 45056,
"refund_vault_factory": 16384,
"refund_window_policy": 8192
}
28 changes: 23 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,32 @@ breaking changes bump the **minor** version, and they are called out as such.
`.git/HEAD`, the resolved branch ref, the index and `src/` so a cached build
cannot report a stale hash. A `test_commit_meta_is_well_formed` test in both
crates pins the embedded commit to 40 hex characters.
- **`RefundVaultFactory`** (issue #129): new `refund-vault-factory` contract
deploys individual `RefundVault` instances via `deploy_v2` + `__constructor`,
storing the vault wasm hash on-chain, keeping a registry of deployed vaults
(`get_vault`, `get_vault_count`), and supporting both `deploy` (default
policy) and `deploy_with_policy` (explicit policy bind). Emits a
`VaultCreatedEvent` per deployment.
- **`RefundWindowPolicy`** (issue #129): new `refund-window-policy` contract is
a stateless policy implementing `check_refund` (the refund-window rule,
window `0` meaning unbounded). It becomes the default policy deployed and
bound by the factory, so changing a policy rule is a new contract rather than
a vault redeploy.

### Changed

- **Advanced WASM Memory Management for Merkle Proofs** (issue #139):
Refactored `ReceiptShard::verify_receipt` to copy host vector inputs into a stack-allocated
static buffer (`proof_buffer: [[u8; 32]; 128]`) and perform intermediate hashing using the pure Wasm
`sha2` crate. This eliminates all guest heap allocations and host roundtrips for intermediate hashes,
ensuring a flat guest memory footprint across all Merkle tree depths.
- **⚠️ `RefundVault` is constructor-only and defers window checks to a policy
contract** (issue #129): `initialize` was removed in favour of
`__constructor(merchant, token, refund_window_ledgers, refund_policy)`,
mirroring `ReceiptShard` — a deployed vault is always initialised (the
"uninitialized" unit tests were removed; the factory rejects `deploy` before
`initialize` instead). `refund()` no longer evaluates the refund window
inline: it calls the bound policy contract via the `RefundPolicy` trait
(same pattern as `ReceiptAnchor`'s `ShardInterface`), mapping deliberate
policy rejections (`WindowExpired`) through and surfacing host-level call
failures as the new `accensa_common::Error::PolicyCallFailed` (302). Adds
`get_refund_policy()` and admin-gated `set_refund_policy()`.

- **`RefundVault` token generality is documented and pinned** (issue #166): the
vault treats all amounts as raw integer units in the token's smallest unit and
performs no decimal arithmetic, so any SEP-41 precision behaves identically.
Expand Down
18 changes: 18 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ members = [
"contracts/receipt-anchor",
"contracts/receipt-shard",
"contracts/refund-vault",
"contracts/refund-vault-factory",
"contracts/refund-window-policy",
"contracts/testutils",
]
resolver = "2"
Expand Down
5 changes: 5 additions & 0 deletions contracts/common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,9 @@ pub enum Error {
NoPendingPolicy = 300,
/// The timelock period has not yet elapsed.
TimelockNotExpired = 301,
/// A refund-policy contract call returned a value the vault could not
/// decode, or failed at the host level (e.g. the registered policy
/// address is not a contract). Distinct from the policy's own deliberate
/// errors (`WindowExpired`, ...), which propagate unchanged.
PolicyCallFailed = 302,
}

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions contracts/refund-vault-factory/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[package]
name = "refund-vault-factory"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Factory that deploys RefundVault instances bound to refund-policy contracts"

[lints]
workspace = true


[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
accensa-common = { workspace = true }
soroban-sdk = { workspace = true }

[dev-dependencies]
refund-window-policy = { path = "../refund-window-policy", features = ["testutils"] }
soroban-sdk = { workspace = true, features = ["testutils"] }
77 changes: 77 additions & 0 deletions contracts/refund-vault-factory/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
use std::process::Command;

fn main() {
let git_dir = Command::new("git")
.args(["rev-parse", "--absolute-git-dir"])
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|output| String::from_utf8(output.stdout).ok())
.map(|dir| dir.trim().to_string());

// Keep the embedded hash honest: re-run this script when the checked-out
// commit, the branch ref it points at, or the index changes, so a cached
// build can never report a stale commit or a clean tree that is actually
// dirty.
if let Some(git_dir) = &git_dir {
println!("cargo:rerun-if-changed={git_dir}/HEAD");
if let Some(ref_path) = git_ref_path(git_dir) {
println!("cargo:rerun-if-changed={ref_path}");
}
println!("cargo:rerun-if-changed={git_dir}/index");
}
println!("cargo:rerun-if-changed=src");

match git_sha() {
Some(sha) => {
println!("cargo:rustc-env=GIT_SHA={sha}");
println!("cargo:rustc-env=GIT_DIRTY={}", git_dirty());
}
None => {
// No git access: a source tarball, a Docker layer without .git, or
// git missing entirely. Keep tarball builds working, but make the
// degraded provenance loud instead of silent.
println!(
"cargo:warning=git rev-parse HEAD failed — contractmeta commit will be 'unknown' (build provenance degraded)"
);
println!("cargo:rustc-env=GIT_SHA=unknown");
println!("cargo:rustc-env=GIT_DIRTY=unknown");
}
}
}

fn git_ref_path(git_dir: &str) -> Option<String> {
let head = std::fs::read_to_string(format!("{git_dir}/HEAD")).ok()?;
head.strip_prefix("ref: ")
.map(|reference| format!("{git_dir}/{}", reference.trim()))
}

fn git_sha() -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let sha = String::from_utf8(output.stdout).ok()?;
let sha = sha.trim();
if sha.is_empty() {
None
} else {
Some(sha.to_string())
}
}

fn git_dirty() -> &'static str {
let dirty = Command::new("git")
.args(["status", "--porcelain"])
.output()
.map(|output| output.status.success() && !output.stdout.is_empty())
.unwrap_or(false);
if dirty {
"1"
} else {
"0"
}
}
Loading
Loading