feat: add Aave V4 protocol (and update V3)#846
Conversation
Per review on PR #846 - the V3 protocol's short slug "aave" conflicts with the expectation that both V3 and V4 coexist clearly. Rename to "aave-v3" so version is explicit in every reference (IntegrationType, actionType, MCP tool invocations, featured_protocol metadata). Code changes: - protocols/aave-v3.ts: slug "aave" -> "aave-v3" - tests/unit/protocol-aave-v3.test.ts: assertions updated - lib/types/integration.ts, protocols/index.ts: auto-regenerated - lib/mcp/tools.ts: update example strings in tool descriptions - scripts/seed/workflows/aave/ renamed to aave-v3/, contents updated - scripts/pr-test/seed-pr-data.sql: actionType and protocolSlug updated - scripts/README.md, docs/api/workflows.md: example strings - .claude/commands/test-protocol.md: slug-mapping example Migration 0048_rename_aave_slug_to_v3.sql: Data-only migration (no schema change) that rewrites existing workflows in place: - workflows.featured_protocol: "aave" -> "aave-v3" - workflows.nodes (jsonb): each node's data.config.actionType from "aave/*" to "aave-v3/*", and the stringified data.config._protocolMeta.protocolSlug from "aave" to "aave-v3" Uses text-level REPLACE on the canonical JSONB text rendition. Two distinct patterns because PostgreSQL's JSONB canonicalization adds spaces after colons at the top level ("actionType": "aave/...") but JSON.stringify output inside _protocolMeta has no spaces (\"protocolSlug\":\"aave\"). Both patterns are key-scoped so free-form text containing the word "aave" (e.g. descriptions) is untouched. Verified locally against a seeded workflow: all three aave references correctly rewritten, web3/write-contract action and a description string containing the word "aave" were left alone. Re-running the migration is a no-op (idempotent).
Per review on PR #846 - the V3 protocol's short slug "aave" conflicts with the expectation that both V3 and V4 coexist clearly. Rename to "aave-v3" so version is explicit in every reference (IntegrationType, actionType, MCP tool invocations, featured_protocol metadata). Code changes: - protocols/aave-v3.ts: slug "aave" -> "aave-v3" - tests/unit/protocol-aave-v3.test.ts: assertions updated - lib/types/integration.ts, protocols/index.ts: auto-regenerated - lib/mcp/tools.ts: update example strings in tool descriptions - scripts/seed/workflows/aave/ renamed to aave-v3/, contents updated - scripts/pr-test/seed-pr-data.sql: actionType and protocolSlug updated - scripts/README.md, docs/api/workflows.md: example strings - .claude/commands/test-protocol.md: slug-mapping example Migration 0048_rename_aave_slug_to_v3.sql: Data-only migration (no schema change) that rewrites existing workflows in place: - workflows.featured_protocol: "aave" -> "aave-v3" - workflows.nodes (jsonb): each node's data.config.actionType from "aave/*" to "aave-v3/*", and the stringified data.config._protocolMeta.protocolSlug from "aave" to "aave-v3" Uses text-level REPLACE on the canonical JSONB text rendition. Two distinct patterns because PostgreSQL's JSONB canonicalization adds spaces after colons at the top level ("actionType": "aave/...") but JSON.stringify output inside _protocolMeta has no spaces (\"protocolSlug\":\"aave\"). Both patterns are key-scoped so free-form text containing the word "aave" (e.g. descriptions) is untouched. Verified locally against a seeded workflow: all three aave references correctly rewritten, web3/write-contract action and a description string containing the word "aave" were left alone. Re-running the migration is a no-op (idempotent).
341817b to
2e9307f
Compare
PR Environment DeployedYour PR environment has been deployed! Environment Details:
Components:
The environment will be automatically cleaned up when this PR is closed or merged. |
PR Environment DeployedYour PR environment has been deployed! Environment Details:
Components:
The environment will be automatically cleaned up when this PR is closed or merged. |
Adds the getUserAccountData read action which returns a UserAccountData
struct (tuple) with 7 fields: riskPremium, avgCollateralFactor,
healthFactor, totalCollateralValue, totalDebtValueRay,
activeCollateralCount, borrowCount.
Verified at the code level that this works end-to-end:
- readContractCore.ts:238-252 unwraps single unnamed tuple returns to
the raw decoded object, making struct fields directly accessible
- The template engine (workflow-executor.ts:454-482) supports dotted
paths: {{@node:Label.result.healthFactor}} resolves correctly
- Unit tests in template.test.ts confirm nested path access
The UI output picker shows one "accountData" entry (type tuple) rather
than 7 individual fields - users discover the subfields via the action
description or MCP schema. Acceptable for v1.
Also resolves the reserveId UX question: chaining get-reserve-id output
into supply/borrow via template refs is the standard supported pattern.
No new field type needed.
Closes both remaining draft caveats from PR #846.
PR Environment DeployedYour PR environment has been deployed! Environment Details:
Components:
The environment will be automatically cleaned up when this PR is closed or merged. |
PR Environment DeployedYour PR environment has been deployed! Environment Details:
Components:
The environment will be automatically cleaned up when this PR is closed or merged. |
The previous commit (6c6fe54) only dropped the custom output overrides from write actions, but ABI-derived outputs still surfaced as UI template suggestions (result0/result1) that resolve to undefined at runtime, since deriveActionsFromAbi unconditionally attaches outputs from the ABI. Fix at the UI consumption layer: gate action.outputs -> outputFields in buildOutputFieldsFromAction on action.type === "read". The protocol model remains unchanged (protocol-abi-derive.test.ts already asserts that writes do get ABI outputs at the model layer -- that is correct, it is purely the UI surfacing that was lying). Applies to every ABI-driven protocol's write actions, not just Aave V4. No user-visible regression: the template references that disappear from autocomplete only ever resolved to undefined. Adds two regression tests exercising protocolActionToPluginAction to assert the behaviour both ways (writes hide ABI outputs, reads surface them). Follow-up: KEEP-296 (decode write-action return values for protocol actions)
…ions.type
Schema audit surfaced two slug-bearing surfaces the original 0051 missed:
* workflows.nodes[].data._eventProtocolSlug -- event trigger nodes store
the protocol slug here (separate from action nodes' actionType /
_protocolMeta). Modelled after the 0025 safe-wallet precedent using
jsonb_set via jsonb_agg, since _eventProtocolSlug is a native jsonb
key (unlike the stringified _protocolMeta).
* integrations.type -- $type<IntegrationType>. Protocol plugins set
requiresCredentials: false so no rows are expected in practice, but
adding the rename is idempotent and cheap insurance.
Not touched:
* _eventProtocolIconPath -- icon file itself is unchanged across the
rename (protocols/aave-v3.ts still declares "/protocols/aave.png"),
unlike the 0025 safe-wallet case which had to rewrite the icon path.
* Historical execution tables (workflow_executions, workflow_execution_logs,
direct_executions) -- rewriting them would falsify past-run history.
Migration header now lists the audited surfaces explicitly.
PR Environment DeployedYour PR environment has been deployed! Environment Details:
Components:
The environment will be automatically cleaned up when this PR is closed or merged. |
Previous pattern swallowed any error that didn't contain three specific
strings into a passing test:
try {
// positive assertions -- only run if RPC succeeds
} catch (error) {
expect(String(error)).not.toContain("INVALID_ARGUMENT");
expect(String(error)).not.toContain("could not decode");
expect(String(error)).not.toContain("invalid function");
}
An RPC timeout, a rate-limit, an unrelated revert reason, or a genuine
ABI mismatch whose error string happened to miss those three needles would
all pass green with no positive assertion actually running.
Rewrite:
- Read tests: remove try/catch. If provider.call fails or the return
can't be decoded, the test fails loudly (as it should).
- Write tests (supply, setUsingAsCollateral): switch from estimateGas
to provider.call with the zero-balance TEST_ADDRESS to trigger a
business-logic revert, and assert rejects.toMatchObject({ code:
"CALL_EXCEPTION" }) -- ethers v6's canonical code for contract-level
reverts. An ABI-level failure (INVALID_ARGUMENT, BAD_DATA) or an
unknown selector raises a different error class, so the matcher
distinguishes "calldata was understood by the contract" from
"calldata never got that far".
Test count unchanged (6). Integration tests remain gated on
INTEGRATION_TEST_MAINNET_RPC_URL, so this change doesn't affect the
default CI path.
PR Environment DeployedYour PR environment has been deployed! Environment Details:
Components:
The environment will be automatically cleaned up when this PR is closed or merged. |
Running the suite against mainnet revealed that setUsingAsCollateral
silently succeeds on reserveId=0 (the Spoke no-ops on nonexistent
reserves rather than reverting), returning "0x". My previous assertion
rejects.toMatchObject({ code: "CALL_EXCEPTION" }) was too narrow --
it assumed every write would revert for a zero-balance caller.
What the write tests are actually proving is "the deployed bytecode
understood our calldata". Both a clean return ("0x" for void functions)
and CALL_EXCEPTION are valid evidence of that. What we still reject:
INVALID_ARGUMENT, BAD_DATA, BUFFER_OVERRUN -- ABI-level errors that
would signal the protocol definition doesn't match the deployed contract.
Extracted the try/catch into expectCallAcceptedByBytecode() so supply
and setUsingAsCollateral share the assertion shape.
Verified: all 6 integration tests pass against eth-mainnet.
🧹 PR Environment Cleaned UpThe PR environment has been successfully deleted. Deleted Resources:
All resources have been cleaned up and will no longer incur costs. |
PR Environment DeployedYour PR environment has been deployed! Environment Details:
Components:
The environment will be automatically cleaned up when this PR is closed or merged. |
Summary
Adds an ABI-driven protocol definition for Aave V4 to match what our existing Aave V3 integration enables. First Spoke only: Lido Spoke on Ethereum mainnet. The other launch Spokes (EtherFi, Kelp, Ethena Correlated, Ethena Ecosystem, Lombard BTC) share the same ABI and can be added in follow-ups.
Aave V4 launched on Ethereum mainnet on 2026-03-30 with a Hub-and-Spoke architecture. Users interact with Spokes (not Hubs) for supply/borrow. Hubs provide credit lines to Spokes and hold admin-only surfaces.
Files (V4 additions)
protocols/abis/aave-v4.jsonprotocols/aave-v4.tsdefineAbiProtocol()withlidoSpokecontracttests/unit/protocol-aave-v4.test.tstests/integration/protocol-aave-v4-onchain.test.tsINTEGRATION_TEST_MAINNET_RPC_URL)protocols/index.tslib/types/integration.ts"aave", adds"aave-v3"and"aave-v4"in IntegrationTypeAction mapping (V3 -> V4)
supplysupplyasset: address->reserveId: uint256. NoreferralCode.withdrawwithdrawborrowborrowinterestRateMode(V4 has only one rate model, uses drawn/premium shares).repayrepayset-collateralset-collateralonBehalfOfparam.get-user-account-dataget-user-account-dataUserAccountDatastruct (tuple).readContractCoreunwraps single unnamed tuples, and the template engine supports dotted paths (result.healthFactor). UI picker shows one "accountData" entry; subfields documented in description.get-user-reserve-dataget-user-supplied-assets+get-user-debtget-reserve-iduint256 reserveIdper Spoke; this resolves from(hub, assetId).Write actions (
supply,withdraw,borrow,repay) intentionally do not declare named outputs. The underlyingwriteContractCorereturnsresult: undefinedfor all writes, so declared outputs would surface in the UI template autocomplete but resolve toundefinedat runtime. Decoding write-action return values is tracked as a platform follow-up: KEEP-296. V3 follows the same convention (outputs on reads only).Cross-cutting improvements
Two UX improvements motivated by V4 that affect all protocols:
Chain selector now restricted to deployed chains. Added
allowedChainIdstoActionConfigFieldBase.buildConfigFieldsFromAction()computes this from each contract'saddressesmap;ChainSelectFieldfilters the dropdown. Previously the network dropdown showed every EVM chain regardless of where the protocol was deployed - users could pick Polygon for an action that only exists on Ethereum and the workflow would fail at runtime. Fix applies to every protocol, not just Aave.Aave V4 field tooltips link to official docs. The info icon cursor is
pointerwhendocUrlis set and clicking the tooltip or icon opens the relevant Aave V4 docs page in a new tab. Infrastructure already existed inProtocolFieldLabel(lib/extensions.tsx); the V4 input overrides populatedocUrlwhere applicable.V3 slug rename (folded in-PR)
Per @eskp review on
lib/types/integration.ts. Shipping the rename alongside V4 so both versions land together with a clean slug story.protocols/aave-v3.ts:slug: "aave"->slug: "aave-v3"lib/types/integration.ts,protocols/index.ts(viapnpm discover-plugins)lib/mcp/tools.ts: updated example strings in tool descriptions (3 occurrences)scripts/seed/workflows/aave/renamed toaave-v3/, JSON contents updatedscripts/pr-test/seed-pr-data.sql: inline workflowactionTypeand_protocolMeta.protocolSlugupdatedscripts/README.md,docs/api/workflows.md: example strings updated.claude/commands/test-protocol.md: slug-mapping example updateddrizzle/0051_rename_aave_slug_to_v3.sql: data-only (no schema change). Rewritesworkflows.featured_protocol, and theactionType+ stringified_protocolMeta.protocolSluginsideworkflows.nodes(jsonb). Text-level REPLACE on JSONB with key-scoped patterns so free-form text containing "aave" is untouched. Uses the same escape convention as the precedent0048_rename_weth_to_wrapped.sql(doubled backslashes in theLIKEpredicate to match the escaped inner-string form produced byJSON.stringify). Verified locally end-to-end: correct transforms, no false positives, idempotent.Known caveats
Resolved. ChainingreserveIdUX is opaque.get-reserve-idoutput into supply/borrow via{{@node:Get Reserve ID.reserveId}}is the standard template-ref pattern. Verified inworkflow-executor.ts(resolveConfigFieldPath) and template.test.ts. No new field type needed for v1.No integration tests.Resolved. 6 on-chain integration tests added targeting mainnet viaINTEGRATION_TEST_MAINNET_RPC_URL(separate env var from the Sepolia-targetingINTEGRATION_TEST_RPC_URL). Tests confirm the Lido Spoke address is valid, the ABI matches deployed bytecode, and all functions respond correctly.Resolved.getUserAccountDatadeferred.readContractCore.tsunwraps single unnamed tuple returns into the raw decoded object. Template engine supports dotted paths (result.healthFactor). Integration tests pass against mainnet including the struct decode.Test plan
pnpm test tests/unit/protocol-aave-v4.test.ts- 20/20pnpm test tests/unit/protocol-- no regressions across existing protocols, including updated aave-v3 assertionspnpm check protocols/aave-v4.ts tests/unit/protocol-aave-v4.test.ts- cleanpnpm type-check- cleanpnpm discover-plugins- registers cleanly,"aave-v4"added to IntegrationTypeaave->aave-v3folded into this PR0048_rename_weth_to_wrapped.sql)Open items (not blockers)
reserveIduint andusingAsCollateralbool; verify chain dropdown shows only Ethereum Mainnet for Aave V4 actions; verify tooltip cursor and doc-link behaviouraave/*actionTypes continue to execute post-migration