Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
15 changes: 15 additions & 0 deletions .env.sepolia
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Shared
NODE_ENV=development

# Sepolia RPC (Infura/Alchemy/Blast/etc.)
STARKNET_RPC_URL=https://starknet-sepolia.infura.io/v3/REPLACE_WITH_KEY
STARKNET_NETWORK=sepolia

# UA² contracts (attach-only; populate before running e2e)
UA2_CLASS_HASH=
UA2_IMPLEMENTATION_ADDR=
UA2_PROXY_ADDR=

# Demo app
NEXT_PUBLIC_NETWORK=sepolia
NEXT_PUBLIC_UA2_PROXY_ADDR=
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ dist/
coverage/
.env
.env.*
!.env.sepolia
!.env.example
!.env.sepolia.example
!packages/contracts/scripts/.env.sepolia.example
Expand Down
60 changes: 53 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,18 +60,60 @@ scarb build
> [mise](https://mise.jdx.dev/). If `scarb` is not on your `PATH`, install the pinned version with
> `mise install scarb@2.12.0` (or ensure `/root/.asdf/shims` is exported when using `asdf`).

### 3. Deploy to Sepolia
### 3. Declare & deploy with `sncast`

Mirror the same workflow locally and on Sepolia so copy/pasting always works. Replace the
placeholders (`<...>`) before running.

```bash
# still inside packages/contracts
export STARKNET_RPC_URL=<YOUR_SEPOLIA_RPC>
export UA2_OWNER_PUBKEY=<OWNER_PUBKEY_FELT>
./scripts/deploy_ua2.sh

# Devnet example (see docs/runbook-sepolia.md for full flow)
RPC=http://127.0.0.1:5050
NAME=devnet

sncast account create --name "$NAME" --url "$RPC"
sncast account deploy --name "$NAME" --url "$RPC"

sncast --account "$NAME" \
declare \
--contract-name UA2Account \
--url "$RPC" \
--max-fee 9638049920000000000

UA2_CLASS_HASH=0xCLASS_HASH_FROM_OUTPUT

OWNER_PUBKEY=0xYOUR_OWNER_FELT
sncast --account "$NAME" \
deploy \
--class-hash "$UA2_CLASS_HASH" \
--constructor-calldata "$OWNER_PUBKEY" \
--url "$RPC" \
--max-fee 9638049920000000000

UA2_PROXY_ADDR=0xDEPLOYED_ADDRESS

sncast --account "$NAME" \
call \
--contract-address "$UA2_PROXY_ADDR" \
--function get_owner \
--url "$RPC"

# Sepolia mirrors the same steps; just switch RPC/NAME and fund the account with STRK (FRI)
RPC=https://starknet-sepolia.infura.io/v3/<YOUR_KEY>
NAME=sepolia
```

The helper script declares the class if needed and writes `UA2_CLASS_HASH`, `UA2_IMPLEMENTATION_ADDR`,
and `UA2_PROXY_ADDR` to `packages/contracts/.ua2-sepolia-addresses.json`. Copy the relevant values into
your `.env.sepolia` (created from `.env.sepolia.example`) and set `NEXT_PUBLIC_UA2_PROXY_ADDR` for the demo app.
If `sncast` reports "fee too low", rerun the declare/deploy with the suggested higher
`--max-fee` (fees are denominated in **FRI (STRK)**). Copy the resulting class hash,
implementation hash, and proxy address into `.env` / `.env.sepolia` so the SDK and demo app
point at the correct contracts. `./scripts/deploy_ua2.sh` is still available when you want an
automated run.

> [!NOTE]
> On devnet, mint FRI to the printed account address via `devnet_mint`. On Sepolia,
> top up the account with STRK/ETH from your faucet or bridge of choice before
> deploying.

### 4. Run demo app

Expand Down Expand Up @@ -114,6 +156,10 @@ For full walkthrough: [`docs/runbook-sepolia.md`](./docs/runbook-sepolia.md)
```bash
npm run e2e:devnet
```

> [!TIP]
> Use the devnet + `sncast` recipe in [`docs/runbook-sepolia.md`](./docs/runbook-sepolia.md) to
> create/fund the named account and deploy the UA² class before running the suite.
* **E2E on Sepolia:**

```bash
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
- `recovery_active: bool`
- `recovery_proposed_owner: felt252`
- `recovery_eta: u64`
- `recovery_confirms: LegacyMap<ContractAddress, bool>` + `recovery_confirm_count: u32`
- `recovery_confirm_count: u32`
- `recovery_proposal_id: u64`
- `recovery_guardian_last_confirm: LegacyMap<ContractAddress, u64>`
- `session: Map<session_key_hash, SessionPolicy>`
Expand Down
24 changes: 20 additions & 4 deletions docs/interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
```rust
struct SessionPolicy {
is_active: bool,
expires_at: u64,
valid_after: u64,
valid_until: u64,
max_calls: u32,
calls_used: u32,
max_value_per_call: Uint256,
Expand All @@ -35,11 +36,15 @@ struct SessionPolicy {

The selector and target allowlists are not embedded in the struct. They are stored in dedicated `LegacyMap` slots keyed by `(key_hash, target)` and `(key_hash, selector)` respectively, and are populated by calling `add_session_with_allowlists` alongside the base policy.

> **Notes:**
> * Supplying empty target and selector lists is permitted, but such a session cannot execute any calls (all lookups fall back to `false`).
> * To keep storage writes + gas predictable in v0, prefer allowlists with ≲32 entries per list.

---

## Events

* `SessionAdded(key_hash: felt252, expires_at: u64, max_calls: u32)`
* `SessionAdded(key_hash: felt252, valid_after: u64, valid_until: u64, max_calls: u32)`
* `SessionRevoked(key_hash: felt252)`
* `SessionUsed(key_hash: felt252, used: u32)`
* `SessionNonceAdvanced(key_hash: felt252, new_nonce: u128)`
Expand All @@ -61,22 +66,33 @@ The contract reverts with the following identifiers:

* `ERR_SESSION_EXPIRED`
* `ERR_SESSION_INACTIVE`
* `ERR_SESSION_STALE`
* `ERR_SESSION_NOT_READY`
* `ERR_SESSION_TARGETS_LEN`
* `ERR_SESSION_SELECTORS_LEN`
* `ERR_POLICY_CALLCAP`
* `ERR_POLICY_SELECTOR_DENIED`
* `ERR_POLICY_TARGET_DENIED`
* `ERR_VALUE_LIMIT_EXCEEDED`
* `ERR_POLICY_SELECTOR_DENIED`
* `ERR_POLICY_CALLCOUNT_MISMATCH`
* `ERR_VALUE_LIMIT_EXCEEDED`
* `ERR_BAD_SESSION_NONCE`
* `ERR_SESSION_SIG_INVALID`
* `ERR_BAD_VALID_WINDOW`
* `ERR_BAD_MAX_CALLS`
* `ERR_SIGNATURE_MISSING`
* `ERR_OWNER_SIG_INVALID`
* `ERR_GUARDIAN_SIG_INVALID`
* `ERR_GUARDIAN_EXISTS`
* `ERR_NOT_GUARDIAN`
* `ERR_GUARDIAN_CALL_DENIED`
* `ERR_BAD_THRESHOLD`
* `ERR_RECOVERY_IN_PROGRESS`
* `ERR_NO_RECOVERY`
* `ERR_RECOVERY_MISMATCH`
* `ERR_ALREADY_CONFIRMED`
* `ERR_BEFORE_ETA`
* `ERR_NOT_ENOUGH_CONFIRMS`
* `ERR_NOT_OWNER`
* `ERR_ZERO_OWNER`
* `ERR_SAME_OWNER`

21 changes: 11 additions & 10 deletions docs/rfc-ua2-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ This RFC proposes a neutral, modular SDK that standardizes these primitives with
* `recovery_active: bool`
* `recovery_proposed_owner: felt252`
* `recovery_eta: u64`
* `recovery_confirms: LegacyMap<ContractAddress,bool>` + `recovery_confirm_count: u32`
* `recovery_confirm_count: u32`
* `recovery_proposal_id: u64`
* `recovery_guardian_last_confirm: LegacyMap<ContractAddress, u64>`
* `session: Map<session_key_hash, SessionPolicy>`
Expand All @@ -145,30 +145,31 @@ OZ Account is the canonical base for `__validate__`/`__execute__` pattern and mu
```
struct SessionPolicy {
is_active: bool,
expires_at: u64, // block timestamp
valid_after: u64, // block timestamp
valid_until: u64, // block timestamp
max_calls: u32,
calls_used: u32,
max_value_per_call: Uint256, // wei-like units for token/native
max_value_per_call: Uint256, // wei-like units for ERC-20 transfers (native send unsupported in v0)
}
```

Selector and target allowlists are stored separately under `sessionTargetAllow(session_key_hash, ContractAddress)` and `sessionSelectorAllow(session_key_hash, felt252)` legacy maps. The owner typically calls `add_session_with_allowlists` to write the base policy and seed those maps in a single transaction.
Selector and target allowlists are stored separately under `sessionTargetAllow(session_key_hash, ContractAddress)` and `sessionSelectorAllow(session_key_hash, felt252)` legacy maps. The owner typically calls `add_session_with_allowlists` to write the base policy and seed those maps in a single transaction. Empty allowlists are technically valid but render the session unusable, and we recommend keeping each list ≤32 entries in v0 to avoid excessive storage writes.

**Validation path:**

* If signature is by `owner_pubkey`: standard path.
* Else if signature verifies to a registered **session key**:

* Check `is_active`, `now <= expires_at`, and `calls_used + tx_call_count <= max_calls`.
* Check `is_active`, `now >= valid_after`, `now <= valid_until`, and `calls_used + tx_call_count <= max_calls`.
* Require allowlist booleans for `(key_hash, target)` and `(key_hash, selector)` to be `true`.
* Enforce ERC-20 transfer amounts ≤ `max_value_per_call`.
* Enforce ERC-20 `transfer` / `transferFrom` amounts ≤ `max_value_per_call` (native `call.value` transfers are out-of-scope for v0).
* Require session nonce match, then verify the ECDSA signature over the poseidon-hashed call set.
* Call `apply_session_usage` to bump counters/nonce and emit `SessionUsed` + `SessionNonceAdvanced`.

**Events:**

```
event SessionAdded(key_hash: felt252, expires_at: u64, max_calls: u32);
event SessionAdded(key_hash: felt252, valid_after: u64, valid_until: u64, max_calls: u32);
event SessionRevoked(key_hash: felt252);
event SessionUsed(key_hash: felt252, used: u32);
event SessionNonceAdvanced(key_hash: felt252, new_nonce: u128);
Expand Down Expand Up @@ -252,7 +253,7 @@ await ua.sessions.revoke(sess.id);
## 10. Security Considerations

* **Domain separation:** Session signatures bind to `(chain_id, account_addr)`. ([docs.starknet.io][1])
* **Expiry & limits:** Every session must have a hard `expires_at`; default small `maxCalls`.
* **Expiry & limits:** Every session must declare `valid_after`/`valid_until`; default small `maxCalls`.
* **Revocation:** `revokeSession(key_hash)` immediately blocks use. Events let dApps react.
* **Replay protection:** Optional per-session nonce (`sessionNonce`) incremented in validation.
* **Guardian griefing:** Require **m-of-n** quorum and **timelock**; owner can cancel a pending recovery.
Expand All @@ -265,7 +266,7 @@ await ua.sessions.revoke(sess.id);

* **Validation path**: O(#calls * (selector + target checks)). Use **bitset/bitmap** encodings for selectors if needed; start with arrays for simplicity, upgrade later.
* **Storage**: `calls_used` is incremented once per tx (after checking aggregate calls), not per inner call, to minimize writes.
* **Policy packing**: keep `expires_at` in `u64`; `maxCalls` in `u32`; selectors as `felt252[]`.
* **Policy packing**: keep `valid_after`/`valid_until` in `u64`; `maxCalls` in `u32`; selectors as `felt252[]`.

---

Expand Down Expand Up @@ -320,7 +321,7 @@ await ua.sessions.revoke(sess.id);
**Events**

```
event SessionAdded(key_hash: felt252, expires_at: u64, max_calls: u32);
event SessionAdded(key_hash: felt252, valid_after: u64, valid_until: u64, max_calls: u32);
event SessionRevoked(key_hash: felt252);
event SessionUsed(key_hash: felt252, used: u32);
event SessionNonceAdvanced(key_hash: felt252, new_nonce: u128);
Expand Down
Loading