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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,16 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Build
- name: Install wasm32v1-none target
run: rustup target add wasm32v1-none

- name: Build vc-vault WASM (required by vc-vault-factory tests)
run: cargo rustc -p vc-vault-contract --target wasm32v1-none --release -- --crate-type cdylib

- name: Build did-stellar-registry WASM
run: cargo build -p did-stellar-registry --target wasm32v1-none --release

- name: Build (native)
run: cargo build --verbose

- name: Run tests
Expand Down
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ resolver = "2"

members = [
"contracts/vc-vault",
"contracts/vc-vault-factory",
"contracts/did-stellar-registry",
]

Expand Down
18 changes: 18 additions & 0 deletions contracts/vc-vault-factory/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[package]
name = "vc-vault-factory-contract"
version = "0.1.0"
edition = { workspace = true }
license = { workspace = true }

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

[features]
testutils = ["soroban-sdk/testutils"]

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

[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }
81 changes: 81 additions & 0 deletions contracts/vc-vault-factory/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# vc-vault-factory

Soroban smart contract that deploys and tracks **single-tenant `vc-vault` instances** on Stellar. Each holder gets their own vault contract — one deployment per identity — rather than sharing a single multi-tenant contract. The factory derives deterministic vault addresses from `(owner, salt)` and maintains a registry used by vaults to validate cross-vault VC transfers.

---

## Public ABI

### Deployment

| Function | Auth | Description |
|---|---|---|
| `__constructor(vault_init_meta)` | *(deployer)* | Runs once at deploy. Stores the vault WASM hash and the contract admin that will be set on every deployed vault. |

`VaultInitMeta` fields:

| Field | Type | Description |
|---|---|---|
| `vault_hash` | `BytesN<32>` | WASM hash of the `vc-vault` contract to deploy |
| `contract_admin` | `Address` | Admin address passed to every new vault's constructor |

### Factory functions

| Function | Auth | Description |
|---|---|---|
| `deploy(owner, did_uri, salt)` | `owner` | Deploy a new `vc-vault` for `owner`. Returns the new vault address. |
| `deploy_sponsored(deployer, owner, did_uri, salt)` | `deployer` | Deploy a vault on behalf of `owner`. The deployer signs and pays; the vault belongs to `owner` from creation. Anyone can be a deployer — no whitelist. |
| `is_vault(vault_address)` | none | Return `true` if `vault_address` was deployed by this factory. Used by `receive_push` inside vaults to validate transfer sources. |

`salt` is a `BytesN<32>` chosen by the caller. Internally the factory derives the actual deploy salt as `keccak256(user_salt ‖ owner_bytes)`, which makes vault addresses deterministic per `(owner, salt)` pair and prevents frontrunning.

The vault constructor called by `deploy_v2` receives `(owner, contract_admin, did_uri, factory_address)`. The factory address is stored inside each vault so it can call `is_vault` during `receive_push`.

---

## Events

| Event | Fields | Emitted by |
|---|---|---|
| `VaultDeployed` | `owner`, `vault_address` | `deploy` |
| `SponsoredVaultDeployed` | `deployer`, `owner`, `vault_address` | `deploy_sponsored` |

---

## Storage layout

| Key | Type | TTL |
|---|---|---|
| `VaultMeta` (instance) | `VaultInitMeta` | threshold 30 days, bump 31 days |
| `Contracts(vault_address)` (persistent) | `bool` | threshold 100 days, bump 120 days |

Instance TTL is extended on every call to `deploy`, `deploy_sponsored`, and `is_vault`.
Persistent entries for deployed vault addresses are extended on `set_deployed` and on each `is_vault` lookup.

---

## Address derivation

Vault addresses are deterministic and unique per `(owner, salt)` pair:

```
deploy_salt = keccak256(user_salt || owner_address_bytes)
vault_address = hash(factory_address || deploy_salt)
```

Two different owners using the same user salt get different vault addresses. The same owner using different salts also gets different addresses. This means a vault address can be pre-computed client-side before submitting a transaction.

---

## Building

```sh
cargo build -p vc-vault-factory-contract --target wasm32v1-none --release
stellar contract build --optimize --manifest-path contracts/vc-vault-factory/Cargo.toml
```

## Testing

```sh
cargo test -p vc-vault-factory-contract
```
77 changes: 77 additions & 0 deletions contracts/vc-vault-factory/src/contract.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
use soroban_sdk::{
contract, contractclient, contractimpl, Address, Bytes, BytesN, Env, IntoVal, String,
};

use crate::{events, storage};
pub use crate::storage::VaultInitMeta;

#[contract]
pub struct VaultFactoryContract;

#[contractclient(name = "VaultFactoryClient")]
pub trait VaultFactory {
/// Deploy a new single-tenant vault for `owner` and register it in the factory.
///
/// `user_salt` is mixed with `owner` via keccak256 so each (owner, salt) pair
/// produces a unique deterministic address and frontrunning is prevented.
fn deploy(e: Env, owner: Address, did_uri: String, user_salt: BytesN<32>) -> Address;

/// Deploy a vault on behalf of `owner`. `deployer` signs and pays; the vault
/// belongs to `owner` from creation. Anyone can be a deployer — no whitelist.
fn deploy_sponsored(e: Env, deployer: Address, owner: Address, did_uri: String, user_salt: BytesN<32>) -> Address;

/// Return true if `vault_address` was deployed by this factory.
fn is_vault(e: Env, vault_address: Address) -> bool;
}

#[contractimpl]
impl VaultFactoryContract {
pub fn __constructor(e: Env, vault_init_meta: VaultInitMeta) {
storage::set_vault_init_meta(&e, &vault_init_meta);
}
}

fn derive_salt(e: &Env, user_salt: BytesN<32>, owner: &Address) -> BytesN<32> {
// Salt = keccak256(user_salt || owner_bytes) — prevents frontrunning.
let mut owner_bytes: [u8; 56] = [0; 56];
owner.to_string().copy_into_slice(&mut owner_bytes);
let mut salt_bytes: Bytes = user_salt.into_val(e);
salt_bytes.extend_from_array(&owner_bytes);
e.crypto().keccak256(&salt_bytes).into()
Comment on lines +34 to +40
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In the current Soroban SDK, what is the canonical byte/XDR serialization API for Addresswhen hashing deterministically, and isto_string() appropriate for stable on-chain address derivation or should raw serialized bytes be used instead?

💡 Result:

In the Soroban SDK, the canonical way to serialize an Address for deterministic hashing is by using its XDR representation, typically accessed via the to_xdr() method provided by the soroban_sdk::xdr::ToXdr trait [1]. Using to_string() is not recommended for on-chain address derivation or stable hashing [1]. The to_string() method produces a human-readable StrKey (e.g., starting with 'G' or 'C'), which is designed for display and off-chain interoperability rather than stable, canonical binary identification [1]. Relying on string representations introduces unnecessary complexity and potential inconsistencies if the encoding format were to change or if different string representations were used. For stable on-chain derivation and deterministic hashing, you should use the raw XDR bytes [1]. The Address type can be serialized to XDR bytes using address.to_xdr(&env), providing a stable, canonical binary format suitable for hashing and storage [1]. If you need to access the underlying components of an address (such as the 32-byte contract hash or Ed25519 public key), you can use the AddressPayload type, which can be extracted from an Address to obtain the raw binary identifier directly [2][3][4]. Top results: [2][3][1]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and print the relevant contract code region
if [ -f contracts/vc-vault-factory/src/contract.rs ]; then
  echo "== contract.rs derive_salt =="
  nl -ba contracts/vc-vault-factory/src/contract.rs | sed -n '1,120p' | sed -n '34,60p'
else
  echo "Missing contracts/vc-vault-factory/src/contract.rs"
fi

# Print README section that documents salt derivation (best-effort)
if [ -f contracts/vc-vault-factory/README.md ]; then
  echo "== README salt derivation section (best-effort) =="
  rg -n "salt|keccak|derive_salt|user_salt|owner_bytes|owner" contracts/vc-vault-factory/README.md | head -n 50
  # show around matches
  rg -n "salt|keccak|derive_salt|user_salt|owner_bytes|owner" contracts/vc-vault-factory/README.md | head -n 10 | cut -d: -f1 | sort -u | while read -r ln; do
    echo "-- context around line $ln --"
    nl -ba contracts/vc-vault-factory/README.md | sed -n "$((ln-5)),$((ln+15))p"
  done
else
  echo "Missing contracts/vc-vault-factory/README.md"
fi

Repository: ACTA-Team/contracts-acta

Length of output: 159


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="contracts/vc-vault-factory/src/contract.rs"
README="contracts/vc-vault-factory/README.md"

echo "== Checking file existence =="
echo "contract.rs: $(test -f "$FILE" && echo yes || echo no)"
echo "README.md:   $(test -f "$README" && echo yes || echo no)"

echo
echo "== contract.rs derive_salt (lines 25-60) =="
if [ -f "$FILE" ]; then
  awk 'NR>=25 && NR<=60 {printf "%d:%s\n", NR, $0}' "$FILE" | sed -n '1,80p'
else
  echo "Missing $FILE"
fi

echo
echo "== README salt-related documentation (best-effort) =="
if [ -f "$README" ]; then
  rg -n "derive_salt|keccak|salt|user_salt|owner_bytes|owner_bytes|owner" "$README" | head -n 50 || true
  # If there are matches, print a small window around the first few match lines
  matches=$(rg -n "derive_salt|keccak|salt|user_salt|owner_bytes|owner" "$README" | head -n 10 | cut -d: -f1 | tr '\n' ' ')
  if [ -n "${matches:-}" ]; then
    for ln in $matches; do
      echo "-- context around README line $ln --"
      awk -v start=$((ln-8)) -v end=$((ln+20)) 'NR>=start && NR<=end {printf "%d:%s\n", NR, $0}' "$README"
      echo
    done
  fi
else
  echo "Missing $README"
fi

Repository: ACTA-Team/contracts-acta

Length of output: 15423


Derive deploy salt from canonical Address bytes (XDR/payload), not owner.to_string()

contracts/vc-vault-factory/src/contract.rs builds owner_bytes by copying owner.to_string() (StrKey text) into a fixed [u8; 56] buffer and hashing it. This doesn’t match contracts/vc-vault-factory/README.md, which defines deploy_salt = keccak256(user_salt || owner_address_bytes) and implies raw/canonical address bytes. The current approach ties determinism to today’s display-string encoding/length and can cause client-side precomputed vault addresses to differ from on-chain results.

Use the canonical serialized address bytes for hashing (e.g., XDR via to_xdr(&env) / ToXdr, or raw bytes via AddressPayload), then hash those bytes with user_salt.

🧰 Tools
🪛 Clippy (1.96.0)

[warning] 34-34: function derive_salt is never used

(warning)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/vc-vault-factory/src/contract.rs` around lines 34 - 40, In
derive_salt, don't use owner.to_string() into a fixed 56-byte buffer; instead
serialize the Address to its canonical bytes and hash those with user_salt (so
the salt = keccak256(user_salt || canonical_address_bytes)). Locate derive_salt
and replace the owner.to_string() copy logic with the Address XDR/raw payload
serialization (e.g., call the Address XDR/ToXdr or extract an AddressPayload/raw
bytes), append those canonical bytes to the user_salt bytes, and then keccak256
that combined byte array so the computed salt matches the README/client-side
preimages.

}

fn deploy_vault(e: &Env, owner: &Address, did_uri: String, user_salt: BytesN<32>) -> Address {
let meta = storage::get_vault_init_meta(e);
let new_salt = derive_salt(e, user_salt, owner);
let factory_address = e.current_contract_address();
let vault_address = e
.deployer()
.with_current_contract(new_salt)
.deploy_v2(meta.vault_hash, (owner.clone(), meta.contract_admin, did_uri, factory_address));
storage::set_deployed(e, &vault_address);
vault_address
}

#[contractimpl]
impl VaultFactory for VaultFactoryContract {
fn deploy(e: Env, owner: Address, did_uri: String, user_salt: BytesN<32>) -> Address {
owner.require_auth();
storage::extend_instance(&e);
let vault_address = deploy_vault(&e, &owner, did_uri, user_salt);
events::vault_deployed(&e, &owner, &vault_address);
vault_address
}

fn deploy_sponsored(e: Env, deployer: Address, owner: Address, did_uri: String, user_salt: BytesN<32>) -> Address {
deployer.require_auth();
storage::extend_instance(&e);
let vault_address = deploy_vault(&e, &owner, did_uri, user_salt);
events::sponsored_vault_deployed(&e, &deployer, &owner, &vault_address);
vault_address
}

fn is_vault(e: Env, vault_address: Address) -> bool {
storage::extend_instance(&e);
storage::is_deployed(&e, &vault_address)
}
}
31 changes: 31 additions & 0 deletions contracts/vc-vault-factory/src/events.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
use soroban_sdk::{contractevent, Address, Env};

#[contractevent]
pub struct VaultDeployed {
pub owner: Address,
pub vault_address: Address,
}

#[contractevent]
pub struct SponsoredVaultDeployed {
pub deployer: Address,
pub owner: Address,
pub vault_address: Address,
}

pub fn vault_deployed(e: &Env, owner: &Address, vault_address: &Address) {
VaultDeployed {
owner: owner.clone(),
vault_address: vault_address.clone(),
}
.publish(e);
}

pub fn sponsored_vault_deployed(e: &Env, deployer: &Address, owner: &Address, vault_address: &Address) {
SponsoredVaultDeployed {
deployer: deployer.clone(),
owner: owner.clone(),
vault_address: vault_address.clone(),
}
.publish(e);
}
10 changes: 10 additions & 0 deletions contracts/vc-vault-factory/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#![no_std]

mod contract;
mod events;
mod storage;

#[cfg(test)]
mod test;

pub use contract::{VaultFactoryContract, VaultFactoryContractClient, VaultFactoryClient, VaultInitMeta};
67 changes: 67 additions & 0 deletions contracts/vc-vault-factory/src/storage.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
use soroban_sdk::{contracttype, Address, BytesN, Env, Symbol};

const ONE_DAY_LEDGERS: u32 = 17_280; // ~5s per ledger

const LEDGER_THRESHOLD_INSTANCE: u32 = ONE_DAY_LEDGERS * 30;
const LEDGER_BUMP_INSTANCE: u32 = LEDGER_THRESHOLD_INSTANCE + ONE_DAY_LEDGERS;

const LEDGER_THRESHOLD_CONTRACTS: u32 = ONE_DAY_LEDGERS * 100;
const LEDGER_BUMP_CONTRACTS: u32 = LEDGER_THRESHOLD_CONTRACTS + ONE_DAY_LEDGERS * 20;

#[derive(Clone)]
#[contracttype]
pub enum VaultFactoryDataKey {
Contracts(Address),
}

#[derive(Clone)]
#[contracttype]
pub struct VaultInitMeta {
pub vault_hash: BytesN<32>,
pub contract_admin: Address,
}

pub fn extend_instance(e: &Env) {
e.storage()
.instance()
.extend_ttl(LEDGER_THRESHOLD_INSTANCE, LEDGER_BUMP_INSTANCE);
}

pub fn set_vault_init_meta(e: &Env, meta: &VaultInitMeta) {
e.storage()
.instance()
.set::<Symbol, VaultInitMeta>(&Symbol::new(e, "VaultMeta"), meta);
}

pub fn get_vault_init_meta(e: &Env) -> VaultInitMeta {
e.storage()
.instance()
.get::<Symbol, VaultInitMeta>(&Symbol::new(e, "VaultMeta"))
.unwrap()
}

pub fn set_deployed(e: &Env, vault_address: &Address) {
let key = VaultFactoryDataKey::Contracts(vault_address.clone());
e.storage()
.persistent()
.set::<VaultFactoryDataKey, bool>(&key, &true);
e.storage()
.persistent()
.extend_ttl(&key, LEDGER_THRESHOLD_CONTRACTS, LEDGER_BUMP_CONTRACTS);
}

pub fn is_deployed(e: &Env, vault_address: &Address) -> bool {
let key = VaultFactoryDataKey::Contracts(vault_address.clone());
if let Some(result) = e
.storage()
.persistent()
.get::<VaultFactoryDataKey, bool>(&key)
{
e.storage()
.persistent()
.extend_ttl(&key, LEDGER_THRESHOLD_CONTRACTS, LEDGER_BUMP_CONTRACTS);
result
} else {
false
}
}
Loading
Loading