diff --git a/commitlint.config.js b/commitlint.config.js index a886231017..fb54433e88 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -85,6 +85,7 @@ module.exports = { 'CHALO-', 'CECHO-', 'CSHLD-', + 'DEFI-', '#', // Prefix used by GitHub issues ], }, diff --git a/modules/abstract-lightning/package.json b/modules/abstract-lightning/package.json index dd6e83e069..24dd202d6a 100644 --- a/modules/abstract-lightning/package.json +++ b/modules/abstract-lightning/package.json @@ -39,7 +39,7 @@ ] }, "dependencies": { - "@bitgo/public-types": "6.22.0", + "@bitgo/public-types": "6.40.1", "@bitgo/sdk-core": "^38.0.0", "@bitgo/statics": "^58.54.0", "@bitgo/utxo-lib": "^11.24.0", diff --git a/modules/bitgo/package.json b/modules/bitgo/package.json index 13bd25bf3b..1bfa52f765 100644 --- a/modules/bitgo/package.json +++ b/modules/bitgo/package.json @@ -140,7 +140,7 @@ "superagent": "^9.0.1" }, "devDependencies": { - "@bitgo/public-types": "6.22.0", + "@bitgo/public-types": "6.40.1", "@bitgo/sdk-opensslbytes": "^2.1.0", "@bitgo/sdk-test": "^9.1.57", "@openpgp/web-stream-tools": "0.0.14", diff --git a/modules/express/package.json b/modules/express/package.json index c44cb9a9e9..1c38715e70 100644 --- a/modules/express/package.json +++ b/modules/express/package.json @@ -60,7 +60,7 @@ "superagent": "^9.0.1" }, "devDependencies": { - "@bitgo/public-types": "6.22.0", + "@bitgo/public-types": "6.40.1", "@bitgo/sdk-lib-mpc": "^10.15.0", "@bitgo/sdk-test": "^9.1.57", "@types/argparse": "^1.0.36", diff --git a/modules/sdk-coin-flrp/package.json b/modules/sdk-coin-flrp/package.json index 9a12613156..4e95ba955c 100644 --- a/modules/sdk-coin-flrp/package.json +++ b/modules/sdk-coin-flrp/package.json @@ -48,7 +48,7 @@ "nock": "^13.3.1" }, "dependencies": { - "@bitgo/public-types": "6.22.0", + "@bitgo/public-types": "6.40.1", "@bitgo/sdk-core": "^38.0.0", "@bitgo/secp256k1": "^1.11.0", "@bitgo/statics": "^58.54.0", diff --git a/modules/sdk-coin-sol/package.json b/modules/sdk-coin-sol/package.json index 8128e8ce49..8b17ffb1eb 100644 --- a/modules/sdk-coin-sol/package.json +++ b/modules/sdk-coin-sol/package.json @@ -57,7 +57,7 @@ }, "dependencies": { "@bitgo/logger": "^1.4.0", - "@bitgo/public-types": "6.22.0", + "@bitgo/public-types": "6.40.1", "@bitgo/sdk-core": "^38.0.0", "@bitgo/sdk-lib-mpc": "^10.15.0", "@bitgo/statics": "^58.54.0", diff --git a/modules/sdk-core/package.json b/modules/sdk-core/package.json index 0c01439ee8..1a418ba74d 100644 --- a/modules/sdk-core/package.json +++ b/modules/sdk-core/package.json @@ -40,7 +40,7 @@ ] }, "dependencies": { - "@bitgo/public-types": "6.22.0", + "@bitgo/public-types": "6.40.1", "@bitgo/sdk-lib-mpc": "^10.15.0", "@bitgo/secp256k1": "^1.11.0", "@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 e26700d9de..55dcb69b3b 100644 --- a/modules/sdk-core/src/bitgo/defi/defiVault.ts +++ b/modules/sdk-core/src/bitgo/defi/defiVault.ts @@ -1,18 +1,24 @@ /** * @prettier */ +import * as t from 'io-ts'; +import { GetVaultResponse, VaultProtocol } from '@bitgo/public-types'; import { + ConcreteDepositResult, + MorphoDepositResult, DefiOperation, DefiOperationListResult, DepositResult, DepositToVaultOptions, GetOperationOptions, + GetVaultConfigOptions, IDefiVault, ListOperationsOptions, ResumeDepositOptions, } from './iDefiVault'; import { IWallet } from '../wallet'; import { BitGoBase } from '../bitgoBase'; +import { decodeWithCodec } from '../utils'; /** * Error thrown when a concurrent active deposit already exists for the (wallet, vault) pair. @@ -47,13 +53,26 @@ export class DefiVault implements IDefiVault { this.bitgo = wallet.bitgo; } + /** + * Fetch vault config from defi-service. Used internally to determine + * which deposit path to take (Concrete vs Morpho). + */ + async getVaultConfig(params: GetVaultConfigOptions): Promise { + if (!params.vaultId) { + throw new Error('vaultId is required'); + } + const raw = await this.bitgo + .get(this.bitgo.microservicesUrl(`/api/defi-service/v1/vaults/${params.vaultId}`)) + .result(); + return decodeWithCodec(GetVaultResponse, raw, 'getVaultConfig'); + } + /** * Deposit an amount of underlying asset into a vault. * - * Internally issues two sendMany calls (approve + deposit) and returns the - * operationId that links them. If the deposit sendMany fails after - * the approve succeeds, the error propagates — the server-side reconciler - * handles orphaned approvals. + * Dispatches to the concrete or morpho path based on vault provider. + * The concrete path returns a pendingApproval (custodial wallet). + * The morpho path issues two sendMany calls (approve + deposit). * * @param params.vaultId - DeFi-service vault identifier * @param params.amount - amount in base units of the underlying asset @@ -68,6 +87,41 @@ export class DefiVault implements IDefiVault { throw new Error('amount is required'); } + const config = await this.getVaultConfig({ vaultId: params.vaultId }); + + if (config.protocol === VaultProtocol.CONCRETE_BTCCX) { + return this.depositToConcreteVault(params); + } else if (config.protocol === VaultProtocol.MORPHO) { + return this.depositToMorphoVault(params); + } else { + throw new Error(`Unsupported vault protocol: ${config.protocol}`); + } + } + + /** + * Concrete BTC vault deposit path. The client BTC wallet is custodial, so + * sendMany returns a pendingApproval rather than a signed transfer. + * No recipients are sent — WP resolves the escrow destination server-side. + */ + private async depositToConcreteVault(params: DepositToVaultOptions): Promise { + const sendManyResult = await this.wallet.sendMany({ + type: 'defi-deposit', + defiParams: { + vaultId: params.vaultId, + amount: params.amount, + actionType: 'defi-deposit', + }, + ...(params.walletPassphrase ? { walletPassphrase: params.walletPassphrase } : {}), + }); + + return this.extractConcreteDepositResult(sendManyResult); + } + + /** + * Morpho vault deposit path. Issues two sendMany calls (approve + deposit) + * and returns the operationId that links them. + */ + private async depositToMorphoVault(params: DepositToVaultOptions): Promise { // 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 @@ -256,6 +310,22 @@ export class DefiVault implements IDefiVault { return intent?.operationId as string | undefined; } + /** + * Extracts {@link ConcreteDepositResult} from a custodial sendMany response. + * Concrete BTC deposits return a `pendingApproval` instead of a txRequest — + * throws if `pendingApproval.id` is absent, indicating an unexpected shape. + */ + private extractConcreteDepositResult(sendManyResult: Record): ConcreteDepositResult { + const SendManyConcreteResponse = t.type({ + pendingApproval: t.intersection([t.type({ id: t.string }), t.partial({ state: t.string })]), + }); + const decoded = decodeWithCodec(SendManyConcreteResponse, sendManyResult, 'defi-deposit sendMany response'); + return { + pendingApprovalId: decoded.pendingApproval.id, + state: decoded.pendingApproval.state ?? 'awaitingSignature', + }; + } + private operationsUrl(): string { return `/api/defi-service/v1/wallets/${this.wallet.id()}/operations`; } diff --git a/modules/sdk-core/src/bitgo/defi/iDefiVault.ts b/modules/sdk-core/src/bitgo/defi/iDefiVault.ts index 6bc706b77a..1959756689 100644 --- a/modules/sdk-core/src/bitgo/defi/iDefiVault.ts +++ b/modules/sdk-core/src/bitgo/defi/iDefiVault.ts @@ -2,6 +2,8 @@ * @prettier */ +import { GetVaultResponse } from '@bitgo/public-types'; + export interface DepositToVaultOptions { /** DeFi-service vault identifier */ vaultId: string; @@ -32,6 +34,10 @@ export interface ListOperationsOptions { cursor?: string; } +export interface GetVaultConfigOptions { + vaultId: string; +} + export interface DefiOperation { operationId: string; walletId: string; @@ -45,14 +51,18 @@ export interface DefiOperation { updatedAt: string; } -export interface DepositResult { +export interface ConcreteDepositResult { + pendingApprovalId: string; + state: string; +} + +export interface MorphoDepositResult { operationId: string; - txRequestIds: { - approve: string; - deposit: string; - }; + txRequestIds: { approve: string; deposit: string }; } +export type DepositResult = ConcreteDepositResult | MorphoDepositResult; + export interface DefiOperationListResult { items: DefiOperation[]; nextCursor?: string; @@ -63,4 +73,5 @@ export interface IDefiVault { resumeDeposit(params: ResumeDepositOptions): Promise; getOperation(params: GetOperationOptions): Promise; listOperations(params: ListOperationsOptions): Promise; + getVaultConfig(params: GetVaultConfigOptions): Promise; } diff --git a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts index a635b2057b..40e591e7d2 100644 --- a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts +++ b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts @@ -135,6 +135,7 @@ export const BuildParams = t.exact( feeToken: t.unknown, // Bridging parameters for cross-chain operations (e.g., BTC to sBTC) bridgingParams: t.unknown, + defiParams: t.unknown, }), ]) ); diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index a9663e6bbc..02e420441a 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -937,6 +937,13 @@ export interface SendManyOptions extends PrebuildAndSignTransactionOptions { eip1559?: EIP1559; gasLimit?: number; custodianTransactionId?: string; + defiParams?: { + vaultId?: string; + amount?: string | number; + actionType?: string; + operationId?: string; + clientIdempotencyKey?: string; + }; } export interface FetchCrossChainUTXOsOptions { diff --git a/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts b/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts index d23f81ab53..21aa745d28 100644 --- a/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts +++ b/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts @@ -1,6 +1,7 @@ import sinon from 'sinon'; import assert from 'assert'; import 'should'; +import { VaultProtocol } from '@bitgo/public-types'; import { ActiveOperationExistsError, DefiVault, Wallet } from '../../../../src'; describe('DefiVault', function () { @@ -18,6 +19,36 @@ describe('DefiVault', function () { }; } + function makeConcreteVault(id: string) { + return { + id, + name: 'Concrete BTC Vault', + protocol: VaultProtocol.CONCRETE_BTCCX, + status: 'active', + coin: 'btc', + assetToken: 'btc', + shareToken: 'cbtc', + riskManager: 'manager-1', + custodyType: 'qualified', + vaultContractAddress: '0xVaultContract', + }; + } + + function makeMorphoVault(id: string) { + return { + id, + name: 'Morpho USDC Vault', + protocol: VaultProtocol.MORPHO, + status: 'active', + coin: 'eth', + assetToken: 'usdc', + shareToken: 'musdc', + riskManager: 'manager-2', + custodyType: 'qualified', + vaultContractAddress: '0xMorphoVault', + }; + } + beforeEach(function () { mockBitGo = { post: sinon.stub(), @@ -50,147 +81,314 @@ describe('DefiVault', function () { sinon.restore(); }); - describe('depositToVault', function () { - it('should call sendMany for approve and deposit on happy path', async function () { - const operationId = 'op-uuid-123'; - - // Mock sendMany for approve and deposit - const sendManyStub = sinon.stub(wallet, 'sendMany'); - // WP writes operationId into the built tx's coinSpecific (full apiVersion), - // not into the intent — mirror that real shape here. - sendManyStub.onFirstCall().resolves({ - txRequest: { - txRequestId: 'txreq-approve-1', - intent: { intentType: 'defi-approve' }, - transactions: [{ unsignedTx: { coinSpecific: { operationId } } }], - }, - }); - sendManyStub.onSecondCall().resolves({ - txRequest: { - txRequestId: 'txreq-deposit-1', - intent: { intentType: 'defi-deposit' }, - transactions: [{ unsignedTx: { coinSpecific: { operationId } } }], + describe('getVaultConfig', function () { + it('should fetch vault config for a given vaultId', async function () { + const vaultConfig = { + id: 'vlt-concrete-1', + name: 'Concrete BTC Vault', + protocol: VaultProtocol.CONCRETE_BTCCX, + status: 'active', + coin: 'btc', + assetToken: 'btc', + shareToken: 'cbtc', + riskManager: 'manager-1', + custodyType: 'qualified', + vaultContractAddress: '0xVaultContract', + concreteConfig: { + sourceWalletId: 'src-wallet', + escrowWalletId: 'escrow-wallet', + escrowDepositAddress: '1ExampleBtcAddress', + positionWalletId: 'pos-wallet', + positionBaseAddress: '1PositionAddress', }, - }); + }; - const result = await defiVault.depositToVault({ - vaultId: 'vlt-galaxy-usdc', - amount: '1000000', - }); + const req = mockRequest(vaultConfig); + mockBitGo.get.returns(req); - result.operationId.should.equal(operationId); - result.txRequestIds.approve.should.equal('txreq-approve-1'); - result.txRequestIds.deposit.should.equal('txreq-deposit-1'); + const result = await defiVault.getVaultConfig({ vaultId: 'vlt-concrete-1' }); - // Verify sendMany was called with correct params for approve - sendManyStub.calledTwice.should.be.true(); - const approveArgs: any = sendManyStub.firstCall.args[0]; - approveArgs.type.should.equal('defiApprove'); - approveArgs.defiParams.vaultId.should.equal('vlt-galaxy-usdc'); - approveArgs.defiParams.amount.should.equal('1000000'); + result.should.deepEqual(vaultConfig); + mockBitGo.get.calledWith('https://bitgo.com/api/defi-service/v1/vaults/vlt-concrete-1').should.be.true(); + }); - // Verify sendMany was called with correct params for deposit - const depositArgs: any = sendManyStub.secondCall.args[0]; - depositArgs.type.should.equal('defiDeposit'); - depositArgs.defiParams.operationId.should.equal(operationId); + it('should throw if vaultId is missing', async function () { + await assert.rejects(() => defiVault.getVaultConfig({ vaultId: '' }), { + message: 'vaultId is required', + }); }); - it('should extract operationId from the lite apiVersion coinSpecific', async function () { - const operationId = 'op-uuid-lite'; + it('should propagate errors from the API (e.g. 404)', async function () { + const req = mockRequest(null); + req.result.rejects(new Error('Not Found')); + mockBitGo.get.returns(req); - const sendManyStub = sinon.stub(wallet, 'sendMany'); - sendManyStub.onFirstCall().resolves({ - txRequest: { - txRequestId: 'txreq-approve-lite', - intent: { intentType: 'defi-approve' }, - unsignedTxs: [{ coinSpecific: { operationId } }], - }, + await assert.rejects(() => defiVault.getVaultConfig({ vaultId: 'vlt-not-found' }), { + message: 'Not Found', }); - sendManyStub.onSecondCall().resolves({ - txRequest: { txRequestId: 'txreq-deposit-lite' }, + }); + }); + + describe('depositToVault', function () { + describe('concrete_btccx provider', function () { + it('should call sendMany once with defi-deposit type and no recipients', async function () { + mockBitGo.get.returns(mockRequest(makeConcreteVault('vlt-btc-1'))); + + const sendManyStub = sinon.stub(wallet, 'sendMany').resolves({ + pendingApproval: { + id: 'pa-uuid-123', + state: 'awaitingSignature', + }, + }); + + const result = await defiVault.depositToVault({ + vaultId: 'vlt-btc-1', + amount: '5000000', + }); + + // Returns ConcreteDepositResult shape + (result as any).pendingApprovalId.should.equal('pa-uuid-123'); + (result as any).state.should.equal('awaitingSignature'); + + // Only one sendMany call — no second deposit sendMany + sendManyStub.calledOnce.should.be.true(); + + const callArgs: any = sendManyStub.firstCall.args[0]; + callArgs.type.should.equal('defi-deposit'); + callArgs.defiParams.vaultId.should.equal('vlt-btc-1'); + callArgs.defiParams.amount.should.equal('5000000'); + callArgs.defiParams.actionType.should.equal('defi-deposit'); + // No recipients key at all + assert.strictEqual(callArgs.recipients, undefined); }); - const result = await defiVault.depositToVault({ vaultId: 'vlt-galaxy-usdc', amount: '1000000' }); + it('should pass walletPassphrase when provided', async function () { + mockBitGo.get.returns(mockRequest(makeConcreteVault('vlt-btc-2'))); - result.operationId.should.equal(operationId); - const depositArgs: any = sendManyStub.secondCall.args[0]; - depositArgs.defiParams.operationId.should.equal(operationId); - }); + const sendManyStub = sinon.stub(wallet, 'sendMany').resolves({ + pendingApproval: { id: 'pa-uuid-456', state: 'awaitingSignature' }, + }); - it('should fall back to intent.operationId for forward-compat', async function () { - const operationId = 'op-uuid-intent'; + await defiVault.depositToVault({ + vaultId: 'vlt-btc-2', + amount: '1000000', + walletPassphrase: 'hunter2', + }); - const sendManyStub = sinon.stub(wallet, 'sendMany'); - sendManyStub.onFirstCall().resolves({ - txRequest: { - txRequestId: 'txreq-approve-intent', - intent: { intentType: 'defi-approve', operationId }, - }, + const callArgs: any = sendManyStub.firstCall.args[0]; + callArgs.walletPassphrase.should.equal('hunter2'); }); - sendManyStub.onSecondCall().resolves({ - txRequest: { txRequestId: 'txreq-deposit-intent' }, + + it('should default state to awaitingSignature when absent from pendingApproval', async function () { + mockBitGo.get.returns(mockRequest(makeConcreteVault('vlt-btc-3'))); + + sinon.stub(wallet, 'sendMany').resolves({ + pendingApproval: { id: 'pa-no-state' }, + }); + + const result = await defiVault.depositToVault({ vaultId: 'vlt-btc-3', amount: '1000000' }); + (result as any).state.should.equal('awaitingSignature'); }); - const result = await defiVault.depositToVault({ vaultId: 'vlt-galaxy-usdc', amount: '1000000' }); + it('should throw when sendMany returns no pendingApproval', async function () { + mockBitGo.get.returns(mockRequest(makeConcreteVault('vlt-btc-4'))); - result.operationId.should.equal(operationId); + sinon.stub(wallet, 'sendMany').resolves({ txRequest: { txRequestId: 'unexpected' } }); + + await assert.rejects(() => defiVault.depositToVault({ vaultId: 'vlt-btc-4', amount: '1000000' }), { + message: 'defi-deposit sendMany response: unknown; unknown', + }); + }); }); - it('should throw when operationId is absent from the approve txRequest', async function () { - const sendManyStub = sinon.stub(wallet, 'sendMany'); - sendManyStub.onFirstCall().resolves({ - txRequest: { - txRequestId: 'txreq-approve-missing', - intent: { intentType: 'defi-approve' }, - transactions: [{ unsignedTx: { coinSpecific: {} } }], - }, + describe('morpho provider', function () { + it('should call sendMany for approve and deposit on happy path', async function () { + mockBitGo.get.returns(mockRequest(makeMorphoVault('vlt-galaxy-usdc'))); + + const operationId = 'op-uuid-123'; + + const sendManyStub = sinon.stub(wallet, 'sendMany'); + // WP writes operationId into the built tx's coinSpecific (full apiVersion), + // not into the intent — mirror that real shape here. + sendManyStub.onFirstCall().resolves({ + txRequest: { + txRequestId: 'txreq-approve-1', + intent: { intentType: 'defi-approve' }, + transactions: [{ unsignedTx: { coinSpecific: { operationId } } }], + }, + }); + sendManyStub.onSecondCall().resolves({ + txRequest: { + txRequestId: 'txreq-deposit-1', + intent: { intentType: 'defi-deposit' }, + transactions: [{ unsignedTx: { coinSpecific: { operationId } } }], + }, + }); + + const result = await defiVault.depositToVault({ + vaultId: 'vlt-galaxy-usdc', + amount: '1000000', + }); + + (result as any).operationId.should.equal(operationId); + (result as any).txRequestIds.approve.should.equal('txreq-approve-1'); + (result as any).txRequestIds.deposit.should.equal('txreq-deposit-1'); + + // Verify sendMany was called with correct params for approve + sendManyStub.calledTwice.should.be.true(); + const approveArgs: any = sendManyStub.firstCall.args[0]; + approveArgs.type.should.equal('defiApprove'); + approveArgs.defiParams.vaultId.should.equal('vlt-galaxy-usdc'); + approveArgs.defiParams.amount.should.equal('1000000'); + + // Verify sendMany was called with correct params for deposit + const depositArgs: any = sendManyStub.secondCall.args[0]; + depositArgs.type.should.equal('defiDeposit'); + depositArgs.defiParams.operationId.should.equal(operationId); }); - await assert.rejects(() => defiVault.depositToVault({ vaultId: 'vlt-galaxy-usdc', amount: '1000000' }), { - message: 'operationId not found in approve txRequest response', + it('should extract operationId from the lite apiVersion coinSpecific', async function () { + mockBitGo.get.returns(mockRequest(makeMorphoVault('vlt-galaxy-usdc'))); + + const operationId = 'op-uuid-lite'; + + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.onFirstCall().resolves({ + txRequest: { + txRequestId: 'txreq-approve-lite', + intent: { intentType: 'defi-approve' }, + unsignedTxs: [{ coinSpecific: { operationId } }], + }, + }); + sendManyStub.onSecondCall().resolves({ + txRequest: { txRequestId: 'txreq-deposit-lite' }, + }); + + const result = await defiVault.depositToVault({ vaultId: 'vlt-galaxy-usdc', amount: '1000000' }); + + (result as any).operationId.should.equal(operationId); + const depositArgs: any = sendManyStub.secondCall.args[0]; + depositArgs.defiParams.operationId.should.equal(operationId); }); - // Deposit sendMany must not be issued when the operationId is missing - sendManyStub.calledOnce.should.be.true(); - }); + it('should fall back to intent.operationId for forward-compat', async function () { + mockBitGo.get.returns(mockRequest(makeMorphoVault('vlt-galaxy-usdc'))); + + const operationId = 'op-uuid-intent'; + + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.onFirstCall().resolves({ + txRequest: { + txRequestId: 'txreq-approve-intent', + intent: { intentType: 'defi-approve', operationId }, + }, + }); + sendManyStub.onSecondCall().resolves({ + txRequest: { txRequestId: 'txreq-deposit-intent' }, + }); - // TODO(CGD-1709): Re-enable when active operation pre-flight check is restored - xit('should reject when an active operation already exists', async function () { - const preflightReq = mockRequest({ - items: [{ operationId: 'existing-op-id', state: 'APPROVE_TX_REQUESTED' }], + const result = await defiVault.depositToVault({ vaultId: 'vlt-galaxy-usdc', amount: '1000000' }); + + (result as any).operationId.should.equal(operationId); }); - mockBitGo.get.returns(preflightReq); - - await assert.rejects( - () => defiVault.depositToVault({ vaultId: 'vlt-galaxy-usdc', amount: '1000000' }), - (err: Error) => { - (err instanceof ActiveOperationExistsError).should.be.true(); - (err as ActiveOperationExistsError).operationId.should.equal('existing-op-id'); - return true; - } - ); - }); - it('should propagate deposit sendMany failure without cleanup', async function () { - const operationId = 'op-uuid-456'; + it('should throw when operationId is absent from the approve txRequest', async function () { + mockBitGo.get.returns(mockRequest(makeMorphoVault('vlt-galaxy-usdc'))); - // Mock sendMany: approve succeeds, deposit fails - const sendManyStub = sinon.stub(wallet, 'sendMany'); - sendManyStub.onFirstCall().resolves({ - txRequest: { - txRequestId: 'txreq-approve-2', - intent: { intentType: 'defi-approve' }, - transactions: [{ unsignedTx: { coinSpecific: { operationId } } }], - }, + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.onFirstCall().resolves({ + txRequest: { + txRequestId: 'txreq-approve-missing', + intent: { intentType: 'defi-approve' }, + transactions: [{ unsignedTx: { coinSpecific: {} } }], + }, + }); + + await assert.rejects(() => defiVault.depositToVault({ vaultId: 'vlt-galaxy-usdc', amount: '1000000' }), { + message: 'operationId not found in approve txRequest response', + }); + + // Deposit sendMany must not be issued when the operationId is missing + sendManyStub.calledOnce.should.be.true(); + }); + + // TODO(CGD-1709): Re-enable when active operation pre-flight check is restored + xit('should reject when an active operation already exists', async function () { + const preflightReq = mockRequest({ + items: [{ operationId: 'existing-op-id', state: 'APPROVE_TX_REQUESTED' }], + }); + mockBitGo.get.returns(preflightReq); + + await assert.rejects( + () => defiVault.depositToVault({ vaultId: 'vlt-galaxy-usdc', amount: '1000000' }), + (err: Error) => { + (err instanceof ActiveOperationExistsError).should.be.true(); + (err as ActiveOperationExistsError).operationId.should.equal('existing-op-id'); + return true; + } + ); }); - sendManyStub.onSecondCall().rejects(new Error('deposit creation failed')); - await assert.rejects(() => defiVault.depositToVault({ vaultId: 'vlt-galaxy-usdc', amount: '1000000' }), { - message: 'deposit creation failed', + it('should propagate deposit sendMany failure without cleanup', async function () { + mockBitGo.get.returns(mockRequest(makeMorphoVault('vlt-galaxy-usdc'))); + + const operationId = 'op-uuid-456'; + + // Mock sendMany: approve succeeds, deposit fails + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.onFirstCall().resolves({ + txRequest: { + txRequestId: 'txreq-approve-2', + intent: { intentType: 'defi-approve' }, + transactions: [{ unsignedTx: { coinSpecific: { operationId } } }], + }, + }); + sendManyStub.onSecondCall().rejects(new Error('deposit creation failed')); + + await assert.rejects(() => defiVault.depositToVault({ vaultId: 'vlt-galaxy-usdc', amount: '1000000' }), { + message: 'deposit creation failed', + }); + + // SDK does not attempt cleanup — reconciler handles orphaned approvals + mockBitGo.del.called.should.be.false(); }); - // SDK does not attempt cleanup — reconciler handles orphaned approvals - mockBitGo.del.called.should.be.false(); + it('should pass clientIdempotencyKey and walletPassphrase when provided', async function () { + mockBitGo.get.returns(mockRequest(makeMorphoVault('vlt-galaxy-usdc'))); + + const operationId = 'op-uuid-789'; + + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.onFirstCall().resolves({ + txRequest: { + txRequestId: 'txreq-approve-3', + intent: { intentType: 'defi-approve' }, + transactions: [{ unsignedTx: { coinSpecific: { operationId } } }], + }, + }); + sendManyStub.onSecondCall().resolves({ + txRequest: { + txRequestId: 'txreq-deposit-3', + intent: { intentType: 'defi-deposit' }, + transactions: [{ unsignedTx: { coinSpecific: { operationId } } }], + }, + }); + + await defiVault.depositToVault({ + vaultId: 'vlt-galaxy-usdc', + amount: '1000000', + clientIdempotencyKey: 'idem-key-123', + walletPassphrase: 'test-passphrase', + }); + + const approveArgs: any = sendManyStub.firstCall.args[0]; + approveArgs.defiParams.clientIdempotencyKey.should.equal('idem-key-123'); + approveArgs.walletPassphrase.should.equal('test-passphrase'); + + const depositArgs: any = sendManyStub.secondCall.args[0]; + depositArgs.defiParams.clientIdempotencyKey.should.equal('idem-key-123'); + depositArgs.walletPassphrase.should.equal('test-passphrase'); + }); }); it('should throw if vaultId is missing', async function () { @@ -200,45 +398,12 @@ describe('DefiVault', function () { }); it('should throw if amount is missing', async function () { + mockBitGo.get.returns(mockRequest(makeMorphoVault('vlt-1'))); + await assert.rejects(() => defiVault.depositToVault({ vaultId: 'vlt-1', amount: '' }), { message: 'amount is required', }); }); - - it('should pass clientIdempotencyKey and walletPassphrase when provided', async function () { - const operationId = 'op-uuid-789'; - - const sendManyStub = sinon.stub(wallet, 'sendMany'); - sendManyStub.onFirstCall().resolves({ - txRequest: { - txRequestId: 'txreq-approve-3', - intent: { intentType: 'defi-approve' }, - transactions: [{ unsignedTx: { coinSpecific: { operationId } } }], - }, - }); - sendManyStub.onSecondCall().resolves({ - txRequest: { - txRequestId: 'txreq-deposit-3', - intent: { intentType: 'defi-deposit' }, - transactions: [{ unsignedTx: { coinSpecific: { operationId } } }], - }, - }); - - await defiVault.depositToVault({ - vaultId: 'vlt-galaxy-usdc', - amount: '1000000', - clientIdempotencyKey: 'idem-key-123', - walletPassphrase: 'test-passphrase', - }); - - const approveArgs: any = sendManyStub.firstCall.args[0]; - approveArgs.defiParams.clientIdempotencyKey.should.equal('idem-key-123'); - approveArgs.walletPassphrase.should.equal('test-passphrase'); - - const depositArgs: any = sendManyStub.secondCall.args[0]; - depositArgs.defiParams.clientIdempotencyKey.should.equal('idem-key-123'); - depositArgs.walletPassphrase.should.equal('test-passphrase'); - }); }); describe('resumeDeposit', function () { @@ -271,9 +436,9 @@ describe('DefiVault', function () { const result = await defiVault.resumeDeposit({ operationId: 'op-resume-1' }); - result.operationId.should.equal('op-resume-1'); - result.txRequestIds.approve.should.equal('txreq-approve-existing'); - result.txRequestIds.deposit.should.equal('txreq-deposit-resume'); + (result as any).operationId.should.equal('op-resume-1'); + (result as any).txRequestIds.approve.should.equal('txreq-approve-existing'); + (result as any).txRequestIds.deposit.should.equal('txreq-deposit-resume'); // Verify sendMany was called with correct defiParams const depositArgs: any = sendManyStub.firstCall.args[0]; diff --git a/yarn.lock b/yarn.lock index 4eabf566b6..d5f7f15cd6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -997,10 +997,10 @@ monocle-ts "^2.3.13" newtype-ts "^0.3.5" -"@bitgo/public-types@6.22.0": - version "6.22.0" - resolved "https://registry.npmjs.org/@bitgo/public-types/-/public-types-6.22.0.tgz#bbef2866c9b2d35e4a6179f7c400abc4f419d0ec" - integrity sha512-FueZVrrAKfevkoC9/TtKQLq5S19PzKfsNSj+0uHt1rEoKJ5vS1Icf/M/8pIwYVR11Kn3mjWzqbYJrJUZI/3FHQ== +"@bitgo/public-types@6.40.1": + version "6.40.1" + resolved "https://registry.npmjs.org/@bitgo/public-types/-/public-types-6.40.1.tgz#99d5810f3878ae236caa4579181753e013bae599" + integrity sha512-1tggms8hR62kti500bxrJRtH1he/Uk1sUiFpjrsFNZfVXFFNaY0O2j4F+/gXSjVIYVJijViccN8xe2cmjXttIA== dependencies: fp-ts "^2.0.0" io-ts "npm:@bitgo-forks/io-ts@2.1.4"