From 2ef34b4c02bc2d52e8f0219206d5b045595a8e5f Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 5 Feb 2026 21:36:36 +0000 Subject: [PATCH 1/4] SIP: PTB Command Context & Scoped Execution Propose three related enhancements to PTBs: - Command Context: Expose previous command info to Move - Scoped Execution: Isolate groups of commands within transactions - Scope Witness: Cryptographic proof for same-scope verification Key design decision: scope_depth() returns 1 for both top-level PTB and explicit scope, preventing contracts from detecting isolation. Co-Authored-By: Claude Opus 4.5 --- sips/sip-ptb_command_context.md | 257 ++++++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 sips/sip-ptb_command_context.md diff --git a/sips/sip-ptb_command_context.md b/sips/sip-ptb_command_context.md new file mode 100644 index 0000000..cd03825 --- /dev/null +++ b/sips/sip-ptb_command_context.md @@ -0,0 +1,257 @@ +| SIP-Number | | +| ---: | :--- | +| Title | PTB Command Context & Scoped Execution | +| Description | Expose PTB command history to Move and enable isolated execution scopes within transactions | +| Author | Greshamscode, @92GC | +| Editor | | +| Type | Standard | +| Category | Framework | +| Created | 2025-02-04 | +| Comments-URI | | +| Status | | + +## Abstract + +Three related enhancements to PTBs: +1. **Command Context** - Expose previous command info (package, module, function) to Move +2. **Scoped Execution** - Isolate groups of commands that share internal context but not external +3. **Scope Witness** - Cryptographic proof that commands belong to same scope + +## Motivation + +Move functions are blind to their PTB context: +- Cannot verify which package/function called them +- Cannot see PTB command history +- No way to create isolated "sub-transactions" within atomic PTB + +**Use cases:** +- Verifiable dynamic dispatch without application-level hot potatoes +- Publish-and-use with type interpolation (see SIP: PTB Type Argument Interpolation) +- Isolated token launches where inner commands can't leak to outer PTB +- Cross-protocol trust verification +- **Preventing AMM censorship of arbitrage** (see below) + +### AMM Censorship Problem + +Without scopes, AMMs could inspect PTB context and refuse to execute if they detect arbitrage: + +```move +// Malicious AMM could do this: +public fun swap(ctx: &TxContext, ...) { + let prev = ptb_context::previous_command(ctx); + let next = ptb_context::command_at(ctx, ptb_context::current_index(ctx) + 1); + + // Refuse if sandwich detected + if (is_competing_amm(prev) || is_competing_amm(next)) { + abort ENoArbitrageAllowed + }; +} +``` + +**Consequences:** +- **Liquidity fragmentation** - AMMs can't be freely composed, fragmenting global liquidity +- **Validator MEV** - Validators see full PTB before execution, can front-run/censor arbitrage +- **Rent extraction** - AMMs become gatekeepers, extracting value from traders + +**Scopes solve this:** +```typescript +// User wraps each AMM call in isolated scope +ptb.scope({ inheritContext: false }, (s) => s.moveCall({ target: "amm_a::swap" })); +ptb.scope({ inheritContext: false }, (s) => s.moveCall({ target: "amm_b::swap" })); +// Neither AMM can see the other exists in this PTB +``` + +This preserves: +- Atomic execution (both swaps succeed or both fail) +- Composability (any AMM can be combined) +- MEV resistance (pattern not visible to contracts) + +## Specification + +### Part 1: Command Context + +```move +module sui::ptb_context { + /// Command types in PTB - defined as enum for type safety + public enum CommandType has copy, drop { + MoveCall, + TransferObjects, + SplitCoins, + MergeCoins, + Publish, + MakeMoveVec, + Upgrade, + Scope, + } + + public struct CommandInfo has copy, drop { + index: u16, + command_type: CommandType, + package_id: address, // MoveCall/Upgrade only + module_name: String, // MoveCall only + function_name: String, // MoveCall only + } + + /// Current command's index in PTB (or scope) + public native fun current_index(ctx: &TxContext): u16; + + /// Total commands in current scope + public native fun total_commands(ctx: &TxContext): u16; + + /// Get command info at index (must be < current_index) + public native fun command_at(ctx: &TxContext, index: u16): Option; + + /// Convenience: previous command + public fun previous_command(ctx: &TxContext): Option { + let idx = current_index(ctx); + if (idx == 0) option::none() + else command_at(ctx, idx - 1) + } + + /// Scope nesting depth (1 = top-level PTB or explicit scope, increments for nested) + /// NOTE: Top-level PTB is indistinguishable from explicit scope - both return 1 + /// This prevents contracts from detecting whether caller used Scope command + public native fun scope_depth(ctx: &TxContext): u8; +} +``` + +### Part 2: Scoped Execution + +New `Command` variant: + +```rust +enum Command { + // ... existing ... + Scope { + commands: Vec, + inherit_context: bool, // Can inner see outer command history? + }, +} +``` + +**Semantics:** +- Commands inside scope share internal context +- `current_index()` resets to 0 inside scope +- `command_at()` only returns scope-internal commands (unless `inherit_context`) +- Results can flow out via normal `Result(scope_idx)` references +- Scopes can nest (scope_depth increments) + +**SDK:** +```typescript +ptb.scope({ inheritContext: false }, (scope) => { + const pub = scope.publish({ modules, deps }); + scope.moveCall({ target: `...`, typeArguments: [scope.typeFromResult(pub, "mod", "Type")] }); +}); +``` + +### Part 3: Scope Witness + +```move +module sui::ptb_context { + /// Witness proving commands are in same scope + public struct ScopeWitness has drop { + scope_id: u256, + creator_index: u16, + scope_depth: u8, + } + + /// Create witness (marks current command as scope anchor) + public native fun create_scope_witness(ctx: &mut TxContext): ScopeWitness; + + /// Verify caller is in same scope as witness creator + public native fun in_same_scope(ctx: &TxContext, witness: &ScopeWitness): bool; + + /// Get scope_id of current scope (unique per scope instance) + public native fun current_scope_id(ctx: &TxContext): u256; +} +``` + +## Rationale + +**Why expose command history?** +- Enables trustless verification of caller identity +- No opt-in required (unlike hot potato patterns) +- Read-only - cannot enforce ordering, just verify + +**Why scopes?** +- Publish-and-use requires isolation (new types shouldn't leak) +- Composability with boundaries (protocol A calls protocol B without exposing internals) +- **Prevent AMM/protocol censorship** - contracts can't refuse execution based on surrounding PTB context +- **Preserve global liquidity** - AMMs remain freely composable for arbitrage +- **MEV resistance** - arbitrage patterns hidden from contracts (validators still see, but can't selectively abort) +- Gas accounting per scope (future: parallel execution hints) + +**Why not expose parameters?** +- Too expensive (arbitrary BCS data) +- Type safety issues (how to represent in Move?) +- Security risk (parameter inspection enables new attack vectors) + +**Why not expose `is_scoped()`?** +- Would allow contracts to detect if caller used Scope command +- Defeats isolation purpose - AMMs could refuse non-scoped calls +- Instead, `scope_depth()` returns 1 for both top-level PTB and explicit scope + +**Alternatives rejected:** +- Full call stack introspection - too invasive, breaks function isolation assumptions +- Mutable context - allows state smuggling between commands + +## Backwards Compatibility + +Purely additive: +- New native functions in `sui::ptb_context` +- New `Scope` command variant +- Existing PTBs work unchanged +- `ptb_context` functions return sensible defaults for non-scoped execution + +## Security Considerations + +**Command Context:** +- Read-only - cannot modify history +- Only past commands visible (not future) +- Package ID is immutable (Original ID, not upgraded address) + +**Scopes:** +- Inner scope cannot access outer scope's Results unless explicitly passed +- `inherit_context: false` provides strict isolation +- Scope witness cannot be forged (VM-generated scope_id) + +**Attack vectors to consider:** +- Scope escape via object references (mitigated: objects still owned normally) +- Context spoofing (mitigated: native functions, not user-controllable) +- Gas exhaustion via deep nesting (mitigated: max scope depth limit) + +**Design tension: Exposure vs Hiding** + +Part 1 (Command Context) and Part 2 (Scopes) create intentional tension: +- Context exposure enables verifiable dispatch (good for security) +- Scopes enable context hiding (good for composability/MEV resistance) + +Resolution: **User controls the boundary** +- Protocols that WANT caller verification use Part 1 (governance, vaults) +- Protocols that SHOULD NOT discriminate are called within scopes (AMMs, orderbooks) +- User decides isolation level per-call via `inheritContext` flag + +## Test Cases + +To be developed: +- Command history accuracy across command types +- Scope isolation verification +- Nested scope behavior +- Scope witness validity checks +- inherit_context: true vs false behavior + +## Reference Implementation + +To be developed. + +## Open Questions + +1. **Max scope depth?** Suggest 64 (sufficient for most use cases, limits complexity) +2. **Scope gas limits?** Should scopes have independent gas budgets? +3. **Result visibility?** Can outer PTB reference inner scope Results by index? +4. **Error semantics?** Does inner scope failure abort entire PTB? (suggest yes - atomic) +5. **Type interpolation interaction?** Should `typeFromResult` work across scope boundaries? + +## Copyright + +Copyright and related rights waived via [CC0](https://creativecommons.org/publicdomain/zero/1.0/). From 1b29f41fe01c65fad8cda3d933c33045d6954d8e Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 5 Feb 2026 21:50:48 +0000 Subject: [PATCH 2/4] Add Argument Provenance (Part 2), reframe motivation Major changes: - Lead with dynamic dispatch use case (eliminating hot potato wrappers) - Add Part 2: Argument Provenance - track where values came from - argument_source() returns Input/Result/NestedResult - Enables "accept coin only if from approved protocol" pattern - AMM censorship now secondary benefit of scopes - Add new open questions for argument indexing Co-Authored-By: Claude Opus 4.5 --- sips/sip-ptb_command_context.md | 196 ++++++++++++++++++++++++-------- 1 file changed, 147 insertions(+), 49 deletions(-) diff --git a/sips/sip-ptb_command_context.md b/sips/sip-ptb_command_context.md index cd03825..8e30e9b 100644 --- a/sips/sip-ptb_command_context.md +++ b/sips/sip-ptb_command_context.md @@ -1,7 +1,7 @@ | SIP-Number | | | ---: | :--- | | Title | PTB Command Context & Scoped Execution | -| Description | Expose PTB command history to Move and enable isolated execution scopes within transactions | +| Description | Expose PTB command history and argument provenance to Move, enabling dynamic dispatch and isolated scopes | | Author | Greshamscode, @92GC | | Editor | | | Type | Standard | @@ -12,59 +12,83 @@ ## Abstract -Three related enhancements to PTBs: +Four related enhancements to PTBs: 1. **Command Context** - Expose previous command info (package, module, function) to Move -2. **Scoped Execution** - Isolate groups of commands that share internal context but not external -3. **Scope Witness** - Cryptographic proof that commands belong to same scope +2. **Argument Provenance** - Track which command produced each argument passed to a function +3. **Scoped Execution** - Isolate groups of commands that share internal context but not external +4. **Scope Witness** - Cryptographic proof that commands belong to same scope ## Motivation -Move functions are blind to their PTB context: -- Cannot verify which package/function called them -- Cannot see PTB command history -- No way to create isolated "sub-transactions" within atomic PTB +### The Hot Potato Problem -**Use cases:** -- Verifiable dynamic dispatch without application-level hot potatoes -- Publish-and-use with type interpolation (see SIP: PTB Type Argument Interpolation) -- Isolated token launches where inner commands can't leak to outer PTB -- Cross-protocol trust verification -- **Preventing AMM censorship of arbitrage** (see below) +Today, composing Move protocols requires **application-level hot potatoes** - wrapper actions that must be pre-built for every external protocol integration. -### AMM Censorship Problem +Example: A DAO wants to execute "spend USDC → swap on Cetus → deposit result": -Without scopes, AMMs could inspect PTB context and refuse to execute if they detect arbitrage: +``` +Current approach: + Intent stages: [VaultSpend → CetusSwapWrapper → VaultDeposit] + ↑ + Must pre-build this action + Type-checks coin flow at compile time + New protocol = new wrapper +``` + +This creates: +- **Action explosion** - Every protocol integration needs custom wrapper code +- **Upgrade burden** - Protocol upgrades require action updates +- **Reduced composability** - Can't use protocols without pre-built wrappers + +### Dynamic Dispatch: The Solution + +With PTB Command Context + Argument Provenance, DAOs can verify constraints at runtime: + +``` +New approach: + Intent stages: [VaultSpend → → VaultDeposit] + ↑ + No wrapper needed! + Runtime verification via ptb_context +``` + +The deposit action verifies: +1. "The coin I'm receiving came from command N" (argument provenance) +2. "Command N was a call to an approved package" (command context) ```move -// Malicious AMM could do this: -public fun swap(ctx: &TxContext, ...) { - let prev = ptb_context::previous_command(ctx); - let next = ptb_context::command_at(ctx, ptb_context::current_index(ctx) + 1); - - // Refuse if sandwich detected - if (is_competing_amm(prev) || is_competing_amm(next)) { - abort ENoArbitrageAllowed - }; +public fun deposit_from_approved_source( + ctx: &TxContext, + coin: Coin, +) { + // Verify coin came from a PTB Result (not raw Input) + let source_idx = ptb_context::argument_source(ctx, 1); // arg 1 = coin + assert!(source_idx.is_some(), ENotFromPTBResult); + + // Verify source command was approved protocol + let source_cmd = ptb_context::command_at(ctx, *source_idx.borrow()); + assert!(is_approved_defi_protocol(source_cmd.package_id), EUnauthorizedSource); + + // Proceed with deposit... } ``` -**Consequences:** -- **Liquidity fragmentation** - AMMs can't be freely composed, fragmenting global liquidity -- **Validator MEV** - Validators see full PTB before execution, can front-run/censor arbitrage -- **Rent extraction** - AMMs become gatekeepers, extracting value from traders +**Result**: No more wrapper actions. Integrate any protocol by adding it to an approved list. -**Scopes solve this:** -```typescript -// User wraps each AMM call in isolated scope -ptb.scope({ inheritContext: false }, (s) => s.moveCall({ target: "amm_a::swap" })); -ptb.scope({ inheritContext: false }, (s) => s.moveCall({ target: "amm_b::swap" })); -// Neither AMM can see the other exists in this PTB +### Secondary Benefits + +**Scoped Execution** prevents protocol censorship: + +Without scopes, protocols could inspect PTB context and refuse to execute: +```move +// Malicious AMM could refuse arbitrage: +if (is_competing_amm(previous_command(ctx))) abort ENoArbitrageAllowed; ``` -This preserves: -- Atomic execution (both swaps succeed or both fail) -- Composability (any AMM can be combined) -- MEV resistance (pattern not visible to contracts) +Scopes hide context between isolated groups of commands, preserving: +- Atomic execution (all-or-nothing) +- Free composability (any protocol combination) +- MEV resistance (patterns hidden from contracts) ## Specification @@ -87,9 +111,9 @@ module sui::ptb_context { public struct CommandInfo has copy, drop { index: u16, command_type: CommandType, - package_id: address, // MoveCall/Upgrade only - module_name: String, // MoveCall only - function_name: String, // MoveCall only + package_id: address, // MoveCall/Upgrade only, @0x0 for others + module_name: String, // MoveCall only, empty for others + function_name: String, // MoveCall only, empty for others } /// Current command's index in PTB (or scope) @@ -98,7 +122,7 @@ module sui::ptb_context { /// Total commands in current scope public native fun total_commands(ctx: &TxContext): u16; - /// Get command info at index (must be < current_index) + /// Get command info at index (must be < current_index, past only) public native fun command_at(ctx: &TxContext, index: u16): Option; /// Convenience: previous command @@ -115,7 +139,52 @@ module sui::ptb_context { } ``` -### Part 2: Scoped Execution +### Part 2: Argument Provenance + +```move +module sui::ptb_context { + /// Argument source types + public enum ArgumentSource has copy, drop { + /// Value came from transaction Input (provided by sender) + Input { input_index: u16 }, + /// Value came from a previous command's Result + Result { command_index: u16, result_index: u16 }, + /// Value came from nested Result (e.g., Result(5, 0) for first return of command 5) + NestedResult { command_index: u16, result_index: u16 }, + } + + /// Get the source of argument at given index for current command + /// arg_index 0 = first argument (excluding &TxContext which is implicit) + /// Returns None if argument tracking unavailable + public native fun argument_source(ctx: &TxContext, arg_index: u8): Option; + + /// Convenience: check if argument came from a specific command's result + public fun argument_is_from_command(ctx: &TxContext, arg_index: u8, command_index: u16): bool { + let source = argument_source(ctx, arg_index); + if (source.is_none()) return false; + + match (*source.borrow()) { + ArgumentSource::Result { command_index: idx, .. } => idx == command_index, + ArgumentSource::NestedResult { command_index: idx, .. } => idx == command_index, + _ => false, + } + } + + /// Convenience: get command index that produced this argument (if Result-based) + public fun argument_command_index(ctx: &TxContext, arg_index: u8): Option { + let source = argument_source(ctx, arg_index); + if (source.is_none()) return option::none(); + + match (*source.borrow()) { + ArgumentSource::Result { command_index, .. } => option::some(command_index), + ArgumentSource::NestedResult { command_index, .. } => option::some(command_index), + _ => option::none(), + } + } +} +``` + +### Part 3: Scoped Execution New `Command` variant: @@ -133,6 +202,7 @@ enum Command { - Commands inside scope share internal context - `current_index()` resets to 0 inside scope - `command_at()` only returns scope-internal commands (unless `inherit_context`) +- `argument_source()` returns indices relative to current scope - Results can flow out via normal `Result(scope_idx)` references - Scopes can nest (scope_depth increments) @@ -144,7 +214,7 @@ ptb.scope({ inheritContext: false }, (scope) => { }); ``` -### Part 3: Scope Witness +### Part 4: Scope Witness ```move module sui::ptb_context { @@ -173,6 +243,11 @@ module sui::ptb_context { - No opt-in required (unlike hot potato patterns) - Read-only - cannot enforce ordering, just verify +**Why argument provenance?** +- Completes the verification story: know WHAT called you AND WHERE values came from +- Enables "accept coin only if it came from approved protocol" pattern +- Type safety preserved by Move's type system; provenance adds origin verification + **Why scopes?** - Publish-and-use requires isolation (new types shouldn't leak) - Composability with boundaries (protocol A calls protocol B without exposing internals) @@ -181,14 +256,20 @@ module sui::ptb_context { - **MEV resistance** - arbitrage patterns hidden from contracts (validators still see, but can't selectively abort) - Gas accounting per scope (future: parallel execution hints) +**Why not expose future commands?** +- Would allow contracts to enforce specific PTB structure +- Breaks composability (can't add cleanup commands after) +- Creates ordering games between protocols + **Why not expose parameters?** - Too expensive (arbitrary BCS data) - Type safety issues (how to represent in Move?) - Security risk (parameter inspection enables new attack vectors) +- Argument provenance provides sufficient information for most use cases **Why not expose `is_scoped()`?** - Would allow contracts to detect if caller used Scope command -- Defeats isolation purpose - AMMs could refuse non-scoped calls +- Defeats isolation purpose - protocols could refuse non-scoped calls - Instead, `scope_depth()` returns 1 for both top-level PTB and explicit scope **Alternatives rejected:** @@ -202,6 +283,7 @@ Purely additive: - New `Scope` command variant - Existing PTBs work unchanged - `ptb_context` functions return sensible defaults for non-scoped execution +- `argument_source` returns `None` for commands executed before this feature ## Security Considerations @@ -210,24 +292,32 @@ Purely additive: - Only past commands visible (not future) - Package ID is immutable (Original ID, not upgraded address) +**Argument Provenance:** +- Read-only tracking of PTB execution flow +- Cannot forge provenance (VM-tracked) +- Useful for allowlist-based verification, not for preventing all attacks +- Contracts should still validate values themselves, not just their source + **Scopes:** - Inner scope cannot access outer scope's Results unless explicitly passed - `inherit_context: false` provides strict isolation - Scope witness cannot be forged (VM-generated scope_id) +- Argument provenance indices are scope-relative (cannot reference out-of-scope commands) **Attack vectors to consider:** - Scope escape via object references (mitigated: objects still owned normally) - Context spoofing (mitigated: native functions, not user-controllable) - Gas exhaustion via deep nesting (mitigated: max scope depth limit) +- Provenance-based allowlist bypass (mitigated: contracts should validate values, not just source) **Design tension: Exposure vs Hiding** -Part 1 (Command Context) and Part 2 (Scopes) create intentional tension: -- Context exposure enables verifiable dispatch (good for security) +Parts 1-2 (Context + Provenance) and Part 3 (Scopes) create intentional tension: +- Context exposure enables verifiable dispatch (good for governance, security) - Scopes enable context hiding (good for composability/MEV resistance) Resolution: **User controls the boundary** -- Protocols that WANT caller verification use Part 1 (governance, vaults) +- Protocols that WANT caller verification use Parts 1-2 (governance, vaults, DAOs) - Protocols that SHOULD NOT discriminate are called within scopes (AMMs, orderbooks) - User decides isolation level per-call via `inheritContext` flag @@ -235,10 +325,12 @@ Resolution: **User controls the boundary** To be developed: - Command history accuracy across command types +- Argument provenance tracking for all argument sources (Input, Result, NestedResult) - Scope isolation verification - Nested scope behavior - Scope witness validity checks - inherit_context: true vs false behavior +- Cross-scope argument provenance (should not leak) ## Reference Implementation @@ -251,6 +343,12 @@ To be developed. 3. **Result visibility?** Can outer PTB reference inner scope Results by index? 4. **Error semantics?** Does inner scope failure abort entire PTB? (suggest yes - atomic) 5. **Type interpolation interaction?** Should `typeFromResult` work across scope boundaries? +6. **Argument indexing?** Should `&TxContext` count as arg 0, or be excluded from indexing? +7. **Nested result granularity?** Should `argument_source` distinguish between tuple elements? + +## Related SIPs + +- PTB Type Argument Interpolation (enables `typeFromResult` for publish-and-use) ## Copyright From 7f9e5298631f5aa01923f4d293cfe78c692eae5c Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 13 Feb 2026 21:55:45 +0000 Subject: [PATCH 3/4] rewrite SIP: concise, full scope visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - All commands in a scope can see each other (not past-only) - Cut from 355 to ~190 lines — removed redundant explanations - Tightened rationale, security, backwards compat sections --- sips/sip-ptb_command_context.md | 282 +++++++------------------------- 1 file changed, 60 insertions(+), 222 deletions(-) diff --git a/sips/sip-ptb_command_context.md b/sips/sip-ptb_command_context.md index 8e30e9b..a1da5fd 100644 --- a/sips/sip-ptb_command_context.md +++ b/sips/sip-ptb_command_context.md @@ -1,7 +1,7 @@ | SIP-Number | | | ---: | :--- | | Title | PTB Command Context & Scoped Execution | -| Description | Expose PTB command history and argument provenance to Move, enabling dynamic dispatch and isolated scopes | +| Description | Expose PTB command metadata and argument provenance to Move; add scoped execution for isolation | | Author | Greshamscode, @92GC | | Editor | | | Type | Standard | @@ -12,91 +12,35 @@ ## Abstract -Four related enhancements to PTBs: -1. **Command Context** - Expose previous command info (package, module, function) to Move -2. **Argument Provenance** - Track which command produced each argument passed to a function -3. **Scoped Execution** - Isolate groups of commands that share internal context but not external -4. **Scope Witness** - Cryptographic proof that commands belong to same scope +Four additions to PTBs: +1. **Command Context** — Any command in a scope can read metadata (package, module, function) of every other command in that scope. +2. **Argument Provenance** — Track which command produced each argument, enabling origin verification. +3. **Scoped Execution** — Partition PTB commands into isolated groups that share internal context but hide it from the outside. +4. **Scope Witness** — Proof that two commands executed in the same scope. ## Motivation -### The Hot Potato Problem +Composing Move protocols today requires hot potato wrappers for every integration. A DAO executing "vault spend → DEX swap → vault deposit" needs a custom wrapper action for each DEX. New DEX = new wrapper code. -Today, composing Move protocols requires **application-level hot potatoes** - wrapper actions that must be pre-built for every external protocol integration. - -Example: A DAO wants to execute "spend USDC → swap on Cetus → deposit result": - -``` -Current approach: - Intent stages: [VaultSpend → CetusSwapWrapper → VaultDeposit] - ↑ - Must pre-build this action - Type-checks coin flow at compile time - New protocol = new wrapper -``` - -This creates: -- **Action explosion** - Every protocol integration needs custom wrapper code -- **Upgrade burden** - Protocol upgrades require action updates -- **Reduced composability** - Can't use protocols without pre-built wrappers - -### Dynamic Dispatch: The Solution - -With PTB Command Context + Argument Provenance, DAOs can verify constraints at runtime: - -``` -New approach: - Intent stages: [VaultSpend → → VaultDeposit] - ↑ - No wrapper needed! - Runtime verification via ptb_context -``` - -The deposit action verifies: -1. "The coin I'm receiving came from command N" (argument provenance) -2. "Command N was a call to an approved package" (command context) +With command context + argument provenance, the deposit function can verify at runtime that the coin it received came from an approved package — no wrapper needed: ```move -public fun deposit_from_approved_source( - ctx: &TxContext, - coin: Coin, -) { - // Verify coin came from a PTB Result (not raw Input) - let source_idx = ptb_context::argument_source(ctx, 1); // arg 1 = coin - assert!(source_idx.is_some(), ENotFromPTBResult); - - // Verify source command was approved protocol - let source_cmd = ptb_context::command_at(ctx, *source_idx.borrow()); - assert!(is_approved_defi_protocol(source_cmd.package_id), EUnauthorizedSource); - - // Proceed with deposit... +public fun deposit_from_approved(ctx: &TxContext, coin: Coin) { + let source = ptb_context::argument_source(ctx, 0); + assert!(source.is_some(), ENotFromPTBResult); + let cmd = ptb_context::command_at(ctx, source.borrow().command_index()); + assert!(is_approved(cmd.package_id), EUnauthorizedSource); } ``` -**Result**: No more wrapper actions. Integrate any protocol by adding it to an approved list. - -### Secondary Benefits - -**Scoped Execution** prevents protocol censorship: - -Without scopes, protocols could inspect PTB context and refuse to execute: -```move -// Malicious AMM could refuse arbitrage: -if (is_competing_amm(previous_command(ctx))) abort ENoArbitrageAllowed; -``` - -Scopes hide context between isolated groups of commands, preserving: -- Atomic execution (all-or-nothing) -- Free composability (any protocol combination) -- MEV resistance (patterns hidden from contracts) +Scopes solve the flipside: without them, a protocol could inspect context and refuse execution when it sees a competitor in the same PTB. Scopes partition commands so each group only sees its own context, preventing censorship while keeping the transaction atomic. ## Specification -### Part 1: Command Context +### 1. Command Context ```move module sui::ptb_context { - /// Command types in PTB - defined as enum for type safety public enum CommandType has copy, drop { MoveCall, TransferObjects, @@ -111,102 +55,75 @@ module sui::ptb_context { public struct CommandInfo has copy, drop { index: u16, command_type: CommandType, - package_id: address, // MoveCall/Upgrade only, @0x0 for others - module_name: String, // MoveCall only, empty for others - function_name: String, // MoveCall only, empty for others + package_id: address, // MoveCall/Upgrade only, @0x0 otherwise + module_name: String, // MoveCall only + function_name: String, // MoveCall only } - /// Current command's index in PTB (or scope) + /// Current command's index in its scope public native fun current_index(ctx: &TxContext): u16; /// Total commands in current scope public native fun total_commands(ctx: &TxContext): u16; - /// Get command info at index (must be < current_index, past only) + /// Get info for any command in the current scope public native fun command_at(ctx: &TxContext, index: u16): Option; - /// Convenience: previous command - public fun previous_command(ctx: &TxContext): Option { - let idx = current_index(ctx); - if (idx == 0) option::none() - else command_at(ctx, idx - 1) - } - - /// Scope nesting depth (1 = top-level PTB or explicit scope, increments for nested) - /// NOTE: Top-level PTB is indistinguishable from explicit scope - both return 1 - /// This prevents contracts from detecting whether caller used Scope command + /// Scope nesting depth. Top-level PTB and explicit scope both return 1 + /// (indistinguishable by design — prevents contracts from detecting scope usage) public native fun scope_depth(ctx: &TxContext): u8; } ``` -### Part 2: Argument Provenance +All commands within a scope can see all other commands in that scope. Visibility does not cross scope boundaries unless `inherit_context` is set. + +### 2. Argument Provenance ```move module sui::ptb_context { - /// Argument source types public enum ArgumentSource has copy, drop { - /// Value came from transaction Input (provided by sender) Input { input_index: u16 }, - /// Value came from a previous command's Result Result { command_index: u16, result_index: u16 }, - /// Value came from nested Result (e.g., Result(5, 0) for first return of command 5) NestedResult { command_index: u16, result_index: u16 }, } - /// Get the source of argument at given index for current command - /// arg_index 0 = first argument (excluding &TxContext which is implicit) - /// Returns None if argument tracking unavailable + /// Source of the argument at the given index (excluding implicit &TxContext) public native fun argument_source(ctx: &TxContext, arg_index: u8): Option; - /// Convenience: check if argument came from a specific command's result + /// Check if argument came from a specific command public fun argument_is_from_command(ctx: &TxContext, arg_index: u8, command_index: u16): bool { let source = argument_source(ctx, arg_index); if (source.is_none()) return false; - match (*source.borrow()) { ArgumentSource::Result { command_index: idx, .. } => idx == command_index, ArgumentSource::NestedResult { command_index: idx, .. } => idx == command_index, _ => false, } } - - /// Convenience: get command index that produced this argument (if Result-based) - public fun argument_command_index(ctx: &TxContext, arg_index: u8): Option { - let source = argument_source(ctx, arg_index); - if (source.is_none()) return option::none(); - - match (*source.borrow()) { - ArgumentSource::Result { command_index, .. } => option::some(command_index), - ArgumentSource::NestedResult { command_index, .. } => option::some(command_index), - _ => option::none(), - } - } } ``` -### Part 3: Scoped Execution +### 3. Scoped Execution -New `Command` variant: +New PTB command variant: ```rust enum Command { - // ... existing ... + // ... existing variants ... Scope { commands: Vec, - inherit_context: bool, // Can inner see outer command history? + inherit_context: bool, }, } ``` -**Semantics:** -- Commands inside scope share internal context -- `current_index()` resets to 0 inside scope -- `command_at()` only returns scope-internal commands (unless `inherit_context`) -- `argument_source()` returns indices relative to current scope -- Results can flow out via normal `Result(scope_idx)` references -- Scopes can nest (scope_depth increments) +- `current_index()` resets to 0 inside a scope +- `command_at()` returns only scope-internal commands (unless `inherit_context: true`) +- Argument provenance indices are scope-relative +- Results flow out via normal `Result(scope_idx)` references +- Scopes nest; `scope_depth` increments accordingly -**SDK:** +SDK usage: ```typescript ptb.scope({ inheritContext: false }, (scope) => { const pub = scope.publish({ modules, deps }); @@ -214,142 +131,63 @@ ptb.scope({ inheritContext: false }, (scope) => { }); ``` -### Part 4: Scope Witness +### 4. Scope Witness ```move module sui::ptb_context { - /// Witness proving commands are in same scope public struct ScopeWitness has drop { scope_id: u256, creator_index: u16, scope_depth: u8, } - /// Create witness (marks current command as scope anchor) public native fun create_scope_witness(ctx: &mut TxContext): ScopeWitness; - - /// Verify caller is in same scope as witness creator public native fun in_same_scope(ctx: &TxContext, witness: &ScopeWitness): bool; - - /// Get scope_id of current scope (unique per scope instance) public native fun current_scope_id(ctx: &TxContext): u256; } ``` ## Rationale -**Why expose command history?** -- Enables trustless verification of caller identity -- No opt-in required (unlike hot potato patterns) -- Read-only - cannot enforce ordering, just verify - -**Why argument provenance?** -- Completes the verification story: know WHAT called you AND WHERE values came from -- Enables "accept coin only if it came from approved protocol" pattern -- Type safety preserved by Move's type system; provenance adds origin verification - -**Why scopes?** -- Publish-and-use requires isolation (new types shouldn't leak) -- Composability with boundaries (protocol A calls protocol B without exposing internals) -- **Prevent AMM/protocol censorship** - contracts can't refuse execution based on surrounding PTB context -- **Preserve global liquidity** - AMMs remain freely composable for arbitrage -- **MEV resistance** - arbitrage patterns hidden from contracts (validators still see, but can't selectively abort) -- Gas accounting per scope (future: parallel execution hints) - -**Why not expose future commands?** -- Would allow contracts to enforce specific PTB structure -- Breaks composability (can't add cleanup commands after) -- Creates ordering games between protocols - -**Why not expose parameters?** -- Too expensive (arbitrary BCS data) -- Type safety issues (how to represent in Move?) -- Security risk (parameter inspection enables new attack vectors) -- Argument provenance provides sufficient information for most use cases - -**Why not expose `is_scoped()`?** -- Would allow contracts to detect if caller used Scope command -- Defeats isolation purpose - protocols could refuse non-scoped calls -- Instead, `scope_depth()` returns 1 for both top-level PTB and explicit scope - -**Alternatives rejected:** -- Full call stack introspection - too invasive, breaks function isolation assumptions -- Mutable context - allows state smuggling between commands - -## Backwards Compatibility +**Full scope visibility (not past-only):** Restricting to past commands would still allow ordering games. Full visibility within a scope is simpler and lets contracts verify the complete execution context they're part of. Scopes are the isolation boundary, not command ordering. -Purely additive: -- New native functions in `sui::ptb_context` -- New `Scope` command variant -- Existing PTBs work unchanged -- `ptb_context` functions return sensible defaults for non-scoped execution -- `argument_source` returns `None` for commands executed before this feature +**Scopes, not `is_scoped()`:** Exposing whether a call is scoped would let protocols refuse non-scoped calls, defeating the purpose. `scope_depth()` returns 1 for both top-level and explicit scopes — indistinguishable. -## Security Considerations +**No parameter exposure:** Too expensive (arbitrary BCS), type-unsafe, and argument provenance covers the important cases. -**Command Context:** -- Read-only - cannot modify history -- Only past commands visible (not future) -- Package ID is immutable (Original ID, not upgraded address) +**Exposure vs hiding is intentional:** Context lets governance protocols verify callers. Scopes let DeFi protocols stay composable. The user picks the boundary per call. -**Argument Provenance:** -- Read-only tracking of PTB execution flow -- Cannot forge provenance (VM-tracked) -- Useful for allowlist-based verification, not for preventing all attacks -- Contracts should still validate values themselves, not just their source +## Backwards Compatibility -**Scopes:** -- Inner scope cannot access outer scope's Results unless explicitly passed -- `inherit_context: false` provides strict isolation -- Scope witness cannot be forged (VM-generated scope_id) -- Argument provenance indices are scope-relative (cannot reference out-of-scope commands) +Purely additive. Existing PTBs work unchanged. `ptb_context` functions return sensible defaults pre-feature. `argument_source` returns `None` for pre-feature commands. -**Attack vectors to consider:** -- Scope escape via object references (mitigated: objects still owned normally) -- Context spoofing (mitigated: native functions, not user-controllable) -- Gas exhaustion via deep nesting (mitigated: max scope depth limit) -- Provenance-based allowlist bypass (mitigated: contracts should validate values, not just source) +## Security Considerations -**Design tension: Exposure vs Hiding** +- Read-only — commands can inspect context but not modify it +- All commands in a scope see each other; scopes are the privacy boundary +- Package IDs are Original IDs (immutable, not upgraded addresses) +- Provenance is VM-tracked and unforgeable +- Scope witnesses are VM-generated — cannot be spoofed +- Max scope depth limit prevents gas exhaustion via deep nesting +- Contracts should validate values, not just their provenance -Parts 1-2 (Context + Provenance) and Part 3 (Scopes) create intentional tension: -- Context exposure enables verifiable dispatch (good for governance, security) -- Scopes enable context hiding (good for composability/MEV resistance) +## Open Questions -Resolution: **User controls the boundary** -- Protocols that WANT caller verification use Parts 1-2 (governance, vaults, DAOs) -- Protocols that SHOULD NOT discriminate are called within scopes (AMMs, orderbooks) -- User decides isolation level per-call via `inheritContext` flag +1. Max scope depth? (suggest 64) +2. Should scopes have independent gas budgets? +3. Can outer PTB reference inner scope Results by index? +4. Does inner scope failure abort the entire PTB? (suggest yes) +5. Should `typeFromResult` work across scope boundaries? +6. Should `&TxContext` count as arg 0 or be excluded from indexing? ## Test Cases -To be developed: -- Command history accuracy across command types -- Argument provenance tracking for all argument sources (Input, Result, NestedResult) -- Scope isolation verification -- Nested scope behavior -- Scope witness validity checks -- inherit_context: true vs false behavior -- Cross-scope argument provenance (should not leak) +To be developed. ## Reference Implementation To be developed. -## Open Questions - -1. **Max scope depth?** Suggest 64 (sufficient for most use cases, limits complexity) -2. **Scope gas limits?** Should scopes have independent gas budgets? -3. **Result visibility?** Can outer PTB reference inner scope Results by index? -4. **Error semantics?** Does inner scope failure abort entire PTB? (suggest yes - atomic) -5. **Type interpolation interaction?** Should `typeFromResult` work across scope boundaries? -6. **Argument indexing?** Should `&TxContext` count as arg 0, or be excluded from indexing? -7. **Nested result granularity?** Should `argument_source` distinguish between tuple elements? - -## Related SIPs - -- PTB Type Argument Interpolation (enables `typeFromResult` for publish-and-use) - ## Copyright Copyright and related rights waived via [CC0](https://creativecommons.org/publicdomain/zero/1.0/). From d67adb608c897d343cc3ed91422131aafc5cb71d Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 13 Feb 2026 21:57:52 +0000 Subject: [PATCH 4/4] lead with dynamic dispatch, defer mechanism names to spec --- sips/sip-ptb_command_context.md | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/sips/sip-ptb_command_context.md b/sips/sip-ptb_command_context.md index a1da5fd..3a0137e 100644 --- a/sips/sip-ptb_command_context.md +++ b/sips/sip-ptb_command_context.md @@ -1,7 +1,7 @@ | SIP-Number | | | ---: | :--- | -| Title | PTB Command Context & Scoped Execution | -| Description | Expose PTB command metadata and argument provenance to Move; add scoped execution for isolation | +| Title | PTB Dynamic Dispatch via Command Context & Scoped Execution | +| Description | Enable dynamic dispatch in PTBs by exposing command metadata and argument origins to Move; add scoped execution for isolation | | Author | Greshamscode, @92GC | | Editor | | | Type | Standard | @@ -12,17 +12,19 @@ ## Abstract -Four additions to PTBs: -1. **Command Context** — Any command in a scope can read metadata (package, module, function) of every other command in that scope. -2. **Argument Provenance** — Track which command produced each argument, enabling origin verification. -3. **Scoped Execution** — Partition PTB commands into isolated groups that share internal context but hide it from the outside. -4. **Scope Witness** — Proof that two commands executed in the same scope. +This SIP enables dynamic dispatch in PTBs. Today, composing protocols requires pre-built hot potato wrappers for every integration — new protocol means new wrapper code. We propose letting contracts inspect which commands are in their PTB scope and where argument values came from, so they can verify callers and value origins at runtime. No wrappers needed. + +Concretely, four additions: +1. Expose command metadata (package, module, function) to all commands within a scope +2. Track which command produced each argument passed to a function +3. A new `Scope` command that partitions PTB commands into isolated groups +4. A witness proving two commands share a scope ## Motivation -Composing Move protocols today requires hot potato wrappers for every integration. A DAO executing "vault spend → DEX swap → vault deposit" needs a custom wrapper action for each DEX. New DEX = new wrapper code. +A DAO wants to execute "vault spend → DEX swap → vault deposit." Today this requires a wrapper action per DEX that type-checks the coin flow at compile time. Adding a new DEX means writing and deploying new wrapper code. -With command context + argument provenance, the deposit function can verify at runtime that the coin it received came from an approved package — no wrapper needed: +With this proposal, the deposit function verifies at runtime that the coin came from an approved package: ```move public fun deposit_from_approved(ctx: &TxContext, coin: Coin) { @@ -33,12 +35,16 @@ public fun deposit_from_approved(ctx: &TxContext, coin: Coin } ``` -Scopes solve the flipside: without them, a protocol could inspect context and refuse execution when it sees a competitor in the same PTB. Scopes partition commands so each group only sees its own context, preventing censorship while keeping the transaction atomic. +Adding a new DEX becomes an allowlist update, not new code. + +Scopes solve the flipside: without isolation, a protocol could inspect context and refuse execution when it sees a competitor. Scopes partition commands so each group only sees its own context, preventing censorship while keeping the transaction atomic. ## Specification ### 1. Command Context +A new `sui::ptb_context` module exposes metadata about commands in the current scope. + ```move module sui::ptb_context { public enum CommandType has copy, drop { @@ -79,6 +85,8 @@ All commands within a scope can see all other commands in that scope. Visibility ### 2. Argument Provenance +Extends `sui::ptb_context` with argument origin tracking. + ```move module sui::ptb_context { public enum ArgumentSource has copy, drop { @@ -149,9 +157,9 @@ module sui::ptb_context { ## Rationale -**Full scope visibility (not past-only):** Restricting to past commands would still allow ordering games. Full visibility within a scope is simpler and lets contracts verify the complete execution context they're part of. Scopes are the isolation boundary, not command ordering. +**Full scope visibility (not past-only):** Restricting to past commands would still allow ordering games. Full visibility within a scope is simpler and lets contracts verify the complete execution context. Scopes are the isolation boundary, not command ordering. -**Scopes, not `is_scoped()`:** Exposing whether a call is scoped would let protocols refuse non-scoped calls, defeating the purpose. `scope_depth()` returns 1 for both top-level and explicit scopes — indistinguishable. +**Scopes, not `is_scoped()`:** Exposing whether a call is scoped lets protocols refuse non-scoped calls, defeating the purpose. `scope_depth()` returns 1 for both top-level and explicit scopes — indistinguishable. **No parameter exposure:** Too expensive (arbitrary BCS), type-unsafe, and argument provenance covers the important cases.