diff --git a/package-lock.json b/package-lock.json index 6ccd7e6b..63b2df19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4750,9 +4750,9 @@ } }, "decentraland-transactions": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/decentraland-transactions/-/decentraland-transactions-1.30.0.tgz", - "integrity": "sha512-IPvZohIKzOsrWk8uC6f/3IvOzNaXXsa7gZqKaLJHDeU0te6NoxO+CeCcR2b/zJxX7k2oaBUO69O1uh4F/wKJtg==" + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/decentraland-transactions/-/decentraland-transactions-1.31.0.tgz", + "integrity": "sha512-k1kq3eh2yTMfYnF/hzVTJocQ/y+UClcDQiwYmkC+VMXgKwtogmTq5wLFVUtkBu83AGNjJxmZvN5PDQa3ZuESYw==" }, "decimal.js": { "version": "10.3.1", diff --git a/package.json b/package.json index fee7dd52..5c2ab1f1 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "dcl-ops-lib": "^1.0.6", "decentraland-commons": "^5.1.0", "decentraland-server": "^3.0.0", - "decentraland-transactions": "^1.30.0", + "decentraland-transactions": "^1.31.0", "escape-html": "^1.0.3", "ethers": "^5.6.1", "express": "^4.16.4", diff --git a/spec/mocks/cheque.ts b/spec/mocks/cheque.ts new file mode 100644 index 00000000..2b6ca516 --- /dev/null +++ b/spec/mocks/cheque.ts @@ -0,0 +1,6 @@ +export const mockedCheque = { + signature: + '0xda1398329aff61ce133d4204c0ebc656b07e0629c280d6f60121874285ac3bbc56e48b9e7211296be9f209d646e723f44a96c82f0e6bd52ee6edf74cc44ee9d71c', // signatured generated using the wallet from fakePrivateKey + qty: 3, + salt: '0xb899ffa17ccea3e2ae5fd36236df56c3bf908a435ad101ed7fa6ec8a9631e253', +} diff --git a/spec/mocks/wallet.ts b/spec/mocks/wallet.ts index 33bcdf23..6c6be084 100644 --- a/spec/mocks/wallet.ts +++ b/spec/mocks/wallet.ts @@ -38,6 +38,7 @@ export const wallet: Wallet = { } // Taken from https://github.com/decentraland/builder-client/blob/00ab26cdbd350ce5f5a46da09358c3662c4c6741/src/test-utils/crypto.ts#L1 +// Wallet address: 0x63FaC9201494f0bd17B9892B9fae4d52fe3BD377 export const fakePrivateKey = '8da4ef21b864d2cc526dbdb2a120bd2874c36c9d0a1fb7f8c63d7f7a8b41de8f' @@ -55,7 +56,6 @@ export async function createIdentity( ): Promise { const address = await signer.getAddress() - // const wallet = EthersWallet.createRandom() const payload = { address: wallet.address, privateKey: wallet.privateKey, diff --git a/src/Collection/Collection.router.spec.ts b/src/Collection/Collection.router.spec.ts index 3497b52a..843deae4 100644 --- a/src/Collection/Collection.router.spec.ts +++ b/src/Collection/Collection.router.spec.ts @@ -29,7 +29,12 @@ import { itemFragmentMock, } from '../../spec/mocks/items' import { itemCurationMock } from '../../spec/mocks/itemCuration' -import { ItemFragment, CollectionFragment } from '../ethereum/api/fragments' +import { mockedCheque } from '../../spec/mocks/cheque' +import { + ItemFragment, + CollectionFragment, + ReceiptFragment, +} from '../ethereum/api/fragments' import { collectionAPI } from '../ethereum/api/collection' import { thirdPartyAPI } from '../ethereum/api/thirdParty' import { Bridge } from '../ethereum/api/Bridge' @@ -85,6 +90,8 @@ jest.mock('../Item/Item.model') jest.mock('./Collection.model') jest.mock('./access') +const thirdPartyAPIMock = thirdPartyAPI as jest.Mocked + describe('Collection router', () => { let dbCollection: CollectionAttributes let dbTPCollection: ThirdPartyCollectionAttributes @@ -1548,13 +1555,7 @@ describe('Collection router', () => { beforeEach(async () => { mockedWallet = new ethers.Wallet(fakePrivateKey) - cheque = { - signature: - '0x393140034cb84d3a71d7dff062cbe0b4b8add8a6a1a66c0157508648ed9e290369c1ab613fd52836dfc08838b094ccd58db0700b24018d0f7bf36ed238811a8e1b', // signatured generated using the wallet from fakePrivateKey - qty: 3, - salt: - '0x866023072516cda998ccd2b696fbbed3912fa5ecea8b474af6e40dadc5352ce4', - } + cheque = { ...mockedCheque } authHeaders = createAuthHeaders( 'post', url, @@ -2136,6 +2137,7 @@ describe('Collection router', () => { ;(SlotUsageCheque.findLastByCollectionId as jest.Mock).mockResolvedValueOnce( {} ) + thirdPartyAPIMock.fetchReceiptById.mockResolvedValueOnce(undefined) }) it('should respond with a 500 saying that the item is missing some properties', () => { @@ -2176,9 +2178,8 @@ describe('Collection router', () => { ] slotUsageCheque = { - qty: 2, - salt: 'salt', - signature: 'signature', + ...mockedCheque, + third_party_id: dbTPCollection.third_party_id, } as SlotUsageChequeAttributes mockCollectionAuthorizationMiddleware( @@ -2198,28 +2199,71 @@ describe('Collection router', () => { ) }) - it('should return an array with the data for pending curations', () => { - return server - .get(buildURL(url)) - .set(createAuthHeaders('get', url)) - .expect(200) - .then((response: any) => { - expect(response.body).toEqual({ - ok: true, - data: { - cheque: { - qty: slotUsageCheque.qty, - salt: slotUsageCheque.salt, - signature: slotUsageCheque.signature, + describe("and the cheque doesn't exist in the blockcahin", () => { + beforeEach(() => { + thirdPartyAPIMock.fetchReceiptById.mockResolvedValueOnce( + undefined + ) + }) + + it('should return an array with the data for pending curations, indicating that the cheque was not used', () => { + return server + .get(buildURL(url)) + .set(createAuthHeaders('get', url)) + .expect(200) + .then((response: any) => { + expect(response.body).toEqual({ + ok: true, + data: { + cheque: { + qty: slotUsageCheque.qty, + salt: slotUsageCheque.salt, + signature: slotUsageCheque.signature, + }, + content_hashes: { + [itemApprovalData[0].id]: 'Qm1abababa', + [itemApprovalData[1].id]: 'Qm2bdbdbdb', + [itemApprovalData[2].id]: 'Qm3rererer', + }, + chequeWasConsumed: false, }, - content_hashes: { - [itemApprovalData[0].id]: 'Qm1abababa', - [itemApprovalData[1].id]: 'Qm2bdbdbdb', - [itemApprovalData[2].id]: 'Qm3rererer', + }) + }) + }) + }) + + describe('and the cheque exists in the blockchain', () => { + beforeEach(() => { + thirdPartyAPIMock.fetchReceiptById.mockResolvedValueOnce({ + id: + '0x7954b5d263d7d1298c98fa330de6a0d94952bb5f6694cab0dde144239d56dce1', + } as ReceiptFragment) + }) + + it('should return an array with the data for pending curations, indicating that the cheque was used', () => { + return server + .get(buildURL(url)) + .set(createAuthHeaders('get', url)) + .expect(200) + .then((response: any) => { + expect(response.body).toEqual({ + ok: true, + data: { + cheque: { + qty: slotUsageCheque.qty, + salt: slotUsageCheque.salt, + signature: slotUsageCheque.signature, + }, + content_hashes: { + [itemApprovalData[0].id]: 'Qm1abababa', + [itemApprovalData[1].id]: 'Qm2bdbdbdb', + [itemApprovalData[2].id]: 'Qm3rererer', + }, + chequeWasConsumed: true, }, - }, + }) }) - }) + }) }) }) }) diff --git a/src/Collection/Collection.service.ts b/src/Collection/Collection.service.ts index d9b38f85..65a62ee3 100644 --- a/src/Collection/Collection.service.ts +++ b/src/Collection/Collection.service.ts @@ -29,7 +29,11 @@ import { } from '../Curation/CollectionCuration' import { CurationStatus } from '../Curation' import { decodeTPCollectionURN, isTPCollection } from '../utils/urn' -import { getAddressFromSignature, toDBCollection } from './utils' +import { + getAddressFromSignature, + getChequeMessageHash, + toDBCollection, +} from './utils' import { CollectionAttributes, FullCollection, @@ -446,6 +450,15 @@ export class CollectionService { return acc }, {} as Record) + const slotUsageCheckHash = await getChequeMessageHash( + slotUsageCheque, + slotUsageCheque.third_party_id + ) + + const remoteCheque = await thirdPartyAPI.fetchReceiptById( + slotUsageCheckHash + ) + return { cheque: { qty, @@ -453,6 +466,7 @@ export class CollectionService { signature, }, content_hashes, + chequeWasConsumed: remoteCheque?.id === slotUsageCheckHash, } } diff --git a/src/Collection/utils.spec.ts b/src/Collection/utils.spec.ts index d020b787..a254d34b 100644 --- a/src/Collection/utils.spec.ts +++ b/src/Collection/utils.spec.ts @@ -7,10 +7,11 @@ import { itemCurationMock } from '../../spec/mocks/itemCuration' import { collectionAPI } from '../ethereum/api/collection' import { ItemCuration } from '../Curation/ItemCuration' import { decodeTPCollectionURN } from '../utils/urn' +import { Cheque } from '../SlotUsageCheque' import { UnpublishedCollectionError } from './Collection.errors' import { CollectionAttributes } from './Collection.types' import { Collection } from './Collection.model' -import { getMergedCollection } from './utils' +import { getChequeMessageHash, getMergedCollection } from './utils' describe('when decoding the TP collection URN', () => { const collectionNetwork = 'ropsten' @@ -137,3 +138,25 @@ describe('getMergedCollection', () => { }) }) }) + +describe('when getting the cheque hash', () => { + let cheque: Cheque + let thirdPartyId: string + + beforeEach(() => { + thirdPartyId = 'urn:decentraland:mumbai:collections-thirdparty:jean-pier' + cheque = { + signature: + '0x1dd053b34b48bc1e08be16c1d4f51908b4551040cf0fb390b90d18583dab2c7716ba3c73f00b5143e8ecdcd6227433226195e545a897df2e28849e91d291d9201c', + qty: 1, + salt: + '0x79ab6dbeeebdd32191ad0b9774e07349b7883359f07237a6cb2179d7bf462a2f', + } + }) + + it('should return the correct hash', () => { + return expect(getChequeMessageHash(cheque, thirdPartyId)).resolves.toEqual( + '0x808b380dc4bd97f8a0cf17c3548ad5c085964b31a99d5c52311c571b398783bc' + ) + }) +}) diff --git a/src/Collection/utils.ts b/src/Collection/utils.ts index 265b4a1d..4331c938 100644 --- a/src/Collection/utils.ts +++ b/src/Collection/utils.ts @@ -1,4 +1,7 @@ import { ethers } from 'ethers' +import { _TypedDataEncoder } from '@ethersproject/hash' +import { TypedDataDomain, TypedDataField } from '@ethersproject/abstract-signer' +import { SignatureLike } from '@ethersproject/bytes' import { env, utils } from 'decentraland-commons' import { ContractData, @@ -10,9 +13,6 @@ import { collectionAPI } from '../ethereum/api/collection' import { Network } from '../ethereum' import { getChainIdFromNetwork } from '../ethereum/utils' import { ItemCuration } from '../Curation/ItemCuration' -import { Cheque } from '../SlotUsageCheque' -import { CollectionAttributes, FullCollection } from './Collection.types' -import { UnpublishedCollectionError } from './Collection.errors' import { decodeTPCollectionURN, getDecentralandCollectionURN, @@ -20,6 +20,9 @@ import { hasTPCollectionURN, isTPCollection, } from '../utils/urn' +import { Cheque } from '../SlotUsageCheque' +import { CollectionAttributes, FullCollection } from './Collection.types' +import { UnpublishedCollectionError } from './Collection.errors' /** * Converts a collection retrieved from the DB into a "FullCollection". @@ -105,10 +108,15 @@ export async function getMergedCollection( return mergedCollection } -export async function getAddressFromSignature( +async function buildChequeSignatureData( cheque: Cheque, thirdPartyId: string -) { +): Promise<{ + domain: TypedDataDomain + types: Record> + values: Record + signature: SignatureLike +}> { const { signature, qty, salt } = cheque const chainId = getChainIdFromNetwork(env.get('ETHEREUM_NETWORK') as Network) const thirdPartyContract: ContractData = await getContract( @@ -121,24 +129,95 @@ export async function getAddressFromSignature( version: thirdPartyContract.version, salt: ethers.utils.hexZeroPad(ethers.utils.hexlify(chainId), 32), } - const domainTypes = { + const types = { ConsumeSlots: [ { name: 'thirdPartyId', type: 'string' }, { name: 'qty', type: 'uint256' }, { name: 'salt', type: 'bytes32' }, ], } - const dataSigned = { + const values = { thirdPartyId, qty, salt, } - const address = ethers.utils.verifyTypedData( + + return { domain, - domainTypes, - dataSigned, - signature + types, + values, + signature, + } +} + +export async function getChequeMessageHash( + cheque: Cheque, + thirdPartyId: string +) { + const textEncoder = new TextEncoder() + const chequeSignatureData = await buildChequeSignatureData( + cheque, + thirdPartyId ) - return address + const dataHash = ethers.utils.keccak256( + ethers.utils.defaultAbiCoder.encode( + ['bytes32', 'bytes32', 'uint256', 'bytes32'], + [ + ethers.utils.keccak256( + textEncoder.encode( + 'ConsumeSlots(string thirdPartyId,uint256 qty,bytes32 salt)' + ) + ), + ethers.utils.keccak256(textEncoder.encode(thirdPartyId)), + chequeSignatureData.values.qty, + chequeSignatureData.values.salt, + ] + ) + ) + + const domainHash = ethers.utils.keccak256( + ethers.utils.defaultAbiCoder.encode( + ['bytes32', 'bytes32', 'bytes32', 'address', 'bytes32'], + [ + ethers.utils.keccak256( + textEncoder.encode( + 'EIP712Domain(string name,string version,address verifyingContract,bytes32 salt)' + ) + ), + ethers.utils.keccak256( + textEncoder.encode(chequeSignatureData.domain.name!) + ), + ethers.utils.keccak256( + textEncoder.encode(chequeSignatureData.domain.version!) + ), + chequeSignatureData.domain.verifyingContract!, + // Check if the padding is the same + chequeSignatureData.domain.salt!, + ] + ) + ) + + const eip191Header = ethers.utils.arrayify('0x1901') + return ethers.utils.solidityKeccak256( + ['bytes', 'bytes32', 'bytes32'], + [eip191Header, domainHash, dataHash] + ) +} + +export async function getAddressFromSignature( + cheque: Cheque, + thirdPartyId: string +) { + const chequeSignatureData = await buildChequeSignatureData( + cheque, + thirdPartyId + ) + + return ethers.utils.verifyTypedData( + chequeSignatureData.domain, + chequeSignatureData.types, + chequeSignatureData.values, + chequeSignatureData.signature + ) } diff --git a/src/Curation/Curation.router.spec.ts b/src/Curation/Curation.router.spec.ts index abafab4c..15314e13 100644 --- a/src/Curation/Curation.router.spec.ts +++ b/src/Curation/Curation.router.spec.ts @@ -636,7 +636,7 @@ describe('when handling a request', () => { } as any }) - describe('when updating a collection curation', () => { + describe("and it's a collection curation", () => { beforeEach(() => { service = mockServiceWithAccess(CollectionCuration, true) @@ -652,7 +652,7 @@ describe('when handling a request', () => { }) }) - describe('when updating an item curation', () => { + describe("and it's an item curation", () => { beforeEach(() => { service = mockServiceWithAccess(ItemCuration, true) @@ -669,6 +669,27 @@ describe('when handling a request', () => { }) }) + describe("when the item doesn't have a previous finished curation", () => { + let req: AuthRequest + + beforeEach(() => { + service = mockServiceWithAccess(ItemCuration, true) + + req = { + auth: { ethAddress: 'ethAddress' }, + params: { id: 'some id' }, + } as any + + jest.spyOn(service, 'getLatestById').mockResolvedValueOnce(undefined) + }) + + it('should reject with an ongoing review message', async () => { + await expect(router.insertItemCuration(req)).rejects.toThrowError( + "Item curations can't be created for items that weren't curated before" + ) + }) + }) + describe('when everything is fine', () => { let req: AuthRequest @@ -720,18 +741,14 @@ describe('when handling a request', () => { describe('when updating an item', () => { let item: ItemAttributes let createItemCurationSpy: jest.SpyInstance - let collectionService: CurationService beforeEach(() => { item = { ...dbItemMock, local_content_hash: 'hash1' } service = mockServiceWithAccess(ItemCuration, true) - collectionService = mockServiceWithAccess(CollectionCuration, true) - jest.spyOn(service, 'getLatestById').mockResolvedValueOnce(undefined) jest - .spyOn(collectionService, 'getLatestById') - .mockResolvedValueOnce(undefined) - + .spyOn(service, 'getLatestById') + .mockResolvedValueOnce({ status: CurationStatus.APPROVED } as any) jest.spyOn(Item, 'findOne').mockResolvedValueOnce(item) createItemCurationSpy = jest diff --git a/src/Curation/Curation.router.ts b/src/Curation/Curation.router.ts index 4f8fbfcd..87179824 100644 --- a/src/Curation/Curation.router.ts +++ b/src/Curation/Curation.router.ts @@ -362,11 +362,17 @@ export class CurationRouter extends Router { type: CurationType ) => { const curationService = CurationService.byType(type) - await this.validateAccessToCuration(curationService, ethAddress, id) - const curation = await curationService.getLatestById(id) + if (!curation && type === CurationType.ITEM) { + throw new HTTPError( + "Item curations can't be created for items that weren't curated before", + { id }, + STATUS_CODES.badRequest + ) + } + if (curation && curation.status === CurationStatus.PENDING) { throw new HTTPError( 'There is already an ongoing review request', diff --git a/src/Item/Item.types.ts b/src/Item/Item.types.ts index e7343b53..5c55444b 100644 --- a/src/Item/Item.types.ts +++ b/src/Item/Item.types.ts @@ -74,6 +74,7 @@ export type ItemApprovalData = { ItemAttributes['id'], ItemCurationAttributes['content_hash'] > + chequeWasConsumed: boolean } type BaseWearableEntityMetadata = Omit< diff --git a/src/ethereum/api/fragments.ts b/src/ethereum/api/fragments.ts index 6f7f507e..b4d591db 100644 --- a/src/ethereum/api/fragments.ts +++ b/src/ethereum/api/fragments.ts @@ -113,6 +113,21 @@ export const tiersFragment = () => gql` } ` +export const receiptsFragment = () => gql` + fragment receiptsFragment on Receipt { + id + qty + signer + } +` + +export type ReceiptFragment = { + id: string + qty: string + signer: string + createdAt: string +} + export type TierFragment = { id: string value: string diff --git a/src/ethereum/api/thirdParty.ts b/src/ethereum/api/thirdParty.ts index fdac6592..a98a9af9 100644 --- a/src/ethereum/api/thirdParty.ts +++ b/src/ethereum/api/thirdParty.ts @@ -7,6 +7,8 @@ import { thirdPartyItemFragment, tiersFragment, TierFragment, + ReceiptFragment, + receiptsFragment, } from './fragments' import { BaseGraphAPI, @@ -98,6 +100,15 @@ const getTiersQuery = () => gql` ${tiersFragment()} ` +const getReceiptByIdQuery = () => gql` + query getReceiptById($hash: String!) { + receipts(first: 1, where: { id: $hash }) { + ...receiptsFragment + } + } + ${receiptsFragment()} +` + export class ThirdPartyAPI extends BaseGraphAPI { fetchThirdParties = async (): Promise => { return this.paginate(['thirdParties'], { @@ -140,6 +151,19 @@ export class ThirdPartyAPI extends BaseGraphAPI { }) } + fetchReceiptById = async ( + hash: string + ): Promise => { + const { + data: { receipts }, + } = await this.query<{ receipts: ReceiptFragment[] }>({ + query: getReceiptByIdQuery(), + variables: { hash }, + }) + + return receipts[0] + } + fetchMaxItemsByThirdParty = async (thirdPartyId: string): Promise => { const { data: { thirdParties },