Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/three-seas-beam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@chainlink/functions-toolkit': patch
---

Added ResponseListener.listenForResponseFromTransaction() method to handle listening for responses if the request was reorged and the requestId changed
30 changes: 22 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,9 +450,27 @@ provider,
functionsRouterAddress,
})

To listen for the response to a single Functions request by request ID, use the the `listenForResponse()` method. Optionally, you can provide a custom timeout after which the listener will throw an error indicating that the time limit was exceeded. If no timeout is provided, the default timeout is 300 seconds.
To listen for a response to a single Functions request, use the `listenForResponseFromTransaction()` method.
Optionally, you can provide:
- timeout after which the listener will throw an error indicating that the time limit was exceeded (default 5 minutes)
- number of block confirmations (default 2)
- frequency of checking if the request is already included on-chain (or if it got moved after a chain re-org) (default 2 seconds)

**Note:** Listening for multiple responses simultaneously is not supported by the `listenForResponse()` method and will lead to undefined behavior.
```
const response: FunctionsResponse = await responseListener.listenForResponseFromTransaction(
txHash: string,
timeout?: number,
confirmations?: number,
checkInterval?: number,
)
```

Alternatively, to listen using a request ID, use the `listenForResponse()` method.

**Notes:**
1. Request ID can change during a chain re-org so it's less reliable than a request transaction hash.
2. If the methods are called after the response is already on chain, it won't be returned correctly.
3. Listening for multiple responses simultaneously is not supported by the above methods and will lead to undefined behavior.

```
const response: FunctionsResponse = await responseListener.listenForResponse(
Expand All @@ -479,13 +497,9 @@ The possible fulfillment codes are shown below.

```
{
FULFILLED = 0, // Indicates that calling the consumer contract's handleOracleFulfill method was successful
FULFILLED = 0, // Indicates that a Function was executed and calling the consumer contract's handleOracleFulfill method was successful
USER_CALLBACK_ERROR = 1, // Indicates that the consumer contract's handleOracleFulfill method reverted
INVALID_REQUEST_ID = 2, // Internal error
COST_EXCEEDS_COMMITMENT = 3, // Indicates that the request was not fulfilled because the cost of fulfillment is higher than the estimated cost due to an increase in gas prices
INSUFFICIENT_GAS_PROVIDED = 4, // Internal error
SUBSCRIPTION_BALANCE_INVARIANT_VIOLATION, // Internal error
INVALID_COMMITMENT = 6, // Internal error
// all other codes indicate internal errors
}
```

Expand Down
42 changes: 41 additions & 1 deletion src/ResponseListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { FulfillmentCode, type FunctionsResponse } from './types'

export class ResponseListener {
private functionsRouter: Contract
private provider: providers.Provider

constructor({
provider,
Expand All @@ -16,6 +17,7 @@ export class ResponseListener {
provider: providers.Provider
functionsRouterAddress: string
}) {
this.provider = provider
this.functionsRouter = new Contract(functionsRouterAddress, FunctionsRouterSource.abi, provider)
}

Expand Down Expand Up @@ -55,7 +57,45 @@ export class ResponseListener {
)
})

return await responsePromise
return responsePromise
}

public async listenForResponseFromTransaction(
txHash: string,
timeout = 3000000,
confirmations = 2,
checkInterval = 2000,
): Promise<FunctionsResponse> {
return new Promise<FunctionsResponse>((resolve, reject) => {
;(async () => {
let requestID: string
// eslint-disable-next-line prefer-const
let checkTimeout: NodeJS.Timeout
const expirationTimeout = setTimeout(() => {
reject('Response not received within timeout period')
}, timeout)

const check = async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might this method lead to a potential race condition if the check interval is much higher than the block time?

  1. User sends Functions request & calls listenForReponseFromTransaction which extracts the current requestId and listens for the response.
  2. A reorg occurs and the reorged request is fulfilled before a check is called again.
  3. Now we are listening for a response after the response has already occurred, meaning we won't ever actually hear it and this function hangs forever.

I think the logic is fine here as this only occurs with high checkInvervals, but maybe something to note in the README entry.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. The old method has a similar problem - when we start listening after the response is emitted, we will miss it. It seems to be inherent to a functionsRouter.on() call. I'll add a note to README.

const receipt = await this.provider.waitForTransaction(txHash, confirmations, timeout)
const updatedID = receipt.logs[0].topics[1]
if (updatedID !== requestID) {
requestID = updatedID
const response = await this.listenForResponse(receipt.logs[0].topics[1], timeout)
if (updatedID === requestID) {
// Resolve only if the ID hasn't changed in the meantime
clearTimeout(expirationTimeout)
clearInterval(checkTimeout)
resolve(response)
}
}
}

// Check periodically if the transaction has been re-orged and requestID changed
checkTimeout = setInterval(check, checkInterval)

check()
})()
})
}

public listenForResponses(
Expand Down
72 changes: 72 additions & 0 deletions test/integration/ResponseListener.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,78 @@ describe('Functions toolkit classes', () => {
expect(errResponse.fulfillmentCode).toBe(FulfillmentCode.FULFILLED)
})

it('Successfully waits for single response from transaction hash', async () => {
const subscriptionManager = new SubscriptionManager({
signer: allowlistedUser_A,
linkTokenAddress,
functionsRouterAddress,
})
await subscriptionManager.initialize()

const subscriptionId = await subscriptionManager.createSubscription()
await subscriptionManager.fundSubscription({
juelsAmount: utils.parseUnits('1', 'ether').toString(),
subscriptionId,
})
await subscriptionManager.addConsumer({
subscriptionId,
consumerAddress: exampleClient.address,
txOptions: {
confirmations: 1,
},
})

const functionsListener = new ResponseListener({
provider: allowlistedUser_A.provider,
functionsRouterAddress,
})

const succReqTx = await exampleClient.sendRequest(
'return Functions.encodeUint256(1)',
1,
[],
[],
[],
subscriptionId,
100_000,
)

const succReq = await succReqTx.wait()
const succResponse = await functionsListener.listenForResponseFromTransaction(
succReq.transactionHash,
1000000,
0,
)

expect(succResponse.responseBytesHexstring).toBe(
'0x0000000000000000000000000000000000000000000000000000000000000001',
)
expect(succResponse.errorString).toBe('')
expect(succResponse.returnDataBytesHexstring).toBe('0x')
expect(succResponse.fulfillmentCode).toBe(FulfillmentCode.FULFILLED)

const errReqTx = await exampleClient.sendRequest(
'return Functions.encodeUint256(1',
1,
[],
[],
[],
subscriptionId,
100_000,
)

const errReq = await errReqTx.wait(1)
const errRequestId = errReq.events[0].topics[1]

const errResponse = await functionsListener.listenForResponse(errRequestId)

expect(errResponse.requestId).toBe(errRequestId)
expect(errResponse.responseBytesHexstring).toBe('0x')
expect(errResponse.errorString).toBe('syntax error, RAM exceeded, or other error')
expect(errResponse.returnDataBytesHexstring).toBe('0x')
expect(errResponse.fulfillmentCode).toBe(FulfillmentCode.FULFILLED)
})

it('Successfully listens for responses', async () => {
const subscriptionManager = new SubscriptionManager({
signer: allowlistedUser_A,
Expand Down
Loading