diff --git a/modules/sdk-core/src/bitgo/defi/defiVault.ts b/modules/sdk-core/src/bitgo/defi/defiVault.ts index 63071f38f3..e26700d9de 100644 --- a/modules/sdk-core/src/bitgo/defi/defiVault.ts +++ b/modules/sdk-core/src/bitgo/defi/defiVault.ts @@ -220,12 +220,39 @@ export class DefiVault implements IDefiVault { } /** - * Extract operationId from the intent of a sendMany result. - * The WP populates operationId in the intent of the approve txRequest. + * Extract operationId from a sendMany result. + * + * The WP writes the defi-service-minted operationId into the built + * transaction's `coinSpecific` (alongside `assignedNonce`), not into the + * intent. Read it from there: the `full` apiVersion surfaces it at + * `transactions[0].unsignedTx.coinSpecific.operationId`, the `lite` version + * at `unsignedTxs[0].coinSpecific.operationId`. Fall back to + * `intent.operationId` for forward-compat in case the WP later also + * populates the intent. */ private extractOperationId(sendManyResult: Record): string | undefined { const txRequest = sendManyResult.txRequest as Record | undefined; - const intent = txRequest?.intent as Record | undefined; + if (!txRequest) { + return undefined; + } + + // full apiVersion: transactions[0].unsignedTx.coinSpecific.operationId + const transactions = txRequest.transactions as Array> | undefined; + const fullUnsignedTx = transactions?.[0]?.unsignedTx as Record | undefined; + const fullCoinSpecific = fullUnsignedTx?.coinSpecific as Record | undefined; + if (fullCoinSpecific?.operationId) { + return fullCoinSpecific.operationId as string; + } + + // lite apiVersion: unsignedTxs[0].coinSpecific.operationId + const unsignedTxs = txRequest.unsignedTxs as Array> | undefined; + const liteCoinSpecific = unsignedTxs?.[0]?.coinSpecific as Record | undefined; + if (liteCoinSpecific?.operationId) { + return liteCoinSpecific.operationId as string; + } + + // forward-compat: intent.operationId (in case the WP later populates the intent) + const intent = txRequest.intent as Record | undefined; return intent?.operationId as string | undefined; } diff --git a/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts b/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts index 6a4377c920..d23f81ab53 100644 --- a/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts +++ b/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts @@ -56,16 +56,20 @@ describe('DefiVault', function () { // 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', operationId }, + intent: { intentType: 'defi-approve' }, + transactions: [{ unsignedTx: { coinSpecific: { operationId } } }], }, }); sendManyStub.onSecondCall().resolves({ txRequest: { txRequestId: 'txreq-deposit-1', - intent: { intentType: 'defi-deposit', operationId }, + intent: { intentType: 'defi-deposit' }, + transactions: [{ unsignedTx: { coinSpecific: { operationId } } }], }, }); @@ -91,6 +95,65 @@ describe('DefiVault', function () { depositArgs.defiParams.operationId.should.equal(operationId); }); + it('should extract operationId from the lite apiVersion coinSpecific', async function () { + 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.operationId.should.equal(operationId); + const depositArgs: any = sendManyStub.secondCall.args[0]; + depositArgs.defiParams.operationId.should.equal(operationId); + }); + + it('should fall back to intent.operationId for forward-compat', async function () { + 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' }, + }); + + const result = await defiVault.depositToVault({ vaultId: 'vlt-galaxy-usdc', amount: '1000000' }); + + result.operationId.should.equal(operationId); + }); + + 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: {} } }], + }, + }); + + 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({ @@ -116,7 +179,8 @@ describe('DefiVault', function () { sendManyStub.onFirstCall().resolves({ txRequest: { txRequestId: 'txreq-approve-2', - intent: { intentType: 'defi-approve', operationId }, + intent: { intentType: 'defi-approve' }, + transactions: [{ unsignedTx: { coinSpecific: { operationId } } }], }, }); sendManyStub.onSecondCall().rejects(new Error('deposit creation failed')); @@ -148,13 +212,15 @@ describe('DefiVault', function () { sendManyStub.onFirstCall().resolves({ txRequest: { txRequestId: 'txreq-approve-3', - intent: { intentType: 'defi-approve', operationId }, + intent: { intentType: 'defi-approve' }, + transactions: [{ unsignedTx: { coinSpecific: { operationId } } }], }, }); sendManyStub.onSecondCall().resolves({ txRequest: { txRequestId: 'txreq-deposit-3', - intent: { intentType: 'defi-deposit', operationId }, + intent: { intentType: 'defi-deposit' }, + transactions: [{ unsignedTx: { coinSpecific: { operationId } } }], }, });