diff --git a/subgraph/src/receipt.ts b/subgraph/src/receipt.ts index 5937ce98a23..808a92af1d9 100644 --- a/subgraph/src/receipt.ts +++ b/subgraph/src/receipt.ts @@ -1,5 +1,10 @@ -import { BigInt, log, Bytes, ethereum } from '@graphprotocol/graph-ts' -import { ERC20_TRANSFER_TOPIC0, nullAddress } from '../tests/constants' +import { BigInt, log, Bytes, ethereum, Address } from '@graphprotocol/graph-ts' +import { + GNP_CHANGED_TOPIC0, + ERC20_TRANSFER_TOPIC0, + nullAddress, +} from '../tests/constants' +import { PublicLockV11 as PublicLock } from '../generated/templates/PublicLock/PublicLockV11' import { Lock, Receipt } from '../generated/schema' @@ -32,13 +37,14 @@ export function createReceipt(event: ethereum.Event): void { ? lock.tokenAddress : Bytes.fromHexString(nullAddress) + const txReceipt = event.receipt! + const logs: ethereum.Log[] = txReceipt.logs + if (tokenAddress != Bytes.fromHexString(nullAddress)) { log.debug('Creating receipt for ERC20 lock {} {}', [ lockAddress, tokenAddress.toHexString(), ]) - const txReceipt = event.receipt! - const logs: ethereum.Log[] = txReceipt.logs if (logs) { // If it is an ERC20 lock, there should be multiple events @@ -76,9 +82,36 @@ export function createReceipt(event: ethereum.Event): void { // which means we don't need to create a receipt. } } else { - log.debug('Creating receipt for base currency lock {}', [lockAddress]) + log.debug('Creating receipt for native currency lock {}', [lockAddress]) receipt.payer = event.transaction.from.toHexString() receipt.amountTransferred = event.transaction.value + // We cannot trust `event.transaction.value` because the purchase function could in fact + // be happening inside of a larger transaction whose value is not the amount transfered, + // In that case, we need to look up the GNPChanged event + // This is a very fragile setup and we should consider moving to a more formal event triggered + // by the contract, like a `Receipt` event that would include everything we need. + if (logs) { + const lockContract = PublicLock.bind(Address.fromString(lockAddress)) + const unlockAddress = lockContract.try_unlockProtocol() + let value = BigInt.zero() + for (let i = 0; i < logs.length; i++) { + const txLog = logs[i] + if ( + txLog.address == unlockAddress.value && + txLog.topics[0].toHexString() == GNP_CHANGED_TOPIC0 + ) { + const decoded = ethereum + .decode('(uint256,uint256,address,uint256,address)', txLog.data)! + .toTuple() + + const keyValue = decoded[1].toBigInt() + value = value.plus(keyValue) + } + } + if (value > BigInt.zero()) { + receipt.amountTransferred = value + } + } } const totalGas = event.transaction.gasPrice.plus(event.transaction.gasLimit) diff --git a/subgraph/tests/constants.ts b/subgraph/tests/constants.ts index 04c98466a34..6f2e4a9a494 100644 --- a/subgraph/tests/constants.ts +++ b/subgraph/tests/constants.ts @@ -8,6 +8,9 @@ export const lockManagers = [ '0x0000000000000000000000000000000000000125', ] +// protocol +export const unlockAddress = '0x0000000000000000000000000000000000000018' + // key export const keyOwnerAddress = '0x0000000000000000000000000000000000000002' export const tokenId = 1234 @@ -32,7 +35,8 @@ export const symbol = 'METAKEY' export const baseTokenURI = 'https:/custom-lock.com/api/key/' // default address used in newMockEvent() function -export const defaultMockAddress = '0xa16081f360e3847006db660bae1c6d1b2e17ec2a' +export const defaultMockAddress = + '0xA16081F360e3847006dB660bae1c6d1b2e17eC2A'.toLowerCase() export const nullAddress = '0x0000000000000000000000000000000000000000' // TODO: compile from contract ABI @@ -40,3 +44,6 @@ export const nullAddress = '0x0000000000000000000000000000000000000000' // TODO: for easier handling on future locks: trigger an "paid" event with the amount and data needed? export const ERC20_TRANSFER_TOPIC0 = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' + +export const GNP_CHANGED_TOPIC0 = + '0x3b50eb9d9b4a8db204f2928c9e572c2865b0d02803493ccb6aa256848323ebb7' diff --git a/subgraph/tests/createCancelKeyEvent.ts b/subgraph/tests/createCancelKeyEvent.ts deleted file mode 100644 index 75f90bb2903..00000000000 --- a/subgraph/tests/createCancelKeyEvent.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { - ethereum, - Address, - BigInt, - Bytes, - Wrapped, -} from '@graphprotocol/graph-ts' -import { - defaultMockAddress, - keyOwnerAddress, - keyPrice, - lockAddress, - lockOwner, - tokenId, -} from './constants' - -const defaultAddress = Address.fromString(defaultMockAddress) -const defaultAddressBytes = defaultAddress as Bytes -const defaultBigInt = BigInt.fromU32(keyPrice) -const defaultIntBytes = Bytes.fromUint8Array(defaultBigInt.reverse()) -const defaultZeroIntBytes = Bytes.fromI32(0) - -function bigIntToBytes(bi: BigInt): Bytes { - let hexString = bi.toHexString() - // Remove the '0x' prefix and pad the hex string to be even length - hexString = hexString - .slice(2) - .padStart( - hexString.length % 2 == 0 ? hexString.length : hexString.length + 1, - '0' - ) - return Bytes.fromHexString('0x' + hexString) as Bytes -} - -function addressToTopic(address: Address): Bytes { - // Convert the address to a hex string, remove the leading 0x - const addressHex = address.toHexString().slice(2) - // Pad the hex string to 64 characters (32 bytes when converted back to bytes) - const paddedHex = addressHex.padStart(64, '0') - // Convert back to Bytes and return - return Bytes.fromHexString('0x' + paddedHex) as Bytes -} - -// Create CancelKey event log for the receipt -function createCancelKeyEventLog( - tokenId: BigInt, - owner: Address, - sendTo: Address, - refund: Bytes -): ethereum.Log { - const eventSignature = defaultAddressBytes - const topics = [ - eventSignature, - bigIntToBytes(tokenId), - addressToTopic(owner), - addressToTopic(sendTo), - ] - return new ethereum.Log( - Address.fromString(lockAddress), - topics, - refund, - defaultAddressBytes, - defaultIntBytes, - defaultAddressBytes, - defaultBigInt, - defaultBigInt, - defaultBigInt, - 'CancelKey', - new Wrapped(false) - ) -} - -// Create transfrer event log for the receipt -function createTransferEventLog( - tokenAddress: Address, - from: Address, - to: Address, - value: Bytes -): ethereum.Log { - const eventSignature = Bytes.fromHexString( - '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' - ) - const topics = [eventSignature, addressToTopic(from), addressToTopic(to)] - return new ethereum.Log( - tokenAddress, - topics, - value, - defaultAddressBytes, - defaultIntBytes, - defaultAddressBytes, - defaultBigInt, - defaultBigInt, - defaultBigInt, - 'Transfer', - new Wrapped(false) - ) -} - -// Create transaction receipt for mock transaction -export function newTransactionReceipt( - tokenAddress: Address, - refund: BigInt -): ethereum.TransactionReceipt { - return new ethereum.TransactionReceipt( - defaultAddressBytes, - defaultBigInt, - defaultAddressBytes, - defaultBigInt, - defaultBigInt, - defaultBigInt, - defaultAddress, - [ - createCancelKeyEventLog( - BigInt.fromU32(tokenId), - Address.fromString(lockOwner), - Address.fromString(keyOwnerAddress), - refund > BigInt.fromU32(0) ? defaultIntBytes : defaultZeroIntBytes - ), - // This Log shouldn't be there if the tokenAddress is nullAddress but id does not really matter - createTransferEventLog( - tokenAddress, - Address.fromString(lockAddress), - Address.fromString(keyOwnerAddress), - refund > BigInt.fromU32(0) ? defaultIntBytes : defaultZeroIntBytes - ), - ], - defaultBigInt, - defaultAddressBytes, - defaultAddressBytes - ) -} diff --git a/subgraph/tests/keys-utils.ts b/subgraph/tests/keys-utils.ts index 46e3621dd19..0ba504a6f75 100644 --- a/subgraph/tests/keys-utils.ts +++ b/subgraph/tests/keys-utils.ts @@ -23,6 +23,7 @@ import { Transfer, RenewKeyPurchase, } from '../generated/templates/PublicLock/PublicLock' +import { GNPChanged } from '../generated/Unlock/Unlock' import { now, @@ -31,8 +32,9 @@ import { tokenId, keyOwnerAddress, lockOwner, + nullAddress, } from './constants' -import { newTransactionReceipt } from './createCancelKeyEvent' +import { newCancelKeyTransactionReceipt } from './mockTxReceipt' export function mockDataSourceV8(): void { const v8context = new DataSourceContext() @@ -204,7 +206,7 @@ export function createCancelKeyEvent( ): CancelKey { const cancelKeyEvent = changetype(newMockEvent()) - cancelKeyEvent.receipt = newTransactionReceipt(tokenAddress, refund) + cancelKeyEvent.receipt = newCancelKeyTransactionReceipt(tokenAddress, refund) cancelKeyEvent.address = dataSource.address() cancelKeyEvent.parameters = [] diff --git a/subgraph/tests/mockTxReceipt.ts b/subgraph/tests/mockTxReceipt.ts new file mode 100644 index 00000000000..ac8ef23c19e --- /dev/null +++ b/subgraph/tests/mockTxReceipt.ts @@ -0,0 +1,187 @@ +import { + ethereum, + Address, + BigInt, + Bytes, + Wrapped, + ByteArray, +} from '@graphprotocol/graph-ts' +import { + defaultMockAddress, + keyOwnerAddress, + keyPrice, + unlockAddress, + lockAddress, + lockOwner, + nullAddress, + tokenId, + GNP_CHANGED_TOPIC0, +} from './constants' + +const defaultAddress = Address.fromString(defaultMockAddress) +const defaultAddressBytes = defaultAddress as Bytes +const defaultBigInt = BigInt.fromU32(keyPrice) +const defaultIntBytes = Bytes.fromUint8Array(defaultBigInt.reverse()) +const defaultZeroIntBytes = Bytes.fromI32(0) + +export function bigIntToBytes(num: BigInt): Bytes { + return Bytes.fromUint8Array(stripZeros(Bytes.fromBigInt(num).reverse())) +} + +export function bigIntToTopic(num: BigInt): Bytes { + const bigIntHex = bigIntToBytes(num).toHexString().slice(2) + const paddedHex = bigIntHex.padStart(64, '0') + return Bytes.fromHexString('0x' + paddedHex) as Bytes +} + +export function stripZeros(bytes: Uint8Array): ByteArray { + let i = 0 + while (i < bytes.length && bytes[i] == 0) { + i++ + } + return Bytes.fromUint8Array(bytes.slice(i)) +} + +function addressToTopic(address: Address): Bytes { + // Convert the address to a hex string, remove the leading 0x + const addressHex = address.toHexString().slice(2) + // Pad the hex string to 64 characters (32 bytes when converted back to bytes) + const paddedHex = addressHex.padStart(64, '0') + // Convert back to Bytes and return + return Bytes.fromHexString('0x' + paddedHex) as Bytes +} + +// Create CancelKey event log for the receipt +function createCancelKeyEventLog( + tokenId: BigInt, + owner: Address, + sendTo: Address, + refund: Bytes +): ethereum.Log { + const eventSignature = defaultAddressBytes + const topics = [ + eventSignature, + bigIntToTopic(tokenId), + addressToTopic(owner), + addressToTopic(sendTo), + ] + return new ethereum.Log( + Address.fromString(lockAddress), + topics, + refund, + defaultAddressBytes, + defaultIntBytes, + defaultAddressBytes, + defaultBigInt, + defaultBigInt, + defaultBigInt, + 'CancelKey', + new Wrapped(false) + ) +} + +// Create transfrer event log for the receipt +function createTransferEventLog( + tokenAddress: Address, + from: Address, + to: Address, + value: Bytes +): ethereum.Log { + const eventSignature = Bytes.fromHexString( + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' + ) + const topics = [eventSignature, addressToTopic(from), addressToTopic(to)] + return new ethereum.Log( + tokenAddress, + topics, + value, + defaultAddressBytes, + defaultIntBytes, + defaultAddressBytes, + defaultBigInt, + defaultBigInt, + defaultBigInt, + 'Transfer', + new Wrapped(false) + ) +} + +// Create transaction receipt for mock transaction +export function newTransactionReceipt( + logs: ethereum.Log[] +): ethereum.TransactionReceipt { + return new ethereum.TransactionReceipt( + defaultAddressBytes, + defaultBigInt, + defaultAddressBytes, + defaultBigInt, + defaultBigInt, + defaultBigInt, + defaultAddress, + logs, + defaultBigInt, + defaultAddressBytes, + defaultAddressBytes + ) +} + +// Create transaction receipt for mock transaction +export function newCancelKeyTransactionReceipt( + tokenAddress: Address, + refund: BigInt +): ethereum.TransactionReceipt { + return newTransactionReceipt([ + createCancelKeyEventLog( + BigInt.fromU32(tokenId), + Address.fromString(lockOwner), + Address.fromString(keyOwnerAddress), + refund > BigInt.fromU32(0) ? defaultIntBytes : defaultZeroIntBytes + ), + // This Log shouldn't be there if the tokenAddress is nullAddress but id does not really matter + createTransferEventLog( + tokenAddress, + Address.fromString(lockAddress), + Address.fromString(keyOwnerAddress), + refund > BigInt.fromU32(0) ? defaultIntBytes : defaultZeroIntBytes + ), + ]) +} + +// adds a GNPChanged event to the tx receipt +export function newGNPChangedTransactionReceipt( + keyValue: BigInt, + totalValue: BigInt +): ethereum.TransactionReceipt { + const eventSignature = Bytes.fromHexString(GNP_CHANGED_TOPIC0) + const grossNetworkProduct = BigInt.fromU32(0) + + // as the tx is sent from another contract (NOT the lock) + // only the event signature is passed as topics[0] + // the rest of the log topics are passed as data + const topics = [eventSignature] + + const GNPChangedValues: Array = [ + ethereum.Value.fromUnsignedBigInt(grossNetworkProduct), + ethereum.Value.fromUnsignedBigInt(keyValue), + ethereum.Value.fromAddress(Address.fromString(nullAddress)), + ethereum.Value.fromUnsignedBigInt(keyValue), + ethereum.Value.fromAddress(Address.fromString(lockAddress)), + ] + const values = changetype(GNPChangedValues) + + return newTransactionReceipt([ + new ethereum.Log( + Address.fromString(unlockAddress), + topics, + ethereum.encode(ethereum.Value.fromTuple(values))!, + defaultAddressBytes, + defaultIntBytes, + defaultAddressBytes, + defaultBigInt, + defaultBigInt, + defaultBigInt, + 'GNPChanged', + new Wrapped(false) + ), + ]) +} diff --git a/subgraph/tests/mocks.ts b/subgraph/tests/mocks.ts index 7bf6aece653..d8ac085b186 100644 --- a/subgraph/tests/mocks.ts +++ b/subgraph/tests/mocks.ts @@ -15,6 +15,7 @@ import { maxNumberOfKeys, maxKeysPerAddress, lockManagers, + unlockAddress, } from './constants' createMockedFunction( @@ -63,6 +64,14 @@ createMockedFunction( .withArgs([]) .returns([ethereum.Value.fromI32(BigInt.fromString('11').toI32())]) +createMockedFunction( + Address.fromString(lockAddress), + 'unlockProtocol', + 'unlockProtocol():(address)' +) + .withArgs([]) + .returns([ethereum.Value.fromAddress(Address.fromString(unlockAddress))]) + createMockedFunction( Address.fromString(lockAddress), 'publicLockVersion', diff --git a/subgraph/tests/receipts.test.ts b/subgraph/tests/receipts.test.ts index 65cd2d95d89..c06e510fba9 100644 --- a/subgraph/tests/receipts.test.ts +++ b/subgraph/tests/receipts.test.ts @@ -36,8 +36,9 @@ import { handleTransfer, } from '../src/public-lock' -// mock contract functions +// mock functions import './mocks' +import { newGNPChangedTransactionReceipt } from './mockTxReceipt' const keyID = `${lockAddress}-${tokenId}` @@ -82,6 +83,55 @@ describe('Receipts for base currency locks', () => { assert.entityCount('Receipt', 1) }) + test('GNP value should override the tx value', () => { + mockDataSourceV11() + + // create fake ETH lock in subgraph + const lock = new Lock(lockAddress) + lock.address = Bytes.fromHexString(lockAddress) + lock.tokenAddress = Bytes.fromHexString(nullAddress) + lock.price = BigInt.fromU32(keyPrice) + lock.lockManagers = [Bytes.fromHexString(lockManagers[0])] + lock.version = BigInt.fromU32(12) + lock.totalKeys = BigInt.fromU32(0) + lock.deployer = Bytes.fromHexString(lockManagers[0]) + lock.numberOfReceipts = BigInt.fromU32(0) + lock.numberOfCancelReceipts = BigInt.fromU32(0) + lock.save() + + // create a key + const newTransferEvent = createTransferEvent( + Address.fromString(nullAddress), + Address.fromString(keyOwnerAddress), + BigInt.fromU32(tokenId) + ) + + // append GNP event to tx + const keyValue = BigInt.fromU32(200) + const totalValue = BigInt.fromU32(1000) // specified a wrong tx.value + + // bind receipt and value to the tx + newTransferEvent.transaction.value = totalValue + newTransferEvent.receipt = newGNPChangedTransactionReceipt( + keyValue, + totalValue + ) + + handleTransfer(newTransferEvent) + + // receipt is there + assert.entityCount('Receipt', 1) + + // make sure the GNPChanged event has been picked up correctly + const hash = newTransferEvent.transaction.hash.toHexString() + assert.fieldEquals( + 'Receipt', + hash, + 'amountTransferred', + keyValue.toString() + ) + }) + test('Receipt has not been created for transfers with no value', () => { mockDataSourceV11() @@ -107,11 +157,6 @@ describe('Receipts for base currency locks', () => { newTransferEvent.transaction.value = BigInt.fromU32(0) // This is a grantKeys transaction handleTransfer(newTransferEvent) - const hash = newTransferEvent.transaction.hash.toHexString() - const timestamp = newTransferEvent.block.timestamp.toString() - const msgSender = newTransferEvent.transaction.from.toHexString() - const amount = newTransferEvent.transaction.value - // key is there assert.entityCount('Key', 1) assert.fieldEquals('Key', keyID, 'tokenId', `${tokenId}`) @@ -250,11 +295,6 @@ describe('Receipts for an ERC20 locks', () => { newTransferEvent.transaction.value = BigInt.fromU32(0) // This is a grantKeys transaction handleTransfer(newTransferEvent) - const hash = newTransferEvent.transaction.hash.toHexString() - const timestamp = newTransferEvent.block.timestamp.toString() - const msgSender = newTransferEvent.transaction.from.toHexString() - const amount = newTransferEvent.transaction.value - // key is there assert.entityCount('Key', 1) assert.fieldEquals('Key', keyID, 'tokenId', `${tokenId}`)