Source Code

;; KES v1
;;
;; This contract implements the KES Reserve protocol for bridging KES between
;; Stacks and other chains.
;;
;; This contract is the main entry point for minting and burning KES.

;; An error occurred while recovering a deposit intent signature's public key.
(define-constant ERR_UNABLE_TO_RECOVER_PK (err u100))
;; The length of the deposit intent is invalid.
(define-constant ERR_INVALID_DEPOSIT_BYTE_LENGTH (err u101))
;; The amount of the deposit intent is larger than u128::max.
(define-constant ERR_INVALID_DEPOSIT_AMOUNT_TOO_HIGH (err u102))
;; The max fee of the deposit intent is larger than u128::max.
(define-constant ERR_INVALID_DEPOSIT_MAX_FEE_TOO_HIGH (err u103))
;; The magic bytes of the deposit intent are invalid.
(define-constant ERR_INVALID_DEPOSIT_INTENT_MAGIC (err u104))
;; The hook data length of the deposit intent is invalid.
(define-constant ERR_INVALID_DEPOSIT_HOOK_DATA_LENGTH (err u105))
;; The signature of the deposit intent is invalid.
(define-constant ERR_INVALID_DEPOSIT_SIGNATURE (err u106))
;; The version of the deposit intent is invalid.
(define-constant ERR_INVALID_DEPOSIT_VERSION (err u107))
;; After accounting for fees, the amount of KES to mint is zero.
(define-constant ERR_INVALID_DEPOSIT_AMOUNT_ZERO (err u108))
;; The fee amount of the mint is larger than the max fee of the deposit intent.
(define-constant ERR_INVALID_DEPOSIT_FEE_AMOUNT_TOO_HIGH (err u109))
;; The remote domain of the deposit intent is invalid.
(define-constant ERR_INVALID_DEPOSIT_REMOTE_DOMAIN (err u110))
;; The remote token of the deposit intent is invalid.
(define-constant ERR_INVALID_DEPOSIT_REMOTE_TOKEN (err u111))
;; The remote recipient of the deposit intent is invalid.
(define-constant ERR_INVALID_DEPOSIT_REMOTE_RECIPIENT (err u112))
;; This nonce has already been used in a different deposit
(define-constant ERR_INVALID_DEPOSIT_NONCE (err u113))
;; The max fee is greater than or equal to the amount.
(define-constant ERR_INVALID_DEPOSIT_MAX_FEE_GTE_AMOUNT (err u114))
;; The remote recipient length of the deposit intent is invalid.
(define-constant ERR_INVALID_DEPOSIT_REMOTE_RECIPIENT_LENGTH (err u115))
;; The withdrawal amount is less than the minimum withdrawal amount.
(define-constant ERR_INVALID_WITHDRAWAL_AMOUNT_TOO_LOW (err u116))
;; The native domain is not the supported value (currently only 0)
(define-constant ERR_INVALID_NATIVE_DOMAIN (err u117))

;; Magic bytes for deposit encoding
(define-constant DEPOSIT_INTENT_MAGIC 0x5a2e0acd)
;; Supported version for parsing deposit intents
(define-constant DEPOSIT_INTENT_VERSION u1)
;; Supported native-domain for withdrawals
(define-constant ETHEREUM_NATIVE_DOMAIN u0)
;; Allowed `domain` for deposits
(define-constant DOMAIN u10003)

;; Map of used nonces
(define-map used-nonces
  (buff 32)
  bool
)

;; Map of Circle attestor public keys
(define-map circle-attestors
  (buff 33)
  bool
)

;; Minimum amount required to withdrawal KES
(define-data-var min-withdrawal-amount uint u0)

;; Helper function to parse a deposit intent from raw bytes.
;; This function takes care of parsing the deposit intent according to the Circle specification.
;; Stacks-specific logic (such as converting the remote recipient to a principal) is handled by other functions.
;;
;; For full validation, including parsing the remote recipient and preventing nonce reuse, use
;; `parse-and-validate-deposit-intent`.
(define-read-only (parse-deposit-intent (deposit-intent (buff 320)))
  (begin
    (asserts! (>= (len deposit-intent) u240) ERR_INVALID_DEPOSIT_BYTE_LENGTH)
    (let (
        (magic (unwrap-panic (as-max-len? (unwrap-panic (slice? deposit-intent u0 u4)) u4)))
        (version (buff-to-uint-be (unwrap-panic (as-max-len? (unwrap-panic (slice? deposit-intent u4 u8)) u4))))
        (amount-left-bytes (unwrap-panic (as-max-len? (unwrap-panic (slice? deposit-intent u8 u24)) u16)))
        (amount (buff-to-uint-be (unwrap-panic (as-max-len? (unwrap-panic (slice? deposit-intent u24 u40)) u16))))
        (remote-domain (buff-to-uint-be (unwrap-panic (as-max-len? (unwrap-panic (slice? deposit-intent u40 u44)) u4))))
        (remote-token (unwrap-panic (as-max-len? (unwrap-panic (slice? deposit-intent u44 u76)) u32)))
        (remote-recipient (unwrap-panic (as-max-len? (unwrap-panic (slice? deposit-intent u76 u108)) u32)))
        (local-token (unwrap-panic (as-max-len? (unwrap-panic (slice? deposit-intent u108 u140)) u32)))
        (local-depositor (unwrap-panic (as-max-len? (unwrap-panic (slice? deposit-intent u140 u172)) u32)))
        (max-fee-left-bytes (unwrap-panic (as-max-len? (unwrap-panic (slice? deposit-intent u172 u188)) u16)))
        (max-fee (buff-to-uint-be (unwrap-panic (as-max-len? (unwrap-panic (slice? deposit-intent u188 u204)) u16))))
        (nonce (unwrap-panic (as-max-len? (unwrap-panic (slice? deposit-intent u204 u236)) u32)))
        (hook-data-len (buff-to-uint-be (unwrap-panic (as-max-len? (unwrap-panic (slice? deposit-intent u236 u240)) u4))))
      )
      (asserts! (is-eq magic DEPOSIT_INTENT_MAGIC)
        ERR_INVALID_DEPOSIT_INTENT_MAGIC
      )
      (asserts! (is-eq amount-left-bytes 0x00000000000000000000000000000000)
        ERR_INVALID_DEPOSIT_AMOUNT_TOO_HIGH
      )
      (asserts! (is-eq max-fee-left-bytes 0x00000000000000000000000000000000)
        ERR_INVALID_DEPOSIT_MAX_FEE_TOO_HIGH
      )
      (asserts! (is-eq (len deposit-intent) (+ u240 hook-data-len))
        ERR_INVALID_DEPOSIT_HOOK_DATA_LENGTH
      )
      (ok {
        magic: magic,
        version: version,
        amount: amount,
        remote-domain: remote-domain,
        remote-token: remote-token,
        remote-recipient: remote-recipient,
        local-token: local-token,
        local-depositor: local-depositor,
        max-fee: max-fee,
        nonce: nonce,
        hook-data: (if (is-eq hook-data-len u0)
          0x
          (unwrap-panic (as-max-len?
            (unwrap-panic (slice? deposit-intent u240 (+ u240 hook-data-len)))
            u80
          ))
        ),
      })
    )
  )
)

;; Recover the attestor public key from a deposit intent and signature.
;; Recovery is done by hashing the deposit intent (via `keccak256`)
;; and then using the `secp256k1-recover?` function.
(define-read-only (recover-deposit-intent-pk
    (deposit-intent (buff 320))
    (signature (buff 65))
  )
  (let (
      (hash (keccak256 deposit-intent))
      (recovered-pk (unwrap! (secp256k1-recover? hash signature) ERR_UNABLE_TO_RECOVER_PK))
    )
    (ok recovered-pk)
  )
)

;; Add or remove a Circle attestor.
;;
;; Can only be called by a caller with the governance role.
(define-public (add-or-remove-circle-attestor
    (public-key (buff 33))
    (enabled bool)
  )
  (begin
    (try! (contract-call? .kes validate-protocol-caller 0x00 contract-caller))
    (map-set circle-attestors public-key enabled)
    (ok true)
  )
)

;; Recover and verify a deposit intent signature.
;;
;; The public key is first recovered (via `recover-deposit-intent-pk`).
;; Then, the public key is checked against the `circle-attestors` map.
(define-read-only (verify-deposit-intent-signature
    (deposit-intent (buff 320))
    (signature (buff 65))
  )
  (begin
    (let ((recovered-pk (try! (recover-deposit-intent-pk deposit-intent signature))))
      (asserts! (default-to false (map-get? circle-attestors recovered-pk))
        ERR_INVALID_DEPOSIT_SIGNATURE
      )
      (ok recovered-pk)
    )
  )
)

;; Convert 32 bytes to a standard principal. This is serialized as
;; 1 version byte, plus 20 hash bytes. This is then left-padded
;; with 11 bytes of 0x00.
;;
;; To support contracts as recipients, `hook-data` can contain a contract name.
;; To use this functionality, `hook-data` MUST be a consensus-serialized buffer
;; of the type { contract-name: (string-ascii 40) }.
;;
;; If `hook-data` is not able to be deserialized, this function falls back
;; to using a standard principal.
(define-read-only (get-remote-recipient
    (remote-recipient-bytes (buff 32))
    (hook-data (buff 80))
  )
  (let (
      (valid-len (asserts! (is-eq (len remote-recipient-bytes) u32)
        ERR_INVALID_DEPOSIT_REMOTE_RECIPIENT_LENGTH
      ))
      (version-byte (unwrap-panic (element-at? remote-recipient-bytes u11)))
      (hash-bytes (unwrap-panic (as-max-len? (unwrap-panic (slice? remote-recipient-bytes u12 u32)) u20)))
      ;; Avoid a VM runtime error when `hook-data` is empty:
      (hook-contract-name (if (is-eq (len hook-data) u0)
        none
        (from-consensus-buff? { contract-name: (string-ascii 40) } hook-data)
      ))
    )
    ;; Must have 0x00 as padding
    (asserts!
      (is-eq
        (unwrap-panic (as-max-len? (unwrap-panic (slice? remote-recipient-bytes u0 u11)) u11))
        0x0000000000000000000000
      )
      ERR_INVALID_DEPOSIT_REMOTE_RECIPIENT
    )
    (ok (unwrap!
      (match hook-contract-name
        contract-name-tup (principal-construct? version-byte hash-bytes
          (get contract-name contract-name-tup)
        )
        (principal-construct? version-byte hash-bytes)
      )
      ERR_INVALID_DEPOSIT_REMOTE_RECIPIENT
    ))
  )
)

;; 32-byte encoded version of the `.kes` contract address.
;; This must be used in deposit intents as the `remote-token` field.
(define-read-only (get-valid-remote-token)
  (concat 0x00000000000000
    (unwrap-panic (as-max-len? (unwrap-panic (to-consensus-buff? .kes)) u28))
  )
)

;; Helper function to parse and validate a deposit intent.
;;
;; In addition to basic parsing (done via `parse-deposit-intent`), this function
;; also validates certain Stacks-specific fields, such as the
;; remote token, remote domain, remote recipient, and version.
;;
;; Additionally, this function validates the `amount` and `max-fee` fields.
(define-read-only (parse-and-validate-deposit-intent (deposit-intent (buff 320)))
  (let (
      (parsed-intent (try! (parse-deposit-intent deposit-intent)))
      (remote-recipient (try! (get-remote-recipient (get remote-recipient parsed-intent)
        (get hook-data parsed-intent)
      )))
      (amount (get amount parsed-intent))
    )
    (asserts! (is-eq (get remote-token parsed-intent) (get-valid-remote-token))
      ERR_INVALID_DEPOSIT_REMOTE_TOKEN
    )
    (asserts! (> amount u0) ERR_INVALID_DEPOSIT_AMOUNT_ZERO)
    (asserts! (is-eq (get remote-domain parsed-intent) DOMAIN)
      ERR_INVALID_DEPOSIT_REMOTE_DOMAIN
    )
    (asserts! (is-eq (get version parsed-intent) DEPOSIT_INTENT_VERSION)
      ERR_INVALID_DEPOSIT_VERSION
    )
    (asserts! (>= amount (get max-fee parsed-intent))
      ERR_INVALID_DEPOSIT_MAX_FEE_GTE_AMOUNT
    )
    (asserts! (is-none (map-get? used-nonces (get nonce parsed-intent)))
      ERR_INVALID_DEPOSIT_NONCE
    )
    (ok (merge parsed-intent { remote-recipient: remote-recipient }))
  )
)

;; Mint KES using a deposit intent.
;; This is the main entry point for minting KES.
;;
;; In addition to validation performed by `parse-and-validate-deposit-intent`, and
;; `verify-deposit-intent-signature`, this function also validates the `fee-amount`
;; provided by the caller to ensure that zero-amount mints are not possible.
;;
;; If `fee-amount` is non-zero (and less than the deposit's `max-fee`),
;; this function will mint `fee-amount` of KES to the caller. This allows
;; for accounts other than the deposit's recipient to cover the STX fee needed to mint.
(define-public (mint
    (deposit-intent (buff 320))
    (signature (buff 65))
    (fee-amount uint)
  )
  (let (
      (parsed-intent (try! (parse-and-validate-deposit-intent deposit-intent)))
      (recovered-pk (try! (verify-deposit-intent-signature deposit-intent signature)))
      (mint-amount (- (get amount parsed-intent) fee-amount))
    )
    (asserts! (>= (get max-fee parsed-intent) fee-amount)
      ERR_INVALID_DEPOSIT_FEE_AMOUNT_TOO_HIGH
    )
    ;; mint to the recipient
    (if (is-eq mint-amount u0)
      true
      (try! (contract-call? .kes protocol-mint mint-amount
        (get remote-recipient parsed-intent)
      ))
    )
    (if (is-eq fee-amount u0)
      true
      (try! (contract-call? .kes protocol-mint fee-amount tx-sender))
    )
    (map-set used-nonces (get nonce parsed-intent) true)
    (print {
      topic: "mint",
      parsed-intent: parsed-intent,
      attestor-pk: recovered-pk,
      mint-amount: mint-amount,
      fee-amount: fee-amount,
    })
    (ok true)
  )
)

;; Set the minimum withdrawal amount.
;;
;; Can only be called by a caller with the custom role `0x04` role.
(define-public (set-min-withdrawal-amount (new-min-withdrawal-amount uint))
  (begin
    (try! (contract-call? .kes validate-protocol-caller 0x04 contract-caller))
    (var-set min-withdrawal-amount new-min-withdrawal-amount)
    (ok true)
  )
)

(define-read-only (get-min-withdrawal-amount)
  (var-get min-withdrawal-amount)
)

;; Burn KES for the purpose of withdrawing KES from the protocol.
;;
;; This function burns KES from the caller's account and emits a `burn` event.
;;
;; The amount must be greater than or equal to the minimum withdrawal amount.
;;
;; `native-domain` must be a supported value (currently only `ETHEREUM_NATIVE_DOMAIN` (u0)).
(define-public (burn
    (amount uint)
    (native-domain uint)
    (native-recipient (buff 32))
  )
  (begin
    (asserts! (>= amount (var-get min-withdrawal-amount))
      ERR_INVALID_WITHDRAWAL_AMOUNT_TOO_LOW
    )
    (asserts! (is-eq native-domain ETHEREUM_NATIVE_DOMAIN)
      ERR_INVALID_NATIVE_DOMAIN
    )
    (try! (contract-call? .kes protocol-burn amount tx-sender))
    (print {
      topic: "burn",
      native-domain: native-domain,
      native-recipient: native-recipient,
      sender: tx-sender,
      amount: amount,
    })
    (ok true)
  )
)

Functions (11)

FunctionAccessArgs
parse-deposit-intentread-onlydeposit-intent: (buff 320
recover-deposit-intent-pkread-onlydeposit-intent: (buff 320
add-or-remove-circle-attestorpublicpublic-key: (buff 33
verify-deposit-intent-signatureread-onlydeposit-intent: (buff 320
get-remote-recipientread-onlyremote-recipient-bytes: (buff 32
get-valid-remote-tokenread-only
parse-and-validate-deposit-intentread-onlydeposit-intent: (buff 320
mintpublicdeposit-intent: (buff 320
set-min-withdrawal-amountpublicnew-min-withdrawal-amount: uint
get-min-withdrawal-amountread-only
burnpublicamount: uint, native-domain: uint, native-recipient: (buff 32