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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ deployments/*.json

# OS
.DS_Store

packages/video
44 changes: 34 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ Sender and receiver stay public and verifiable on-chain. Only the amount moves i

</div>

<div align="center">

<h2>Watch the launch demo</h2>

<a href="https://youtu.be/l7yIpmE0OqE">
<img src="https://img.youtube.com/vi/l7yIpmE0OqE/hqdefault.jpg" alt="Watch the Lucent launch demo on YouTube" />
</a>

<p><a href="https://youtu.be/l7yIpmE0OqE">Watch on YouTube</a> — a short animated walkthrough of shielding USDC, confidential transfers, payroll, escrow, and selective disclosure.</p>

</div>

Balances are Pedersen commitments on the Grumpkin curve. Every register,
withdraw, and transfer carries an UltraHonk zero-knowledge proof, generated
in the browser and verified natively on-chain by Soroban — no off-chain
Expand Down Expand Up @@ -140,13 +152,24 @@ salary stays decryptable to them and opaque to everyone else.
**PrivateEscrow** is custodial: each escrow deploys its own **instance
contract**, giving it its own isolated confidential account (a confidential
balance is keyed by contract address, so custody requires a dedicated address
per escrow). Funding an instance is two on-chain calls, not one —
`store_payout_proofs` stores the two pre-generated payout proofs
(instance→recipient, instance→depositor), then `fund` registers the instance
and transfers the amount in, refusing to run until the payout proofs are
already stored. They're split because all four proofs together (register +
transfer-in + both payout proofs, each a ~14KB UltraHonk proof) exceed what
fits in one Soroban transaction.
per escrow). Funding takes four on-chain calls:

1. `prepare_funding` stores the exact registration and transfer-in inputs.
2. `store_payout_proof(true, …)` verifies the recipient payout against the future funded commitment.
3. `store_payout_proof(false, …)` verifies the depositor refund against the same commitment.
4. `fund()` atomically registers the instance, transfers the funds in, and merges once.

Each payout call also supplies the remaining balance blinding factor. The token
checks that the remaining commitment opens to zero, so a partial-transfer proof
cannot settle an escrow. Amounts remain confidential.

The updated payout interface requires redeploying the token, escrow instance
WASM, and factory together before using the updated SDK/app. Existing deployed
instances retain their previous interface.

No funds move until both proofs pass. Re-preparing clears both validated proofs.
Separate calls keep the proof payloads within Soroban's transaction size limit.
Settlement spends the fixed commitment without merging unsolicited later credits.

At settlement, the instance's own state machine (created → funded →
completed → released / disputed / refunded / cancelled) submits exactly the
Expand Down Expand Up @@ -224,9 +247,10 @@ cargo test --manifest-path contracts/Cargo.toml

| Crate | Coverage | Tests |
|---|---|---|
| `contracts/token` | Escrow preflight PI parity, zero-balance opening, partial-payout rejection, malformed inputs and verifier rejection | 6 |
| `contracts/payroll` | State machine, employer auth, atomic batch payout, cancel paths | 10 |
| `contracts/escrow-instance` | Every state transition, release-window timing, arbiter vs. no-arbiter branches, auth, the two-step `store_payout_proofs` → `fund` guard | 18 |
| `contracts/escrow-factory` | Deploys a real instance and drives it through `store_payout_proofs` → `fund` → `mark_completed` → `release` | 1 |
| `contracts/escrow-instance` | Every state transition, release-window timing, arbiter vs. no-arbiter branches, auth, pre-funding proof validation and unsolicited-credit regressions | 24 |
| `contracts/escrow-factory` | Deploys a real instance and drives it through preparation → proof validation → funding → release | 1 |
| `contracts/policy` | Allowlist add/remove/is_authorized round-trip, admin auth rejection | 3 |

## Deploying
Expand Down Expand Up @@ -262,7 +286,7 @@ A full end-to-end walkthrough, real proofs on testnet:
2. **Send** — confidential transfer to a second registered account.
3. **Payroll** — create a template of employees, open + confirm a run, enter
salaries, execute; then **Auditor** decrypts every salary amount.
4. **Escrow** — deploy + fund an escrow (two wallet confirmations); walk it
4. **Escrow** — deploy + fund an escrow (four funding confirmations); walk it
through mark-completed → release (or dispute → resolve).
5. **Selective disclosure** — mint a request on the Verify screen, disclose
the matching transfer from Home's activity feed, verify the returned
Expand Down
1 change: 1 addition & 0 deletions contracts/Cargo.lock

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

6 changes: 4 additions & 2 deletions contracts/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ license = "MIT"
# The confidential token, access-control, and proc-macro crates are consumed as
# git dependencies from the `feat/confidential-verifier-ultrahonk` branch of
# OpenZeppelin/stellar-contracts (they are NOT published to crates.io). Cargo.lock
# pins the exact resolved commit; this branch was validated at rev 539968f. Run
# `cargo update -p stellar-tokens` to move to the branch tip.
# pins the exact resolved commit; this branch was validated at rev 539968f.
# Do not update independently of SDK/circuits/VKs: newer branch revisions change
# the proof protocol. See ../UPSTREAM_RECONCILIATION.md.
#
# Transitive deps (soroban-poseidon, stellar-contract-utils, stellar-governance,
# and the pinned `ultrahonk-soroban-verifier` git rev) are resolved through that
Expand All @@ -25,6 +26,7 @@ soroban-sdk = { version = "26.0.0" }
stellar-access = { git = "https://github.com/OpenZeppelin/stellar-contracts", branch = "feat/confidential-verifier-ultrahonk" }
stellar-macros = { git = "https://github.com/OpenZeppelin/stellar-contracts", branch = "feat/confidential-verifier-ultrahonk" }
stellar-tokens = { git = "https://github.com/OpenZeppelin/stellar-contracts", branch = "feat/confidential-verifier-ultrahonk" }
stellar-contract-utils = { git = "https://github.com/OpenZeppelin/stellar-contracts", branch = "feat/confidential-verifier-ultrahonk" }

# Soroban release profile: optimize for wasm code size and determinism.
[profile.release]
Expand Down
31 changes: 26 additions & 5 deletions contracts/escrow-factory/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,25 @@ impl MockToken {
}
pub fn confidential_transfer(e: &Env, from: Address, to: Address, _data: Bytes) {
from.require_auth();
let mut log: Vec<(Address, Address)> =
e.storage().instance().get(&symbol_short!("xfers")).unwrap_or(Vec::new(e));
let mut log: Vec<(Address, Address)> = e
.storage()
.instance()
.get(&symbol_short!("xfers"))
.unwrap_or(Vec::new(e));
log.push_back((from, to));
e.storage().instance().set(&symbol_short!("xfers"), &log);
}
pub fn verify_escrow_payout(
_e: &Env,
_register_data: Bytes,
_auditor_id: u32,
transfer_in: Bytes,
_to: Address,
data: Bytes,
_remaining_blinding: soroban_sdk::BytesN<32>,
) -> bool {
!data.is_empty() && data == transfer_in
}
pub fn merge(_e: &Env, account: Address) {
account.require_auth();
}
Expand Down Expand Up @@ -59,14 +73,21 @@ fn create_escrow_deploys_a_working_instance() {
// through fund -> mark_completed -> release.
let escrow = instance::Client::new(&e, &addr);
let blob = Bytes::from_array(&e, &[7u8; 8]);
escrow.store_payout_proofs(&blob, &blob);
escrow.fund(&blob, &0u32, &blob);
escrow.prepare_funding(&blob, &0u32, &blob);
escrow.store_payout_proof(&true, &blob, &soroban_sdk::BytesN::from_array(&e, &[0; 32]));
escrow.store_payout_proof(
&false,
&blob,
&soroban_sdk::BytesN::from_array(&e, &[0; 32]),
);
escrow.fund();
escrow.mark_completed(&String::from_str(&e, "ipfs://x"));
escrow.release();
assert_eq!(escrow.get_escrow().state, instance::EscrowState::Released);

// Second escrow gets a distinct address.
let (id2, addr2) = factory.create_escrow(&depositor, &recipient, &Some(recipient.clone()), &10u64);
let (id2, addr2) =
factory.create_escrow(&depositor, &recipient, &Some(recipient.clone()), &10u64);
assert_eq!(id2, 2);
assert_ne!(addr, addr2);
}

Large diffs are not rendered by default.

Binary file modified contracts/escrow-factory/testdata/private_escrow_instance.wasm
Binary file not shown.
119 changes: 89 additions & 30 deletions contracts/escrow-instance/src/instance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use soroban_sdk::{
contract, contractevent, contractimpl, contracttype, Address, Bytes, Env, String,
};

use crate::{Escrow, EscrowState, Error, TokenClient, RELEASE_WINDOW};
use crate::{Error, Escrow, EscrowState, TokenClient, RELEASE_WINDOW};

#[contracttype]
enum DataKey {
Expand All @@ -16,6 +16,9 @@ enum DataKey {
ReleaseProof,
/// Pre-generated payout proof: instance -> depositor.
RefundProof,
RegisterData,
AuditorId,
TransferIn,
}

#[contractevent]
Expand Down Expand Up @@ -79,37 +82,81 @@ impl PrivateEscrowInstance {
);
}

/// Store the two pre-generated payout proofs (instance → recipient,
/// instance → depositor) the state machine will later select between.
/// Split out from `fund` because bundling both proofs alongside the
/// register and transfer-in proofs in one transaction exceeds Soroban's
/// per-transaction size ceiling — four ~14KB UltraHonk proofs is too much
/// for a single call. Must be called before `fund`; harmless to call again
/// while still `Created` (last write wins).
pub fn store_payout_proofs(e: &Env, release_proof: Bytes, refund_proof: Bytes) -> Result<(), Error> {
/// Prepare immutable inputs for payout validation. Re-preparing invalidates
/// both previously checked proofs. No token registration or funds move yet.
pub fn prepare_funding(
e: &Env,
register_data: Bytes,
auditor_id: u32,
transfer_in: Bytes,
) -> Result<(), Error> {
let esc = get(e);
esc.depositor.require_auth();
if esc.state != EscrowState::Created {
return Err(Error::BadState);
}
e.storage().instance().set(&DataKey::ReleaseProof, &release_proof);
e.storage().instance().set(&DataKey::RefundProof, &refund_proof);
e.storage()
.instance()
.set(&DataKey::RegisterData, &register_data);
e.storage().instance().set(&DataKey::AuditorId, &auditor_id);
e.storage()
.instance()
.set(&DataKey::TransferIn, &transfer_in);
e.storage().instance().remove(&DataKey::ReleaseProof);
e.storage().instance().remove(&DataKey::RefundProof);
Ok(())
}

/// Lock funds into this escrow. The depositor:
/// * registers this instance as a confidential account (`register_data`
/// is the register proof for the instance's Grumpkin identity), and
/// * `confidential_transfer`s the amount in (`transfer_in`).
/// Requires `store_payout_proofs` to have already been called — `Funded` is
/// only ever reached with a complete, working payout-proof set already in
/// place, so there is no reachable half-funded state. State: Created -> Funded.
pub fn fund(
/// Verify one settlement branch against the EXACT prepared funding inputs
/// before storing it. One proof per call keeps transaction size and proving
/// verification cost bounded. A rejected proof never enables funding.
pub fn store_payout_proof(
e: &Env,
register_data: Bytes,
auditor_id: u32,
transfer_in: Bytes,
release: bool,
proof: Bytes,
remaining_blinding: soroban_sdk::BytesN<32>,
) -> Result<(), Error> {
let esc = get(e);
esc.depositor.require_auth();
if esc.state != EscrowState::Created {
return Err(Error::BadState);
}
let register_data: Bytes = e
.storage()
.instance()
.get(&DataKey::RegisterData)
.ok_or(Error::FundingNotPrepared)?;
let auditor_id: u32 = e.storage().instance().get(&DataKey::AuditorId).unwrap();
let transfer_in: Bytes = e.storage().instance().get(&DataKey::TransferIn).unwrap();
let to = if release {
&esc.recipient
} else {
&esc.depositor
};
if !token(e).verify_escrow_payout(
&register_data,
&auditor_id,
&transfer_in,
to,
&proof,
&remaining_blinding,
) {
return Err(Error::InvalidPayoutProof);
}
let key = if release {
DataKey::ReleaseProof
} else {
DataKey::RefundProof
};
e.storage().instance().set(&key, &proof);
Ok(())
}

/// Execute the prepared funding only after BOTH payout proofs passed.
/// Registration, transfer and merge are atomic: nobody can inject a credit
/// between registration and this one merge. Later credits stay in receiving
/// and cannot invalidate the frozen spendable commitment used at settlement.
pub fn fund(e: &Env) -> Result<(), Error> {
let mut esc = get(e);
esc.depositor.require_auth();
if esc.state != EscrowState::Created {
Expand All @@ -120,14 +167,18 @@ impl PrivateEscrowInstance {
{
return Err(Error::PayoutProofsMissing);
}

let register_data: Bytes = e.storage().instance().get(&DataKey::RegisterData).unwrap();
let auditor_id: u32 = e.storage().instance().get(&DataKey::AuditorId).unwrap();
let transfer_in: Bytes = e.storage().instance().get(&DataKey::TransferIn).unwrap();
let token = token(e);
let this = e.current_contract_address();
// The instance authorizes its own registration by invocation.
token.register(&this, &auditor_id, &register_data);
// from = depositor (the transaction source authorizes this).
token.confidential_transfer(&esc.depositor, &this, &transfer_in);
token.merge(&this);

e.storage().instance().remove(&DataKey::RegisterData);
e.storage().instance().remove(&DataKey::TransferIn);
e.storage().instance().remove(&DataKey::AuditorId);
esc.state = EscrowState::Funded;
put(e, &esc);
Funded {}.publish(e);
Expand All @@ -145,7 +196,11 @@ impl PrivateEscrowInstance {
esc.state = EscrowState::Completed;
esc.completed_at = e.ledger().timestamp();
put(e, &esc);
Completed { by: esc.recipient, proof_uri }.publish(e);
Completed {
by: esc.recipient,
proof_uri,
}
.publish(e);
Ok(())
}

Expand Down Expand Up @@ -180,7 +235,11 @@ impl PrivateEscrowInstance {
}
esc.state = EscrowState::Disputed;
put(e, &esc);
Disputed { by: esc.recipient, proof_uri }.publish(e);
Disputed {
by: esc.recipient,
proof_uri,
}
.publish(e);
Ok(())
}

Expand Down Expand Up @@ -299,11 +358,11 @@ fn depositor_proof(e: &Env) -> Bytes {
e.storage().instance().get(&DataKey::RefundProof).unwrap()
}

/// Fold received funds into spendable, then send them out with the selected
/// pre-generated proof. `from` is this contract, which authorizes by invocation.
/// Spend only the commitment fixed during funding, using the selected
/// pre-verified proof. Unsolicited credits remain isolated in receiving.
/// `from` is this contract, which authorizes by invocation.
fn payout(e: &Env, proof: &Bytes, to: &Address) {
let token = token(e);
let this = e.current_contract_address();
token.merge(&this);
token.confidential_transfer(&this, to, proof);
}
27 changes: 17 additions & 10 deletions contracts/escrow-instance/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,11 @@
//! and pays out with a `confidential_transfer` where `from` is the instance
//! itself — a contract authorizes its own outgoing transfer by invocation, so
//! the recipient or arbiter can trigger a payout without holding any key.
//! * Because a contract cannot prove on-chain, the depositor pre-generates the
//! two possible payout proofs (instance → recipient, instance → depositor)
//! and stores them via `store_payout_proofs` *before* calling `fund` — the
//! instance submits exactly the one the state machine selects, later. This
//! is two transactions, not one: four ~14KB UltraHonk proofs bundled into a
//! single `fund` call (register + transfer-in + both payout proofs) exceeds
//! Soroban's per-transaction size ceiling. `fund` refuses to run until the
//! payout proofs are already in place, so `Funded` is only ever reached with
//! a complete, working proof set — there's no reachable half-funded state.
//! * The depositor prepares registration + funding inputs, then submits each
//! payout proof for on-chain verification against that future commitment.
//! Only after both branches validate can `fund` atomically register, receive
//! and merge the exact prepared transfer. Settlement never merges later credits.
//! Four transactions keep proof payloads within the transaction size limit.
//!
//! # Trust caveat
//!
Expand All @@ -45,6 +41,15 @@ pub trait ConfidentialTokenInterface {
fn register(env: Env, account: Address, auditor_id: u32, data: Bytes);
fn confidential_transfer(env: Env, from: Address, to: Address, data: Bytes);
fn merge(env: Env, account: Address);
fn verify_escrow_payout(
env: Env,
register_data: Bytes,
auditor_id: u32,
transfer_in: Bytes,
to: Address,
data: Bytes,
remaining_blinding: soroban_sdk::BytesN<32>,
) -> bool;
}

#[contracterror]
Expand All @@ -60,8 +65,10 @@ pub enum Error {
ArbiterSet = 7,
TooEarly = 8,
NotFound = 9,
/// `fund` was called before `store_payout_proofs` — see instance.rs.
/// Both payout branches must be verified before `fund`.
PayoutProofsMissing = 10,
FundingNotPrepared = 11,
InvalidPayoutProof = 12,
}

/// Lifecycle of an escrow, mirroring the Solidity reference exactly.
Expand Down
Loading
Loading