From 1f1c00007173aeb0995564162b24932bd3d24b2c Mon Sep 17 00:00:00 2001 From: Hugo Arregui Date: Fri, 23 Dec 2022 16:25:26 -0300 Subject: [PATCH] scene on chain validations --- etc/content-validator.api.md | 9 +- package.json | 2 +- src/types.ts | 9 +- src/validations/access-checker/scenes.ts | 335 +----------------- test/setup/mock.ts | 18 +- .../the-graph-client/the-graph-client.spec.ts | 13 +- yarn.lock | 8 +- 7 files changed, 54 insertions(+), 340 deletions(-) diff --git a/etc/content-validator.api.md b/etc/content-validator.api.md index 546ac8eb..d37f5f74 100644 --- a/etc/content-validator.api.md +++ b/etc/content-validator.api.md @@ -22,6 +22,11 @@ export type BlockInformation = { // @public (undocumented) export const calculateDeploymentSize: (deployment: DeploymentToValidate, externalCalls: ExternalCalls) => Promise; +// @public (undocumented) +export type Checker = { + checkLAND(address: string, x: number, y: number, block: number): Promise; +}; + // @public (undocumented) export type ConditionalValidation = { predicate: (components: ContentValidatorComponents, deployment: DeploymentToValidate) => ValidationResponse | Promise; @@ -92,7 +97,7 @@ export const statelessValidations: readonly [Validation, Validation, Validation] // @public export type SubGraphs = { L1: { - landManager: ISubgraphComponent; + checker: Checker; collections: ISubgraphComponent; ensOwner: ISubgraphComponent; }; @@ -144,7 +149,7 @@ export type Warnings = string[]; // Warnings were encountered during analysis: // -// src/types.ts:147:3 - (ae-forgotten-export) The symbol "PermissionResult" needs to be exported by the entry point index.d.ts +// src/types.ts:154:3 - (ae-forgotten-export) The symbol "PermissionResult" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/package.json b/package.json index 8ec3c768..3d9e1dc3 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "typescript": "^4.7.3" }, "dependencies": { - "@dcl/block-indexer": "1.0.0-20221219151053.commit-69f0ed7", + "@dcl/block-indexer": "^1.0.0-20221223191317.commit-2d753e7", "@dcl/content-hash-tree": "^1.1.3", "@dcl/hashing": "1.1.2", "@dcl/schemas": "^5.4.2", diff --git a/src/types.ts b/src/types.ts index 6ea87095..f73b210d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -122,13 +122,20 @@ export const fromErrors = (...errors: Errors): ValidationResponse => ({ errors: errors.length > 0 ? errors : undefined }) +/** + * @public + */ +export type Checker = { + checkLAND(address: string, x: number, y: number, block: number): Promise +} + /** * A list with all sub-graphs used for validations. * @public */ export type SubGraphs = { L1: { - landManager: ISubgraphComponent + checker: Checker collections: ISubgraphComponent ensOwner: ISubgraphComponent } diff --git a/src/validations/access-checker/scenes.ts b/src/validations/access-checker/scenes.ts index 183497d1..fb7fc425 100644 --- a/src/validations/access-checker/scenes.ts +++ b/src/validations/access-checker/scenes.ts @@ -1,305 +1,30 @@ -import { EthAddress } from '@dcl/schemas' import ms from 'ms' -import { ExternalCalls, fromErrors, Validation } from '../../types' +import { fromErrors, Validation } from '../../types' -type Timestamp = number -type AddressSnapshot = { - address: string -} - -type EstateSnapshot = { - estateId: number -} - -type Estate = AuthorizationHistory & { - id: number -} - -type Parcel = AuthorizationHistory & { - x: number - y: number - estates: EstateSnapshot[] -} - -type AuthorizationHistory = { - owners: AddressSnapshot[] - operators: AddressSnapshot[] - updateOperators: AddressSnapshot[] -} - -type Authorization = { - type: 'Operator' | 'ApprovalForAll' | 'UpdateManager' - isApproved: boolean -} +const SCENE_LOOKBACK_TIME = ms('5m') /** * Checks if the given address has access to the given parcel at the given timestamp. * @public */ export const scenes: Validation = { - validate: async ({ externalCalls, logs, subGraphs }, deployment) => { - const logger = logs.getLogger('scenes access validator') - const getAuthorizations = async ( - owner: EthAddress, - operator: EthAddress, - timestamp: Timestamp - ): Promise => { - const query = ` - query GetAuthorizations($owner: String!, $operator: String!, $timestamp: Int!) { - authorizations( - where: { - owner: $owner, - operator: $operator, - createdAt_lte: $timestamp - }, - orderBy: timestamp, - orderDirection: desc - ) { - type - isApproved - } - }` - - const variables = { - owner, - operator, - timestamp: Math.floor(timestamp / 1000) // js(ms) -> UNIX(s) - } - - try { - return ( - await subGraphs.L1.landManager.query<{ - authorizations: Authorization[] - }>(query, variables) - ).authorizations - } catch (error) { - logger.error(`Error fetching authorizations for ${owner}`) - throw error - } - } - - const getEstate = async (estateId: string, timestamp: Timestamp): Promise => { - /** - * You can use `owner`, `operator` and `updateOperator` to check the current value for that estate. - * Keep in mind that each association (owners, operators, etc) is capped to a thousand (1000) results. - * For more information, you can use the query explorer at https://thegraph.com/explorer/subgraph/decentraland/land-manager - */ - - const query = ` - query GetEstate($estateId: String!, $timestamp: Int!) { - estates(where:{ id: $estateId }) { - id - owners( - where: { createdAt_lte: $timestamp }, - orderBy: timestamp, - orderDirection: desc, - first: 1 - ) { - address - } - operators( - where: { createdAt_lte: $timestamp }, - orderBy: timestamp, - orderDirection: desc, - first: 1 - ) { - address - } - updateOperators( - where: { createdAt_lte: $timestamp }, - orderBy: timestamp, - orderDirection: desc, - first: 1 - ) { - address - } - } - }` - - const variables = { - estateId, - timestamp: Math.floor(timestamp / 1000) // UNIX - } - - try { - return ( - await subGraphs.L1.landManager.query<{ - estates: Estate[] - }>(query, variables) - ).estates[0] - } catch (error) { - logger.error(`Error fetching estate (${estateId})`) - throw error - } - } - - const getParcel = async (x: number, y: number, timestamp: Timestamp): Promise => { - /** - * You can use `owner`, `operator` and `updateOperator` to check the current value for that parcel. - * Keep in mind that each association (owners, operators, etc) is capped to a thousand (1000) results. - * For more information, you can use the query explorer at https://thegraph.com/explorer/subgraph/decentraland/land-manager - */ - - const query = ` - query GetParcel($x: Int!, $y: Int!, $timestamp: Int!) { - parcels(where:{ x: $x, y: $y }) { - estates( - where: { createdAt_lte: $timestamp }, - orderBy: createdAt, - orderDirection: desc, - first: 1 - ) { - estateId - } - owners( - where: { createdAt_lte: $timestamp }, - orderBy: timestamp, - orderDirection: desc, - first: 1 - ) { - address - } - operators( - where: { createdAt_lte: $timestamp }, - orderBy: timestamp, - orderDirection: desc, - first: 1 - ) { - address - } - updateOperators( - where: { createdAt_lte: $timestamp }, - orderBy: timestamp, - orderDirection: desc, - first: 1 - ) { - address - } - } - }` - - const variables = { - x, - y, - timestamp: Math.floor(timestamp / 1000) // UNIX - } - - try { - const r = await subGraphs.L1.landManager.query<{ - parcels: Parcel[] - }>(query, variables) - - if (r.parcels && r.parcels.length) return r.parcels[0] - - logger.error(`Error fetching parcel (${x}, ${y}, ${timestamp}): ${JSON.stringify(r)}`) - throw new Error(`Error fetching parcel (${x}, ${y}), ${timestamp}`) - } catch (error) { - logger.error(`Error fetching parcel (${x}, ${y}, ${timestamp})`) - throw error - } - } - - const hasAccessThroughAuthorizations = async ( - owner: EthAddress, - ethAddress: EthAddress, - timestamp: Timestamp - ): Promise => { - /* You also get access if you received: - * - an authorization with isApproved and type Operator, ApprovalForAll or UpdateManager - * at that time - */ - const authorizations = await getAuthorizations(owner.toLowerCase(), ethAddress.toLowerCase(), timestamp) - - const firstOperatorAuthorization = authorizations.find((authorization) => authorization.type === 'Operator') - const firstApprovalForAllAuthorization = authorizations.find( - (authorization) => authorization.type === 'ApprovalForAll' - ) - const firstUpdateManagerAuthorization = authorizations.find( - (authorization) => authorization.type === 'UpdateManager' - ) - - if ( - firstOperatorAuthorization?.isApproved || - firstApprovalForAllAuthorization?.isApproved || - firstUpdateManagerAuthorization?.isApproved - ) { - return true - } - - return false - } - - const hasAccessThroughFirstLevelAuthorities = async ( - target: AuthorizationHistory, - ethAddress: EthAddress - ): Promise => { - const firstLevelAuthorities = [...target.owners, ...target.operators, ...target.updateOperators] - .filter((addressSnapshot) => addressSnapshot.address) - .map((addressSnapshot) => addressSnapshot.address.toLowerCase()) - return firstLevelAuthorities.includes(ethAddress.toLowerCase()) - } - - const isEstateUpdateAuthorized = async ( - estateId: number, - timestamp: Timestamp, - ethAddress: EthAddress - ): Promise => { - const estate = await getEstate(estateId.toString(), timestamp) - if (estate) { - return ( - (await hasAccessThroughFirstLevelAuthorities(estate, ethAddress)) || - (await hasAccessThroughAuthorizations(estate.owners[0].address, ethAddress, timestamp)) - ) - } - throw new Error(`Couldn\'t find the state ${estateId}`) - } - - const isParcelUpdateAuthorized = async ( - x: number, - y: number, - timestamp: Timestamp, - ethAddress: EthAddress, - _externalCalls: ExternalCalls - ): Promise => { - /* You get direct access if you were the: - * - owner - * - operator - * - update operator - * at that time - */ - const parcel = await getParcel(x, y, timestamp) - if (parcel) { - const belongsToEstate: boolean = - parcel.estates != undefined && parcel.estates.length > 0 && parcel.estates[0].estateId != undefined + validate: async ({ externalCalls, subGraphs }, deployment) => { + const { entity } = deployment + const { pointers, timestamp } = entity - return ( - (await hasAccessThroughFirstLevelAuthorities(parcel, ethAddress)) || - (await hasAccessThroughAuthorizations(parcel.owners[0].address, ethAddress, timestamp)) || - (belongsToEstate && (await isEstateUpdateAuthorized(parcel.estates[0].estateId, timestamp, ethAddress))) - ) + let block: number + try { + // Check that the address has access (we check both the present and the 5 min into the past to avoid synchronization issues in the blockchain) + const blockInfo = await subGraphs.l1BlockSearch.findBlockForTimestamp(timestamp - SCENE_LOOKBACK_TIME) + if (blockInfo === undefined) { + return fromErrors('Deployment timestamp is invalid, no matching block found') } - throw new Error(`Parcel(${x},${y},${timestamp}) not found`) - } - const checkParcelAccess = async ( - x: number, - y: number, - timestamp: Timestamp, - ethAddress: EthAddress, - externalCalls: ExternalCalls - ): Promise => { - try { - return await retry(() => isParcelUpdateAuthorized(x, y, timestamp, ethAddress, externalCalls), 5, '0.1s') - } catch (error) { - logger.error(`Error checking parcel access (${x}, ${y}, ${timestamp}, ${ethAddress}).`) - throw error - } + block = blockInfo.block + } catch (err: any) { + return fromErrors(`Deployment timestamp is invalid, no matching block found: ${err}`) } - const SCENE_LOOKBACK_TIME = ms('5m') - - const { entity } = deployment - const { pointers, timestamp } = entity const ethAddress = externalCalls.ownerAddress(deployment.auditInfo) const errors = [] @@ -311,10 +36,7 @@ export const scenes: Validation = { const x: number = parseInt(pointerParts[0], 10) const y: number = parseInt(pointerParts[1], 10) try { - // Check that the address has access (we check both the present and the 5 min into the past to avoid synchronization issues in the blockchain) - const hasAccess = - (await checkParcelAccess(x, y, timestamp, ethAddress, externalCalls)) || - (await checkParcelAccess(x, y, timestamp - SCENE_LOOKBACK_TIME, ethAddress, externalCalls)) + const hasAccess = await subGraphs.L1.checker.checkLAND(ethAddress, x, y, block) if (!hasAccess) { errors.push(`The provided Eth Address does not have access to the following parcel: (${x},${y})`) } @@ -331,30 +53,3 @@ export const scenes: Validation = { return fromErrors(...errors) } } - -/** @internal */ -async function retry( - execution: () => Promise, - attempts: number, - waitTime: string = '1s', - failedAttemptCallback?: (attemptsLeft: number) => void -): Promise { - while (attempts > 0) { - try { - return await execution() - // ^^^^^ never remove this "await" keyword, otherwise this function won't - // catch the exception and perform the retries - } catch (error) { - attempts-- - if (attempts > 0) { - if (failedAttemptCallback) { - failedAttemptCallback(attempts) - } - await new Promise((res) => setTimeout(res, ms(waitTime))) - } else { - throw error - } - } - } - throw new Error('Please specify more than one attempt for the retry function') -} diff --git a/test/setup/mock.ts b/test/setup/mock.ts index 0968d51b..2a63a001 100644 --- a/test/setup/mock.ts +++ b/test/setup/mock.ts @@ -1,7 +1,7 @@ import { IConfigComponent, ILoggerComponent } from '@well-known-components/interfaces' import { ISubgraphComponent } from '@well-known-components/thegraph-component' import { createTheGraphClient } from '../../src' -import { ContentValidatorComponents, ExternalCalls, QueryGraph, SubGraphs } from '../../src/types' +import { Checker, ContentValidatorComponents, ExternalCalls, QueryGraph, SubGraphs } from '../../src/types' import { ItemCollection } from '../../src/validations/access-checker/items/collection-asset' import { createConfigComponent } from '@well-known-components/env-config-provider' import { BlockInfo, BlockRepository, createAvlBlockSearch, metricsDefinitions } from '@dcl/block-indexer' @@ -17,6 +17,12 @@ export const buildLogger = (): ILoggerComponent => ({ }) }) +export function createMockChecker(): Checker { + return { + checkLAND: jest.fn() + } +} + export const buildComponents = (components?: Partial): ContentValidatorComponents => { const config = components?.config ?? buildConfig({}) @@ -78,8 +84,8 @@ export function buildSubGraphs(subGraphs?: Partial): SubGraphs { const logs = buildLogger() return { L1: { + checker: createMockChecker(), collections: createMockSubgraphComponent(), - landManager: createMockSubgraphComponent(), ensOwner: createMockSubgraphComponent() }, L2: { @@ -148,7 +154,7 @@ export function buildMockedQueryGraph(collection?: Partial, _mer accounts: [{ id: COMMITTEE_MEMBER }] }) ), - landManager: createMockSubgraphComponent(), + checker: createMockChecker(), ensOwner: createMockSubgraphComponent() }, L2: { @@ -189,8 +195,8 @@ export const fetcherWithValidCollectionAndCreator = (address: string): SubGraphs export function fetcherWithThirdPartyMerkleRoot(root: string): SubGraphs { return buildSubGraphs({ L1: { + checker: createMockChecker(), collections: createMockSubgraphComponent(), - landManager: createMockSubgraphComponent(), ensOwner: createMockSubgraphComponent() }, L2: { @@ -211,8 +217,8 @@ export function fetcherWithThirdPartyMerkleRoot(root: string): SubGraphs { export const fetcherWithThirdPartyEmptyMerkleRoots = (): SubGraphs => buildSubGraphs({ L1: { + checker: createMockChecker(), collections: createMockSubgraphComponent(), - landManager: createMockSubgraphComponent(), ensOwner: createMockSubgraphComponent() }, L2: { @@ -263,12 +269,12 @@ export function fetcherWithItemsOwnership( ): SubGraphs { return buildSubGraphs({ L1: { + checker: createMockChecker(), collections: createMockSubgraphComponent( jest.fn().mockResolvedValue({ items: ethereum ?? defaultEthereum }) ), - landManager: createMockSubgraphComponent(), ensOwner: createMockSubgraphComponent( jest.fn().mockResolvedValue({ names: ens ?? defaultEns diff --git a/test/unit/the-graph-client/the-graph-client.spec.ts b/test/unit/the-graph-client/the-graph-client.spec.ts index 7d4cbe13..e6aceba7 100644 --- a/test/unit/the-graph-client/the-graph-client.spec.ts +++ b/test/unit/the-graph-client/the-graph-client.spec.ts @@ -6,6 +6,7 @@ import { buildLogger, buildSubGraphs, createMockBlockRepository, + createMockChecker, createMockSubgraphComponent } from '../../setup/mock' @@ -47,8 +48,8 @@ describe('TheGraphClient', () => { it('When no block for current timestamp, it should continue and check the block from 5 minute before', async () => { const subGraphs = buildSubGraphs({ L1: { + checker: createMockChecker(), collections: createMockSubgraphComponent(), - landManager: createMockSubgraphComponent(), ensOwner: createMockSubgraphComponent( jest.fn().mockImplementation(async (_query, _variables) => { if (_variables['block'] === 123400) { @@ -87,8 +88,8 @@ describe('TheGraphClient', () => { it('When current block has not been indexed yet, it should continue and check the block from 5 minute before', async () => { const subGraphs = buildSubGraphs({ L1: { + checker: createMockChecker(), collections: createMockSubgraphComponent(), - landManager: createMockSubgraphComponent(), ensOwner: createMockSubgraphComponent( jest.fn().mockImplementation(async (_query, _variables) => { if (_variables['block'] === 123500) { @@ -124,8 +125,8 @@ describe('TheGraphClient', () => { it('When both current and 5-min before blocks have not been indexed yet, it should report error', async () => { const subGraphs = buildSubGraphs({ L1: { + checker: createMockChecker(), collections: createMockSubgraphComponent(), - landManager: createMockSubgraphComponent(), ensOwner: createMockSubgraphComponent(jest.fn().mockRejectedValue('error')) } }) @@ -145,6 +146,7 @@ describe('TheGraphClient', () => { it('When no block for current timestamp, it should continue and check the block from 5 minute before', async () => { const subGraphs = buildSubGraphs({ L1: { + checker: createMockChecker(), collections: createMockSubgraphComponent( jest.fn().mockResolvedValue({ items: [ @@ -154,7 +156,6 @@ describe('TheGraphClient', () => { ] }) ), - landManager: createMockSubgraphComponent(), ensOwner: createMockSubgraphComponent(jest.fn().mockRejectedValue('error')) }, L2: { @@ -201,6 +202,7 @@ describe('TheGraphClient', () => { it('When current block has not been indexed yet, it should continue and check the block from 5 minute before', async () => { const subGraphs = buildSubGraphs({ L1: { + checker: createMockChecker(), collections: createMockSubgraphComponent( jest.fn().mockImplementation(async (_query, _variables) => { if (_variables['block'] === 123500) { @@ -216,7 +218,6 @@ describe('TheGraphClient', () => { } }) ), - landManager: createMockSubgraphComponent(), ensOwner: createMockSubgraphComponent() }, L2: { @@ -262,8 +263,8 @@ describe('TheGraphClient', () => { it('When both current and 5-min before blocks have not been indexed yet, it should report error', async () => { const subGraphs = buildSubGraphs({ L1: { + checker: createMockChecker(), collections: createMockSubgraphComponent(jest.fn().mockRejectedValue('error')), - landManager: createMockSubgraphComponent(), ensOwner: createMockSubgraphComponent() }, L2: { diff --git a/yarn.lock b/yarn.lock index 1349c610..2352935e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -472,10 +472,10 @@ resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== -"@dcl/block-indexer@1.0.0-20221219151053.commit-69f0ed7": - version "1.0.0-20221219151053.commit-69f0ed7" - resolved "https://registry.yarnpkg.com/@dcl/block-indexer/-/block-indexer-1.0.0-20221219151053.commit-69f0ed7.tgz#8b9ef4cfafde2efb6695e4f54a20fbf38dadd4a8" - integrity sha512-s4CV0cc2zr82van8gXeReXHXCIELvHb/htjGabLiDsfwtPgQqshby0E/PgTfNFYqPBmJWY8VxkS1XgKVMmZ1zw== +"@dcl/block-indexer@^1.0.0-20221223191317.commit-2d753e7": + version "1.0.0-20221223191317.commit-2d753e7" + resolved "https://registry.yarnpkg.com/@dcl/block-indexer/-/block-indexer-1.0.0-20221223191317.commit-2d753e7.tgz#a255bfbad56b7af4a37b636a8f6d8b9c0d454fd1" + integrity sha512-7aEWfJTY/vPQihDmBYuStYezCRNmeRQrFCcgsNUtYRdU9tmtjQsuT3M+miWTPM+4k/YVPOVPwtYMPbi7uaRTsA== dependencies: "@well-known-components/interfaces" "^1.1.3" "@well-known-components/logger" "^3.0.0"