Skip to main content
Status: Draft
Type: Standards Track
Category: Core
Created: 2025-10-02
Revised: 2026-03-09
Requires: CIP-1 (Actor Scheduler), CIP-3 (Fee Model)
This specification defines the verifiable, asynchronous off‑chain compute framework. Revision 2026-03-05 replaces the ring-buffer VRF selection with Fisher-Yates VRF + stake-weighted sortition, removes the skip_task mechanism in favor of timeout-based re-selection, and introduces the commit-reveal aggregator model for result verification. Revision 2026-03-09 expands §7 to reflect the implemented Entitlement type system (Scope / Action / Constraints / Role), updates the system actor table to include Secrets Manager (0x04) and TEE Verifier (0x05), and clarifies the Pool membership grant flow.

Abstract

This proposal introduces a framework for executing verifiable, off-chain computations within the Cowboy ecosystem. It defines a standardized, asynchronous protocol for smart contracts to request external data fetching, complex computations, or AI model inferences from a decentralized network of off-chain “Runners.” The architecture is built on a deterministic, stake-weighted VRF selection mechanism using Fisher-Yates shuffle, ensuring verifiable task assignment that is resistant to correlation attacks and Sybil manipulation. The core design philosophy emphasizes user-centric verification, task clarity through mandatory result schemas, and on-chain stability via a deferred transaction model for callbacks (per CIP-1).

Motivation

To unlock advanced use cases involving AI/ML, large datasets, or Web2 APIs, smart contracts need a secure and reliable bridge to the off-chain world. This CIP proposes a flexible and unopinionated approach, tailored for Cowboy’s on-chain Python environment, that addresses the limitations of existing oracle solutions. This framework empowers developers to:
  1. Integrate Complex Logic: Run Python-based AI model inferences or heavy computations off-chain.
  2. Preserve On-chain Stability: Utilize an asynchronous, deferred transaction model to prevent network congestion from off-chain interactions.
  3. Achieve Verifiable Decentralization: Leverage a stake-weighted VRF-based system to eliminate centralized schedulers and allow anyone to verify task assignments.
  4. Enforce Clarity: Mandate task and result schemas to reduce ambiguity and ensure Runners can reliably execute and be verified.
  5. Choose Their Trust Model: Allow developers to select the number of Runners, the verification mode, and optional Runner pool constraints for their specific application’s security needs.

Specification

The framework consists of seven on-chain System Actor components and the off-chain Runner network:
AddressSystem ActorRole
0x0000…0001Runner RegistryRunner registration, staking, capabilities, health, reputation
0x0000…0002Job DispatcherJob submission, VRF selection, job lifecycle management
0x0000…0003Result VerifierCommit-reveal aggregation, result verification, callback dispatch
0x0000…0004Secrets ManagerEncrypted secret storage and TEE-gated secret release
0x0000…0005TEE VerifierRemote attestation verification for TEE-based runners
0x0000…0007Entitlement RegistryPermission management: Runner Pool access control and general RBAC (see §7)
Off-chain RunnersExternal nodes that execute tasks and submit results
Address gap note: 0x0006 is reserved for future use.

1. Task Definition and Result Schema

Every off-chain task submission must include a result_schema. This mandatory payload defines the explicit output constraints of the task, enabling objective validation by the protocol. The schema defines at minimum:
  • max_return_bytes: Maximum result size to prevent gas-bombing attacks.
  • expected_execution_ms: Target execution time to help Runners assess feasibility.
  • data_format: Expected data structure (e.g., JSON schema, binary format).

2. Job Specification

Every job submitted to the Dispatcher carries a JobSpec:
FieldTypeDescription
job_idH256Unique job identifier
job_typeJobTypeLlm, Http, Mcp, or Custom
boundsResourceBoundsResource limits for execution
verificationVerificationConfigVerification mode and parameters
max_priceU256Maximum price (CBY wei)
tipU256Priority tip
timeout_blocksu64Timeout in blocks
callbackCallbackInfoCallback Actor and handler
submitterAddressSubmitting Actor’s address
submitted_atu64Submission block height
required_runner_poolOption<Vec<u8>>Optional: Pool ID bytes; only Runners holding a RunnerJoinPool(pool_id) Entitlement may be selected (§7)

3. Core Workflow (Asynchronous & Deferred)

Actor calls submit_job(job_spec)
    │  → Job Dispatcher (0x0000...0002)


1. Validate JobSpec (bounds ≠ 0, max_price > 0, timeout > 0)
2. Record submission_block → fixes the candidate snapshot
3. Generate VRF seed: seed = Keccak256(block_hash || "cowboy-runner-select-v2:" || job_id || submitted_at_le8)
4. Build candidate list (see §4)
5. Run Fisher-Yates VRF selection (see §5) → assign M runners
6. Store job → JobStatus::Assigned

Runner receives JobAssignment (see delivery note below)

    ├── Execute → submit commitment → Aggregator collects → submit_result
    └── Do not execute → job times out → automatic re-selection (see §6)

Result Verifier (0x0000...0003)
    └── Verify commitments → produce VerifiedResult → deferred callback to Actor

4. Runner Registry & Candidate List Construction

System Actor: 0x0000...0001 The Registry maintains runners with full registration data including stake (self‑bonded and delegated per CIP‑13), capabilities, rate card, health, and reputation. Candidate list construction (at submission_block):
  1. Health filter: HealthStatus::Healthy (heartbeat received within heartbeat_timeout_blocks)
  2. Reputation filter: reputation ≥ 50
  3. Capability filter: Runner supports the required JobType
  4. TEE filter: If tee_required, runner must declare TEE support (attestation verified by TEE Verifier 0x05)
  5. Price filter: Estimated cost ≤ job_spec.max_price
  6. Concurrency filter: active_jobs < max_concurrent_jobs
  7. Entitlement filter (optional): If required_runner_pool = Some(pool_id), the Entitlement Registry (0x07) must confirm that the runner holds an Entitlement with Scope::RunnerPool(pool_id) and Action::RunnerJoinPool(pool_id) that has not expired and has not exceeded its max_uses (see §7.3)
Candidate list ordering: After filtering, candidates are sorted ascending by address bytes to produce a globally consistent, deterministic ordered list. This sort is mandatory — different nodes must arrive at the identical ordered list. Candidate list snapshot: The filtered and sorted list is fixed at submission_block. Runners who join or leave after submission do not affect the selection for this job.

5. Verifiable Runner Selection (Fisher-Yates VRF)

The ring-buffer selection from the original CIP-2 draft (start_index + consecutive indices) is superseded by this specification. Ring-buffer selection is vulnerable to correlation attacks: an adversary controlling M adjacent positions in the list can guarantee selection for any M-runner job.
VRF Seed (deterministic, no private key required):
seed = Keccak256(
    block_hash              // consensus-fixed, globally consistent
    || "cowboy-runner-select-v2:"  // domain separator (prevents cross-context collisions)
    || job_id               // unique per job
    || submitted_at_le8     // 8-byte LE block height
)
block_hash is the hash of the submission_block, already fixed by consensus. No private key is required. block_hash is a public value — all nodes agree on it — so HMAC’s key-hiding property is unnecessary here. A domain separator achieves the same cross-context isolation.
Implementation note: Use cowboy_types::keccak256, which is the project-standard hash primitive (consistent with job ID generation, timer ID generation, token ID generation, etc.). The pvm_host::randomness() API uses HKDF-SHA256 specifically because it is a security-critical PRF exposed to Actor code; the runner selection seed has no such requirement.
Stake-Weighted Fisher-Yates Shuffle (select M from N):
candidates ← sorted filtered list (length N)
weights[i] ← stake_to_weight(candidates[i].stake)
   where stake_to_weight(s) = floor(log2(s / MIN_STAKE + 1)) + 1
   (logarithmic compression: 1024× stake → 11× weight, preventing whale monopoly)

current_seed ← seed
for i in 0..M:
    total_remaining ← sum(weights[i..N])
    hash ← Keccak256(current_seed || i_le8)
    r ← u64_from_le(hash[0..8]) mod total_remaining
    j ← weighted_pick(weights[i..N], r)   // cumulative weight lookup → O(N)
    swap(candidates[i], candidates[i+j])
    swap(weights[i], weights[i+j])
    current_seed ← hash

selected ← candidates[0..M].addresses
Properties:
  • Selected runners are pairwise independent: no two selections are correlated
  • Higher stake → higher probability of selection (Sybil resistance)
  • Same seed + same candidates always produces the same result (determinism)
  • Any node can independently verify the selection (verifiability)
On-chain verification (in Result Verifier and Job Dispatcher):
Given (candidates_snapshot, seed, M), re-run the above algorithm to verify that msg.sender is among the selected set. The candidates_snapshot Merkle root is stored at submission time.

6. Timeout-Based Re-selection (replaces skip_task)

The skip_task interface is removed from this revision. Explicit skipping creates adverse incentives (runners cherry-picking jobs) and unnecessary on-chain transactions. Timeout-based re-selection achieves the same liveness guarantee with better economic alignment.
Timeout re-selection protocol:
  1. Selected runners have timeout_blocks to submit their result commitment.
  2. If no commitment is received within timeout_blocks:
    • All timed-out runners receive reputation -= TIMEOUT_PENALTY (default: 5)
    • Dispatcher triggers re-selection:
      retry_seed = Keccak256(original_seed || "retry:" || retry_count_le4)
    • Timed-out runners are excluded from the retry candidate list
    • A new set of M runners is selected from the remaining candidates
  3. After MAX_RETRIES (default: 3) consecutive failures, the job transitions to Failed and reputation -= SLASH_THRESHOLD for persistent non-responders
  4. After SLASH_THRESHOLD consecutive slashes, a stake slash is triggered
Economic alignment: Not executing = passive timeout = reputation loss. Runners are incentivized to either execute or proactively avoid accepting jobs they cannot execute (by not heartbeating for job types they lack).

7. Entitlement Registry (0x0000…0007)

The Entitlement Registry is a system Actor that provides a unified, on-chain permission management layer. For this CIP, its primary role is Runner Pool access control (gating job assignments to a curated set of runners). It also provides general RBAC infrastructure consumed by other subsystems (Token admin delegation, Actor access control, etc.).
7.1 Core Type System
The Entitlement type system is defined in cowboy_types::entitlement:
/// Scope — defines the resource boundary a permission applies to
pub enum Scope {
    Global,                   // chain-wide
    Actor(Address),           // specific Actor
    Token([u8; 32]),          // specific Token (token_id)
    Runner(Address),          // specific Runner
    RunnerPool(Vec<u8>),      // Runner Pool membership (pool_id bytes)
    Namespace(Vec<u8>),       // custom named namespace
}

/// Action — defines the permitted operation (single action per Entitlement record)
pub enum Action {
    All,                                       // wildcard
    // Token actions
    TokenTransfer, TokenMint, TokenBurn,
    TokenFreeze, TokenSetHook, TokenTransferOwnership,
    // Actor actions
    ActorDeploy, ActorSendMessage,
    ActorExecuteHandler(Vec<u8>),              // handler name
    // Runner actions
    RunnerRegister { min_stake_override: Option<u128> },
    RunnerSubmitJob, RunnerSubmitResult,
    RunnerJoinPool(Vec<u8>),                   // pool_id — grants pool membership
    // System actions
    SystemTransfer, SystemCreateAccount, SystemUpgrade,
    // Custom
    Custom(Vec<u8>),
}

/// Constraints — use conditions attached to an Entitlement
pub struct Constraints {
    pub valid_from:          Option<u64>,   // block height activation
    pub valid_until:         Option<u64>,   // block height expiry
    pub max_uses:            u64,           // 0 = unlimited
    pub used_count:          u64,
    pub max_amount_per_use:  Option<u64>,
    pub max_total_amount:    Option<u64>,
    pub total_amount_used:   u64,
    pub rate_limit:          Option<RateLimit>,
    pub delegatable:         bool,
    pub delegation_depth_max: u8,           // max delegation chain depth (0 = non-delegatable)
    pub revocable:           bool,
}

/// A single Entitlement record (one scope + one action per record)
pub struct Entitlement {
    pub grantee:    Address,
    pub scope:      Scope,
    pub action:     Action,
    pub constraints: Constraints,
    pub parent_id:  Option<EntitlementId>,  // set when derived via delegation
}

pub type EntitlementId = [u8; 32];  // keccak256(grantee || scope || action || parent_id?)

/// Named permission set (RBAC role)
pub struct Role {
    pub name:        Vec<u8>,
    pub scopes:      Vec<Scope>,
    pub actions:     Vec<Action>,
    pub constraints: Constraints,
}
Design note: Each Entitlement record covers one (Scope, Action) pair. Granting multiple actions requires multiple Grant calls (or an AssignRole that references a Role collecting them). This keeps revocation granular: revoking one action does not affect others.
7.2 System Instructions (instruction numbers 30–39)
pub enum SystemInstruction {
    // ... existing instructions 0–29 ...

    // Entitlement instructions (30–39)
    EntitlementGrant {
        grantee:     Address,
        scope:       Vec<u8>,       // codec-encoded Scope
        action:      Vec<u8>,       // codec-encoded Action
        constraints: Vec<u8>,       // codec-encoded Constraints
    },                                              // 30
    EntitlementRevoke {
        entitlement_id: [u8; 32],
    },                                              // 31
    EntitlementDelegate {
        entitlement_id: [u8; 32],
        delegatee:      Address,
        constraints:    Vec<u8>,    // must be a strict subset of parent constraints
    },                                              // 32
    EntitlementCreateRole {
        name:          Vec<u8>,
        scopes:        Vec<u8>,     // codec-encoded Vec<Scope>
        actions:       Vec<u8>,     // codec-encoded Vec<Action>
        constraints:   Vec<u8>,
    },                                              // 33
    EntitlementAssignRole {
        role_id:  [u8; 32],
        assignee: Address,
    },                                              // 34
    EntitlementRevokeRole {
        role_id:  [u8; 32],
        assignee: Address,
    },                                              // 35
}
7.3 Runner Pool Membership Model
A Runner Pool is an on-chain access-control list identified by an arbitrary pool_id: Vec<u8>. Membership is represented as an Entitlement:
Scope::RunnerPool(pool_id) + Action::RunnerJoinPool(pool_id)
Grant flow:
Pool Owner / Governance

    └── EntitlementGrant {
            grantee:  <runner_address>,
            scope:    Scope::RunnerPool(pool_id),
            action:   Action::RunnerJoinPool(pool_id),
            constraints: Constraints {
                valid_until: Some(expiry_block),  // optional time-bound
                max_uses: 0,                      // unlimited while valid
                delegatable: false,               // pool membership is non-delegatable
                revocable: true,
                ..Default::default()
            },
        }
Candidate filter check (step 7 of §4, at submission_block):
EntitlementRegistry.check(
    grantee = runner_address,
    scope   = Scope::RunnerPool(required_runner_pool),
    action  = Action::RunnerJoinPool(required_runner_pool),
    at_block = submission_block,
) → bool
The check passes if at least one non-expired, non-exhausted Entitlement matching (grantee, scope, action) exists. used_count is not incremented by the membership check — only by explicit max_uses-bounded grants (e.g. one-time trial membership). Access modes enabled:
Moderequired_runner_poolEffect
Open (default)NoneAny qualified runner may be selected
WhitelistSome(trusted_pool_id)Only runners with pool Entitlement
Compliance-gatedSome(eu_tee_pool_id)Only runners with regional or TEE-certified Entitlement
Governance-curatedSome(dao_pool_id)DAO grants pool membership via on-chain governance
7.4 Gas Costs
OperationCyclesCells
EntitlementGrant5,000500
EntitlementRevoke2,000100
EntitlementDelegate3,000300
EntitlementCreateRole5,000500
EntitlementAssignRole / RevokeRole2,000100
Entitlement check (per candidate, §4 filter)5000
7.5 Safety Limits
LimitValueRationale
Max Entitlements per address256Prevent storage inflation
Max delegation chain depth5 (delegation_depth_max)Prevent unbounded chains
Max Roles per address64Bound role-expansion attacks
Lazy expiry cleanupOn-accessExpired Entitlements deleted on first check post-expiry
7.6 Backwards Compatibility
  1. Default pass-through: If the Entitlement Registry Actor does not exist, all checks return false (no runner is admitted via pool filter). Jobs with required_runner_pool = None are unaffected.
  2. Existing subsystems: Token owner == caller checks remain the first-priority gate; Entitlement provides an additional delegation path, not a replacement.
  3. Instruction numbering isolation: Instructions 30–39 do not conflict with existing instructions 0–29.

8. Result Submission: Commit-Reveal + Designated Aggregator

This revision replaces the original model (all runners independently submit full results to chain) with a commit-reveal + aggregator model that reduces on-chain Gas by ~(N-1)/N and prevents runners from copying each other’s results after seeing them.
Participants:
  • Runners: All M selected runners execute the job independently
  • Aggregator: The selected runner with the highest reputation (deterministic, ties broken by address order). Acts as the coordinator.
Submission flow:
Step 1 – Commit (all M runners, on-chain, low cost):
    commit(runner_addr, job_id, hash(result_bytes || runner_sig))
    Deadline: submitted_at + commit_deadline_blocks

Step 2 – Collect (Aggregator, off-chain):
    Aggregator receives result_bytes from other runners via direct HTTP push
    Aggregator runs verification logic (majority vote, structured match, etc.)

Step 3 – Reveal (Aggregator, on-chain):
    submit_verified_result(job_id, VerifiedResult, reveals[])
    reveals[i] = { runner, result_bytes, runner_sig }
    Aggregator receives a small bonus reward for honest aggregation

Step 4 – On-chain verification (Result Verifier):
    For each reveal[i]:
        assert hash(result_bytes || runner_sig) == stored_commitment[i]
        assert runner_sig is valid Ed25519 over result_bytes
    Run verification mode checks (MajorityVote, StructuredMatch, etc.)
    Produce VerifiedResult → trigger deferred callback (CIP-1)
Safety properties:
  • Runners cannot copy others’ results after seeing them (commitment pre-locks the result)
  • Aggregator cannot forge other runners’ results (commitment-bound)
  • Aggregator failure: other runners may submit individual reveals after aggregator_timeout_blocks; Result Verifier falls back to self-aggregation

9. Verification Modes

ModeRunnersDescription
None1No verification. Dev/test only.
EconomicBond1Single runner with economic stake bond.
MajorityVoteN ≥ 3Extract vote_field, majority wins with threshold.
StructuredMatchN ≥ 2Pipeline of checks: JsonSchema, field matching, numeric tolerance, numeric range, per-field majority.
DeterministicN ≥ 2Byte-identical match across all results. Requires tee_required = true for meaningful guarantees (LLM inference is inherently non-deterministic without TEE + fixed model hash). TEE attestation verified by TEE Verifier (0x05).
SemanticSimilarityN ≥ 3Cosine similarity clustering; largest cluster meeting threshold wins.

10. Randomness Evolution Path

The VRF seed source evolves across three phases without changing the selection algorithm or Actor-facing APIs:
PhaseSeed IKMSecurity Assumption
L1 (current)block_hash of submission_blockHonest supermajority of block proposers
L2 (near-term)block_vrf_output = EC-VRF output by block proposer (in block header)Honest block proposer per round
L3 (long-term)block_vrf_output = Threshold-BLS t-of-n beacont-of-n validators honest
The pvm_host.rs randomness API is designed for zero-Actor-code-change migration: only the IKM source switches internally.

Rationale

  • Fisher-Yates VRF over ring-buffer: The original ring-buffer selection (selecting N consecutive indices) is vulnerable to correlation attacks — an adversary controlling adjacent positions in the active list is always selected together for any N-runner job. Fisher-Yates with independent weighted draws eliminates this correlation entirely.
  • Stake-weighting: Equal-probability selection ignores stake, making Sybil attacks cheap (register 100 minimal-stake accounts = 100× chance). Logarithmic stake weighting provides proportional incentives without enabling whale monopoly.
  • No private key for VRF seed: Using Keccak256(block_hash || domain || job_id) instead of a dispatcher private key eliminates both the hardcoded-key security hole and the single-node selection bias. block_hash is consensus-fixed and globally consistent.
  • Timeout re-selection over skip_task: skip_task creates adverse incentives (runners selectively skip low-value jobs) and adds unnecessary on-chain transactions. Passive timeout with reputation penalty provides the same liveness guarantee with better economic alignment.
  • Commit-reveal aggregator: All-runners-submit to chain costs Gas × N and allows result copying. Commit-reveal prevents copying; designating the highest-reputation runner as aggregator is deterministic and Gas-efficient.
  • Deferred Transactions (CIP-1): Callbacks are delivered as deferred transactions, decoupling off-chain execution latency from the main chain execution path.
  • Mandatory Result Schemas: Creates a clear contract between developers and Runners, prevents gas-bombing, and simplifies result verification.
  • Entitlement as membership proof (not just a flag): Pool membership is represented as an on-chain Entitlement record (with expiry, rate-limit, delegatability) rather than a simple boolean flag. This allows time-bounded trial access, revocable membership, and delegation to sub-pools without protocol changes.
  • Single action per Entitlement record: Each record carries one (Scope, Action) pair. This enables fine-grained revocation without affecting co-granted actions, and keeps the membership check path O(1) per action.

Backwards Compatibility

This CIP is fully backwards compatible with the core protocol. System Actor addresses are unchanged (0x010x05, 0x07; 0x06 is reserved). The skip_task interface is removed; existing task submissions that relied on skip behavior will rely on timeout re-selection instead, which provides equivalent liveness guarantees.

Security Considerations

  • VRF Grinding: A submitter could attempt to choose submitted_at to influence the seed. Since seed = Keccak256(block_hash || domain || job_id || submitted_at) and block_hash is consensus-fixed before the submitter can observe it, this attack requires pre-computing the block hash — equivalent to breaking the consensus security assumption.
  • Stake Concentration: The logarithmic weight compression log2(effective_stake/MIN_STAKE + 1) + 1 limits the selection advantage of high-stake runners (including delegated stake per CIP‑13). A runner with 1024× the minimum effective stake gets only 11× the selection weight, not 1024×. MIN_STAKE = 10,000 CBY.
  • Aggregator Collusion: The Aggregator sees all results before submitting to chain. Mitigations: (1) other runners’ commitments are locked before Aggregator submits; (2) other runners can independently reveal if Aggregator is unresponsive; (3) Aggregator’s extra reward is forfeit if the submitted VerifiedResult is later challenged.
  • Active List Manipulation: Minimum stake (MIN_STAKE) and reputation threshold (min_reputation = 50) provide economic deterrence. The Entitlement pool mechanism (§7) allows job submitters to further restrict runner eligibility.
  • Callback Griefing: A malicious developer could write a callback that always fails, preventing runners from being paid. The failed_callbacks counter accrues as a reputational penalty, and runners may blacklist actors with high failure rates.
  • Historical Snapshot Integrity: The candidate list is fixed at submission_block. The Merkle root of the candidate list must be stored on-chain at submission time to enable re-selection verification. Runners who join or leave post-submission do not affect the snapshot.
  • Entitlement Inflation: Each address is limited to 256 active Entitlements and 64 assigned Roles. EntitlementGrant consumes 500 Cells to deter spam. Expired Entitlements are lazily cleaned on first post-expiry check.
  • Delegation Chain Depth: delegation_depth_max (max 5) and the requirement that delegated constraints be a strict subset of parent constraints prevent privilege escalation through chained delegation.
  • Pool Membership Expiry: Pool Entitlements with valid_until set are automatically ineligible after the specified block. Node operators running compliance-sensitive pools should set time-bounded grants and renew via governance.