For the complete documentation index, see llms.txt
Build a cross-chain DApp with EffectStream
이 가이드로 state가 Midnight과 EVM 체인에 동시에 존재하는 DApp을 구축하세요. 동작하는 템플릿을 실행하고, 그것이 두 체인을 어떻게 잇는지 익힌 뒤, 여러분만의 필드를 처음부터 끝까지 추가합니다.
Index contract state with EffectStream는 Midnight 컨트랙트 하나를 읽는 방법을 다룹니다. 이 가이드는 EffectStream이 존재하는 이유가 되는 경우, 즉 두 체인을 상호 연관 짓고 그것들에 쓰는 경우를 다룹니다.
Prerequisites
이 가이드의 모든 절차에 공통으로 적용됩니다.
- Bun. Set up Bun for Midnight development를 참고하세요.
- Foundry.
forge가 Solidity 아티팩트를 컴파일하며, orchestrator는 시작 전에 이것이 PATH에 있는지 확인합니다. - Compact 컴파일러. orchestrator가 이것도 PATH에서 확인합니다. 템플릿은
0.31.0으로 고정하므로compact update 0.31.0을 실행하세요. - 약 8GB의 여유 메모리와, 프런트엔드 빌드용으로 상향한 Node 힙. Vite는 약 9000개 모듈을 변환하며 Node의 기본 한계를 넘으므로, 시작 전에
NODE_OPTIONS=--max-old-space-size=8192를 export하세요.
템플릿은 자체 Midnight 스택을 띄우므로, 별도로 실행 중인 노드나 proof server가 필요하지 않습니다.
How a rollup joins two chains
DApp을 여러 체인에 나누는 일은 보통 bridge를 뜻합니다. 메시지 전달 컨트랙트, relayer, 그리고 한 체인이 다른 체인을 검증하게 해 주는 light client가 그것입니다. 이 장치는 체인 간 실행을 atomic하게 만들기 위해 존재합니다.
EffectStream은 그보다 약한 요구사항을 겨냥합니다. 체인 간 atomic 실행이 아니라 두 체인에 대한 일관된 뷰가 필요할 때는, 어느 체인도 상대가 존재한다는 것을 알 필요가 없습니다. 둘은 독립적으로 ingest되고, 여러분이 작성한 state machine이 이들을 하나의 데이터베이스로 병합합니다.
템플릿은 NFT를 그 선을 따라 나눕니다. EVM 체인의 ERC-721 컨트랙트가 전송을 소유합니다. 누가 토큰을 보유하는지에는 모두가 합의해야 하기 때문입니다. Midnight의 Compact circuit은 private 입력을 받아 그로부터 도출된 속성 하나를 disclose합니다. 그 뒤의 값들은 private로 남기 때문입니다. 양쪽은 키 (contract_address, token_id)를 공유하며, 사용자가 이를 양쪽에 제공합니다. rollup은 그 키로 join합니다.
한 가지 결과가 여러분이 작성하는 코드를 좌우합니다. EffectStream은 Midnight의 public ledger를 읽으므로, disclose() 호출이 공개하는 것이 곧 여러분 state machine이 볼 수 있는 것입니다. circuit을 설계하는 것이 sync 표면을 설계하는 셈입니다.
두 번째 결과는 로그에서 드러납니다. 각 체인은 독립적으로 sync하므로, Midnight 속성이 그 토큰을 만든 EVM 전송보다 먼저 도착할 수 있습니다. midnightContractState transition은 placeholder 행을 삽입해 이를 처리하며, 두 체인이 따라잡는 동안 이벤트를 건너뛰는 모습을 보게 됩니다.
Run the template
전체 스택을 시작하고 토큰을 mint합니다.
Procedure
-
리포지토리를 clone하고 템플릿으로 이동합니다.
git clone https://github.com/effectstream/effectstream.gitcd effectstream/templates/evm-midnight-v2 -
의존성을 설치합니다.
bun install -
스택을 시작합니다. 이 명령은 Compact circuit을 컴파일하고, Solidity 컨트랙트를 컴파일·배포하며, Midnight 컨트랙트를 배포한 뒤 데이터베이스·sync 노드·batcher·프런트엔드를 시작합니다.
bun run dev -
http://localhost:10599에서 DApp을 열고, 토큰을 mint한 뒤 그 위에 속성을 설정합니다.
Verification
sync 노드는 프런트엔드와 독립적으로 실행되므로 직접 확인하세요. 병합된 뷰는 각 토큰을 그 EVM 소유자 및 Midnight 속성과 함께 반환합니다.
curl http://localhost:9999/api/erc721
sync 노드 자체 로그는 두 체인이 함께 전진하는 것을 보여주며, 이것이 rollup이 동작하는 모습입니다.
INFO effectstream-sync-block-merge: finalized block 145 @ 0xdf3de2... | {"mainNtp":[145,145],"mainEvmRPC":[794,797]}
[Midnight:undeployed] Fetching blocks from 32 to 32.
Local service endpoints
템플릿이 시작하는 모든 서비스입니다.
| Service | URL |
|---|---|
| Frontend | http://localhost:10599 |
| Sync node API | http://localhost:9999 |
| Sync node OpenAPI docs | http://localhost:9999/documentation |
| Batcher | http://localhost:3334 |
| Orchestrator API | http://localhost:4747 |
| EVM chain (main) | http://localhost:8545 |
| EVM chain (parallel) | http://localhost:8546 |
| Midnight node RPC | http://localhost:9944 |
| Midnight indexer | http://localhost:8088/api/v3/graphql |
| Midnight proof server | http://localhost:6300 |
| Database | postgres://postgres:postgres@localhost:5432/postgres |
The ingestion pipeline
네 개의 파일이 체인 이벤트를 와이어에서 여러분의 API까지 실어 나릅니다. 템플릿을 이해하려면 순서대로 읽으세요.
packages/node/config.dev.ts는 네트워크·sync 프로토콜·primitive를 선언합니다. 체인당 primitive 하나이며, 각각 stateMachinePrefix를 지정합니다.
.addPrimitive(
(syncProtocols) => syncProtocols.parallelMidnight,
(network, deployments, syncProtocol) => ({
name: "MidnightContractState",
type: PrimitiveTypeMidnightGeneric,
startBlockHeight: 1,
contractAddress: readMidnightContract("contract-round-value", {
networkId: midnightNetworkConfig.id,
}).contractAddress,
stateMachinePrefix: "midnightContractState",
contract: { ledger: CounterContract.ledger },
networkId: midnightNetworkConfig.id,
}),
)
contract: { ledger: CounterContract.ledger }는 primitive에 compact compile이 생성하는 reader를 넘깁니다. 그 reader는 Compact이 표현할 수 있는 모든 것을 디코딩하며, 이 점이 Index contract state with EffectStream의 선언적 스키마와의 차이입니다.
packages/node/grammar.ts는 각 prefix를 parser에 매핑합니다. 두 prefix 모두 builtin grammar를 사용하므로, 템플릿은 자체 grammar를 작성하지 않습니다.
packages/node/state-machine.ts는 prefix별 state transition 함수 하나씩을 담습니다. 각 함수는 파싱된 payload를 받아 데이터베이스에 씁니다.
packages/node/api.ts는 병합된 결과를 HTTP로 제공합니다.
Add a field end to end
새 값 하나를 Compact circuit에서 API까지 실어 나릅니다. 이 경로를 대신 생성해 주는 것은 없으므로, 필드 하나가 circuit·데이터베이스 스키마·쿼리·state transition·라우트에 두루 손을 대게 됩니다. 그 비용을 미리 아는 것이 이 패턴을 선택하는 일의 일부입니다.
Procedure
-
packages/contracts-midnight/contract-round-value/src/counter.compact을 엽니다. ledger 필드를 추가하고,increment에서 그에 대응하는 인자를 받아disclose()를 통해 할당합니다. 추가된 부분에는 표시가 되어 있습니다.pragma language_version >= 0.17;import CompactStandardLibrary;export ledger round: Counter;export ledger contract_address: Bytes<64>;export ledger token_id: Bytes<64>;export ledger property_name: Bytes<32>;export ledger value: Bytes<32>;export ledger rarity: Bytes<32>; // addedexport circuit increment(contract_address_: Bytes<64>,token_id_: Bytes<64>,property_name_: Bytes<32>,value_: Bytes<32>,rarity_: Bytes<32>, // added): [] {round.increment(1);contract_address = disclose(contract_address_);token_id = disclose(token_id_);property_name = disclose(property_name_);value = disclose(value_);rarity = disclose(rarity_); // added}인자를 추가하면 circuit의 시그니처가 바뀌므로, 모든 호출자가 새 값을 필요로 합니다. 프런트엔드는 이 circuit을
packages/frontend/client/src/increment.ts에서 호출하고, batcher는packages/batcher/midnight-balancing.ts에서 호출합니다. -
생성된 ledger reader가 새 필드를 포함하도록 circuit을 다시 컴파일합니다.
bun run build:midnight -
packages/database/migrations/의 마이그레이션에 컬럼을 추가합니다. -
packages/database/sql/sm_example.sql의 대응하는 쿼리에 컬럼을 추가한 뒤, 타입이 지정된 쿼리를 다시 생성합니다.bun run build:pgtypes -
packages/node/state-machine.ts의midnightContractStatetransition에서 필드를 디코딩합니다.Bytes<32>ledger 필드는 고정 폭 바이트로 도착하므로,decodeField가 이를 다시 문자열로 되돌립니다. 기존 줄들 옆에 한 줄을 추가하세요.const contract_address = decodeField(payload.contract_address);const token_id = decodeField(payload.token_id);const property_name = decodeField(payload.property_name);const value = decodeField(payload.value);const rarity = decodeField(payload.rarity); // added그런 다음 같은 transition 아래쪽의
insertEvmMidnightProperty호출에, 이미 거기서 쓰이는 필드들과 나란히rarity를 넘깁니다. -
packages/node/api.ts의GET /api/erc721에서 컬럼을 반환합니다. -
스택을 재시작하고 프런트엔드에서 속성을 설정합니다.
Verification
엔드포인트는 새 필드를 기존 필드들과 함께 반환합니다.
curl http://localhost:9999/api/erc721
Additional resources
- Index contract state with EffectStream: 읽기 경로, Midnight 컨트랙트 하나, 로컬 스택 없음.
- EffectStream documentation: 전체 API, batcher, 그리고 다른 cross-chain 템플릿들.
- Security and best practices:
disclose()가 무엇을 공개하는지, 곧 sync 노드가 읽는 것.