From c3c738c0ac2a0bfd7abbb44aaa03cddd1016d5cf Mon Sep 17 00:00:00 2001 From: Kwame Bryan Date: Fri, 28 Nov 2025 06:17:26 -0500 Subject: [PATCH 1/6] ADD SIP: Agent Coordination Framework * A SIP for the Stacks ecosystem inspired by ERC-8001 --- .../contracts/agent-coordination.clar | 362 ++++++++++++++++++ sips/stacks-8001/sip-xxx.md | 154 ++++++++ 2 files changed, 516 insertions(+) create mode 100644 sips/stacks-8001/contracts/agent-coordination.clar create mode 100644 sips/stacks-8001/sip-xxx.md diff --git a/sips/stacks-8001/contracts/agent-coordination.clar b/sips/stacks-8001/contracts/agent-coordination.clar new file mode 100644 index 00000000..4ae3704e --- /dev/null +++ b/sips/stacks-8001/contracts/agent-coordination.clar @@ -0,0 +1,362 @@ +;; SIP-XXX Agent Coordination Contract +;; This contract implements the core logic of SIP-XXX: proposing intents, collecting acceptances, and state transitions. +;; +;; Constants (status codes and error codes) +(define-constant NONE u0) +(define-constant PROPOSED u1) +(define-constant READY u2) +(define-constant EXECUTED u3) +(define-constant CANCELLED u4) +(define-constant EXPIRED u5) + +(define-constant ERR_UNAUTHORISED u401) ;; Caller is not authorised to perform this action (e.g., not the initiator) +(define-constant ERR_NOT_FOUND u404) ;; Specified intent not found +(define-constant ERR_INVALID_STATE u405) ;; Operation not allowed in current state (e.g., accepting an already executed intent) +(define-constant ERR_INVALID_SIGNATURE u406) ;; Provided signature did not verify correctly +(define-constant ERR_DUPLICATE_PARTICIPANT u407) ;; Duplicate participant in list (violates uniqueness) +(define-constant ERR_NOT_PARTICIPANT u408) ;; Caller not in participants list +(define-constant ERR_ALREADY_ACCEPTED u409) ;; This participant’s acceptance already recorded +(define-constant ERR_EXPIRED u410) ;; Intent or acceptance has expired, action not allowed +(define-constant ERR_NONCE_NOT_HIGHER u411) ;; Nonce provided is not higher than the initiator’s previous nonce +(define-constant ERR_INVALID_PARAMS u412) ;; General invalid parameters (e.g., missing agent in list, unsorted list in strict implementation) + +;; Data maps +(define-map intents ((intent-hash (buff 32))) + ( + (agent principal) ;; initiator of the intent + (expiry uint) ;; expiration time for the intent + (nonce uint) ;; nonce of the intent (for initiator) + (coord-type (buff 32)) ;; coordination type identifier + (coord-value uint) ;; coordination value field + (participants (list 100 principal)) ;; list of participants' principals (unique, sorted) + (status uint) ;; current status code of the intent + (accept-count uint) ;; how many acceptances have been collected + ) +) + +(define-map last-nonce ((agent principal)) ((nonce uint))) ;; tracks the latest nonce used by each initiator + +(define-map acceptances ((intent-hash (buff 32)) (participant principal)) ((accept-expiry uint))) + +;; Private helper: convert a principal to a 21-byte buffer (version byte + 20-byte hash) +(define-private (principal->bytes (p principal)) + (let ((res (principal-destruct? p))) + (match res + err (unwrap-panic res) ;; principal-destruct? only errors if principal is invalid; unwrap-panic triggers if so + ok-data + (let ((version (tuple-get ok-data "version")) (hash-bytes (tuple-get ok-data "hash-bytes"))) + ;; concatenate version and hash (both buffers) to produce 21-byte representation + (concat version hash-bytes) + ) + ) + ) +) + +;; Private helper: check if a list of principals contains a given principal +(define-private (contains? (plist (list 100 principal)) (p principal)) + (if (is-eq plist (list)) + false + (let ((head (unwrap-panic (get 0 plist)))) + (or (is-eq head p) (contains? (list-drop-n 1 plist) p)) + ) + ) +) + +;; Private helper: check if a participants list has unique entries (no duplicates). +(define-private (is-unique-list (plist (list 100 principal))) + (if (is-eq plist (list)) + true + (let ( + (head (unwrap-panic (get 0 plist))) + (tail (list-drop-n 1 plist)) + ) + (if (contains? tail head) + false + (is-unique-list tail) + ) + ) + ) +) + +;; Compute an intent hash from given fields (simplified for reference: includes contract domain and key fields) +(define-private (compute-intent-hash (agent principal) (participants (list 100 principal)) (payload-hash (buff 32)) (expiry uint) (nonce uint) (coord-type (buff 32)) (coord-value uint)) + (keccak256 + (concat + (principal->bytes (as-contract tx-sender)) ;; domain: using contract's principal (current contract) - Clarity uses (as-contract tx-sender) as current contract principal + (principal->bytes agent) + payload-hash + ) + ) +) +;; NOTE: In a full implementation, the hash should include all fields (expiry, nonce, coord-type, coord-value, participants list, etc.) in a deterministic encoded form, +;; along with chain-id and contract name for domain separation. This simplified version only includes critical parts for brevity. + +;; Public function: propose a new intent +(define-public (propose-intent (agent principal) (participants (list 100 principal)) (expiry uint) (nonce uint) (coord-type (buff 32)) (coord-value uint) (payload-hash (buff 32))) + (begin + ;; Only the agent (initiator) can propose their intent + (if (not (is-eq agent tx-sender)) + (err ERR_UNAUTHORISED) + ) + ;; Participants list must contain the agent + (if (not (contains? participants agent)) + (err ERR_INVALID_PARAMS) ;; initiator not in participants list + ) + ;; Check participant list uniqueness (and sorting, if we enforce separately) + (if (not (is-unique-list participants)) + (err ERR_DUPLICATE_PARTICIPANT) + ) + ;; Nonce must be greater than last used nonce for this agent + (let ((prev-nonce (unwrap-or (map-get? last-nonce ((agent agent))) {nonce: u0}))) + (if (<= nonce (get nonce prev-nonce)) + (err ERR_NONCE_NOT_HIGHER) + ) + ) + ;; Expiry must be in the future (greater than current block time) + (let ((now (stacks-block-time))) + (if (<= expiry now) + (err ERR_EXPIRED) + ) + ) + ;; Compute unique intent hash (ID) + (let ((intent-hash (compute-intent-hash agent participants payload-hash expiry nonce coord-type coord-value))) + ;; Ensure not already used (i.e., not conflicting with an existing intent) + (if (map-get? intents ((intent-hash intent-hash))) + (err ERR_INVALID_PARAMS) ;; collision or reuse + ) + ;; Store the intent + (map-set intents + ((intent-hash intent-hash)) + ( + (agent agent) + (expiry expiry) + (nonce nonce) + (coord-type coord-type) + (coord-value coord-value) + (participants participants) + (status PROPOSED) + (accept-count u0) + ) + ) + ;; Update the initiator's last nonce + (map-set last-nonce ((agent agent)) ((nonce nonce))) + ;; Return the intent identifier + (ok intent-hash) + ) + ) +) + +;; Public function: accept an intent (participant provides their signature) +(define-public (accept-intent (intent-hash (buff 32)) (accept-expiry uint) (conditions (buff 32)) (signature (buff 65))) + (begin + ;; Ensure the intent exists + (let ((intent-data (map-get? intents ((intent-hash intent-hash))))) + (if (is-none intent-data) + (err ERR_NOT_FOUND) + ) + (let ( + (intent (unwrap intent-data ERR_NOT_FOUND)) + (now (stacks-block-time)) + ) + ;; Check intent status is Proposed (still collecting signatures) + (if (not (is-eq (get status intent) PROPOSED)) + (err ERR_INVALID_STATE) + ) + ;; Intent must not be expired + (if (> now (get expiry intent)) + (begin + ;; Mark as expired if past expiry + (map-set intents ((intent-hash intent-hash)) (tuple (agent (get agent intent)) (expiry (get expiry intent)) (nonce (get nonce intent)) (coord-type (get coord-type intent)) (coord-value (get coord-value intent)) (participants (get participants intent)) (status EXPIRED) (accept-count (get accept-count intent)))) + (err ERR_EXPIRED) + ) + ) + ;; The caller must be one of the participants + (if (not (contains? (get participants intent) tx-sender)) + (err ERR_NOT_PARTICIPANT) + ) + ;; Check if already accepted by this participant + (if (map-get? acceptances ((intent-hash intent-hash) (participant tx-sender))) + (err ERR_ALREADY_ACCEPTED) + ) + ;; The acceptance's expiry must not be in the past and not beyond intent expiry + (if (<= accept-expiry now) + (err ERR_EXPIRED) + ) + (if (> accept-expiry (get expiry intent)) + (err ERR_INVALID_PARAMS) ;; participant's acceptance expiry cannot exceed intent expiry + ) + ;; Verify the signature: recover the public key and derive principal + (let ( + ;; Prepare message hash for verification: we hash intent-hash + participant + their constraints + contract domain + (msg-hash (keccak256 + (concat + (principal->bytes (as-contract tx-sender)) + intent-hash + (principal->bytes tx-sender) + conditions + (buff 0) ;; Note: if we included accept-expiry and nonce in the signed data, we'd need to encode them here. + ) + )) + (recover-result (secp256k1-recover? msg-hash signature)) + ) + (match recover-result + err (err ERR_INVALID_SIGNATURE) + ok (let ((pubkey recover-result)) + (let ((derived-principal (principal-of? pubkey))) + (match derived-principal + err (err ERR_INVALID_SIGNATURE) + ok (let ((signer (unwrap derived-principal ERR_INVALID_SIGNATURE))) + ;; Check that the derived principal matches the tx-sender (the claimed participant) + (if (is-eq signer tx-sender) + (begin + ;; Record the acceptance + (map-set acceptances ((intent-hash intent-hash) (participant tx-sender)) ((accept-expiry accept-expiry))) + ;; Increment acceptance count + (map-set intents ((intent-hash intent-hash)) + (tuple + (agent (get agent intent)) + (expiry (get expiry intent)) + (nonce (get nonce intent)) + (coord-type (get coord-type intent)) + (coord-value (get coord-value intent)) + (participants (get participants intent)) + (status (get status intent)) + (accept-count (+ u1 (get accept-count intent))) + ) + ) + ;; If this was the last required acceptance, update status to Ready + (let ((new-count (+ u1 (get accept-count intent))) (total (len (get participants intent)))) + (if (>= new-count total) + (map-set intents ((intent-hash intent-hash)) (tuple + (agent (get agent intent)) + (expiry (get expiry intent)) + (nonce (get nonce intent)) + (coord-type (get coord-type intent)) + (coord-value (get coord-value intent)) + (participants (get participants intent)) + (status READY) + (accept-count new-count) + )) + ) + ) + (ok true) + ) + (err ERR_INVALID_SIGNATURE) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) +) + +;; Public function: execute an intent (mark as executed if ready) +(define-public (execute-intent (intent-hash (buff 32))) + (begin + (let ((intent-data (map-get? intents ((intent-hash intent-hash))))) + (if (is-none intent-data) + (err ERR_NOT_FOUND) + ) + (let ((intent (unwrap intent-data ERR_NOT_FOUND)) (now (stacks-block-time))) + ;; Only allow execution if status is Ready + (if (not (is-eq (get status intent) READY)) + (err ERR_INVALID_STATE) + ) + ;; Check current time against intent expiry + (if (> now (get expiry intent)) + (err ERR_EXPIRED) + ) + ;; Check that all acceptance attestations are still valid (not expired individually) + (let ((plist (get participants intent))) + (if (is-expired-acceptance? intent-hash plist now) + (err ERR_EXPIRED) + ) + ) + ;; (Business logic for executing the intent's action would go here, if applicable) + ;; Mark intent as executed + (map-set intents ((intent-hash intent-hash)) + (tuple + (agent (get agent intent)) + (expiry (get expiry intent)) + (nonce (get nonce intent)) + (coord-type (get coord-type intent)) + (coord-value (get coord-value intent)) + (participants (get participants intent)) + (status EXECUTED) + (accept-count (get accept-count intent)) + ) + ) + (ok true) + ) + ) + ) +) + +;; Public function: cancel an intent (initiator only) +(define-public (cancel-intent (intent-hash (buff 32))) + (begin + (let ((intent-data (map-get? intents ((intent-hash intent-hash))))) + (if (is-none intent-data) + (err ERR_NOT_FOUND) + ) + (let ((intent (unwrap intent-data ERR_NOT_FOUND))) + ;; Only initiator can cancel + (if (not (is-eq (get agent intent) tx-sender)) + (err ERR_UNAUTHORISED) + ) + ;; Only allow cancellation if not already executed or cancelled + (if (or (is-eq (get status intent) EXECUTED) (is-eq (get status intent) CANCELLED)) + (err ERR_INVALID_STATE) + ) + ;; Mark as cancelled + (map-set intents ((intent-hash intent-hash)) + (tuple + (agent (get agent intent)) + (expiry (get expiry intent)) + (nonce (get nonce intent)) + (coord-type (get coord-type intent)) + (coord-value (get coord-value intent)) + (participants (get participants intent)) + (status CANCELLED) + (accept-count (get accept-count intent)) + ) + ) + (ok true) + ) + ) + ) +) + +;; Read-only function: get the status code of an intent +(define-read-only (get-coordination-status (intent-hash (buff 32))) + (let ((intent-data (map-get? intents ((intent-hash intent-hash))))) + (if (is-none intent-data) + (err ERR_NOT_FOUND) + (ok (get status (unwrap intent-data ERR_NOT_FOUND))) + ) + ) +) + +;; Private helper: check if any acceptance has expired at a given time +(define-private (is-expired-acceptance? (intent-hash (buff 32)) (plist (list 100 principal)) (current-time uint)) + (if (is-eq plist (list)) + false + (let ((participant (unwrap-panic (get 0 plist)))) + (let ((acc-data (map-get? acceptances ((intent-hash intent-hash) (participant participant))))) + (if (is-none acc-data) + false ;; if a participant hasn't accepted, from perspective of execution readiness this function might not be called because status wouldn't be Ready. + (let ((acc (unwrap acc-data (err ERR_NOT_FOUND)))) + (if (> current-time (get accept-expiry acc)) + true + (is-expired-acceptance? intent-hash (list-drop-n 1 plist) current-time) + ) + ) + ) + ) + ) + ) +) diff --git a/sips/stacks-8001/sip-xxx.md b/sips/stacks-8001/sip-xxx.md new file mode 100644 index 00000000..241b6309 --- /dev/null +++ b/sips/stacks-8001/sip-xxx.md @@ -0,0 +1,154 @@ +# Preamble + +SIP Number: XXX +Title: Standard for Multi-Party Agent Coordination +Author: [Author Name] () +Consideration: Technical +Type: Standard +Status: Draft +Created: 28 November 2025 +Licence: CC0-1.0 (Creative Commons Zero v1.0 Universal) +Sign-off: *(pending)* + +# Abstract + +This proposal introduces a standard primitive for secure coordination among multiple independent agents on the Stacks blockchain. It defines an **intent** message format and protocol by which an initiator posts a desired action (the intent) and other participants submit cryptographic acceptances. The intent becomes **executable** once all required participants have provided acceptance attestations before a specified expiry. This standard specifies the data structures, canonical status codes, Clarity contract interface, and rules needed to implement this coordination framework on Stacks. It leverages off-chain **signed structured data** (per SIP-018) and on-chain verification using Clarity’s cryptographic functions. By standardising multi-party approval workflows, SIP-XXX enables trust-minimised coordination in use cases such as multi-sig transactions, decentralised MEV mitigation strategies, and cross-contract agent actions, all using a common protocol. + +# Licence and Copyright + +This SIP is released under the terms of the **Creative Commons CC0 1.0 Universal** licence:contentReference[oaicite:17]{index=17}. By contributing to this SIP, authors agree to dedicate their work to the public domain. The Stacks Open Internet Foundation holds copyright for this document. + +# Introduction + +As decentralised applications and autonomous agents become more complex, there are many scenarios where a group of independent actors must agree on an action before it is executed. Examples include multi-signature wallet approvals, collaborative trades or arbitrage across DEXs, and MEV (Maximal Extractable Value) mitigation where solvers and bidders coordinate on transaction ordering. In current practice, these often rely on bespoke protocols or off-chain agreements, leading to fragmentation and potential security risks. + +On Ethereum, the concept of *intents* has emerged to express desired actions in a chain-agnostic way, but earlier standards (like ERC-7521 and ERC-7683) handled only single-initiator flows:contentReference[oaicite:18]{index=18}. Ethereum’s recent ERC-8001 filled this gap by introducing a minimal coordination primitive for multiple parties:contentReference[oaicite:19]{index=19}. This SIP adapts ERC-8001’s approach to Stacks, taking into account Clarity’s design and existing SIPs (e.g. SIP-018 for signing data). + +The key idea is that an initiator can propose an intent which enumerates all participants who need to agree. Each participant (including the initiator) produces a digital signature (an **acceptance attestation**) to confirm their agreement under certain conditions. These signatures are collected on-chain. If and only if every listed party’s attestation is present and valid within the allowed time window, the intent is marked as ready to execute. This guarantees that the intended action has unanimous approval from the required set of agents, without needing an off-chain coordinator to aggregate trust. + +Privacy and advanced policies (like threshold k-of-n approvals, bond posting, or cross-chain intents) are intentionally **out of scope** for this base standard:contentReference[oaicite:20]{index=20}:contentReference[oaicite:21]{index=21}. The goal is to establish a simple, extensible on-chain core that other modules and protocols can build upon for added functionality. + +# Specification + +The keywords “MUST”, “SHOULD”, and “MAY” in this document are to be interpreted as described in RFC 2119. + +## Status Codes + +Implementations MUST use the following canonical status codes for each intent’s lifecycle state:contentReference[oaicite:22]{index=22}: + +- `None` (`0`): No record of the intent (default state before proposal). +- `Proposed` (`1`): Intent has been proposed and stored, but not all required acceptances are yet present. +- `Ready` (`2`): **All participants have accepted.** The intent is fully signed and can be executed. +- `Executed` (`3`): Intent was executed successfully (finalised outcome). +- `Cancelled` (`4`): Intent was explicitly cancelled by the initiator and will not execute. +- `Expired` (`5`): Intent expired before execution. + +A compliant contract MUST provide a read-only function (e.g. `get-coordination-status(intentId)`) that returns one of these status codes for a given intent. External tools and UI can use these codes to inform users of the intent’s state. + +## Data Structures + +**Agent Intent:** The core message posted by an initiator describing the coordination request. It is a tuple of fields: +- `payloadHash` (`buff 32`): A hash (e.g. SHA-256 or KECCAK256) of the detailed payload of the intent. The payload can include domain-specific instructions or data for execution, but is not interpreted by the core contract (opaque to this SIP). +- `expiry` (`uint`): A Unix timestamp (in seconds) by which the intent expires. The intent cannot be executed after this time. It MUST be set to a future time when proposing and is used to determine *Expired* status. +- `nonce` (`uint`): A monotonic sequence number for intents per initiator (agent). This provides replay protection – each new intent from the same agent MUST use a `nonce` greater than their previous intents’ nonces:contentReference[oaicite:23]{index=23}. +- `agentId` (`principal`): The Stacks principal of the initiator (the one proposing the intent). This principal must match the transaction sender that creates the intent on-chain. +- `coordinationType` (`buff 32`): An application-specific identifier for the type or context of this coordination. For example, it could be the hash of a string like `"MEV_SANDWICH_V1"` or `"MULTISIG_TXN"` to indicate how the payload should be interpreted by off-chain actors. +- `coordinationValue` (`uint`): An optional value field (e.g. an amount in micro-STX or an abstract value) that is informational for the core protocol. The core standard does not assign meaning to this field, but higher-level modules MAY use it (for example, to require a bond or to encode an expected payment amount). +- `participants` (`list(principal)`): The list of all participants’ principals involved in this intent, **including the initiator** (`agentId`). This list MUST be strictly ascending (sorted) by principal and contain no duplicates:contentReference[oaicite:24]{index=24}:contentReference[oaicite:25]{index=25}. Ordering the addresses canonically ensures everyone computes the same intent hash and prevents duplicate signers. + +**Acceptance Attestation:** A participant’s acceptance of an intent. It is represented by: +- `intentHash` (`buff 32`): The hash of the Agent Intent that the participant is agreeing to. (See **Signature Semantics** below for how this hash is computed). +- `participant` (`principal`): The participant’s principal (the signer of this attestation). +- `nonce` (`uint`): An optional nonce for the acceptance. In the core standard, this MAY be omitted or set to `0` for simplicity. (In extended use, participants could use a personal nonce to prevent replay of their acceptance across different similar intents, but that is not required here). +- `expiry` (`uint`): The timestamp until which this acceptance is valid. This allows a participant to impose an earlier deadline than the intent’s overall expiry. The attestation is only valid to execute the intent if the current time is <= this expiry. Typically, participants set this equal to or slightly less than the intent’s `expiry` to ensure timely execution. +- `conditionsHash` (`buff 32`): A hash of any participant-specific conditions for their acceptance. This field is optional and not interpreted by the base contract logic. It might encode constraints like “price must be above X” or other domain-specific requirements that the participant expects to be true at execution. If no extra conditions, this can be a zero hash (all 0x00 bytes). +- `signature` (`buff 64/65`): The participant’s digital signature over the intent. This is the Secp256k1 ECDSA signature (65 bytes including recovery ID, or 64-byte compact form per EIP-2098) that proves the participant indeed signed the `intentHash` (and associated domain). + +**Coordination Payload:** (Optional in core) The full data that `payloadHash` represents. The structure of this payload is outside the scope of SIP-XXX, as it is application-specific. However, by convention it could include fields like `version` (a format identifier), `coordinationType` (MUST equal the above type for redundancy), `coordinationData` (opaque binary or structured commands to execute), `conditionsHash` (the combined conditions for execution), `timestamp` (when the intent was created), and `metadata`. These are not processed by the core contract, but hashing them into `payloadHash` ensures that all participants are agreeing to the exact same details. + +## Signature Semantics and Domain Separation + +All signatures in this protocol MUST be made over a well-defined message that includes a domain separator specific to this SIP and the current contract: +- The initiator’s signature covers the **Agent Intent**. Off-chain, the initiator SHOULD sign a digest computed as `H = keccak256(domain, AgentIntent)` or similar, where `domain` binds the network (mainnet/testnet), the SIP number, and the contract address (including contract name):contentReference[oaicite:26]{index=26}. This prevents an intent for one contract or chain from being re-used on another. The contract’s Clarity code can reconstruct the expected `intentHash` on-chain to verify any signatures. +- Each participant’s **Acceptance Attestation** signature covers their `intentHash` plus their own constraints. In practice, a participant would sign a message encoding: the `intentHash` (linking to a specific intent), their `participant` address, optional `nonce`, `expiry`, and `conditionsHash`, along with the same domain separator. This yields a 32-byte hash that is then signed via Secp256k1. +- Clarity’s `secp256k1-verify` or `secp256k1-recover?` functions are used to verify these signatures on-chain. A compliant implementation MUST support 65-byte signatures with low-S values and SHOULD support 64-byte compact signatures:contentReference[oaicite:27]{index=27}. If a signature’s recovery byte is present, the contract will use it to recover the public key and derive the signing principal (via `principal-of?`); otherwise, the contract can verify directly given a provided public key. +- **Stacks Signed Message Prefix:** Implementations SHOULD prepend the standard `"Stacks Signed Message:\n"` prefix (as defined in SIP-018) when computing signature hashes for off-chain signing:contentReference[oaicite:28]{index=28}. However, since SIP-018 primarily covers personal messages, the use of a structured EIP-712-like approach with an explicit domain as described is RECOMMENDED for clarity and to avoid ambiguities. + +By following these semantics, any signature collected under this standard is tightly bound to the specific intent and contract, mitigating replay attacks across contexts. + +## Standard Contract Interface (Clarity) + +An implementing smart contract MUST provide public functions roughly as follows (names are illustrative): + +- `(define-public (propose-intent (intent )) (response (buff 32) uint))` + Creates a new intent on-chain. Accepts the intent fields (or a struct/tuple) as parameters. On success, stores the intent and returns a unique identifier (e.g. the `intentHash`). The function MUST verify that: + - `intent.agentId` matches the `tx-sender` (only the initiator can propose their intent). + - The `participants` list includes `agentId` and is sorted and without duplicates. + - `intent.nonce` is strictly greater than the last used nonce for this `agentId` (to prevent reuse). + - `intent.expiry` is in the future (greater than current time). + If these checks pass, the intent is recorded (e.g. in a map from `intentHash` to intent data) with status `Proposed`:contentReference[oaicite:29]{index=29}. It also initialises tracking for acceptances (e.g. zero accepted count). If any check fails, it returns an error code and does not create the intent. +- `(define-public (accept-intent (intent-hash (buff 32)) (sig (buff 65)) [optional pubkey/fields])) (response bool uint))` + Records a participant’s acceptance for the given intent. The participant calling this function (`tx-sender`) is implicitly the accepting principal. The contract will: + - Look up the intent by `intent-hash`. If not found, return an error (intent doesn’t exist). + - Check that the intent’s status is `Proposed` (only accept if still gathering signatures). + - Verify that `tx-sender` is indeed one of the intent’s `participants` and that they have not already accepted. + - Verify the provided `sig` using `tx-sender`’s public key or by recovering it. The signature must be valid ECDSA over the expected acceptance message (containing `intentHash` and the participant’s constraints). If the contract requires the participant to also supply their `expiry` or `conditionsHash`, it must check those values too against what was signed. + - Check that neither the intent nor the acceptance is expired at the current time. + On success, the acceptance is recorded (e.g. mark this participant as having signed, increment a counter) and if this was the last required acceptance, update the intent’s status to `Ready`. The function returns `(ok true)` on success. If any verification fails, it returns an error code. +- `(define-public (execute-intent (intent-hash (buff 32)) (payload )) (response bool uint))` + Marks a ready intent as executed. This would typically be called by a designated executor (which could be one of the participants or any party, depending on the use case) when it performs the action described in the intent’s payload. The contract MUST verify: + - The intent exists and has status `Ready`. + - The current time is <= intent’s expiry and all acceptance expiries (i.e., not too late to execute). + - (Optionally, the provided `payload` matches the stored `payloadHash` to ensure the actual execution details correspond to what was agreed. Often the payload execution happens off-chain or in another contract, so this might not be applicable in every implementation.) + On success, the contract sets the status to `Executed` and returns true. Typically, the actual business logic (transferring funds, etc.) is executed off-chain or by another contract that coordinates with this one — SIP-XXX’s reference implementation only handles the state change and verification, not the actual fulfilment of the intent’s action. +- `(define-public (cancel-intent (intent-hash (buff 32))) (response bool uint))` + Allows the initiator (and **only** the initiator) to cancel an intent that is not yet executed. This function: + - Verifies `tx-sender` equals the intent’s `agentId`. + - If the intent is still `Proposed` or `Ready` (i.e., not executed/expired), it sets status to `Cancelled`. (Once cancelled, any future accept or execute calls for that intent should fail.) + Returns true on successful cancellation. Cancellation is useful if the initiator wants to abort the process (for example, if conditions changed or a mistake was made), even if some signatures have already been collected. Participants can also implicitly “cancel” by simply not signing, but this formal cancel allows reclaiming of resources or clearing intents. + +- `(define-read-only (get-coordination-status (intent-hash (buff 32))) (response uint uint))` + Returns the current status code (0–5 as defined above) of the given intent, or an error if the intent is not found. This is used by off-chain clients or other contracts to poll the state of an intent. + +The above interface is an example; the actual function names and parameters may vary, but any SIP-XXX compliant contract **MUST** provide equivalent functionality. + +## Lifecycle Rules + +An implementation of SIP-XXX MUST enforce the following lifecycle: + +1. **Proposal:** An initiator calls `propose-intent` to register a new intent on-chain. Initially, its status is `Proposed`. At this point, no acceptances are present. The initiator’s signature on the intent (off-chain) is assumed by virtue of them calling the function (the transaction itself confirms their intent). +2. **Acceptance:** Each participant (including possibly the initiator, if the design requires a separate acceptance from them) calls `accept-intent` with their signature. These can happen in any order. The contract verifies each signature and records it. Participants MAY also provide their acceptance via an off-chain aggregator who then submits them in one transaction, but each acceptance must be individually verifiable on-chain. As acceptances come in, the contract may emit events or simply allow querying of how many acceptances are collected. When the final required acceptance is received, the contract SHOULD update the status to `Ready`. +3. **Execution:** Once an intent is `Ready`, it can be executed. Execution might be triggered by a call to `execute-intent`. In some designs, the same transaction that calls `execute-intent` could also carry out the intended action (e.g., via a payload or by triggering another contract, if the intent’s action is encoded in Clarity). The core contract itself does not mandate how the intent’s action is executed – it only tracks the state. After execution, the status becomes `Executed`. Only one execution is allowed; subsequent calls should be rejected or be no-ops. +4. **Cancellation:** At any time before execution (and before expiry), the initiator can cancel the intent, moving it to `Cancelled`. This halts the process and invalidates any collected signatures for that intent. +5. **Expiration:** If the current time passes the intent’s `expiry` (or any acceptance’s `expiry` if earlier), the intent is considered expired. A contract may implement this by not allowing execution after expiry and marking the status as `Expired` when queried. Expiration does not require an explicit transaction; it’s a state that arises from time passing. However, to be reflected on-chain (for example, if one wants to emit an event or prevent further actions), an explicit check is needed in functions like `accept-intent` and `execute-intent`. Once expired, an intent cannot reach `Ready` if it wasn’t already, and certainly cannot be executed. A new intent would have to be proposed if the parties still wish to proceed. + +These rules ensure a coherent flow: intents move forward to execution or terminate via cancellation/expiry, but do not revert backwards in state. + +## Backwards Compatibility + +This SIP does not alter any existing Stacks consensus rules or contract standards. It is an additive standard. There is no direct predecessor in Stacks that it must remain compatible with (the concept is new to Stacks, though inspired by Ethereum). + +One consideration: SIP-018 (Structured Data Signing) should be compatible with this SIP’s approach to ensure wallets and tools can sign the required messages. This proposal assumes SIP-018 or an equivalent is available to provide the signing prefix and domain as needed. + +## Security Considerations + +**Replay Prevention:** By using initiator-specific nonces for intents and including the contract’s identity in the signed message, this protocol prevents signatures from one context being reused in another:contentReference[oaicite:30]{index=30}. Each initiator’s `nonce` ensures they (and their wallet software) won’t accidentally reuse an intent message, and domain separation (SIP number, contract address, chain id) ensures an intent on Stacks mainnet contract “X” cannot be executed on a testnet or a different contract “Y”. + +**Signature Verification and Malleability:** Implementations must use Clarity’s crypto functions correctly to avoid accepting forged signatures. Only acceptances that produce a valid recoverable public key matching the participant’s address should be counted. Low-S requirement (as enforced by most Secp256k1 libraries) should be ensured:contentReference[oaicite:31]{index=31} – if using `secp256k1-verify`, it returns false for high-S signatures, and if using recovery, the contract should reject any signature that does not pass verification. Both 64-byte and 65-byte signatures should be accepted to accommodate different wallet implementations (per EIP-2098 compressed form). + +**Timeliness (Expiry):** The expiry mechanism is crucial for safety. Without expiries, an old intent could linger and potentially be executed much later under different conditions, or a participant’s acceptance could be “banked” and used when they no longer intend. By expiring intents, we limit this risk. However, note that the contract cannot automatically remove an expired intent without a transaction; it can only prevent further actions. It is up to clients or a scheduled off-chain service to clean up or notify about expired intents. Parties should choose reasonable expiry times – long enough to gather signatures and execute, but short enough to limit risk exposure. + +**Partial Signatures / Equivocation:** The protocol does not stop a malicious participant from signing multiple intents (equivocation) hoping only one gets executed. If a participant does so and two intents both become ready, an executor might waste resources preparing both. This is an application-level concern; modules can add penalties or reputation tracking to discourage such behaviour:contentReference[oaicite:32]{index=32}. The core simply treats each intent separately. It is RECOMMENDED that when this standard is used in economic protocols, there are additional incentives (like slashing or deposits) to align participants’ behaviour. + +**Front-Running and MEV:** Because intents in this standard are posted on-chain in a public contract, a malicious observer could potentially see a `Proposed` intent and attempt to front-run the eventual action. However, since the intent can only be executed with all signatures and after a certain time, the window for exploitation is limited. For greater privacy, participants might delay broadcasting their acceptances until execution is imminent, or use a commit-reveal scheme where only hashes of signatures are posted initially. Those techniques are outside SIP-XXX’s scope but can be layered on. In environments with high MEV risk, consider encrypting the payload off-chain and only revealing it at execution time:contentReference[oaicite:33]{index=33}. + +## Reference Implementation + +A reference implementation of this standard is provided in the accompanying file: [`contracts/agent-coordination.clar`](contracts/agent-coordination.clar). This Clarity contract illustrates one way to realise SIP-XXX. It uses: +- A map to store proposed intents (keyed by a 32-byte intent hash). +- A map to track each initiator’s latest nonce (to enforce monotonic nonces). +- Functions `propose-intent`, `accept-intent`, `cancel-intent`, `execute-intent`, and getters for status, closely following the interface described above. +- Signature verification via `secp256k1-recover?` to derive the signer’s public key and then `principal-of?` to get the corresponding principal, which is compared to the claimed participant. +- Checks for sorted participants and expiry conditions. + +Developers can refer to this implementation as a starting point for their own contracts. Note that depending on the use case, you may need to adjust data types (e.g. use SHA-256 instead of KECCAK, or handle different payload schemas). The reference code is provided under CC0 licence for maximum reuse. From 606812988d448573e0b6611045e84449f39e12ed Mon Sep 17 00:00:00 2001 From: Kwame Bryan Date: Fri, 28 Nov 2025 10:36:57 -0500 Subject: [PATCH 2/6] Update sip-xxx.md --- sips/stacks-8001/sip-xxx.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sips/stacks-8001/sip-xxx.md b/sips/stacks-8001/sip-xxx.md index 241b6309..9c01c866 100644 --- a/sips/stacks-8001/sip-xxx.md +++ b/sips/stacks-8001/sip-xxx.md @@ -2,7 +2,7 @@ SIP Number: XXX Title: Standard for Multi-Party Agent Coordination -Author: [Author Name] () +Author: [Kwame Bryan] () Consideration: Technical Type: Standard Status: Draft From 47cd4bfdcb21e05f24c32283ab3c8115588bbe2d Mon Sep 17 00:00:00 2001 From: Kwame Bryan Date: Fri, 5 Dec 2025 14:49:08 -0500 Subject: [PATCH 3/6] Update 8001 Contracts and Tests * Update contracts based on whoabuddy's template. * I want to take advantage of Clarity 4's timestamp. I will need to return to this later. --- .idea/workspace.xml | 59 ++ .../contracts/agent-coordination.clar | 917 ++++++++++++------ sips/stacks-8001/tests/erc-8001.test.ts | 527 ++++++++++ 3 files changed, 1182 insertions(+), 321 deletions(-) create mode 100644 .idea/workspace.xml create mode 100644 sips/stacks-8001/tests/erc-8001.test.ts diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 00000000..2dc96af0 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,59 @@ + + + + + + + + + + { + "associatedIndex": 5 +} + + + + { + "keyToString": { + "ModuleVcsDetector.initialDetectionPerformed": "true", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.git.unshallow": "true", + "git-widget-placeholder": "sip/8001Stacks", + "node.js.detected.package.eslint": "true", + "node.js.selected.package.eslint": "(autodetect)", + "node.js.selected.package.tslint": "(autodetect)", + "nodejs_package_manager_path": "npm", + "vue.rearranger.settings.migration": "true" + } +} + + + + + + + + + + 1764344113560 + + + + + + \ No newline at end of file diff --git a/sips/stacks-8001/contracts/agent-coordination.clar b/sips/stacks-8001/contracts/agent-coordination.clar index 4ae3704e..0d62cb86 100644 --- a/sips/stacks-8001/contracts/agent-coordination.clar +++ b/sips/stacks-8001/contracts/agent-coordination.clar @@ -1,362 +1,637 @@ -;; SIP-XXX Agent Coordination Contract -;; This contract implements the core logic of SIP-XXX: proposing intents, collecting acceptances, and state transitions. +;; ERC-8001 Agent Coordination Protocol - Stacks Implementation ;; -;; Constants (status codes and error codes) -(define-constant NONE u0) -(define-constant PROPOSED u1) -(define-constant READY u2) -(define-constant EXECUTED u3) -(define-constant CANCELLED u4) -(define-constant EXPIRED u5) - -(define-constant ERR_UNAUTHORISED u401) ;; Caller is not authorised to perform this action (e.g., not the initiator) -(define-constant ERR_NOT_FOUND u404) ;; Specified intent not found -(define-constant ERR_INVALID_STATE u405) ;; Operation not allowed in current state (e.g., accepting an already executed intent) -(define-constant ERR_INVALID_SIGNATURE u406) ;; Provided signature did not verify correctly -(define-constant ERR_DUPLICATE_PARTICIPANT u407) ;; Duplicate participant in list (violates uniqueness) -(define-constant ERR_NOT_PARTICIPANT u408) ;; Caller not in participants list -(define-constant ERR_ALREADY_ACCEPTED u409) ;; This participant’s acceptance already recorded -(define-constant ERR_EXPIRED u410) ;; Intent or acceptance has expired, action not allowed -(define-constant ERR_NONCE_NOT_HIGHER u411) ;; Nonce provided is not higher than the initiator’s previous nonce -(define-constant ERR_INVALID_PARAMS u412) ;; General invalid parameters (e.g., missing agent in list, unsorted list in strict implementation) - -;; Data maps -(define-map intents ((intent-hash (buff 32))) - ( - (agent principal) ;; initiator of the intent - (expiry uint) ;; expiration time for the intent - (nonce uint) ;; nonce of the intent (for initiator) - (coord-type (buff 32)) ;; coordination type identifier - (coord-value uint) ;; coordination value field - (participants (list 100 principal)) ;; list of participants' principals (unique, sorted) - (status uint) ;; current status code of the intent - (accept-count uint) ;; how many acceptances have been collected +;; Trustless multi-party coordination for AI agents and humans. +;; Flow: PROPOSE - ACCEPT (signatures) - EXECUTE or CANCEL +;; +;; Clarity 3 (uses block-height for expiry) +;; Block time: ~10 min. 1 hour = 6 blocks, 1 day = 144 blocks + +;; ============================================================================= +;; CONSTANTS +;; ============================================================================= + +;; Coordination States +(define-constant STATE_NONE u0) +(define-constant STATE_PROPOSED u1) +(define-constant STATE_READY u2) +(define-constant STATE_EXECUTED u3) +(define-constant STATE_CANCELLED u4) +(define-constant STATE_EXPIRED u5) + +;; Error Codes +(define-constant ERR_UNAUTHORIZED (err u100)) +(define-constant ERR_NOT_FOUND (err u101)) +(define-constant ERR_INVALID_STATE (err u102)) +(define-constant ERR_INVALID_SIGNATURE (err u103)) +(define-constant ERR_NOT_PARTICIPANT (err u104)) +(define-constant ERR_ALREADY_ACCEPTED (err u105)) +(define-constant ERR_INTENT_EXPIRED (err u106)) +(define-constant ERR_NONCE_TOO_LOW (err u107)) +(define-constant ERR_INVALID_PARTICIPANTS (err u108)) +(define-constant ERR_ACCEPTANCE_EXPIRED (err u109)) +(define-constant ERR_PAYLOAD_MISMATCH (err u110)) +(define-constant ERR_SERIALIZATION_FAILED (err u111)) +(define-constant ERR_DUPLICATE_INTENT (err u112)) + +;; SIP-018 Structured Data Signing +(define-constant SIP018_PREFIX 0x534950303138) +(define-constant DOMAIN_NAME "ERC-8001-Agent-Coordination") +(define-constant DOMAIN_VERSION "1") +(define-constant MSG_TYPE_INTENT "AgentIntent") +(define-constant MSG_TYPE_ACCEPTANCE "AcceptanceAttestation") + +;; ============================================================================= +;; DATA MAPS +;; ============================================================================= + +(define-map intents + { intent-hash: (buff 32) } + { + agent: principal, + payload-hash: (buff 32), + expiry: uint, + nonce: uint, + coordination-type: (buff 32), + coordination-value: uint, + participants: (list 20 principal), + status: uint, + accept-count: uint + } +) + +(define-map agent-nonces + { agent: principal } + uint +) + +(define-map acceptances + { intent-hash: (buff 32), participant: principal } + { + expiry: uint, + conditions: (buff 32) + } +) + +;; ============================================================================= +;; SIP-018 HASHING +;; ============================================================================= + +(define-read-only (get-domain-tuple) + { + name: DOMAIN_NAME, + version: DOMAIN_VERSION, + chain-id: chain-id + } +) + +(define-read-only (get-domain-hash) + (sha256 (unwrap-panic (to-consensus-buff? (get-domain-tuple)))) +) + +(define-private (compute-structured-data-hash (message-buff (buff 8192))) + (sha256 + (concat SIP018_PREFIX + (concat (get-domain-hash) (sha256 message-buff)) ) + ) ) -(define-map last-nonce ((agent principal)) ((nonce uint))) ;; tracks the latest nonce used by each initiator +;; ============================================================================= +;; INTENT HASH COMPUTATION +;; ============================================================================= -(define-map acceptances ((intent-hash (buff 32)) (participant principal)) ((accept-expiry uint))) +(define-private (make-intent-message + (payload-hash (buff 32)) + (expiry uint) + (nonce uint) + (agent principal) + (coordination-type (buff 32)) + (coordination-value uint) + (participants (list 20 principal))) + { + msg-type: MSG_TYPE_INTENT, + payload-hash: payload-hash, + expiry: expiry, + nonce: nonce, + agent: agent, + coordination-type: coordination-type, + coordination-value: coordination-value, + participants: participants + } +) -;; Private helper: convert a principal to a 21-byte buffer (version byte + 20-byte hash) -(define-private (principal->bytes (p principal)) - (let ((res (principal-destruct? p))) - (match res - err (unwrap-panic res) ;; principal-destruct? only errors if principal is invalid; unwrap-panic triggers if so - ok-data - (let ((version (tuple-get ok-data "version")) (hash-bytes (tuple-get ok-data "hash-bytes"))) - ;; concatenate version and hash (both buffers) to produce 21-byte representation - (concat version hash-bytes) - ) - ) +(define-read-only (compute-intent-hash + (payload-hash (buff 32)) + (expiry uint) + (nonce uint) + (agent principal) + (coordination-type (buff 32)) + (coordination-value uint) + (participants (list 20 principal))) + (let ( + (intent-msg (make-intent-message + payload-hash expiry nonce agent + coordination-type coordination-value participants)) + ) + (match (to-consensus-buff? intent-msg) + serialized (ok (compute-structured-data-hash serialized)) + ERR_SERIALIZATION_FAILED ) + ) ) -;; Private helper: check if a list of principals contains a given principal -(define-private (contains? (plist (list 100 principal)) (p principal)) - (if (is-eq plist (list)) - false - (let ((head (unwrap-panic (get 0 plist)))) - (or (is-eq head p) (contains? (list-drop-n 1 plist) p)) - ) +;; ============================================================================= +;; ACCEPTANCE HASH COMPUTATION +;; ============================================================================= + +(define-private (make-acceptance-message + (intent-hash (buff 32)) + (participant principal) + (accept-nonce uint) + (expiry uint) + (conditions (buff 32))) + { + msg-type: MSG_TYPE_ACCEPTANCE, + intent-hash: intent-hash, + participant: participant, + nonce: accept-nonce, + expiry: expiry, + conditions: conditions + } +) + +(define-read-only (compute-acceptance-digest + (intent-hash (buff 32)) + (participant principal) + (accept-nonce uint) + (expiry uint) + (conditions (buff 32))) + (let ( + (acceptance-msg (make-acceptance-message + intent-hash participant accept-nonce expiry conditions)) + ) + (match (to-consensus-buff? acceptance-msg) + serialized (ok (compute-structured-data-hash serialized)) + ERR_SERIALIZATION_FAILED ) + ) ) -;; Private helper: check if a participants list has unique entries (no duplicates). -(define-private (is-unique-list (plist (list 100 principal))) - (if (is-eq plist (list)) - true - (let ( - (head (unwrap-panic (get 0 plist))) - (tail (list-drop-n 1 plist)) - ) - (if (contains? tail head) - false - (is-unique-list tail) - ) - ) +;; ============================================================================= +;; PARTICIPANT VALIDATION +;; ============================================================================= + +(define-private (principal-in-list? (p principal) (plist (list 20 principal))) + (is-some (index-of? plist p)) +) + +;; Compare principals by comparing bytes of consensus buffer +;; Uses element-at? which returns (buff 1) avoiding type issues +(define-private (principal-lt? (a principal) (b principal)) + (let ( + (a-buff (unwrap-panic (to-consensus-buff? a))) + (b-buff (unwrap-panic (to-consensus-buff? b))) + ) + ;; Compare first 8 bytes (sufficient for ordering) + (let ( + (a0 (buff-to-uint-be (default-to 0x00 (element-at? a-buff u0)))) + (b0 (buff-to-uint-be (default-to 0x00 (element-at? b-buff u0)))) + (a1 (buff-to-uint-be (default-to 0x00 (element-at? a-buff u1)))) + (b1 (buff-to-uint-be (default-to 0x00 (element-at? b-buff u1)))) + (a2 (buff-to-uint-be (default-to 0x00 (element-at? a-buff u2)))) + (b2 (buff-to-uint-be (default-to 0x00 (element-at? b-buff u2)))) + (a3 (buff-to-uint-be (default-to 0x00 (element-at? a-buff u3)))) + (b3 (buff-to-uint-be (default-to 0x00 (element-at? b-buff u3)))) + (a4 (buff-to-uint-be (default-to 0x00 (element-at? a-buff u4)))) + (b4 (buff-to-uint-be (default-to 0x00 (element-at? b-buff u4)))) + (a5 (buff-to-uint-be (default-to 0x00 (element-at? a-buff u5)))) + (b5 (buff-to-uint-be (default-to 0x00 (element-at? b-buff u5)))) + (a6 (buff-to-uint-be (default-to 0x00 (element-at? a-buff u6)))) + (b6 (buff-to-uint-be (default-to 0x00 (element-at? b-buff u6)))) + (a7 (buff-to-uint-be (default-to 0x00 (element-at? a-buff u7)))) + (b7 (buff-to-uint-be (default-to 0x00 (element-at? b-buff u7)))) + ) + ;; Lexicographic comparison + (if (< a0 b0) true + (if (> a0 b0) false + (if (< a1 b1) true + (if (> a1 b1) false + (if (< a2 b2) true + (if (> a2 b2) false + (if (< a3 b3) true + (if (> a3 b3) false + (if (< a4 b4) true + (if (> a4 b4) false + (if (< a5 b5) true + (if (> a5 b5) false + (if (< a6 b6) true + (if (> a6 b6) false + (< a7 b7))))))))))))))) ) + ) ) -;; Compute an intent hash from given fields (simplified for reference: includes contract domain and key fields) -(define-private (compute-intent-hash (agent principal) (participants (list 100 principal)) (payload-hash (buff 32)) (expiry uint) (nonce uint) (coord-type (buff 32)) (coord-value uint)) - (keccak256 - (concat - (principal->bytes (as-contract tx-sender)) ;; domain: using contract's principal (current contract) - Clarity uses (as-contract tx-sender) as current contract principal - (principal->bytes agent) - payload-hash - ) +(define-private (check-sorted-step + (current principal) + (state { valid: bool, prev: (optional principal) })) + (let ((prev-opt (get prev state))) + (if (not (get valid state)) + { valid: false, prev: (some current) } + (match prev-opt + prev-val + { valid: (principal-lt? prev-val current), prev: (some current) } + { valid: true, prev: (some current) } + ) ) + ) ) -;; NOTE: In a full implementation, the hash should include all fields (expiry, nonce, coord-type, coord-value, participants list, etc.) in a deterministic encoded form, -;; along with chain-id and contract name for domain separation. This simplified version only includes critical parts for brevity. - -;; Public function: propose a new intent -(define-public (propose-intent (agent principal) (participants (list 100 principal)) (expiry uint) (nonce uint) (coord-type (buff 32)) (coord-value uint) (payload-hash (buff 32))) - (begin - ;; Only the agent (initiator) can propose their intent - (if (not (is-eq agent tx-sender)) - (err ERR_UNAUTHORISED) - ) - ;; Participants list must contain the agent - (if (not (contains? participants agent)) - (err ERR_INVALID_PARAMS) ;; initiator not in participants list - ) - ;; Check participant list uniqueness (and sorting, if we enforce separately) - (if (not (is-unique-list participants)) - (err ERR_DUPLICATE_PARTICIPANT) - ) - ;; Nonce must be greater than last used nonce for this agent - (let ((prev-nonce (unwrap-or (map-get? last-nonce ((agent agent))) {nonce: u0}))) - (if (<= nonce (get nonce prev-nonce)) - (err ERR_NONCE_NOT_HIGHER) - ) - ) - ;; Expiry must be in the future (greater than current block time) - (let ((now (stacks-block-time))) - (if (<= expiry now) - (err ERR_EXPIRED) - ) - ) - ;; Compute unique intent hash (ID) - (let ((intent-hash (compute-intent-hash agent participants payload-hash expiry nonce coord-type coord-value))) - ;; Ensure not already used (i.e., not conflicting with an existing intent) - (if (map-get? intents ((intent-hash intent-hash))) - (err ERR_INVALID_PARAMS) ;; collision or reuse - ) - ;; Store the intent - (map-set intents - ((intent-hash intent-hash)) - ( - (agent agent) - (expiry expiry) - (nonce nonce) - (coord-type coord-type) - (coord-value coord-value) - (participants participants) - (status PROPOSED) - (accept-count u0) - ) - ) - ;; Update the initiator's last nonce - (map-set last-nonce ((agent agent)) ((nonce nonce))) - ;; Return the intent identifier - (ok intent-hash) - ) + +(define-private (is-sorted-unique? (plist (list 20 principal))) + (let ((n (len plist))) + (if (<= n u1) + true + (get valid (fold check-sorted-step plist { valid: true, prev: none })) ) + ) ) -;; Public function: accept an intent (participant provides their signature) -(define-public (accept-intent (intent-hash (buff 32)) (accept-expiry uint) (conditions (buff 32)) (signature (buff 65))) - (begin - ;; Ensure the intent exists - (let ((intent-data (map-get? intents ((intent-hash intent-hash))))) - (if (is-none intent-data) - (err ERR_NOT_FOUND) - ) - (let ( - (intent (unwrap intent-data ERR_NOT_FOUND)) - (now (stacks-block-time)) - ) - ;; Check intent status is Proposed (still collecting signatures) - (if (not (is-eq (get status intent) PROPOSED)) - (err ERR_INVALID_STATE) - ) - ;; Intent must not be expired - (if (> now (get expiry intent)) - (begin - ;; Mark as expired if past expiry - (map-set intents ((intent-hash intent-hash)) (tuple (agent (get agent intent)) (expiry (get expiry intent)) (nonce (get nonce intent)) (coord-type (get coord-type intent)) (coord-value (get coord-value intent)) (participants (get participants intent)) (status EXPIRED) (accept-count (get accept-count intent)))) - (err ERR_EXPIRED) - ) - ) - ;; The caller must be one of the participants - (if (not (contains? (get participants intent) tx-sender)) - (err ERR_NOT_PARTICIPANT) - ) - ;; Check if already accepted by this participant - (if (map-get? acceptances ((intent-hash intent-hash) (participant tx-sender))) - (err ERR_ALREADY_ACCEPTED) - ) - ;; The acceptance's expiry must not be in the past and not beyond intent expiry - (if (<= accept-expiry now) - (err ERR_EXPIRED) - ) - (if (> accept-expiry (get expiry intent)) - (err ERR_INVALID_PARAMS) ;; participant's acceptance expiry cannot exceed intent expiry - ) - ;; Verify the signature: recover the public key and derive principal - (let ( - ;; Prepare message hash for verification: we hash intent-hash + participant + their constraints + contract domain - (msg-hash (keccak256 - (concat - (principal->bytes (as-contract tx-sender)) - intent-hash - (principal->bytes tx-sender) - conditions - (buff 0) ;; Note: if we included accept-expiry and nonce in the signed data, we'd need to encode them here. - ) - )) - (recover-result (secp256k1-recover? msg-hash signature)) - ) - (match recover-result - err (err ERR_INVALID_SIGNATURE) - ok (let ((pubkey recover-result)) - (let ((derived-principal (principal-of? pubkey))) - (match derived-principal - err (err ERR_INVALID_SIGNATURE) - ok (let ((signer (unwrap derived-principal ERR_INVALID_SIGNATURE))) - ;; Check that the derived principal matches the tx-sender (the claimed participant) - (if (is-eq signer tx-sender) - (begin - ;; Record the acceptance - (map-set acceptances ((intent-hash intent-hash) (participant tx-sender)) ((accept-expiry accept-expiry))) - ;; Increment acceptance count - (map-set intents ((intent-hash intent-hash)) - (tuple - (agent (get agent intent)) - (expiry (get expiry intent)) - (nonce (get nonce intent)) - (coord-type (get coord-type intent)) - (coord-value (get coord-value intent)) - (participants (get participants intent)) - (status (get status intent)) - (accept-count (+ u1 (get accept-count intent))) - ) - ) - ;; If this was the last required acceptance, update status to Ready - (let ((new-count (+ u1 (get accept-count intent))) (total (len (get participants intent)))) - (if (>= new-count total) - (map-set intents ((intent-hash intent-hash)) (tuple - (agent (get agent intent)) - (expiry (get expiry intent)) - (nonce (get nonce intent)) - (coord-type (get coord-type intent)) - (coord-value (get coord-value intent)) - (participants (get participants intent)) - (status READY) - (accept-count new-count) - )) - ) - ) - (ok true) - ) - (err ERR_INVALID_SIGNATURE) - ) - ) - ) - ) - ) - ) - ) - ) - ) +(define-private (validate-participants + (participants (list 20 principal)) + (agent principal)) + (and + (> (len participants) u0) + (is-sorted-unique? participants) + (principal-in-list? agent participants) + ) +) + +;; ============================================================================= +;; SIGNATURE VERIFICATION +;; ============================================================================= + +(define-private (verify-signature + (message-hash (buff 32)) + (signature (buff 65)) + (expected-signer principal)) + (match (secp256k1-recover? message-hash signature) + recovered-pubkey + (match (principal-of? recovered-pubkey) + recovered-principal (is-eq recovered-principal expected-signer) + err-principal false + ) + err-recover false + ) +) + +;; ============================================================================= +;; STATUS HELPERS +;; ============================================================================= + +(define-private (get-effective-status (stored-status uint) (expiry uint)) + (if (or (is-eq stored-status STATE_EXECUTED) + (is-eq stored-status STATE_CANCELLED)) + stored-status + (if (> stacks-block-height expiry) + STATE_EXPIRED + stored-status ) + ) ) -;; Public function: execute an intent (mark as executed if ready) -(define-public (execute-intent (intent-hash (buff 32))) - (begin - (let ((intent-data (map-get? intents ((intent-hash intent-hash))))) - (if (is-none intent-data) - (err ERR_NOT_FOUND) - ) - (let ((intent (unwrap intent-data ERR_NOT_FOUND)) (now (stacks-block-time))) - ;; Only allow execution if status is Ready - (if (not (is-eq (get status intent) READY)) - (err ERR_INVALID_STATE) - ) - ;; Check current time against intent expiry - (if (> now (get expiry intent)) - (err ERR_EXPIRED) - ) - ;; Check that all acceptance attestations are still valid (not expired individually) - (let ((plist (get participants intent))) - (if (is-expired-acceptance? intent-hash plist now) - (err ERR_EXPIRED) - ) - ) - ;; (Business logic for executing the intent's action would go here, if applicable) - ;; Mark intent as executed - (map-set intents ((intent-hash intent-hash)) - (tuple - (agent (get agent intent)) - (expiry (get expiry intent)) - (nonce (get nonce intent)) - (coord-type (get coord-type intent)) - (coord-value (get coord-value intent)) - (participants (get participants intent)) - (status EXECUTED) - (accept-count (get accept-count intent)) - ) - ) - (ok true) - ) - ) +(define-private (check-acceptance-fresh + (participant principal) + (state { fresh: bool, intent-hash: (buff 32) })) + (if (not (get fresh state)) + state + (match (map-get? acceptances + { intent-hash: (get intent-hash state), participant: participant }) + acceptance + { + fresh: (<= stacks-block-height (get expiry acceptance)), + intent-hash: (get intent-hash state) + } + { fresh: false, intent-hash: (get intent-hash state) } ) + ) +) + +(define-private (all-acceptances-fresh? + (intent-hash (buff 32)) + (participants (list 20 principal))) + (get fresh + (fold check-acceptance-fresh + participants + { fresh: true, intent-hash: intent-hash })) ) -;; Public function: cancel an intent (initiator only) -(define-public (cancel-intent (intent-hash (buff 32))) - (begin - (let ((intent-data (map-get? intents ((intent-hash intent-hash))))) - (if (is-none intent-data) - (err ERR_NOT_FOUND) +;; ============================================================================= +;; PUBLIC: PROPOSE +;; ============================================================================= + +(define-public (propose-coordination + (payload-hash (buff 32)) + (expiry uint) + (nonce uint) + (coordination-type (buff 32)) + (coordination-value uint) + (participants (list 20 principal))) + (let ( + (agent tx-sender) + (now stacks-block-height) + (prev-nonce (default-to u0 (map-get? agent-nonces { agent: agent }))) + ) + (asserts! (> expiry now) ERR_INTENT_EXPIRED) + (asserts! (> nonce prev-nonce) ERR_NONCE_TOO_LOW) + (asserts! (validate-participants participants agent) ERR_INVALID_PARTICIPANTS) + + (let ( + (intent-hash-result (compute-intent-hash + payload-hash expiry nonce agent + coordination-type coordination-value participants)) + ) + (match intent-hash-result + intent-hash + (begin + (asserts! (is-none (map-get? intents { intent-hash: intent-hash })) + ERR_DUPLICATE_INTENT) + + (map-set intents { intent-hash: intent-hash } + { + agent: agent, + payload-hash: payload-hash, + expiry: expiry, + nonce: nonce, + coordination-type: coordination-type, + coordination-value: coordination-value, + participants: participants, + status: STATE_PROPOSED, + accept-count: u0 + } ) - (let ((intent (unwrap intent-data ERR_NOT_FOUND))) - ;; Only initiator can cancel - (if (not (is-eq (get agent intent) tx-sender)) - (err ERR_UNAUTHORISED) - ) - ;; Only allow cancellation if not already executed or cancelled - (if (or (is-eq (get status intent) EXECUTED) (is-eq (get status intent) CANCELLED)) - (err ERR_INVALID_STATE) - ) - ;; Mark as cancelled - (map-set intents ((intent-hash intent-hash)) - (tuple - (agent (get agent intent)) - (expiry (get expiry intent)) - (nonce (get nonce intent)) - (coord-type (get coord-type intent)) - (coord-value (get coord-value intent)) - (participants (get participants intent)) - (status CANCELLED) - (accept-count (get accept-count intent)) + + (map-set agent-nonces { agent: agent } nonce) + + (print { + event: "coordination-proposed", + intent-hash: intent-hash, + agent: agent, + coordination-type: coordination-type, + coordination-value: coordination-value, + participant-count: (len participants), + expiry: expiry + }) + + (ok intent-hash) + ) + err-val (err err-val) + ) + ) + ) +) + +;; ============================================================================= +;; PUBLIC: ACCEPT +;; ============================================================================= + +(define-public (accept-coordination + (intent-hash (buff 32)) + (accept-expiry uint) + (conditions (buff 32)) + (signature (buff 65))) + (let ( + (caller tx-sender) + (now stacks-block-height) + (accept-nonce u0) + ) + (match (map-get? intents { intent-hash: intent-hash }) + intent + (begin + (asserts! (<= now (get expiry intent)) ERR_INTENT_EXPIRED) + (asserts! (is-eq (get status intent) STATE_PROPOSED) ERR_INVALID_STATE) + (asserts! (principal-in-list? caller (get participants intent)) + ERR_NOT_PARTICIPANT) + (asserts! (is-none (map-get? acceptances + { intent-hash: intent-hash, participant: caller })) + ERR_ALREADY_ACCEPTED) + (asserts! (> accept-expiry now) ERR_ACCEPTANCE_EXPIRED) + + (let ( + (digest-result (compute-acceptance-digest + intent-hash caller accept-nonce accept-expiry conditions)) + ) + (match digest-result + digest + (begin + (asserts! (verify-signature digest signature caller) + ERR_INVALID_SIGNATURE) + + (map-set acceptances + { intent-hash: intent-hash, participant: caller } + { expiry: accept-expiry, conditions: conditions } + ) + + (let ( + (new-count (+ (get accept-count intent) u1)) + (total-required (len (get participants intent))) + (new-status (if (>= new-count total-required) + STATE_READY + STATE_PROPOSED)) + ) + (map-set intents { intent-hash: intent-hash } + (merge intent { + accept-count: new-count, + status: new-status + }) ) + + (print { + event: "coordination-accepted", + intent-hash: intent-hash, + participant: caller, + accepted-count: new-count, + required-count: total-required, + is-ready: (>= new-count total-required) + }) + + (ok (>= new-count total-required)) + ) ) - (ok true) + err-val (err err-val) ) + ) ) + ERR_NOT_FOUND ) + ) ) -;; Read-only function: get the status code of an intent -(define-read-only (get-coordination-status (intent-hash (buff 32))) - (let ((intent-data (map-get? intents ((intent-hash intent-hash))))) - (if (is-none intent-data) - (err ERR_NOT_FOUND) - (ok (get status (unwrap intent-data ERR_NOT_FOUND))) +;; ============================================================================= +;; PUBLIC: EXECUTE +;; ============================================================================= + +(define-public (execute-coordination + (intent-hash (buff 32)) + (payload (buff 1024)) + (execution-data (buff 1024))) + (let ((now stacks-block-height)) + (match (map-get? intents { intent-hash: intent-hash }) + intent + (begin + (asserts! (is-eq (get status intent) STATE_READY) ERR_INVALID_STATE) + (asserts! (<= now (get expiry intent)) ERR_INTENT_EXPIRED) + (asserts! (all-acceptances-fresh? intent-hash (get participants intent)) + ERR_ACCEPTANCE_EXPIRED) + (asserts! (is-eq (sha256 payload) (get payload-hash intent)) + ERR_PAYLOAD_MISMATCH) + + (map-set intents { intent-hash: intent-hash } + (merge intent { status: STATE_EXECUTED }) + ) + + (print { + event: "coordination-executed", + intent-hash: intent-hash, + executor: tx-sender, + payload-hash: (get payload-hash intent), + coordination-type: (get coordination-type intent), + coordination-value: (get coordination-value intent) + }) + + (ok true) ) + ERR_NOT_FOUND ) + ) ) -;; Private helper: check if any acceptance has expired at a given time -(define-private (is-expired-acceptance? (intent-hash (buff 32)) (plist (list 100 principal)) (current-time uint)) - (if (is-eq plist (list)) - false - (let ((participant (unwrap-panic (get 0 plist)))) - (let ((acc-data (map-get? acceptances ((intent-hash intent-hash) (participant participant))))) - (if (is-none acc-data) - false ;; if a participant hasn't accepted, from perspective of execution readiness this function might not be called because status wouldn't be Ready. - (let ((acc (unwrap acc-data (err ERR_NOT_FOUND)))) - (if (> current-time (get accept-expiry acc)) - true - (is-expired-acceptance? intent-hash (list-drop-n 1 plist) current-time) - ) - ) - ) - ) +;; ============================================================================= +;; PUBLIC: CANCEL +;; ============================================================================= + +(define-public (cancel-coordination + (intent-hash (buff 32)) + (reason (string-ascii 64))) + (let ((now stacks-block-height)) + (match (map-get? intents { intent-hash: intent-hash }) + intent + (let ( + (agent (get agent intent)) + (status (get status intent)) + (expiry (get expiry intent)) + ) + (asserts! (not (is-eq status STATE_EXECUTED)) ERR_INVALID_STATE) + (asserts! (not (is-eq status STATE_CANCELLED)) ERR_INVALID_STATE) + (asserts! (or (is-eq tx-sender agent) (> now expiry)) + ERR_UNAUTHORIZED) + + (map-set intents { intent-hash: intent-hash } + (merge intent { status: STATE_CANCELLED }) + ) + + (print { + event: "coordination-cancelled", + intent-hash: intent-hash, + canceller: tx-sender, + reason: reason, + was-expired: (> now expiry) + }) + + (ok true) ) + ERR_NOT_FOUND ) + ) ) + +;; ============================================================================= +;; READ-ONLY: QUERIES +;; ============================================================================= + +(define-read-only (get-coordination-status (intent-hash (buff 32))) + (match (map-get? intents { intent-hash: intent-hash }) + intent + (let ( + (effective-status (get-effective-status + (get status intent) + (get expiry intent))) + (accepted-list (get-accepted-participants intent-hash (get participants intent))) + ) + (ok { + status: effective-status, + agent: (get agent intent), + participants: (get participants intent), + accepted-by: accepted-list, + accept-count: (get accept-count intent), + expiry: (get expiry intent), + coordination-type: (get coordination-type intent), + coordination-value: (get coordination-value intent), + payload-hash: (get payload-hash intent) + }) + ) + ERR_NOT_FOUND + ) +) + +(define-private (collect-accepted + (p principal) + (state { accepted: (list 20 principal), intent-hash: (buff 32) })) + (if (is-some (map-get? acceptances + { intent-hash: (get intent-hash state), participant: p })) + { + accepted: (unwrap-panic (as-max-len? + (append (get accepted state) p) u20)), + intent-hash: (get intent-hash state) + } + state + ) +) + +(define-read-only (get-accepted-participants + (intent-hash (buff 32)) + (participants (list 20 principal))) + (get accepted + (fold collect-accepted participants + { accepted: (list), intent-hash: intent-hash })) +) + +(define-read-only (get-required-acceptances (intent-hash (buff 32))) + (match (map-get? intents { intent-hash: intent-hash }) + intent (ok (len (get participants intent))) + ERR_NOT_FOUND + ) +) + +(define-read-only (get-agent-nonce (agent principal)) + (default-to u0 (map-get? agent-nonces { agent: agent })) +) + +(define-read-only (get-acceptance + (intent-hash (buff 32)) + (participant principal)) + (map-get? acceptances { intent-hash: intent-hash, participant: participant }) +) + +;; ============================================================================= +;; READ-ONLY: SIGNING HELPERS +;; ============================================================================= + +(define-read-only (get-signing-domain) + { + name: DOMAIN_NAME, + version: DOMAIN_VERSION, + chain-id: chain-id, + domain-hash: (get-domain-hash) + } +) + +(define-read-only (get-acceptance-message-to-sign + (intent-hash (buff 32)) + (participant principal) + (expiry uint) + (conditions (buff 32))) + { + domain: (get-domain-tuple), + message: (make-acceptance-message + intent-hash participant u0 expiry conditions) + } +) \ No newline at end of file diff --git a/sips/stacks-8001/tests/erc-8001.test.ts b/sips/stacks-8001/tests/erc-8001.test.ts new file mode 100644 index 00000000..a0460ce8 --- /dev/null +++ b/sips/stacks-8001/tests/erc-8001.test.ts @@ -0,0 +1,527 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { initSimnet, Simnet } from "@hirosystems/clarinet-sdk"; +import { Cl, ClarityType, serializeCV } from "@stacks/transactions"; + +// Initialize simnet once +let simnet: Simnet; + +beforeEach(async () => { + // Reinitialize simnet before each test for clean state + simnet = await initSimnet(); +}); + +// ============================================================================= +// CONSTANTS (must match contract) +// ============================================================================= + +const ERR = { + UNAUTHORIZED: 100, + NOT_FOUND: 101, + INVALID_STATE: 102, + INVALID_SIGNATURE: 103, + NOT_PARTICIPANT: 104, + ALREADY_ACCEPTED: 105, + INTENT_EXPIRED: 106, + NONCE_TOO_LOW: 107, + INVALID_PARTICIPANTS: 108, + ACCEPTANCE_EXPIRED: 109, + PAYLOAD_MISMATCH: 110, + SERIALIZATION_FAILED: 111, + DUPLICATE_INTENT: 112, +}; + +// Clarity types +const CV_OK = ClarityType.ResponseOk; // 7 +const CV_ERR = ClarityType.ResponseErr; // 8 +const CV_UINT = ClarityType.UInt; // 1 +const CV_TUPLE = ClarityType.Tuple; // 12 + +// ============================================================================= +// HELPER FUNCTIONS +// ============================================================================= + +function buff32(str: string): Uint8Array { + const bytes = new TextEncoder().encode(str); + const buffer = new Uint8Array(32); + buffer.set(bytes.slice(0, 32)); + return buffer; +} + +// Sort principals by consensus buffer (matches Clarity's to-consensus-buff?) +function sortPrincipals(principals: string[]): string[] { + return [...principals].sort((a, b) => { + const bufA = serializeCV(Cl.principal(a)); + const bufB = serializeCV(Cl.principal(b)); + const minLen = Math.min(bufA.length, bufB.length); + for (let i = 0; i < minLen; i++) { + if (bufA[i] !== bufB[i]) return bufA[i] - bufB[i]; + } + return bufA.length - bufB.length; + }); +} + +function isOk(result: any): boolean { + return result.type === CV_OK; +} + +function isErr(result: any): boolean { + return result.type === CV_ERR; +} + +function getErrCode(result: any): number { + if (result.type === CV_ERR && result.value.type === CV_UINT) { + return Number(result.value.value); + } + return -1; +} + +// ============================================================================= +// PROPOSE COORDINATION TESTS +// ============================================================================= + +describe("propose-coordination", () => { + + it("successfully creates a new coordination", async () => { + const accounts = simnet.getAccounts(); + const wallet1 = accounts.get("wallet_1")!; + const wallet2 = accounts.get("wallet_2")!; + + const participants = sortPrincipals([wallet1, wallet2]); + const payloadHash = buff32("rps-game-payload"); + const coordinationType = buff32("RPS-GAME"); + + const { result } = simnet.callPublicFn( + "erc-8001", + "propose-coordination", + [ + Cl.buffer(payloadHash), + Cl.uint(simnet.blockHeight + 100), + Cl.uint(1), + Cl.buffer(coordinationType), + Cl.uint(1000), + Cl.list(participants.map(p => Cl.principal(p))), + ], + wallet1 + ); + + expect(isOk(result)).toBe(true); + }); + + it("fails if agent not in participants", async () => { + const accounts = simnet.getAccounts(); + const wallet1 = accounts.get("wallet_1")!; + const wallet2 = accounts.get("wallet_2")!; + const wallet3 = accounts.get("wallet_3")!; + + const participants = sortPrincipals([wallet2, wallet3]); + + const { result } = simnet.callPublicFn( + "erc-8001", + "propose-coordination", + [ + Cl.buffer(buff32("test")), + Cl.uint(simnet.blockHeight + 100), + Cl.uint(1), + Cl.buffer(buff32("TEST")), + Cl.uint(0), + Cl.list(participants.map(p => Cl.principal(p))), + ], + wallet1 + ); + + expect(isErr(result)).toBe(true); + expect(getErrCode(result)).toBe(ERR.INVALID_PARTICIPANTS); + }); + + it("fails if expiry is in the past", async () => { + const accounts = simnet.getAccounts(); + const wallet1 = accounts.get("wallet_1")!; + const wallet2 = accounts.get("wallet_2")!; + + simnet.mineEmptyBlocks(10); + const participants = sortPrincipals([wallet1, wallet2]); + + const { result } = simnet.callPublicFn( + "erc-8001", + "propose-coordination", + [ + Cl.buffer(buff32("test-past")), + Cl.uint(5), + Cl.uint(1), + Cl.buffer(buff32("TEST")), + Cl.uint(0), + Cl.list(participants.map(p => Cl.principal(p))), + ], + wallet1 + ); + + expect(isErr(result)).toBe(true); + expect(getErrCode(result)).toBe(ERR.INTENT_EXPIRED); + }); + + it("fails if nonce not increasing", async () => { + const accounts = simnet.getAccounts(); + const wallet1 = accounts.get("wallet_1")!; + const wallet2 = accounts.get("wallet_2")!; + + const participants = sortPrincipals([wallet1, wallet2]); + + // First proposal with nonce 5 + simnet.callPublicFn( + "erc-8001", + "propose-coordination", + [ + Cl.buffer(buff32("test1")), + Cl.uint(simnet.blockHeight + 100), + Cl.uint(5), + Cl.buffer(buff32("TEST")), + Cl.uint(0), + Cl.list(participants.map(p => Cl.principal(p))), + ], + wallet1 + ); + + // Second proposal with nonce 3 (lower) should fail + const { result } = simnet.callPublicFn( + "erc-8001", + "propose-coordination", + [ + Cl.buffer(buff32("test2")), + Cl.uint(simnet.blockHeight + 100), + Cl.uint(3), + Cl.buffer(buff32("TEST")), + Cl.uint(0), + Cl.list(participants.map(p => Cl.principal(p))), + ], + wallet1 + ); + + expect(isErr(result)).toBe(true); + expect(getErrCode(result)).toBe(ERR.NONCE_TOO_LOW); + }); +}); + +// ============================================================================= +// CANCEL COORDINATION TESTS +// ============================================================================= + +describe("cancel-coordination", () => { + + it("agent can cancel before expiry", async () => { + const accounts = simnet.getAccounts(); + const wallet1 = accounts.get("wallet_1")!; + const wallet2 = accounts.get("wallet_2")!; + + const participants = sortPrincipals([wallet1, wallet2]); + + const { result: proposeResult } = simnet.callPublicFn( + "erc-8001", + "propose-coordination", + [ + Cl.buffer(buff32("cancel-test")), + Cl.uint(simnet.blockHeight + 100), + Cl.uint(1), + Cl.buffer(buff32("TEST")), + Cl.uint(0), + Cl.list(participants.map(p => Cl.principal(p))), + ], + wallet1 + ); + + expect(isOk(proposeResult)).toBe(true); + const intentHash = proposeResult.value; + + const { result } = simnet.callPublicFn( + "erc-8001", + "cancel-coordination", + [intentHash, Cl.stringAscii("Changed my mind")], + wallet1 + ); + + expect(isOk(result)).toBe(true); + }); + + it("non-agent cannot cancel before expiry", async () => { + const accounts = simnet.getAccounts(); + const wallet1 = accounts.get("wallet_1")!; + const wallet2 = accounts.get("wallet_2")!; + + const participants = sortPrincipals([wallet1, wallet2]); + + const { result: proposeResult } = simnet.callPublicFn( + "erc-8001", + "propose-coordination", + [ + Cl.buffer(buff32("cancel-test-2")), + Cl.uint(simnet.blockHeight + 100), + Cl.uint(1), + Cl.buffer(buff32("TEST")), + Cl.uint(0), + Cl.list(participants.map(p => Cl.principal(p))), + ], + wallet1 + ); + + expect(isOk(proposeResult)).toBe(true); + const intentHash = proposeResult.value; + + const { result } = simnet.callPublicFn( + "erc-8001", + "cancel-coordination", + [intentHash, Cl.stringAscii("I want out")], + wallet2 + ); + + expect(isErr(result)).toBe(true); + expect(getErrCode(result)).toBe(ERR.UNAUTHORIZED); + }); + + it("anyone can cancel after expiry", async () => { + const accounts = simnet.getAccounts(); + const wallet1 = accounts.get("wallet_1")!; + const wallet2 = accounts.get("wallet_2")!; + const wallet3 = accounts.get("wallet_3")!; + + const participants = sortPrincipals([wallet1, wallet2]); + const expiry = simnet.blockHeight + 10; + + const { result: proposeResult } = simnet.callPublicFn( + "erc-8001", + "propose-coordination", + [ + Cl.buffer(buff32("expire-test")), + Cl.uint(expiry), + Cl.uint(1), + Cl.buffer(buff32("TEST")), + Cl.uint(0), + Cl.list(participants.map(p => Cl.principal(p))), + ], + wallet1 + ); + + expect(isOk(proposeResult)).toBe(true); + const intentHash = proposeResult.value; + + // Mine blocks past expiry + simnet.mineEmptyBlocks(15); + + const { result } = simnet.callPublicFn( + "erc-8001", + "cancel-coordination", + [intentHash, Cl.stringAscii("Cleanup")], + wallet3 + ); + + expect(isOk(result)).toBe(true); + }); + + it("cannot cancel already cancelled coordination", async () => { + const accounts = simnet.getAccounts(); + const wallet1 = accounts.get("wallet_1")!; + const wallet2 = accounts.get("wallet_2")!; + + const participants = sortPrincipals([wallet1, wallet2]); + + const { result: proposeResult } = simnet.callPublicFn( + "erc-8001", + "propose-coordination", + [ + Cl.buffer(buff32("double-cancel")), + Cl.uint(simnet.blockHeight + 100), + Cl.uint(1), + Cl.buffer(buff32("TEST")), + Cl.uint(0), + Cl.list(participants.map(p => Cl.principal(p))), + ], + wallet1 + ); + + const intentHash = proposeResult.value; + + // First cancel + simnet.callPublicFn( + "erc-8001", + "cancel-coordination", + [intentHash, Cl.stringAscii("First cancel")], + wallet1 + ); + + // Second cancel should fail + const { result } = simnet.callPublicFn( + "erc-8001", + "cancel-coordination", + [intentHash, Cl.stringAscii("Second cancel")], + wallet1 + ); + + expect(isErr(result)).toBe(true); + expect(getErrCode(result)).toBe(ERR.INVALID_STATE); + }); +}); + +// ============================================================================= +// READ-ONLY FUNCTION TESTS +// ============================================================================= + +describe("read-only functions", () => { + + it("get-agent-nonce returns 0 initially", async () => { + const accounts = simnet.getAccounts(); + const wallet1 = accounts.get("wallet_1")!; + + const result = simnet.callReadOnlyFn( + "erc-8001", + "get-agent-nonce", + [Cl.principal(wallet1)], + wallet1 + ); + + expect(result.result.type).toBe(CV_UINT); + expect(result.result.value).toBe(0n); + }); + + it("get-signing-domain returns domain info", async () => { + const accounts = simnet.getAccounts(); + const wallet1 = accounts.get("wallet_1")!; + + const result = simnet.callReadOnlyFn( + "erc-8001", + "get-signing-domain", + [], + wallet1 + ); + + expect(result.result.type).toBe(CV_TUPLE); + }); + + it("get-coordination-status returns NOT_FOUND for non-existent intent", async () => { + const accounts = simnet.getAccounts(); + const wallet1 = accounts.get("wallet_1")!; + + const fakeIntentHash = buff32("does-not-exist"); + + const result = simnet.callReadOnlyFn( + "erc-8001", + "get-coordination-status", + [Cl.buffer(fakeIntentHash)], + wallet1 + ); + + expect(isErr(result.result)).toBe(true); + expect(getErrCode(result.result)).toBe(ERR.NOT_FOUND); + }); +}); + +// ============================================================================= +// RPS GAME USER STORY +// ============================================================================= + +describe("RPS Game: User Story", () => { + + it("full coordination flow: propose -> query -> cancel", async () => { + const accounts = simnet.getAccounts(); + const wallet1 = accounts.get("wallet_1")!; + const wallet2 = accounts.get("wallet_2")!; + + const gamePayload = buff32("ROCK:PAPER:secret123"); + const participants = sortPrincipals([wallet1, wallet2]); + const coordinationType = buff32("RPS-GAME"); + + // Step 1: Propose + const { result: proposeResult, events } = simnet.callPublicFn( + "erc-8001", + "propose-coordination", + [ + Cl.buffer(gamePayload), + Cl.uint(simnet.blockHeight + 144), + Cl.uint(1), + Cl.buffer(coordinationType), + Cl.uint(1000), + Cl.list(participants.map(p => Cl.principal(p))), + ], + wallet1 + ); + + expect(isOk(proposeResult)).toBe(true); + expect(events.length).toBeGreaterThan(0); + + const intentHash = proposeResult.value; + + // Step 2: Query status + const statusResult = simnet.callReadOnlyFn( + "erc-8001", + "get-coordination-status", + [intentHash], + wallet1 + ); + expect(isOk(statusResult.result)).toBe(true); + + // Step 3: Cancel + const { result: cancelResult } = simnet.callPublicFn( + "erc-8001", + "cancel-coordination", + [intentHash, Cl.stringAscii("Game cancelled")], + wallet1 + ); + expect(isOk(cancelResult)).toBe(true); + }); +}); + +// ============================================================================= +// EDGE CASES +// ============================================================================= + +describe("Edge Cases", () => { + + it("single participant coordination", async () => { + const accounts = simnet.getAccounts(); + const wallet1 = accounts.get("wallet_1")!; + + const participants = [wallet1]; + + const { result: proposeResult } = simnet.callPublicFn( + "erc-8001", + "propose-coordination", + [ + Cl.buffer(buff32("self-task")), + Cl.uint(simnet.blockHeight + 100), + Cl.uint(1), + Cl.buffer(buff32("SELF-COORD")), + Cl.uint(0), + Cl.list(participants.map(p => Cl.principal(p))), + ], + wallet1 + ); + + expect(isOk(proposeResult)).toBe(true); + }); + + it("multi-party coordination with 5 participants", async () => { + const accounts = simnet.getAccounts(); + const wallet1 = accounts.get("wallet_1")!; + const wallet2 = accounts.get("wallet_2")!; + const wallet3 = accounts.get("wallet_3")!; + const wallet4 = accounts.get("wallet_4")!; + const wallet5 = accounts.get("wallet_5")!; + + const participants = sortPrincipals([ + wallet1, wallet2, wallet3, wallet4, wallet5 + ]); + + const { result: proposeResult } = simnet.callPublicFn( + "erc-8001", + "propose-coordination", + [ + Cl.buffer(buff32("multi-party")), + Cl.uint(simnet.blockHeight + 1000), + Cl.uint(1), + Cl.buffer(buff32("MULTI-SIG")), + Cl.uint(50000), + Cl.list(participants.map(p => Cl.principal(p))), + ], + wallet1 + ); + + expect(isOk(proposeResult)).toBe(true); + }); +}); \ No newline at end of file From 7eaba4d87c49f867f533235f692a264391a08649 Mon Sep 17 00:00:00 2001 From: Kwame Bryan Date: Wed, 11 Feb 2026 07:54:33 -0500 Subject: [PATCH 4/6] SIP-037 updates * Revisions based on Stacks SIP proposal initial review. --- .gitignore | 2 +- .idea/workspace.xml | 59 ------------------- .../contracts/agent-coordination.clar | 7 ++- .../sip-xxx.md => sip-037/sip-037.md} | 44 +++++++------- .../tests/sip-037.test.ts} | 0 5 files changed, 28 insertions(+), 84 deletions(-) delete mode 100644 .idea/workspace.xml rename sips/{stacks-8001 => sip-037}/contracts/agent-coordination.clar (98%) rename sips/{stacks-8001/sip-xxx.md => sip-037/sip-037.md} (84%) rename sips/{stacks-8001/tests/erc-8001.test.ts => sip-037/tests/sip-037.test.ts} (100%) diff --git a/.gitignore b/.gitignore index 19d57931..9af13678 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,5 @@ .DS_Store *.orig *.rej - +.idea .aider* diff --git a/.idea/workspace.xml b/.idea/workspace.xml deleted file mode 100644 index 2dc96af0..00000000 --- a/.idea/workspace.xml +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - { - "associatedIndex": 5 -} - - - - { - "keyToString": { - "ModuleVcsDetector.initialDetectionPerformed": "true", - "RunOnceActivity.ShowReadmeOnStart": "true", - "RunOnceActivity.git.unshallow": "true", - "git-widget-placeholder": "sip/8001Stacks", - "node.js.detected.package.eslint": "true", - "node.js.selected.package.eslint": "(autodetect)", - "node.js.selected.package.tslint": "(autodetect)", - "nodejs_package_manager_path": "npm", - "vue.rearranger.settings.migration": "true" - } -} - - - - - - - - - - 1764344113560 - - - - - - \ No newline at end of file diff --git a/sips/stacks-8001/contracts/agent-coordination.clar b/sips/sip-037/contracts/agent-coordination.clar similarity index 98% rename from sips/stacks-8001/contracts/agent-coordination.clar rename to sips/sip-037/contracts/agent-coordination.clar index 0d62cb86..c08aa46c 100644 --- a/sips/stacks-8001/contracts/agent-coordination.clar +++ b/sips/sip-037/contracts/agent-coordination.clar @@ -1,4 +1,7 @@ -;; ERC-8001 Agent Coordination Protocol - Stacks Implementation +;; SIP-037 Agent Coordination Protocol - Stacks Implementation +;; +;; SIP-037 is a Stacks implementation of ERC-8001 +;; Canonised version found at https://eips.ethereum.org/EIPS/eip-8001 ;; ;; Trustless multi-party coordination for AI agents and humans. ;; Flow: PROPOSE - ACCEPT (signatures) - EXECUTE or CANCEL @@ -35,7 +38,7 @@ ;; SIP-018 Structured Data Signing (define-constant SIP018_PREFIX 0x534950303138) -(define-constant DOMAIN_NAME "ERC-8001-Agent-Coordination") +(define-constant DOMAIN_NAME "SIP-037-Agent-Coordination") (define-constant DOMAIN_VERSION "1") (define-constant MSG_TYPE_INTENT "AgentIntent") (define-constant MSG_TYPE_ACCEPTANCE "AcceptanceAttestation") diff --git a/sips/stacks-8001/sip-xxx.md b/sips/sip-037/sip-037.md similarity index 84% rename from sips/stacks-8001/sip-xxx.md rename to sips/sip-037/sip-037.md index 9c01c866..0b82b795 100644 --- a/sips/stacks-8001/sip-xxx.md +++ b/sips/sip-037/sip-037.md @@ -1,8 +1,8 @@ # Preamble -SIP Number: XXX +SIP Number: SIP-037 Title: Standard for Multi-Party Agent Coordination -Author: [Kwame Bryan] () +Author: [Kwame Bryan] (<@kbryan>) , [Jason Schrader] (<@whoabuddy>) Consideration: Technical Type: Standard Status: Draft @@ -12,21 +12,21 @@ Sign-off: *(pending)* # Abstract -This proposal introduces a standard primitive for secure coordination among multiple independent agents on the Stacks blockchain. It defines an **intent** message format and protocol by which an initiator posts a desired action (the intent) and other participants submit cryptographic acceptances. The intent becomes **executable** once all required participants have provided acceptance attestations before a specified expiry. This standard specifies the data structures, canonical status codes, Clarity contract interface, and rules needed to implement this coordination framework on Stacks. It leverages off-chain **signed structured data** (per SIP-018) and on-chain verification using Clarity’s cryptographic functions. By standardising multi-party approval workflows, SIP-XXX enables trust-minimised coordination in use cases such as multi-sig transactions, decentralised MEV mitigation strategies, and cross-contract agent actions, all using a common protocol. +This proposal introduces a standard primitive for secure coordination among multiple independent agents on the Stacks blockchain. It defines an **intent** message format and protocol by which an initiator posts a desired action (the intent) and other participants submit cryptographic acceptances. The intent becomes **executable** once all required participants have provided acceptance attestations before a specified expiry. This standard specifies the data structures, canonical status codes, Clarity contract interface, and rules needed to implement this coordination framework on Stacks. It leverages off-chain **signed structured data** (per SIP-018) and on-chain verification using Clarity’s cryptographic functions. By standardising multi-party approval workflows, SIP-037 enables trust-minimised coordination in use cases such as multi-sig transactions, decentralised MEV mitigation strategies, and cross-contract agent actions, all using a common protocol. # Licence and Copyright -This SIP is released under the terms of the **Creative Commons CC0 1.0 Universal** licence:contentReference[oaicite:17]{index=17}. By contributing to this SIP, authors agree to dedicate their work to the public domain. The Stacks Open Internet Foundation holds copyright for this document. +This SIP is released under the terms of the **Creative Commons CC0 1.0 Universal** licence. By contributing to this SIP, authors agree to dedicate their work to the public domain. The Stacks Open Internet Foundation holds copyright for this document. # Introduction As decentralised applications and autonomous agents become more complex, there are many scenarios where a group of independent actors must agree on an action before it is executed. Examples include multi-signature wallet approvals, collaborative trades or arbitrage across DEXs, and MEV (Maximal Extractable Value) mitigation where solvers and bidders coordinate on transaction ordering. In current practice, these often rely on bespoke protocols or off-chain agreements, leading to fragmentation and potential security risks. -On Ethereum, the concept of *intents* has emerged to express desired actions in a chain-agnostic way, but earlier standards (like ERC-7521 and ERC-7683) handled only single-initiator flows:contentReference[oaicite:18]{index=18}. Ethereum’s recent ERC-8001 filled this gap by introducing a minimal coordination primitive for multiple parties:contentReference[oaicite:19]{index=19}. This SIP adapts ERC-8001’s approach to Stacks, taking into account Clarity’s design and existing SIPs (e.g. SIP-018 for signing data). +On Ethereum, the concept of *intents* has emerged to express desired actions in a chain-agnostic way, but earlier standards (like ERC-7521 and ERC-7683) handled only single-initiator flows. Ethereum’s recent ERC-8001 filled this gap by introducing a minimal coordination primitive for multiple parties. This SIP adapts ERC-8001’s approach to Stacks, taking into account Clarity’s design and existing SIPs (e.g. SIP-018 for signing data). The key idea is that an initiator can propose an intent which enumerates all participants who need to agree. Each participant (including the initiator) produces a digital signature (an **acceptance attestation**) to confirm their agreement under certain conditions. These signatures are collected on-chain. If and only if every listed party’s attestation is present and valid within the allowed time window, the intent is marked as ready to execute. This guarantees that the intended action has unanimous approval from the required set of agents, without needing an off-chain coordinator to aggregate trust. -Privacy and advanced policies (like threshold k-of-n approvals, bond posting, or cross-chain intents) are intentionally **out of scope** for this base standard:contentReference[oaicite:20]{index=20}:contentReference[oaicite:21]{index=21}. The goal is to establish a simple, extensible on-chain core that other modules and protocols can build upon for added functionality. +Privacy and advanced policies (like threshold k-of-n approvals, bond posting, or cross-chain intents) are intentionally **out of scope** for this base standard. The goal is to establish a simple, extensible on-chain core that other modules and protocols can build upon for added functionality. # Specification @@ -34,7 +34,7 @@ The keywords “MUST”, “SHOULD”, and “MAY” in this document are to be ## Status Codes -Implementations MUST use the following canonical status codes for each intent’s lifecycle state:contentReference[oaicite:22]{index=22}: +Implementations MUST use the following canonical status codes for each intent’s lifecycle state. - `None` (`0`): No record of the intent (default state before proposal). - `Proposed` (`1`): Intent has been proposed and stored, but not all required acceptances are yet present. @@ -50,11 +50,11 @@ A compliant contract MUST provide a read-only function (e.g. `get-coordination-s **Agent Intent:** The core message posted by an initiator describing the coordination request. It is a tuple of fields: - `payloadHash` (`buff 32`): A hash (e.g. SHA-256 or KECCAK256) of the detailed payload of the intent. The payload can include domain-specific instructions or data for execution, but is not interpreted by the core contract (opaque to this SIP). - `expiry` (`uint`): A Unix timestamp (in seconds) by which the intent expires. The intent cannot be executed after this time. It MUST be set to a future time when proposing and is used to determine *Expired* status. -- `nonce` (`uint`): A monotonic sequence number for intents per initiator (agent). This provides replay protection – each new intent from the same agent MUST use a `nonce` greater than their previous intents’ nonces:contentReference[oaicite:23]{index=23}. +- `nonce` (`uint`): A monotonic sequence number for intents per initiator (agent). This provides replay protection – each new intent from the same agent MUST use a `nonce` greater than their previous intents’ nonces. - `agentId` (`principal`): The Stacks principal of the initiator (the one proposing the intent). This principal must match the transaction sender that creates the intent on-chain. - `coordinationType` (`buff 32`): An application-specific identifier for the type or context of this coordination. For example, it could be the hash of a string like `"MEV_SANDWICH_V1"` or `"MULTISIG_TXN"` to indicate how the payload should be interpreted by off-chain actors. - `coordinationValue` (`uint`): An optional value field (e.g. an amount in micro-STX or an abstract value) that is informational for the core protocol. The core standard does not assign meaning to this field, but higher-level modules MAY use it (for example, to require a bond or to encode an expected payment amount). -- `participants` (`list(principal)`): The list of all participants’ principals involved in this intent, **including the initiator** (`agentId`). This list MUST be strictly ascending (sorted) by principal and contain no duplicates:contentReference[oaicite:24]{index=24}:contentReference[oaicite:25]{index=25}. Ordering the addresses canonically ensures everyone computes the same intent hash and prevents duplicate signers. +- `participants` (`list(principal)`): The list of all participants’ principals involved in this intent, **including the initiator** (`agentId`). This list MUST be strictly ascending (sorted) by principal and contain no duplicates. Ordering the addresses canonically ensures everyone computes the same intent hash and prevents duplicate signers. **Acceptance Attestation:** A participant’s acceptance of an intent. It is represented by: - `intentHash` (`buff 32`): The hash of the Agent Intent that the participant is agreeing to. (See **Signature Semantics** below for how this hash is computed). @@ -64,15 +64,15 @@ A compliant contract MUST provide a read-only function (e.g. `get-coordination-s - `conditionsHash` (`buff 32`): A hash of any participant-specific conditions for their acceptance. This field is optional and not interpreted by the base contract logic. It might encode constraints like “price must be above X” or other domain-specific requirements that the participant expects to be true at execution. If no extra conditions, this can be a zero hash (all 0x00 bytes). - `signature` (`buff 64/65`): The participant’s digital signature over the intent. This is the Secp256k1 ECDSA signature (65 bytes including recovery ID, or 64-byte compact form per EIP-2098) that proves the participant indeed signed the `intentHash` (and associated domain). -**Coordination Payload:** (Optional in core) The full data that `payloadHash` represents. The structure of this payload is outside the scope of SIP-XXX, as it is application-specific. However, by convention it could include fields like `version` (a format identifier), `coordinationType` (MUST equal the above type for redundancy), `coordinationData` (opaque binary or structured commands to execute), `conditionsHash` (the combined conditions for execution), `timestamp` (when the intent was created), and `metadata`. These are not processed by the core contract, but hashing them into `payloadHash` ensures that all participants are agreeing to the exact same details. +**Coordination Payload:** (Optional in core) The full data that `payloadHash` represents. The structure of this payload is outside the scope of SIP-037, as it is application-specific. However, by convention it could include fields like `version` (a format identifier), `coordinationType` (MUST equal the above type for redundancy), `coordinationData` (opaque binary or structured commands to execute), `conditionsHash` (the combined conditions for execution), `timestamp` (when the intent was created), and `metadata`. These are not processed by the core contract, but hashing them into `payloadHash` ensures that all participants are agreeing to the exact same details. ## Signature Semantics and Domain Separation All signatures in this protocol MUST be made over a well-defined message that includes a domain separator specific to this SIP and the current contract: -- The initiator’s signature covers the **Agent Intent**. Off-chain, the initiator SHOULD sign a digest computed as `H = keccak256(domain, AgentIntent)` or similar, where `domain` binds the network (mainnet/testnet), the SIP number, and the contract address (including contract name):contentReference[oaicite:26]{index=26}. This prevents an intent for one contract or chain from being re-used on another. The contract’s Clarity code can reconstruct the expected `intentHash` on-chain to verify any signatures. +- The initiator’s signature covers the **Agent Intent**. Off-chain, the initiator SHOULD sign a digest computed as `H = keccak256(domain, AgentIntent)` or similar, where `domain` binds the network (mainnet/testnet), the SIP number, and the contract address (including contract name). This prevents an intent for one contract or chain from being re-used on another. The contract’s Clarity code can reconstruct the expected `intentHash` on-chain to verify any signatures. - Each participant’s **Acceptance Attestation** signature covers their `intentHash` plus their own constraints. In practice, a participant would sign a message encoding: the `intentHash` (linking to a specific intent), their `participant` address, optional `nonce`, `expiry`, and `conditionsHash`, along with the same domain separator. This yields a 32-byte hash that is then signed via Secp256k1. -- Clarity’s `secp256k1-verify` or `secp256k1-recover?` functions are used to verify these signatures on-chain. A compliant implementation MUST support 65-byte signatures with low-S values and SHOULD support 64-byte compact signatures:contentReference[oaicite:27]{index=27}. If a signature’s recovery byte is present, the contract will use it to recover the public key and derive the signing principal (via `principal-of?`); otherwise, the contract can verify directly given a provided public key. -- **Stacks Signed Message Prefix:** Implementations SHOULD prepend the standard `"Stacks Signed Message:\n"` prefix (as defined in SIP-018) when computing signature hashes for off-chain signing:contentReference[oaicite:28]{index=28}. However, since SIP-018 primarily covers personal messages, the use of a structured EIP-712-like approach with an explicit domain as described is RECOMMENDED for clarity and to avoid ambiguities. +- Clarity’s `secp256k1-verify` or `secp256k1-recover?` functions are used to verify these signatures on-chain. A compliant implementation MUST support 65-byte signatures with low-S values and SHOULD support 64-byte compact signatures. If a signature’s recovery byte is present, the contract will use it to recover the public key and derive the signing principal (via `principal-of?`); otherwise, the contract can verify directly given a provided public key. +- **Stacks Signed Message Prefix:** Implementations SHOULD prepend the standard `"Stacks Signed Message:\n"` prefix (as defined in SIP-018) when computing signature hashes for off-chain signing. However, since SIP-018 primarily covers personal messages, the use of a structured EIP-712-like approach with an explicit domain as described is RECOMMENDED for clarity and to avoid ambiguities. By following these semantics, any signature collected under this standard is tightly bound to the specific intent and contract, mitigating replay attacks across contexts. @@ -86,7 +86,7 @@ An implementing smart contract MUST provide public functions roughly as follows - The `participants` list includes `agentId` and is sorted and without duplicates. - `intent.nonce` is strictly greater than the last used nonce for this `agentId` (to prevent reuse). - `intent.expiry` is in the future (greater than current time). - If these checks pass, the intent is recorded (e.g. in a map from `intentHash` to intent data) with status `Proposed`:contentReference[oaicite:29]{index=29}. It also initialises tracking for acceptances (e.g. zero accepted count). If any check fails, it returns an error code and does not create the intent. + If these checks pass, the intent is recorded (e.g. in a map from `intentHash` to intent data) with status `Proposed`. It also initialises tracking for acceptances (e.g. zero accepted count). If any check fails, it returns an error code and does not create the intent. - `(define-public (accept-intent (intent-hash (buff 32)) (sig (buff 65)) [optional pubkey/fields])) (response bool uint))` Records a participant’s acceptance for the given intent. The participant calling this function (`tx-sender`) is implicitly the accepting principal. The contract will: - Look up the intent by `intent-hash`. If not found, return an error (intent doesn’t exist). @@ -100,7 +100,7 @@ An implementing smart contract MUST provide public functions roughly as follows - The intent exists and has status `Ready`. - The current time is <= intent’s expiry and all acceptance expiries (i.e., not too late to execute). - (Optionally, the provided `payload` matches the stored `payloadHash` to ensure the actual execution details correspond to what was agreed. Often the payload execution happens off-chain or in another contract, so this might not be applicable in every implementation.) - On success, the contract sets the status to `Executed` and returns true. Typically, the actual business logic (transferring funds, etc.) is executed off-chain or by another contract that coordinates with this one — SIP-XXX’s reference implementation only handles the state change and verification, not the actual fulfilment of the intent’s action. + On success, the contract sets the status to `Executed` and returns true. Typically, the actual business logic (transferring funds, etc.) is executed off-chain or by another contract that coordinates with this one — SIP-037’s reference implementation only handles the state change and verification, not the actual fulfilment of the intent’s action. - `(define-public (cancel-intent (intent-hash (buff 32))) (response bool uint))` Allows the initiator (and **only** the initiator) to cancel an intent that is not yet executed. This function: - Verifies `tx-sender` equals the intent’s `agentId`. @@ -110,11 +110,11 @@ An implementing smart contract MUST provide public functions roughly as follows - `(define-read-only (get-coordination-status (intent-hash (buff 32))) (response uint uint))` Returns the current status code (0–5 as defined above) of the given intent, or an error if the intent is not found. This is used by off-chain clients or other contracts to poll the state of an intent. -The above interface is an example; the actual function names and parameters may vary, but any SIP-XXX compliant contract **MUST** provide equivalent functionality. +The above interface is an example; the actual function names and parameters may vary, but any SIP-037 compliant contract **MUST** provide equivalent functionality. ## Lifecycle Rules -An implementation of SIP-XXX MUST enforce the following lifecycle: +An implementation of SIP-037 MUST enforce the following lifecycle: 1. **Proposal:** An initiator calls `propose-intent` to register a new intent on-chain. Initially, its status is `Proposed`. At this point, no acceptances are present. The initiator’s signature on the intent (off-chain) is assumed by virtue of them calling the function (the transaction itself confirms their intent). 2. **Acceptance:** Each participant (including possibly the initiator, if the design requires a separate acceptance from them) calls `accept-intent` with their signature. These can happen in any order. The contract verifies each signature and records it. Participants MAY also provide their acceptance via an off-chain aggregator who then submits them in one transaction, but each acceptance must be individually verifiable on-chain. As acceptances come in, the contract may emit events or simply allow querying of how many acceptances are collected. When the final required acceptance is received, the contract SHOULD update the status to `Ready`. @@ -132,19 +132,19 @@ One consideration: SIP-018 (Structured Data Signing) should be compatible with t ## Security Considerations -**Replay Prevention:** By using initiator-specific nonces for intents and including the contract’s identity in the signed message, this protocol prevents signatures from one context being reused in another:contentReference[oaicite:30]{index=30}. Each initiator’s `nonce` ensures they (and their wallet software) won’t accidentally reuse an intent message, and domain separation (SIP number, contract address, chain id) ensures an intent on Stacks mainnet contract “X” cannot be executed on a testnet or a different contract “Y”. +**Replay Prevention:** By using initiator-specific nonces for intents and including the contract’s identity in the signed message, this protocol prevents signatures from one context being reused in another. Each initiator’s `nonce` ensures they (and their wallet software) won’t accidentally reuse an intent message, and domain separation (SIP number, contract address, chain id) ensures an intent on Stacks mainnet contract “X” cannot be executed on a testnet or a different contract “Y”. -**Signature Verification and Malleability:** Implementations must use Clarity’s crypto functions correctly to avoid accepting forged signatures. Only acceptances that produce a valid recoverable public key matching the participant’s address should be counted. Low-S requirement (as enforced by most Secp256k1 libraries) should be ensured:contentReference[oaicite:31]{index=31} – if using `secp256k1-verify`, it returns false for high-S signatures, and if using recovery, the contract should reject any signature that does not pass verification. Both 64-byte and 65-byte signatures should be accepted to accommodate different wallet implementations (per EIP-2098 compressed form). +**Signature Verification and Malleability:** Implementations must use Clarity’s crypto functions correctly to avoid accepting forged signatures. Only acceptances that produce a valid recoverable public key matching the participant’s address should be counted. Low-S requirement (as enforced by most Secp256k1 libraries) should be ensured – if using `secp256k1-verify`, it returns false for high-S signatures, and if using recovery, the contract should reject any signature that does not pass verification. Both 64-byte and 65-byte signatures should be accepted to accommodate different wallet implementations (per EIP-2098 compressed form). **Timeliness (Expiry):** The expiry mechanism is crucial for safety. Without expiries, an old intent could linger and potentially be executed much later under different conditions, or a participant’s acceptance could be “banked” and used when they no longer intend. By expiring intents, we limit this risk. However, note that the contract cannot automatically remove an expired intent without a transaction; it can only prevent further actions. It is up to clients or a scheduled off-chain service to clean up or notify about expired intents. Parties should choose reasonable expiry times – long enough to gather signatures and execute, but short enough to limit risk exposure. -**Partial Signatures / Equivocation:** The protocol does not stop a malicious participant from signing multiple intents (equivocation) hoping only one gets executed. If a participant does so and two intents both become ready, an executor might waste resources preparing both. This is an application-level concern; modules can add penalties or reputation tracking to discourage such behaviour:contentReference[oaicite:32]{index=32}. The core simply treats each intent separately. It is RECOMMENDED that when this standard is used in economic protocols, there are additional incentives (like slashing or deposits) to align participants’ behaviour. +**Partial Signatures / Equivocation:** The protocol does not stop a malicious participant from signing multiple intents (equivocation) hoping only one gets executed. If a participant does so and two intents both become ready, an executor might waste resources preparing both. This is an application-level concern; modules can add penalties or reputation tracking to discourage such behaviour. The core simply treats each intent separately. It is RECOMMENDED that when this standard is used in economic protocols, there are additional incentives (like slashing or deposits) to align participants’ behaviour. -**Front-Running and MEV:** Because intents in this standard are posted on-chain in a public contract, a malicious observer could potentially see a `Proposed` intent and attempt to front-run the eventual action. However, since the intent can only be executed with all signatures and after a certain time, the window for exploitation is limited. For greater privacy, participants might delay broadcasting their acceptances until execution is imminent, or use a commit-reveal scheme where only hashes of signatures are posted initially. Those techniques are outside SIP-XXX’s scope but can be layered on. In environments with high MEV risk, consider encrypting the payload off-chain and only revealing it at execution time:contentReference[oaicite:33]{index=33}. +**Front-Running and MEV:** Because intents in this standard are posted on-chain in a public contract, a malicious observer could potentially see a `Proposed` intent and attempt to front-run the eventual action. However, since the intent can only be executed with all signatures and after a certain time, the window for exploitation is limited. For greater privacy, participants might delay broadcasting their acceptances until execution is imminent, or use a commit-reveal scheme where only hashes of signatures are posted initially. Those techniques are outside SIP-037’s scope but can be layered on. In environments with high MEV risk, consider encrypting the payload off-chain and only revealing it at execution time. ## Reference Implementation -A reference implementation of this standard is provided in the accompanying file: [`contracts/agent-coordination.clar`](contracts/agent-coordination.clar). This Clarity contract illustrates one way to realise SIP-XXX. It uses: +A reference implementation of this standard is provided in the accompanying file: [`contracts/agent-coordination.clar`](contracts/agent-coordination.clar). This Clarity contract illustrates one way to realise SIP-037. It uses: - A map to store proposed intents (keyed by a 32-byte intent hash). - A map to track each initiator’s latest nonce (to enforce monotonic nonces). - Functions `propose-intent`, `accept-intent`, `cancel-intent`, `execute-intent`, and getters for status, closely following the interface described above. diff --git a/sips/stacks-8001/tests/erc-8001.test.ts b/sips/sip-037/tests/sip-037.test.ts similarity index 100% rename from sips/stacks-8001/tests/erc-8001.test.ts rename to sips/sip-037/tests/sip-037.test.ts From 824dfeacd36d55201da9ca1eb3bdab329cf1c38b Mon Sep 17 00:00:00 2001 From: Kwame Bryan Date: Wed, 11 Feb 2026 15:50:50 -0500 Subject: [PATCH 5/6] Update sip-037.md * Revised SIP number in preamble --- sips/sip-037/sip-037.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sips/sip-037/sip-037.md b/sips/sip-037/sip-037.md index 0b82b795..27e978c6 100644 --- a/sips/sip-037/sip-037.md +++ b/sips/sip-037/sip-037.md @@ -1,6 +1,6 @@ # Preamble -SIP Number: SIP-037 +SIP Number: 037 Title: Standard for Multi-Party Agent Coordination Author: [Kwame Bryan] (<@kbryan>) , [Jason Schrader] (<@whoabuddy>) Consideration: Technical From 6dc91280eb63b543e707c8a437972137ed99b4bd Mon Sep 17 00:00:00 2001 From: Kwame Bryan Date: Tue, 17 Feb 2026 16:30:52 -0500 Subject: [PATCH 6/6] Delete .gitignore remove .gitignore --- .gitignore | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 .gitignore diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 9af13678..00000000 --- a/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -*.swp -.DS_Store -*.orig -*.rej -.idea -.aider*