Source Code

;; This is a Cofund helper contract that provides a wrapper around the state contract to manage users.
;; The current version provides the ability to add, remove, & rotate users.

;; cons
(define-constant SIP018_MSG_PREFIX 0x534950303138)
(define-constant HASH_LENGTH u32)
(define-constant KEY_LENGTH u33)

;; errs
(define-constant ERR_INVALID_PARAMS (err u301))
(define-constant ERR_THRESHOLD_NOT_MET (err u302))
(define-constant ERR_INVALID_LENGTH (err u303))
(define-constant ERR_INVALID_SIGNATURE (err u304))
(define-constant ERR_AUTH_ID_REPLAY (err u305))
(define-constant ERR_SIGNER_NOT_ADMIN (err u306))
(define-constant ERR_DUPLICATE_SIGNATURE (err u307))
(define-constant ERR_SIGNER_NOT_REGISTERED (err u308))
(define-constant ERR_SIGNER_WRONG_CLIENT (err u309))
(define-constant ERR_SIGNER_NOT_FOUND (err u310))
(define-constant ERR_SIGNER_INACTIVE (err u311))
(define-constant ERR_CALLER_NOT_REGISTERED (err u312))
(define-constant ERR_FORWARDING_FORBIDDEN (err u313))
(define-constant ERR_CALLER_ID_MISMATCH (err u314))
(define-constant ERR_CLIENT_ID_MISMATCH (err u315))
(define-constant ERR_CALLER_NOT_ADMIN (err u316))

;; add-user-invite-wrapper
;; This function, if successful, adds a new user to the users map.
;; The interface offers two different ways of securely inviting a user based on whether caller is inviting an admin or non-admin.
;; An admin can simply call to add any position *other* than admin or an admin can call to add a new admin by passing in a list of signatures.
;; @param client-id; The client's ID
;; @param caller-id; The caller's ID
;; @param user-id; The new user's ID
;; @param user-position; The new user's position
;; @param invite-hash; The invite hash
;; @param admin-auth-optional; The admin auth optional
;; @param admin-optional-signed-data; The admin optional signed data
(define-public (add-user-invite-wrapper (client-id (buff 32))
                          (caller-id (string-ascii 64))
                          (user-id (string-ascii 64))
                          (user-is-admin bool)
                          (user-position (string-ascii 128))
                          (invite-hash (buff 32))
                          (admin-auth-optional (optional (string-ascii 64)))
                          (admin-optional-signed-data (optional (list 35 {signer: (buff 33), signature: (buff 65)}))))
    (let (
        (user-id-by-address (unwrap! (contract-call? .cf-helpers-state-v0 get-user-id-by-address tx-sender) ERR_CALLER_NOT_REGISTERED))
        (sender-caller-id (get user-id user-id-by-address))
        (sender-client-id (get client-id user-id-by-address))
        (user (unwrap! 
            (contract-call? .cf-helpers-state-v0 get-user (get client-id user-id-by-address) sender-caller-id)
            ERR_CALLER_NOT_REGISTERED))
        ) 
        ;; Check that caller is both tx-sender & contract-caller (calling through a contract)
        (asserts! (is-eq contract-caller tx-sender) ERR_FORWARDING_FORBIDDEN)
        ;; If adding an admin, signatures MUST be provided
        (asserts! 
            (is-eq sender-caller-id caller-id)
            ERR_CALLER_ID_MISMATCH
        )
        (asserts! (is-eq client-id sender-client-id) ERR_CLIENT_ID_MISMATCH)
        (asserts! 
            (get is-admin user)
            ERR_CALLER_NOT_ADMIN)
        ;; Execute non-admin or admin path
        (match admin-optional-signed-data
            signed-data
            (begin
                ;; Verify both admin-id-optional & admin-auth-optional were passed in
                (asserts! (is-some admin-auth-optional) ERR_INVALID_PARAMS)
                ;; Adding an admin, verification steps required
                ;; Verify signatures
                ;; Check that all signatures are valid & all signers are in the policy
                (try! (verify-admin-signature "Add" client-id  user-id (unwrap-panic admin-auth-optional) signed-data))
                ;; Check number of active admins
                (if (> (unwrap-panic (contract-call? .cf-helpers-state-v0 get-active-admins client-id)) u1)
                    ;; > 1, threshold required
                    (asserts! (>= (len signed-data) u2) ERR_THRESHOLD_NOT_MET)
                    ;; < 2, no threshold required
                    true
                )
                true
            )
            true
        )
        ;; Call into state contract to complete admin invite
        (try! (contract-call? .cf-helpers-state-v0 add-user-invite client-id caller-id invite-hash user-id user-position user-is-admin))
        (print {
            topic: "User Invite Added",
            client-id: client-id,
            caller-id: caller-id,
            user-id: user-id,
            user-position: user-position,
            invite-hash: invite-hash
        })
        (ok true)
    )
)
;; add-user-completion
;; This function, if successful, completes the user invite process by registering their address & key. 
;; It's important to note that this function is only callable by the new user.
;; @param client-id; The client's ID
;; @param caller-id; The caller's ID
;; @param invite-hash; The invite hash
;; @param invite-preimage-id; The invite preimage ID
;; @param new-user-key; The new user's key
(define-public (add-user-invite-complete-wrapper (client-id (buff 32))
                          (invite-hash (buff 32))
                          (invite-preimage-id uint)
                          (new-user-key (buff 33)))
    (begin
        ;; Check that all byte lengths are correct
        (asserts! (and (is-eq (len client-id) HASH_LENGTH) (is-eq (len invite-hash) HASH_LENGTH) (is-eq (len new-user-key) KEY_LENGTH)) ERR_INVALID_LENGTH)
        ;; Check that tx-sender is the new user address & tx-sender is the contract-caller
        (asserts! (is-eq (some contract-caller) (some tx-sender)) ERR_FORWARDING_FORBIDDEN)
        ;; Attempt to complete invite & add user
        (try! (contract-call? .cf-helpers-state-v0 add-user-invite-complete client-id invite-hash invite-preimage-id new-user-key))
        (ok true)
    )
)
;; remove-user-wrapper
;; This function, if successful, removes a user from the users map. 
;; This function also offers two security paths based on whether the removed-id is an admin or not.
;; @param client-id; The client's ID
;; @param user-id; The caller's ID
;; @param removed-user-id; The removed user's ID
;; @param admin-auth-optional; The admin auth optional
(define-public (remove-user-wrapper (client-id (buff 32))
                          (user-id (string-ascii 64))
                          (removed-user-id (string-ascii 64))
                          (admin-auth-optional (optional (string-ascii 64)))
                          (admin-optional-signed-data (optional (list 35 {signer: (buff 33), signature: (buff 65)}))))
    (begin
        ;; Check that client-id is correct length
        (asserts! (is-eq (len client-id) HASH_LENGTH) ERR_INVALID_LENGTH)
        ;; Check that caller is both tx-sender & contract-caller
        (asserts! (is-eq (some contract-caller) (some tx-sender)) ERR_FORWARDING_FORBIDDEN)
        (match admin-optional-signed-data
            signed-data
            (begin
                ;; Verify both admin-id-optional & admin-auth-optional were passed in
                (asserts! (is-some admin-auth-optional) ERR_INVALID_PARAMS)
                ;; Verify signatures
                ;; Check that all signatures are valid & all signers are in the policy
                (try! (verify-admin-signature "Remove"  client-id user-id (unwrap-panic admin-auth-optional) signed-data))
                ;; Check that signing threshold is met (2)
                (asserts! (>= (len signed-data) u2) ERR_THRESHOLD_NOT_MET)
                ;; Call into state contract to remove user
                (try! (contract-call? .cf-helpers-state-v0 remove-user client-id user-id removed-user-id (some (len signed-data))))
            )
            ;; Call into state contract to remove user
            (try! (contract-call? .cf-helpers-state-v0 remove-user client-id user-id removed-user-id none))
        )
        (print {
            topic: "User Removed",
            client-id: client-id,
            caller-id: user-id,
            removed-user-id: removed-user-id
        })
        (ok true)
    )
)
;; rotate-user-items
;; This function, if successful, rotates a user's address & key. 
;; This function also offers two security paths based on whether the user-id is an admin or not.
;; @param client-id; The client's ID
;; @param caller-id; The caller's ID
;; @param user-id; The user's ID
;; @param new-user-address; The new user's address
;; @param new-user-key; The new user's key
;; @param admin-auth-optional; The admin auth optional
;; @param admin-optional-signed-data; The admin optional signed data
(define-public (rotate-user-wrapper (client-id (buff 32))
                          (caller-id (string-ascii 64))
                          (user-id (string-ascii 64))
                          (new-user-address principal)
                          (new-user-key (buff 33))
                          (admin-auth-optional (optional (string-ascii 64)))
                          (admin-optional-signed-data (optional (list 35 {signer: (buff 33), signature: (buff 65)}))))
    (begin
        ;; Check that client-id is correct length
        (asserts! (is-eq (len client-id) HASH_LENGTH) ERR_INVALID_LENGTH)
        ;; Check that caller is both tx-sender & contract-caller
        (asserts! (is-eq (some contract-caller) (some tx-sender)) ERR_FORWARDING_FORBIDDEN)
        (match admin-optional-signed-data
            signed-data
            (begin
                ;; Verify both admin-id-optional & admin-auth-optional were passed in
                (asserts! (is-some admin-auth-optional) ERR_INVALID_PARAMS)
                ;; Verify signatures
                ;; Check that all signatures are valid & all signers are in the policy
                (try! (verify-admin-signature "Rotate" client-id user-id (unwrap-panic admin-auth-optional) signed-data))
                ;; Check that signing threshold is met (2)
                (asserts! (>= (len signed-data) u2) ERR_THRESHOLD_NOT_MET)
                ;; Call into state contract to rotate user items
                (try! (contract-call? .cf-helpers-state-v0 rotate-user client-id caller-id user-id new-user-address new-user-key (some (len signed-data))))
            )
            ;; Call into state contract to rotate user items
            (try! (contract-call? .cf-helpers-state-v0 rotate-user client-id caller-id user-id new-user-address new-user-key none))
        )
        (ok true)
    )
)

;; verify-admin-signature
;; This function & the it's helpers verify a batch of signatures reserved for admin actions.
;; This includes adding, removing, or rotating *admins*.
;; @param type; The type of action
;; @param admin-id; The admin's ID
;; @param auth-id; The auth ID
;; @param signed-data; The signed data
(define-private (verify-admin-signature (type (string-ascii 32))
                                    (client-id (buff 32))
                                    (admin-id (string-ascii 64))
                                    (auth-id (string-ascii 64))
                                    (signed-data (list 35 {signer: (buff 33), signature: (buff 65)})))
    (begin
        ;; verify fresh auth-id
        (asserts! (is-none (contract-call? .cf-helpers-state-v0 get-used-auth-ids client-id auth-id)) ERR_AUTH_ID_REPLAY)
        ;; Check that all signatures are valid, all signers are admins, and no duplicate signers
        (try! (fold verify-admin-signature-helper signed-data (ok {client-id: client-id, type: type, admin-id: admin-id, auth-id: auth-id, seen-signers: (list)})))
        ;; update auth-id
        (unwrap! (contract-call? .cf-helpers-state-v0 set-auth-id client-id "users" auth-id) ERR_AUTH_ID_REPLAY)
        (ok true)
    )
)
;; verify-admin-signature-helper
;; This function verifies an individual signature from the list of signatures for an attempted action.
;; @param signed-data; The signed data
;; @param signed-response; The signed response which includes items needed to recreate the message hash
(define-private (verify-admin-signature-helper (signed-data {signer: (buff 33), signature: (buff 65)})
                                  (signed-response (response {client-id: (buff 32), type: (string-ascii 32), admin-id: (string-ascii 64), auth-id: (string-ascii 64), seen-signers: (list 35 (buff 33))} uint)))
    (match signed-response
        ok-response
        (let (
            ;; Look up user by their signing key
            (signer-user-id (contract-call? .cf-helpers-state-v0 get-user-id-by-key (get signer signed-data)))
        )
            ;; verify no duplicate signers
            (asserts! (is-none (index-of? (get seen-signers ok-response) (get signer signed-data))) ERR_DUPLICATE_SIGNATURE)
            ;; verify signature
            (asserts! (read-valid-admin-signature (get type ok-response) (get admin-id ok-response) (get auth-id ok-response) (get signature signed-data) (get signer signed-data)) ERR_INVALID_SIGNATURE)
            ;; verify signer is a registered user
            (asserts! (is-some signer-user-id) ERR_SIGNER_NOT_REGISTERED)
            ;; verify signer belongs to this client and is an active admin
            (let (
                (signer-info (unwrap-panic signer-user-id))
                (signer-user (contract-call? .cf-helpers-state-v0 get-user (get client-id signer-info) (get user-id signer-info)))
            )
                ;; verify signer belongs to same client
                (asserts! (is-eq (get client-id signer-info) (get client-id ok-response)) ERR_SIGNER_WRONG_CLIENT)
                ;; verify signer user exists and is active admin
                (asserts! (is-some signer-user) ERR_SIGNER_NOT_FOUND)
                (let ((user-data (unwrap-panic signer-user)))
                    (asserts! (get active user-data) ERR_SIGNER_INACTIVE)
                    (asserts! (get is-admin user-data) ERR_SIGNER_NOT_ADMIN)
                )
            )
            ;; Return with signer added to seen-signers list
            (ok (merge ok-response {seen-signers: (unwrap-panic (as-max-len? (append (get seen-signers ok-response) (get signer signed-data)) u35))}))
        )
        err-response
        (err err-response)
    )
)

;; read-valid-admin-signature
;; This helper function reads & validates an individual signature with 'secp256k1-verify'. This is a read-only
;; so for off-chain testing purposes.
;; @param type; The type of action ("add", "remove", "rotate")
;; @param admin-id; The admin's ID
;; @param auth-id; The auth ID
;; @param signature; The signature
;; @param signer-key; The signer's key
(define-read-only (read-valid-admin-signature (type (string-ascii 32))
                                        (admin-id (string-ascii 64))
                                        (auth-id (string-ascii 64))
                                        (signature (buff 65))
                                        (signer-key (buff 33)))

        (secp256k1-verify (get-admin-signature-message-hash type admin-id auth-id) signature signer-key)
)

;; get-admin-signature-message-hash
;; Generate a message hash for validating a signer key. The message hash follows SIP018 for signing structured data.
;; The domain is `{ name: "cofund-signer", version: "1.0.0", chain-id: chain-id }`.
;; The message is '{ admin-id: admin-id, auth-id: auth-id, type: type }'.
;; @param type; The type of action ("add", "remove", "rotate")
;; @param admin-id; The admin's ID
;; @param auth-id; The auth ID
(define-read-only (get-admin-signature-message-hash (type (string-ascii 32))
                                                    (admin-id (string-ascii 64))
                                                    (auth-id (string-ascii 64)))
  (sha256 (concat
    SIP018_MSG_PREFIX
    (concat
      (sha256 (unwrap-panic (to-consensus-buff? { name: "cofund-signer", version: "1.0.0", chain-id: u1 })))
      (sha256 (unwrap-panic
        (to-consensus-buff? {
          admin-id: admin-id,
          auth-id: auth-id,
          type: type,
        })))
    ))
  )
)

Functions (7)

FunctionAccessArgs
add-user-invite-wrapperpublicclient-id: (buff 32
add-user-invite-complete-wrapperpublicclient-id: (buff 32
remove-user-wrapperpublicclient-id: (buff 32
rotate-user-wrapperpublicclient-id: (buff 32
verify-admin-signatureprivatetype: (string-ascii 32
read-valid-admin-signatureread-onlytype: (string-ascii 32
get-admin-signature-message-hashread-onlytype: (string-ascii 32