diff --git a/.changeset/signature-check-final-status.md b/.changeset/signature-check-final-status.md new file mode 100644 index 00000000..e0140e75 --- /dev/null +++ b/.changeset/signature-check-final-status.md @@ -0,0 +1,5 @@ +--- +'@relayprotocol/relay-sdk': patch +--- + +Stop signature steps from polling on after the status check reports `failure` or `refund`. A `failure` whose `details` did not contain "Transaction failed" was retried until the polling limit and then surfaced as a generic "Failed to get an ok response" timeout, and a `refund` was never treated as final. `execute` now rejects on the first such status, with the reported `details` for a failure and "Transaction failed: Refunded" (so `refunded` is set) for a refund, matching transaction steps. diff --git a/packages/sdk/src/utils/executeSteps/signatureStep.test.ts b/packages/sdk/src/utils/executeSteps/signatureStep.test.ts index edcc774b..cbac85a0 100644 --- a/packages/sdk/src/utils/executeSteps/signatureStep.test.ts +++ b/packages/sdk/src/utils/executeSteps/signatureStep.test.ts @@ -223,3 +223,55 @@ describe('handleSignatureStepItem (HyperCore)', () => { expect(stepItem.progressState).toBe('validating') }) }) + +describe('handleSignatureStepItem (status polling)', () => { + beforeEach(() => { + vi.mocked(findHyperliquidSendHash).mockResolvedValue(undefined) + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it('Should fail right away with the failure details from the check.', async () => { + const request = pollReturns({ + status: 'failure', + details: 'Deposit transaction reverted' + }) + const { params } = setup() + params.maximumAttempts = 5 + + await expect(handleSignatureStepItem(params)).rejects.toThrow( + 'Deposit transaction reverted' + ) + expect(request).toHaveBeenCalledOnce() + }) + + it('Should fail right away as refunded when the check reports a refund.', async () => { + const request = pollReturns({ status: 'refund' }) + const { stepItem, params } = setup() + params.maximumAttempts = 5 + + await expect(handleSignatureStepItem(params)).rejects.toThrow( + 'Transaction failed: Refunded' + ) + expect(request).toHaveBeenCalledOnce() + expect(stepItem.checkStatus).toBe('refund') + }) + + it('Should keep polling when the check request itself errors.', async () => { + const request = vi + .spyOn(axios, 'request') + .mockRejectedValueOnce(new Error('Network Error')) + .mockResolvedValue({ + status: 200, + data: { status: 'success', txHashes: [FILL_HASH] } + } as never) + const { stepItem, params } = setup() + params.maximumAttempts = 2 + + await handleSignatureStepItem(params) + + expect(request).toHaveBeenCalledTimes(2) + expect(stepItem.checkStatus).toBe('success') + }) +}) diff --git a/packages/sdk/src/utils/executeSteps/signatureStep.ts b/packages/sdk/src/utils/executeSteps/signatureStep.ts index 2a5f85b1..683c1157 100644 --- a/packages/sdk/src/utils/executeSteps/signatureStep.ts +++ b/packages/sdk/src/utils/executeSteps/signatureStep.ts @@ -14,6 +14,12 @@ import { postHyperliquidSignature } from '../hyperliquid.js' +/** + * A failure or refund reported by the check endpoint. It is final, whatever + * its message says, so polling stops instead of retrying. + */ +class CheckStatusError extends Error {} + /** * Handles the execution of a signature step item, including signing, posting, and validation. */ @@ -362,7 +368,12 @@ export async function handleSignatureStepItem({ client.log(['Transaction completed successfully'], LogLevel.Verbose) return // Success - exit polling } else if (res?.data?.status === 'failure') { - throw Error(res?.data?.details || 'Transaction failed') + throw new CheckStatusError( + res?.data?.details || 'Transaction failed' + ) + } else if (res?.data?.status === 'refund') { + stepItem.checkStatus = 'refund' + throw new CheckStatusError('Transaction failed: Refunded') } else if (res.status >= 400) { // Handle HTTP error responses that don't have our expected data structure throw Error( @@ -375,10 +386,11 @@ export async function handleSignatureStepItem({ } catch (error: any) { // If it's a deliberate failure response, re-throw immediately if ( - error.message && - (error.message.includes('Transaction failed') || - error.message.includes('Failed to check') || - error.message === 'Failed to check') + error instanceof CheckStatusError || + (error.message && + (error.message.includes('Transaction failed') || + error.message.includes('Failed to check') || + error.message === 'Failed to check')) ) { throw error }