From 1b486bb946d90c9a4d9ea1d5eb0d7aa5f99cac60 Mon Sep 17 00:00:00 2001 From: Derek Croote Date: Thu, 16 Mar 2023 22:08:26 -0700 Subject: [PATCH] Implement RequesterAuthorizerWithErc721 authorizers (#1670) * Implement RequesterAuthorizerWithErc721 authorizers * Fix authorizers empty arrays behavior Previously requesterEndpointAuthorizers being empty would result in all requests being authorized, independent of whether the other authorizer arrays were nonempty * Add INFO log for all authorizer arrays being empty --------- Co-authored-by: Emanuel Tesar --- .changeset/tiny-pumpkins-cross.md | 7 + .prettierignore | 2 +- .swp | Bin 0 -> 12288 bytes .../config/config.example.json | 4 +- .../test/fixtures/config.aws.valid.json | 4 +- .../config.example.json | 4 +- .../create-config.ts | 2 + .../config.example.json | 4 +- .../create-config.ts | 2 + .../config.example.json | 4 +- .../coingecko-http-gateways/create-config.ts | 2 + .../config.example.json | 4 +- .../create-config.ts | 2 + .../config.example.json | 4 +- .../coingecko-pre-processing/create-config.ts | 2 + .../coingecko-template/config.example.json | 4 +- .../coingecko-template/create-config.ts | 2 + .../coingecko/config.example.json | 4 +- .../integrations/coingecko/create-config.ts | 2 + .../failing-example/config.example.json | 4 +- .../failing-example/create-config.ts | 2 + .../config.example.json | 4 +- .../relay-security-schemes/create-config.ts | 2 + .../weather-multi-value/config.example.json | 4 +- .../weather-multi-value/create-config.ts | 2 + .../airnode-node/config/config.example.json | 4 +- .../coordinator/calls/chain-limits.test.ts | 2 + .../authorization-application.test.ts | 7 +- .../authorization-application.ts | 6 +- .../authorization-fetching.test.ts | 111 +++- .../authorization/authorization-fetching.ts | 145 ++++- .../evm/handlers/initialize-provider.test.ts | 38 +- .../src/evm/handlers/initialize-provider.ts | 132 ++++- .../src/providers/actions.test.ts | 13 +- .../airnode-node/src/providers/state.test.ts | 13 +- .../test/e2e/erc721-authorizers.feature.ts | 49 ++ .../test/fixtures/config/config.ts | 2 + .../test/fixtures/operation/deploy-config.ts | 2 + .../test/fixtures/provider-states/evm.ts | 2 + .../airnode-node/test/setup/e2e/deployment.ts | 31 +- .../airnode-node/test/setup/e2e/testing.ts | 2 +- packages/airnode-node/test/setup/e2e/utils.ts | 2 + .../src/config/evm-dev-config.json | 4 +- .../src/evm/deploy/deployment.ts | 31 +- .../airnode-operation/src/evm/deploy/state.ts | 11 + .../src/scripts/evm-dev-deploy.ts | 31 +- packages/airnode-operation/src/types.ts | 32 +- .../contracts/authorizers/mock/MockErc721.sol | 12 + .../dev/RequesterAuthorizerWithErc721.sol | 545 ++++++++++++++++++ .../AccessControlRegistryAdminned.sol | 56 ++ .../AccessControlRegistryUser.sol | 18 + .../access-control-registry/RoleDeriver.sol | 49 ++ .../interfaces/IAccessControlRegistry.sol | 32 + .../IAccessControlRegistryAdminned.sol | 8 + .../interfaces/IAccessControlRegistryUser.sol | 6 + .../IRequesterAuthorizerWithErc721.sol | 187 ++++++ packages/airnode-protocol/hardhat.config.js | 34 +- packages/airnode-protocol/src/index.ts | 11 + .../src/config/config.test.ts | 25 +- .../airnode-validator/src/config/config.ts | 34 +- .../test/fixtures/config.valid.json | 19 + .../fixtures/interpolated-config.valid.json | 19 + .../fixtures/invalid-secret-name/config.json | 4 +- 63 files changed, 1654 insertions(+), 152 deletions(-) create mode 100644 .changeset/tiny-pumpkins-cross.md create mode 100644 .swp create mode 100644 packages/airnode-node/test/e2e/erc721-authorizers.feature.ts create mode 100644 packages/airnode-protocol/contracts/authorizers/mock/MockErc721.sol create mode 100644 packages/airnode-protocol/contracts/dev/RequesterAuthorizerWithErc721.sol create mode 100644 packages/airnode-protocol/contracts/dev/access-control-registry/AccessControlRegistryAdminned.sol create mode 100644 packages/airnode-protocol/contracts/dev/access-control-registry/AccessControlRegistryUser.sol create mode 100644 packages/airnode-protocol/contracts/dev/access-control-registry/RoleDeriver.sol create mode 100644 packages/airnode-protocol/contracts/dev/access-control-registry/interfaces/IAccessControlRegistry.sol create mode 100644 packages/airnode-protocol/contracts/dev/access-control-registry/interfaces/IAccessControlRegistryAdminned.sol create mode 100644 packages/airnode-protocol/contracts/dev/access-control-registry/interfaces/IAccessControlRegistryUser.sol create mode 100644 packages/airnode-protocol/contracts/dev/interfaces/IRequesterAuthorizerWithErc721.sol diff --git a/.changeset/tiny-pumpkins-cross.md b/.changeset/tiny-pumpkins-cross.md new file mode 100644 index 0000000000..5f408ce14f --- /dev/null +++ b/.changeset/tiny-pumpkins-cross.md @@ -0,0 +1,7 @@ +--- +'@api3/airnode-node': minor +'@api3/airnode-protocol': minor +'@api3/airnode-validator': minor +--- + +Implement RequesterAuthorizerWithErc721 authorizers diff --git a/.prettierignore b/.prettierignore index 15de7f59b4..ea9692a1fe 100644 --- a/.prettierignore +++ b/.prettierignore @@ -38,4 +38,4 @@ config.json # !!! Temporary until the proper formatting is sorted out !!! # https://github.com/api3dao/airnode/issues/1660 -packages/airnode-protocol/contracts \ No newline at end of file +packages/airnode-protocol/contracts diff --git a/.swp b/.swp new file mode 100644 index 0000000000000000000000000000000000000000..3e0b14d7a439d6c9c762b877b472cc548e00051e GIT binary patch literal 12288 zcmeI2Uysv95Wr3QKwBXdwO?Qh4^FDYPQvNXVNsDHkScLJ@TWYW6WX)K&MMBXySwgj zjZXa({XYE`eeWmf*j~rxFx(-eBBb#{tnAM0%>3q$6~)K1Uw=7d2f@t4>w5LkTz~)N zd9dNKgxrGRx?LZ-KNXN^ky}JRcIJH6lO+J{f_ppWj-{>sG&c;|KQr zch5hWCuEoi5CI}U1c(3;AOb{y2oQn)pMWi{cz>eVFP!Nw-TlhByZcHvB0vO)01+Sp zM1Tko0U|&IhyW2F0z}|5B)}8T`}eBnee)GMkN^L-zyC+ydfq?R-1=u27r9IXhyW2F z0z`laT!w(QOvW0xfoEXsGqC|x+R-@LS_V44y_hePM1wKxE=!=u)#(PL4VoznTiaT>X$0<)k>b+ua_`4GB_?po;)n`+}Rq9 zkD@L;cw`)7&QQT>g)*5iR-e8B{DrWqCUcA@8zFt(vB33W%gx*9bnf*wbhSj+fOfX*x=$Y@x?6^3QY_vlyJgZs|k!M(TrbhQB* z)vzUg7g(l22KK4QB zonzM*eeuDoxpRnqi-du;Ay?S54Xw-%htx(!=ISH0`^T0X}7_V@P0oiN0hR^@RJt4&yuNVsZYgu)nnDu(m@{aF=vP-xgY7dj4%n%9n#u~vY_ afub~Eu$4};a => ({ authorizers: { requesterEndpointAuthorizers: [], crossChainRequesterAuthorizers: [], + requesterAuthorizersWithErc721: [], + crossChainRequesterAuthorizersWithErc721: [], }, authorizations: { requesterEndpointAuthorizations: {}, diff --git a/packages/airnode-examples/integrations/coingecko-cross-chain-authorizer/config.example.json b/packages/airnode-examples/integrations/coingecko-cross-chain-authorizer/config.example.json index 8847b74a45..0db3dc9f19 100644 --- a/packages/airnode-examples/integrations/coingecko-cross-chain-authorizer/config.example.json +++ b/packages/airnode-examples/integrations/coingecko-cross-chain-authorizer/config.example.json @@ -16,7 +16,9 @@ "url": "${CROSS_CHAIN_PROVIDER_URL}" } } - ] + ], + "requesterAuthorizersWithErc721": [], + "crossChainRequesterAuthorizersWithErc721": [] }, "authorizations": { "requesterEndpointAuthorizations": {} diff --git a/packages/airnode-examples/integrations/coingecko-cross-chain-authorizer/create-config.ts b/packages/airnode-examples/integrations/coingecko-cross-chain-authorizer/create-config.ts index 6472cf7ccd..c2395903a5 100644 --- a/packages/airnode-examples/integrations/coingecko-cross-chain-authorizer/create-config.ts +++ b/packages/airnode-examples/integrations/coingecko-cross-chain-authorizer/create-config.ts @@ -27,6 +27,8 @@ const createConfig = async (generateExampleFile: boolean): Promise => ({ }, }, ], + requesterAuthorizersWithErc721: [], + crossChainRequesterAuthorizersWithErc721: [], }, authorizations: { requesterEndpointAuthorizations: {}, diff --git a/packages/airnode-examples/integrations/coingecko-http-gateways/config.example.json b/packages/airnode-examples/integrations/coingecko-http-gateways/config.example.json index 24afe1cba7..3c2cdfd87d 100644 --- a/packages/airnode-examples/integrations/coingecko-http-gateways/config.example.json +++ b/packages/airnode-examples/integrations/coingecko-http-gateways/config.example.json @@ -4,7 +4,9 @@ "maxConcurrency": 100, "authorizers": { "requesterEndpointAuthorizers": [], - "crossChainRequesterAuthorizers": [] + "crossChainRequesterAuthorizers": [], + "requesterAuthorizersWithErc721": [], + "crossChainRequesterAuthorizersWithErc721": [] }, "authorizations": { "requesterEndpointAuthorizations": {} diff --git a/packages/airnode-examples/integrations/coingecko-http-gateways/create-config.ts b/packages/airnode-examples/integrations/coingecko-http-gateways/create-config.ts index fcc5313da6..da9032b18e 100644 --- a/packages/airnode-examples/integrations/coingecko-http-gateways/create-config.ts +++ b/packages/airnode-examples/integrations/coingecko-http-gateways/create-config.ts @@ -14,6 +14,8 @@ const createConfig = async (generateExampleFile: boolean): Promise => ({ authorizers: { requesterEndpointAuthorizers: [], crossChainRequesterAuthorizers: [], + requesterAuthorizersWithErc721: [], + crossChainRequesterAuthorizersWithErc721: [], }, authorizations: { requesterEndpointAuthorizations: {}, diff --git a/packages/airnode-examples/integrations/coingecko-post-processing/config.example.json b/packages/airnode-examples/integrations/coingecko-post-processing/config.example.json index b422cf3f0d..9b8c0f2a67 100644 --- a/packages/airnode-examples/integrations/coingecko-post-processing/config.example.json +++ b/packages/airnode-examples/integrations/coingecko-post-processing/config.example.json @@ -4,7 +4,9 @@ "maxConcurrency": 100, "authorizers": { "requesterEndpointAuthorizers": [], - "crossChainRequesterAuthorizers": [] + "crossChainRequesterAuthorizers": [], + "requesterAuthorizersWithErc721": [], + "crossChainRequesterAuthorizersWithErc721": [] }, "authorizations": { "requesterEndpointAuthorizations": {} diff --git a/packages/airnode-examples/integrations/coingecko-post-processing/create-config.ts b/packages/airnode-examples/integrations/coingecko-post-processing/create-config.ts index fade47d266..f6ffb444db 100644 --- a/packages/airnode-examples/integrations/coingecko-post-processing/create-config.ts +++ b/packages/airnode-examples/integrations/coingecko-post-processing/create-config.ts @@ -14,6 +14,8 @@ const createConfig = async (generateExampleFile: boolean): Promise => ({ authorizers: { requesterEndpointAuthorizers: [], crossChainRequesterAuthorizers: [], + requesterAuthorizersWithErc721: [], + crossChainRequesterAuthorizersWithErc721: [], }, authorizations: { requesterEndpointAuthorizations: {}, diff --git a/packages/airnode-examples/integrations/coingecko-pre-processing/config.example.json b/packages/airnode-examples/integrations/coingecko-pre-processing/config.example.json index 5f6d5a0bc8..d700c6a450 100644 --- a/packages/airnode-examples/integrations/coingecko-pre-processing/config.example.json +++ b/packages/airnode-examples/integrations/coingecko-pre-processing/config.example.json @@ -4,7 +4,9 @@ "maxConcurrency": 100, "authorizers": { "requesterEndpointAuthorizers": [], - "crossChainRequesterAuthorizers": [] + "crossChainRequesterAuthorizers": [], + "requesterAuthorizersWithErc721": [], + "crossChainRequesterAuthorizersWithErc721": [] }, "authorizations": { "requesterEndpointAuthorizations": {} diff --git a/packages/airnode-examples/integrations/coingecko-pre-processing/create-config.ts b/packages/airnode-examples/integrations/coingecko-pre-processing/create-config.ts index dcd27525a6..eeb42a8ac6 100644 --- a/packages/airnode-examples/integrations/coingecko-pre-processing/create-config.ts +++ b/packages/airnode-examples/integrations/coingecko-pre-processing/create-config.ts @@ -14,6 +14,8 @@ const createConfig = async (generateExampleFile: boolean): Promise => ({ authorizers: { requesterEndpointAuthorizers: [], crossChainRequesterAuthorizers: [], + requesterAuthorizersWithErc721: [], + crossChainRequesterAuthorizersWithErc721: [], }, authorizations: { requesterEndpointAuthorizations: {}, diff --git a/packages/airnode-examples/integrations/coingecko-template/config.example.json b/packages/airnode-examples/integrations/coingecko-template/config.example.json index 745d12b89c..16b404e95f 100644 --- a/packages/airnode-examples/integrations/coingecko-template/config.example.json +++ b/packages/airnode-examples/integrations/coingecko-template/config.example.json @@ -4,7 +4,9 @@ "maxConcurrency": 100, "authorizers": { "requesterEndpointAuthorizers": [], - "crossChainRequesterAuthorizers": [] + "crossChainRequesterAuthorizers": [], + "requesterAuthorizersWithErc721": [], + "crossChainRequesterAuthorizersWithErc721": [] }, "authorizations": { "requesterEndpointAuthorizations": {} diff --git a/packages/airnode-examples/integrations/coingecko-template/create-config.ts b/packages/airnode-examples/integrations/coingecko-template/create-config.ts index 6eb9a4ec94..90da078396 100644 --- a/packages/airnode-examples/integrations/coingecko-template/create-config.ts +++ b/packages/airnode-examples/integrations/coingecko-template/create-config.ts @@ -15,6 +15,8 @@ const createConfig = async (generateExampleFile: boolean): Promise => ({ authorizers: { requesterEndpointAuthorizers: [], crossChainRequesterAuthorizers: [], + requesterAuthorizersWithErc721: [], + crossChainRequesterAuthorizersWithErc721: [], }, authorizations: { requesterEndpointAuthorizations: {}, diff --git a/packages/airnode-examples/integrations/coingecko/config.example.json b/packages/airnode-examples/integrations/coingecko/config.example.json index c6faff8d15..cc89f4c6fb 100644 --- a/packages/airnode-examples/integrations/coingecko/config.example.json +++ b/packages/airnode-examples/integrations/coingecko/config.example.json @@ -4,7 +4,9 @@ "maxConcurrency": 100, "authorizers": { "requesterEndpointAuthorizers": [], - "crossChainRequesterAuthorizers": [] + "crossChainRequesterAuthorizers": [], + "requesterAuthorizersWithErc721": [], + "crossChainRequesterAuthorizersWithErc721": [] }, "authorizations": { "requesterEndpointAuthorizations": {} diff --git a/packages/airnode-examples/integrations/coingecko/create-config.ts b/packages/airnode-examples/integrations/coingecko/create-config.ts index 51d80ee6d8..9319665c7e 100644 --- a/packages/airnode-examples/integrations/coingecko/create-config.ts +++ b/packages/airnode-examples/integrations/coingecko/create-config.ts @@ -14,6 +14,8 @@ const createConfig = async (generateExampleFile: boolean): Promise => ({ authorizers: { requesterEndpointAuthorizers: [], crossChainRequesterAuthorizers: [], + requesterAuthorizersWithErc721: [], + crossChainRequesterAuthorizersWithErc721: [], }, authorizations: { requesterEndpointAuthorizations: {}, diff --git a/packages/airnode-examples/integrations/failing-example/config.example.json b/packages/airnode-examples/integrations/failing-example/config.example.json index add44b1fe8..439cf431da 100644 --- a/packages/airnode-examples/integrations/failing-example/config.example.json +++ b/packages/airnode-examples/integrations/failing-example/config.example.json @@ -4,7 +4,9 @@ "maxConcurrency": 100, "authorizers": { "requesterEndpointAuthorizers": [], - "crossChainRequesterAuthorizers": [] + "crossChainRequesterAuthorizers": [], + "requesterAuthorizersWithErc721": [], + "crossChainRequesterAuthorizersWithErc721": [] }, "authorizations": { "requesterEndpointAuthorizations": {} diff --git a/packages/airnode-examples/integrations/failing-example/create-config.ts b/packages/airnode-examples/integrations/failing-example/create-config.ts index 457c8cafc3..d3690fccc6 100644 --- a/packages/airnode-examples/integrations/failing-example/create-config.ts +++ b/packages/airnode-examples/integrations/failing-example/create-config.ts @@ -14,6 +14,8 @@ const createConfig = async (generateExampleFile: boolean): Promise => ({ authorizers: { requesterEndpointAuthorizers: [], crossChainRequesterAuthorizers: [], + requesterAuthorizersWithErc721: [], + crossChainRequesterAuthorizersWithErc721: [], }, authorizations: { requesterEndpointAuthorizations: {}, diff --git a/packages/airnode-examples/integrations/relay-security-schemes/config.example.json b/packages/airnode-examples/integrations/relay-security-schemes/config.example.json index aa966a2324..430b520830 100644 --- a/packages/airnode-examples/integrations/relay-security-schemes/config.example.json +++ b/packages/airnode-examples/integrations/relay-security-schemes/config.example.json @@ -4,7 +4,9 @@ "maxConcurrency": 100, "authorizers": { "requesterEndpointAuthorizers": [], - "crossChainRequesterAuthorizers": [] + "crossChainRequesterAuthorizers": [], + "requesterAuthorizersWithErc721": [], + "crossChainRequesterAuthorizersWithErc721": [] }, "authorizations": { "requesterEndpointAuthorizations": {} diff --git a/packages/airnode-examples/integrations/relay-security-schemes/create-config.ts b/packages/airnode-examples/integrations/relay-security-schemes/create-config.ts index 40b3a2527e..ea59d10eeb 100644 --- a/packages/airnode-examples/integrations/relay-security-schemes/create-config.ts +++ b/packages/airnode-examples/integrations/relay-security-schemes/create-config.ts @@ -14,6 +14,8 @@ const createConfig = async (generateExampleFile: boolean): Promise => ({ authorizers: { requesterEndpointAuthorizers: [], crossChainRequesterAuthorizers: [], + requesterAuthorizersWithErc721: [], + crossChainRequesterAuthorizersWithErc721: [], }, authorizations: { requesterEndpointAuthorizations: {}, diff --git a/packages/airnode-examples/integrations/weather-multi-value/config.example.json b/packages/airnode-examples/integrations/weather-multi-value/config.example.json index b5092cc14f..25e2a9163d 100644 --- a/packages/airnode-examples/integrations/weather-multi-value/config.example.json +++ b/packages/airnode-examples/integrations/weather-multi-value/config.example.json @@ -4,7 +4,9 @@ "maxConcurrency": 100, "authorizers": { "requesterEndpointAuthorizers": [], - "crossChainRequesterAuthorizers": [] + "crossChainRequesterAuthorizers": [], + "requesterAuthorizersWithErc721": [], + "crossChainRequesterAuthorizersWithErc721": [] }, "authorizations": { "requesterEndpointAuthorizations": {} diff --git a/packages/airnode-examples/integrations/weather-multi-value/create-config.ts b/packages/airnode-examples/integrations/weather-multi-value/create-config.ts index 3151367184..3708dc544f 100644 --- a/packages/airnode-examples/integrations/weather-multi-value/create-config.ts +++ b/packages/airnode-examples/integrations/weather-multi-value/create-config.ts @@ -14,6 +14,8 @@ const createConfig = async (generateExampleFile: boolean): Promise => ({ authorizers: { requesterEndpointAuthorizers: [], crossChainRequesterAuthorizers: [], + requesterAuthorizersWithErc721: [], + crossChainRequesterAuthorizersWithErc721: [], }, authorizations: { requesterEndpointAuthorizations: {}, diff --git a/packages/airnode-node/config/config.example.json b/packages/airnode-node/config/config.example.json index e0f37594d1..1fd69a25ad 100644 --- a/packages/airnode-node/config/config.example.json +++ b/packages/airnode-node/config/config.example.json @@ -4,7 +4,9 @@ "maxConcurrency": 100, "authorizers": { "requesterEndpointAuthorizers": [], - "crossChainRequesterAuthorizers": [] + "crossChainRequesterAuthorizers": [], + "requesterAuthorizersWithErc721": [], + "crossChainRequesterAuthorizersWithErc721": [] }, "authorizations": { "requesterEndpointAuthorizations": {} diff --git a/packages/airnode-node/src/coordinator/calls/chain-limits.test.ts b/packages/airnode-node/src/coordinator/calls/chain-limits.test.ts index 90e2895583..29b6ef7087 100644 --- a/packages/airnode-node/src/coordinator/calls/chain-limits.test.ts +++ b/packages/airnode-node/src/coordinator/calls/chain-limits.test.ts @@ -10,6 +10,8 @@ const createChainConfig = (overrides: Partial): ChainConfig => { authorizers: { requesterEndpointAuthorizers: [], crossChainRequesterAuthorizers: [], + requesterAuthorizersWithErc721: [], + crossChainRequesterAuthorizersWithErc721: [], }, authorizations: { requesterEndpointAuthorizations: {}, diff --git a/packages/airnode-node/src/evm/authorization/authorization-application.test.ts b/packages/airnode-node/src/evm/authorization/authorization-application.test.ts index 498c6029e1..5e337610a6 100644 --- a/packages/airnode-node/src/evm/authorization/authorization-application.test.ts +++ b/packages/airnode-node/src/evm/authorization/authorization-application.test.ts @@ -33,7 +33,12 @@ describe('mergeAuthorizations', () => { const apiCall = fixtures.requests.buildApiCall({ id: '0xapiCallId' }); const authorizationByRequestId = { '0xapiCallId': true }; const [logs, res] = authorization.mergeAuthorizations([apiCall], authorizationByRequestId); - expect(logs).toEqual([]); + expect(logs).toEqual([ + { + level: 'DEBUG', + message: 'Requester:requesterAddress is authorized to access Endpoint ID:endpointId for Request ID:0xapiCallId', + }, + ]); expect(res).toEqual([apiCall]); }); diff --git a/packages/airnode-node/src/evm/authorization/authorization-application.ts b/packages/airnode-node/src/evm/authorization/authorization-application.ts index 55f8341f2a..639f671d59 100644 --- a/packages/airnode-node/src/evm/authorization/authorization-application.ts +++ b/packages/airnode-node/src/evm/authorization/authorization-application.ts @@ -30,7 +30,11 @@ function applyAuthorization( } if (authorized) { - return { ...acc, requests: [...acc.requests, apiCall] }; + const log = logger.pend( + 'DEBUG', + `Requester:${apiCall.requesterAddress} is authorized to access Endpoint ID:${apiCall.endpointId} for Request ID:${apiCall.id}` + ); + return { ...acc, logs: [...acc.logs, log], requests: [...acc.requests, apiCall] }; } // If the request is unauthorized, update drop the request diff --git a/packages/airnode-node/src/evm/authorization/authorization-fetching.test.ts b/packages/airnode-node/src/evm/authorization/authorization-fetching.test.ts index ae3f2868ad..fe2944d2d0 100644 --- a/packages/airnode-node/src/evm/authorization/authorization-fetching.test.ts +++ b/packages/airnode-node/src/evm/authorization/authorization-fetching.test.ts @@ -9,16 +9,18 @@ mockEthers({ }); import { ethers } from 'ethers'; +import { RequesterAuthorizerWithErc721Factory } from '@api3/airnode-protocol'; import * as authorization from './authorization-fetching'; import * as fixtures from '../../../test/fixtures'; import { AirnodeRrpV0 } from '../contracts'; import { ApiCall, Request } from '../../../src/types'; describe('fetch (authorizations)', () => { - let mutableFetchOptions: authorization.FetchOptions; + let mutableFetchOptions: authorization.AirnodeRrpFetchOptions; beforeEach(() => { mutableFetchOptions = { + type: 'airnodeRrp', requesterEndpointAuthorizers: [ '0x711c93B32c0D28a5d18feD87434cce11C3e5699B', '0x9E0e23766b0ed0C492804872c5164E9187fB56f5', @@ -39,26 +41,6 @@ describe('fetch (authorizations)', () => { expect(res).toEqual({}); }); - it('returns true for all pending requests if authorizers arrays are empty', async () => { - const apiCalls = Array.from(Array(19).keys()).map((n) => { - return fixtures.requests.buildApiCall({ - id: `${n}`, - endpointId: `endpointId-${n}`, - requesterAddress: `requesterAddress-${n}`, - sponsorAddress: 'sponsorAddress', - }); - }); - const [logs, res] = await authorization.fetch(apiCalls, { - ...mutableFetchOptions, - requesterEndpointAuthorizers: [], - }); - - expect(logs).toEqual([]); - expect(Object.keys(res).length).toEqual(19); - expect(res['0']).toEqual(true); - expect(res['18']).toEqual(true); - }); - it('calls the contract with groups of 10', async () => { checkAuthorizationStatusesMock.mockResolvedValueOnce(Array(10).fill(true)); checkAuthorizationStatusesMock.mockResolvedValueOnce(Array(9).fill(true)); @@ -135,8 +117,17 @@ describe('fetch (authorizations)', () => { const apiCall = fixtures.requests.buildApiCall(); const [logs, res] = await authorization.fetch([apiCall], mutableFetchOptions); expect(logs).toEqual([ - { level: 'ERROR', message: 'Failed to fetch group authorization details', error: new Error('Server says no') }, - { level: 'INFO', message: `Fetched authorization status for Request:${apiCall.id}` }, + { + level: 'WARN', + message: + 'Failed to fetch requesterEndpointAuthorizers authorization using checkAuthorizationStatuses. ' + + 'Falling back to fetching authorizations individually.', + error: new Error('Server says no'), + }, + { + level: 'INFO', + message: `Fetched requesterEndpointAuthorizers authorization using checkAuthorizationStatus for Request:${apiCall.id}`, + }, ]); expect(res).toEqual({ [apiCall.id]: true }); }); @@ -151,8 +142,17 @@ describe('fetch (authorizations)', () => { const apiCall = fixtures.requests.buildApiCall(); const [logs, res] = await authorization.fetch([apiCall], mutableFetchOptions); expect(logs).toEqual([ - { level: 'ERROR', message: 'Failed to fetch group authorization details', error: new Error('Server says no') }, - { level: 'INFO', message: `Fetched authorization status for Request:${apiCall.id}` }, + { + level: 'WARN', + message: + 'Failed to fetch requesterEndpointAuthorizers authorization using checkAuthorizationStatuses. ' + + 'Falling back to fetching authorizations individually.', + error: new Error('Server says no'), + }, + { + level: 'INFO', + message: `Fetched requesterEndpointAuthorizers authorization using checkAuthorizationStatus for Request:${apiCall.id}`, + }, ]); expect(res).toEqual({ [apiCall.id]: false }); }); @@ -167,10 +167,16 @@ describe('fetch (authorizations)', () => { const apiCall = fixtures.requests.buildApiCall(); const [logs, res] = await authorization.fetch([apiCall], mutableFetchOptions); expect(logs).toEqual([ - { level: 'ERROR', message: 'Failed to fetch group authorization details', error: new Error('Server says no') }, + { + level: 'WARN', + message: + 'Failed to fetch requesterEndpointAuthorizers authorization using checkAuthorizationStatuses. ' + + 'Falling back to fetching authorizations individually.', + error: new Error('Server says no'), + }, { level: 'ERROR', - message: `Failed to fetch authorization details for Request:${apiCall.id}`, + message: `Failed to fetch requesterEndpointAuthorizers authorization using checkAuthorizationStatus for Request:${apiCall.id}`, error: new Error('Server still says no'), }, ]); @@ -333,7 +339,12 @@ describe('fetchAuthorizationStatus', () => { airnodeAddress, apiCall ); - expect(logs).toEqual([{ level: 'INFO', message: `Fetched authorization status for Request:${apiCall.id}` }]); + expect(logs).toEqual([ + { + level: 'INFO', + message: `Fetched requesterEndpointAuthorizers authorization using checkAuthorizationStatus for Request:${apiCall.id}`, + }, + ]); expect(res).toEqual(true); }); @@ -347,7 +358,12 @@ describe('fetchAuthorizationStatus', () => { airnodeAddress, apiCall ); - expect(logs).toEqual([{ level: 'INFO', message: `Fetched authorization status for Request:${apiCall.id}` }]); + expect(logs).toEqual([ + { + level: 'INFO', + message: `Fetched requesterEndpointAuthorizers authorization using checkAuthorizationStatus for Request:${apiCall.id}`, + }, + ]); expect(res).toEqual(false); }); @@ -364,10 +380,45 @@ describe('fetchAuthorizationStatus', () => { expect(logs).toEqual([ { level: 'ERROR', - message: `Failed to fetch authorization details for Request:${apiCall.id}`, + message: `Failed to fetch requesterEndpointAuthorizers authorization using checkAuthorizationStatus for Request:${apiCall.id}`, error: new Error('Server still says no'), }, ]); expect(res).toEqual(null); }); }); + +describe('decodeMulticall', () => { + it('decodes the results of a multicall', () => { + const requesterAuthorizerWithErc721 = RequesterAuthorizerWithErc721Factory.connect( + '0x0', + new ethers.providers.JsonRpcProvider() + ); + const data = [ + ethers.utils.defaultAbiCoder.encode(['bool'], [true]), + ethers.utils.defaultAbiCoder.encode(['bool'], [false]), + ]; + expect(authorization.decodeMulticall(requesterAuthorizerWithErc721, data)).toEqual([true, false]); + }); +}); + +describe('applyErc721Authorizations', () => { + it('returns authorization statuses in the same order that they were requested', () => { + const apiCalls: Request[] = [ + fixtures.requests.buildApiCall({ id: '0x1' }), + fixtures.requests.buildApiCall({ id: '0x2' }), + fixtures.requests.buildApiCall({ id: '0x3' }), + fixtures.requests.buildApiCall({ id: '0x4' }), + ]; + const erc721s = ['0x1', '0x2']; + const results = [true, true, true, false, false, true, false, false]; + // The requester is authorized if authorized by any Erc721 + const expected = { + '0x1': true, + '0x2': true, + '0x3': true, + '0x4': false, + }; + expect(authorization.applyErc721Authorizations(apiCalls, erc721s, results)).toEqual(expected); + }); +}); diff --git a/packages/airnode-node/src/evm/authorization/authorization-fetching.ts b/packages/airnode-node/src/evm/authorization/authorization-fetching.ts index bbbd0826c9..9609697f2e 100644 --- a/packages/airnode-node/src/evm/authorization/authorization-fetching.ts +++ b/packages/airnode-node/src/evm/authorization/authorization-fetching.ts @@ -5,19 +5,31 @@ import isEmpty from 'lodash/isEmpty'; import isNil from 'lodash/isNil'; import { logger } from '@api3/airnode-utilities'; import { go } from '@api3/promise-utils'; +import { RequesterAuthorizerWithErc721, RequesterAuthorizerWithErc721Factory } from '@api3/airnode-protocol'; import { ApiCall, AuthorizationByRequestId, Request, LogsData } from '../../types'; import { CONVENIENCE_BATCH_SIZE, BLOCKCHAIN_CALL_ATTEMPT_TIMEOUT } from '../../constants'; import { AirnodeRrpV0, AirnodeRrpV0Factory } from '../contracts'; -import { RequesterEndpointAuthorizers, ChainAuthorizations } from '../../config'; +import { Erc721s, ChainAuthorizations, RequesterEndpointAuthorizers } from '../../config'; export interface FetchOptions { - readonly requesterEndpointAuthorizers: RequesterEndpointAuthorizers; - readonly authorizations: ChainAuthorizations; readonly airnodeAddress: string; - readonly airnodeRrpAddress: string; + readonly authorizations: ChainAuthorizations; readonly provider: ethers.providers.JsonRpcProvider; } +export interface AirnodeRrpFetchOptions extends FetchOptions { + readonly type: 'airnodeRrp'; + readonly airnodeRrpAddress: string; + readonly requesterEndpointAuthorizers: RequesterEndpointAuthorizers; +} + +export interface Erc721FetchOptions extends FetchOptions { + readonly type: 'erc721'; + readonly chainId: string; + readonly erc721s: Erc721s; + readonly RequesterAuthorizerWithErc721Address: string; +} + export async function fetchAuthorizationStatus( airnodeRrp: AirnodeRrpV0, requesterEndpointAuthorizers: RequesterEndpointAuthorizers, @@ -39,16 +51,22 @@ export async function fetchAuthorizationStatus( if (!goAuthorized.success) { const log = logger.pend( 'ERROR', - `Failed to fetch authorization details for Request:${apiCall.id}`, + `Failed to fetch requesterEndpointAuthorizers authorization using checkAuthorizationStatus for Request:${apiCall.id}`, goAuthorized.error ); return [[log], null]; } if (isNil(goAuthorized.data)) { - const log = logger.pend('ERROR', `Failed to fetch authorization details for Request:${apiCall.id}`); + const log = logger.pend( + 'ERROR', + `Failed to fetch requesterEndpointAuthorizers authorization using checkAuthorizationStatus for Request:${apiCall.id}` + ); return [[log], null]; } - const successLog = logger.pend('INFO', `Fetched authorization status for Request:${apiCall.id}`); + const successLog = logger.pend( + 'INFO', + `Fetched requesterEndpointAuthorizers authorization using checkAuthorizationStatus for Request:${apiCall.id}` + ); return [[successLog], goAuthorized.data]; } @@ -77,7 +95,12 @@ async function fetchAuthorizationStatuses( const goData = await go(contractCall, { retries: 1, attemptTimeoutMs: BLOCKCHAIN_CALL_ATTEMPT_TIMEOUT }); if (!goData.success) { - const groupLog = logger.pend('ERROR', 'Failed to fetch group authorization details', goData.error); + const groupLog = logger.pend( + 'WARN', + 'Failed to fetch requesterEndpointAuthorizers authorization using checkAuthorizationStatuses. ' + + 'Falling back to fetching authorizations individually.', + goData.error + ); // If the authorization batch cannot be fetched, fallback to fetching authorizations individually const promises: Promise>[] = apiCalls.map( @@ -111,6 +134,62 @@ async function fetchAuthorizationStatuses( return [[], authorizationsById]; } +export function decodeMulticall( + requesterAuthorizerWithErc721: RequesterAuthorizerWithErc721, + data: string[] +): boolean[] { + return data.map((d) => requesterAuthorizerWithErc721.interface.decodeFunctionResult('isAuthorized', d)[0]); +} + +/** + * Returns authorization statuses by id in their requested order from the decoded multicall boolean array + */ +export function applyErc721Authorizations( + apiCalls: Request[], + erc721s: Erc721s, + authorizations: boolean[] +): AuthorizationByRequestId { + return apiCalls.reduce((acc, apiCall, index) => { + // Erc721s as an array requires slicing the authorizations array to get each api call's authorizations + const resultIndex = index * erc721s.length; + // The requester is authorized if authorized by any Erc721 + const authorized = authorizations.slice(resultIndex, resultIndex + erc721s.length).some((r) => r); + return { ...acc, [apiCall.id]: authorized }; + }, {}); +} + +async function fetchErc721AuthorizationStatuses( + requesterAuthorizerWithErc721: RequesterAuthorizerWithErc721, + airnodeAddress: string, + erc721s: Erc721s, + chainId: string, + apiCalls: Request[] +): Promise> { + // Batch isAuthorized calls using multicall. + const calldata = apiCalls.flatMap((apiCall) => { + return erc721s.map((erc721) => { + return requesterAuthorizerWithErc721.interface.encodeFunctionData('isAuthorized', [ + airnodeAddress, + chainId, + apiCall.requesterAddress, + erc721, + ]); + }); + }); + const contractCall = () => requesterAuthorizerWithErc721.callStatic.multicall(calldata); + const goData = await go(contractCall, { retries: 1, attemptTimeoutMs: BLOCKCHAIN_CALL_ATTEMPT_TIMEOUT }); + + if (!goData.success) { + const groupLog = logger.pend('ERROR', 'Failed to fetch Erc721 batch authorizations', goData.error); + + return [[groupLog], null]; + } + const decodedMulticall = decodeMulticall(requesterAuthorizerWithErc721, goData.data); + const authorizationsById = applyErc721Authorizations(apiCalls, erc721s, decodedMulticall); + + return [[], authorizationsById]; +} + export const checkConfigAuthorizations = (apiCalls: Request[], fetchOptions: FetchOptions) => { return apiCalls.reduce((acc: AuthorizationByRequestId, apiCall) => { // Check if an authorization is found in config for the apiCall endpointId @@ -129,21 +208,13 @@ export const checkConfigAuthorizations = (apiCalls: Request[], fetchOpt export async function fetch( apiCalls: Request[], - fetchOptions: FetchOptions + fetchOptions: AirnodeRrpFetchOptions | Erc721FetchOptions ): Promise> { // If there are no pending API calls then there is no need to make an ETH call if (isEmpty(apiCalls)) { return [[], {}]; } - // If there are no authorizer contracts then endpoint is public - if (isEmpty(fetchOptions.requesterEndpointAuthorizers)) { - const authorizationByRequestIds = apiCalls.map((pendingApiCall) => ({ - [pendingApiCall.id]: true, - })); - return [[], Object.assign({}, ...authorizationByRequestIds) as AuthorizationByRequestId]; - } - // Skip fetching authorization statuses if found in config for a specific authorization type // and requester address const configAuthorizationsByRequestId = checkConfigAuthorizations(apiCalls, fetchOptions); @@ -157,18 +228,34 @@ export async function fetch( // Request groups of 10 at a time const groupedPairs = chunk(apiCallsToFetchAuthorizationStatus, CONVENIENCE_BATCH_SIZE); - // Create an instance of the contract that we can re-use - const airnodeRrp = AirnodeRrpV0Factory.connect(fetchOptions.airnodeRrpAddress, fetchOptions.provider); - - // Fetch all authorization statuses in parallel - const promises = groupedPairs.map((pairs) => - fetchAuthorizationStatuses( - airnodeRrp, - fetchOptions.requesterEndpointAuthorizers, - fetchOptions.airnodeAddress, - pairs - ) - ); + let promises: Promise>[]; + switch (fetchOptions.type) { + case 'airnodeRrp': + // Fetch all authorization statuses in parallel + promises = groupedPairs.map((pairs) => + fetchAuthorizationStatuses( + AirnodeRrpV0Factory.connect(fetchOptions.airnodeRrpAddress, fetchOptions.provider), + fetchOptions.requesterEndpointAuthorizers, + fetchOptions.airnodeAddress, + pairs + ) + ); + break; + case 'erc721': + promises = groupedPairs.map((pairs) => + fetchErc721AuthorizationStatuses( + RequesterAuthorizerWithErc721Factory.connect( + fetchOptions.RequesterAuthorizerWithErc721Address, + fetchOptions.provider + ), + fetchOptions.airnodeAddress, + fetchOptions.erc721s, + fetchOptions.chainId, + pairs + ) + ); + break; + } const responses = await Promise.all(promises); const responseLogs = flatMap(responses, (r) => r[0]); diff --git a/packages/airnode-node/src/evm/handlers/initialize-provider.test.ts b/packages/airnode-node/src/evm/handlers/initialize-provider.test.ts index d92b2a4625..c27f3fb4a9 100644 --- a/packages/airnode-node/src/evm/handlers/initialize-provider.test.ts +++ b/packages/airnode-node/src/evm/handlers/initialize-provider.test.ts @@ -42,7 +42,8 @@ describe('initializeProvider', () => { const state = fixtures.buildEVMProviderState(); const res = await initializeProvider(state); - expect(fetchAuthorizationsSpy).toHaveBeenCalledTimes(1); + // Empty authorizer arrays short-circuits authorization fetching + expect(fetchAuthorizationsSpy).toHaveBeenCalledTimes(0); expect(res?.requests.apiCalls).toEqual([ { airnodeAddress: '0xA30CA71Ba54E83127214D3271aEA8F5D6bD4Dace', @@ -241,6 +242,9 @@ describe('initializeProvider', () => { '0x4': false, '0x5': false, '0x6': true, + '0x7': false, + '0x8': false, + '0x9': false, }; const crossChainAuthorizations: AuthorizationByRequestId = { '0x1': true, @@ -248,8 +252,35 @@ describe('initializeProvider', () => { '0x3': false, '0x4': false, }; + const erc721authorizations: AuthorizationByRequestId = { + '0x1': false, + '0x2': false, + '0x3': false, + '0x4': false, + '0x5': false, + '0x6': false, + '0x7': true, + '0x8': false, + '0x9': false, + }; + const erc721CrossChainAuthorizations: AuthorizationByRequestId = { + '0x1': false, + '0x2': false, + '0x3': false, + '0x4': false, + '0x5': false, + '0x6': false, + '0x7': false, + '0x8': true, + '0x9': false, + }; - const merged = mergeAuthorizationsByRequestId([authorizations, crossChainAuthorizations]); + const merged = mergeAuthorizationsByRequestId([ + authorizations, + crossChainAuthorizations, + erc721authorizations, + erc721CrossChainAuthorizations, + ]); expect(merged).toEqual({ '0x1': true, '0x2': true, @@ -257,6 +288,9 @@ describe('initializeProvider', () => { '0x4': false, '0x5': false, '0x6': true, + '0x7': true, + '0x8': true, + '0x9': false, } as AuthorizationByRequestId); }); }); diff --git a/packages/airnode-node/src/evm/handlers/initialize-provider.ts b/packages/airnode-node/src/evm/handlers/initialize-provider.ts index 2872679931..593afca5d9 100644 --- a/packages/airnode-node/src/evm/handlers/initialize-provider.ts +++ b/packages/airnode-node/src/evm/handlers/initialize-provider.ts @@ -1,4 +1,5 @@ import flatMap from 'lodash/flatMap'; +import isEmpty from 'lodash/isEmpty'; import mergeWith from 'lodash/mergeWith'; import { logger, PendingLog } from '@api3/airnode-utilities'; import { go } from '@api3/promise-utils'; @@ -10,12 +11,16 @@ import * as templates from '../templates'; import * as transactionCounts from '../transaction-counts'; import * as verification from '../verification'; import { buildEVMProvider } from '../evm-provider'; -import { AuthorizationByRequestId, EVMProviderState, ProviderState } from '../../types'; +import { AuthorizationByRequestId, EVMProviderState, LogsData, ProviderState } from '../../types'; type ParallelPromise = Promise<{ readonly id: string; readonly data: any; readonly logs: PendingLog[] }>; async function fetchSameChainAuthorizations(currentState: ProviderState) { - const fetchOptions: authorizations.FetchOptions = { + if (isEmpty(currentState.settings.authorizers.requesterEndpointAuthorizers)) { + return { id: 'authorizations', data: {}, logs: [] }; + } + const fetchOptions: authorizations.AirnodeRrpFetchOptions = { + type: 'airnodeRrp', requesterEndpointAuthorizers: currentState.settings.authorizers.requesterEndpointAuthorizers, authorizations: currentState.settings.authorizations, airnodeAddress: currentState.settings.airnodeAddress, @@ -26,14 +31,16 @@ async function fetchSameChainAuthorizations(currentState: ProviderState) { - const promises = currentState.settings.authorizers.crossChainRequesterAuthorizers.map(async (authorizer) => { - const fetchOptions: authorizations.FetchOptions = { - requesterEndpointAuthorizers: authorizer.requesterEndpointAuthorizers, - authorizations: currentState.settings.authorizations, +async function fetchSameChainErc721Authorizations(currentState: ProviderState) { + const promises = currentState.settings.authorizers.requesterAuthorizersWithErc721.map(async (authorizer) => { + const fetchOptions: authorizations.Erc721FetchOptions = { + type: 'erc721', airnodeAddress: currentState.settings.airnodeAddress, - airnodeRrpAddress: authorizer.contracts.AirnodeRrp, - provider: buildEVMProvider(authorizer.chainProvider.url, authorizer.chainId), + authorizations: currentState.settings.authorizations, + chainId: currentState.settings.chainId, + erc721s: authorizer.erc721s, + provider: currentState.provider, + RequesterAuthorizerWithErc721Address: authorizer.RequesterAuthorizerWithErc721, }; const result = await authorizations.fetch(currentState.requests.apiCalls, fetchOptions); return result; @@ -43,7 +50,51 @@ async function fetchCrossChainAuthorizations(currentState: ProviderState r[0]); const authorizationStatuses = responses.map((r) => r[1]); - return { id: 'crossChainAuthorizations', data: authorizationStatuses, logs }; + return { id: 'erc721Authorizations', data: authorizationStatuses, logs }; +} + +async function fetchCrossChainAuthorizations( + currentState: ProviderState, + id: 'crossChainAuthorizations' | 'erc721CrossChainAuthorizations' +) { + let promises: Promise>[]; + switch (id) { + case 'crossChainAuthorizations': + promises = currentState.settings.authorizers.crossChainRequesterAuthorizers.map(async (authorizer) => { + const fetchOptions: authorizations.AirnodeRrpFetchOptions = { + type: 'airnodeRrp', + requesterEndpointAuthorizers: authorizer.requesterEndpointAuthorizers, + authorizations: currentState.settings.authorizations, + airnodeAddress: currentState.settings.airnodeAddress, + airnodeRrpAddress: authorizer.contracts.AirnodeRrp, + provider: buildEVMProvider(authorizer.chainProvider.url, authorizer.chainId), + }; + const result = await authorizations.fetch(currentState.requests.apiCalls, fetchOptions); + return result; + }); + break; + case 'erc721CrossChainAuthorizations': + promises = currentState.settings.authorizers.crossChainRequesterAuthorizersWithErc721.map(async (authorizer) => { + const fetchOptions: authorizations.Erc721FetchOptions = { + type: 'erc721', + airnodeAddress: currentState.settings.airnodeAddress, + authorizations: currentState.settings.authorizations, + chainId: authorizer.chainId, + erc721s: authorizer.erc721s, + provider: buildEVMProvider(authorizer.chainProvider.url, authorizer.chainId), + RequesterAuthorizerWithErc721Address: authorizer.contracts.RequesterAuthorizerWithErc721, + }; + const result = await authorizations.fetch(currentState.requests.apiCalls, fetchOptions); + return result; + }); + break; + } + + const responses = await Promise.all(promises); + const logs = flatMap(responses, (r) => r[0]); + const authorizationStatuses = responses.map((r) => r[1]); + + return { id: id, data: authorizationStatuses, logs }; } async function fetchTransactionCounts(currentState: ProviderState) { @@ -142,32 +193,59 @@ export async function initializeProvider( // STEP 6: Fetch authorizations and transaction counts // ================================================================= // NOTE: None of these promises can fail otherwise Promise.all will reject - const authAndTxCountPromises: readonly ParallelPromise[] = [ - fetchSameChainAuthorizations(state5), - fetchTransactionCounts(state5), - fetchCrossChainAuthorizations(state5), - ]; + + // If all authorizers arrays are empty then all requests are authorized + const allAuthorizersEmpty = Object.values(state5.settings.authorizers).every((arr) => isEmpty(arr)); + + const authAndTxCountPromises: readonly ParallelPromise[] = allAuthorizersEmpty + ? [fetchTransactionCounts(state5)] + : [ + fetchTransactionCounts(state5), + fetchSameChainAuthorizations(state5), + fetchSameChainErc721Authorizations(state5), + fetchCrossChainAuthorizations(state5, 'crossChainAuthorizations'), + fetchCrossChainAuthorizations(state5, 'erc721CrossChainAuthorizations'), + ]; + const authAndTxResults = await Promise.all(authAndTxCountPromises); // These promises can resolve in any order, so we need to find each one by it's key const txCountRes = authAndTxResults.find((r) => r.id === 'transaction-counts')!; logger.logPending(txCountRes.logs); + const transactionCountsBySponsorAddress = txCountRes.data!; - const authRes = authAndTxResults.find((r) => r.id === 'authorizations')!; - logger.logPending(authRes.logs); + let mergedAuthorizationsByRequestId: AuthorizationByRequestId; + if (allAuthorizersEmpty) { + logger.info('Authorizing all requests because all authorizer arrays are empty'); + mergedAuthorizationsByRequestId = Object.fromEntries( + state5.requests.apiCalls.map((pendingApiCall) => [pendingApiCall.id, true]) + ); + } else { + const authRes = authAndTxResults.find((r) => r.id === 'authorizations')!; + logger.logPending(authRes.logs); - const crossAuthRes = authAndTxResults.find((r) => r.id === 'crossChainAuthorizations')!; - logger.logPending(crossAuthRes.logs); + const crossAuthRes = authAndTxResults.find((r) => r.id === 'crossChainAuthorizations')!; + logger.logPending(crossAuthRes.logs); - const transactionCountsBySponsorAddress = txCountRes.data!; + const erc721AuthRes = authAndTxResults.find((r) => r.id === 'erc721Authorizations')!; + logger.logPending(erc721AuthRes.logs); + + const erc721CrossAuthRes = authAndTxResults.find((r) => r.id === 'erc721CrossChainAuthorizations')!; + logger.logPending(erc721CrossAuthRes.logs); + + // Merge authorization statuses + const authorizationsByRequestId: AuthorizationByRequestId = authRes.data!; + const crossAuthorizationsByRequestId: AuthorizationByRequestId[] = crossAuthRes.data!; + const erc721AuthorizationsByRequestId: AuthorizationByRequestId[] = erc721AuthRes.data!; + const erc721crossAuthorizationsByRequestId: AuthorizationByRequestId[] = erc721CrossAuthRes.data!; + mergedAuthorizationsByRequestId = mergeAuthorizationsByRequestId([ + authorizationsByRequestId, + ...crossAuthorizationsByRequestId, + ...erc721AuthorizationsByRequestId, + ...erc721crossAuthorizationsByRequestId, + ]); + } - // Merge authorization statuses - const authorizationsByRequestId: AuthorizationByRequestId = authRes.data!; - const crossAuthorizationsByRequestId: AuthorizationByRequestId[] = crossAuthRes.data!; - const mergedAuthorizationsByRequestId = mergeAuthorizationsByRequestId([ - authorizationsByRequestId, - ...crossAuthorizationsByRequestId, - ]); const state6 = state.update(state5, { transactionCountsBySponsorAddress }); // ================================================================= diff --git a/packages/airnode-node/src/providers/actions.test.ts b/packages/airnode-node/src/providers/actions.test.ts index 8a108a085f..cf6f6a8118 100644 --- a/packages/airnode-node/src/providers/actions.test.ts +++ b/packages/airnode-node/src/providers/actions.test.ts @@ -43,6 +43,8 @@ const chains: ChainConfig[] = [ authorizers: { requesterEndpointAuthorizers: [ethers.constants.AddressZero], crossChainRequesterAuthorizers: [], + requesterAuthorizersWithErc721: [], + crossChainRequesterAuthorizersWithErc721: [], }, authorizations: { requesterEndpointAuthorizations: {}, @@ -72,7 +74,12 @@ const chains: ChainConfig[] = [ }, }, { - authorizers: { requesterEndpointAuthorizers: [ethers.constants.AddressZero], crossChainRequesterAuthorizers: [] }, + authorizers: { + requesterEndpointAuthorizers: [ethers.constants.AddressZero], + crossChainRequesterAuthorizers: [], + requesterAuthorizersWithErc721: [], + crossChainRequesterAuthorizersWithErc721: [], + }, authorizations: { requesterEndpointAuthorizations: {}, }, @@ -131,6 +138,8 @@ describe('initialize', () => { authorizers: { requesterEndpointAuthorizers: [ethers.constants.AddressZero], crossChainRequesterAuthorizers: [], + requesterAuthorizersWithErc721: [], + crossChainRequesterAuthorizersWithErc721: [], }, authorizations: { requesterEndpointAuthorizations: {}, @@ -186,6 +195,8 @@ describe('initialize', () => { authorizers: { requesterEndpointAuthorizers: [ethers.constants.AddressZero], crossChainRequesterAuthorizers: [], + requesterAuthorizersWithErc721: [], + crossChainRequesterAuthorizersWithErc721: [], }, authorizations: { requesterEndpointAuthorizations: {}, diff --git a/packages/airnode-node/src/providers/state.test.ts b/packages/airnode-node/src/providers/state.test.ts index ad4db6ef75..09176ed2a1 100644 --- a/packages/airnode-node/src/providers/state.test.ts +++ b/packages/airnode-node/src/providers/state.test.ts @@ -22,6 +22,8 @@ describe('create', () => { authorizers: { requesterEndpointAuthorizers: [ethers.constants.AddressZero], crossChainRequesterAuthorizers: [], + requesterAuthorizersWithErc721: [], + crossChainRequesterAuthorizersWithErc721: [], }, authorizations: { requesterEndpointAuthorizations: {}, @@ -61,6 +63,8 @@ describe('create', () => { authorizers: { requesterEndpointAuthorizers: [ethers.constants.AddressZero], crossChainRequesterAuthorizers: [], + requesterAuthorizersWithErc721: [], + crossChainRequesterAuthorizersWithErc721: [], }, authorizations: { requesterEndpointAuthorizations: {}, @@ -116,7 +120,12 @@ describe('create', () => { const airnodeAddress = '0xA30CA71Ba54E83127214D3271aEA8F5D6bD4Dace'; const chainConfig: ChainConfig = { maxConcurrency: 100, - authorizers: { requesterEndpointAuthorizers: [ethers.constants.AddressZero], crossChainRequesterAuthorizers: [] }, + authorizers: { + requesterEndpointAuthorizers: [ethers.constants.AddressZero], + crossChainRequesterAuthorizers: [], + requesterAuthorizersWithErc721: [], + crossChainRequesterAuthorizersWithErc721: [], + }, authorizations: { requesterEndpointAuthorizations: {}, }, @@ -157,6 +166,8 @@ describe('create', () => { authorizers: { requesterEndpointAuthorizers: [ethers.constants.AddressZero], crossChainRequesterAuthorizers: [], + requesterAuthorizersWithErc721: [], + crossChainRequesterAuthorizersWithErc721: [], }, authorizations: { requesterEndpointAuthorizations: {}, diff --git a/packages/airnode-node/test/e2e/erc721-authorizers.feature.ts b/packages/airnode-node/test/e2e/erc721-authorizers.feature.ts new file mode 100644 index 0000000000..73f9ebfc15 --- /dev/null +++ b/packages/airnode-node/test/e2e/erc721-authorizers.feature.ts @@ -0,0 +1,49 @@ +import { erc721Mocks } from '@api3/airnode-protocol'; +import { ethers } from 'ethers'; +import * as local from '../../src/workers/local-handlers'; +import { operation } from '../fixtures'; +import { increaseTestTimeout, deployAirnodeAndMakeRequests, fetchAllLogNames } from '../setup/e2e'; + +increaseTestTimeout(); + +it('deploys a requesterAuthorizerWithErc721 contract and authorizes requests', async () => { + const requests = [operation.buildFullRequest()]; + const { provider, deployment, deployerIndex, mnemonic } = await deployAirnodeAndMakeRequests(__filename, requests); + const requesterAuthorizerWithErc721Address = deployment.contracts.RequesterAuthorizerWithErc721; + + // Send the NFT to the requester + const deployer = provider.getSigner(deployerIndex); + const onERC721ReceivedArguments = ethers.utils.defaultAbiCoder.encode( + ['address', 'uint256', 'address'], + [ethers.Wallet.fromMnemonic(mnemonic).address, 31337, deployment.requesters.MockRrpRequesterFactory] + ); + await erc721Mocks.MockErc721Factory.connect(deployment.erc721s.MockErc721Factory, deployer)[ + 'safeTransferFrom(address,address,uint256,bytes)' + ](await deployer.getAddress(), requesterAuthorizerWithErc721Address, 0, onERC721ReceivedArguments); + + const erc721Address = deployment.erc721s.MockErc721Factory; + + const config = local.loadConfig(); + config.chains[0].authorizers.requesterEndpointAuthorizers = []; + config.chains[0].authorizers.crossChainRequesterAuthorizers = []; + config.chains[0].authorizers.crossChainRequesterAuthorizersWithErc721 = []; + // Since requesterAuthorizersWithErc721 is not empty, only it can authorize + config.chains[0].authorizers.requesterAuthorizersWithErc721 = [ + { + erc721s: [erc721Address], + RequesterAuthorizerWithErc721: requesterAuthorizerWithErc721Address, + }, + ]; + jest.spyOn(local, 'loadConfig').mockReturnValueOnce(config); + + const preInvokeExpectedLogs = ['SetSponsorshipStatus', 'SetSponsorshipStatus', 'CreatedTemplate', 'MadeFullRequest']; + const preInvokelogNames = await fetchAllLogNames(provider, deployment.contracts.AirnodeRrp); + expect(preInvokelogNames).toEqual(preInvokeExpectedLogs); + + await local.startCoordinator(); + + // FulfilledRequest being present indicates success + const postInvokeExpectedLogs = [...preInvokeExpectedLogs, 'FulfilledRequest']; + const postInvokeLogs = await fetchAllLogNames(provider, deployment.contracts.AirnodeRrp); + expect(postInvokeLogs).toEqual(postInvokeExpectedLogs); +}); diff --git a/packages/airnode-node/test/fixtures/config/config.ts b/packages/airnode-node/test/fixtures/config/config.ts index 3efdc66976..741198f741 100644 --- a/packages/airnode-node/test/fixtures/config/config.ts +++ b/packages/airnode-node/test/fixtures/config/config.ts @@ -39,6 +39,8 @@ export function buildConfig(overrides?: Partial): Config { authorizers: { requesterEndpointAuthorizers: [], crossChainRequesterAuthorizers: [], + requesterAuthorizersWithErc721: [], + crossChainRequesterAuthorizersWithErc721: [], }, authorizations: { requesterEndpointAuthorizations: {}, diff --git a/packages/airnode-node/test/fixtures/operation/deploy-config.ts b/packages/airnode-node/test/fixtures/operation/deploy-config.ts index 0cac664e8e..3c2160767e 100644 --- a/packages/airnode-node/test/fixtures/operation/deploy-config.ts +++ b/packages/airnode-node/test/fixtures/operation/deploy-config.ts @@ -9,6 +9,8 @@ export function buildDeployConfig(mnemonic: string, config?: Partial): C authorizers: { requesterEndpointAuthorizers: [], crossChainRequesterAuthorizers: [], + requesterAuthorizersWithErc721: [], + crossChainRequesterAuthorizersWithErc721: [], }, authorizations: { requesterEndpointAuthorizations: {}, diff --git a/packages/airnode-node/test/fixtures/provider-states/evm.ts b/packages/airnode-node/test/fixtures/provider-states/evm.ts index a112f9276f..926e35d8f9 100644 --- a/packages/airnode-node/test/fixtures/provider-states/evm.ts +++ b/packages/airnode-node/test/fixtures/provider-states/evm.ts @@ -16,6 +16,8 @@ export function buildEVMProviderState( authorizers: { requesterEndpointAuthorizers: [], crossChainRequesterAuthorizers: [], + requesterAuthorizersWithErc721: [], + crossChainRequesterAuthorizersWithErc721: [], }, authorizations: { requesterEndpointAuthorizations: {}, diff --git a/packages/airnode-node/test/setup/e2e/deployment.ts b/packages/airnode-node/test/setup/e2e/deployment.ts index e9f0d118b3..dc118f8404 100644 --- a/packages/airnode-node/test/setup/e2e/deployment.ts +++ b/packages/airnode-node/test/setup/e2e/deployment.ts @@ -1,31 +1,34 @@ import * as operation from '@api3/airnode-operation'; +// TODO: Why we have a similar function in evm-dev-deploy.ts? export async function deployAirnodeRrp(config: operation.Config): Promise { - const state1 = operation.buildDeployState(config); + let state = operation.buildDeployState(config); // Deploy contracts - const state2 = await operation.deployAirnodeRrp(state1); - const state3 = await operation.deployRequesters(state2); - const state4 = await operation.deployAccessControlRegistry(state3); - const state5 = await operation.deployAuthorizers(state4); + state = await operation.deployAirnodeRrp(state); + state = await operation.deployRequesters(state); + state = await operation.deployAccessControlRegistry(state); + state = await operation.deployAuthorizers(state); + state = await operation.deployErc721s(state); + state = await operation.deployRequesterAuthorizerWithErc721(state); // Assign wallets - const state6 = await operation.assignAirnodeAccounts(state5); - const state7 = await operation.assignRequesterAccounts(state6); - const state8 = await operation.assignSponsorWallets(state7); + state = operation.assignAirnodeAccounts(state); + state = operation.assignRequesterAccounts(state); + state = operation.assignSponsorWallets(state); // Fund wallets - const state9 = await operation.fundAirnodeAccounts(state8); - const state10 = await operation.fundSponsorAccounts(state9); - const state11 = await operation.fundSponsorWallets(state10); + state = await operation.fundAirnodeAccounts(state); + state = await operation.fundSponsorAccounts(state); + state = await operation.fundSponsorWallets(state); // Sponsor requester contracts - const state12 = await operation.sponsorRequesters(state11); + state = await operation.sponsorRequesters(state); // Create templates - const state13 = await operation.createTemplates(state12); + state = await operation.createTemplates(state); - const deployment = operation.buildSaveableDeployment(state13); + const deployment = operation.buildSaveableDeployment(state); return deployment; } diff --git a/packages/airnode-node/test/setup/e2e/testing.ts b/packages/airnode-node/test/setup/e2e/testing.ts index bc8aa99b1a..5beefb6e1c 100644 --- a/packages/airnode-node/test/setup/e2e/testing.ts +++ b/packages/airnode-node/test/setup/e2e/testing.ts @@ -35,5 +35,5 @@ export const deployAirnodeAndMakeRequests = async (filename: string, requests?: mockReadFileSync('config.json', JSON.stringify(config)); jest.spyOn(validator, 'unsafeParseConfigWithSecrets').mockReturnValue(config); - return { deployment, provider: buildProvider(), config, mnemonic }; + return { deployment, provider: buildProvider(), config, mnemonic, deployerIndex }; }; diff --git a/packages/airnode-node/test/setup/e2e/utils.ts b/packages/airnode-node/test/setup/e2e/utils.ts index 18c67725fa..5487a7c484 100644 --- a/packages/airnode-node/test/setup/e2e/utils.ts +++ b/packages/airnode-node/test/setup/e2e/utils.ts @@ -18,6 +18,8 @@ export function buildChainConfig(contracts: Contracts): ChainConfig { authorizers: { requesterEndpointAuthorizers: [], crossChainRequesterAuthorizers: [], + requesterAuthorizersWithErc721: [], + crossChainRequesterAuthorizersWithErc721: [], }, authorizations: { requesterEndpointAuthorizations: {}, diff --git a/packages/airnode-operation/src/config/evm-dev-config.json b/packages/airnode-operation/src/config/evm-dev-config.json index 65906af883..4ee6207cb9 100644 --- a/packages/airnode-operation/src/config/evm-dev-config.json +++ b/packages/airnode-operation/src/config/evm-dev-config.json @@ -5,7 +5,9 @@ "mnemonic": "achieve climb couple wait accident symbol spy blouse reduce foil echo label", "authorizers": { "requesterEndpointAuthorizers": [], - "crossChainRequesterAuthorizers": [] + "crossChainRequesterAuthorizers": [], + "requesterAuthorizersWithErc721": [], + "crossChainRequesterAuthorizersWithErc721": [] }, "authorizations": { "requesterEndpointAuthorizations": {} diff --git a/packages/airnode-operation/src/evm/deploy/deployment.ts b/packages/airnode-operation/src/evm/deploy/deployment.ts index 8a9a1ba1df..f65ff473bb 100644 --- a/packages/airnode-operation/src/evm/deploy/deployment.ts +++ b/packages/airnode-operation/src/evm/deploy/deployment.ts @@ -1,4 +1,11 @@ -import { AccessControlRegistryFactory, AirnodeRrpV0Factory, authorizers, mocks } from '@api3/airnode-protocol'; +import { + AccessControlRegistryFactory, + AirnodeRrpV0Factory, + authorizers, + mocks, + erc721Mocks, + RequesterAuthorizerWithErc721Factory, +} from '@api3/airnode-protocol'; import { ethers } from 'ethers'; import { DeployState as State } from '../../types'; @@ -11,6 +18,7 @@ export async function deployAirnodeRrp(state: State): Promise { export async function deployRequesters(state: State): Promise { const requestersByName: { [name: string]: ethers.Contract } = {}; + // TODO: This uses generic mocks exported from Airnode protocol and assumes all of them are requesters for (const [mockName, MockArtifact] of Object.entries(mocks)) { const MockRequester = new MockArtifact(state.deployer); const mockRequester = await MockRequester.deploy(state.contracts.AirnodeRrp!.address); @@ -40,3 +48,24 @@ export async function deployAuthorizers(state: State): Promise { } return { ...state, authorizersByName }; } + +export async function deployErc721s(state: State): Promise { + const erc721sByName: { [name: string]: ethers.Contract } = {}; + for (const [mockName, MockArtifact] of Object.entries(erc721Mocks)) { + const MockErc721 = new MockArtifact(state.deployer); + const mockErc721 = await MockErc721.deploy(); + await mockErc721.deployed(); + erc721sByName[mockName] = mockErc721 as unknown as ethers.Contract; + } + return { ...state, erc721sByName }; +} + +export async function deployRequesterAuthorizerWithErc721(state: State): Promise { + const RequesterAuthorizerWithErc721 = new RequesterAuthorizerWithErc721Factory(state.deployer); + const requesterAuthorizerWithErc721 = await RequesterAuthorizerWithErc721.deploy( + state.contracts.AccessControlRegistry!.address, + 'RequesterAuthorizerWithErc721 admin' + ); + await requesterAuthorizerWithErc721.deployed(); + return { ...state, contracts: { ...state.contracts, RequesterAuthorizerWithErc721: requesterAuthorizerWithErc721 } }; +} diff --git a/packages/airnode-operation/src/evm/deploy/state.ts b/packages/airnode-operation/src/evm/deploy/state.ts index c255d7d01f..fc48f9bf4d 100644 --- a/packages/airnode-operation/src/evm/deploy/state.ts +++ b/packages/airnode-operation/src/evm/deploy/state.ts @@ -25,6 +25,8 @@ export function buildDeployState(config: Config): State { provider, sponsorsById: {}, templatesByName: {}, + erc721sByName: {}, + authorizers: {}, }; } @@ -61,6 +63,7 @@ function buildSaveableAirnode(state: State, airnodeName: string): DeployedAirnod export function buildSaveableDeployment(state: State): Deployment { const contracts = { AirnodeRrp: state.contracts.AirnodeRrp!.address, + RequesterAuthorizerWithErc721: state.contracts.RequesterAuthorizerWithErc721!.address, }; const requesterNames = Object.keys(state.requestersByName); @@ -69,6 +72,12 @@ export function buildSaveableDeployment(state: State): Deployment { return { ...acc, [name]: requester.address }; }, {}); + const erc721Names = Object.keys(state.erc721sByName); + const erc721s: { [name: string]: string } = erc721Names.reduce((acc: any, name: string) => { + const erc721 = state.erc721sByName[name]; + return { ...acc, [name]: erc721.address }; + }, {}); + const sponsors: DeployedSponsor[] = state.config.sponsors.reduce((acc: any, configRequester: ConfigSponsor) => { const sponsor = state.sponsorsById[configRequester.id]; const data = { @@ -90,5 +99,7 @@ export function buildSaveableDeployment(state: State): Deployment { contracts, requesters, sponsors, + erc721s, + authorizers: state.authorizersByName, }; } diff --git a/packages/airnode-operation/src/scripts/evm-dev-deploy.ts b/packages/airnode-operation/src/scripts/evm-dev-deploy.ts index c7f1a0a3cb..fb82e4db2c 100644 --- a/packages/airnode-operation/src/scripts/evm-dev-deploy.ts +++ b/packages/airnode-operation/src/scripts/evm-dev-deploy.ts @@ -6,37 +6,38 @@ async function run() { logger.log('--> Loading configuration...'); const config = io.loadConfig(); - const state1 = deploy.buildDeployState(config); + let state = deploy.buildDeployState(config); logger.log('--> Deploying contracts...'); - const state2 = await deploy.deployAirnodeRrp(state1); - const state3 = await deploy.deployRequesters(state2); - const state4 = await deploy.deployAccessControlRegistry(state3); - const state5 = await deploy.deployAuthorizers(state4); + state = await deploy.deployAirnodeRrp(state); + state = await deploy.deployRequesters(state); + state = await deploy.deployAccessControlRegistry(state); + state = await deploy.deployAuthorizers(state); + state = await deploy.deployErc721s(state); logger.log('--> Assigning wallets...'); - const state6 = await deploy.assignAirnodeAccounts(state5); - const state7 = await deploy.assignRequesterAccounts(state6); - const state8 = await deploy.assignSponsorWallets(state7); + state = await deploy.assignAirnodeAccounts(state); + state = await deploy.assignRequesterAccounts(state); + state = await deploy.assignSponsorWallets(state); logger.log('--> Funding wallets...'); - const state9 = await deploy.fundAirnodeAccounts(state8); - const state10 = await deploy.fundSponsorAccounts(state9); - const state11 = await deploy.fundSponsorWallets(state10); + state = await deploy.fundAirnodeAccounts(state); + state = await deploy.fundSponsorAccounts(state); + state = await deploy.fundSponsorWallets(state); logger.log('--> Sponsoring requester contracts...'); - const state12 = await deploy.sponsorRequesters(state11); + state = await deploy.sponsorRequesters(state); logger.log('--> Creating templates...'); - const state13 = await deploy.createTemplates(state12); + state = await deploy.createTemplates(state); logger.log('--> Deployment successful!'); logger.log('--> Saving deployment...'); - io.saveDeployment(state13); + io.saveDeployment(state); logger.log('--> Deployment saved!'); - return state13; + return state; } run(); diff --git a/packages/airnode-operation/src/types.ts b/packages/airnode-operation/src/types.ts index b0f5a3a269..95955c8f5f 100644 --- a/packages/airnode-operation/src/types.ts +++ b/packages/airnode-operation/src/types.ts @@ -1,6 +1,6 @@ import { ethers } from 'ethers'; import { InputParameter } from '@api3/airnode-abi'; -import { AirnodeRrpV0, AccessControlRegistry } from '@api3/airnode-protocol'; +import { AirnodeRrpV0, AccessControlRegistry, RequesterAuthorizerWithErc721 } from '@api3/airnode-protocol'; // =========================================== // General @@ -9,10 +9,13 @@ export interface DeployState { readonly airnodesByName: { readonly [name: string]: Airnode }; readonly authorizersByName: { readonly [name: string]: string }; readonly requestersByName: { readonly [name: string]: ethers.Contract }; + readonly erc721sByName: { readonly [name: string]: ethers.Contract }; + readonly authorizers: { readonly [name: string]: ethers.Contract }; readonly config: Config; readonly contracts: { readonly AirnodeRrp?: AirnodeRrpV0; readonly AccessControlRegistry?: AccessControlRegistry; + readonly RequesterAuthorizerWithErc721?: RequesterAuthorizerWithErc721; }; readonly deployer: ethers.providers.JsonRpcSigner; readonly provider: ethers.providers.JsonRpcProvider; @@ -89,8 +92,11 @@ export interface Deployment { readonly requesters: { readonly [name: string]: string }; readonly contracts: { readonly AirnodeRrp: string; + readonly RequesterAuthorizerWithErc721: string; }; readonly sponsors: DeployedSponsor[]; + readonly erc721s: { readonly [name: string]: string }; + readonly authorizers: { readonly [name: string]: string }; } // =========================================== @@ -122,8 +128,30 @@ export interface CrossChainConfig { }; } +export interface Erc721CrossChainConfig { + readonly erc721s: string[]; + readonly chainType: string; + readonly chainId: string; + readonly contracts: { + readonly RequesterAuthorizerWithErc721: string; + }; + readonly chainProvider: { + url: string; + }; +} + +export interface Erc721Config { + readonly erc721s: string[]; + readonly RequesterAuthorizerWithErc721: string; +} + export interface ConfigAirnode { - readonly authorizers: { requesterEndpointAuthorizers: string[]; crossChainRequesterAuthorizers: CrossChainConfig[] }; + readonly authorizers: { + requesterEndpointAuthorizers: string[]; + crossChainRequesterAuthorizers: CrossChainConfig[]; + requesterAuthorizersWithErc721: Erc721Config[]; + crossChainRequesterAuthorizersWithErc721: Erc721CrossChainConfig[]; + }; readonly authorizations: { requesterEndpointAuthorizations: { [endpointId: string]: string[] }; }; diff --git a/packages/airnode-protocol/contracts/authorizers/mock/MockErc721.sol b/packages/airnode-protocol/contracts/authorizers/mock/MockErc721.sol new file mode 100644 index 0000000000..5dd06a8ba4 --- /dev/null +++ b/packages/airnode-protocol/contracts/authorizers/mock/MockErc721.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.17; + +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; + +contract MockErc721 is ERC721 { + constructor() ERC721("Token", "TKN") { + for (uint256 tokenId = 0; tokenId < 10; tokenId++) { + _mint(msg.sender, tokenId); + } + } +} diff --git a/packages/airnode-protocol/contracts/dev/RequesterAuthorizerWithErc721.sol b/packages/airnode-protocol/contracts/dev/RequesterAuthorizerWithErc721.sol new file mode 100644 index 0000000000..83ddcbe7d3 --- /dev/null +++ b/packages/airnode-protocol/contracts/dev/RequesterAuthorizerWithErc721.sol @@ -0,0 +1,545 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.17; + +import "@openzeppelin/contracts/metatx/ERC2771Context.sol"; +import "./access-control-registry/AccessControlRegistryAdminned.sol"; +import "./interfaces/IRequesterAuthorizerWithErc721.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; + +/// @title Authorizer contract that users can deposit the ERC721 tokens +/// recognized by the Airnode to receive authorization for the requester contract +/// on the chain +/// @notice For an Airnode to recognize an ERC721 token, it needs to be +/// configured to do so at deploy-time. It can be expected for Airnodes to only +/// recognize the respective NFT keys that their operators have issued, but +/// this is not necessarily true. +/// For an Airnode to serve requesters on a chain, it needs to be configured to +/// do so at deploy-time. +/// Airnodes are allowed to block specific requesters. It can be expected for +/// Airnodes to only do this when the requester is breaking T&C. The tokens +/// that have been deposited to authorize requesters that have been blocked can +/// be revoked, which transfers them to the Airnode account. This can be seen +/// as a staking–slashing mechanism. +contract RequesterAuthorizerWithErc721 is + ERC2771Context, + AccessControlRegistryAdminned, + IRequesterAuthorizerWithErc721 +{ + struct TokenDeposits { + uint256 count; + mapping(address => Deposit) depositorToDeposit; + } + + struct Deposit { + uint256 tokenId; + uint32 withdrawalLeadTime; + uint32 earliestWithdrawalTime; + } + + /// @notice Withdrawal lead time setter role description + string + public constant + override WITHDRAWAL_LEAD_TIME_SETTER_ROLE_DESCRIPTION = + "Withdrawal lead time setter"; + + /// @notice Requester blocker role description + string public constant override REQUESTER_BLOCKER_ROLE_DESCRIPTION = + "Requester blocker"; + + bytes32 private constant WITHDRAWAL_LEAD_TIME_SETTER_ROLE_DESCRIPTION_HASH = + keccak256( + abi.encodePacked(WITHDRAWAL_LEAD_TIME_SETTER_ROLE_DESCRIPTION) + ); + + bytes32 private constant REQUESTER_BLOCKER_ROLE_DESCRIPTION_HASH = + keccak256(abi.encodePacked(REQUESTER_BLOCKER_ROLE_DESCRIPTION)); + + /// @notice Deposits of the token with the address made for the Airnode to + /// authorize the requester address on the chain + mapping(address => mapping(uint256 => mapping(address => mapping(address => TokenDeposits)))) + public + override airnodeToChainIdToRequesterToTokenAddressToTokenDeposits; + + /// @notice Withdrawal lead time of the Airnode. This creates the window of + /// opportunity during which a requester can be blocked for breaking T&C + /// and the respective tokens can be revoked. + /// The withdrawal lead time at deposit-time will apply to a specific + /// deposit. + mapping(address => uint32) public override airnodeToWithdrawalLeadTime; + + /// @notice If the Airnode has blocked the requester on the chain. Tokens + /// deposited to authorize a blocked requester are revocable. + mapping(address => mapping(uint256 => mapping(address => bool))) + public + override airnodeToChainIdToRequesterToBlockStatus; + + /// @param _accessControlRegistry AccessControlRegistry contract address + /// @param _adminRoleDescription Admin role description + constructor( + address _accessControlRegistry, + string memory _adminRoleDescription + ) + ERC2771Context(_accessControlRegistry) + AccessControlRegistryAdminned( + _accessControlRegistry, + _adminRoleDescription + ) + {} + + /// @notice Called by the Airnode or its withdrawal lead time setters to + /// set withdrawal lead time + /// @param airnode Airnode address + /// @param withdrawalLeadTime Withdrawal lead time + function setWithdrawalLeadTime( + address airnode, + uint32 withdrawalLeadTime + ) external override { + require( + airnode == _msgSender() || + IAccessControlRegistry(accessControlRegistry).hasRole( + deriveWithdrawalLeadTimeSetterRole(airnode), + _msgSender() + ), + "Sender cannot set lead time" + ); + require(withdrawalLeadTime <= 30 days, "Lead time too long"); + airnodeToWithdrawalLeadTime[airnode] = withdrawalLeadTime; + emit SetWithdrawalLeadTime(airnode, withdrawalLeadTime, _msgSender()); + } + + /// @notice Called by the Airnode or its requester blockers to set + /// requester block statuses + /// @param airnode Airnode address + /// @param chainId Chain ID + /// @param requester Requester address + /// @param status Block status + function setRequesterBlockStatus( + address airnode, + uint256 chainId, + address requester, + bool status + ) external override { + require( + airnode == _msgSender() || + IAccessControlRegistry(accessControlRegistry).hasRole( + deriveRequesterBlockerRole(airnode), + _msgSender() + ), + "Sender cannot block requester" + ); + require(chainId != 0, "Chain ID zero"); + require(requester != address(0), "Requester address zero"); + airnodeToChainIdToRequesterToBlockStatus[airnode][chainId][ + requester + ] = status; + emit SetRequesterBlockStatus( + airnode, + requester, + chainId, + status, + _msgSender() + ); + } + + /// @notice Called by the ERC721 contract upon `safeTransferFrom()` to this + /// contract to deposit a token to authorize the requester + /// @dev The first argument is the operator, which we do not need + /// @param _from Account from which the token is transferred + /// @param _tokenId Token ID + /// @param _data Airnode address, chain ID and requester address in + /// ABI-encoded form + /// @return `onERC721Received()` function selector + function onERC721Received( + address, + address _from, + uint256 _tokenId, + bytes calldata _data + ) external override returns (bytes4) { + require(_data.length == 96, "Unexpected data length"); + (address airnode, uint256 chainId, address requester) = abi.decode( + _data, + (address, uint256, address) + ); + require(airnode != address(0), "Airnode address zero"); + require(chainId != 0, "Chain ID zero"); + require(requester != address(0), "Requester address zero"); + require( + !airnodeToChainIdToRequesterToBlockStatus[airnode][chainId][ + requester + ], + "Requester blocked" + ); + TokenDeposits + storage tokenDeposits = airnodeToChainIdToRequesterToTokenAddressToTokenDeposits[ + airnode + ][chainId][requester][_msgSender()]; + uint256 tokenDepositCount; + unchecked { + tokenDepositCount = ++tokenDeposits.count; + } + require( + tokenDeposits.depositorToDeposit[_from].earliestWithdrawalTime == 0, + "Token already deposited" + ); + tokenDeposits.depositorToDeposit[_from] = Deposit({ + tokenId: _tokenId, + withdrawalLeadTime: airnodeToWithdrawalLeadTime[airnode], + earliestWithdrawalTime: type(uint32).max + }); + emit DepositedToken( + airnode, + requester, + _from, + chainId, + _msgSender(), + _tokenId, + tokenDepositCount + ); + return this.onERC721Received.selector; + } + + /// @notice Called by a token depositor to update the requester for which + /// they have deposited the token for + /// @dev This is especially useful for not having to wait when the Airnode + /// has set a non-zero withdrawal lead time + /// @param airnode Airnode address + /// @param chainIdPrevious Previous chain ID + /// @param requesterPrevious Previous requester address + /// @param chainIdNext Next chain ID + /// @param requesterNext Next requester address + /// @param token Token address + function updateDepositRequester( + address airnode, + uint256 chainIdPrevious, + address requesterPrevious, + uint256 chainIdNext, + address requesterNext, + address token + ) external override { + require( + !airnodeToChainIdToRequesterToBlockStatus[airnode][chainIdPrevious][ + requesterPrevious + ], + "Previous requester blocked" + ); + require( + !airnodeToChainIdToRequesterToBlockStatus[airnode][chainIdNext][ + requesterNext + ], + "Next requester blocked" + ); + TokenDeposits + storage requesterPreviousTokenDeposits = airnodeToChainIdToRequesterToTokenAddressToTokenDeposits[ + airnode + ][chainIdPrevious][requesterPrevious][token]; + Deposit + storage requesterPreviousDeposit = requesterPreviousTokenDeposits + .depositorToDeposit[_msgSender()]; + require( + requesterPreviousDeposit.earliestWithdrawalTime != 0, + "Token not deposited" + ); + require( + requesterPreviousDeposit.earliestWithdrawalTime == type(uint32).max, + "Withdrawal initiated" + ); + TokenDeposits + storage requesterNextTokenDeposits = airnodeToChainIdToRequesterToTokenAddressToTokenDeposits[ + airnode + ][chainIdNext][requesterNext][token]; + require( + requesterNextTokenDeposits + .depositorToDeposit[_msgSender()] + .earliestWithdrawalTime == 0, + "Token already deposited" + ); + uint256 requesterNextTokenDepositCount = ++requesterNextTokenDeposits + .count; + requesterNextTokenDeposits.count = requesterNextTokenDepositCount; + uint256 requesterPreviousTokenDepositCount = --requesterPreviousTokenDeposits + .count; + requesterPreviousTokenDeposits + .count = requesterPreviousTokenDepositCount; + uint256 tokenId = requesterPreviousDeposit.tokenId; + requesterNextTokenDeposits.depositorToDeposit[_msgSender()] = Deposit({ + tokenId: tokenId, + withdrawalLeadTime: requesterPreviousDeposit.withdrawalLeadTime, + earliestWithdrawalTime: 0 + }); + requesterPreviousTokenDeposits.depositorToDeposit[ + _msgSender() + ] = Deposit({ + tokenId: 0, + withdrawalLeadTime: 0, + earliestWithdrawalTime: 0 + }); + emit UpdatedDepositRequesterTo( + airnode, + requesterNext, + _msgSender(), + chainIdNext, + token, + tokenId, + requesterNextTokenDepositCount + ); + emit UpdatedDepositRequesterFrom( + airnode, + requesterPrevious, + _msgSender(), + chainIdPrevious, + token, + tokenId, + requesterPreviousTokenDepositCount + ); + } + + /// @notice Called by a token depositor to initiate withdrawal + /// @dev The depositor is allowed to initiate a withdrawal even if the + /// respective requester is blocked. However, the withdrawal will not be + /// executable as long as the requester is blocked. + /// Token withdrawals can be initiated even if withdrawal lead time is + /// zero. + /// @param airnode Airnode address + /// @param chainId Chain ID + /// @param requester Requester address + /// @param token Token address + /// @return earliestWithdrawalTime Earliest withdrawal time + function initiateTokenWithdrawal( + address airnode, + uint256 chainId, + address requester, + address token + ) external override returns (uint32 earliestWithdrawalTime) { + TokenDeposits + storage tokenDeposits = airnodeToChainIdToRequesterToTokenAddressToTokenDeposits[ + airnode + ][chainId][requester][token]; + Deposit storage deposit = tokenDeposits.depositorToDeposit[ + _msgSender() + ]; + require(deposit.earliestWithdrawalTime != 0, "Token not deposited"); + require( + deposit.earliestWithdrawalTime == type(uint32).max, + "Withdrawal already initiated" + ); + uint256 tokenDepositCount; + unchecked { + tokenDepositCount = --tokenDeposits.count; + } + earliestWithdrawalTime = uint32( + block.timestamp + deposit.withdrawalLeadTime + ); + deposit.earliestWithdrawalTime = earliestWithdrawalTime; + emit InitiatedTokenWithdrawal( + airnode, + requester, + _msgSender(), + chainId, + token, + deposit.tokenId, + earliestWithdrawalTime, + tokenDepositCount + ); + } + + /// @notice Called by a token depositor to withdraw + /// @param airnode Airnode address + /// @param chainId Chain ID + /// @param requester Requester address + /// @param token Token address + function withdrawToken( + address airnode, + uint256 chainId, + address requester, + address token + ) external override { + require( + !airnodeToChainIdToRequesterToBlockStatus[airnode][chainId][ + requester + ], + "Requester blocked" + ); + TokenDeposits + storage tokenDeposits = airnodeToChainIdToRequesterToTokenAddressToTokenDeposits[ + airnode + ][chainId][requester][token]; + Deposit storage deposit = tokenDeposits.depositorToDeposit[ + _msgSender() + ]; + require(deposit.earliestWithdrawalTime != 0, "Token not deposited"); + uint256 tokenDepositCount; + if (deposit.earliestWithdrawalTime == type(uint32).max) { + require( + deposit.withdrawalLeadTime == 0, + "Withdrawal not initiated" + ); + unchecked { + tokenDepositCount = --tokenDeposits.count; + } + } else { + require( + block.timestamp >= deposit.earliestWithdrawalTime, + "Cannot withdraw yet" + ); + unchecked { + tokenDepositCount = tokenDeposits.count; + } + } + uint256 tokenId = deposit.tokenId; + tokenDeposits.depositorToDeposit[_msgSender()] = Deposit({ + tokenId: 0, + withdrawalLeadTime: 0, + earliestWithdrawalTime: 0 + }); + emit WithdrewToken( + airnode, + requester, + _msgSender(), + chainId, + token, + tokenId, + tokenDepositCount + ); + IERC721(token).safeTransferFrom(address(this), _msgSender(), tokenId); + } + + /// @notice Called to revoke the token deposited to authorize a requester + /// that is blocked now + /// @param airnode Airnode address + /// @param chainId Chain ID + /// @param requester Requester address + /// @param token Token address + /// @param depositor Depositor address + function revokeToken( + address airnode, + uint256 chainId, + address requester, + address token, + address depositor + ) external override { + require( + airnodeToChainIdToRequesterToBlockStatus[airnode][chainId][ + requester + ], + "Airnode did not block requester" + ); + TokenDeposits + storage tokenDeposits = airnodeToChainIdToRequesterToTokenAddressToTokenDeposits[ + airnode + ][chainId][requester][token]; + Deposit storage deposit = tokenDeposits.depositorToDeposit[depositor]; + require(deposit.earliestWithdrawalTime != 0, "Token not deposited"); + uint256 tokenDepositCount; + if (deposit.earliestWithdrawalTime == type(uint32).max) { + unchecked { + tokenDepositCount = --tokenDeposits.count; + } + } else { + unchecked { + tokenDepositCount = tokenDeposits.count; + } + } + uint256 tokenId = deposit.tokenId; + tokenDeposits.depositorToDeposit[depositor] = Deposit({ + tokenId: 0, + withdrawalLeadTime: 0, + earliestWithdrawalTime: 0 + }); + emit RevokedToken( + airnode, + requester, + depositor, + chainId, + token, + tokenId, + tokenDepositCount + ); + IERC721(token).safeTransferFrom(address(this), airnode, tokenId); + } + + /// @notice Returns the deposit of the token with the address made by the + /// depositor for the Airnode to authorize the requester address on the + /// chain + /// @param airnode Airnode address + /// @param chainId Chain ID + /// @param requester Requester address + /// @param token Token address + /// @param depositor Depositor address + /// @return tokenId Token ID + /// @return withdrawalLeadTime Withdrawal lead time captured at + /// deposit-time + /// @return earliestWithdrawalTime Earliest withdrawal time + function airnodeToChainIdToRequesterToTokenToDepositorToDeposit( + address airnode, + uint256 chainId, + address requester, + address token, + address depositor + ) + external + view + override + returns ( + uint256 tokenId, + uint32 withdrawalLeadTime, + uint32 earliestWithdrawalTime + ) + { + Deposit + storage deposit = airnodeToChainIdToRequesterToTokenAddressToTokenDeposits[ + airnode + ][chainId][requester][token].depositorToDeposit[depositor]; + (tokenId, withdrawalLeadTime, earliestWithdrawalTime) = ( + deposit.tokenId, + deposit.withdrawalLeadTime, + deposit.earliestWithdrawalTime + ); + } + + /// @notice Returns if the requester on the chain is authorized for the + /// Airnode due to a token with the address being deposited + /// @param airnode Airnode address + /// @param chainId Chain ID + /// @param requester Requester address + /// @param token Token address + /// @return Authorization status + function isAuthorized( + address airnode, + uint256 chainId, + address requester, + address token + ) external view override returns (bool) { + return + !airnodeToChainIdToRequesterToBlockStatus[airnode][chainId][ + requester + ] && + airnodeToChainIdToRequesterToTokenAddressToTokenDeposits[airnode][ + chainId + ][requester][token].count > + 0; + } + + /// @notice Derives the withdrawal lead time setter role for the Airnode + /// @param airnode Airnode address + /// @return withdrawalLeadTimeSetterRole Withdrawal lead time setter role + function deriveWithdrawalLeadTimeSetterRole( + address airnode + ) public view override returns (bytes32 withdrawalLeadTimeSetterRole) { + withdrawalLeadTimeSetterRole = _deriveRole( + _deriveAdminRole(airnode), + WITHDRAWAL_LEAD_TIME_SETTER_ROLE_DESCRIPTION_HASH + ); + } + + /// @notice Derives the requester blocker role for the Airnode + /// @param airnode Airnode address + /// @return requesterBlockerRole Requester blocker role + function deriveRequesterBlockerRole( + address airnode + ) public view override returns (bytes32 requesterBlockerRole) { + requesterBlockerRole = _deriveRole( + _deriveAdminRole(airnode), + REQUESTER_BLOCKER_ROLE_DESCRIPTION_HASH + ); + } +} diff --git a/packages/airnode-protocol/contracts/dev/access-control-registry/AccessControlRegistryAdminned.sol b/packages/airnode-protocol/contracts/dev/access-control-registry/AccessControlRegistryAdminned.sol new file mode 100644 index 0000000000..aa519cfcd7 --- /dev/null +++ b/packages/airnode-protocol/contracts/dev/access-control-registry/AccessControlRegistryAdminned.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/utils/Multicall.sol"; +import "./RoleDeriver.sol"; +import "./AccessControlRegistryUser.sol"; +import "./interfaces/IAccessControlRegistryAdminned.sol"; + +/// @title Contract to be inherited by contracts whose adminship functionality +/// will be implemented using AccessControlRegistry +contract AccessControlRegistryAdminned is + Multicall, + RoleDeriver, + AccessControlRegistryUser, + IAccessControlRegistryAdminned +{ + /// @notice Admin role description + string public override adminRoleDescription; + + bytes32 internal immutable adminRoleDescriptionHash; + + /// @dev Contracts deployed with the same admin role descriptions will have + /// the same roles, meaning that granting an account a role will authorize + /// it in multiple contracts. Unless you want your deployed contract to + /// share the role configuration of another contract, use a unique admin + /// role description. + /// @param _accessControlRegistry AccessControlRegistry contract address + /// @param _adminRoleDescription Admin role description + constructor( + address _accessControlRegistry, + string memory _adminRoleDescription + ) AccessControlRegistryUser(_accessControlRegistry) { + require( + bytes(_adminRoleDescription).length > 0, + "Admin role description empty" + ); + adminRoleDescription = _adminRoleDescription; + adminRoleDescriptionHash = keccak256( + abi.encodePacked(_adminRoleDescription) + ); + } + + /// @notice Derives the admin role for the specific manager address + /// @param manager Manager address + /// @return adminRole Admin role + function _deriveAdminRole(address manager) + internal + view + returns (bytes32 adminRole) + { + adminRole = _deriveRole( + _deriveRootRole(manager), + adminRoleDescriptionHash + ); + } +} diff --git a/packages/airnode-protocol/contracts/dev/access-control-registry/AccessControlRegistryUser.sol b/packages/airnode-protocol/contracts/dev/access-control-registry/AccessControlRegistryUser.sol new file mode 100644 index 0000000000..c9ff2254ef --- /dev/null +++ b/packages/airnode-protocol/contracts/dev/access-control-registry/AccessControlRegistryUser.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "./interfaces/IAccessControlRegistry.sol"; +import "./interfaces/IAccessControlRegistryUser.sol"; + +/// @title Contract to be inherited by contracts that will interact with +/// AccessControlRegistry +contract AccessControlRegistryUser is IAccessControlRegistryUser { + /// @notice AccessControlRegistry contract address + address public immutable override accessControlRegistry; + + /// @param _accessControlRegistry AccessControlRegistry contract address + constructor(address _accessControlRegistry) { + require(_accessControlRegistry != address(0), "ACR address zero"); + accessControlRegistry = _accessControlRegistry; + } +} diff --git a/packages/airnode-protocol/contracts/dev/access-control-registry/RoleDeriver.sol b/packages/airnode-protocol/contracts/dev/access-control-registry/RoleDeriver.sol new file mode 100644 index 0000000000..58964fe7d7 --- /dev/null +++ b/packages/airnode-protocol/contracts/dev/access-control-registry/RoleDeriver.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @title Contract to be inherited by contracts that will derive +/// AccessControlRegistry roles +/// @notice If a contract interfaces with AccessControlRegistry and needs to +/// derive roles, it should inherit this contract instead of re-implementing +/// the logic +contract RoleDeriver { + /// @notice Derives the root role of the manager + /// @param manager Manager address + /// @return rootRole Root role + function _deriveRootRole(address manager) + internal + pure + returns (bytes32 rootRole) + { + rootRole = keccak256(abi.encodePacked(manager)); + } + + /// @notice Derives the role using its admin role and description + /// @dev This implies that roles adminned by the same role cannot have the + /// same description + /// @param adminRole Admin role + /// @param description Human-readable description of the role + /// @return role Role + function _deriveRole(bytes32 adminRole, string memory description) + internal + pure + returns (bytes32 role) + { + role = _deriveRole(adminRole, keccak256(abi.encodePacked(description))); + } + + /// @notice Derives the role using its admin role and description hash + /// @dev This implies that roles adminned by the same role cannot have the + /// same description + /// @param adminRole Admin role + /// @param descriptionHash Hash of the human-readable description of the + /// role + /// @return role Role + function _deriveRole(bytes32 adminRole, bytes32 descriptionHash) + internal + pure + returns (bytes32 role) + { + role = keccak256(abi.encodePacked(adminRole, descriptionHash)); + } +} diff --git a/packages/airnode-protocol/contracts/dev/access-control-registry/interfaces/IAccessControlRegistry.sol b/packages/airnode-protocol/contracts/dev/access-control-registry/interfaces/IAccessControlRegistry.sol new file mode 100644 index 0000000000..3b999f91f6 --- /dev/null +++ b/packages/airnode-protocol/contracts/dev/access-control-registry/interfaces/IAccessControlRegistry.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/access/IAccessControl.sol"; + +interface IAccessControlRegistry is IAccessControl { + event InitializedManager(bytes32 indexed rootRole, address indexed manager); + + event InitializedRole( + bytes32 indexed role, + bytes32 indexed adminRole, + string description, + address sender + ); + + function initializeManager(address manager) external; + + function initializeRoleAndGrantToSender( + bytes32 adminRole, + string calldata description + ) external returns (bytes32 role); + + function deriveRootRole(address manager) + external + pure + returns (bytes32 rootRole); + + function deriveRole(bytes32 adminRole, string calldata description) + external + pure + returns (bytes32 role); +} diff --git a/packages/airnode-protocol/contracts/dev/access-control-registry/interfaces/IAccessControlRegistryAdminned.sol b/packages/airnode-protocol/contracts/dev/access-control-registry/interfaces/IAccessControlRegistryAdminned.sol new file mode 100644 index 0000000000..2afc3a541b --- /dev/null +++ b/packages/airnode-protocol/contracts/dev/access-control-registry/interfaces/IAccessControlRegistryAdminned.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "./IAccessControlRegistryUser.sol"; + +interface IAccessControlRegistryAdminned is IAccessControlRegistryUser { + function adminRoleDescription() external view returns (string memory); +} diff --git a/packages/airnode-protocol/contracts/dev/access-control-registry/interfaces/IAccessControlRegistryUser.sol b/packages/airnode-protocol/contracts/dev/access-control-registry/interfaces/IAccessControlRegistryUser.sol new file mode 100644 index 0000000000..a194e1ac6f --- /dev/null +++ b/packages/airnode-protocol/contracts/dev/access-control-registry/interfaces/IAccessControlRegistryUser.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IAccessControlRegistryUser { + function accessControlRegistry() external view returns (address); +} diff --git a/packages/airnode-protocol/contracts/dev/interfaces/IRequesterAuthorizerWithErc721.sol b/packages/airnode-protocol/contracts/dev/interfaces/IRequesterAuthorizerWithErc721.sol new file mode 100644 index 0000000000..053e16e024 --- /dev/null +++ b/packages/airnode-protocol/contracts/dev/interfaces/IRequesterAuthorizerWithErc721.sol @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; +import "../access-control-registry/interfaces/IAccessControlRegistryAdminned.sol"; + +interface IRequesterAuthorizerWithErc721 is + IERC721Receiver, + IAccessControlRegistryAdminned +{ + event SetWithdrawalLeadTime( + address indexed airnode, + uint32 withdrawalLeadTime, + address sender + ); + + event SetRequesterBlockStatus( + address indexed airnode, + address indexed requester, + uint256 chainId, + bool status, + address sender + ); + + event DepositedToken( + address indexed airnode, + address indexed requester, + address indexed depositor, + uint256 chainId, + address token, + uint256 tokenId, + uint256 tokenDepositCount + ); + + event UpdatedDepositRequesterFrom( + address indexed airnode, + address indexed requester, + address indexed depositor, + uint256 chainId, + address token, + uint256 tokenId, + uint256 tokenDepositCount + ); + + event UpdatedDepositRequesterTo( + address indexed airnode, + address indexed requester, + address indexed depositor, + uint256 chainId, + address token, + uint256 tokenId, + uint256 tokenDepositCount + ); + + event InitiatedTokenWithdrawal( + address indexed airnode, + address indexed requester, + address indexed depositor, + uint256 chainId, + address token, + uint256 tokenId, + uint32 earliestWithdrawalTime, + uint256 tokenDepositCount + ); + + event WithdrewToken( + address indexed airnode, + address indexed requester, + address indexed depositor, + uint256 chainId, + address token, + uint256 tokenId, + uint256 tokenDepositCount + ); + + event RevokedToken( + address indexed airnode, + address indexed requester, + address indexed depositor, + uint256 chainId, + address token, + uint256 tokenId, + uint256 tokenDepositCount + ); + + function setWithdrawalLeadTime( + address airnode, + uint32 withdrawalLeadTime + ) external; + + function setRequesterBlockStatus( + address airnode, + uint256 chainId, + address requester, + bool status + ) external; + + function updateDepositRequester( + address airnode, + uint256 chainIdPrevious, + address requesterPrevious, + uint256 chainIdNext, + address requesterNext, + address token + ) external; + + function initiateTokenWithdrawal( + address airnode, + uint256 chainId, + address requester, + address token + ) external returns (uint32 earliestWithdrawalTime); + + function withdrawToken( + address airnode, + uint256 chainId, + address requester, + address token + ) external; + + function revokeToken( + address airnode, + uint256 chainId, + address requester, + address token, + address depositor + ) external; + + function airnodeToChainIdToRequesterToTokenToDepositorToDeposit( + address airnode, + uint256 chainId, + address requester, + address token, + address depositor + ) + external + view + returns ( + uint256 tokenId, + uint32 withdrawalLeadTime, + uint32 earliestWithdrawalTime + ); + + function isAuthorized( + address airnode, + uint256 chainId, + address requester, + address token + ) external view returns (bool); + + function deriveWithdrawalLeadTimeSetterRole( + address airnode + ) external view returns (bytes32 withdrawalLeadTimeSetterRole); + + function deriveRequesterBlockerRole( + address airnode + ) external view returns (bytes32 requesterBlockerRole); + + // solhint-disable-next-line func-name-mixedcase + function WITHDRAWAL_LEAD_TIME_SETTER_ROLE_DESCRIPTION() + external + view + returns (string memory); + + // solhint-disable-next-line func-name-mixedcase + function REQUESTER_BLOCKER_ROLE_DESCRIPTION() + external + view + returns (string memory); + + function airnodeToChainIdToRequesterToTokenAddressToTokenDeposits( + address airnode, + uint256 chainId, + address requester, + address token + ) external view returns (uint256 tokenDepositCount); + + function airnodeToWithdrawalLeadTime( + address airnode + ) external view returns (uint32 withdrawalLeadTime); + + function airnodeToChainIdToRequesterToBlockStatus( + address airnode, + uint256 chainId, + address requester + ) external view returns (bool isBlocked); +} diff --git a/packages/airnode-protocol/hardhat.config.js b/packages/airnode-protocol/hardhat.config.js index 1f22a88209..6629eaba2d 100644 --- a/packages/airnode-protocol/hardhat.config.js +++ b/packages/airnode-protocol/hardhat.config.js @@ -22,11 +22,35 @@ module.exports = { tests: process.env.EXTENDED_TEST ? './extended-test' : './test', }, solidity: { - version: '0.8.9', - settings: { - optimizer: { - enabled: true, - runs: 1000, + compilers: [ + { + version: '0.8.9', + settings: { + optimizer: { + enabled: true, + runs: 1000, + }, + }, + }, + ], + overrides: { + 'contracts/dev/RequesterAuthorizerWithErc721.sol': { + version: '0.8.17', + settings: { + optimizer: { + enabled: true, + runs: 200, + }, + }, + }, + 'contracts/authorizers/mock/MockErc721.sol': { + version: '0.8.17', + settings: { + optimizer: { + enabled: true, + runs: 200, + }, + }, }, }, }, diff --git a/packages/airnode-protocol/src/index.ts b/packages/airnode-protocol/src/index.ts index 904d444549..2f7b317061 100644 --- a/packages/airnode-protocol/src/index.ts +++ b/packages/airnode-protocol/src/index.ts @@ -17,6 +17,8 @@ import { AccessControlRegistry__factory as AccessControlRegistryFactory, RequesterAuthorizerWithAirnode__factory as RequesterAuthorizerWithAirnodeFactory, RrpBeaconServerV0__factory as RrpBeaconServerV0Factory, + RequesterAuthorizerWithErc721__factory as RequesterAuthorizerWithErc721Factory, + MockErc721__factory as MockErc721Factory, } from './contracts'; import references from '../deployments/references.json'; @@ -35,9 +37,15 @@ const PROTOCOL_IDS = { AIRKEEPER: '12345', }; +// TODO: This and the mock exports below should be defined in airnode-operations instead and not exported from here +const erc721Mocks = { + MockErc721Factory, +}; + const mocks = { MockRrpRequesterFactory, }; + const authorizers = { RequesterAuthorizerWithAirnodeFactory, }; @@ -49,10 +57,12 @@ export { AirnodeRrpV0Factory, AccessControlRegistryFactory, RrpBeaconServerV0Factory, + RequesterAuthorizerWithErc721Factory, mocks, authorizers, networks, PROTOCOL_IDS, + erc721Mocks, }; export type { @@ -60,6 +70,7 @@ export type { MockRrpRequesterV0, AccessControlRegistry, RequesterAuthorizerWithAirnode, + RequesterAuthorizerWithErc721, RrpBeaconServerV0, } from './contracts'; diff --git a/packages/airnode-validator/src/config/config.test.ts b/packages/airnode-validator/src/config/config.test.ts index a1c5b41073..38cd8016f3 100644 --- a/packages/airnode-validator/src/config/config.test.ts +++ b/packages/airnode-validator/src/config/config.test.ts @@ -623,7 +623,7 @@ describe('chainConfigSchema', () => { }); describe('authorizers', () => { - it('allows cross-chain and same-chain authorizers', () => { + it('allows simultaneous authorizers', () => { const config = JSON.parse( readFileSync(join(__dirname, '../../test/fixtures/interpolated-config.valid.json')).toString() ); @@ -644,12 +644,31 @@ describe('authorizers', () => { }, }, ], + requesterAuthorizersWithErc721: [ + { + erc721s: ['0x00bDB2315678afecb367f032d93F642f64180a00'], + RequesterAuthorizerWithErc721: '0x999DB2315678afecb367f032d93F642f64180aa9', + }, + ], + crossChainRequesterAuthorizersWithErc721: [ + { + erc721s: ['0x4abDB2315678afecb367f032d93F642f64180aa7'], + chainType: 'evm', + chainId: '1', + chainProvider: { + url: 'http://some.random.url', + }, + contracts: { + RequesterAuthorizerWithErc721: '0x3FbDB2315678afecb367f032d93F642f64180aa6', + }, + }, + ], }, }; expect(() => chainConfigSchema.parse(validAuthorizersChainConfig)).not.toThrow(); }); - it('allows cross-chain and same-chain authorizers to be empty', () => { + it('allows authorizers to be empty', () => { const config = JSON.parse( readFileSync(join(__dirname, '../../test/fixtures/interpolated-config.valid.json')).toString() ); @@ -658,6 +677,8 @@ describe('authorizers', () => { authorizers: { requesterEndpointAuthorizers: [], crossChainRequesterAuthorizers: [], + requesterAuthorizersWithErc721: [], + crossChainRequesterAuthorizersWithErc721: [], }, }; expect(() => chainConfigSchema.parse(validAuthorizersChainConfig)).not.toThrow(); diff --git a/packages/airnode-validator/src/config/config.ts b/packages/airnode-validator/src/config/config.ts index 5926a2f9d2..f7eec806e9 100644 --- a/packages/airnode-validator/src/config/config.ts +++ b/packages/airnode-validator/src/config/config.ts @@ -54,7 +54,7 @@ export const logFormatSchema = z.union([z.literal('json'), z.literal('plain')]); export const chainTypeSchema = z.literal('evm'); -export const chainContractsSchema = z +export const airnodeRrpContractSchema = z .object({ AirnodeRrp: evmAddressSchema, }) @@ -160,16 +160,41 @@ export const chainAuthorizationsSchema = z.object({ export const requesterEndpointAuthorizersSchema = z.array(evmAddressSchema); export const crossChainRequesterAuthorizerSchema = z.object({ - requesterEndpointAuthorizers: requesterEndpointAuthorizersSchema, + requesterEndpointAuthorizers: requesterEndpointAuthorizersSchema.nonempty(), + chainType: chainTypeSchema, + chainId: z.string(), + contracts: airnodeRrpContractSchema, + chainProvider: providerSchema, +}); + +export const erc721sSchema = z.array(evmAddressSchema); + +export const requesterAuthorizerWithErc721Schema = z.object({ + erc721s: erc721sSchema.nonempty(), + RequesterAuthorizerWithErc721: evmAddressSchema, +}); + +export const requesterAuthorizersWithErc721Schema = z.array(requesterAuthorizerWithErc721Schema); + +export const requesterAuthorizerWithErc721ContractSchema = z + .object({ + RequesterAuthorizerWithErc721: evmAddressSchema, + }) + .strict(); + +export const crossChainRequesterAuthorizersWithErc721Schema = z.object({ + erc721s: erc721sSchema.nonempty(), chainType: chainTypeSchema, chainId: z.string(), - contracts: chainContractsSchema, + contracts: requesterAuthorizerWithErc721ContractSchema, chainProvider: providerSchema, }); export const chainAuthorizersSchema = z.object({ requesterEndpointAuthorizers: requesterEndpointAuthorizersSchema, crossChainRequesterAuthorizers: z.array(crossChainRequesterAuthorizerSchema), + requesterAuthorizersWithErc721: requesterAuthorizersWithErc721Schema, + crossChainRequesterAuthorizersWithErc721: z.array(crossChainRequesterAuthorizersWithErc721Schema), }); export const maxConcurrencySchema = z.number().int().positive(); @@ -192,7 +217,7 @@ export const chainConfigSchema = z authorizers: chainAuthorizersSchema, authorizations: chainAuthorizationsSchema, blockHistoryLimit: z.number().int().optional(), // Defaults to BLOCK_COUNT_HISTORY_LIMIT defined in airnode-node - contracts: chainContractsSchema, + contracts: airnodeRrpContractSchema, id: z.string(), // Defaults to BLOCK_MIN_CONFIRMATIONS defined in airnode-node but may be overridden // by a requester if the _minConfirmations reserved parameter is configured @@ -470,6 +495,7 @@ export type Gateway = SchemaType; export type ChainAuthorizers = SchemaType; export type CrossChainAuthorizer = SchemaType; export type RequesterEndpointAuthorizers = SchemaType; +export type Erc721s = SchemaType; export type ChainAuthorizations = SchemaType; export type ChainOptions = SchemaType; export type ChainType = SchemaType; diff --git a/packages/airnode-validator/test/fixtures/config.valid.json b/packages/airnode-validator/test/fixtures/config.valid.json index d854315529..561553aede 100644 --- a/packages/airnode-validator/test/fixtures/config.valid.json +++ b/packages/airnode-validator/test/fixtures/config.valid.json @@ -16,6 +16,25 @@ "url": "http://127.0.0.2" } } + ], + "requesterAuthorizersWithErc721": [ + { + "erc721s": ["0x00bDB2315678afecb367f032d93F642f64180a00"], + "RequesterAuthorizerWithErc721": "0x999DB2315678afecb367f032d93F642f64180aa9" + } + ], + "crossChainRequesterAuthorizersWithErc721": [ + { + "erc721s": ["0x3FbDB2315678afecb367f032d93F642f64180aa6"], + "chainType": "evm", + "chainId": "4", + "contracts": { + "RequesterAuthorizerWithErc721": "0x6bbbb2315678afecb367f032d93F642f64180aa4" + }, + "chainProvider": { + "url": "http://127.0.0.2" + } + } ] }, "authorizations": { diff --git a/packages/airnode-validator/test/fixtures/interpolated-config.valid.json b/packages/airnode-validator/test/fixtures/interpolated-config.valid.json index 38ebb25cc6..a962a1576d 100644 --- a/packages/airnode-validator/test/fixtures/interpolated-config.valid.json +++ b/packages/airnode-validator/test/fixtures/interpolated-config.valid.json @@ -16,6 +16,25 @@ "url": "http://127.0.0.2" } } + ], + "requesterAuthorizersWithErc721": [ + { + "erc721s": ["0x00bDB2315678afecb367f032d93F642f64180a00"], + "RequesterAuthorizerWithErc721": "0x999DB2315678afecb367f032d93F642f64180aa9" + } + ], + "crossChainRequesterAuthorizersWithErc721": [ + { + "erc721s": ["0x3FbDB2315678afecb367f032d93F642f64180aa6"], + "chainType": "evm", + "chainId": "4", + "contracts": { + "RequesterAuthorizerWithErc721": "0x6bbbb2315678afecb367f032d93F642f64180aa4" + }, + "chainProvider": { + "url": "http://127.0.0.2" + } + } ] }, "authorizations": { diff --git a/packages/airnode-validator/test/fixtures/invalid-secret-name/config.json b/packages/airnode-validator/test/fixtures/invalid-secret-name/config.json index 219eee7d09..915c99e660 100644 --- a/packages/airnode-validator/test/fixtures/invalid-secret-name/config.json +++ b/packages/airnode-validator/test/fixtures/invalid-secret-name/config.json @@ -4,7 +4,9 @@ "maxConcurrency": 100, "authorizers": { "requesterEndpointAuthorizers": [], - "crossChainRequesterAuthorizers": [] + "crossChainRequesterAuthorizers": [], + "requesterAuthorizersWithErc721": [], + "crossChainRequesterAuthorizersWithErc721": [] }, "authorizations": { "requesterEndpointAuthorizations": {}