https://github.com/jaerius/rollup
블록 propse
오프체인 트랜잭션 집계,
상태 루트 계산,
compress,
L1에 제출
5-1. 블록은 아직 최종 확정 x
제출 된 후 일정 기간 동안 트랜젝션의 유효성에 이의 제기 가능,
문제 발생 시 블록의 유효성 확인하는 추가 검증
검증 기간이 지나고 이의 가 없으면 블록이 최종화
L1에 배치를 저장해놓으면 바로 상태가 바뀌는 것이 아닌가?
calldata, memory는 상태에 영향을 미치지 않는다.
memory는 코드 실행 후 데이터가 날아간다.
challenge가 들어오고, challenge가 유효했을때 어떻게 롤백을 구현할 것인가?
롤백을 어떻게 구현해야하나?
-> 블록체인은 특성상 롤백을 할 수 없는 것이 아닌가?
-> 롤백을 할때 어떤 블록을 롤백 할 것인가?
-> 그렇다면 L2에서는 꼭 블록이 있어야 하나?
-> 한 블록에 하나의 트랜잭션이 들어가는 것이 아니라면 롤백하기 너무 힘들어 질것이다
-> 그렇다면 뒤에 블록 추가할 수 밖에 없다
-> 그러면 블록 구조체의 필드에 유효성을 나타내는 필드를 넣는 것 밖에 없을 것이다.
valid = true, false
L2의 블록 valid false으로 어떻게 바꿀 것인가?
-> 블록체인에서 바꿀 수는 없는 것 아닌가?
-> 새로 붙이기
-> 그럼 이건 배치를 생성하지 않아야 할 것이다 (valid가 false이면 배치에 넣지 않는 로직 추가)
-> 상태도 바뀌지 않고, 마지막에 previousState를 currentState로 넣어주기만 하는 로직을 추가해야한다
L1은 L2에서 바꾸고 다시 트랜잭션 쏘는것 받기(수동적, 왜냐면 L1에서 컴퓨테이션을 적게 해야하기 때문)
L1에서는
// 롤백 로직: 이전 상태 루트로 복구 bytes32 previousStateRoot = _batchNumber > 0 ? batches[_batchNumber - 1].stateRoot : bytes32(0);
위의 로직으로 state를 돌린다
Transaction 서명 검사
-> transaction 실행
-> 블록에 담을 데이터 생성
-> 배치 데이터 생성
-> 블록 생성
-> 배치 제출
-> 배치 verify (구현 중..)
-> challenge 혹은 finalize
Sign전 후 나눌 필요가 있었다.
EIP 2930, EIP 1559 등의 하드 포크로 인해서 트랜잭션 구조가 점점 복잡해지기는 했지만,
가장 간단한 구조를 취함.
export interface UnsignedTransaction { // 트랜잭션 기본 구성 필드
to: string;
amount: bigint;
fee: bigint;
nonce: bigint;
data: string;
gasPrice: bigint;
gasLimit: bigint;
chainId: number;
}
export interface Transaction extends UnsignedTransaction { // 트랜잭션 검증용 필드, from은 원래 블록체인 상에 기록되지만 L2, offchain을 만드려고 하기 때문에, 따로 인터페이스를 만들어 정의해 뒀다
from: string;
hash: string;
}
export interface SignedTransaction extends Transaction { // 서명 이후 추가되는 필드
v: number;
r: string;
s: string;
}
모든 필드 -> 직렬화(RLP) -> 해시 -> 해시한 메시지에 개인키로 서명 -> 해시값 도출 -> 파싱하면 기존 필드 및 v,r,s를 얻을 수 있음
ethers.js를 이용해서 위를 두가지 방식으로 수행할 수 있습니다.
v,r,s는 ECDSA 서명으로 인하여 추가되는 필드.
verify 과정
직접 계산한 메시지 해시와 받은 트랜잭션 해시가 같은지 확인
-> 위조 여부 판단 가능
모든 필드 -> 직렬화 -> 해시 비교 tx.hash
v,r,s,messageHash를 이용해서 recoverAddress 후 트랜잭션의 송신자의 주소와 일치하는지 확인
recoverAddress , tx.from
결국 누가 메시지를 보냈는지를 증명하는 것이 서명
누가 -> 블록체인 상의 from 주소와 v,r,s로 recover가 같은지
메시지 -> 제대로 된 메시지인지 위조 여부 판단
recovery Id로 서명자가 어떤 공개키를 사용했는지 알 수 있게 한다.
EIP -155로 인해 chainId를 v값을 계산하는데 사용했다.
따라서 v값을 복호화 하기 위해서 아래 식을 따라야 했습니다.
if (v >= 37) {
v = v - (2 * txData.chainId + 8);
}
EIP - 155는 replay attack을 막기 위한 업데이트 입니다.
한 트랜잭션이 다른 체인에서도 유효할 때 replay 되는 것을 방지하기 위해 chainId를 추가했습니다.\
EIP-155 적용 전에는 v 값은 27 혹은 28이지만
적용 후에는 체인 아이디가 1인경우
v = 27 + 2 x 1 + 35 = 64가 되어야 합니다.
public async signTransaction(
tx: Transaction,
signer: SignerWithAddress,
): Promise<{ signedTx: SignedTransaction; sig: any }> {
const txData = {
nonce: ethers.utils.hexlify(tx.nonce),
gasPrice: ethers.utils.hexlify(tx.gasPrice),
gasLimit: ethers.utils.hexlify(tx.gasLimit),
to: tx.to,
value: ethers.utils.hexlify(tx.amount),
data: tx.data,
chainId: tx.chainId || 1,
};
// Hardhat 설정에서 privateKey 가져오기
const signerAddress = await signer.getAddress();
const accounts = network.config.accounts as any[];
const account = accounts.find(
(acc) => ethers.utils.computeAddress(acc.privateKey) === signerAddress,
);
if (!account) {
throw new Error('Signer not found in Hardhat config');
}
const rlpEncoded = ethers.utils.RLP.encode([
txData.nonce,
txData.gasPrice,
txData.gasLimit,
txData.to,
txData.value,
txData.data,
ethers.utils.hexlify(txData.chainId),
'0x',
'0x',
]);
// 인코딩된 데이터의 Keccak256 해시 계산
const messageHash = ethers.utils.keccak256(rlpEncoded);
// 메시지 해시에 서명
const signingKey = new ethers.utils.SigningKey(account.privateKey);
const signedData = signingKey.signDigest(messageHash);
console.log('서명된 트랜잭션 데이터:', txData);
// EIP-155에 따라 v 값 조정(replay attack을 막기 위해 네트워크마다 chainId 부여)
const v = ethers.BigNumber.from(signedData.v).add(txData.chainId * 2 + 8);
console.log('서명된 트랜잭션 데이터:', txData);
return {
signedTx: {
...tx,
v: v.toNumber(),
r: signedData.r,
s: signedData.s,
hash: messageHash,
},
sig: ethers.utils.joinSignature(signedData),
};
}
public async verifyTransaction(
tx: SignedTransaction,
sig: string,
): Promise<boolean> {
const txData = {
nonce: ethers.utils.hexlify(tx.nonce),
gasPrice: ethers.utils.hexlify(tx.gasPrice),
gasLimit: ethers.utils.hexlify(tx.gasLimit),
to: tx.to,
value: ethers.utils.hexlify(tx.amount),
data: tx.data,
chainId: tx.chainId || 1,
};
const rlpEncoded = ethers.utils.RLP.encode([
txData.nonce,
txData.gasPrice,
txData.gasLimit,
txData.to,
txData.value,
txData.data,
ethers.utils.hexlify(txData.chainId),
'0x',
'0x',
]);
const messageHash = ethers.utils.keccak256(rlpEncoded);
console.log('검증된 트랜잭션 데이터:', txData);
const recoveredAddress = ethers.utils.recoverAddress(messageHash, sig);
if (tx.hash === messageHash) {
console.log('message is not forged');
}
console.log('Recovered address:', recoveredAddress);
console.log('Original from address:', tx.from);
return recoveredAddress.toLowerCase() === tx.from.toLowerCase();
export interface BlockData {
transactions: SignedTransaction[];
stateRoot: string;
blockNumber: bigint;
previousBlockHash: string;
timestamp: number;
blockHash: string;
nonce: bigint;
batchData: string;
}
블록 구성에 꼭 필요하다고 생각하는 것들만 넣었음
transactions
transactions를 계산해서 블록이 끝났을때의 상태 루트
이전 블록과 연결하기 위한 previousBlockHash
이번 blockhash
롤업에서 트랜잭션 데이터를 compress한 batchData
stateroot는 모든 계정의 상태의 해시 값입니다.
이더리움에서는 Merkle patricia tree를 사용합니다.
merkle tree는 leaf node를 해시하고, sibling과도 해시하여 결국 트리를 나타내는 하나의 해시 값이 나올 때까지 반복적으로 해시 합니다.
patricia tree는 반복되는 접두어를 최대한 긴 길이의 부모를 갖게 하여 검색 시간을 줄이고자 하는 자료 구조입니다.
하지만 이번 stateRoot 구현에서는 merkle tree만을 사용해서 stateRoot를 계산했습니다.
public computeStateRoot(): string {
const leaves = Array.from(this.accounts.entries()).map(([address, account]) => {
const balanceBytes = ethers.utils.arrayify(ethers.BigNumber.from(account.balance.toString()).toHexString());
const nonceBytes = ethers.utils.arrayify(ethers.BigNumber.from(account.nonce.toString()).toHexString());
const encodedAccount = ethers.utils.RLP.encode([
address,
balanceBytes,
nonceBytes
]);
return ethers.utils.keccak256(encodedAccount);
});
const merkleTree = MerkleTree.buildMerkleTree(leaves);
return merkleTree[merkleTree.length - 1][0];
}
static buildMerkleTree(leaves: string[]): string[][] {
if (leaves.length === 0) return [['']];
let tree = [leaves];
while (tree[tree.length - 1].length > 1) {
const currentLevel = tree[tree.length - 1];
const nextLevel: string[] = [];
for (let i = 0; i < currentLevel.length; i += 2) {
if (i + 1 < currentLevel.length) {
nextLevel.push(
ethers.utils.keccak256(
ethers.utils.solidityPack(
['bytes32', 'bytes32'],
[currentLevel[i], currentLevel[i + 1]]
)
)
);
} else {
nextLevel.push(currentLevel[i]);
}
}
tree.push(nextLevel);
}
return tree;
}
state가 바뀔 때마다 모든 계정의 상태를 스냅샷 찍어서 db에 기록
-> revertState (challenge과정 중)에 쓰임
-> (더 나은 방법이 있을 것 같지만 다음에 구현하기로 합니다..)
nonce값을 증가시키면서 요구하는 해시 찾기
요구하는 해시는? : difficulty의 숫자에 따라 해시 값 앞의 0을 고정해 놓는다
ex ) difficulty =3 이면, 0x000..... 값이 나오면 통과
계산 방법 : nonce값과 proposer의 주소를 값을 인자로 받고, 해시할 데이터 (타임 스탬프, stateroot...)를 모두 더해서 해시한 값이 요구하는 해시의 조건을 만족시키는지 확인한다.
proposer의 주소를 받는 이유는 각 proposer마다 해시할 데이터 외에 다른 고유 값이 필요했기 때문이다.
같다면 그 때 사용된 nonce가 작거나, 동시에 여러명이 pow를 수행한다면 빨리 조건을 만족시키는 노드가 블록을 L1에 propose할 수 있다.
구현에서는 동적 난이도 조절 x, 한 번에 여러 노드가 수행하지 않고, proposer가 따로 수행한 다음 nonce값이 제일 낮은 노드가 proposer가 되도록 했다
import { Block } from "./Block";
import { sha256, toUtf8Bytes } from "ethers/lib/utils";
class POW {
private difficulty: number;
private block: Block;
constructor(difficulty: number, block: Block) {
this.difficulty = difficulty;
this.block = block;
}
public async mine(
block: Block,
proposer: string
): Promise<{ proposer: string; nonce: number; hash: string }> {
let nonce = 0;
let hash = "";
while (true) {
hash = this.calculateHash(nonce, proposer);
console.log(`Proposer: ${proposer}, Nonce: ${nonce}, Hash: ${hash}`);
if (this.isValidHash(hash)) {
break;
}
nonce++;
}
return { proposer, nonce, hash };
}
private calculateHash(nonce: number, proposer: string): string {
const transactionsData = JSON.stringify(
this.block.transactions.map((tx) => ({
from: tx.from,
to: tx.to,
amount: tx.amount.toString(),
nonce: tx.nonce.toString(),
v: tx.v,
r: tx.r,
s: tx.s,
}))
);
const dataToHash =
this.block.timestamp.toString() +
this.block.previousBlockHash +
transactionsData +
nonce.toString() +
proposer +
this.block.stateRoot +
this.block.batchData;
const hash = sha256(toUtf8Bytes(dataToHash)).toString();
return hash;
}
private isValidHash(hash: string): boolean {
const prefix = "0".repeat(this.difficulty);
return hash.substring(2).startsWith(prefix);
}
}
export default POW;
아래와 같은 난이도 조절 매커니즘을 이용하면 실제 프로덕트처럼 구현할 수 있었을 것입니다.
pow기반 난이도 조절 메커니즘 :
비트코인 :
빈도: 매 2016 블록
목표: 평균 블록 생성 시간 10분 유지
공식 : 1. 지난 2016 블록 총 생성 시간 계산
actualTime = lastBlockTime - firstBlockTime
2. 목표 시간과 실제 시간 비교
targetTime = 2016 * 10 * 60 20160분
3. 난이도 조절
newDifficulty = oldDifficulty x (actualTime / targetTime)
급격한 변동 방지를 위해 조절 폭 4배로 제한
이더리움 :
빈도 : 각 블록마다
목표 : 평균 블록 생성 시간 15초 유지
공식 : 1. 이전 블록 시간과 비교
blockTime = currentBlock.timestamp - parentBlock.timestamp
2. 난이도 조절
newDifficulty = parentDifficulty + (parent // 2048) x max(1 - (blockTime // 10), -99)
난이도가 급격하게 변하지 않도록 조절 폭 제어
parentDifficulty // 2048 새 난이도의 변동폭 결정1- blockTime // 10 블록 생성 시간이 목표 시간(10초)보다 길거나 짧은지에 따라 난이도 조정.
짧으면 이 값이 양수가 되어 난이도 증가, 10초보다 길면 음수가 되어 난이도 감소
max(1 - (blockTime // 10), -99) : 난이도가 지나치게 감소하는 것 방지, 최소값을 -99로 제한하여 한 번에 너무 많이 줄어드는 것 방지
롤업에서 중요한 부분입니다
롤업 = L1을 최대한 조금만 이용(L2에서 필요한 보안 등 무결성을 지키기 위할 때, L1사용)
그래서 L2에서 트랜잭션 처리를 모두 한 후에, 트랜잭션을 모아서(기준 필요), RLP 직렬화, 트랜잭션 별 인코딩, 배치 한꺼번에 인코딩, gzip으로 압축, 나머지 배치의 메타데이터와 결합, base64(stringify), utf8(문자열로 변환), hex(16진수로 변환)
이더리움에서는 바이너리 데이터를 처리하는데, 16진수 형식을 이용하면 바이너리 데이터를 사람이 읽을 수 있는 문자열 형태로 표현하기 때문에 일반적으로 16진수를 통해 스마트 컨트랙트로 인코딩하여 전송한다.
Hex decoded data: Uint8Array(340) [
72, 52, 115, 73, 65, 65, 65, 65, 65, 65, 65, 65,
67, 111, 50, 81, 117, 51, 85, 69, 77, 65, 103, 69,
87, 49, 113, 43, 103, 110, 75, 81, 81, 68, 87, 52,
102, 79, 115, 105, 80, 50, 99, 88, 115, 56, 68, 77,
52, 117, 102, 71, 49, 82, 48, 114, 98, 105, 120, 80,
88, 98, 76, 103, 113, 57, 109, 79, 54, 84, 74, 67,
103, 115, 121, 57, 120, 102, 105, 50, 90, 99, 83, 120,
122, 105, 109, 118, 49, 71, 81, 74, 71, 84, 52, 48,
78, 78, 111, 51,
... 240 more items
]
UTF-8 data: H4sIAAAAAAAACo2Qu3UEMAgEW1q+gnKQQDW4fOsiP2cXs8DM4ufG1R0rbixPXbLgq9mO6TJCgsy9xfi2ZcSxzimv1GQJGT40NNo3ysZqcluQWLEYUk3owMAgULAxAkSnUOr0uaS0yumNhdV0DMfB22lJDazXbd5romcUunnlJpqZXbCX9DYLXgf3pQI5x+66nJ/P8sCC26ZHGJcCbg9H1lWv7R74c/7W5Pt2cCH/nB+x3A7h7jlteA/zrbAfLkU90a2jFM2LKpPpuHJuaBaDrUBTeBW9goay6wlX39obHUXKWyUQ81xftZxn59BOx+MMO3Ol/XLgFxYAY9LuAQAA
Base64 decoded data: <Buffer 1f 8b 08 00 00 00 00 00 00 0a 8d 90 bb 75 04 30 08 04 5b 5a be 82 72 90 40 35 b8 7c eb 22 3f 67 17 b3 c0 cc e2 e7 c6 d5 1d 2b 6e 2c 4f 5d b2 e0 ab d9 ... 205 more bytes>
gunzip result (Buffer): <Buffer 30 78 66 38 66 34 62 38 37 38 66 38 37 36 39 34 37 33 37 30 36 37 64 32 35 63 35 34 37 35 31 30 39 30 31 35 36 36 64 33 35 32 66 64 35 39 38 38 63 35 ... 444 more bytes>
RLP encoded batch: 0xf8f4b878f87694737067d25c547510901566d352fd5988c5d9ea6a9492383e2c1e1e4df8a5e5ae9b58135a235094531c05020101825208011ca0a461c5d9417a6120132454e50c602b6173ae05d7fd2b7e8dee404b279b11eeeba05c606d55827c0f2b7809ec5f7f29509432c182d5ede320f1806545337f46ab6680b878f8769492383e2c1e1e4df8a5e5ae9b58135a235094531c94737067d25c547510901566d352fd5988c5d9ea6a0f030101825208011ba03fd832ddecd503839fd526c2a40a7fdb4e418d271a9921c6429b049a2025a01ea04540c6e19da2b7adfabb0d8a142b43808ee32a4629cb9e1b960c5485cef3d6f280
Serialized transaction: 0xdf02018252089492383e2c1e1e4df8a5e5ae9b58135a235094531c0580018080
Transaction hash: 0xa577f3c6c24b8a030ec96816e9b4da55b6fa7253955f14d7d2a22e01905b3495
Serialized transaction: 0xdf030182520894737067d25c547510901566d352fd5988c5d9ea6a0f80018080
Transaction hash: 0x93ac151dcba2256255e2b24e7fea974e6f0fb9b621d825e2868af31db582d221
순서가 틀렸을 것입니다. 다른 코드를 보면서 공부해보겠습니다!
하려고 하는 것
revertToState(여기서 에러) -> 검사하고 싶은 배치의 트랜잭션 실행 후 computeStateRoot를 실행 <-> 원래 스냅샷과 비교
-> 틀렸다면 merkleProof 생성 -> 챌린지(contract)-> verifyChallenge(contract) -> challenge가 유효하면 해당 트랜잭션 제외하고, pendingTransaction의 맨 앞에 넣는다 -> proposer의 잔액 삭감
-> 맞다면 -> challengePeriod 넘어서 finalize
특정 값이 트리에 있다는 증명
이 트랜잭션을 내가 줄 데이터와 계속 해시하면 루트 해시를 얻을 수 있어!
리프에서 루트로 갈 때가지 해시에 필요한 값들을 제공
ex ) a,b,c,d 가 리프로 있다고 가정하고, c에 대한 proof를 만든다고 하면
d, H(a,b)를 proof가 된다. H(H(c,d), H(a,b)) 이기 때문