From aaf7b8dcfac6ad8899b14cf2c0e16fa233763ead Mon Sep 17 00:00:00 2001 From: Sachin Roy Date: Tue, 8 Sep 2026 14:07:00 +0530 Subject: [PATCH] feat(sdk-core): add Aave V3 vault deposits Add AAVE_V3 vault dispatch to sdk-core. Morpho and Aave V3 share the same ERC-4626 approve/deposit sequencing, so both protocols dispatch to a single depositToErc4626Vault path that issues the defiApprove and defiDeposit sendMany pair and returns the operationId linking them, with full and lite operation ID extraction coverage. Upgrade @bitgo/public-types to 6.69.0 in sdk-core, the only module consuming VaultProtocol.AAVE_V3, so the SDK accepts the published Aave protocol codec and clients can deposit into StataTokenV2 vaults instead of receiving an unsupported protocol error. Ticket: DEFI-857 Session-Id: 133a26bb-c1ee-4f99-886a-f4f1b1b017e5 Task-Id: 3624de8d-630a-454d-ba30230221e2 --- modules/sdk-core/package.json | 2 +- modules/sdk-core/src/bitgo/defi/defiVault.ts | 21 ++-- modules/sdk-core/src/bitgo/defi/iDefiVault.ts | 7 +- .../test/unit/bitgo/defi/defiVault.ts | 118 ++++++++++++++++++ yarn.lock | 8 ++ 5 files changed, 144 insertions(+), 12 deletions(-) diff --git a/modules/sdk-core/package.json b/modules/sdk-core/package.json index 2be7a6532b..5d602ddf76 100644 --- a/modules/sdk-core/package.json +++ b/modules/sdk-core/package.json @@ -40,7 +40,7 @@ ] }, "dependencies": { - "@bitgo/public-types": "6.66.0", + "@bitgo/public-types": "6.69.0", "@bitgo/sdk-lib-mpc": "^10.18.0", "@bitgo/secp256k1": "^1.11.1", "@bitgo/sjcl": "^1.1.0", diff --git a/modules/sdk-core/src/bitgo/defi/defiVault.ts b/modules/sdk-core/src/bitgo/defi/defiVault.ts index 6357962c08..1425befe2b 100644 --- a/modules/sdk-core/src/bitgo/defi/defiVault.ts +++ b/modules/sdk-core/src/bitgo/defi/defiVault.ts @@ -6,7 +6,6 @@ import { CoinFeature } from '@bitgo/statics'; import { GetVaultResponse, VaultProtocol, VaultProtocolType } from '@bitgo/public-types'; import { ConcreteDepositResult, - MorphoDepositResult, DefiOperation, DefiOperationListResult, DepositResult, @@ -60,7 +59,7 @@ export class DefiVault implements IDefiVault { /** * Minimal dispatch codec. The deposit path reads only `protocol` to choose - * between the concrete and morpho flows, so it must not hard-fail on the + * between the concrete, Morpho, and Aave flows, so it must not hard-fail on the * validity of unrelated response fields (e.g. `composition[]`) it never * consumes. A code path must not fail on the validity of data it does not * consume. @@ -83,7 +82,7 @@ export class DefiVault implements IDefiVault { /** * Fetch vault config from defi-service. Used internally to determine - * which deposit path to take (Concrete vs Morpho). + * which deposit path to take (Concrete vs Morpho vs Aave V3). */ async getVaultConfig(params: GetVaultConfigOptions): Promise { return decodeWithCodec(GetVaultResponse, await this.fetchVaultRaw(params.vaultId), 'getVaultConfig'); @@ -107,9 +106,9 @@ export class DefiVault implements IDefiVault { /** * Deposit an amount of underlying asset into a vault. * - * Dispatches to the concrete or morpho path based on vault provider. + * Dispatches to the concrete, Morpho, or Aave V3 path based on vault provider. * The concrete path returns a pendingApproval (custodial wallet). - * The morpho path issues two sendMany calls (approve + deposit). + * The Morpho and Aave V3 paths issue two sendMany calls (approve + deposit). * * @param params.vaultId - DeFi-service vault identifier * @param params.amount - amount in base units of the underlying asset @@ -127,8 +126,8 @@ export class DefiVault implements IDefiVault { if (protocol === VaultProtocol.CONCRETE_BTCCX) { return this.depositToConcreteVault(params); - } else if (protocol === VaultProtocol.MORPHO) { - return this.depositToMorphoVault(params); + } else if (protocol === VaultProtocol.MORPHO || protocol === VaultProtocol.AAVE_V3) { + return this.depositToErc4626Vault(params); } else { throw new Error(`Unsupported vault protocol: ${protocol}`); } @@ -154,10 +153,12 @@ export class DefiVault implements IDefiVault { } /** - * Morpho vault deposit path. Issues two sendMany calls (approve + deposit) - * and returns the operationId that links them. + * ERC-4626 vault deposit path (Morpho, Aave V3, ...). Issues two sendMany + * calls (approve + deposit) and returns the operationId that links them. */ - private async depositToMorphoVault(params: DepositToVaultOptions): Promise { + private async depositToErc4626Vault( + params: DepositToVaultOptions + ): Promise<{ operationId: string; txRequestIds: { approve: string; deposit: string } }> { // TODO(CGD-1709): Re-enable active operation pre-flight check once the // defi-service operations endpoint is deployed and returning active state. // const activeOps: DefiOperationListResult = await this.bitgo diff --git a/modules/sdk-core/src/bitgo/defi/iDefiVault.ts b/modules/sdk-core/src/bitgo/defi/iDefiVault.ts index 4cc4e7bc8f..2fcd4e407d 100644 --- a/modules/sdk-core/src/bitgo/defi/iDefiVault.ts +++ b/modules/sdk-core/src/bitgo/defi/iDefiVault.ts @@ -59,7 +59,12 @@ export interface MorphoDepositResult { txRequestIds: { approve: string; deposit: string }; } -export type DepositResult = ConcreteDepositResult | MorphoDepositResult; +export interface AaveDepositResult { + operationId: string; + txRequestIds: { approve: string; deposit: string }; +} + +export type DepositResult = ConcreteDepositResult | MorphoDepositResult | AaveDepositResult; export interface DefiOperationListResult { items: DefiOperation[]; diff --git a/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts b/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts index 9e9cf0e0e4..81951eb3cd 100644 --- a/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts +++ b/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts @@ -51,6 +51,21 @@ describe('DefiVault', function () { }; } + function makeAaveVault(id: string) { + return { + id, + name: 'Aave V3 USDC Vault', + protocol: VaultProtocol.AAVE_V3, + status: 'active', + coin: 'eth', + assetToken: 'usdc', + shareToken: 'stataUSDC', + riskManager: 'manager-3', + custodyType: 'qualified', + vaultContractAddress: '0xAaveVault', + }; + } + // A vault response whose `composition[]` no longer matches the strict // GetVaultResponse codec — mirrors the prod incident where a display-only // nested field was narrowed/removed server-side. The deposit dispatch path @@ -443,6 +458,109 @@ describe('DefiVault', function () { }); }); + describe('aave_v3 provider', function () { + it('should call sendMany for approve and deposit on happy path', async function () { + mockBitGo.get.returns(mockRequest(makeAaveVault('vlt-aave-usdc'))); + + const operationId = 'op-aave-123'; + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.onFirstCall().resolves({ + txRequest: { + txRequestId: 'txreq-aave-approve-1', + intent: { intentType: 'defi-approve' }, + transactions: [{ unsignedTx: { coinSpecific: { operationId } } }], + }, + }); + sendManyStub.onSecondCall().resolves({ + txRequest: { + txRequestId: 'txreq-aave-deposit-1', + intent: { intentType: 'defi-deposit' }, + transactions: [{ unsignedTx: { coinSpecific: { operationId } } }], + }, + }); + + const result = await defiVault.depositToVault({ + vaultId: 'vlt-aave-usdc', + amount: '1000000', + }); + + (result as any).operationId.should.equal(operationId); + (result as any).txRequestIds.approve.should.equal('txreq-aave-approve-1'); + (result as any).txRequestIds.deposit.should.equal('txreq-aave-deposit-1'); + sendManyStub.calledTwice.should.be.true(); + + const approveArgs: any = sendManyStub.firstCall.args[0]; + approveArgs.type.should.equal('defiApprove'); + approveArgs.defiParams.should.deepEqual({ vaultId: 'vlt-aave-usdc', amount: '1000000' }); + + const depositArgs: any = sendManyStub.secondCall.args[0]; + depositArgs.type.should.equal('defiDeposit'); + depositArgs.defiParams.should.deepEqual({ + vaultId: 'vlt-aave-usdc', + amount: '1000000', + operationId, + }); + }); + + it('should extract operationId from the lite apiVersion coinSpecific', async function () { + mockBitGo.get.returns(mockRequest(makeAaveVault('vlt-aave-usdc'))); + + const operationId = 'op-aave-lite'; + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.onFirstCall().resolves({ + txRequest: { + txRequestId: 'txreq-aave-approve-lite', + unsignedTxs: [{ coinSpecific: { operationId } }], + }, + }); + sendManyStub.onSecondCall().resolves({ + txRequest: { txRequestId: 'txreq-aave-deposit-lite' }, + }); + + const result = await defiVault.depositToVault({ vaultId: 'vlt-aave-usdc', amount: '1000000' }); + + (result as any).operationId.should.equal(operationId); + (result as any).txRequestIds.approve.should.equal('txreq-aave-approve-lite'); + (result as any).txRequestIds.deposit.should.equal('txreq-aave-deposit-lite'); + }); + + it('should throw when operationId is absent from the approve txRequest', async function () { + mockBitGo.get.returns(mockRequest(makeAaveVault('vlt-aave-usdc'))); + + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.resolves({ + txRequest: { + txRequestId: 'txreq-aave-approve-missing', + transactions: [{ unsignedTx: { coinSpecific: {} } }], + }, + }); + + await assert.rejects(() => defiVault.depositToVault({ vaultId: 'vlt-aave-usdc', amount: '1000000' }), { + message: 'operationId not found in approve txRequest response', + }); + sendManyStub.calledOnce.should.be.true(); + }); + + it('should propagate deposit sendMany failure without cleanup', async function () { + mockBitGo.get.returns(mockRequest(makeAaveVault('vlt-aave-usdc'))); + + const operationId = 'op-aave-failure'; + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.onFirstCall().resolves({ + txRequest: { + txRequestId: 'txreq-aave-approve-failure', + transactions: [{ unsignedTx: { coinSpecific: { operationId } } }], + }, + }); + sendManyStub.onSecondCall().rejects(new Error('Aave deposit creation failed')); + + await assert.rejects(() => defiVault.depositToVault({ vaultId: 'vlt-aave-usdc', amount: '1000000' }), { + message: 'Aave deposit creation failed', + }); + mockBitGo.del.called.should.be.false(); + }); + }); + it('should throw if vaultId is missing', async function () { await assert.rejects(() => defiVault.depositToVault({ vaultId: '', amount: '1000000' }), { message: 'vaultId is required', diff --git a/yarn.lock b/yarn.lock index 2f9d5f283e..cdbd431483 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1028,6 +1028,14 @@ dependencies: fp-ts "^2.0.0" io-ts "npm:@bitgo-forks/io-ts@2.1.4" + +"@bitgo/public-types@6.69.0": + version "6.69.0" + resolved "https://registry.npmjs.org/@bitgo/public-types/-/public-types-6.69.0.tgz#dd900b0678193e71da1d7e9b46e7aad5f1e05a76" + integrity sha512-0nmunf1mb+XihYiXtfBp8E1Lzbtv3OFY896yo5z05s7mh7ox+PlAQdMoAQ/ESDJRSNCIn4bLo7tPe/OkzRa+7Q== + dependencies: + fp-ts "^2.0.0" + io-ts "npm:@bitgo-forks/io-ts@2.1.4" io-ts-types "^0.5.16" monocle-ts "^2.3.13" newtype-ts "^0.3.5"