Skip to main content
Status: Draft Type: Standards Track Category: SDK

Overview

This improvement proposal aims to hide underlying mechanisms (message passing, Timer, Gas) through high-level abstractions while strictly adhering to the PVM determinism constraints defined by the Cowboy mainchain (no JIT, soft-float, fixed hash seed, CBOR serialization).

Design Principles

  1. Determinism First: All SDK abstractions must compile to deterministic on-chain operations
  2. Explicit Over Implicit: State crossing block boundaries must be explicitly declared
  3. Secure by Default: Prevent developers from inadvertently writing code that breaks consensus
  4. Progressive Complexity: Simple APIs for simple scenarios, full control for complex scenarios

Chapter 1: Call Primitives and Delivery Timing

1.1 Three Call Primitives

The Cowboy SDK provides three call primitives for different scenarios:
PrimitiveDelivery TimingReturn ValueAtomicityRollback PropagationTypical Use Cases
call()T+0 (same transaction)✅ Direct return✅ Shared context✅ Cascading rollbackAtomic operations, state queries
send()T+N (next block)❌ None❌ Independent transaction❌ IrrevocableNotifications, triggering tasks
await continuationT+N (after off-chain execution)✅ Resume return❌ Independent transaction❌ IrrevocableLLM, HTTP, cross-Actor async

1.2 Execution Sequence Diagram

1.3 Synchronous Call (call) - T+0

Synchronous calls execute immediately within the current transaction, sharing atomic context. PVM Determinism Constraints:
  • Call depth accumulates to reentrancy limit (32), each call() consumes 1 depth level
  • Must explicitly pass cycles_limit to prevent infinite recursion
  • Return values must be CBOR serializable types
from cowboy_sdk import call

def atomic_swap(self, user: str, amount_a: int, amount_b: int):
    """
    Synchronous call semantics:
    - Immediate execution: call() jumps to target Actor immediately within current transaction
    - Shared context: Caller and callee share the same transaction's read-write set
    - Atomic rollback: raise at any point rolls back the entire call chain
    """
    
    # Synchronous call: execute immediately and return result
    balance_a = call(
        target="0x1111...",
        method="get_balance",
        args={"user": user},
        cycles_limit=5000  # Must explicitly specify
    )
    
    if balance_a < amount_a:
        raise InsufficientBalance()
    
    # These two transfers execute atomically within the same transaction
    call(
        target="0x1111...", 
        method="transfer", 
        args={"from_addr": user, "to_addr": self.address, "amount": amount_a},
        cycles_limit=10000
    )
    
    call(
        target="0x2222...", 
        method="transfer",
        args={"from_addr": self.address, "to_addr": user, "amount": amount_b},
        cycles_limit=10000
    )
    # If execution reaches here without exceptions, both transfers are committed
Syntactic Sugar: ActorRef
from cowboy_sdk import ActorRef

@actor
class TradingBot:
    def check_arbitrage(self):
        # SDK syntactic sugar: automatically generates call() invocation
        oracle = ActorRef("0x4444...")
        price = oracle.get_price("ETH")  # Compiles to call(...)
        
        if price < 1000:
            self.execute_buy()

1.4 Asynchronous Message (send) - T+N

Asynchronous messages are queued for delivery in the next block, with no return value and irrevocable. PVM Determinism Constraints:
  • Multiple send() calls within the same transaction queue messages in call order
  • Message ID: keccak256(sender_addr + nonce + target + payload_hash)
  • Messages are strictly delivered at the start of the next block
from cowboy_sdk import send

def trigger_downstream(self, order_id: str):
    """
    Asynchronous message semantics:
    - Delayed delivery: Messages queue to next block
    - No return value: send() returns None immediately
    - Irrevocable: Messages cannot be cancelled after sending
    """
    
    # Send notification (fire and forget)
    send(
        target="0x3333...",
        message={"action": "notify", "order_id": order_id}
    )
    
    # Can send multiple messages consecutively, delivered in order
    send(target="0x4444...", message={"action": "log", "order_id": order_id})
    send(target="0x5555...", message={"action": "audit", "order_id": order_id})
⚠️ Fire-and-Forget Risks and Compensation Patterns
# ❌ Anti-pattern: raise after send() causes inconsistency
def risky_workflow(self, order_id: str):
    send(target="0x3333...", message={"action": "order_created", ...})
    
    result = call(target="0x1111...", method="reserve_inventory", ...)
    if not result.success:
        # ⚠️ send() already dispatched, cannot be revoked
        raise InventoryError()

# ✅ Recommended pattern: Complete potentially failing operations first, then send()
def safe_workflow(self, order_id: str):
    # Execute all potentially failing synchronous calls first
    result = call(target="0x1111...", method="reserve_inventory", ...)
    if not result.success:
        raise InventoryError()
    
    call(target="0x2222...", method="charge_payment", ...)
    
    # Send notifications only after all critical operations succeed
    send(target="0x3333...", message={"action": "order_created", ...})

1.5 Reentrancy and Circular Calls

from cowboy_sdk import call, reentrancy_guard

@actor
class ContractA:
    def method_1(self, depth: int = 0):
        if depth > 5:
            return "max depth reached"
        
        # Call B, B will callback to A.method_2
        # Legal reentrancy, as long as total depth ≤ 32
        result = call(
            target="0xBBBB...",
            method="call_back_to_a",
            args={"depth": depth},
            cycles_limit=50000
        )
        return result

@actor
class SafeToken:
    # SDK decorator automatically handles reentrancy protection
    @reentrancy_guard
    def transfer(self, to: str, amount: int):
        # SDK automatically locks at entry, unlocks at exit
        # Lock key is deterministically generated based on keccak256(method_name + caller_addr)
        pass

Chapter 2: Continuation Mechanism

Continuation is the core mechanism for Cowboy to handle cross-block asynchronous operations. The SDK provides two Continuation decorators:
DecoratorPurposeawait Target
@runner.continuationCall off-chain Runner servicesrunner.llm(), runner.http(), etc.
@actor.continuationInter-Actor async request-responseActorRef.async_*() methods
Both share the same compilation strategy and state machine mechanism, differing only in the await target.

2.1 Compilation Strategy: Explicit State Machine Transformation

The SDK compiles async functions into Finite State Automata (FSM), with each await point defining a state. PVM Determinism Constraints:
  • State serialization uses Canonical CBOR
  • State ID: keccak256(actor_addr + method_name + invocation_nonce)
  • Each Continuation state occupies Actor storage quota
  • Captured variables must be CBOR serializable types (closures, function references, generators are prohibited)

2.2 capture() - Explicit State Capture

Developers must use capture() to explicitly declare variables that need to be preserved across await:
from cowboy_sdk import runner, capture

@runner.continuation
async def sequential_workflow(self, msg):
    # Declare variables to capture across await
    ctx = capture()
    
    # Step 1: First await
    ctx.step1 = await runner.http("https://api1.com/data")
    # After compilation: send message + save {state: 1, ctx: {step1: ...}} + return
    
    # Step 2: Use step1 result
    ctx.step2 = await runner.llm(f"Analyze: {ctx.step1}")
    # After compilation: restore state + send message + save {state: 2, ctx: {...}} + return
    
    # Step 3: Final processing
    self.storage.set("result", ctx.step2.summary)
    # After compilation: restore state + execute + cleanup Continuation state

2.3 Supported Patterns and Limitations

PatternSupport StatusNotes
Sequential await✅ Supported (max 8)Each await generates a state transition
Conditional await✅ SupportedState machine includes branches
await in loops⚠️ Limited supportMust use @bounded_loop to declare upper bound
await in try/except✅ SupportedException states are also serialized
await in nested function calls❌ Not supportedMust flatten to top-level function
Recursive await❌ Not supportedCannot serialize recursive stack

2.4 Conditional Branch await

@runner.continuation
async def conditional_workflow(self, msg):
    ctx = capture()
    
    ctx.analysis = await runner.llm("Initial analysis...")
    
    # Conditional branch: state machine includes two possible subsequent states
    if ctx.analysis.confidence > 0.8:
        # Branch A: State 2A
        ctx.details = await runner.llm("Deep dive...")
        self.execute_high_confidence(ctx.details)
    else:
        # Branch B: State 2B  
        ctx.fallback = await runner.http("https://fallback-api.com")
        self.execute_low_confidence(ctx.fallback)

2.5 Bounded Loop await

Using await in loops requires declaring iteration upper bound:
from cowboy_sdk import runner, capture, bounded_loop

@runner.continuation
async def loop_workflow(self, msg):
    ctx = capture()
    
    ctx.items = await runner.http("https://api.com/items")
    ctx.results = []
    
    # bounded_loop declares loop upper bound, compiler generates states accordingly
    # If actual iterations exceed max_iterations, throws LoopBoundExceeded
    @bounded_loop(max_iterations=10)
    async def process_items():
        for item in ctx.items[:10]:  # Must slice before loop
            result = await runner.process(item)
            ctx.results.append(result)
    
    await process_items()
    
    return aggregate(ctx.results)

2.6 Error Handling with await

@runner.continuation
async def error_handling_workflow(self, msg):
    ctx = capture()
    
    try:
        ctx.result = await runner.llm("...", timeout_blocks=50)
        self.process(ctx.result)
        
    except RunnerTimeoutError:
        # Timeout: SDK deterministically triggers this branch at block N+50
        ctx.fallback = await runner.http("https://fallback.com")
        self.process_fallback(ctx.fallback)
        
    except RunnerValidationError as e:
        # Validation failure: Runner result doesn't conform to Schema
        self.log_error(e)

2.7 @actor.continuation - Inter-Actor Async Calls

For async request-response patterns between Actors (not Runner):
from cowboy_sdk import actor, capture

@actor
class TradingBot:
    @actor.continuation(timeout_blocks=100)
    async def query_multiple_oracles(self, assets: list[str]):
        ctx = capture()  # Also requires capture()
        
        oracle = ActorRef("0x4444...")
        ctx.results = []
        
        # Must use bounded_loop
        @bounded_loop(max_iterations=5)
        async def fetch_prices():
            for asset in assets[:5]:
                # await compiles to send() + callback handler
                price = await oracle.async_get_price(asset)
                ctx.results.append(price)
        
        await fetch_prices()
        return ctx.results

2.8 Continuation State Storage

# Continuation state is stored under a special namespace in Actor storage
# Key: __continuation:{correlation_id}
# Value: CBOR({
#     "state": int,           # Current state number
#     "ctx": dict,            # Captured variables
#     "created_block": int,   # Block height at creation
#     "timeout_block": int,   # Timeout block height
#     "checksum": bytes       # State integrity checksum
# })

# Storage limits
CONTINUATION_MAX_SIZE = 64 * 1024  # Single Continuation max 64 KiB
CONTINUATION_MAX_COUNT = 100        # Max 100 active Continuations per Actor

2.9 Compilation Output Example (Informative)

Original code:
@runner.continuation
async def example(self, msg):
    ctx = capture()
    ctx.a = await runner.llm("step1")
    ctx.b = await runner.llm(f"step2: {ctx.a}")
    return ctx.b
Compiled equivalent:
def example(self, msg):
    cont_state = self._load_continuation(msg)
    
    if cont_state is None:
        # Initial call: send first task
        correlation_id = self._gen_correlation_id()
        self._save_continuation(correlation_id, {"state": 0, "ctx": {}})
        send(RUNNER, {
            "job_type": "llm", "prompt": "step1",
            "correlation_id": correlation_id,
            "reply_handler": "example__resume"
        })
        return
    
def example__resume(self, msg):
    cont_state = self._load_continuation(msg.correlation_id)
    ctx = cont_state["ctx"]
    
    if cont_state["state"] == 0:
        ctx["a"] = msg.result
        self._save_continuation(msg.correlation_id, {"state": 1, "ctx": ctx})
        send(RUNNER, {
            "job_type": "llm", "prompt": f"step2: {ctx['a']}",
            "correlation_id": msg.correlation_id,
            "reply_handler": "example__resume"
        })
        return
        
    elif cont_state["state"] == 1:
        ctx["b"] = msg.result
        self._delete_continuation(msg.correlation_id)
        return ctx["b"]

Chapter 3: State Safety Mechanisms

Cowboy provides two complementary state safety mechanisms:
MechanismPurposeTiming
guardVerify state unchanged during cross-block periodOn Continuation resume
captureSave local variables across blocksBefore and after await points

3.1 Guard Mechanism - State Protection

Guard prevents stale state vulnerabilities caused by cross-block execution. PVM Determinism Constraints:
  • Object identity comparison (id() or is) is prohibited
  • State fingerprint uses Canonical CBOR + keccak256
  • Cannot use pickle or unstable JSON

Method A: Decorator-Level Guard

# Declaration: Before resuming execution, specified storage keys must be unchanged
@runner.continuation(guard_unchanged=["price", "config"])
async def execute_strategy(self, msg):
    # SDK internal logic:
    # 1. Capture current value: v1 = keccak256(cbor(storage.get("price")))
    # 2. Write v1 to continuation state
    # 3. Send task...
    # 4. (Cross-block wait) ...
    # 5. On resume, recalculate: v2 = keccak256(cbor(storage.get("price")))
    # 6. If v1 != v2, throw StateConflictError
    
    result = await runner.llm("Analyze market...")
    self.buy()

Method B: Object-Level Fine-Grained Guard

async def flexible_trade(self, msg):
    # .guard() returns a GuardedValue object
    # Internal storage: {'key': 'balance', 'snapshot_hash': '0x123...', 'value': 1000}
    balance = self.storage.guard("balance") 
    
    try:
        # SDK automatically injects balance's snapshot_hash into continuation state
        result = await runner.llm(...)
        
        # Explicitly accessing .value triggers validation
        # If current storage["balance"] hash doesn't match snapshot, throws exception
        new_balance = balance.value - 100
        self.storage.set("balance", new_balance)
        
    except StateConflictError:
        # Deterministic exception: all nodes throw exception at the same instruction
        self.log("Balance changed, aborting.")

3.2 Collaboration Between Guard and Capture

guard and capture solve different problems and can be used together:
@runner.continuation(guard_unchanged=["user_balance"])  # Verify balance unchanged
async def complex_workflow(self, msg):
    ctx = capture()  # Save local variables
    
    # ctx saves intermediate computation results
    ctx.analysis = await runner.llm("...")
    
    # guard_unchanged ensures user_balance wasn't modified by other transactions during wait
    # If modified, throws StateConflictError on resume
    
    ctx.decision = await runner.llm(f"Based on {ctx.analysis}...")
    
    # user_balance is guaranteed to be consistent with start time during execution
    self.execute_trade(ctx.decision)
Summary of Differences:
  • capture() saves local variables (temporary values within the function)
  • guard_unchanged verifies storage state (Actor’s persistent data)

Chapter 4: Async Tools

4.1 Timeout and Retry

PVM Determinism Constraints:
  • Time units must be block height, using seconds or time.time() is prohibited
  • Retry jitter must use on-chain VRF, random.random() is prohibited
from cowboy_sdk import Retry

async def fetch_data(self):
    try:
        result = await runner.http(
            url="https://api.example.com",
            # PVM constraint: timeout must be integer (block count)
            timeout_blocks=20,  
            
            # Retry policy: delay sequence is fixed [1, 2, 4, 8] blocks
            # If jitter is needed, SDK internally uses HKDF(VRF_Beacon, actor_addr)
            retry_policy=Retry(max_attempts=3, backoff="exponential") 
        )
    except RunnerTimeoutError:
        # Deterministic error, all nodes trigger at block N+20
        self.cleanup()
SDK Internal Implementation:
  • Timer ID generation: keccak256(current_msg_id + "timer"), ensures consistency across all nodes
  • Automatic cleanup: When Runner result is received or Timeout triggers, SDK automatically cancels the other resource

4.2 TaskGroup - Structured Concurrency

Allows developers to write parallel tasks with synchronous code thinking. PVM Determinism Constraints:
  • Task creation order within TaskGroup must be strictly consistent, determining message Nonce and hash
  • When aggregating results, SDK returns results in deterministic order (by task creation order)
async with runner.TaskGroup() as tg:
    # Task 1: consumes nonce N at creation
    t1 = tg.create_task(runner.llm(prompt="A"))
    # Task 2: consumes nonce N+1 at creation
    t2 = tg.create_task(runner.llm(prompt="B"))

# Reaching here means all tasks are complete
# PVM constraint: regardless of which returns first, result access order is deterministic
if t1.result.score > t2.result.score:
    self.action()

Chapter 5: Type System

5.1 CowboyModel - PVM-Safe Data Model

Standard Pydantic BaseModel may use non-deterministic behavior. The SDK provides a customized CowboyModel: PVM Determinism Constraints:
  • Python native float depends on hardware FPU, is non-deterministic
  • Must use SoftFloat instead of float
  • Decimal must specify precision
from cowboy_sdk import CowboyModel, Field
from cowboy_sdk.types import SoftFloat

class MarketAnalysis(CowboyModel):
    # Must use SoftFloat instead of float
    sentiment_score: SoftFloat = Field(..., ge=0, le=1)
    tags: list[str]
    # Use string for amounts to avoid floating-point precision issues
    price_target: str  

@actor
class Trader:
    async def analyze(self):
        # response_model tells Runner to conform to this JSON Schema
        result = await runner.llm(
            prompt="Analyze...",
            response_model=MarketAnalysis
        )
        
        # SDK internal:
        # 1. Receive JSON result
        # 2. Validate using canonical CBOR rules
        # 3. Instantiate MarketAnalysis (throws DeterministicValidationError if validation fails)
        
        if result.sentiment_score > SoftFloat("0.8"):
            self.buy()

5.2 PVM-Specific Types

TypeReplacesDescription
SoftFloatfloatUses software floating-point library, cross-platform deterministic
ordered_setsetInsertion-ordered set, deterministic iteration order
BlockHeightintSemantic block height type

Chapter 6: Declarative Verification Builder

Use fluent chaining to replace hand-written complex verification JSON configuration. PVM Determinism Constraints:
  • Regardless of how code is called, the final generated Job Spec JSON must have ordered keys

6.1 Basic API

from cowboy_sdk import Verify
from cowboy_sdk.types import SoftFloat

await runner.llm(
    prompt="...",
    verification=Verify.builder()
        .mode("structured_match")
        .runners(5)
        .threshold(3)
        .check(Verify.numeric_tolerance("score", SoftFloat("0.05")))
        .check(Verify.no_prompt_leak())
        .check(Verify.custom(actor="0x123...", method="check_quality"))
        .build() 
)

6.2 Verification Modes

ModeMethodDescription
none.mode("none")No verification, only guarantees non-delivery
economic_bond.mode("economic_bond")Single Runner + bond
majority_vote.mode("majority_vote")Majority vote on specified field
structured_match.mode("structured_match")Validator function matching
deterministic.mode("deterministic")Exact match + TEE
semantic_similarity.mode("semantic_similarity")Embedding similarity

6.3 Built-in Checkers

CheckerDescription
Verify.exact_match()Byte-for-byte equality
Verify.json_schema_valid(schema)JSON Schema validation
Verify.structured_match(fields)Specified fields must match
Verify.majority_vote(field)Field value >50% agreement
Verify.numeric_tolerance(field, tolerance)Number within ±tolerance
Verify.numeric_range(field, min, max)Number within bounds
Verify.set_equality(field)Unordered set equality
Verify.contains_all(substrings)Output contains required strings
Verify.contains_none(substrings)Output excludes strings
Verify.regex_match(pattern)Regular expression match
Verify.length_bounds(min, max)Output length within bounds
Verify.semantic_similarity(threshold)Embedding cosine similarity
Verify.no_prompt_leak()Output doesn’t contain system prompt
Verify.entropy_check(min_entropy)Output is not repetitive/degenerate
Verify.custom(actor, method)Custom validator Actor

6.4 Complete Example

from cowboy_sdk import Verify, runner
from cowboy_sdk.types import SoftFloat

# Scenario: Financial analysis, requiring high reliability
await runner.llm(
    prompt="Analyze BTC market trends...",
    response_model=MarketAnalysis,
    verification=Verify.builder()
        .mode("structured_match")
        .runners(5)
        .threshold(3)
        # Must pass Schema validation
        .check(Verify.json_schema_valid(MarketAnalysis.schema()))
        # sentiment_score error not exceeding 0.05
        .check(Verify.numeric_tolerance("sentiment_score", SoftFloat("0.05")))
        # tags field must match exactly
        .check(Verify.structured_match(["tags"]))
        # No system prompt leakage allowed
        .check(Verify.no_prompt_leak())
        # Custom business logic validation
        .check(Verify.custom(actor="0xABC...", method="validate_analysis"))
        .build(),
    # Other options
    timeout_blocks=100,
    tee_required=True
)

Chapter 7: Mixed Usage Patterns

7.1 Comprehensive Example

from cowboy_sdk import actor, runner, call, send, capture, Verify
from cowboy_sdk.types import SoftFloat

@actor
class TradingAgent:
    
    @runner.continuation(guard_unchanged=["user_balance"])
    async def hybrid_workflow(self, msg):
        """Demonstrates mixed usage of three primitives"""
        ctx = capture()
        
        # Step 1: Synchronous call to query state (T+0)
        ctx.balance = call(
            target="0x1111...",
            method="get_balance",
            args={"user": msg.user},
            cycles_limit=5000
        )
        
        # Step 2: Off-chain LLM analysis (T+N)
        ctx.analysis = await runner.llm(
            prompt=f"Should user with balance {ctx.balance} trade?",
            timeout_blocks=100,
            verification=Verify.builder()
                .mode("structured_match")
                .runners(3)
                .threshold(2)
                .check(Verify.json_schema_valid(TradeDecision.schema()))
                .build()
        )
        
        # Step 3: Decision based on analysis result
        if ctx.analysis.recommendation == "trade":
            # Synchronous call to execute trade (T+0, executes atomically in resumed transaction)
            # guard_unchanged ensures user_balance is unchanged
            call(
                target="0x2222...",
                method="execute_trade",
                args={"user": msg.user, "amount": ctx.balance // 2},
                cycles_limit=50000
            )
        
        # Step 4: Send notification (T+N, next block)
        send(
            target="0x3333...",
            message={
                "action": "trade_completed",
                "user": msg.user,
                "analysis": ctx.analysis.summary
            }
        )

Appendix A: PVM Compatibility Iron Rules

Developers must adhere to the following rules:
#RuleAlternative
1Prohibit import timeUse Block Height
2Prohibit import randomUse SDK-provided VRF interface
3Prohibit floatUse cowboy_sdk.types.SoftFloat
4Prohibit set()SDK automatically converts to ordered_set
5Prohibit pickleCross-block data must support CBOR
6call() depth limitCumulative max 32 levels, must explicitly pass cycles_limit
7await point limitSingle Continuation function max 8 sequential awaits
8await in loopsMust use @bounded_loop to declare iteration upper bound
9Continuation captureUse capture() to explicitly declare variables preserved across await
10send() is irrevocableComplete potentially failing call() first, then send() last

Appendix B: Call Semantics Quick Reference

B.1 Call Primitive Selection


Appendix C: Mechanism Comparison Table

MechanismPurposeScopeTiming
capture()Save local variablesWithin functionBefore and after await
guard_unchangedVerify storage state unchangedstorage keysOn Continuation resume
storage.guard()Fine-grained verification of single keySingle keyWhen accessing .value
@reentrancy_guardPrevent reentrancy attacksMethod levelMethod entry/exit
@bounded_loopLimit loop iterationsLoop blockDuring loop execution