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

Create and transfer an unshielded token

Midnight에서는 토큰에 어떤 프라이버시 모델을 선택하느냐에 따라 스마트 컨트랙트를 작성하는 방식이 달라집니다. 이 튜토리얼에서는 Compact로 unshielded 토큰, 즉 잔액이 온체인에 남아 누구나 읽을 수 있는 투명한 토큰을 만들고 전송해 봅니다.

이 문서는 2부작의 첫 번째입니다. 여기서 투명한 토큰을 만들고 나면, 자매 문서인 shielded 토큰 만들고 전송하기에서 잔액이 감춰지고 그에 맞춰 설계가 달라지는 프라이버시 보존 버전을 만듭니다. 순서대로 읽는 것이 Midnight에서 프라이버시가 토큰 설계를 어떻게 바꾸는지 이해하는 가장 빠른 길입니다.

이 튜토리얼은 Compact 기본기를 알고 스마트 컨트랙트를 배포해 본 개발자를 대상으로 합니다. Compact가 처음이라면 Compact 언어 레퍼런스를 먼저 읽거나 Midnight Academy를 살펴보세요.

How tokens work on Midnight

Midnight에는 프로토콜이 직접 발행하는 네이티브 토큰 NIGHT 하나가 있습니다. 테스트 네트워크에서는 faucet에서 tNIGHT으로 받거나 로컬 네트워크의 genesis wallet에 미리 충전된 상태로 받습니다. 메인넷에서는 Cardano에서 단방향 cNIGHT → mNIGHT 브리지를 통해 Midnight으로 넘어옵니다. 전체 그림은 토큰 개요를 참고하세요. NIGHT 외의 나머지 토큰은 모두 스마트 컨트랙트가 만들며, 이 문서에서 하는 작업이 바로 그것입니다.

여러분이 만들 토큰이 전체 지형에서 어디에 놓이는지 보면 이해가 빠릅니다. 토큰은 두 축을 따라 네 종류로 나뉩니다. 토큰이 어디서 발행되는가, 그리고 투명한가 프라이버시를 보존하는가입니다.

Unshielded (투명)Shielded (프라이버시 보존)
네이티브프로토콜 발행, 예를 들어 NIGHT프로토콜 발행, 금액 비공개
컨트랙트스마트 컨트랙트가 mint, 잔액은 온체인스마트 컨트랙트가 mint, 잔액 비공개

2부작 튜토리얼은 이 표의 컨트랙트 행 두 개를 다룹니다. 이 문서가 unshielded 열이고, shielded 튜토리얼이 shielded 열입니다. 네이티브 토큰은 프로토콜이 발행하며, 아래에서 자금 조달에 쓰는 NIGHT이 여기에 해당합니다. 세 번째 갈래인 hybrid 토큰은 네이티브 코인을 스마트 컨트랙트의 통제 아래 두는 방식인데, 아직 설계 중인 프로토콜 변경에 의존하므로 이 문서의 범위 밖입니다.

토큰 표준

Midnight의 토큰 표준은 아직 정리되는 중입니다. 이 튜토리얼의 스마트 컨트랙트는 초안 단계의 표준 대신 지금 동작하는 프로토콜 primitive를 직접 사용합니다. 현재 논의 방향은 토큰 표준 논의 스레드를 참고하세요.

이 primitive들은 출발점이 같습니다. mint하거나 전송하거나 잔액을 확인하려면 먼저 다루려는 토큰을 식별할 수단이 필요합니다. 그 식별자가 color입니다.

Token color

모든 토큰에는 토큰 타입을 식별하는 Bytes<32> 값인 color가 있습니다. 컨트랙트 토큰의 color는 스마트 컨트랙트와 여러분이 정하는 고정 라벨인 domain separator에서 나옵니다. mint primitive가 color를 반환하며, 이후 그 토큰에 대한 모든 연산에 이 color를 씁니다. color가 스마트 컨트랙트에 의존하기 때문에, 같은 라벨을 쓰는 두 스마트 컨트랙트도 서로 다른 토큰을 만듭니다.

color는 어떤 토큰을 다루는지를 정할 뿐, 누가 얼마를 갖고 있는지는 정하지 않습니다. 컨트랙트가 그 보유량을 어떻게 추적할지, 애초에 추적이 필요한지는 프라이버시 모델이 결정합니다.

How privacy changes what you store

이 문서의 핵심 개념입니다. 프라이버시 모델은 마지막에 켜고 끄는 플래그가 아니라, 스마트 컨트랙트가 무엇을 저장해야 하는지를 바꿉니다.

unshielded 쪽에서는 체인이 주소별로 각 토큰 color의 잔액을 기록합니다. 스마트 컨트랙트는 unshieldedBalance(color)로 자신의 잔액을 읽습니다. 잔액과 총 공급량이 공개되므로 balanceOftotalSupply에 해당하는 투명한 구현이 성립합니다.

shielded 쪽에서는 체인이 잔액을 감춥니다. 금액이 아니라 commitment와 nullifier를 기록합니다. 읽을 수 있는 공개 잔액 자체가 없습니다. 누가 무엇을 보유하는지 알아야 하는 스마트 컨트랙트는 이를 직접 추적해야 하고, 그 값을 shielded coin으로 관리해야 합니다. 그래서 shielded smart contract는 잔액을 전혀 추적하지 않고 하나의 트랜잭션 안에서 coin을 mint해 호출자에게 전달하는 반면, unshielded 쪽은 체인의 공개 잔액에 기댑니다.

Unshielded 토큰Shielded 토큰
잔액 저장온체인, 주소별비공개, 스마트 컨트랙트가 추적
잔액 읽기unshieldedBalance(color)공개 조회 불가, 스마트 컨트랙트 상태만
컨트랙트가 보유하는 값잔액변경 불가능한 coin

shielded coin은 변경할 수 없습니다. 값을 제자리에서 바꿀 수 없습니다. 금액을 바꾸려면 스마트 컨트랙트가 현재 coin을 소비하고 새 금액의 coin을 새로 만들어야 합니다. nonce가 각 coin을 고유하게 유지합니다. 이 소비 후 재생성 패턴은 shielded 튜토리얼의 Go further 섹션에서 확인할 수 있습니다.

토큰이 잔액으로 있든 coin으로 있든, 결국 어느 시점에는 한 보유자에게서 다른 보유자에게로 이동합니다. 그 이동에는 두 가지 방식이 있습니다.

Two ways to transfer

토큰은 두 방식 중 하나로 움직이며, 그 차이는 shielded 토큰에서 특히 중요합니다.

  1. 지갑 대 지갑: 보유자가 지갑에 토큰을 갖고 있다면 지갑이 직접 전송합니다. 스마트 컨트랙트는 관여하지 않습니다.
  2. 컨트랙트 경유: 스마트 컨트랙트가 자신이 보유한 토큰을 옮깁니다. 투명한 토큰은 sendUnshielded로, shielded 토큰은 shielded coin을 보내는 방식으로 처리합니다.

shielded 토큰에서는 이 점을 분명히 짚어야 합니다. 스마트 컨트랙트는 coin을 직접 보관하는 동안에만 shielded 값에 규칙을 강제할 수 있습니다. 사용자가 coin을 자기 지갑에 갖는 순간부터는 스마트 컨트랙트 바깥에서 자기 키로 사용합니다. shielded 토큰을 통제해야 하는 스마트 컨트랙트가 coin을 볼트에 직접 보관하는 이유가 여기에 있습니다.

Set up your project

이 섹션에서는 toolchain을 설치하고, 로컬 Midnight 네트워크를 띄우고, 2부작 전체에서 사용할 프로젝트를 구성합니다. 환경이 이미 준비되어 있다면 Build an unshielded token으로 건너뛰세요.

Prerequisites

시작하기 전에 다음을 확인하세요.

  • Compact toolchain 설치. toolchain 설치하기를 따르세요. 이 튜토리얼은 컴파일러 0.31.1 버전을 기준으로 합니다. 더 낮은 버전이라면 compact update를 실행하세요. 0.31.0에서는 특정 캐스트에 대해 생성된 ZK circuit에서 range check 제약이 빠질 수 있었고, 0.31.1에서 수정되었습니다.
  • Docker와 Docker Compose v2 (로컬 네트워크와 proof server 실행용).
  • Node.js 22 이상.

Start the local network

이 튜토리얼은 로컬 Midnight 네트워크에서 실행합니다. 로컬 네트워크에는 NIGHT이 충전되어 있고 수수료 등록까지 끝난 genesis wallet이 있어서, faucet 없이 몇 초 만에 배포할 수 있습니다. midnight-local-dev 도구가 노드, indexer, proof server를 Docker로 띄우고 이 지갑을 초기화해 줍니다.

별도 터미널에서 clone하고 실행하세요.

git clone https://github.com/midnightntwrk/midnight-local-dev.git
cd midnight-local-dev
npm install
npm start

프롬프트가 뜨면 이미지를 내려받고 네트워크를 시작하는 쪽을 선택하세요. 도구가 컨테이너 세 개를 올리고 genesis master wallet(seed 0x00...001)에 NIGHT과 등록된 DUST를 채웁니다. 튜토리얼이 끝날 때까지 이 터미널은 그대로 두세요.

네트워크가 노출하는 엔드포인트는 다음과 같습니다.

  • 노드: http://localhost:9944
  • Indexer: http://localhost:8088
  • Proof server: http://localhost:6300

midnight-local-dev가 proof server까지 실행하므로 따로 띄울 필요가 없습니다.

Preprod에서 실행하고 싶다면

같은 코드를 로컬 대신 공개 Preprod 네트워크에서 그대로 실행할 수 있습니다. 이 경우 midnight-local-dev는 실행하지 않습니다. 대신 별도 터미널에서 독립 proof server를 띄우세요.

docker run -p 6300:6300 midnightntwrk/proof-server:8.0.3 midnight-proof-server -v

그리고 Preprod faucet에서 직접 지갑에 자금을 채웁니다(Run it에서 다룹니다). 흐름은 같고, 로컬보다 몇 분 더 걸릴 뿐입니다.

Create the project

프로젝트를 만들고 Midnight 의존성을 설치합니다. 아래 버전 조합은 동작이 검증된 세트입니다. npm이 최신 버전을 고르게 두지 말고 적힌 그대로 설치하세요.

mkdir midnight-tokens && cd midnight-tokens
npm init -y
npm pkg set type=module

컨트랙트 런타임, 컨트랙트 프레임워크, provider를 설치합니다.

npm install \
@midnight-ntwrk/midnight-js-contracts@4.1.1 \
@midnight-ntwrk/midnight-js-indexer-public-data-provider@4.1.1 \
@midnight-ntwrk/midnight-js-http-client-proof-provider@4.1.1 \
@midnight-ntwrk/midnight-js-node-zk-config-provider@4.1.1 \
@midnight-ntwrk/midnight-js-types@4.1.1 \
@midnight-ntwrk/midnight-js-utils@4.1.1

아래 단계는 기본적으로 로컬 네트워크를 사용합니다. Preprod에서 실행하려면 스크립트 실행 시 MIDNIGHT_NETWORK=preprod를 설정하세요. 나머지는 그대로입니다.

Build an unshielded token

컨트랙트는 파일 하나에 블록 단위로 쌓아 올립니다. 먼저 디렉터리와 빈 파일을 만드세요.

mkdir -p contracts
touch contracts/unshielded-token.compact

에디터에서 contracts/unshielded-token.compact를 열고 아래 블록을 순서대로 추가하세요. 마지막 블록까지 넣으면 컨트랙트 전체가 완성됩니다.

Set the language version and import the library

파일은 언어 버전과 standard library로 시작합니다.

pragma language_version 0.23;

import CompactStandardLibrary;

여기서 wallet-sdk 버전이 중요합니다. 1.0.0으로 고정하세요. 마이너 버전을 올리면 컨트랙트 배포 시 네트워크가 거부하는 ledger 버전이 딸려 옵니다.

import CompactStandardLibrary는 아래에서 사용할 mintUnshieldedToken, sendUnshielded, unshieldedBalanceGte 같은 토큰 primitive를 가져옵니다.

Declare the on-chain state

컨트랙트의 public state를 추가합니다.

export ledger token_color: Bytes<32>;
export ledger initialized: Boolean;
export ledger owner: Bytes<32>;

ledger 필드는 온체인에 존재합니다. token_color는 토큰의 정체성을 담으며 처음 mint할 때 설정됩니다. initialized는 mint가 이미 이뤄졌는지 기록하고, 다른 circuit은 동작 전에 이 값을 확인합니다. owner는 배포자의 secret에서 파생한 키 해시를 담으며, mint와 transfer circuit이 이 값을 기준으로 권한을 검사합니다. 온체인 ledger 상태는 export 여부와 무관하게 공개되며 누구나 읽을 수 있습니다. export가 더해 주는 것은 컴파일 산출물에 생성되는 JavaScript 바인딩으로, 이를 통해 배포 스크립트가 이 필드들을 이름으로 읽을 수 있습니다.

Initialize at deployment

시작 상태를 설정할 constructor를 추가합니다.

constructor(ownerSecret: Bytes<32>) {
// 배포자에게서 파생한 키가 mint와 transfer 권한을 가진 owner가 됩니다.
owner = disclose(deriveOwnerKey(ownerSecret));
initialized = false;
}

// domain separator를 붙인 해시로 secret에서 공개 키를 파생합니다. 이 값은 secret을
// 가진 호출자만 재현할 수 있으므로 권한 검사 기준으로 안전합니다.
export circuit deriveOwnerKey(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "tutorial:unshielded:owner:v1"), sk]);
}

constructor는 인자를 받을 수 있고, 여기서는 owner의 secret을 받습니다. 배포 스크립트가 배포 시점에 이 값을 넘깁니다. Midnight에서 constructor 인자와 circuit 파라미터는 witness 데이터입니다. 오프체인에 머무르기 때문에, 여기서 파생한 값을 공개 ledger 상태에 쓰려면 disclose()가 필요합니다. 이 constructor는 secret의 해시만 공개해 저장하므로 secret 자체는 온체인에 절대 나타나지 않으며, initialized는 false로 시작합니다. (권한 검사에 ownPublicKey()를 일부러 쓰지 않았습니다. 이 함수는 prover가 주장한 값을 반환할 뿐 서명자와 결속되지 않아, 누구든 이 검사를 우회할 수 있습니다.)

Create the token

mint circuit을 추가합니다. 토큰이 실제로 생겨나는 지점입니다.

export circuit mint(sk: Bytes<32>, amount: Uint<64>): [] {
// 배포 secret을 가진 owner만 mint할 수 있습니다.
assert(owner == deriveOwnerKey(sk), "not authorized to mint");
const domain = pad(32, "tutorial:unshielded:token");
const color = mintUnshieldedToken(
domain,
disclose(amount),
left<ContractAddress, UserAddress>(kernel.self())
);
token_color = color;
initialized = true;
}

위 코드에서 각 부분은 다음과 같습니다.

  • assert가 권한 게이트입니다. sk로 owner 키를 다시 계산해 저장된 owner와 일치하는지 확인하므로 배포자만 mint할 수 있습니다. sk는 평범한 circuit 파라미터라 비공개로 유지되고, 비교는 증명 안에서 일어나므로 이 assert에는 disclose()가 필요 없습니다.
  • pad(32, "tutorial:unshielded:token")은 고정된 Bytes<32> 라벨로 domain separator를 만듭니다. 이 값과 컨트랙트 주소가 토큰 color를 결정합니다.
  • mintUnshieldedToken(...)amount만큼 mint하고 토큰의 color를 반환합니다. 수신자는 컨트랙트 자신의 주소인 kernel.self()이며, 주소 타입의 컨트랙트 쪽임을 표시하기 위해 left<ContractAddress, UserAddress>(...)로 감쌉니다. 따라서 새로 발행된 물량은 컨트랙트가 갖게 됩니다.
  • 마지막 두 줄이 color를 기록하고 initialized를 true로 바꿉니다. 두 가지를 짚어 둘 만합니다. mint 금액은 Uint<64>입니다(프로토콜이 한 번의 mint를 이 폭으로 제한합니다). 반면 전송은 더 넓은 Uint<128>을 씁니다. 그리고 disclose(...)는 온체인에 공개해도 되는 값임을 표시하는데, mint는 공개 동작이므로 금액을 공개합니다.

kernel.self()로 mint하면 컨트랙트에 바로 적립되므로, 밖으로 전송하기 전까지는 새 물량을 컨트랙트가 보유합니다.

sk 게이트가 없다면 이 mint는 누구나 호출할 수 있고 공급량을 마음대로 부풀릴 수 있습니다. 실제 토큰에는 권한 검사가 반드시 필요하므로, 이 튜토리얼은 이를 확장 기능이 아니라 토큰의 일부로 다룹니다. shielded 튜토리얼은 같은 게이트를 다른 방식으로 구현합니다. private 상태에 기반한 witness 함수로 secret을 공급하므로, 2부작을 함께 보면 circuit 권한 검사의 두 가지 방식을 모두 볼 수 있습니다.

Transfer the token

컨트랙트에서 지갑으로 토큰을 옮기는 transfer circuit을 추가합니다.

export circuit transfer(sk: Bytes<32>, recipient: UserAddress, amount: Uint<128>): [] {
// owner만 컨트랙트 밖으로 토큰을 옮길 수 있습니다.
assert(owner == deriveOwnerKey(sk), "not authorized to transfer");
assert(initialized, "token not minted yet");
assert(unshieldedBalanceGte(token_color, disclose(amount)), "insufficient balance");
sendUnshielded(
token_color,
disclose(amount),
right<ContractAddress, UserAddress>(disclose(recipient))
);
}

assert 세 줄이 안전장치입니다. circuit의 일부로 실행되며, 조건 하나라도 거짓이면 assertion이 실패해 호출 전체가 거부되고 온체인에는 아무것도 반영되지 않습니다. 첫 번째는 mint와 동일한 권한 게이트입니다. 이것이 없으면 누구나 transfer를 호출해 컨트랙트의 전체 공급량을 원하는 주소로 빼낼 수 있습니다. 두 번째는 아직 토큰을 mint하지 않았다면 호출을 실패시킵니다. 세 번째는 unshieldedBalanceGte로 컨트랙트 잔액이 부족한지 확인해 실패시킵니다.

그다음 sendUnshielded(...)가 해당 금액을 수신자에게 보냅니다. 이번에는 수신자를 right<ContractAddress, UserAddress>(...)로 감싸 주소 타입의 UserAddress 쪽임을 표시합니다. 토큰이 컨트랙트가 아니라 지갑으로 가기 때문입니다.

Read the balance

unshielded 잔액을 읽는 데는 circuit이 필요 없습니다. 토큰이 투명하므로 잔액이 온체인에 공개되어 있고, Midnight.js가 지갑 상태에서 바로 읽어 옵니다. getUnshieldedBalances가 토큰 color에서 금액으로의 맵을 반환합니다(내부적으로는 unshielded.balances입니다). 다음 섹션의 배포 스크립트도 정확히 이 방식을 써서, 컨트랙트를 호출하지 않고 after.unshielded.balances[color]를 읽어 전송 결과를 확인합니다.

바로 이 지점이 shielded 토큰과 갈리는 부분입니다. shielded 토큰에는 읽을 수 있는 공개 잔액이 없고, 그래서 shielded 튜토리얼의 컨트랙트는 값을 직접 추적해야 합니다. 2부작이 짚으려는 대비가 바로 이것입니다.

여기까지가 컨트랙트 전부입니다. 이제 contracts/unshielded-token.compact에 위 순서대로 완성된 컨트랙트가 들어 있습니다.

Compile the contract

파일을 컴파일하고 결과물을 src/managed로 내보냅니다.

compact compile contracts/unshielded-token.compact src/managed/unshielded-token

컴파일러는 컨트랙트의 TypeScript 인터페이스, prover 키와 verifier 키, proof server가 사용하는 ZK 산출물을 모두 src/managed/unshielded-token 아래에 생성합니다. 다음 섹션의 배포 스크립트는 src/managed/unshielded-token/contract/index.js에서 생성된 컨트랙트를 import해 배포용으로 감싸고, ledger로 이 토큰의 public state token_color, initialized, owner를 디코딩합니다. 컴파일이 끝났으니 이제 배포하고 첫 토큰을 mint할 차례입니다.

Deploy and test your token

이제 실제 네트워크에서 컨트랙트를 실행합니다. 지갑 패키지를 추가하고, 코드를 네트워크에 연결하는 작은 지원 파일 네 개를 만든 다음, 토큰을 만들고 다시 읽고 지갑으로 전송하는 배포 스크립트를 작성합니다.

아래 단계는 기본적으로 로컬 네트워크를 사용합니다. Preprod에서 실행하려면 스크립트 실행 시 MIDNIGHT_NETWORK=preprod를 설정하세요. 나머지는 그대로입니다.

info

이 작업을 하는 동안 로컬 네트워크(Preprod라면 독립 proof server)를 계속 실행해 두세요. 배포와 모든 circuit 호출은 proof server를 통해 영지식 증명을 생성합니다.

Install the wallet packages

wallet SDK, 하네스가 import하는 보조 라이브러리, 스크립트 실행용 tsx를 추가합니다. 앞서와 마찬가지로 버전을 정확히 고정하세요.

npm install \
@midnight-ntwrk/wallet-sdk@1.0.0 \
@midnight-ntwrk/testkit-js@4.1.1 \
@midnight-ntwrk/midnight-js-protocol@4.1.1 \
@midnight-ntwrk/midnight-js-network-id@4.1.1 \
@midnight-ntwrk/midnight-js-level-private-state-provider@4.1.1 \
rxjs@7.8.2 pino@10.3.1 pino-pretty@13.1.3 ws@8.21.0
npm install --save-dev tsx@4.22.4

여기서 wallet-sdk 버전이 중요합니다. 1.0.0으로 고정하세요. 마이너 버전을 올리면 컨트랙트 배포 시 네트워크가 거부하는 ledger 버전이 딸려 오기 때문입니다.

참고로, 이 시점의 package.json 의존성은 다음과 같아야 합니다.

{
"dependencies": {
"@midnight-ntwrk/midnight-js-contracts": "4.1.1",
"@midnight-ntwrk/midnight-js-http-client-proof-provider": "4.1.1",
"@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.1.1",
"@midnight-ntwrk/midnight-js-level-private-state-provider": "4.1.1",
"@midnight-ntwrk/midnight-js-network-id": "4.1.1",
"@midnight-ntwrk/midnight-js-node-zk-config-provider": "4.1.1",
"@midnight-ntwrk/midnight-js-protocol": "4.1.1",
"@midnight-ntwrk/midnight-js-types": "4.1.1",
"@midnight-ntwrk/midnight-js-utils": "4.1.1",
"@midnight-ntwrk/testkit-js": "4.1.1",
"@midnight-ntwrk/wallet-sdk": "1.0.0",
"pino": "10.3.1",
"pino-pretty": "13.1.3",
"rxjs": "7.8.2",
"ws": "8.21.0"
},
"devDependencies": {
"tsx": "4.22.4"
}
}

Configure the networks

src/config.ts를 만드세요.

touch src/config.ts

이 파일은 네트워크별 엔드포인트를 담고 MIDNIGHT_NETWORK을 읽어 하나를 고릅니다.

export type NetworkConfig = {
networkId: string;
indexer: string;
indexerWS: string;
node: string;
nodeWS: string;
proofServer: string;
faucet: string;
};

export const LOCAL_CONFIG: NetworkConfig = {
networkId: 'undeployed',
indexer: 'http://127.0.0.1:8088/api/v4/graphql',
indexerWS: 'ws://127.0.0.1:8088/api/v4/graphql/ws',
node: 'http://127.0.0.1:9944',
nodeWS: 'ws://127.0.0.1:9944',
proofServer: 'http://127.0.0.1:6300',
faucet: '',
};

export const PREVIEW_CONFIG: NetworkConfig = {
networkId: 'preview',
indexer: 'https://indexer.preview.midnight.network/api/v4/graphql',
indexerWS: 'wss://indexer.preview.midnight.network/api/v4/graphql/ws',
node: 'https://rpc.preview.midnight.network',
nodeWS: 'wss://rpc.preview.midnight.network',
proofServer: process.env['MIDNIGHT_PROOF_SERVER'] ?? 'http://127.0.0.1:6300',
// wallet 자금은 https://midnight-tmnight-preview.nethermind.dev/ 에서 직접 채웁니다.
// 이 필드는 머신용 API 엔드포인트를 위한 것이므로 비워 둡니다.
faucet: '',
};

export const PREPROD_CONFIG: NetworkConfig = {
networkId: 'preprod',
indexer: 'https://indexer.preprod.midnight.network/api/v4/graphql',
indexerWS: 'wss://indexer.preprod.midnight.network/api/v4/graphql/ws',
node: 'https://rpc.preprod.midnight.network',
nodeWS: 'wss://rpc.preprod.midnight.network',
proofServer: process.env['MIDNIGHT_PROOF_SERVER'] ?? 'http://127.0.0.1:6300',
// wallet 자금은 https://midnight-tmnight-preprod.nethermind.dev/ 에서 직접 채웁니다.
// 이 필드는 머신용 API 엔드포인트를 위한 것이므로 비워 둡니다.
faucet: '',
};

export function getConfig(): NetworkConfig {
const network = process.env['MIDNIGHT_NETWORK'] ?? 'local';
if (network === 'local') return LOCAL_CONFIG;
if (network === 'preview') return PREVIEW_CONFIG;
if (network === 'preprod') return PREPROD_CONFIG;
throw new Error(
`Unknown network: ${network}. Supported: 'local', 'preview', 'preprod'.`,
);
}

Add the wallet

src/wallet.ts를 만드세요.

touch src/wallet.ts

지원 파일 중 보일러플레이트가 가장 많은 파일입니다. wallet-sdk 파사드 위에 지갑을 구성하고, 컨트랙트 프레임워크가 요구하는 WalletProviderMidnightProvider 인터페이스에 맞춰 어댑팅합니다. 그대로 복사해 쓰세요. shielded 튜토리얼에서도 수정 없이 재사용합니다.

import {
type CoinPublicKey,
DustSecretKey,
type EncPublicKey,
type FinalizedTransaction,
LedgerParameters,
ZswapSecretKeys,
} from '@midnight-ntwrk/midnight-js-protocol/ledger';
import {
type MidnightProvider,
type UnboundTransaction,
type WalletProvider,
} from '@midnight-ntwrk/midnight-js-types';
import { ttlOneHour } from '@midnight-ntwrk/midnight-js-utils';
import {
type WalletFacade,
type FacadeState,
type UnshieldedKeystore,
} from '@midnight-ntwrk/wallet-sdk';
import {
type DustWalletOptions,
type EnvironmentConfiguration,
FluentWalletBuilder,
} from '@midnight-ntwrk/testkit-js';
import * as Rx from 'rxjs';
import type { Logger } from 'pino';

export type WalletSecret =
| { kind: 'seed'; value: string }
| { kind: 'mnemonic'; value: string };

export class MidnightWalletProvider implements MidnightProvider, WalletProvider {
readonly wallet: WalletFacade;
readonly unshieldedKeystore: UnshieldedKeystore;

private constructor(
private readonly logger: Logger,
wallet: WalletFacade,
private readonly zswapSecretKeys: ZswapSecretKeys,
private readonly dustSecretKey: DustSecretKey,
unshieldedKeystore: UnshieldedKeystore,
) {
this.wallet = wallet;
this.unshieldedKeystore = unshieldedKeystore;
}

getCoinPublicKey(): CoinPublicKey {
return this.zswapSecretKeys.coinPublicKey;
}

getEncryptionPublicKey(): EncPublicKey {
return this.zswapSecretKeys.encryptionPublicKey;
}

async balanceTx(
tx: UnboundTransaction,
ttl: Date = ttlOneHour(),
): Promise<FinalizedTransaction> {
const recipe = await this.wallet.balanceUnboundTransaction(
tx,
{
shieldedSecretKeys: this.zswapSecretKeys,
dustSecretKey: this.dustSecretKey,
},
{ ttl },
);
return await this.wallet.finalizeRecipe(recipe);
}

submitTx(tx: FinalizedTransaction): Promise<string> {
return this.wallet.submitTransaction(tx);
}

async start(): Promise<void> {
this.logger.info('Starting wallet...');
await this.wallet.start(this.zswapSecretKeys, this.dustSecretKey);
}

async stop(): Promise<void> {
return this.wallet.stop();
}

static async build(
logger: Logger,
env: EnvironmentConfiguration,
secret: WalletSecret,
): Promise<MidnightWalletProvider> {
const dustOptions: DustWalletOptions = {
ledgerParams: LedgerParameters.initialParameters(),
additionalFeeOverhead: 1_000n,
feeBlocksMargin: 5,
};

const base = FluentWalletBuilder.forEnvironment(env)
.withDustOptions(dustOptions);
const builder =
secret.kind === 'mnemonic'
? base.withMnemonic(secret.value)
: base.withSeed(secret.value);

const buildResult = await builder.buildWithoutStarting();
const { wallet, seeds, keystore } = buildResult as {
wallet: WalletFacade;
seeds: {
masterSeed: string;
shielded: Uint8Array;
dust: Uint8Array;
};
keystore: UnshieldedKeystore;
};

logger.info(
`Wallet built from ${secret.kind}; master seed: ${seeds.masterSeed.slice(0, 8)}...`,
);

return new MidnightWalletProvider(
logger,
wallet,
ZswapSecretKeys.fromSeed(seeds.shielded),
DustSecretKey.fromSeed(seeds.dust),
keystore,
);
}
}

function isProgressStrictlyComplete(progress: unknown): boolean {
if (!progress || typeof progress !== 'object') {
return false;
}
const candidate = progress as { isStrictlyComplete?: unknown };
if (typeof candidate.isStrictlyComplete !== 'function') {
return false;
}
return (candidate.isStrictlyComplete as () => boolean)();
}

export async function syncWallet(
logger: Logger,
wallet: WalletFacade,
timeout = 300_000,
): Promise<FacadeState> {
logger.info('Syncing wallet...');
let emissionCount = 0;
return Rx.firstValueFrom(
wallet.state().pipe(
Rx.tap((state: FacadeState) => {
emissionCount++;
// 업데이트 200번마다 한 번씩 상태를 남겨, 콘솔을 도배하지 않으면서
// 긴 sync의 진행 상황을 확인할 수 있게 합니다.
if (emissionCount % 200 === 0) {
const shielded = isProgressStrictlyComplete(state.shielded.state.progress);
const unshielded = isProgressStrictlyComplete(state.unshielded.progress);
const dust = isProgressStrictlyComplete(state.dust.state.progress);
logger.info(`Still syncing: shielded=${shielded}, unshielded=${unshielded}, dust=${dust}`);
}
}),
// shielded 채널과 unshielded 채널이 따라잡을 때까지 기다립니다. dust 채널은
// 기다리지 않습니다. 공개 네트워크에서는 dust가 끝내 "strictly complete"를
// 보고하지 않을 수 있어, 그러면 이 대기가 영원히 멈춰 버립니다.
Rx.filter(
(state: FacadeState) =>
isProgressStrictlyComplete(state.shielded.state.progress) &&
isProgressStrictlyComplete(state.unshielded.progress),
),
Rx.tap(() => logger.info('Wallet synced.')),
Rx.timeout({
each: timeout,
with: () =>
Rx.throwError(() => new Error(`Wallet sync timed out after ${timeout}ms`)),
}),
),
);
}

배포 스크립트에서 호출하는 함수는 두 개입니다. seed로 지갑을 만드는 MidnightWalletProvider.build(...), 그리고 지갑이 체인을 따라잡을 때까지 기다렸다가 현재 상태를 반환하는 syncWallet(...)입니다.

Add the providers

src/providers.ts를 만드세요.

touch src/providers.ts

프레임워크가 사용하는 provider 세트를 조립합니다. public state는 indexer에서, 증명은 proof server에서, ZK 설정은 컴파일 산출물에서 가져오고, 여기에 방금 만든 지갑을 더합니다.

import { type MidnightProviders } from '@midnight-ntwrk/midnight-js-types';
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';
import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider';
import { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider';
import { type MidnightWalletProvider } from './wallet.js';
import { type NetworkConfig } from './config.js';

export type TokenCircuits = 'mint' | 'transfer';

export type TokenProviders = MidnightProviders<any>;

export function buildProviders(
wallet: MidnightWalletProvider,
zkConfigPath: string,
config: NetworkConfig,
): TokenProviders {
const zkConfigProvider = new NodeZkConfigProvider<TokenCircuits>(zkConfigPath);

return {
privateStateProvider: levelPrivateStateProvider({
privateStateStoreName: `unshielded-token-${Date.now()}`,
privateStoragePasswordProvider: () => 'Unshielded-Token-Test-Password',
accountId: wallet.getCoinPublicKey(),
}),
publicDataProvider: indexerPublicDataProvider(
config.indexer,
config.indexerWS,
),
zkConfigProvider,
proofProvider: httpClientProofProvider(
config.proofServer,
zkConfigProvider,
),
walletProvider: wallet,
midnightProvider: wallet,
};
}

이 토큰은 private 상태를 두지 않지만 프레임워크는 여전히 private state provider를 요구하므로 하나 포함해 둡니다. 내용은 비어 있는 채로 유지됩니다.

Wrap the compiled contract

src/contract.ts를 만드세요.

touch src/contract.ts

생성된 컨트랙트를 import해 배포 가능한 단위로 만듭니다.

import { CompiledContract } from '@midnight-ntwrk/midnight-js-protocol/compact-js';
import path from 'node:path';

export { Contract, ledger, type Ledger } from './managed/unshielded-token/contract/index.js';
import { Contract } from './managed/unshielded-token/contract/index.js';

const currentDir = path.resolve(new URL(import.meta.url).pathname, '..');
export const zkConfigPath = path.resolve(currentDir, 'managed', 'unshielded-token');

export const CompiledUnshieldedToken = CompiledContract.make(
'UnshieldedToken',
Contract,
).pipe(
CompiledContract.withVacantWitnesses,
CompiledContract.withCompiledFileAssets(zkConfigPath),
);

이 컨트랙트는 witness 함수를 선언하지 않아 오프체인 구현을 넘길 것이 없으므로 withVacantWitnesses를 씁니다. 빈 witness 객체를 붙여 줍니다. witness는 오프체인 코드를 위한 훅으로, 상태 공개 여부와는 별개의 개념입니다. ledger 상태는 컨트랙트에 witness가 있든 없든 공개됩니다. zkConfigPath는 컴파일러가 src/managed/unshielded-token 아래 생성한 키를 proof server가 찾도록 가리킵니다.

Write the deploy script

이제 이 토큰에 고유한 부분입니다.

src/deploy.ts를 만들고 블록 단위로 채워 나가세요.

touch src/deploy.ts

먼저 import 구문, indexer에 필요한 WebSocket 전역, 로거부터 작성합니다.

import { randomBytes } from 'node:crypto';
import { WebSocket } from 'ws';
import { firstValueFrom } from 'rxjs';
import { filter, timeout as rxTimeout } from 'rxjs/operators';
import pino from 'pino';
import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
import { deployContract, submitCallTx } from '@midnight-ntwrk/midnight-js-contracts';
import { type EnvironmentConfiguration } from '@midnight-ntwrk/testkit-js';
import { UnshieldedAddress } from '@midnight-ntwrk/wallet-sdk';
import { unshieldedToken } from '@midnight-ntwrk/midnight-js-protocol/ledger';
import { toHex, fromHex } from '@midnight-ntwrk/midnight-js-utils';
import { getConfig } from './config.js';
import { MidnightWalletProvider, syncWallet } from './wallet.js';
import { buildProviders } from './providers.js';
import { CompiledUnshieldedToken, ledger, zkConfigPath } from './contract.js';

// indexer의 GraphQL subscription은 Node 환경에서 WebSocket 전역을 필요로 합니다.
(globalThis as any).WebSocket = WebSocket;

const logger = pino({ level: 'info', transport: { target: 'pino-pretty' } });

seed로 지갑을 만들고 시작한 뒤, 자금을 채울 수 있도록 주소를 출력합니다.

const config = getConfig();
setNetworkId(config.networkId);

const env: EnvironmentConfiguration = {
walletNetworkId: config.networkId,
networkId: config.networkId,
indexer: config.indexer,
indexerWS: config.indexerWS,
node: config.node,
nodeWS: config.nodeWS,
faucet: config.faucet,
proofServer: config.proofServer,
};

const seed = process.env['MIDNIGHT_SEED'];
if (!seed) {
throw new Error('Set MIDNIGHT_SEED to your wallet seed (hex, no 0x prefix).');
}

const wallet = await MidnightWalletProvider.build(logger, env, { kind: 'seed', value: seed });
await wallet.start();

// wallet의 첫 상태 업데이트에서 주소를 읽어 출력합니다. 이 주소로 자금을 채웁니다.
const initialState = await firstValueFrom(wallet.wallet.state());
const address = UnshieldedAddress.codec
.encode(config.networkId, initialState.unshielded.address)
.asString();
logger.info(`Fund this address with tNIGHT, then this continues: ${address}`);

wallet seed는 MIDNIGHT_SEED로 넘깁니다. 스크립트는 지갑을 만들고 unshielded 주소를 출력한 뒤, 그 주소에 NIGHT이 들어올 때까지 기다립니다. 로컬에서는 genesis wallet에 이미 자금이 있으므로 대기 없이 바로 넘어갑니다. Preprod에서는 스크립트가 기다리는 동안 출력된 주소로 faucet에서 자금을 채우면 됩니다.

NIGHT이 도착할 때까지 기다리고, unshielded 채널을 sync한 다음, 수수료로 쓸 tDUST를 생성하도록 그 NIGHT을 등록합니다.

const nightRaw = unshieldedToken().raw;

// 1) NIGHT이 도착할 때까지 기다립니다.
logger.info('Waiting for NIGHT to arrive...');
await firstValueFrom(
wallet.wallet.state().pipe(
filter((s: any) => (s.unshielded.balances[nightRaw] ?? 0n) > 0n),
rxTimeout({ each: 30 * 60_000 }),
),
);
logger.info('NIGHT received.');

// 2) 등록 전에 unshielded 채널 sync가 끝나기를 기다립니다. 그래야 wallet이
// 자신의 NIGHT UTXO를 정확히 파악한 상태가 됩니다.
logger.info('Waiting for the unshielded channel to sync...');
const syncedState = await firstValueFrom(
wallet.wallet.state().pipe(
filter((s: any) => s.unshielded.progress?.isStrictlyComplete() === true),
rxTimeout({ each: 30 * 60_000 }),
),
);
logger.info('Unshielded channel synced.');

// 3) DUST 생성을 위해 NIGHT UTXO를 등록합니다. 새로 들어온 NIGHT은 자동으로
// 등록되지 않으며, DUST가 없으면 wallet은 transaction 수수료를 낼 수 없습니다.
const unregistered = syncedState.unshielded.availableCoins.filter(
(coin: any) =>
coin.utxo.type === nightRaw &&
coin.meta.registeredForDustGeneration === false,
);

if (unregistered.length > 0) {
logger.info(`Registering ${unregistered.length} NIGHT UTXO(s) for DUST generation...`);
const recipe = await wallet.wallet.registerNightUtxosForDustGeneration(
unregistered,
wallet.unshieldedKeystore.getPublicKey(),
(payload: Uint8Array) => wallet.unshieldedKeystore.signData(payload),
);
const finalized = await wallet.wallet.finalizeRecipe(recipe);
const txId = await wallet.wallet.submitTransaction(finalized);
logger.info(`DUST registration submitted: ${txId}`);
} else {
logger.info('NIGHT is already registered for DUST generation.');
}

Midnight의 수수료는 DUST로 지불하며, NIGHT을 등록하면 DUST가 생성됩니다. 갓 받은 NIGHT은 등록되어 있지 않으므로, 스크립트가 unshielded 채널 sync를 마친 뒤 등록합니다. 로컬에서는 genesis wallet이 이미 등록되어 있어 그 사실을 알리고 넘어갑니다.

이 필터는 등록 플래그뿐 아니라 토큰 타입도 확인합니다. DUST 생성 등록은 NIGHT에만 가능하고, 다른 토큰 타입이 하나라도 섞이면 wallet SDK가 호출 전체를 거부합니다. 타입 확인은 재실행 시 특히 중요합니다. 아래에서 mint한 토큰이 지갑에 들어오면 그 UTXO들도 registeredForDustGeneration === false로 보고되는데, nightRaw 검사가 없으면 스크립트가 이들까지 등록 호출에 넘겨 실패하게 됩니다.

이어지는 트랜잭션에 수수료를 낼 수 있도록 DUST가 생길 때까지 폴링합니다.

// 4) DUST를 쓸 수 있을 때까지 기다립니다. subscription을 계속 열어 두는 대신
// 잔액을 폴링해, 오래 기다려도 메모리 사용이 늘지 않게 합니다.
logger.info('Waiting for DUST to be generated from your NIGHT...');
const dustDeadline = Date.now() + 30 * 60_000;
let dustBalance = 0n;
while (Date.now() < dustDeadline) {
const s = await firstValueFrom(wallet.wallet.state());
try {
dustBalance = s.dust.balance(new Date());
} catch {
dustBalance = 0n;
}
logger.info(` dust balance: ${dustBalance}`);
if (dustBalance > 0n) break;
await new Promise((r) => setTimeout(r, 15_000));
}
if (dustBalance <= 0n) {
throw new Error('Timed out waiting for DUST to be generated.');
}
logger.info(`DUST available: ${dustBalance}`);

DUST는 등록된 NIGHT에서 짧은 시간에 걸쳐 생성되므로 잔액이 0에서 시작해 올라갑니다. 스크립트는 15초마다 폴링하다가 값이 양수가 되면 빠져나옵니다. 로컬에서는 몇 초, Preprod에서는 대략 1~2분 걸립니다. 이 시간은 대부분 블록 확정과 wallet sync에 쓰이며, DUST 자체는 등록 트랜잭션이 온체인에 올라간 순간부터 매초 쌓입니다. faucet의 기본 지급량인 1,000 tNIGHT 기준으로 분당 약 0.5 tDUST가 쌓이고 약 일주일 뒤 tNIGHT당 5 tDUST에서 상한에 도달합니다. 따라서 잔액이 양수가 되는 시점이면 이미 아래의 배포와 호출 비용을 훨씬 웃도는 양을 보유하게 됩니다.

provider를 구성하고, 컨트랙트를 배포하고, 초기 상태를 읽습니다.

const providers = buildProviders(wallet, zkConfigPath, config);

// owner secret이 mint와 transfer의 권한을 통제합니다. 여기서는 실행할 때마다
// 무작위로 만들지만, 실제 배포에서는 반드시 저장해 두어야 합니다. 이 값이 없으면
// 컨트랙트는 다시는 mint도 transfer도 할 수 없습니다.
const ownerSecret = randomBytes(32);

const deployed = await deployContract(providers, {
compiledContract: CompiledUnshieldedToken,
privateStateId: 'unshielded-token',
initialPrivateState: {},
args: [ownerSecret],
});
const contractAddress = deployed.deployTxData.public.contractAddress;
logger.info(`Deployed at ${contractAddress}`);

async function readLedger() {
const state = await providers.publicDataProvider.queryContractState(contractAddress);
return ledger(state!.data);
}

logger.info(`initialized at deploy: ${(await readLedger()).initialized}`); // false

deployContract는 온체인 주소를 반환하고, args는 constructor 인자를 전달합니다. 여기서는 owner secret입니다. constructor는 secret의 해시만 저장하므로 secret 자체는 스크립트를 벗어나지 않습니다. queryContractState는 컨트랙트의 public state를 가져오고, ledger(...)가 이를 여러분이 선언한 필드로 디코딩합니다. 배포 직후 initialized는 false입니다.

공급량을 mint한 뒤 상태를 다시 읽어 토큰이 생성되었는지 확인합니다.

await submitCallTx(providers, {
compiledContract: CompiledUnshieldedToken,
contractAddress,
privateStateId: 'unshielded-token',
circuitId: 'mint',
args: [ownerSecret, 1000n],
});

const afterMint = await readLedger();
logger.info(`initialized after mint: ${afterMint.initialized}`); // true
logger.info(`token color: ${toHex(afterMint.token_color)}`);

submitCallTx가 mint circuit을 온체인에서 실행합니다. args는 Compact 시그니처와 같은 순서로, owner secret이 먼저 오고 금액이 뒤따릅니다. secret은 witness 데이터이므로 트랜잭션에 드러나지 않고 proof에만 입력됩니다.

실행 후 initialized는 true가 되고 token_color에는 mint가 만든 color가 담깁니다. 이제 토큰이 존재하며, 1,000단위 전부를 컨트랙트가 보유합니다.

일부를 자신의 지갑으로 전송한 뒤 지갑 잔액을 읽어 도착했는지 확인합니다.

// UserAddress circuit 인자는 수신자의 원시 주소 바이트를 담은 { bytes: Uint8Array }
// 형태이므로, wallet의 주소 객체에서 그 바이트를 꺼냅니다.
function toUserAddressBytes(unshielded: any): Uint8Array {
const pk = unshielded?.state?.publicKey ?? unshielded?.publicKey;
if (pk?.address instanceof Uint8Array) return pk.address;
if (typeof pk?.addressHex === 'string') return fromHex(pk.addressHex);
const addr = unshielded?.address;
if (addr?.bytes instanceof Uint8Array) return addr.bytes;
if (addr?.data instanceof Uint8Array) return addr.data;
if (typeof addr?.addressHex === 'string') return fromHex(addr.addressHex);
throw new Error('Could not find raw unshielded address bytes.');
}

const recipient = { bytes: toUserAddressBytes(syncedState.unshielded) };

await submitCallTx(providers, {
compiledContract: CompiledUnshieldedToken,
contractAddress,
privateStateId: 'unshielded-token',
circuitId: 'transfer',
args: [ownerSecret, recipient, 100n],
});

const after = await syncWallet(logger, wallet.wallet, 60 * 60_000);
const color = toHex(afterMint.token_color);
logger.info(`wallet balance of the token: ${after.unshielded.balances[color] ?? 0n}`); // 100

await wallet.stop();

UserAddress circuit 인자는 수신자의 원시 주소 바이트를 담은 { bytes: Uint8Array } 형태의 객체입니다. 그래서 toUserAddressBytes가 지갑 주소에서 그 바이트를 꺼냅니다. 이어지는 transfer 호출이 컨트랙트에서 여러분의 지갑 주소로 100단위를 옮깁니다. 다시 sync하면 지갑이 해당 토큰 color에 대해 잔액 100을 보고하며, 이것이 전송이 온체인에 반영되었다는 증거입니다.

Run it

로컬 네트워크를 별도 터미널에서 실행한 상태로, genesis wallet seed를 사용해 배포 스크립트를 실행하세요.

MIDNIGHT_NETWORK=local \
MIDNIGHT_SEED=0000000000000000000000000000000000000000000000000000000000000001 \
npx tsx src/deploy.ts

이 seed는 로컬 네트워크의 genesis wallet이며, midnight-local-dev가 이미 NIGHT을 채우고 DUST 등록까지 마쳐 둔 계정입니다. 스크립트는 sync하고 자금을 확인한 뒤 DUST를 몇 초 기다렸다가 배포하고, mint하고, 전송합니다. 컨트랙트 주소, mint 후 true로 바뀌는 initialized, 전송 후 잔액 100이 차례로 출력됩니다. 여기까지 오면 unshielded 토큰을 처음부터 끝까지 만들고 옮겨 본 것입니다.

Run against Preprod instead

같은 스크립트를 Preprod에서 실행할 때 달라지는 점은 두 가지입니다. 자금이 있는 지갑을 직접 준비해야 하고, 독립 proof server를 계속 실행해 두어야 합니다(Start the local network 참고).

먼저 32바이트 값을 16진수 문자열로 표현한 일회용 wallet seed를 생성합니다.

openssl rand -hex 32

이 seed는 일회용 테스트 wallet으로만 다루세요. 테스트 자금만 담기며, 실제 용도로 재사용해서는 안 됩니다. 그다음 원격 sync가 더 오래 걸리므로 노드에 메모리를 넉넉히 주고 그 seed로 스크립트를 실행합니다.

NODE_OPTIONS="--max-old-space-size=8192" \
MIDNIGHT_NETWORK=preprod \
MIDNIGHT_SEED=<your-seed-hex> \
npx tsx src/deploy.ts

스크립트가 지갑 주소를 출력하고 대기합니다. 그 주소를 복사해 Preprod faucet에 붙여 넣고 tNIGHT을 요청하세요. 자금이 들어오면 스크립트가 DUST 생성을 위해 등록하고, DUST가 생길 때까지 기다린 뒤 로컬과 똑같이 배포하고, mint하고, 전송합니다.

로컬 실행보다 몇 분 정도 더 걸린다고 보면 됩니다.

What's next

다음은 이 토큰의 shielded 버전을 만들 차례입니다. shielded 잔액은 감춰져 있어 체인이 대신 추적해 주지 못하므로, 컨트랙트가 하나의 트랜잭션 안에서 shielded coin을 mint해 호출자에게 전달하고, 여러분은 지갑의 shielded 잔액을 읽어 결과를 확인합니다.

이 문서에서 만든 지갑과 provider 설정을 그대로 재사용하며, 접속하는 네트워크도 같습니다.