🇺🇸 English
May 9, 2024
This software functions as an Autonomous Investment Agent (AIA) designed to execute trades using a dynamic dollar-cost-average (DCA) investment strategy across various liquidity pools within the AO ecosystem. The agent automatically buys a base token at predetermined, user-configurable intervals using a consistent amount of a quote token for each transaction.
A standout feature is its functional autonomy, unique to the AO network, which is essential for autonomous functionality. Once initiated, it runs autonomously on the AO platform without the need for off-chain signals or human intervention.
The management interface of the DCA agent is facilitated through a frontend hosted on the Arweave's Permaweb and operates without the need for trusted intermediaries.
With its design and technology stack, the DCA Agent is the first fully decentralized and uncensorable AgentFi application of its type. Once deployed, agents will operate as programmed and can only be deactivated by their creators.
Visit the latest DCA Agent on Permaweb or fork the code on GitHub.
Begin by configuring your agent upon creation:
Configuration involves setting DCA parameters, defining an acceptable slippage range, and selecting the DEX (AMM liquidity pool) for the swaps.
Your agent will execute DCA buys provided there are sufficient quote token funds deposited:
The dashboard offers agent owners:
The DCA agent acts as a basic blueprint for further development, aimed at facilitating more sophisticated multi-agent configurations. While in its initial stages and continuously improving, it performs most of the application-specific heavy lifting, providing visionary builders with a significant head start.
As the AO's Autonomous Finance ecosystem evolves, we anticipate the development of more advanced and intricate designs for the DCA agent and AIAs in general. With its high configurability and composability, the DCA agent is designed to be easily customized and integrated into multi-agent configurations. Imagine an AIA that includes a DCA component as part of its strategy by simply integrating a DCA agent.
Below are some thoughts on the future capabilities and composability of the DCA agent.
The DCA agent is capable of executing advanced trading strategies based on highly customizable parameters:
The current iteration of the DCA agent operates using a single liquidity pool for swaps. However, plans for diversifying liquidity sources and developing a global RFQ system are underway. Such enhancements will not only automate the buying process but also ensure the aggregation of the best possible swap prices across DEXs and private market makers.
The DCA agent is already highly composable, but it's possible to explore alternative designs for even greater flexibility. One such approach involves agents that never take custody of user funds. In this model, multiple specialized agents are granted permission to access user funds only when profitable opportunities arise. For example, a DCA agent might access permissioned funds, execute a profitable transaction, and then return the funds to the user's self-custodied wallet.
This approach significantly enhances capital efficiency. Users aren't required to lock their capital into a specific strategy or pool; instead, they authorize agents to use a predefined amount of funds, which remain accessible to the users until utilized for a profitable action by any of the agents.
Through the initial development of the DCA agent, we have gained a deeper understanding of the AO platform, encompassing both its capabilities and limitations. This process has also led to significant insights into effective design patterns suitable for DCA Agent development.
We will elaborate on these insights in an upcoming article.
This project serves as a foundational example within the emerging AgentFI domain, illustrating how autonomous investments can be managed by a trustless, fully on-chain process without the need for third-party tools or infrastructure. It aims to provide a starting point for the community to build more complex agents.
Our intention is to iterate upon this base with more robust designs, using advanced patterns that will be described in further articles and blueprints.
The current iteration of the project includes:
Looking forward, we plan to expand the functionality to include tighter integration with DEXI - our application that allows DeFi users to navigate and discover the AO ecosystem.
Future iterations of the agent could include capabilities for:
Providing a visualization of the DCA agent's activity history, complete with links to AOLINK, would allow owners to inspect each swap via its unique ID.
The AO platform uniquely supports automation through native crons, distinguishing itself in the realm of dApp development. Developers can set up cron jobs that operate entirely on-chain, a crucial feature for achieving full autonomy in financial agents.
Given the ongoing efforts to enhance the power and reliability of cron jobs, it's premature to finalize best practices. However, we can share one insight that seems pertinent at this current stage.
We have devised a pattern known as Direct Trigger for utilizing crons in straightforward scenarios where the process requires only one action that needs regular execution. In such cases, the process is tagged at the time of creation (or spawning) to receive cron ticks. The frequency of these ticks is determined by a single parameter.
Direct Trigger
// spawning cron process from @permaweb/ao-connect
const signer = createDataItemSigner(window.arweaveWallet)
const process = await ao.spawn({
module: "SBNb1qPQ1TDwpD_mboxm2YllmMLXpWw4U8P9Ff8W9vk",
scheduler: "_GQ33BkPtZrqxA84vM8Zk-N2aO0toNNu_C-l-rawrBA",
signer,
// -- configure cron
tags: [
{ name: "Cron-Interval", value: "1-minute" },
{ name: "Cron-Tag-Action", value: "TriggerSwap" },
],
})
// Turn on monitoring to activate cron triggering
await monitor({
process,
signer,
})
/*
* As a result, our process starts receiving cron ticks once per minute.
* If it has a handler that matches { "Action" : "TriggerSwap" }
* these ticks will execute said handler.
*/
-- process.lua
Handlers.add(
"triggerSwap",
function(msg)
-- ensure only authentic cron ticks are matched
return Handlers.utils.hasMatchingTag("Action", "TriggerSwap")(msg)
and msg.Cron
end,
function(msg)
-- ... perform swap
end
)
In scenarios that demand multiple types of actions to be executed regularly, we employ an Indirect Trigger pattern. This approach uses separate processes that function as trigger-notifiers. These are simple cron-powered agents that remind the main process to perform its tasks.
A practical use case for this is a DCA agent designed to manage retrying failed message deliveries. This agent:
Indirect Trigger
-- swap_notifier.lua
Agent = Agent or nil -- set after creation via dedicated handler
Handlers.add(
"cron",
function(msg)
return Handlers.utils.hasMatchingTag("Action", "Cron")(msg)
and msg.Cron
end,
function(msg)
ao.send({Target = Agent})
end
)
-- retry_notifier.lua
-- analogous to swap_notifier.lua, but handler execution is
-- ...
ao.send({Target = Agent})
-- ...
-- process.lua
SwapNotifier = SwapNotifier or nil -- set via handler after notifier is spawned
RetryNotifier = SwapNotifier or nil -- set via handler after notifier is spawned
Handlers.add(
"triggerSwap",
function(msg)
return msg.From == SwapNotifier
end,
function(msg)
-- ... perform swap
end
)
Handlers.add(
"checkQueue",
function(msg)
return msg.From == RetryNotifier
end,
function(msg)
-- ... perform check on retry queue
end
)
When implementing the Indirect Trigger pattern, it becomes crucial for the main process to recognize the identities of its trigger-notifiers, ensuring it cannot be activated by impostors.
Additionally, this pattern offers a strategic advantage in updating cron tick intervals, which is not natively possible within a single-cron-action scenario. Although cron intervals are set as process tags at inception and cannot be changed afterward, the use of Indirect Triggers allows for flexibility. By simply spawning a new trigger-notifier with a different interval, it becomes trivial to adjust the timing of the tasks as needed.
Cron processes need active monitoring to function correctly and ensure real-time triggering. The capability to monitor and unmonitor can effectively toggle real-time cron triggering on and off. Currently, monitoring can only be initiated or stopped through a process from AO Connect, not from within a Lua handler.
Occasionally, messages triggered by cron execution might slip past the MU, causing slight inconsistencies or delays in how triggers are executed. We anticipate improvements will lead to greater stability as we approach the mainnet launch.
In the following sections, we will primarily explore the AO processes integral to our project. We may also highlight Frontend features or delve into implementation details where they add technical value.
Our project is structured around two critical AO processes essential for the DCA Agent application:
Agent Process with Cron Trigger
**AO Process (Backend) **
These processes interact with the following components:
DCA Agent, Backend and related processes
The diagram below illustrates the architectural flow for a DCA buy, where a swap from quote to base token occurs. This visualization helps to detail the steps and interactions within such transactions.
Steps for a DCA buy
A similar sequence of messages would be involved for swaps in the opposite direction (base to quote), which plays a crucial role in the liquidation of an agent’s assets.
The Agent Process securely holds funds, requiring deposits to facilitate DCA buys. Upon executing swaps, it retains the resulting assets, ensuring that all assets remain liquid (i.e., available for withdrawal by the user at any time).
Currently, the ao bakcend processand & the agent are tightly coupled. We envision standardizing both components such that they could be easily swapped with others while maintaining system compatibility. However, given our specific goal to integrate these agents with DEXI, we have decided to defer the standardization of the DCA agent and backend to future iterations.
The frontend leverages Arweave Gateway for interactions with AO processes. It engages with:
Overview of the Frontend Architecture
The web frontend facilitates basic agent management and interaction, supporting multiple agents per user. To maintain independence from traditional web2 backends that and enhance decentralization, the UI is designed to be deployed as a static website on the permaweb. All persisted data resides on AO, maintained within the state of AO processes.
For simplicity and to streamline operations, agents are spawned independently and their owners can register them with the backend without any access control. This setup is secure because the backend requires no access control in its current configuration; it cannot be effectively abused. Registered agents are associated with their owner's account, preventing the possibility of polluting other users' data with unauthorized registrations.
An alternative approach could involve a factory setup where agents are directly spawned from the backend. For more details on our decision against this method, see the discussion here.
-- backend.lua
Handlers.add(
'registerAgent',
Handlers.utils.hasMatchingTag('Action', 'RegisterAgent'),
registration.registerAgent
)
-- registration.lua
mod.registerAgent = function(msg)
local agent = msg.Tags.Agent
-- ...
local sender = msg.From
-- 👇 registration only affects sender-related data
-- => ok to do without access control
RegisteredAgents[agent] = msg.From
AgentsPerUser[sender] = AgentsPerUser[sender] or {}
AgentInfosPerUser[sender] = AgentInfosPerUser[sender] or {}
table.insert(AgentsPerUser[sender], agent)
table.insert(AgentInfosPerUser[sender], {
-- ...
})
response.success("RegisterAgent")(msg)
end
While it's feasible to design the backend as a factory that spawns agents, we found this approach to be more cumbersome for users. Consequently, we opted for a non-factory setup. However, if the need arises for access-controlled registration, reverting to a factory-based approach could be a viable solution.
Here is how agent creation and registration would function in the factory version:
Changes on the frontend:
This approach ensures a streamlined process that integrates security and user convenience, should there be a future shift towards a factory setup.
In our implementation, users typically start as the owners of an agent, but ownership can be transferred at the initialization phase, allowing the owner to also be another process. This flexibility supports various operational scenarios, such as:
Alternative Ownership Model: An interesting alternative ownership model involves an agent that does not hold funds itself but is authorized to access funds from another source, such as a different process that maintains these funds.
Use Case Example: As an owner, I could utilize multiple agents to manage investments without needing to distribute funds among them. This is particularly useful if the agents do not invest at fixed intervals but respond to unpredictable signals. Such a setup not only reduces operational overhead but also leads to a more efficient usage of available funds, enhancing the overall capital efficiency.
In AO, every process has a global variable Owner
, which is automatically assigned when the process is created. The owner of a process can be either a wallet or another process.
Ownership can be transferred by updating the value of Owner
. We incorporate this mechanism into our application by enhancing our Handlers with functionality similar to the onlyOwner
modifier commonly used in Solidity smart contracts, ensuring actions are restricted to the process owner.
-- process.lua
Handlers.add(
"retire",
Handlers.utils.hasMatchingTag("Action", "Retire"),
function(msg)
permissions.onlyOwner(msg)
lifeCycle.retire(msg)
end
)
...
-- permissions.lua
mod.onlyOwner = function(msg)
assert(msg.From == Owner, "Only the owner is allowed")
end
Unlike Ethereum smart contracts, AO Processes do not have a constructor. Therefore, any initialization necessary for the operation of an agent must be performed through a subsequent message. This message functions like a regular message but is specifically designed to update the agent's state.
To manage initialization status, we employ an IsInitialized
flag. Processes that are not yet initialized will refuse to handle (almost) any message. They are configured to only allow:
This selective message handling is facilitated by placing a "catch-all" message handler at the top of the Handlers list. This setup acts as a gatekeeper, ensuring that the process cannot perform or respond to any actions until it has been properly initialized.
-- msg to be sent by end user or another process
Handlers.add(
"initialize",
Handlers.utils.hasMatchingTag("Action", "Initialize"),
lifeCycle.initialize
)
-- ! every handler below is gated on Initialized == true
Handlers.add(
"checkInit",
function(msg)
return not Initialized -- the match is positive if we're not initialized yet
end,
response.errorMessage("error - process is not initialized")
)
In our DCA agent application, while the user who creates the agent is typically intended to be the owner, we aim for maximum composability and setup flexibility. This approach allows for other processes to be designated as DCA agent owners. Moreover, we are exploring future integrations with DEXI, enabling users to create agents through the DEXI UI potentially using trusted agent factories. This setup would ideally separate the roles of who spawns the process, who becomes its proper owner, and who performs the initialization.
In smart contract development, it's often crucial to have strict access control over initialization, ensuring only the designated owner can perform it. However, we chose a more relaxed approach to maintain simplicity.
To simplify our design, we've merged the "proper owner initialization" and "DCA config initialization" into a single "Initialize" action. Until this initialization is performed, the agent remains "useless." The sender of the "Initialize" message automatically becomes the proper owner of the process.
This design choice does theoretically open up the possibility for malicious sabotage. If someone else initializes the process before the intended owner, they could take control. Nevertheless, we assess that this risk of front running is significantly lower on AO compared to other global state VM platforms. For more on the security aspects and why this concern is less prominent on AO, see our detailed security discussion.
In our system, all crucial data is stored in the process state directly on-chain, eliminating the need for queries via Arweave gateways.
From the backend process, we can effortlessly retrieve a list of all available agents per user, including essential information such as a history of buys and sells.
To ensure atomicity and isolation for the multi-step actions performed on assets (such as DCA buys, withdrawals, and liquidations), our agent loosely employs a state machine model, functioning similarly to a mutex. This model also keeps track of related successes and failures.
IsSwapping = IsSwapping or false
IsWithdrawing = IsWithdrawing or false
IsDepositing = IsDepositing or false
IsLiquidating = IsLiquidating or false
LastWithdrawalNoticeId = LastWithdrawalNoticeId or nil
LastDepositNoticeId = LastDepositNoticeId or nil
LastLiquidationNoticeId = LastLiquidationNoticeId or nil
LastSwapNoticeId = LastSwapNoticeId or nil
LastWithdrawalError = LastWithdrawalError or nil
LastLiquidationError = LastLiquidationError or nil
LastSwapError = LastSwapError or nil
A more advanced state machine would facilitate tracking the progress of each specific multi-step action. This enhancement would be particularly beneficial for developing a UI that provides users with accurate progress updates on any particular action.
Currently, the isolation provided by our agent is quite basic. If an attempt is made to perform an asset action while another is already in progress, the agent simply returns an error.
mod.checkNotBusy = function()
local flags = json.encode({
IsSwapping = IsSwapping,
IsDepositing = IsDepositing,
IsWithdrawing = IsWithdrawing,
IsLiquidating = IsLiquidating
})
if IsDepositing or IsWithdrawing or IsLiquidating or IsSwapping then
response.errorMessage(
"error - process is busy with another action on funds" .. flags
)()
end
end
Here is an example of how the check is used to achieve the mutex-like behaviour:
Handlers.add(
"withdrawQuoteToken",
Handlers.utils.hasMatchingTag("Action", "WithdrawQuoteToken"),
function(msg)
permissions.onlyOwner(msg)
status.checkNotBusy()
progress.startWithdrawal(msg)
withdrawals.withdrawQuoteToken(msg)
end
)
A more optimal behavior would be to queue incoming messages that trigger asset actions, rather than rejecting them. This change would alleviate the need for the triggering entity to retry the action, enhancing user experience and system efficiency.
While developing with Javascript for the AO platform, it's important to note the absence of straightforward methods to subscribe to AO events—a feature commonly utilized in EVM development via smart contract events and Javascript libraries like web3 and ethers.
In the case of our DCA Agent UI, an approach involving polling the Arweave Gateway endpoints for AO messages with specific tags could have been possible. However, given our agent's substantial state management within its process memory, we opted to poll updates directly from our agent process via dryRun
calls. This choice allows our UI to more accurately reflect the real-time state of the agent, despite requiring additional overhead in managing React hooks to detect state changes. This complexity is currently manageable given the complexity of the agent.
For the DCA Agent, and particularly for more sophisticated agents, it's crucial to maintain a real-time awareness of data pertinent to their operation that is persisted externally. For instance, an agent needs to know its own balance of a specific token to make timely decisions based on incoming signals.
While an agent might track such state locally, it must also be vigilant about potential inconsistencies due to the absence of ordering guarantees inherent in distributed computing platforms. Having this data readily available helps in making effective real-time decisions, though the agent must continually check for and reconcile any discrepancies.
Consider an example where an agent is programmed to respond to two types of signals, A and B. Signal A represents exceptional, highly profitable opportunities, while Signal B occurs regularly. When Signal A is detected, the agent should execute a specific swap using its funds. However, these funds are also earmarked for activities triggered by Signal B. The challenge lies in maximizing fund usage for Signal A while ensuring sufficient reserves remain for Signal B.
This scenario underscores the necessity for the agent to maintain an accurate awareness of its token balance, which might fluctuate due to unrecorded deposits or potential withdrawals by third parties, should AO token processes ever support approvals similar to ERC20 tokens.
Although this specific challenge has only arisen once, it represents a real risk of loss of opportunity and could impact the agent's competitive edge. Therefore, addressing it is crucial.
Solution:
To manage this, the agent listens for any Credit-Notice
or Debit-Notice
from the token process, responding with a {"Action" : "Balance"}
message to update its balance. It then updates its internal balance based on the response tagged with "Balance."
However, due to the lack of guaranteed message ordering, there's a risk that responses may not correspond to the most recent balance inquiries. This could erroneously affect the locally mirrored balance.
Our approach mitigates this by tracking the timestamp of each balance update. If a new update's timestamp is not later than the last, the agent disregards this update, ensuring the integrity and accuracy of the balance information.
mod.latestBalanceUpdateBaseToken = function(msg)
-- balance responses may come in any order, so we disregard delayed ones (possibly stale values)
if (msg.Timestamp > LatestBaseTokenBalTimestamp) then
LatestBaseTokenBal = msg.Balance
LatestBaseTokenBalTimestamp = msg.Timestamp
ao.send({ Target = Backend, Action = "UpdateBaseTokenBalance", Balance = msg.Balance })
end
end
In typical decentralized finance platforms, the predictable nature of swap timings and amounts by a DCA agent might lead to front running concerns, especially within global state VMs where transactions can be precisely ordered to the benefit of an attacker, enabling tactics like sandwich attacks. However, on AO, the scenario is quite different.
Front running is considered unlikely on AO due to the lack of guarantees around transaction ordering. While an attacker might attempt to execute a sandwich attack by timing their transactions around the DCA agent's scheduled swaps, such maneuvers are visible to others who could potentially front run the attacker themselves, thus negating the profitability of the attack. This inherent risk makes front running a less viable strategy on AO compared to traditional DeFi settings.
When determining the expected output of a swap, the AMM pool itself can serve as a reliable source for pricing on AO. This contrasts with EVM-based systems, where relying on the pool's price during swap executions could expose users to exploitation by front runners.
On platforms with global state VMs, DeFi applications typically rely on external oracles to mitigate risks associated with manipulated pricing. These oracles themselves then become potential attack vectors. Given the reduced feasibility of front running on AO, the necessity for external price oracles diminishes, allowing us to rely directly on AMM pool prices without adopting the external oracle best practices common in other DeFi environments.
One of the key challenges in designing the agent process revolves around the coding and handling of incoming messages. Correctly identifying the purpose of each message is crucial for appropriate response actions.
A Credit-Notice
received from the Quote Token process might indicate several scenarios:
To address this complexity, we have opted to enhance the granularity of our matching functions within the handlers. This approach allows us to:
This differentiation in message handling ensures that each action is processed accurately, enhancing the overall robustness and clarity of the agent's operations.
-- process.lua
--[[
This file defines all the handlers.
It should give a clear overview for understanding
everything the process can do in terms of handling messages
--]]
--[[
The handler below matches Credit-Notice messages from the BaseToken,
but is not the only one doing so ==>> we use the patterns.continue()
wrapper to allow for matching of other handlers, too
]]
Handlers.add(
"balanceUpdateCreditBaseToken",
patterns.continue(function(msg)
return Handlers.utils.hasMatchingTag("Action", "Credit-Notice")(msg)
and msg.From == BaseToken
end),
balances.balanceUpdateCreditBaseToken
)
--[[
The handler below is another Credit-Notice matcher for messages from
BaseToken. It uses an imported function to perform a match,
but keeps the matching of the 'Action' Tag right here in explicit form,
so that we can rapidly find all the 'Credit-Notice' handlers of the process.
Furthermore, using the patterns.continue() wrapper has the effect that this
--]]
Handlers.add(
'swapBackErrorByPool',
function(msg)
return Handlers.utils.hasMatchingTag('Action', 'Credit-Notice')(msg)
and liquidation.isSwapBackErrorByRefundCreditNotice(msg)
end,
progress.concludeLiquidationOnErrorByRefundCreditNotice
)
-- liquidation.lua
mod.isSwapBackErrorByRefundCreditNotice = function(msg)
return msg.From == BaseToken
and msg.Sender == Pool
and msg.Tags["X-Refunded-Transfer"] ~= nil
end
In developing our agent, we strategically decided to omit some best practices that are typically involved in creating a production-grade agent on AO. This was done to concentrate our efforts on addressing the more challenging issues effectively.
Input Validation:
Type Support for Messages and Handlers:
msg.From
, PatternFunction
, etc.).User Experience on the Web Frontend:
Start/Pause Functionality and State Transitions:
Defensive Programming:
These omissions were made with a focus on delivering a functional prototype that addresses the core functionalities while acknowledging the areas where future enhancements could be made.
Thank you for taking the time to explore the intricacies of our DCA agent development on AO. This article aims to provide transparency in our development process, sharing both our current achievements and the areas we're looking to enhance. We hope this insight fosters a deeper understanding of the challenges and potential solutions in decentralized Agent development, pushing the AgentFi domain.
Stay tuned for more updates as we continue to refine our technology and explore new possibilities. We're committed to advancing the field and sharing our learnings along the way.
For further information, to contribute, or to stay connected with our progress, please visit the following resources:
We're excited about what the future holds. Expect more detailed articles and updates as we push the boundaries of what's possible with decentralized finance!