Skip to main content
For the complete documentation index, see llms.txt

Index contract state with EffectStream

이 가이드로 Midnight 컨트랙트용 indexer를 구축하세요. indexer는 컨트랙트의 public state를 감시하고 자체 기록을 유지하며, 체인이 답하지 못하는 질문에 답합니다.

여기의 절차는 preview 네트워크와, 배포할 필요 없는 컨트랙트를 대상으로 하므로, 가이드 전체가 로컬 스택 없이 실행됩니다. 체인 하나를 읽는 것은 EffectStream이 하는 가장 작은 일입니다. cross-chain 경로는 Additional resources에서 안내합니다.

커뮤니티 유지보수, 공식 지원 아님

EffectStream이 이 가이드가 쓰는 @effectstream/* 패키지와 템플릿을 유지보수하며, Midnight Foundation이 아닙니다. 프로젝트가 자주 바뀌므로, 리포지토리와 이 가이드가 어긋나면 리포지토리를 따르세요. EffectStream 문제는 EffectStream tracker에, 이 페이지의 문제는 Midnight docs repository에 보고하세요.

Prerequisites​

이 가이드의 모든 절차에 공통으로 적용됩니다.

  • Bun. EffectStream은 TypeScript를 직접 실행하고 Bun을 패키지 매니저로 사용합니다. Set up Bun for Midnight development를 참고하세요.
  • 환경 레퍼런스에 나열된 호스팅형 preview indexer에 대한 네트워크 접근.
  • 검증 단계에서 사용할 curl 또는 임의의 HTTP 클라이언트.

이 절차들은 HTTPS로 호스팅형 indexer에서 읽어오므로, Docker·Midnight node·proof server·Compact toolchain이 필요하지 않습니다.

The compatibility matrix does not cover EffectStream

EffectStream 패키지는 어떤 @midnight-ntwrk/* 패키지에도 의존하지 않으므로, support matrix는 프로젝트에서 컨트랙트 쪽 절반을 관장할 뿐 이쪽 절반은 관장하지 않습니다.

Why a DApp needs an indexer​

Midnight 컨트랙트는 현재 public state만 저장하고 그 외에는 아무것도 저장하지 않습니다. 최고 점수는 누가 보유하고 있나요? 지난 한 시간 동안 무엇이 바뀌었나요? 체인은 어느 쪽도 답하지 않습니다. 질의할 수 있는 이력을 남기지 않고, 어떤 합계도 계산하지 않습니다.

indexer가 그 질문에 답합니다. state 변경을 감시하고, 각 변경에 여러분이 작성한 함수를 적용해 그 결과를 여러분이 통제하는 데이터베이스에 저장할 수 있습니다. 변경을 체인 순서대로 처리하므로, 같은 이력을 재생하면 같은 결과가 나옵니다.

Midnight은 이미 indexer를 운영하며, 이 가이드는 그것을 대체하는 대신 그것에 연결합니다. Midnight indexer는 체인이 보유한 내용을 보고하고, 여러분의 DApp은 Deploying and operating a contract의 publicDataProvider를 통해 여기에 접근합니다. EffectStream은 그 indexer에서 읽어와 그 위에 여러분만의 파생 state를 구축합니다.

ledger를 읽는 데에는 두 가지 결과가 따릅니다. 변경은 여러 블록의 확정을 거친 뒤에야 여러분 코드에 도달하므로, 체인 재구성이 일어나도 체인이 나중에 버리는 state를 여러분 데이터베이스가 붙들고 있을 일이 없습니다. 그리고 여러분 코드는 컨트랙트가 공개하는 것만 정확히 보게 되므로, What the ledger schema can read는 컨트랙트가 내리는 결정이 됩니다.

Run the indexer​

Midnight 컨트랙트를 감시하며 각 state 변경을 출력하는 노드를 만들고 실행합니다. 여기서 사용하는 컨트랙트는 이미 preview에서 동작 중인 counter이므로, 아무것도 배포하지 않습니다.

Procedure​

  1. 빈 프로젝트 디렉터리를 만들고, EffectStream 노드에 필요한 단 하나의 의존성을 담은 package.json을 추가합니다. 버전은 import 문이 아니라 여기서 고정하세요.

    package.json
    {
    "name": "midnight-indexer",
    "private": true,
    "scripts": {
    "start": "bun index.ts"
    },
    "dependencies": {
    "@effectstream/node-sdk": "0.104.0"
    }
    }
  2. Bun으로 설치합니다. auto-install 캐시에 기대는 대신 실제 install을 실행하세요. SDK는 자기 자신을 이름으로 import하는 패키지에 접근하는데, 그런 import를 해석하려면 캐시가 결코 만들지 않는 node_modules 디렉터리가 필요합니다.

    bun install
  3. package.json 옆에 index.ts를 만듭니다. runNode 호출 하나가 노드 전체를 기술하며, 이름·데이터베이스·감시할 source들·source별 transition 함수 하나를 받습니다.

    index.ts
    import { midnightContract, pglite, runNode } from "@effectstream/node-sdk";

    await runNode({
    appName: "midnight-indexer",
    database: pglite(),
    sources: {
    counter: midnightContract({
    network: "preview",
    address: "c1a9ec7c4d2566f59456fd915a0438bf4dc9b8671d4c2308d30af796c51ad20f",
    startBlockHeight: "latest",
    ledger: { round: "uint128" },
    }),
    },
    transitions: {
    counter: ({ state, blockHeight }) => {
    console.log(`round ${state.round} at block ${blockHeight}`);
    },
    },
    });

    pglite()는 노드에 임베디드 PostgreSQL 호환 데이터베이스를 제공하며, dataDir를 넘기지 않는 한 메모리에 유지됩니다. sources 키(여기서는 counter)는 여러분이 정하는 것이고, transitions가 이를 재사용해 둘이 짝을 이룹니다. network를 설정하면 호스팅형 indexer가 선택되고, address는 0x 접두사 없는 컨트랙트의 64자 hex 주소를 받습니다. startBlockHeight는 "latest"를 받으면 indexer가 위치한 블록에서 시작하고, 블록 번호를 받으면 그 높이부터 재생합니다. What the ledger schema can read에서 ledger 옵션과, 그것이 여러분이 생각하는 컨트랙트에서 동작하는지를 다룹니다.

  4. 노드를 시작합니다.

    bun start
  5. transition에서 디코딩된 state를 읽습니다. transition은 state, blockHeight, blockTimestamp, network, address를 담은 객체를 받습니다. 부호 없는 정수는 uint128이 JavaScript number에 들어가지 않으므로 십진 문자열로 도착합니다. 바이트 필드와 map 키는 0x 접두사 hex 문자열로 도착합니다. 산술이 필요하면 BigInt(state.round)로 변환하세요.

Replay history instead of waiting for a call

transition은 블록이 아니라 state 변경에 발동하므로, 조용한 컨트랙트는 아무것도 출력하지 않습니다. startBlockHeight는 블록 번호를 받으면 tip에서 시작하는 대신 그 높이부터 재생합니다. 재생은 짧게 유지하세요. 노드는 초당 몇 블록을 가져오는데, 호스팅형 indexer는 너무 많이, 너무 빨리 가져오는 클라이언트에 403 Forbidden으로 응답합니다. 컨트랙트의 가장 최근 호출이 있었던 블록은 indexer의 contractAction query로 찾아, 그보다 약간 아래에서 시작하세요. 이 가이드에서 쓰는 counter는 마지막으로 블록 377864에서 호출되었습니다.

Verification​

노드는 preview에 연결하고, HTTP 서버를 시작하며, 가져오는 각 블록을 다음과 같이 로그로 남깁니다.

[MidnightClient] Using indexer https://indexer.preview.midnight.network/api/v4/graphql (network: preview)
Paima Engine HTTP server running on http://127.0.0.1:9999
[Midnight:preview] Fetching blocks from 788014 to 788014.
10:20:19 INFO effectstream-sync-block-merge: finalized block 1 @ undefined... | {"clock":[1,1]}
10:20:21 INFO effectstream-sync-block-merge: finalized block 3 @ undefined... | {"clock":[3,3],"midnight-counter":[788014,788014]}

finalized block 번호는 EffectStream 자체 clock으로, 매 실행마다 1에서 시작하며, @ undefined는 병합된 블록 해시가 들어갈 자리를 채웁니다. Midnight 높이는 midnight-counter 쌍으로, Midnight 블록을 실어 나른 tick에 나타납니다. 여러분의 높이는 다릅니다.

round N at block M 줄은 컨트랙트의 state가 바뀔 때마다 나타납니다. 아무도 호출하지 않는 컨트랙트는 그런 줄을 만들지 않으며, 위 팁이 다루는 상황이 바로 이것입니다.

What the ledger schema can read​

ledger 옵션은 public state를 디코딩하는 방법을 선언하며, 그래서 노드에 컴파일된 컨트랙트 아티팩트가 필요 없습니다. 이 옵션은 노드가 애초에 어떤 컨트랙트를 읽을 수 있는지도 결정하므로, 여기에 맞춰 계획을 세우기 전에 여러분 컨트랙트와 대조해 확인하세요.

EffectStream은 스키마 키를 ledger 필드에 위치로 매칭합니다. 첫 번째 키가 첫 번째 필드를, 두 번째 키가 두 번째 필드를 읽고, 스키마가 소진될 때까지 이어집니다. 스키마는 다음 타입을 받습니다.

  • "uint8"부터 "uint128"까지, little-endian으로 십진 문자열로 디코딩
  • "bytes", 0x 접두사 hex 문자열로 디코딩
  • "boolean"
  • { type: "map", value: <type> }, 임의로 중첩 가능하며 키는 0x hex 문자열
  • { type: "option", value: <type> }, 값이 없으면 null로 디코딩

struct, Vector, enum은 parse 단계에서 실패합니다. 매칭이 위치 기반이므로, 읽을 수 없는 필드 하나가 그 뒤의 모든 필드를 막습니다. ledger에서 세 번째에 놓인 struct는 네 번째 필드와 다섯 번째 필드까지 잃게 만듭니다.

Some contracts need the compiler-generated decoder instead

스키마는 struct·enum·wrapped 타입을 표현할 수 없는데, Compact 컨트랙트는 이 셋을 모두 사용합니다. 컨트랙트의 ledger가 상단 가까이에 이런 것을 하나라도 두고 있다면, EffectStream의 전체 설정 API를 대신 사용하세요. 이 API는 Compact 컴파일러가 생성하는 디코더를 받아 언어가 표현할 수 있는 모든 것을 읽습니다. EffectStream documentation을 참고하세요.

컨트랙트를 여러분이 통제한다면, 스키마가 읽을 수 있도록 ledger를 설계할 수 있습니다. indexer가 필요로 하는 필드는 상단에 두고, struct와 digest는 하단에 두세요. 인덱싱이 필요한 struct는 같은 방식으로 키가 매겨진 형제 최상위 선언들로 평탄화하세요. 작은 숫자 네 개를 담은 Map<Bytes<32>, GameState>는 Map<Bytes<32>, Uint<8>> 선언 네 개가 됩니다. 그 대가로 ledger 컨테이너가 하나가 아니라 넷이 되고 업데이트마다 쓰기가 네 번 일어나므로, 값이 얼마나 자주 바뀌는지와 견주어 판단하세요.

// An indexer reads these, in this order.
export ledger status: Map<Bytes<32>, Uint<8>>;
export ledger winner: Map<Bytes<32>, Uint<8>>;
export ledger scores: Map<Bytes<32>, Map<Uint<16>, Uint<8>>>;

// Structs and digests go last.
export ledger commitments: Map<Bytes<32>, GameKeys>;

여기에 대응하는 스키마는 struct가 시작되는 지점에서 멈춥니다.

ledger: {
status: { type: "map", value: "uint8" },
winner: { type: "map", value: "uint8" },
scores: { type: "map", value: { type: "map", value: "uint8" } },
}

순서는 프라이버시가 아니라 인덱싱에 관한 결정입니다. 모든 ledger 필드는 어디에 있든 공개되는데, 이는 Security and best practices가 disclose()에 대해 짚는 요점입니다. 스키마가 반드시 읽어야 하는 필드를 먼저 두세요. identity commitment과 Merkle root는 독자가 아니라 in-circuit 검사에 쓰이므로 하단에 두는 것이 맞습니다.

Serve indexed state over HTTP​

노드가 기록하는 내용을 노출해 애플리케이션의 나머지 부분이 읽을 수 있게 합니다. runNode는 HTTP 서버를 호스팅합니다.

Prerequisites​

Procedure​

  1. index.ts를 이 버전으로 교체합니다. 값을 로그로 남기는 대신 변수에 기록하고, 그것을 제공하는 api 함수를 추가합니다.

    index.ts
    import { midnightContract, pglite, runNode } from "@effectstream/node-sdk";

    let round = "waiting for the next contract update";

    await runNode({
    appName: "midnight-indexer",
    database: pglite(),
    sources: {
    counter: midnightContract({
    network: "preview",
    address: "c1a9ec7c4d2566f59456fd915a0438bf4dc9b8671d4c2308d30af796c51ad20f",
    startBlockHeight: "latest",
    ledger: { round: "uint128" },
    }),
    },
    transitions: {
    counter: ({ state }) => {
    round = state.round;
    },
    },
    api: async (server) => {
    server.get("/round", async (_request, reply) => reply.send({ round }));
    },
    });
  2. 노드를 재시작하고 엔드포인트를 요청합니다. runNode에 apiPort를 설정하지 않는 한 서버는 9999 포트에서 대기합니다.

Choose the database mode deliberately

pglite()는 기본적으로 데이터를 메모리에 유지하므로, 실행할 때마다 체인 tip에서 시작하고 노드가 꺼져 있던 동안 일어난 일은 놓칩니다. pglite({ dataDir: "./data" })를 넘기면 데이터베이스가 영속화되고, 노드는 기록해 둔 블록 높이에서 재개합니다.

Verification​

엔드포인트는 마지막 transition이 기록한 값으로 응답하며, 아직 transition이 발동하지 않았다면 시작 값으로 응답합니다.

curl http://localhost:9999/round
{"round":"waiting for the next contract update"}

Indexer troubleshooting​

이 표는 이 가이드 경로에서 마주칠 수 있는 실패와 그 해결책을 정리합니다.

SymptomCauseFix
자기 이름으로 자신을 import하는 패키지를 해석하다 install 실패Bun의 auto-install 캐시가 node_modules 앵커를 만들지 않음bun start 전에 bun install을 실행하세요
첫 transition이 예상보다 훨씬 오래 걸림EffectStream은 각 변경을 여러 블록의 확정 동안 붙들어 둠기다리세요. 이 지연이 데이터베이스를 확정된 state와 일관되게 유지합니다
디코딩된 숫자와의 비교가 결코 일치하지 않음부호 없는 정수는 number가 아니라 십진 문자열로 디코딩됨비교 전에 BigInt(state.field)로 변환하세요
노드가 Unable to read the Midnight preview tip: 403 Forbidden로 멈춤호스팅형 indexer가 클라이언트를 거부하는 것으로, 재생이 너무 많은 블록을 너무 빨리 가져올 때 일어납니다다시 시도하기 전에 기다리고, 재생을 블록 1이 아니라 필요한 높이보다 약간 아래에서 시작하세요
scalar가 있어야 할 자리에 array가 있다는 parse 오류struct·enum·Vector가 스키마 위치에 놓임컴파일러 생성 디코더를 사용하거나, 컨트랙트를 통제한다면 ledger를 재정렬하세요. What the ledger schema can read를 참고하세요

Additional resources​