Introduction to Flow Actions
Flow Actions are being reviewed and finalized in FLIP 339. The specific implementation may change as a part of this process.
These tutorials will be updated, but you may need to refactor your code if the implementation changes.
Actions are a suite of standardized Cadence interfaces that enable developers to compose complex workflows, starting with DeFi, by connecting small, reusable components. Actions provide a "LEGO" framework of plug-and-play blocks where each component performs a single operation (deposit, withdraw, swap, price lookup, flash loan) while maintaining composability with other components to create sophisticated workflows executable in a single atomic transaction.
By using Flow Actions, developers are to able remove large amounts of bespoke complexity from building DeFi apps and can instead focus on business logic using nouns and verbs.
Key Features
- Atomic Composition - All operations complete or fail together
- Weak Guarantees - Flexible error handling, no-ops when conditions aren't met
- Event Traceability - UniqueIdentifier system for tracking operations
- Protocol Agnostic - Standardized interfaces across different protocols
- Struct-based - Lightweight, copyable components for efficient composition
Learning Objectives
After completing this tutorial, you will be able to:
- Understand the key features of Flow Actions including atomic composition, weak guarantees, and event traceability
- Create and use Sources to provide tokens from various protocols and locations
- Create and use Sinks to accept tokens up to defined capacity limits
- Create and use Swappers to exchange tokens between different types with price estimation
- Create and use Price Oracles to get price data for assets with consistent denomination
- Create and use Flashers to provide flash loans with atomic repayment requirements
- Use UniqueIdentifiers to trace and correlate operations across multiple Flow Actions
- Compose complex DeFi workflows by connecting multiple Actions in a single atomic transaction
Prerequisites
Cadence Programming Language
This tutorial assumes you have a modest knowledge of Cadence. If you don't, you'll be able to follow along, but you'll get more out of it if you complete our series of Cadence tutorials. Most developers find it more pleasant than other blockchain languages and it's not hard to pick up.
Flow Action Types
The first five Flow Actions implement five core primitives to integrate external DeFi protocols.
- Source: Provides tokens on demand (e.g. withdraw from vault, claim rewards, pull liquidity)
- Sink: Accepts tokens up to capacity (e.g. deposit to vault, repay loan, add liquidity)
- Swapper: Exchanges one token type for another (e.g. targeted DEX trades, multi-protocol aggregated swaps)
- PriceOracle: Provides price data for assets (e.g. external price feeds, DEX prices, price caching)
- Flasher: Provides flash loans with atomic repayment (e.g. arbitrage, liquidations)
Connectors
Connectors create the bridge between the standardized interfaces of Flow Actions and the often bespoke and complicated mechanisms of different DeFi protocols. You can utilize existing connectors written by other developers, or create your own.
Flow Actions are instantiated by creating an instance of the appropriate [struct] from a connector that provides the desired type of action connected to the desired DeFi protocol.
Read the connectors article to learn more about them.
Token Types
In Cadence, tokens that adhere to the Fungible Token Standard have types that work with type safety principles.
For example, you can find the type of $FLOW by running this script:
_10import "FlowToken"_10_10access(all) fun main(): String {_10 return Type<@FlowToken.Vault>().identifier_10}
You'll get:
_10A.1654653399040a61.FlowToken.Vault
These types are used by many Flow Actions to provide a safer method of working with tokens than an arbitrary address that may or may not be a token.
Flow Actions
The following Flow Actions standardize usage patterns for common defi-related tasks. By working with them, you - or ai agents - can more easily write transactions and functionality regardless of the myriad of different ways each protocol works to accomplish these tasks.
That being said, defi protocols and tools operate very differently, which means the calls to instantiate the same kind of action connected to different protocols will vary by protocol and connector.
Source
A source is a primitive component that can supply a vault containing the requested type and amount of tokens from something the user controls, or has authorized access to. This includes, but is not limited to, personal vaults, accounts in protocols, and rewards.
You'll likely use one or more sources in any transactions using actions if the user needs to pay for something or otherwise provide tokens.
Sources conform to the Source
interface:
_10access(all) struct interface Source : IdentifiableStruct {_10 /// Returns the Vault type provided by this Source_10 access(all) view fun getSourceType(): Type_10 /// Returns an estimate of how much can be withdrawn_10 access(all) fun minimumAvailable(): UFix64_10 /// Withdraws up to maxAmount, returning what's actually available_10 access(FungibleToken.Withdraw) fun withdrawAvailable(maxAmount: UFix64): @{FungibleToken.Vault}_10}
In other words, every source is guaranteed to have the above functions and return types allowing you to get the type of vault returned by the source, get an estimate of how many tokens may be withdrawn currently, and actually withdraw those tokens, up to the amount available.
Sources degrade gracefully - If the requested amount of tokens is not available, they return the available amount. They always return a vault, even if that vault is empty.
You create a source by instantiating a struct that conforms to the Source
interface corresponding to a given protocol connector. For example, if you want to create a source from a generic vault, you can do that by creating a VaultSource
from FungibleTokenConnectors
:
_20import "FungibleToken"_20import "FungibleTokenConnectors"_20_20transaction {_20_20 prepare(acct: auth(BorrowValue) {_20 let withdrawCap = acct.storage.borrow<auth(FungibleToken.Withdraw) {FungibleToken.Vault}>(_20 /storage/flowTokenVault_20 )_20_20 let source = FungibleTokenConnectors.VaultSource(_20 min: 0.0,_20 withdrawVault: withdrawCap,_20 uniqueID: nil_20 )_20_20 // Note: Logs are only visible in the emulator console_20 log("Source created for vault type: ".concat(source.withdrawVaultType.identifier))_20 }_20}
Sink
A sink is the opposite of a source - it's a place to send tokens, up to the limit of the capacity defined in the sink. As with any resource, this process is non-destructive. Any remaining tokens are left in the vault provided by the source. They also have flexible limits, meaning the capacity can be dynamic.
Sinks adhere to the Sink
interface.
_10access(all) struct interface Sink : IdentifiableStruct {_10 /// Returns the Vault type accepted by this Sink_10 access(all) view fun getSinkType(): Type_10 /// Returns an estimate of remaining capacity_10 access(all) fun minimumCapacity(): UFix64_10 /// Deposits up to capacity, leaving remainder in the referenced vault_10 access(all) fun depositCapacity(from: auth(FungibleToken.Withdraw) &{FungibleToken.Vault})_10}
You create a sink similar how you create a source, by instantiating an instance of the appropriate struct
from the connector. For example, to create a sink in a generic vault from, instantiate a VaultSink
from FungibleTokenConnectors
:
_27import "FungibleToken"_27import "FungibleTokenConnectors"_27_27transaction {_27_27 prepare(acct: &Account) {_27 // Public, non-auth capability to deposit into the vault_27 let depositCap = acct.capabilities.get<&{FungibleToken.Vault}>(_27 /public/flowTokenReceiver_27 )_27_27 // Optional: specify a max balance the user's Flow Token vault should hold_27 let maxBalance: UFix64? = nil // or UFix64(1000.0)_27_27 // Optional: for aligning with Source in a stack_27 let uniqueID = nil_27_27 let sink = FungibleTokenConnectors.VaultSink(_27 max: maxBalance,_27 depositVault: depositCap,_27 uniqueID: uniqueID_27 )_27_27 // Note: Logs are only visible in the emulator console_27 log("VaultSink created for deposit type: ".concat(sink.depositVaultType.identifier))_27 }_27}
Swapper
A swapper exchanges tokens between different types with support for bidirectional swaps and price estimation. Bi-directional means that they support swaps in both directions, which is necessary in the event that an inner connector can't accept the full swap output balance.
They also contain price discovery to provide estimates for the amounts in and out via the [{Quote}
] object, and the [quote system] enables price caching and execution parameter optimization.
Swappers conform to the Swapper
interface:
_13access(all) struct interface Swapper : IdentifiableStruct {_13 /// Input and output token types - in and out token types via default `swap()` route_13 access(all) view fun inType(): Type_13 access(all) view fun outType(): Type_13_13 /// Price estimation methods - quote required amount given some desired output & output for some provided input_13 access(all) fun quoteIn(forDesired: UFix64, reverse: Bool): {Quote}_13 access(all) fun quoteOut(forProvided: UFix64, reverse: Bool): {Quote}_13_13 /// Swap execution methods_13 access(all) fun swap(quote: {Quote}?, inVault: @{FungibleToken.Vault}): @{FungibleToken.Vault}_13 access(all) fun swapBack(quote: {Quote}?, residual: @{FungibleToken.Vault}): @{FungibleToken.Vault}_13}
Once again, you create a swapper by instantiating the appropriate struct
from the appropriate connector. To create a swapper for IncrementFi with the IncrementFiSwapConnectors
, instantiate Swapper
:
_33import "FlowToken"_33import "USDCFlow"_33import "IncrementFiSwapConnectors"_33import "SwapConfig"_33_33transaction {_33 prepare(acct: &Account) {_33 // Derive the path keys from the token types_33 let flowKey = SwapConfig.SliceTokenTypeIdentifierFromVaultType(vaultTypeIdentifier: Type<@FlowToken.Vault>().identifier)_33 let usdcFlowKey = SwapConfig.SliceTokenTypeIdentifierFromVaultType(vaultTypeIdentifier: Type<@USDCFlow.Vault>().identifier)_33_33 // Minimal path Flow -> USDCFlow_33 let swapper = IncrementFiSwapConnectors.Swapper(_33 path: [_33 flowKey,_33 usdcFlowKey_33 ],_33 inVault: Type<@FlowToken.Vault>(),_33 outVault: Type<@USDCFlow.Vault>(),_33 uniqueID: nil_33 )_33_33 // Example: quote how much USDCFlow you'd get for 10.0 FLOW_33 let qOut = swapper.quoteOut(forProvided: 10.0, reverse: false)_33 // Note: Logs are only visible in the emulator console_33 log(qOut)_33_33 // Example: quote how much FLOW you'd need to get 25.0 USDCFlow_33 let qIn = swapper.quoteIn(forDesired: 25.0, reverse: false)_33 // Note: Logs are only visible in the emulator console_33 log(qIn)_33 }_33}
Price Oracle
A price oracle provides price data for assets with a consistent denomination. All prices are returned in the same unit and will return nil
rather than reverting in the event that a price is unavailable. Prices are indexed by Cadence type, requiring a specific Cadence-based token type for which to serve prices, as opposed to looking up an asset by a generic address.
You can pass an argument this Type
, or any conforming fungible token type conforming to the interface to the price
function to get a price.
The full interface for PriceOracle
is:
_10access(all) struct interface PriceOracle : IdentifiableStruct {_10 /// Returns the denomination asset (e.g., USDCf, FLOW)_10 access(all) view fun unitOfAccount(): Type_10 /// Returns current price or nil if unavailable, conditions for which are implementation-specific_10 access(all) fun price(ofToken: Type): UFix64?_10}
To create a PriceOracle
from Band with BandOracleConnectors
:
You need to pay the oracle to get information from it. Here, we're using another Flow Action - a source - to fund getting a price from the oracle.
_32import "FlowToken"_32import "FungibleToken"_32import "FungibleTokenConnectors"_32import "BandOracleConnectors"_32_32transaction {_32_32 prepare(acct: auth(IssueStorageCapabilityController) &Account) {_32 // Ensure we have an authorized capability for FlowToken (auth Withdraw)_32 let storagePath = /storage/flowTokenVault_32 let withdrawCap = acct.capabilities.storage.issue<auth(FungibleToken.Withdraw) &{FungibleToken.Vault}>(storagePath)_32_32 // Fee source must PROVIDE FlowToken vaults (per PriceOracle preconditions)_32 let feeSource = FungibleTokenConnectors.VaultSource(_32 min: 0.0, // keep at least 0.0 FLOW in the vault_32 withdrawVault: withdrawCap, // auth withdraw capability_32 uniqueID: nil_32 )_32_32 // unitOfAccount must be a mapped symbol in BandOracleConnectors.assetSymbols._32 // The contract's init already maps FlowToken -> "FLOW", so this is valid._32 let oracle = BandOracleConnectors.PriceOracle(_32 unitOfAccount: Type<@FlowToken.Vault>(), // quote token (e.g. FLOW in BASE/FLOW)_32 staleThreshold: 600, // seconds; nil to skip staleness checks_32 feeSource: feeSource,_32 uniqueID: nil_32 )_32_32 // Note: Logs are only visible in the emulator console_32 log("Created PriceOracle; unit: ".concat(oracle.unitOfAccount().identifier))_32 }_32}
Flasher
A flasher provides flash loans with atomic repayment requirements.
If you're not familiar with flash loans, imagine a scenario where you discovered an NFT listed for sale one one marketplace for 1 million dollars, then noticed an open bid to buy that same NFT for 1.1 million dollars on another marketplace.
In theory, you could make an easy 100k by buying the NFT on the first marketplace and then fulfilling the open buy offer on the second marketplace. There's just one big problem - You might not have 1 million dollars liquid just laying around for you to purchase the NFT!
Flash loans solve this problem by enabling you to create one transaction during which you:
- Borrow 1 million dollars
- Purchase the NFT
- Sell the NFT
- Repay 1 million dollars plus a small fee
This scenario may be a scam. A scammer could set up this situation as bait and cancel the buy order the instant someone purchases the NFT that is for sale. You'd be left having paid a vast amount of money for something worthless.
The great thing about Cadence transactions, with or without Actions, is that you can set up an atomic transaction where everything either works, or is reverted. Either you make 100k, or nothing happens except a tiny expenditure of gas.
Flashers adhere to the Flasher
interface:
_13access(all) struct interface Flasher : IdentifiableStruct {_13 /// Returns the asset type this Flasher can issue as a flash loan_13 access(all) view fun borrowType(): Type_13 /// Returns the estimated fee for a flash loan of the specified amount_13 access(all) fun calculateFee(loanAmount: UFix64): UFix64_13 /// Performs a flash loan of the specified amount. The callback function is passed the fee amount, a loan Vault,_13 /// and data. The callback function should return a Vault containing the loan + fee._13 access(all) fun flashLoan(_13 amount: UFix64,_13 data: AnyStruct?,_13 callback: fun(UFix64, @{FungibleToken.Vault}, AnyStruct?): @{FungibleToken.Vault} // fee, loan, data_13 )_13}
You create a flasher the same way as the other actions, but you'll need the address for a SwapPair
. You can get that onchain at runtime. For example, to borrow $FLOW from IncrementFi:
_62import "FungibleToken"_62import "FlowToken"_62import "USDCFlow"_62import "SwapInterfaces"_62import "SwapConfig"_62import "SwapFactory"_62import "IncrementFiFlashloanConnectors"_62_62transaction {_62_62 prepare(_ acct: &Account) {_62 // Increment uses token *keys* like "A.1654653399040a61.FlowToken" (mainnet FlowToken)_62 // and "A.f1ab99c82dee3526.USDCFlow" (mainnet USDCFlow)._62 let flowKey = SwapConfig.SliceTokenTypeIdentifierFromVaultType(vaultTypeIdentifier: Type<@FlowToken.Vault>().identifier)_62 let usdcFlowKey = SwapConfig.SliceTokenTypeIdentifierFromVaultType(vaultTypeIdentifier: Type<@USDCFlow.Vault>().identifier)_62_62 // Ask the factory for the pair's public capability (or address), then verify it._62 // Depending on the exact factory interface you have, one of these will exist:_62 // - getPairAddress(token0Key: String, token1Key: String): Address_62 // - getPairPublicCap(token0Key: String, token1Key: String): Capability<&{SwapInterfaces.PairPublic}>_62 // - getPair(token0Key: String, token1Key: String): Address_62 //_62 // Try address first; if your factory exposes a different helper, swap it in._62 let pairAddr: Address = SwapFactory.getPairAddress(flowKey, usdcFlowKey)_62_62 // Sanity-check: borrow PairPublic and verify it actually contains FLOW/USDCFlow_62 let pair = getAccount(pairAddr)_62 .capabilities_62 .borrow<&{SwapInterfaces.PairPublic}>(SwapConfig.PairPublicPath)_62 ?? panic("Could not borrow PairPublic at resolved address")_62_62 let info = pair.getPairInfoStruct()_62 assert(_62 (info.token0Key == flowKey && info.token1Key == usdcFlowKey) ||_62 (info.token0Key == usdcFlowKey && info.token1Key == flowKey),_62 message: "Resolved pair does not match FLOW/USDCFlow"_62 )_62_62 // Instantiate the Flasher to borrow FLOW (switch to USDCFlow if you want that leg)_62 let flasher = IncrementFiFlashloanConnectors.Flasher(_62 pairAddress: pairAddr,_62 type: Type<@FlowToken.Vault>(),_62 uniqueID: nil_62 )_62_62 // Note: Logs are only visible in the emulator console_62 log("Flasher ready on mainnet FLOW/USDCFlow at ".concat(pairAddr.toString()))_62_62 flasher.flashloan(_62 amount: 100.0_62 data: nil_62 callback: flashloanCallback_62 )_62 }_62}_62_62// Callback function passed to flasher.flashloan_62access(all)_62fun flashloanCallback(fee: UFix64, loan: @{FungibleToken.Vault}, data: AnyStruct?): @{FungibleToken.Vault} {_62 log("Flashloan with balance of \(loan.balance) \(loan.getType().identifier) executed")_62 return <-loan_62}
Identification and Traceability
The UniqueIdentifier
enables protocols to trace stack operations via Flow Actions interface-level events, identifying them by IDs. IdentifiableResource
implementations should ensure that access to the identifier is encapsulated by the structures they identify.
While Cadence struct types can be created in any context (including being passed in as transaction parameters), the authorized AuthenticationToken
capability ensures that only those issued by the Flow Actions contract can be utilized in connectors, preventing forgery.
For example, to use a UniqueIdentifier
in a source->swap->sink:
_82import "FungibleToken"_82import "FlowToken"_82import "USDCFlow"_82import "FungibleTokenConnectors"_82import "IncrementFiSwapConnectors"_82import "SwapConfig"_82import "DeFiActions"_82_82transaction {_82_82 prepare(acct: auth(BorrowValue, IssueStorageCapabilityController, PublishCapability, SaveValue, UnpublishCapability) &Account) {_82 // Standard token paths_82 let storagePath = /storage/flowTokenVault_82 let receiverStoragePath = USDCFlow.VaultStoragePath_82 let receiverPublicPath = USDCFlow.VaultPublicPath_82_82 // Ensure private auth-withdraw (for Source)_82 let withdrawCap = acct.capabilities.storage.issue<auth(FungibleToken.Withdraw) &{FungibleToken.Vault}>(storagePath)_82_82 // Ensure public receiver Capability (for Sink) - configure receiving Vault is none exists_82 if acct.storage.type(at: receiverStoragePath) == nil {_82 // Save the USDCFlow Vault_82 acct.storage.save(<-USDCFlow.createEmptyVault(vaultType: Type<@USDCFlow.Vault>()), to: USDCFlow.VaultStoragePath)_82 // Issue and publish public Capabilities to the token's default paths_82 let publicCap = acct.capabilities.storage.issue<&USDCFlow.Vault>(storagePath)_82 ?? panic("failed to link public receiver")_82 acct.capabilities.unpublish(receiverPublicPath)_82 acct.capabilities.unpublish(USDCFlow.ReceiverPublicPath)_82 acct.capabilities.publish(cap, at: receiverPublicPath)_82 acct.capabilities.publish(cap, at: USDCFlow.ReceiverPublicPath)_82 }_82 let depositCap = acct.capabilities.get<&{FungibleToken.Vault}>(receiverPublicPath)_82_82 // Initialize shared UniqueIdentifier - passed to each connector on init_82 let uniqueIdentifier = DeFiActions.createUniqueIdentifier()_82_82 // Instantiate: Source, Swapper, Sink_82 let source = FungibleTokenConnectors.VaultSource(_82 min: 5.0,_82 withdrawVault: withdrawCap,_82 uniqueID: uniqueIdentifier_82 )_82_82 // Derive the IncrementFi token keys from the token types_82 let flowKey = SwapConfig.SliceTokenTypeIdentifierFromVaultType(vaultTypeIdentifier: Type<@FlowToken.Vault>().identifier)_82 let usdcFlowKey = SwapConfig.SliceTokenTypeIdentifierFromVaultType(vaultTypeIdentifier: Type<@USDCFlow.Vault>().identifier)_82_82 // Replace with a real Increment path when swapping tokens (e.g., FLOW → USDCFlow)_82 // e.g. ["A.1654653399040a61.FlowToken", "A.f1ab99c82dee3526.USDCFlow"]_82 let swapper = IncrementFiSwapConnectors.Swapper(_82 path: [flowKey, usdcFlowKey],_82 inVault: Type<@FlowToken.Vault>(),_82 outVault: Type<@USDCFlow.Vault>(),_82 uniqueID: uniqueIdentifier_82 )_82_82 let sink = FungibleTokenConnectors.VaultSink(_82 max: nil,_82 depositVault: depositCap,_82 uniqueID: uniqueIdentifier_82 )_82_82 // ----- Real composition (no destroy) -----_82 // 1) Withdraw from Source_82 let tokens <- source.withdrawAvailable(maxAmount: 100.0)_82_82 // 2) Swap with Swapper from FLOW → USDCFlow_82 let swapped <- swapper.swap(quote: nil, inVault: <-tokens)_82_82 // 3) Deposit into Sink (consumes by reference via withdraw())_82 sink.depositCapacity(from: &swapped as auth(FungibleToken.Withdraw) &{FungibleToken.Vault})_82_82 // 4) Return any residual by depositing the *entire* vault back to user's USDCFlow vault_82 // (works even if balance is 0; deposit will still consume the resource)_82 depositCap.borrow().deposit(from: <-swapped)_82_82 // Optional: inspect that all three share the same ID_82 log(source.id())_82 log(swapper.id())_82 log(sink.id())_82 }_82}
Why UniqueIdentifier
Matters in FlowActions
The UniqueIdentifier
is used to tag multiple FlowActions connectors as part of the same logical operation.
By aligning the same ID across connectors (e.g., Source → Swapper → Sink), you can:
1. Event Correlation
- Every connector emits events tagged with its
UniqueIdentifier
. - Shared IDs let you filter and group related events in the chain's event stream.
- Makes it easy to see that a withdrawal, swap, and deposit were part of one workflow.
2. Stack Tracing
- When using composite connectors (e.g.,
SwapSource
,SwapSink
,MultiSwapper
), IDs allow you to trace the complete path through the stack. - Helpful for debugging and understanding the flow of operations inside complex strategies.
3. Analytics & Attribution
- Enables measuring usage of specific strategies or routes.
- Lets you join data from multiple connectors into a single logical "transaction" for reporting.
- Supports fee attribution and performance monitoring across multi-step workflows.
Without a Shared UniqueIdentifier
- Events from different connectors appear unrelated, even if they occurred in the same transaction.
- Harder to debug, track, or analyze multi-step processes.
Conclusion
In this tutorial, you learned about Flow Actions, a suite of standardized Cadence interfaces that enable developers to compose complex DeFi workflows using small, reusable components. You explored the five core Flow Action types - Source, Sink, Swapper, PriceOracle, and Flasher - and learned how to create and use them with various connectors.
Now that you have completed this tutorial, you should be able to:
- Understand the key features of Flow Actions including atomic composition, weak guarantees, and event traceability
- Create and use Sources to provide tokens from various protocols and locations
- Create and use Sinks to accept tokens up to defined capacity limits
- Create and use Swappers to exchange tokens between different types with price estimation
- Create and use Price Oracles to get price data for assets with consistent denomination
- Create and use Flashers to provide flash loans with atomic repayment requirements
- Use UniqueIdentifiers to trace and correlate operations across multiple Flow Actions
- Compose complex DeFi workflows by connecting multiple Actions in a single atomic transaction