This guide demonstrates how to establish secure communication between Pact contracts. We will use the staking and aggregator contracts from the eckoDEX stack as an example. The respective contract addresses are kaddex.aggregator
and kaddex.staking
, and both contracts are deployed on chain 2.
For reference, view the contracts here:
In this setup, the aggregator contract handles functionalities such as tracking voting power, while the staking contract manages user staking positions. Users accrue voting power by staking their KDX tokens. Consequently, the staking contract’s stake
and unstake
entrypoints must update a table in the aggregator contract. Furthermore, the staking contract should be the only entity capable of updating this table. Since Pact does not allow direct writing to tables or invoking capabilities from external contracts, we use the following strategy to enable secure cross-contract communication.
The aggregator module is designed to be called by the staking module, so we must verify that calls originate from the staking contract. To achieve this, a table is created to store the guards authorized to execute sensitive functions. This is defined at line 16 of aggregator.pact
:
;; aggregator.pact lines 16-21
(defschema privilege
guards:[guard]
action:string)
(deftable privilege-table:{privilege})
This table holds a list of guards for each entry. The following function verifies the guards by ensuring that at least one guard passes before a privileged action is executed:
;; aggregator.pact lines 83-87
(defun or-guard:bool
( a:bool
b:guard )
(or a (try false (enforce-guard b)))
)
The check-privilege
function reads an entry from the privilege table and uses the or-guard
function to confirm that the required privileges are present:
;; aggregator.pact lines 74-81
(defun check-privilege:bool (action:string)
@doc "Enforce privilege utility function"
(with-default-read privilege-table action
{ 'guards: [] }
{ 'guards := guards }
(fold (or-guard) false guards)
)
)
Subsequently, the enforce-privilege
function calls check-privilege
and enforces that the privilege is granted:
;; aggregator.pact lines 66-72
(defun enforce-privilege:bool (action:string)
@doc "Checks privilege for a given action"
(let ((privilege-granted (check-privilege action)))
(enforce privilege-granted (format "Could not obtain privilege on action {}" [action]))
)
)
Two distinct actions are defined for staking and unstaking:
;; aggregator.pact lines 23-24
(defconst ACTION_AGGREGATE_STAKE:string 'aggregate-stake)
(defconst ACTION_AGGREGATE_UNSTAKE:string 'aggregate-unstake)
The following function grants a guard the privilege to perform a specific action. Note that the OPS capability ensures that only contract operators can execute this function:
;; aggregator.pact lines 53-64
(defun grant-privilege:string (g:guard action:string)
@doc "Grants a guard the privilege to perform a specific action."
(with-capability (OPS)
(with-default-read privilege-table action
{'guards:[],'action:action}
{'guards:=guards-read, 'action:=action-read}
(write privilege-table action{
'guards:(+ guards-read [g]),
'action:action
})
)
)
)
The following functions update the aggregator table in response to staking actions. Privileged operations are executed within the scope of the PRIVILEGE-GUARD
capability.
;; aggregator.pact lines 116-145
(defun aggregate-stake:string (requesting-account:string amount:decimal)
@doc "Determines whether an account is staking for the first time or has already staked, then updates the stake accordingly."
(with-capability (PRIVILEGE-GUARD ACTION_AGGREGATE_STAKE)
(with-capability (AGGREGATE)
(with-default-read staking-aggregator-table requesting-account
{"account": "", "start-time": (curr-time), "staked-amount": 0.0, "shift": 0.0}
{"account": =account, "start-time": =start-time, "staked-amount": =staked-amount, "shift": =shift}
(if (= account "")
(create-new-stake-row requesting-account amount)
(if (= staked-amount 0.0)
(update-account-entering-staking-again account amount)
(update-old-stake-row account amount start-time staked-amount shift)
)
)
)
)
)
)
(defun aggregate-unstake:string (requesting-account:string unstaked-amount:decimal)
@doc "Called whenever an account initiates an unstaking action. It calls update-when-unstaking to record data in the staking-aggregator table."
(with-capability (PRIVILEGE-GUARD ACTION_AGGREGATE_UNSTAKE)
(with-capability (AGGREGATE)
(with-default-read staking-aggregator-table requesting-account
{"account": "", "start-time": (curr-time), "staked-amount": 0.0, "shift": 0.0}
{"account": =account, "start-time": =start-time, "staked-amount": =staked-amount, "shift": =shift}
(enforce (!= account "") "Account does not exist")
(update-when-unstaking account unstaked-amount start-time staked-amount shift)
)
)
)
)
The staking contract defines a "magic" capability, AGGREGATOR_NOTIFY
, which allows it to notify the aggregator contract:
;; staking.pact line 55
(defcap AGGREGATOR_NOTIFY () true)
Before updating the aggregator’s state, the following function enforces this capability:
;; staking.pact line 62
(defun enforce-aggregator-update () (require-capability (AGGREGATOR_NOTIFY)))
A guard for interacting with the aggregator is created using this special capability:
;; staking.pact line 151
(defun aggregator-guard:guard () (create-user-guard (enforce-aggregator-update)))
During deployment, the staking contract grants the necessary privileges as follows:
;; staking.pact lines 891-892
(kaddex.aggregator.grant-privilege (kaddex.staking.aggregator-guard) "aggregate-stake")
(kaddex.aggregator.grant-privilege (kaddex.staking.aggregator-guard) "aggregate-unstake")
Both staking and unstaking methods call protected external functions to securely update the aggregator's table. For example:
;; staking.pact lines 431-432
(with-capability (AGGREGATOR_NOTIFY)
(kaddex.aggregator.aggregate-unstake account unstake-amount))
;; staking.pact lines 597-598
(with-capability (AGGREGATOR_NOTIFY)
(kaddex.aggregator.aggregate-stake account to-add))
This approach demonstrates how to enable communication between two contracts without creating circular references. In this setup, privileged functions in the aggregator contract can only be called within the scope of the AGGREGATOR_NOTIFY
capability defined in the staking module. The strategy involves:
- Aggregator Contract: Managing a privilege table that enforces access control using guards.
- Staking Contract: Utilizing a special capability to update the aggregator's state.
- Guard Creation: Creating a guard using the staking contract’s capability to ensure that only authorized updates are performed.
A similar approach is employed by Kadena Cabinet to synchronize the bonder and poller contracts. For more details, see the Kadena Cabinet GitHub repository.
Many thanks to @aserranoni for their contributions and suggestions that improved this documentation.
🥳