-
Notifications
You must be signed in to change notification settings - Fork 3
feat: vault factory POC — single-tenant vc-vault + vc-vault-factory #52
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
f05094a
refactor: flatten storage keys — drop owner from VcVaultDataKey
aguilar1x 219cf83
refactor: drop owner param from validator guards
aguilar1x 2e00c72
refactor: drop owner param from vault logic
aguilar1x ac5b465
refactor: single-tenant contract — new constructor, drop multi-tenant…
aguilar1x 7ff3d38
test: rewrite vc-vault test suite for single-tenant API
aguilar1x 748988b
chore: add vc-vault-factory to workspace members
aguilar1x 86df4a0
feat: add vc-vault-factory with deploy and deploy_sponsored
aguilar1x 6d02919
feat: add VaultFactory storage key and factory address read/write
aguilar1x 1ddfcce
feat: add push and receive_push for cross-vault VC transfer
aguilar1x 58ee974
test: add push and receive_push integration tests to factory suite
aguilar1x fe78dab
docs: add README for vc-vault-factory contract
aguilar1x fe3fa2b
Merge dev into feat/vault-factory-poc
aguilar1x 96e26ad
ci: build vault WASM before tests and use unoptimized artifact
aguilar1x 94d5068
fix(vc-vault): close revocation-bypass in receive_push
aguilar1x ac332b6
test(vc-vault-factory): fix misleading test name and comment
aguilar1x File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"] } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
| } | ||
|
|
||
| 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) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
In the current Soroban SDK, what is the canonical byte/XDR serialization API forAddresswhen 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 thesoroban_sdk::xdr::ToXdrtrait [1]. Usingto_string()is not recommended for on-chain address derivation or stable hashing [1]. Theto_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]. TheAddresstype can be serialized to XDR bytes usingaddress.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 theAddressPayloadtype, which can be extracted from anAddressto obtain the raw binary identifier directly [2][3][4]. Top results: [2][3][1]Citations:
🏁 Script executed:
Repository: ACTA-Team/contracts-acta
Length of output: 159
🏁 Script executed:
Repository: ACTA-Team/contracts-acta
Length of output: 15423
Derive deploy salt from canonical
Addressbytes (XDR/payload), notowner.to_string()contracts/vc-vault-factory/src/contract.rsbuildsowner_bytesby copyingowner.to_string()(StrKey text) into a fixed[u8; 56]buffer and hashing it. This doesn’t matchcontracts/vc-vault-factory/README.md, which definesdeploy_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 viaAddressPayload), then hash those bytes withuser_salt.🧰 Tools
🪛 Clippy (1.96.0)
[warning] 34-34: function
derive_saltis never used(warning)
🤖 Prompt for AI Agents