Commit 49dedea9 authored by Georgios Konstantopoulos's avatar Georgios Konstantopoulos Committed by GitHub

fix: Allow Multiple Fraud Proofs per State Root (#72)

* feat: allow multiple state transitioners per pre-state root by tx hash

* test: specify tx hash when finalizing fraud verification

* test: specify tx hash when recording gas spent

* test: add test for multiple fraud proofs per stateroot

* chore: remove redundant test

* fix up test indexing and mock interface
Co-authored-by: default avatarben-chain <ben@pseudonym.party>
parent 4a3ef8f6
......@@ -65,9 +65,9 @@ contract OVM_BondManager is iOVM_BondManager, Lib_AddressResolver {
********************/
/// Adds `who` to the list of witnessProviders for the provided `preStateRoot`.
function recordGasSpent(bytes32 _preStateRoot, address who, uint256 gasSpent) override public {
function recordGasSpent(bytes32 _preStateRoot, bytes32 _txHash, address who, uint256 gasSpent) override public {
// The sender must be the transitioner that corresponds to the claimed pre-state root
address transitioner = address(iOVM_FraudVerifier(resolve("OVM_FraudVerifier")).getStateTransitioner(_preStateRoot));
address transitioner = address(iOVM_FraudVerifier(resolve("OVM_FraudVerifier")).getStateTransitioner(_preStateRoot, _txHash));
require(transitioner == msg.sender, Errors.ONLY_TRANSITIONER);
witnessProviders[_preStateRoot].total += gasSpent;
......
......@@ -9,10 +9,10 @@ import { Lib_AddressResolver } from "../../libraries/resolver/Lib_AddressResolve
abstract contract OVM_FraudContributor is Lib_AddressResolver {
/// Decorate your functions with this modifier to store how much total gas was
/// consumed by the sender, to reward users fairly
modifier contributesToFraudProof(bytes32 preStateRoot) {
modifier contributesToFraudProof(bytes32 preStateRoot, bytes32 txHash) {
uint startGas = gasleft();
_;
uint gasSpent = startGas - gasleft();
iOVM_BondManager(resolve('OVM_BondManager')).recordGasSpent(preStateRoot, msg.sender, gasSpent);
iOVM_BondManager(resolve('OVM_BondManager')).recordGasSpent(preStateRoot, txHash, msg.sender, gasSpent);
}
}
......@@ -52,7 +52,8 @@ contract OVM_FraudVerifier is Lib_AddressResolver, OVM_FraudContributor, iOVM_Fr
* @return _transitioner Corresponding state transitioner contract.
*/
function getStateTransitioner(
bytes32 _preStateRoot
bytes32 _preStateRoot,
bytes32 _txHash
)
override
public
......@@ -61,7 +62,7 @@ contract OVM_FraudVerifier is Lib_AddressResolver, OVM_FraudContributor, iOVM_Fr
iOVM_StateTransitioner _transitioner
)
{
return transitioners[_preStateRoot];
return transitioners[keccak256(abi.encodePacked(_preStateRoot, _txHash))];
}
......@@ -90,9 +91,11 @@ contract OVM_FraudVerifier is Lib_AddressResolver, OVM_FraudContributor, iOVM_Fr
)
override
public
contributesToFraudProof(_preStateRoot)
contributesToFraudProof(_preStateRoot, Lib_OVMCodec.hashTransaction(_transaction))
{
if (_hasStateTransitioner(_preStateRoot)) {
bytes32 _txHash = Lib_OVMCodec.hashTransaction(_transaction);
if (_hasStateTransitioner(_preStateRoot, _txHash)) {
return;
}
......@@ -122,14 +125,19 @@ contract OVM_FraudVerifier is Lib_AddressResolver, OVM_FraudContributor, iOVM_Fr
_preStateRootBatchHeader.prevTotalElements + _preStateRootProof.index == _transactionBatchHeader.prevTotalElements + _transactionProof.index,
"Pre-state root global index must equal to the transaction root global index."
);
deployTransitioner(_preStateRoot, _txHash, _preStateRootProof.index);
}
transitioners[_preStateRoot] = iOVM_StateTransitionerFactory(
// NB: Stack too deep :/
function deployTransitioner(bytes32 _preStateRoot, bytes32 _txHash, uint256 _stateTransitionIndex) private {
transitioners[keccak256(abi.encodePacked(_preStateRoot, _txHash))] = iOVM_StateTransitionerFactory(
resolve("OVM_StateTransitionerFactory")
).create(
address(libAddressManager),
_preStateRootProof.index,
_stateTransitionIndex,
_preStateRoot,
Lib_OVMCodec.hashTransaction(_transaction)
_txHash
);
}
......@@ -138,6 +146,7 @@ contract OVM_FraudVerifier is Lib_AddressResolver, OVM_FraudContributor, iOVM_Fr
* @param _preStateRoot State root before the fraudulent transaction.
* @param _preStateRootBatchHeader Batch header for the provided pre-state root.
* @param _preStateRootProof Inclusion proof for the provided pre-state root.
* @param _txHash The transaction for the state root
* @param _postStateRoot State root after the fraudulent transaction.
* @param _postStateRootBatchHeader Batch header for the provided post-state root.
* @param _postStateRootProof Inclusion proof for the provided post-state root.
......@@ -146,15 +155,16 @@ contract OVM_FraudVerifier is Lib_AddressResolver, OVM_FraudContributor, iOVM_Fr
bytes32 _preStateRoot,
Lib_OVMCodec.ChainBatchHeader memory _preStateRootBatchHeader,
Lib_OVMCodec.ChainInclusionProof memory _preStateRootProof,
bytes32 _txHash,
bytes32 _postStateRoot,
Lib_OVMCodec.ChainBatchHeader memory _postStateRootBatchHeader,
Lib_OVMCodec.ChainInclusionProof memory _postStateRootProof
)
override
public
contributesToFraudProof(_preStateRoot)
contributesToFraudProof(_preStateRoot, _txHash)
{
iOVM_StateTransitioner transitioner = transitioners[_preStateRoot];
iOVM_StateTransitioner transitioner = getStateTransitioner(_preStateRoot, _txHash);
iOVM_StateCommitmentChain ovmStateCommitmentChain = iOVM_StateCommitmentChain(resolve("OVM_StateCommitmentChain"));
iOVM_BondManager ovmBondManager = iOVM_BondManager(resolve("OVM_BondManager"));
......@@ -191,7 +201,17 @@ contract OVM_FraudVerifier is Lib_AddressResolver, OVM_FraudContributor, iOVM_Fr
_postStateRoot != transitioner.getPostStateRoot(),
"State transition has not been proven fraudulent."
);
cancelStateTransition(_postStateRootBatchHeader, _preStateRoot);
}
// NB: Stack too deep :/
function cancelStateTransition(
Lib_OVMCodec.ChainBatchHeader memory _postStateRootBatchHeader,
bytes32 _preStateRoot
) private {
iOVM_StateCommitmentChain ovmStateCommitmentChain = iOVM_StateCommitmentChain(resolve("OVM_StateCommitmentChain"));
iOVM_BondManager ovmBondManager = iOVM_BondManager(resolve("OVM_BondManager"));
// delete the state batch
ovmStateCommitmentChain.deleteStateBatch(
_postStateRootBatchHeader
......@@ -219,7 +239,8 @@ contract OVM_FraudVerifier is Lib_AddressResolver, OVM_FraudContributor, iOVM_Fr
* @return _exists Whether or not we already have a transitioner for the root.
*/
function _hasStateTransitioner(
bytes32 _preStateRoot
bytes32 _preStateRoot,
bytes32 _txHash
)
internal
view
......@@ -227,6 +248,6 @@ contract OVM_FraudVerifier is Lib_AddressResolver, OVM_FraudContributor, iOVM_Fr
bool _exists
)
{
return address(transitioners[_preStateRoot]) != address(0);
return address(getStateTransitioner(_preStateRoot, _txHash)) != address(0);
}
}
......@@ -167,7 +167,7 @@ contract OVM_StateTransitioner is Lib_AddressResolver, OVM_FraudContributor, iOV
override
public
onlyDuringPhase(TransitionPhase.PRE_EXECUTION)
contributesToFraudProof(preStateRoot)
contributesToFraudProof(preStateRoot, transactionHash)
{
// Exit quickly to avoid unnecessary work.
require(
......@@ -215,7 +215,7 @@ contract OVM_StateTransitioner is Lib_AddressResolver, OVM_FraudContributor, iOV
override
public
onlyDuringPhase(TransitionPhase.PRE_EXECUTION)
contributesToFraudProof(preStateRoot)
contributesToFraudProof(preStateRoot, transactionHash)
{
// Exit quickly to avoid unnecessary work.
require(
......@@ -251,7 +251,7 @@ contract OVM_StateTransitioner is Lib_AddressResolver, OVM_FraudContributor, iOV
override
public
onlyDuringPhase(TransitionPhase.PRE_EXECUTION)
contributesToFraudProof(preStateRoot)
contributesToFraudProof(preStateRoot, transactionHash)
{
// Exit quickly to avoid unnecessary work.
require(
......@@ -307,7 +307,7 @@ contract OVM_StateTransitioner is Lib_AddressResolver, OVM_FraudContributor, iOV
override
public
onlyDuringPhase(TransitionPhase.PRE_EXECUTION)
contributesToFraudProof(preStateRoot)
contributesToFraudProof(preStateRoot, transactionHash)
{
require(
Lib_OVMCodec.hashTransaction(_transaction) == transactionHash,
......@@ -346,7 +346,7 @@ contract OVM_StateTransitioner is Lib_AddressResolver, OVM_FraudContributor, iOV
override
public
onlyDuringPhase(TransitionPhase.POST_EXECUTION)
contributesToFraudProof(preStateRoot)
contributesToFraudProof(preStateRoot, transactionHash)
{
require(
ovmStateManager.commitAccount(_ovmContractAddress) == true,
......@@ -381,7 +381,7 @@ contract OVM_StateTransitioner is Lib_AddressResolver, OVM_FraudContributor, iOV
override
public
onlyDuringPhase(TransitionPhase.POST_EXECUTION)
contributesToFraudProof(preStateRoot)
contributesToFraudProof(preStateRoot, transactionHash)
{
require(
ovmStateManager.commitContractStorage(_ovmContractAddress, _key) == true,
......
......@@ -76,6 +76,7 @@ interface iOVM_BondManager {
function recordGasSpent(
bytes32 _preStateRoot,
bytes32 _txHash,
address _who,
uint256 _gasSpent
) external;
......
......@@ -17,7 +17,7 @@ interface iOVM_FraudVerifier {
* Public Functions: Transition Status *
***************************************/
function getStateTransitioner(bytes32 _preStateRoot) external view returns (iOVM_StateTransitioner _transitioner);
function getStateTransitioner(bytes32 _preStateRoot, bytes32 _txHash) external view returns (iOVM_StateTransitioner _transitioner);
/****************************************
......@@ -38,6 +38,7 @@ interface iOVM_FraudVerifier {
bytes32 _preStateRoot,
Lib_OVMCodec.ChainBatchHeader calldata _preStateRootBatchHeader,
Lib_OVMCodec.ChainInclusionProof calldata _preStateRootProof,
bytes32 _txHash,
bytes32 _postStateRoot,
Lib_OVMCodec.ChainBatchHeader calldata _postStateRootBatchHeader,
Lib_OVMCodec.ChainInclusionProof calldata _postStateRootProof
......
......@@ -10,6 +10,7 @@ import { iOVM_BondManager } from "../../iOVM/verification/iOVM_BondManager.sol";
contract mockOVM_BondManager is iOVM_BondManager {
function recordGasSpent(
bytes32 _preStateRoot,
bytes32 _txHash,
address _who,
uint256 _gasSpent
)
......
......@@ -12,12 +12,21 @@ contract Mock_FraudVerifier {
bondManager = _bondManager;
}
function setStateTransitioner(bytes32 preStateRoot, address addr) public {
transitioners[preStateRoot] = addr;
function setStateTransitioner(bytes32 preStateRoot, bytes32 txHash, address addr) public {
transitioners[keccak256(abi.encodePacked(preStateRoot, txHash))] = addr;
}
function getStateTransitioner(bytes32 preStateRoot) public view returns (address) {
return transitioners[preStateRoot];
function getStateTransitioner(
bytes32 _preStateRoot,
bytes32 _txHash
)
public
view
returns (
address
)
{
return transitioners[keccak256(abi.encodePacked(_preStateRoot, _txHash))];
}
function finalize(bytes32 _preStateRoot, address publisher, uint256 timestamp) public {
......
......@@ -22,6 +22,7 @@ describe('BondManager', () => {
const witnessProvider2 = wallets[5]
const sender = wallets[0].address
const txHash = ethers.constants.HashZero
const ONE_WEEK = 3600 * 24 * 7
......@@ -56,6 +57,7 @@ describe('BondManager', () => {
).deploy()
await fraudVerifier.setStateTransitioner(
preStateRoot,
txHash,
stateTransitioner.address
)
await manager.setAddress('OVM_FraudVerifier', fraudVerifier.address)
......@@ -143,13 +145,28 @@ describe('BondManager', () => {
beforeEach(async () => {
await bondManager
.connect(stateTransitioner)
.recordGasSpent(preStateRoot, witnessProvider.address, user1Gas[0])
.recordGasSpent(
preStateRoot,
txHash,
witnessProvider.address,
user1Gas[0]
)
await bondManager
.connect(stateTransitioner)
.recordGasSpent(preStateRoot, witnessProvider.address, user1Gas[1])
.recordGasSpent(
preStateRoot,
txHash,
witnessProvider.address,
user1Gas[1]
)
await bondManager
.connect(stateTransitioner)
.recordGasSpent(preStateRoot, witnessProvider2.address, user2Gas)
.recordGasSpent(
preStateRoot,
txHash,
witnessProvider2.address,
user2Gas
)
})
describe('post witnesses', () => {
......@@ -167,7 +184,12 @@ describe('BondManager', () => {
it('cannot post witnesses from non-transitioners for that state root', async () => {
await expect(
bondManager.recordGasSpent(preStateRoot, witnessProvider.address, 100)
bondManager.recordGasSpent(
preStateRoot,
txHash,
witnessProvider.address,
100
)
).to.be.revertedWith(Errors.ONLY_TRANSITIONER)
})
})
......
......@@ -100,7 +100,8 @@ describe('OVM_ProxyEOA', () => {
})
})
describe('upgrade()', () => {
const implSlotKey = '0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddead'
const implSlotKey =
'0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddead'
it(`should upgrade the proxy implementation`, async () => {
const newImpl = `0x${'81'.repeat(20)}`
const newImplBytes32 = addrToBytes32(newImpl)
......
......@@ -14,18 +14,21 @@ import {
DUMMY_OVM_TRANSACTIONS,
NON_NULL_BYTES32,
NULL_BYTES32,
hashTransaction,
} from '../../../helpers'
const DUMMY_TX_CHAIN_ELEMENTS = [...Array(10)].map(() => {
const DUMMY_TX_CHAIN_ELEMENTS = [...Array(10).keys()].map((i) => {
return {
isSequenced: false,
queueIndex: BigNumber.from(0),
timestamp: BigNumber.from(0),
timestamp: BigNumber.from(i),
blockNumber: BigNumber.from(0),
txData: NULL_BYTES32,
}
})
const DUMMY_HASH = hashTransaction(DUMMY_OVM_TRANSACTIONS[0])
const DUMMY_BATCH_PROOFS_WITH_INDEX = [
{
index: 11,
......@@ -181,7 +184,10 @@ describe('OVM_FraudVerifier', () => {
).to.not.be.reverted
expect(
await OVM_FraudVerifier.getStateTransitioner(NULL_BYTES32)
await OVM_FraudVerifier.getStateTransitioner(
NULL_BYTES32,
DUMMY_HASH
)
).to.equal(Mock__OVM_StateTransitioner.address)
})
......@@ -233,6 +239,7 @@ describe('OVM_FraudVerifier', () => {
NULL_BYTES32,
DUMMY_BATCH_HEADERS[0],
DUMMY_BATCH_PROOFS[0],
DUMMY_HASH,
NON_NULL_BYTES32,
DUMMY_BATCH_HEADERS[0],
DUMMY_BATCH_PROOFS[0]
......@@ -260,6 +267,7 @@ describe('OVM_FraudVerifier', () => {
NULL_BYTES32,
DUMMY_BATCH_HEADERS[0],
DUMMY_BATCH_PROOFS[0],
DUMMY_HASH,
NON_NULL_BYTES32,
DUMMY_BATCH_HEADERS[0],
batchProof
......@@ -287,6 +295,7 @@ describe('OVM_FraudVerifier', () => {
NULL_BYTES32,
DUMMY_BATCH_HEADERS[0],
DUMMY_BATCH_PROOFS[0],
DUMMY_HASH,
NON_NULL_BYTES32,
DUMMY_BATCH_HEADERS[0],
batchProof
......@@ -317,6 +326,7 @@ describe('OVM_FraudVerifier', () => {
NULL_BYTES32,
DUMMY_BATCH_HEADERS[0],
DUMMY_BATCH_PROOFS[0],
DUMMY_HASH,
NON_NULL_BYTES32,
DUMMY_BATCH_HEADERS[0],
batchProof
......@@ -345,6 +355,7 @@ describe('OVM_FraudVerifier', () => {
NULL_BYTES32,
DUMMY_BATCH_HEADERS[0],
DUMMY_BATCH_PROOFS[0],
DUMMY_HASH,
NON_NULL_BYTES32,
DUMMY_BATCH_HEADERS[0],
batchProof
......@@ -367,6 +378,7 @@ describe('OVM_FraudVerifier', () => {
NULL_BYTES32,
DUMMY_BATCH_HEADERS[0],
DUMMY_BATCH_PROOFS[0],
DUMMY_HASH,
NON_NULL_BYTES32,
DUMMY_BATCH_HEADERS[0],
batchProof
......@@ -387,6 +399,155 @@ describe('OVM_FraudVerifier', () => {
})
})
})
describe('multiple fraud proofs for the same pre-execution state', () => {
let state2: any
let DUMMY_HASH_2 = hashTransaction(DUMMY_OVM_TRANSACTIONS[1])
beforeEach(async () => {
state2 = smockit(
await ethers.getContractFactory('OVM_StateTransitioner')
)
Mock__OVM_StateTransitionerFactory.smocked.create.will.return.with(
state2.address
)
Mock__OVM_StateTransitioner.smocked.getPostStateRoot.will.return.with(
NULL_BYTES32
)
state2.smocked.getPostStateRoot.will.return.with(NULL_BYTES32)
})
it('creates multiple state transitioners per tx hash', async () => {
await expect(
OVM_FraudVerifier.initializeFraudVerification(
NULL_BYTES32,
DUMMY_BATCH_HEADERS[0],
DUMMY_BATCH_PROOFS[0],
DUMMY_OVM_TRANSACTIONS[1],
DUMMY_TX_CHAIN_ELEMENTS[0],
DUMMY_BATCH_HEADERS[0],
DUMMY_BATCH_PROOFS[0]
)
).to.not.be.reverted
expect(
await OVM_FraudVerifier.getStateTransitioner(
NULL_BYTES32,
DUMMY_HASH
)
).to.equal(Mock__OVM_StateTransitioner.address)
expect(
await OVM_FraudVerifier.getStateTransitioner(
NULL_BYTES32,
DUMMY_HASH_2
)
).to.equal(state2.address)
})
const batchProof = {
...DUMMY_BATCH_PROOFS[0],
index: DUMMY_BATCH_PROOFS[0].index + 1,
}
it('Case 1: allows proving fraud on the same pre-state root twice', async () => {
// finalize previous fraud
await OVM_FraudVerifier.finalizeFraudVerification(
NULL_BYTES32,
DUMMY_BATCH_HEADERS[0],
DUMMY_BATCH_PROOFS[0],
DUMMY_HASH,
NON_NULL_BYTES32,
DUMMY_BATCH_HEADERS[0],
batchProof
)
// start new fraud
await OVM_FraudVerifier.initializeFraudVerification(
NULL_BYTES32,
DUMMY_BATCH_HEADERS[0],
DUMMY_BATCH_PROOFS[0],
DUMMY_OVM_TRANSACTIONS[1],
DUMMY_TX_CHAIN_ELEMENTS[1],
DUMMY_BATCH_HEADERS[1],
DUMMY_BATCH_PROOFS[0]
)
// finalize it as well
await OVM_FraudVerifier.finalizeFraudVerification(
NULL_BYTES32,
DUMMY_BATCH_HEADERS[0],
DUMMY_BATCH_PROOFS[0],
DUMMY_HASH_2,
NON_NULL_BYTES32,
DUMMY_BATCH_HEADERS[1],
batchProof
)
// the new batch was deleted
expect(
Mock__OVM_StateCommitmentChain.smocked.deleteStateBatch.calls[0]
).to.deep.equal([
Object.values(DUMMY_BATCH_HEADERS[1]).map((value) => {
return Number.isInteger(value) ? BigNumber.from(value) : value
}),
])
})
it('Case 2: does not get blocked by the first transitioner', async () => {
// start new fraud
await OVM_FraudVerifier.initializeFraudVerification(
NULL_BYTES32,
DUMMY_BATCH_HEADERS[0],
DUMMY_BATCH_PROOFS[0],
DUMMY_OVM_TRANSACTIONS[1],
DUMMY_TX_CHAIN_ELEMENTS[1],
DUMMY_BATCH_HEADERS[1],
DUMMY_BATCH_PROOFS[0]
)
// finalize the new fraud first
await OVM_FraudVerifier.finalizeFraudVerification(
NULL_BYTES32,
DUMMY_BATCH_HEADERS[0],
DUMMY_BATCH_PROOFS[0],
DUMMY_HASH_2,
NON_NULL_BYTES32,
DUMMY_BATCH_HEADERS[1],
batchProof
)
// the new fraud's batch was deleted
expect(
Mock__OVM_StateCommitmentChain.smocked.deleteStateBatch.calls[0]
).to.deep.equal([
Object.values(DUMMY_BATCH_HEADERS[1]).map((value) => {
return Number.isInteger(value) ? BigNumber.from(value) : value
}),
])
// finalize previous fraud
await OVM_FraudVerifier.finalizeFraudVerification(
NULL_BYTES32,
DUMMY_BATCH_HEADERS[0],
DUMMY_BATCH_PROOFS[0],
DUMMY_HASH,
NON_NULL_BYTES32,
DUMMY_BATCH_HEADERS[0],
batchProof
)
// the old fraud's batch was deleted
expect(
Mock__OVM_StateCommitmentChain.smocked.deleteStateBatch.calls[0]
).to.deep.equal([
Object.values(DUMMY_BATCH_HEADERS[0]).map((value) => {
return Number.isInteger(value) ? BigNumber.from(value) : value
}),
])
})
})
})
})
})
......@@ -12,6 +12,16 @@ export const DUMMY_BATCH_HEADERS = [
[NULL_BYTES32, NON_ZERO_ADDRESS]
),
},
{
batchIndex: 1,
batchRoot: NULL_BYTES32,
batchSize: 0,
prevTotalElements: 0,
extraData: ethers.utils.defaultAbiCoder.encode(
['uint256', 'address'],
[NULL_BYTES32, NON_ZERO_ADDRESS]
),
},
]
export const DUMMY_BATCH_PROOFS = [
......@@ -19,4 +29,8 @@ export const DUMMY_BATCH_PROOFS = [
index: 0,
siblings: [NULL_BYTES32],
},
{
index: 1,
siblings: [NULL_BYTES32],
},
]
import { ZERO_ADDRESS, NULL_BYTES32 } from '../constants'
import { ethers } from 'ethers'
export const DUMMY_OVM_TRANSACTIONS = [
{
timestamp: 0,
export interface Transaction {
timestamp: number
blockNumber: number
l1QueueOrigin: number
l1TxOrigin: string
entrypoint: string
gasLimit: number
data: string
}
export const DUMMY_OVM_TRANSACTIONS: Array<Transaction> = [
...Array(10).keys(),
].map((i) => {
return {
timestamp: i,
blockNumber: 0,
l1QueueOrigin: 0,
l1TxOrigin: ZERO_ADDRESS,
entrypoint: ZERO_ADDRESS,
gasLimit: 0,
data: NULL_BYTES32,
},
]
}
})
export const hashTransaction = ({
timestamp,
blockNumber,
l1QueueOrigin,
l1TxOrigin,
entrypoint,
gasLimit,
data,
}: Transaction): string => {
return ethers.utils.solidityKeccak256(
['uint256', 'uint256', 'uint8', 'address', 'address', 'uint256', 'bytes'],
[
timestamp,
blockNumber,
l1QueueOrigin,
l1TxOrigin,
entrypoint,
gasLimit,
data,
]
)
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment