For the complete documentation index, see llms.txt
Build a Midnight wallet
이 가이드는 Midnight 지갑을 만드는 팀을 위한 것입니다. 브라우저 확장, 모바일 앱, 다른 제품에 내장된 지갑 모두 해당합니다. 지갑은 두 가지 일을 합니다. 사용자의 키와 자금을 보관하는 일은 Wallet SDK가 담당하고, DApp에 응답하는 일은 DApp Connector API로 처리합니다. Wallet SDK 가이드는 SDK 호출을 설명합니다. 이 가이드는 그 주변에서 제품이 책임지는 부분과, connector의 지갑 쪽을 구현하는 방법을 다룹니다.
이 페이지의 코드는 DApp Connector API 4.0.1과 Wallet SDK 1.2.0을 대상으로 합니다. support matrix에 있는 버전입니다.
Prerequisites
-
Initialize the wallet에서 보여주듯 키를 파생하고
WalletFacade를 시작할 수 있습니다. -
번들러가 빌드하는 TypeScript 프로젝트가 있고,
moduleResolution이"bundler"로 설정되어 있으며, 다음 패키지가 설치되어 있습니다. 이 페이지의 모든 샘플은 바로 이 버전들을 기준으로 타입 체크를 통과합니다.npm install @midnight-ntwrk/dapp-connector-api@4.0.1 @midnightntwrk/wallet-sdk@1.2.0 @midnight-ntwrk/ledger-v8@8.1.3 @midnight-ntwrk/zkir-v2@2.1.1 rxjs@7.8.2그런 다음 Wallet SDK의 주소 패키지를 위해
@scure/base를 고정합니다.@midnightntwrk/wallet-sdk-address-format3.1.2는@scure/base2.x라면 무엇이든 받으므로, npm은 2.4.0 이상을 설치합니다. 2.4.0부터MidnightBech32m.parse가 호출하는bech32m.decodeToBytes는 기본값으로 90자보다 긴 문자열을 거부합니다. shielded 주소는 124~135자라서MidnightBech32m.parse가 모든 주소에서 예외를 던집니다.package.json에 다음 override를 추가하고npm install을 다시 실행하세요:{"overrides": {"@midnightntwrk/wallet-sdk-address-format": {"@scure/base": "2.3.0"}}}고정 범위는 해당 패키지로 한정하세요. 프로젝트 전체에
@scure/base를 고정하면 다른 Wallet SDK import가 깨집니다. 이 가이드는 npm의overrides형식만 테스트했습니다. Wallet SDK가 디코더에 길이 제한을 전달하게 되면 고정을 제거할 수 있으며, 이 작업은 midnight-wallet issue 842에서 추적합니다.샘플은
@midnightntwrk/wallet-sdk/facade같은@midnightntwrk/wallet-sdk패키지의 서브경로를 통해 Wallet SDK를 가져옵니다. Wallet SDK는@midnightntwrknpm scope를 쓰고, connector·ledger·ZKIR 패키지는@midnight-ntwrk를 씁니다. 두 scope에 관한 자세한 내용은 Wallet SDK 가이드의 Installation을 참고하세요. -
Verification 섹션의 테스트를 실행하려면 Vitest와 jsdom도 필요합니다. 샘플은 TypeScript 5.9.3으로 타입 체크합니다.
npm install --save-dev vitest@3.2.4 jsdom@26.1.0 typescript@5.9.3
What your wallet owns
Wallet SDK는 지갑의 엔진입니다. 키를 파생하고, indexer와 sync하고, 잔액을 맞추고, 증명하고, 트랜잭션을 제출합니다. 사용자가 보거나 신뢰하는 모든 것은 여러분 제품의 일부입니다. 이 표는 SDK가 제공하는 것과 지갑이 그것으로 하는 일을 정리합니다.
| 영역 | Wallet SDK가 제공하는 것 | 지갑이 하는 일 |
|---|---|---|
| 키 | HDWallet.fromSeed가 하나의 seed에서 shielded·unshielded·DUST 키를 파생합니다 | 복구 구문을 생성하거나 가져오고, seed를 암호화해 저장하고, 잠금을 해제하고, 사용 후 키 자료를 메모리에서 지웁니다 |
| 계정 | selectAccount가 파생 경로에서 계정 인덱스를 선택합니다 | 여러 계정을 지원한다면 각 DApp이 어느 계정을 쓰는지 보여줍니다 |
| Sync | WalletFacade.init, start, state(), waitForSyncedState() | 지갑이 언제 sync할지 정하고, 진행 상황을 보여주고, 종료 시 stop()을 호출합니다 |
| 영속화 | shielded·unshielded·DUST 지갑의 serializeState()와 restore() | 지갑별·네트워크별로 직렬화된 상태를 저장합니다. 지갑이 보유한 코인 목록이 담기므로 비공개로 유지하세요 |
| 서비스 | 지갑 설정의 indexer·node·proof server 엔드포인트 | 사용자가 엔드포인트를 고르게 하고, getConfiguration으로 DApp에 알립니다 |
| 증명 | 기본값은 proof server 클라이언트, 또는 makeWasmProvingService를 이용한 WebAssembly 증명 | 자체 트랜잭션과 getProvingProvider를 호출하는 DApp 모두에 대해 증명이 어디서 실행될지 고릅니다 |
| 이용약관 | WalletFacade.fetchTermsAndConditions가 indexer에서 현재 약관의 URL과 SHA-256 해시를 반환합니다 | 사용자에게 약관을 보여줍니다 |
| 승인 | 프롬프트나 UI 없음 | 모든 프롬프트: 연결, 권한, 트랜잭션, 데이터 서명 |
지갑 상태를 저장하고 복원하는 한 가지 방법은 Node.js 지갑 가이드의 What to keep between restarts를 참고하세요.
지갑이 DApp을 위해 설치하는 객체는 DApp의 페이지 안에서, 여러분이 통제하지 못하는 코드 바로 옆에서 실행됩니다. seed, 키, WalletFacade, 승인 프롬프트는 전송 계층 뒤에 두고 절대 페이지에 두지 마세요. 확장의 백그라운드 컨텍스트나 앱의 네이티브 프로세스 같은 별도 컨텍스트에 보관하세요. 페이지 쪽 객체는 각 호출을 그곳으로 전달하기만 합니다.
이 페이지의 샘플은 간결함을 위해 Wallet SDK를 직접 호출합니다.
How DApps reach your wallet
DApp은 여러분이 페이지에 설치하는 작은 initial API 객체를 통해 지갑을 찾습니다. 그 뒤로는 connect가 반환하는 connected API 객체를 통해 동작합니다. 이 설계는 역할을 나눕니다. DApp은 트랜잭션이 해야 할 일을 기술하고, 지갑은 코인을 공급하고 수수료를 내고 서명하고 증명합니다.
- Initial API:
rdns,name,icon,apiVersion,connect(networkId)를 가진 객체입니다. 새로 만든 UUIDv4를 키로 삼아window.midnight아래에 설치합니다. 그 키와 함께, 내용은 draft CAIP-372와 호환됩니다. - Connected API:
connect가 resolve하는 객체입니다. 잔액, 주소, 트랜잭션, 데이터 서명, 위임 증명, 설정, 연결 상태를 다룹니다. 모든 메서드가 항상 존재하며, 지갑은 어떤 호출이든 거부할 수 있습니다. - 코인에 직접 접근 불가: 어떤 메서드도 DApp에 shielded 코인이나 unshielded UTXO를 주지 않습니다. DApp은 전송 같은 결과를 요청하고, 코인은 지갑이 선택합니다.
- 트랜잭션은 hex 문자열로: 메서드는 직렬화된 ledger 트랜잭션을 문자열로 받고 반환합니다.
Transaction.serialize()의 hex 인코딩을 쓰며, leaderboard tutorial과 Midnight.js testkit 어댑터가 모두 이를 사용합니다.
명세의 일부 타입 스니펫은 4.0.1 패키지와 다릅니다. @midnight-ntwrk/dapp-connector-api를 기준으로 코딩하세요. 예를 들어 getDustBalance는 bigint가 아니라 { cap, balance }를 반환하고, hintUsage는 WalletConnectedAPI 메서드 이름을 받습니다. 또한 패키지는 Configuration.proverServerUri를 deprecated로 표시하고 getProvingProvider를 쓰도록 안내합니다.
Connections and permissions
지갑은 각 DApp이 무엇을 보고 무엇을 할 수 있는지 결정합니다. 명세는 DApp이 의존할 수 있는 규칙을 정하고, 사용자 경험은 여러분에게 맡깁니다.
연결. connect(networkId)는 DApp이 원하는 네트워크를 받습니다. 지갑이 그 네트워크에 연결할 수 없으면 호출을 거부하세요. 연결과 거기서 얻는 권한을 사용자가 승인하게 하고, DApp이 요청한 네트워크를 보여줄 수 있습니다. connect는 한 번 넘게 실행될 수 있으니(예: DApp이 재연결할 때) 각 연결을 별개로 유지하세요.
권한. 접근은 메서드 단위로, 제품에 맞는 세분화 수준으로 허용하거나 거부합니다. 호출을 거부할 때는 PermissionRejected 코드로 reject하세요. DApp은 이 거부가 자기 페이지를 닫을 때까지 지속된다고 가정할 수 있으니, DApp이 재연결한 뒤에도 같은 거부를 계속 반환하세요. DApp은 사용할 것으로 예상하는 메서드를 담아 hintUsage를 호출할 수 있습니다. 그 호출을 해당 권한들을 한꺼번에 요청하는 계기로 삼으세요. promise는 사용자가 답한 뒤에만 undefined로 resolve하세요.
오류. type이 DAppConnectorAPIError이고 code와 reason을 담은 Error로 reject하세요. 패키지는 APIError를 클래스가 아니라 타입으로 정의합니다. 지갑 코드와 DApp 코드 사이에서는 instanceof 검사가 통하지 않기 때문입니다. 이 페이지의 샘플처럼 Error를 확장한 클래스로 구현할 수 있습니다.
| 코드 | 사용 시점 |
|---|---|
InternalError | 지갑이 요청을 처리하지 못함 |
Rejected | 사용자가 이 요청 하나를 거절함(예: 트랜잭션 비용을 확인한 뒤) |
InvalidRequest | 역직렬화되지 않는 트랜잭션처럼 형식이 잘못된 요청 |
PermissionRejected | 사용자 또는 정책이 이 DApp에 이 메서드를 허용하지 않음 |
Disconnected | 연결이 종료됨 |
공개 범위. 잔액, 주소, 내역은 사용자가 공유하기로 선택하는 데이터로 취급하세요:
- 사용 가능한 잔액만 보고하세요. 지금 트랜잭션에서 지출할 의향이 있는 금액입니다. Wallet SDK의 shielded·unshielded 상태에 있는
balancesgetter는 이미 사용 가능한 코인만 셉니다. - shielded 잔액과 트랜잭션 내역은 권한 뒤에 두세요. 명세는 이들을 사용자가 가볍게 공유해서는 안 되는 데이터로 지목합니다.
- 주소는 연결된 네트워크용 Bech32m 형식으로 반환하세요. 패키지는 shielded coin public key와 encryption public key도 Bech32m으로 문서화합니다.
- 꼭 필요하지 않으면
getConfiguration을 제한하지 마세요. DApp은 이를 통해 지갑과 동일한 indexer와 서비스에 접근합니다. - 여러 계정의 잔액을 합쳐 보고한다면, DApp이 보고된 금액을 한 트랜잭션에서 지출할 수 있도록 하세요. 각 DApp 상호작용이 어느 계정을 쓰는지 보여주세요.
Requests that move tokens
connected API 메서드 다섯 개가 트랜잭션을 만들거나 완성하거나 전달합니다. 트랜잭션을 반환하는 메서드는 제출 준비가 끝난 것, 즉 bound·서명·증명이 된 것을 반환해야 합니다. 메서드와 옵션에 따라 여전히 불균형을 담을 수 있습니다.
스마트 컨트랙트 호출의 밸런싱. balanceUnsealedTransaction은 DApp이 가장 많이 쓰는 메서드입니다. DApp은 자신의 스마트 컨트랙트 호출을 증명하고, 트랜잭션을 bound되지 않은 상태로, ledger 타입 Transaction<SignatureEnabled, Proof, PreBinding>로 보냅니다. 지갑은 각 토큰의 균형을 맞추는 입력과 출력을 더하고 수수료를 냅니다. 스마트 컨트랙트 호출에 이 메서드가 필요한 이유는, 지갑이 자신의 입력과 출력을 호출과 같은 intent에 더하기 때문입니다.
완성된 트랜잭션의 밸런싱. balanceSealedTransaction은 스왑의 상대편 같은 bound 트랜잭션 Transaction<SignatureEnabled, Proof, Binding>을 받습니다. 지갑은 이를 별도 intent에서 밸런싱합니다. fallible section을 쓰는 스마트 컨트랙트에서는 이것이 실패할 수 있으므로, 그런 경우 DApp은 balanceUnsealedTransaction을 씁니다.
결제. makeTransfer는 DApp이 나열한 출력으로 전송을 구성하도록 지갑에 요청합니다. 각 수신자는 연결된 네트워크용의, 해당 출력 종류에 맞는 Bech32m 주소입니다.
스왑. makeIntent는 의도적으로 불균형한 트랜잭션을 만듭니다. desiredInputs는 남고 desiredOutputs는 모자란 상태입니다. 상대방이 예컨대 balanceSealedTransaction으로 이를 완성합니다. intentId 옵션은 intent의 segment를 정합니다. 1을 쓰면 병합된 트랜잭션이 이 intent보다 먼저 실행하는 동작이 없습니다. 특정 번호를 쓰면 그 segment를 지정하고, "random"을 쓰면 지갑이 하나를 고릅니다. 스왑이 unshielded 토큰을 옮길 때 Wallet SDK 1.2.0은 그 intent를 segment 1에 두므로, 이 페이지의 샘플은 다른 번호를 모두 거부합니다. "random"을 써도 그 intent는 segment 1에 들어가므로, 그런 intent 두 개를 한 트랜잭션으로 병합할 수 없습니다. shielded 토큰만의 스왑은 자체 intent를 만들지 않습니다.
수수료. 트랜잭션을 구성하거나 밸런싱하는 모든 메서드는 payFees 옵션을 받습니다. 기본값은 true이고, makeIntent는 이 옵션을 요구합니다. payFees: false이면 지갑은 DustSpend를 추가해서는 안 됩니다. 그래야 sponsor service 같은 다른 당사자가 수수료를 낼 수 있습니다.
제출. submitTransaction은 지갑을 릴레이어로 만듭니다. DApp은 연결된 네트워크용의 bound·서명·증명된 트랜잭션을 보내고, 지갑이 이를 제출합니다.
위임 증명. getProvingProvider는 DApp의 KeyMaterialProvider를 받습니다. 이 provider는 키 위치에 대해 circuit의 ZKIR, prover key, verifier key를 반환합니다. 그리고 check와 prove를 가진 ProvingProvider를 반환하는데, ledger의 WebAssembly 바인딩에 있는 것과 호환됩니다. 지갑에 중요한 지점이 셋 있습니다:
- 증명하는 preimage에는 proof가 숨기는 바로 그 private 입력이 담겨 있어, 지갑이 이를 읽을 수 있습니다. 로그에 남기지 말고, 사용자가 고른 prover에만 보내세요.
- 증명은 최악의 경우 몇 분이 걸릴 수 있고, 그 경험은 지갑의 몫입니다.
- prover key는 보통 10~20MB이고 80MB를 넘을 수도 있습니다. 증명 호출이 메시지 크기 제한이 있는 채널을 건너간다면 이를 감안해 설계하세요.
Connector methods and the Wallet SDK
이 표는 메서드를 구현하거나 디버깅할 때를 위해, 각 connected API 메서드를 그 뒤에 있는 Wallet SDK 1.2.0 호출에 대응시킵니다. Implementing the connected API의 샘플이 이를 따릅니다. 표에서 state()는 WalletFacade.state()입니다.
| Connected API 메서드 | Wallet SDK 호출 | 비고 |
|---|---|---|
getShieldedBalances | state() 다음 shielded.balances | 사용 가능한 코인만 |
getUnshieldedBalances | state() 다음 unshielded.balances | 사용 가능한 코인만 |
getDustBalance | state() 다음 dust.balance(time), 그리고 dust.totalCoins의 각 항목에 대한 maxCap | cap은 backing NIGHT이 미사용인 코인들의 maxCap을 샘플이 합산합니다 |
getShieldedAddresses | state() 다음 shielded.address, shielded.coinPublicKey, shielded.encryptionPublicKey | 각각을 address-format 코덱으로 인코딩합니다 |
getUnshieldedAddress | unshielded keystore의 getBech32Address() | |
getDustAddress | state() 다음 dust.address | MidnightBech32m.encode로 인코딩합니다 |
balanceUnsealedTransaction | balanceUnboundTransaction | 다음에 signRecipe와 finalizeRecipe |
balanceSealedTransaction | balanceFinalizedTransaction | 다음에 signRecipe와 finalizeRecipe |
makeTransfer | transferTransaction | 수신자를 MidnightBech32m.parse로 파싱합니다 |
makeIntent | initSwap | initSwap은 intent ID를 받지 않고, unshielded 부분을 모두 segment 1에 둡니다. inputs 키가 없는 종류의 출력은 무시하며, payFees 기본값이 false이므로 입력 키 양쪽과 payFees를 모두 전달하세요 |
submitTransaction | submitTransaction | SDK는 트랜잭션 식별자를 반환하고, connector 메서드는 아무것도 반환하지 않습니다 |
signData | unshielded keystore의 signData | 접두사가 붙은 메시지를 서명합니다 |
getProvingProvider | @midnight-ntwrk/zkir-v2의 provingProvider | DApp의 키 자료를 감쌉니다 |
getTxHistory | 직접 대응 없음 | facade의 getAllFromTxHistory() 항목에는 segment별 executionStatus가 없습니다 |
getConfiguration, getConnectionStatus, hintUsage | 없음 | 지갑 자체 상태 |
밸런싱에서 payFees: false는 tokenKindsToBalance: ['shielded', 'unshielded']에 대응하며, DUST를 제외합니다.
Installing the initial API
페이지가 로드되자마자 initial API를 설치하세요. 그래야 DApp이 window.midnight의 항목을 나열할 때 지갑을 찾습니다. 명세는 고정된 이름 대신 새로 만든 UUIDv4 키를 요구하고, 페이지의 그 무엇도 덮어쓸 수 없는 항목을 요구합니다.
Procedure
-
initial API 객체를 만듭니다.
rdns는 제품이 유지되는 동안 그대로 두고,apiVersion은 구현하는@midnight-ntwrk/dapp-connector-api의 정확한 버전으로 설정하세요.connect는 호출할 때마다 새 세션을 시작합니다. 권한 결정은 같은 네트워크의 세션 사이에서 이어집니다.createConnectedAPI는 Implementing the connected API에서 가져옵니다.src/initial-api.tsimport { ErrorCodes, type ConnectedAPI, type InitialAPI } from '@midnight-ntwrk/dapp-connector-api';import { createConnectedAPI } from './connected-api';import { ConnectorError } from './errors';import type { MethodName, Session, WalletContext } from './session';export function createInitialAPI(wallets: ReadonlyMap<string, WalletContext>, origin: string): InitialAPI {// Permission decisions for each network, kept for as long as the page stays open.const decisions = new Map<string, Pick<Session, 'granted' | 'rejected'>>();return {rdns: 'com.example.wallet', // Keep this stable for the life of the product.name: 'Example Wallet',icon: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=',apiVersion: '4.0.1', // The exact @midnight-ntwrk/dapp-connector-api version you implement.// Each call starts a separate session, so a DApp can reconnect cleanly.connect: async (networkId: string): Promise<ConnectedAPI> => {const ctx = wallets.get(networkId);if (ctx === undefined) {throw new ConnectorError(ErrorCodes.InvalidRequest, `This wallet can't connect to ${networkId}.`);}if (!(await ctx.ui.approveConnection(origin, networkId))) {throw new ConnectorError(ErrorCodes.Rejected, 'The user declined the connection.');}let decided = decisions.get(networkId);if (decided === undefined) {decided = { granted: new Set<MethodName>(['getConfiguration']), rejected: new Set<MethodName>() };decisions.set(networkId, decided);}return createConnectedAPI(ctx, { origin, networkId, ...decided, connected: true });},};}명세는 지갑이 연결할 수 없는 네트워크를 거부하라고 하지만 그에 쓸 코드는 지정하지 않습니다. 이 샘플은
InvalidRequest로 거부하고, 사용자가 연결을 거절할 때는Rejected를 씁니다. -
객체를
window.midnight아래에 설치합니다. 페이지의 첫 번째 지갑이window.midnight을 non-writable·non-configurable 속성으로 생성합니다. 그런 다음 각 지갑이 새로 만든 UUIDv4 키 아래에 자신의 항목을 frozen·non-writable·non-configurable 상태로 추가합니다.src/install.tsimport type { InitialAPI } from '@midnight-ntwrk/dapp-connector-api';export function installInitialAPI(initialAPI: InitialAPI): string {// The first wallet to arrive creates window.midnight and locks the property.if (!Object.prototype.hasOwnProperty.call(window, 'midnight')) {Object.defineProperty(window, 'midnight', {value: Object.create(null),writable: false,configurable: false,});}// A freshly generated UUIDv4, as the specification requires. Never a fixed name.const key = crypto.randomUUID();Object.defineProperty(window.midnight, key, {value: Object.freeze(initialAPI),writable: false,configurable: false,enumerable: true,});return key;}crypto.randomUUID()는 UUIDv4를 반환하지만, 브라우저는 HTTPS 페이지 같은 secure context에서만 이를 제공합니다. 지갑이 일반 HTTP 페이지에서도 동작한다면, 대신 라이브러리로 UUIDv4를 생성하세요. -
지갑이 예컨대 하드포크 전후처럼 호환되지 않는 connector 버전을 한꺼번에 여러 개 구현한다면, 버전마다 initial API를 하나씩 설치하세요. 각각에 고유한
apiVersion과 키를 부여하세요.
Verification
이 테스트는 지갑을 두 번째 지갑 옆에 함께 설치합니다. 그런 다음 DApp이 하는 일을 그대로 합니다. window.midnight을 나열하고, apiVersion으로 필터링하고, rdns로 지갑을 고르고, 연결합니다. 또한 거부된 권한이 같은 네트워크로의 재연결 뒤에도 유지되는지, 다른 네트워크는 자체 결정을 갖는지 확인합니다. jsdom 환경에서 Vitest 3.2.4로 실행합니다.
// @vitest-environment jsdom
import type { Configuration, InitialAPI } from '@midnight-ntwrk/dapp-connector-api';
import { describe, expect, it } from 'vitest';
import { createInitialAPI } from '../src/initial-api';
import { installInitialAPI } from '../src/install';
import type { WalletContext } from '../src/session';
const services: Configuration = {
indexerUri: 'http://localhost:8088/api/v4/graphql',
indexerWsUri: 'ws://localhost:8088/api/v4/graphql/ws',
substrateNodeUri: 'ws://localhost:9944',
networkId: 'undeployed',
};
const wallet = (approve: boolean) =>
({
services,
ui: {
approveConnection: async () => approve,
approveMethods: async (_origin: string, methods: string[]) => methods,
},
}) as unknown as WalletContext;
describe('Initial API', () => {
it('installs under a fresh UUID and a DApp can find it by rdns and connect', async () => {
const otherWallet: InitialAPI = {
rdns: 'org.other.wallet',
name: 'Other',
icon: 'data:,',
apiVersion: '4.0.1',
connect: async () => {
throw new Error('not used');
},
};
const key = installInitialAPI(createInitialAPI(new Map([['undeployed', wallet(true)]]), 'http://localhost:3000'));
installInitialAPI(otherWallet);
expect(key).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
expect(Object.keys(window.midnight!)).toHaveLength(2);
expect(Object.isFrozen(window.midnight![key])).toBe(true);
expect(() => {
(window as { midnight?: unknown }).midnight = {};
}).toThrow(TypeError);
expect(() => {
window.midnight![key] = otherWallet;
}).toThrow(TypeError);
// What a DApp does: enumerate, filter by apiVersion, pick by rdns.
const found = Object.values(window.midnight ?? {})
.filter((w) => w.apiVersion.startsWith('4.'))
.find((w) => w.rdns === 'com.example.wallet');
const connected = await found!.connect('undeployed');
expect(Object.isFrozen(connected)).toBe(true);
expect(await connected.getConnectionStatus()).toEqual({ status: 'connected', networkId: 'undeployed' });
expect(await connected.getConfiguration()).toEqual(services);
await expect(found!.connect('mainnet')).rejects.toMatchObject({
type: 'DAppConnectorAPIError',
code: 'InvalidRequest',
});
});
it('keeps a refused permission when the DApp reconnects to the same network', async () => {
let prompts = 0;
const ctx = {
services,
ui: {
approveConnection: async () => true,
approveMethods: async () => {
prompts++;
return [];
},
},
} as unknown as WalletContext;
const initialAPI = createInitialAPI(
new Map([
['undeployed', ctx],
['preprod', ctx],
]),
'http://localhost:3000',
);
const rejected = { type: 'DAppConnectorAPIError', code: 'PermissionRejected' };
await expect((await initialAPI.connect('undeployed')).getShieldedBalances()).rejects.toMatchObject(rejected);
await expect((await initialAPI.connect('undeployed')).getShieldedBalances()).rejects.toMatchObject(rejected);
expect(prompts).toBe(1);
// Another network has its own decisions, so the wallet asks again.
await expect((await initialAPI.connect('preprod')).getShieldedBalances()).rejects.toMatchObject(rejected);
expect(prompts).toBe(2);
});
it('rejects the connection when the user declines', async () => {
const initialAPI = createInitialAPI(new Map([['undeployed', wallet(false)]]), 'http://localhost:3000');
await expect(initialAPI.connect('undeployed')).rejects.toMatchObject({
type: 'DAppConnectorAPIError',
code: 'Rejected',
});
});
});
✓ test/install.test.ts > Initial API > installs under a fresh UUID and a DApp can find it by rdns and connect
✓ test/install.test.ts > Initial API > keeps a refused permission when the DApp reconnects to the same network
✓ test/install.test.ts > Initial API > rejects the connection when the user declines
Implementing the connected API
연결마다 connected API 객체를 하나씩 만들고, 실행 중인 WalletFacade로 뒷받침하세요. 메서드가 데이터를 읽거나 동작하기 전에 사용자의 권한을 확인하고, 객체를 반환하기 전에 freeze하세요.
Procedure
-
실행 중인 지갑과 세션이 담는 것을 정의합니다.
WalletContext는 한 네트워크에 대한 실행 중인 지갑이고,Session은 하나의connect호출이 할 수 있는 일을 기록합니다.WalletUI는 여러분 자신의 프롬프트를 대신합니다.src/session.tsimport type { Configuration, WalletConnectedAPI } from '@midnight-ntwrk/dapp-connector-api';import type * as ledger from '@midnight-ntwrk/ledger-v8';import type { BalancingRecipe, WalletFacade } from '@midnightntwrk/wallet-sdk/facade';import type { UnshieldedKeystore } from '@midnightntwrk/wallet-sdk/unshielded';export type MethodName = keyof WalletConnectedAPI;// Your wallet's own prompts. Each one resolves with the user's answer.export type WalletUI = {approveConnection(origin: string, networkId: string): Promise<boolean>;approveMethods(origin: string, methods: MethodName[]): Promise<MethodName[]>;approveTransaction(origin: string, recipe: BalancingRecipe): Promise<boolean>;approveSignData(origin: string, data: Uint8Array): Promise<boolean>;};// A running wallet for one network.export type WalletContext = {facade: WalletFacade;shieldedSecretKeys: ledger.ZswapSecretKeys;dustSecretKey: ledger.DustSecretKey;unshieldedKeystore: UnshieldedKeystore;// The endpoints you report to DApps from getConfiguration.services: Configuration;ui: WalletUI;};// What one connect() call may do.export type Session = {origin: string;networkId: string;granted: Set<MethodName>;rejected: Set<MethodName>;connected: boolean;}; -
오류 클래스와 권한 검사를 추가합니다. 이 검사는 DApp이 메서드를 처음 호출할 때 사용자에게 묻고, 페이지가 열려 있는 동안 답을 기억합니다.
toConnectorError는 Wallet SDK 실패를InternalError로 바꿔, DApp이 항상DAppConnectorAPIError를 받도록 합니다.src/errors.tsimport { ErrorCodes, type APIError, type ErrorCode } from '@midnight-ntwrk/dapp-connector-api';import type { MethodName, Session, WalletContext } from './session';export class ConnectorError extends Error implements APIError {readonly type = 'DAppConnectorAPIError';constructor(readonly code: ErrorCode,readonly reason: string,) {super(reason);this.name = 'ConnectorError';}}// Wallet SDK failures arrive as plain errors, which DApps can't tell apart from bugs.export function toConnectorError(error: unknown): ConnectorError {return error instanceof ConnectorError ? error : new ConnectorError(ErrorCodes.InternalError, String(error));}export function recordDecision(session: Session, asked: MethodName[], allowed: MethodName[]): void {for (const method of asked) {(allowed.includes(method) ? session.granted : session.rejected).add(method);}}// Ask once per method. A refusal sticks for as long as the DApp's page is open.export async function requireAccess(ctx: WalletContext, session: Session, method: MethodName): Promise<void> {if (!session.connected) {throw new ConnectorError(ErrorCodes.Disconnected, 'This connection has ended.');}if (!session.granted.has(method) && !session.rejected.has(method)) {recordDecision(session, [method], await ctx.ui.approveMethods(session.origin, [method]));}if (!session.granted.has(method)) {throw new ConnectorError(ErrorCodes.PermissionRejected, `${method} is not allowed for ${session.origin}.`);}} -
지갑 데이터를 읽는 메서드를 구현합니다.
getConfiguration은 보고하기로 고른 엔드포인트를 반환합니다. 이 샘플은getConnectionStatus에는 권한을 묻지 않지만, 명세상 지갑은 어떤 메서드든 거부할 수 있습니다.src/read-methods.tsimport type { ConnectedAPI } from '@midnight-ntwrk/dapp-connector-api';import {MidnightBech32m,ShieldedCoinPublicKey,ShieldedEncryptionPublicKey,} from '@midnightntwrk/wallet-sdk/address-format';import { firstValueFrom } from 'rxjs';import { requireAccess } from './errors';import type { Session, WalletContext } from './session';type ReadMethods = Pick<ConnectedAPI,| 'getConfiguration'| 'getConnectionStatus'| 'getShieldedBalances'| 'getUnshieldedBalances'| 'getDustBalance'| 'getShieldedAddresses'| 'getUnshieldedAddress'| 'getDustAddress'>;export function readMethods(ctx: WalletContext, session: Session): ReadMethods {const state = () => firstValueFrom(ctx.facade.state());const { networkId } = session;return {getConfiguration: async () => {await requireAccess(ctx, session, 'getConfiguration');return { ...ctx.services };},getConnectionStatus: async () =>session.connected ? { status: 'connected', networkId } : { status: 'disconnected' },// The SDK's balances getters count available coins only, which is what the specification asks for.getShieldedBalances: async () => {await requireAccess(ctx, session, 'getShieldedBalances');return (await state()).shielded.balances;},getUnshieldedBalances: async () => {await requireAccess(ctx, session, 'getUnshieldedBalances');return (await state()).unshielded.balances;},getDustBalance: async () => {await requireAccess(ctx, session, 'getDustBalance');const { dust } = await state();return {balance: dust.balance(new Date()),// Count only DUST whose backing NIGHT is unspent: the cap of the current NIGHT balance.cap: dust.totalCoins.filter((coin) => coin.dtime === undefined).reduce((sum, coin) => sum + coin.maxCap, 0n),};},// Addresses and keys go out in Bech32m for the session's network.getShieldedAddresses: async () => {await requireAccess(ctx, session, 'getShieldedAddresses');const { shielded } = await state();return {shieldedAddress: MidnightBech32m.encode(networkId, shielded.address).asString(),shieldedCoinPublicKey: ShieldedCoinPublicKey.codec.encode(networkId, shielded.coinPublicKey).asString(),shieldedEncryptionPublicKey: ShieldedEncryptionPublicKey.codec.encode(networkId, shielded.encryptionPublicKey).asString(),};},getUnshieldedAddress: async () => {await requireAccess(ctx, session, 'getUnshieldedAddress');return { unshieldedAddress: ctx.unshieldedKeystore.getBech32Address().asString() };},getDustAddress: async () => {await requireAccess(ctx, session, 'getDustAddress');const { dust } = await state();return { dustAddress: MidnightBech32m.encode(networkId, dust.address).asString() };},};}Keep credentials out of getConfiguration지갑의 엔드포인트에 URL 쿼리의 Blockfrost 프로젝트 토큰 같은 자격 증명이 담겨 있다면, 그 URL은 보고하지 마세요. 연결된 모든 DApp이 그 토큰을 받게 됩니다.
-
트랜잭션 메서드를 구현합니다. 각 메서드는 DApp의 요청을 Wallet SDK 밸런싱 recipe로 바꾸고, 사용자가 무엇을 승인하는지 보여줍니다. 그다음 결과를 서명하고 증명하고 직렬화합니다. 사용자가 거절하거나 어떤 단계가 실패하면,
revert가 Wallet SDK가 트랜잭션을 위해 따로 떼어 둔 코인을 해제합니다.src/transaction-methods.tsimport { ErrorCodes, type ConnectedAPI, type DesiredOutput } from '@midnight-ntwrk/dapp-connector-api';import * as ledger from '@midnight-ntwrk/ledger-v8';import { MidnightBech32m, ShieldedAddress, UnshieldedAddress } from '@midnightntwrk/wallet-sdk/address-format';import type {BalancingRecipe,CombinedSwapInputs,CombinedTokenTransfer,} from '@midnightntwrk/wallet-sdk/facade';import { ConnectorError, requireAccess, toConnectorError } from './errors';import type { MethodName, Session, WalletContext } from './session';type TransactionMethods = Pick<ConnectedAPI,| 'balanceUnsealedTransaction'| 'balanceSealedTransaction'| 'makeTransfer'| 'makeIntent'| 'submitTransaction'>;// DApps send and receive serialized ledger transactions as hex strings.const toHex = (bytes: Uint8Array) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');function invalidInput<T>(read: () => T): T {try {return read();} catch (error) {throw new ConnectorError(ErrorCodes.InvalidRequest, String(error));}}function fromHex(hex: string): Uint8Array {if (!/^(?:[0-9a-f]{2})+$/i.test(hex)) throw new Error('Expected a hex-encoded transaction.');return Uint8Array.from(hex.match(/../g)!, (byte) => parseInt(byte, 16));}export function transactionMethods(ctx: WalletContext, session: Session): TransactionMethods {const secretKeys = { shieldedSecretKeys: ctx.shieldedSecretKeys, dustSecretKey: ctx.dustSecretKey };const ttl = () => new Date(Date.now() + 60 * 60 * 1000);// payFees defaults to true. With payFees: false, leave DUST out so no DustSpend is added.const tokenKinds = (payFees = true): 'all' | ('shielded' | 'unshielded')[] =>payFees ? 'all' : ['shielded', 'unshielded'];// Build the recipe, ask the user, then sign, prove, and serialize.// Release the coins the SDK set aside if the user declines or a step fails.async function settle(method: MethodName, build: () => Promise<BalancingRecipe>): Promise<{ tx: string }> {const recipe = await build().catch((error: unknown) => {throw toConnectorError(error);});if (!(await ctx.ui.approveTransaction(session.origin, recipe))) {await ctx.facade.revert(recipe);throw new ConnectorError(ErrorCodes.Rejected, `The user declined ${method}.`);}try {const signed = await ctx.facade.signRecipe(recipe, (payload) => ctx.unshieldedKeystore.signData(payload));const finalized = await ctx.facade.finalizeRecipe(signed);return { tx: toHex(finalized.serialize()) };} catch (error) {await ctx.facade.revert(recipe);throw toConnectorError(error);}}// Recipients arrive as Bech32m strings and must belong to the session's network.function toTransfers(outputs: DesiredOutput[]): CombinedTokenTransfer[] {const parse = (recipient: string) => invalidInput(() => MidnightBech32m.parse(recipient));const shielded = outputs.filter((output) => output.kind === 'shielded').map(({ type, value, recipient }) => ({type,amount: value,receiverAddress: invalidInput(() => parse(recipient).decode(ShieldedAddress, session.networkId)),}));const unshielded = outputs.filter((output) => output.kind === 'unshielded').map(({ type, value, recipient }) => ({type,amount: value,receiverAddress: invalidInput(() => parse(recipient).decode(UnshieldedAddress, session.networkId)),}));return [...(shielded.length > 0 ? [{ type: 'shielded' as const, outputs: shielded }] : []),...(unshielded.length > 0 ? [{ type: 'unshielded' as const, outputs: unshielded }] : []),];}return {// A smart contract call from the DApp: proven, not yet bound.balanceUnsealedTransaction: async (tx, options) => {await requireAccess(ctx, session, 'balanceUnsealedTransaction');const unbound = invalidInput(() =>ledger.Transaction.deserialize<ledger.SignatureEnabled, ledger.Proof, ledger.PreBinding>('signature','proof','pre-binding',fromHex(tx),),);return settle('balanceUnsealedTransaction', () =>ctx.facade.balanceUnboundTransaction(unbound, secretKeys, {ttl: ttl(),tokenKindsToBalance: tokenKinds(options?.payFees),}),);},// A finished transaction, such as the other side of a swap.balanceSealedTransaction: async (tx, options) => {await requireAccess(ctx, session, 'balanceSealedTransaction');const sealed = invalidInput(() =>ledger.Transaction.deserialize<ledger.SignatureEnabled, ledger.Proof, ledger.Binding>('signature','proof','binding',fromHex(tx),),);return settle('balanceSealedTransaction', () =>ctx.facade.balanceFinalizedTransaction(sealed, secretKeys, {ttl: ttl(),tokenKindsToBalance: tokenKinds(options?.payFees),}),);},makeTransfer: async (desiredOutputs, options) => {await requireAccess(ctx, session, 'makeTransfer');const transfers = toTransfers(desiredOutputs);return settle('makeTransfer', () =>ctx.facade.transferTransaction(transfers, secretKeys, { ttl: ttl(), payFees: options?.payFees ?? true }),);},makeIntent: async (desiredInputs, desiredOutputs, options) => {await requireAccess(ctx, session, 'makeIntent');// Wallet SDK 1.2.0 puts an unshielded swap intent in segment 1, so no other segment can be honored.if (typeof options.intentId === 'number' && options.intentId !== 1) {throw new ConnectorError(ErrorCodes.InvalidRequest, 'This wallet creates intents in segment 1 only.');}if (typeof options.payFees !== 'boolean') {throw new ConnectorError(ErrorCodes.InvalidRequest, 'makeIntent needs a payFees option.');}// Pass both keys: in Wallet SDK 1.2.0, initSwap ignores the outputs of a kind with no inputs key.const inputs: Required<CombinedSwapInputs> = { shielded: {}, unshielded: {} };for (const { kind, type, value } of desiredInputs) {inputs[kind][type] = (inputs[kind][type] ?? 0n) + value;}const outputs = toTransfers(desiredOutputs);return settle('makeIntent', () =>ctx.facade.initSwap(inputs, outputs, secretKeys, { ttl: ttl(), payFees: options.payFees }),);},submitTransaction: async (tx) => {await requireAccess(ctx, session, 'submitTransaction');const sealed = invalidInput(() =>ledger.Transaction.deserialize<ledger.SignatureEnabled, ledger.Proof, ledger.Binding>('signature','proof','binding',fromHex(tx),),);await ctx.facade.submitTransaction(sealed).catch((error: unknown) => {throw toConnectorError(error);});},};}Show the whole transaction before signingsignRecipe는 트랜잭션의 서명되지 않은 unshielded 입력을 모두 서명합니다. DApp 자신의 intent에 있는 입력까지 포함합니다. unshielded 코인은 공개이므로, DApp은 사용자의 NIGHT을 자기 intent에 넣을 수 있고, 그러면 지갑이 그것을 서명해 넘겨줍니다. 승인 프롬프트는 지갑이 추가한 것만이 아니라 트랜잭션 전체에 걸친 사용자 잔액의 순변화를 보여줘야 합니다.finalizeRecipe가 반환되면 Wallet SDK는 그 트랜잭션을 pending으로 추적합니다. DApp이 끝내 제출하지 않으면, 그 코인은 트랜잭션이 만료될 때까지 따로 떼어진 채 남습니다. 만료 시점은 가장 이른 intent TTL, 또는 지갑이 트랜잭션을 만든 지 세 시간 뒤인 ledger의 DUST grace period 중 먼저 오는 쪽입니다. 이 샘플에서는 한 시간짜리 TTL입니다. Wallet SDK는 지갑이 실행 중일 때만 코인을 해제합니다. pending 트랜잭션 추적기가 1초에 한 번 확인하기 때문입니다. -
signData를 구현합니다.encoding옵션이 말하는 대로 데이터를 디코딩한 다음,midnight_signed_message:<size>:뒤에 디코딩된 바이트를 붙여 서명합니다. 여기서<size>는 바이트 단위 길이입니다. 4.0.1 타입은 결과에서data가 어떤 형태를 취하는지 밝히지 않으며, 이 샘플은 DApp이 보낸 문자열을 반환합니다. 아직 제안 단계인 MIP-0031은 대신data가 접두사 붙은 전체 메시지를 hex로 인코딩해 담게 합니다.src/sign-data.tsimport { ErrorCodes, type ConnectedAPI, type SignDataOptions } from '@midnight-ntwrk/dapp-connector-api';import { ConnectorError, requireAccess } from './errors';import type { Session, WalletContext } from './session';function decodeData(data: string, encoding: SignDataOptions['encoding']): Uint8Array {try {switch (encoding) {case 'text':return new TextEncoder().encode(data);case 'base64':return Uint8Array.from(atob(data), (char) => char.charCodeAt(0));case 'hex':if (!/^(?:[0-9a-f]{2})*$/i.test(data)) throw new Error('not hex');return Uint8Array.from(data.match(/../g) ?? [], (byte) => parseInt(byte, 16));}} catch {// Fall through to the error below.}throw new ConnectorError(ErrorCodes.InvalidRequest, `The data is not valid ${encoding}.`);}// The specification requires this prefix, so signData can never produce a transaction signature.export function prefixMessage(data: Uint8Array): Uint8Array {const header = new TextEncoder().encode(`midnight_signed_message:${data.length}:`);const message = new Uint8Array(header.length + data.length);message.set(header);message.set(data, header.length);return message;}export function signingMethods(ctx: WalletContext, session: Session): Pick<ConnectedAPI, 'signData'> {return {signData: async (data, options) => {await requireAccess(ctx, session, 'signData');const bytes = decodeData(data, options.encoding);if (!(await ctx.ui.approveSignData(session.origin, bytes))) {throw new ConnectorError(ErrorCodes.Rejected, 'The user declined to sign.');}return {data,signature: ctx.unshieldedKeystore.signData(prefixMessage(bytes)),verifyingKey: ctx.unshieldedKeystore.getPublicKey(),};},};} -
위임 증명을 구현합니다. 이 샘플은 WebAssembly에서 로컬로 증명합니다. DApp의 circuit은 DApp의
KeyMaterialProvider를 통해 읽습니다. Midnight 자체의 Zswap·DUST circuit에는 Wallet SDK의 prover 클라이언트가 처음 사용할 때 내려받는 키 자료를 씁니다.src/proving.tsimport type { ConnectedAPI } from '@midnight-ntwrk/dapp-connector-api';import { WasmProver } from '@midnightntwrk/wallet-sdk/prover-client/effect';import { provingProvider, type KeyMaterialProvider } from '@midnight-ntwrk/zkir-v2';import { requireAccess } from './errors';import type { Session, WalletContext } from './session';// Zswap and DUST keys and public parameters, downloaded on first use from a development bucket fixed in the SDK, with no status or hash check.const builtInKeys = WasmProver.makeDefaultKeyMaterialProvider();export function provingMethods(ctx: WalletContext, session: Session): Pick<ConnectedAPI, 'getProvingProvider'> {return {getProvingProvider: async (dappKeys) => {await requireAccess(ctx, session, 'getProvingProvider');const keys: KeyMaterialProvider = {async lookupKey(keyLocation) {// Midnight's own circuits, such as midnight/zswap/output, use the built-in keys.if (keyLocation.startsWith('midnight/')) {return builtInKeys.lookupKey(keyLocation);}const [ir, proverKey, verifierKey] = await Promise.all([dappKeys.getZKIR(keyLocation),dappKeys.getProverKey(keyLocation),dappKeys.getVerifierKey(keyLocation),]);return { ir, proverKey, verifierKey };},getParams: (k) => builtInKeys.getParams(k),};// Proves in WebAssembly in this context. The preimages contain the user's private inputs.return provingProvider(keys);},};}대신 proof server에서 증명하려면, Midnight.js의
httpClientProvingProvider처럼 요청마다 키 자료를 함께 보내세요.@midnightntwrk/wallet-sdk/prover-client/effect에서 내보내지는 Wallet SDK의WasmProver는 Web Worker에서 WebAssembly prover를 실행합니다.WasmProver.makeDefaultKeyMaterialProvider는 Wallet SDK에 고정된 개발용 S3 버킷midnight-s3-fileshare-dev-eu-west-1에서 키와 파라미터를 내려받습니다. 다운로드 성공 여부를 확인하지 않고, 해시도 검증하지 않습니다. 운영 지갑이라면 그 자료를 직접 호스팅하거나 고정하고 해시를 검증하세요. -
객체를 조립하고 freeze합니다. 이 샘플은 보고할 트랜잭션 내역이 없으므로
getTxHistory를 거부합니다.src/connected-api.tsimport { ErrorCodes, type ConnectedAPI } from '@midnight-ntwrk/dapp-connector-api';import { ConnectorError, recordDecision } from './errors';import { provingMethods } from './proving';import { readMethods } from './read-methods';import type { Session, WalletContext } from './session';import { signingMethods } from './sign-data';import { transactionMethods } from './transaction-methods';export function createConnectedAPI(ctx: WalletContext, session: Session): ConnectedAPI {return Object.freeze({...readMethods(ctx, session),...transactionMethods(ctx, session),...signingMethods(ctx, session),...provingMethods(ctx, session),// This sample keeps no history to report, so it declines the call.getTxHistory: async () => {throw new ConnectorError(ErrorCodes.PermissionRejected, 'Transaction history is not available.');},// A chance to ask for several permissions at once. Resolve only after the user answers.hintUsage: async (methodNames) => {const undecided = methodNames.filter((m) => !session.granted.has(m) && !session.rejected.has(m));if (undecided.length > 0) {recordDecision(session, undecided, await ctx.ui.approveMethods(session.origin, undecided));}},});}
Verification
이 테스트는 네트워크가 필요 없습니다. 실제 unshielded 키를 파생하고 signData가 접두사 붙은 메시지를 서명하는지 확인합니다. 거부된 권한이 유지되는지, hintUsage가 힌트로 준 메서드를 허용하는지 확인합니다. 스텁 facade로, makeIntent가 두 입력 종류를 모두 initSwap에 전달하는지, Wallet SDK 실패가 InternalError로 DApp에 도달하는지 확인합니다. makeTransfer가 실제 shielded Bech32m 수신자를 디코딩하는지도 확인하는데, 이는 @scure/base override 없이는 실패합니다. 끝으로, 잘못된 입력과 끊긴 연결에 대한 코드를 확인합니다. 완전한 트랜잭션에는 sync되고 자금이 있는 지갑이 필요하므로, 이 테스트는 그런 지갑을 만들지 않습니다.
import * as ledger from '@midnight-ntwrk/ledger-v8';
import {
MidnightBech32m,
ShieldedAddress,
ShieldedCoinPublicKey,
ShieldedEncryptionPublicKey,
} from '@midnightntwrk/wallet-sdk/address-format';
import type { CombinedTokenTransfer } from '@midnightntwrk/wallet-sdk/facade';
import { HDWallet, Roles } from '@midnightntwrk/wallet-sdk/hd';
import { createKeystore } from '@midnightntwrk/wallet-sdk/unshielded';
import { describe, expect, it } from 'vitest';
import { createConnectedAPI } from '../src/connected-api';
import type { MethodName, Session, WalletContext } from '../src/session';
import { prefixMessage } from '../src/sign-data';
function unshieldedKey(): Uint8Array {
const seed = new Uint8Array(32).fill(7);
const hd = HDWallet.fromSeed(seed);
if (hd.type !== 'seedOk') throw new Error('bad seed');
const derived = hd.hdWallet.selectAccount(0).selectRole(Roles.NightExternal).deriveKeyAt(0);
if (derived.type !== 'keyDerived') throw new Error('derivation failed');
return derived.key;
}
function setup(denied: MethodName[] = [], facade: object = {}) {
const prompts: MethodName[][] = [];
const ctx = {
facade,
unshieldedKeystore: createKeystore(unshieldedKey(), 'preprod'),
ui: {
approveMethods: async (_origin: string, methods: MethodName[]) => {
prompts.push(methods);
return methods.filter((m) => !denied.includes(m));
},
approveSignData: async () => true,
},
} as unknown as WalletContext;
const session: Session = {
origin: 'https://dapp.example',
networkId: 'preprod',
granted: new Set(['getConfiguration']),
rejected: new Set(),
connected: true,
};
return { ctx, session, prompts, api: createConnectedAPI(ctx, session) };
}
describe('Connected API', () => {
it('signs the prefixed message, not the raw data', async () => {
const { api } = setup();
const result = await api.signData('hello midnight', { encoding: 'text', keyType: 'unshielded' });
const raw = new TextEncoder().encode('hello midnight');
expect(new TextDecoder().decode(prefixMessage(raw))).toBe('midnight_signed_message:14:hello midnight');
expect(result.data).toBe('hello midnight');
expect(ledger.verifySignature(result.verifyingKey, prefixMessage(raw), result.signature)).toBe(true);
expect(ledger.verifySignature(result.verifyingKey, raw, result.signature)).toBe(false);
});
it('keeps a refused permission for the rest of the session', async () => {
const { api, prompts } = setup(['getShieldedBalances']);
const rejected = { type: 'DAppConnectorAPIError', code: 'PermissionRejected' };
await expect(api.getShieldedBalances()).rejects.toMatchObject(rejected);
await expect(api.getShieldedBalances()).rejects.toMatchObject(rejected);
expect(prompts).toEqual([['getShieldedBalances']]);
});
it('grants hinted methods and returns Bech32m addresses for the session network', async () => {
const { api } = setup();
await expect(api.hintUsage(['getUnshieldedAddress'])).resolves.toBeUndefined();
const { unshieldedAddress } = await api.getUnshieldedAddress();
expect(unshieldedAddress.startsWith('mn_addr_preprod1')).toBe(true);
});
it('passes both input kinds to initSwap and wraps Wallet SDK failures', async () => {
let received: unknown;
const facade = {
initSwap: async (inputs: unknown) => {
received = inputs;
throw new Error('Insufficient funds');
},
};
const { api } = setup([], facade);
const night = ledger.unshieldedToken().raw;
const intent = api.makeIntent([{ kind: 'unshielded', type: night, value: 10n }], [], {
intentId: 'random',
payFees: true,
});
await expect(intent).rejects.toMatchObject({ type: 'DAppConnectorAPIError', code: 'InternalError' });
expect(received).toEqual({ shielded: {}, unshielded: { [night]: 10n } });
});
it('decodes a shielded Bech32m recipient for makeTransfer', async () => {
let received: CombinedTokenTransfer[] = [];
const facade = {
transferTransaction: async (transfers: CombinedTokenTransfer[]) => {
received = transfers;
throw new Error('Stop after the Wallet SDK call');
},
};
const { api } = setup([], facade);
const keys = ledger.ZswapSecretKeys.fromSeed(new Uint8Array(32).fill(3));
const address = new ShieldedAddress(
ShieldedCoinPublicKey.fromHexString(keys.coinPublicKey),
ShieldedEncryptionPublicKey.fromHexString(keys.encryptionPublicKey),
);
const recipient = MidnightBech32m.encode('preprod', address).asString();
const transfer = api.makeTransfer([{ kind: 'shielded', type: ledger.shieldedToken().raw, value: 5n, recipient }]);
await expect(transfer).rejects.toMatchObject({ code: 'InternalError' });
expect(received).toHaveLength(1);
expect(received[0].type).toBe('shielded');
expect((received[0].outputs[0].receiverAddress as ShieldedAddress).equals(address)).toBe(true);
});
it('rejects malformed transactions and reports a lost connection', async () => {
const { api, session } = setup();
await expect(api.balanceUnsealedTransaction('not-hex')).rejects.toMatchObject({ code: 'InvalidRequest' });
await expect(api.getTxHistory(0, 10)).rejects.toMatchObject({ code: 'PermissionRejected' });
await expect(api.makeIntent([], [], { intentId: 5, payFees: true })).rejects.toMatchObject({
code: 'InvalidRequest',
});
// @ts-expect-error A plain JavaScript DApp can leave payFees out.
await expect(api.makeIntent([], [], { intentId: 1 })).rejects.toMatchObject({ code: 'InvalidRequest' });
session.connected = false;
await expect(api.getUnshieldedAddress()).rejects.toMatchObject({ code: 'Disconnected' });
expect(await api.getConnectionStatus()).toEqual({ status: 'disconnected' });
});
});
✓ test/connected-api.test.ts > Connected API > signs the prefixed message, not the raw data
✓ test/connected-api.test.ts > Connected API > keeps a refused permission for the rest of the session
✓ test/connected-api.test.ts > Connected API > grants hinted methods and returns Bech32m addresses for the session network
✓ test/connected-api.test.ts > Connected API > passes both input kinds to initSwap and wraps Wallet SDK failures
✓ test/connected-api.test.ts > Connected API > decodes a shielded Bech32m recipient for makeTransfer
✓ test/connected-api.test.ts > Connected API > rejects malformed transactions and reports a lost connection
Test Files 2 passed (2)
Tests 9 passed (9)
Network configuration and API versions
DApp은 어느 네트워크와 서비스를 쓸지는 지갑에서, 여러분이 어떤 connector 버전을 구현하는지는 initial API에서 알게 됩니다.
네트워크 ID. 명세는 네트워크 ID 하나를 고정합니다. mainnet입니다. 다른 네트워크에 대해서는 preview, preprod, undeployed처럼 Wallet SDK 설정이 쓰는 ID를 받아들이세요. 지갑이 연결할 수 없는 네트워크는 거부하세요.
설정. getConfiguration은 indexerUri, indexerWsUri, substrateNodeUri, networkId, 그리고 선택적 proverServerUri를 반환합니다. 이들을 네트워크에 맞는 배포처, 되도록 지갑이 쓰는 배포처로 향하게 해, DApp이 사용자가 고른 서비스를 따르도록 하세요. DApp은 networkId를 자신이 요청한 네트워크와 비교할 수 있습니다. 패키지는 proverServerUri를 deprecated로 표시하고, 4.0.0 릴리스 노트는 5.0.0에서 이를 제거할 계획입니다. 일부 DApp은 여전히 이를 읽으므로, 사용자가 proof server를 설정해 둔 동안에는 이를 보고하세요.
연결 상태. getConnectionStatus는 세션이 살아 있는 동안 { status: 'connected', networkId }를, 종료된 뒤에는 { status: 'disconnected' }를 반환합니다.
API 버전. apiVersion은 구현한 @midnight-ntwrk/dapp-connector-api의 정확한 버전, 예컨대 4.0.1로 설정하세요. DApp은 이를 자신이 지원하는 semver 범위와 대조해, 범위 밖의 지갑은 건너뜁니다. 패키지는 정확한 버전으로 고정하고, 업그레이드할 때 apiVersion을 갱신하세요. 4.0.1 버전은 balanceUnsealedTransaction, balanceSealedTransaction, makeTransfer에 payFees를 추가한 패치 릴리스입니다. makeIntent에는 이미 있었습니다.
Security checklist for wallet builders
connector를 출시하기 전에 이 목록을 하나씩 점검하세요.
-
window.midnight을 잠그세요. 지갑이 이를 생성한다면 non-writable·non-configurable로 만들어, 다른 확장이 교체하지 못하게 하세요. - 설치하는 것을 freeze하세요. initial API와 모든 connected API 객체를 freeze하세요. 항목은 새로 만든 UUIDv4 아래에 non-writable·non-configurable 속성으로 추가하세요.
- 정체성을 안정적으로 유지하세요. 악성 확장은 비슷한
rdns나name으로 항목을 설치할 수 있고, DApp은 그런 것을 발견하면 사용자에게 경고해야 합니다. 안정적인rdns는 DApp이 지갑을 알아보는 데 도움이 됩니다. - 모든 DApp을 신뢰할 수 없는 것으로 취급하세요. 연결하기 전에 묻고, 모든 메서드에서 권한을 확인하세요. DApp의 origin은 페이지가 보내는 데이터가 아니라 여러분 자신의 전송 계층에서 가져오세요.
- 서명 전에 전체 비용을 보여주세요. 밸런싱 후에 프롬프트를 띄우고, 트랜잭션 전체에 걸친 사용자 잔액의 순변화를 보여주세요.
signRecipe는 DApp 자신의 intent에 있는 서명되지 않은 입력도 서명합니다. 사용자가 거절하면revert로 떼어 둔 코인을 해제하세요. - 모든
signData메시지에 접두사를 붙이세요. 원시 바이트가 아니라midnight_signed_message:<size>:뒤에 데이터를 붙여 서명하세요. 그래야 DApp이signData로 트랜잭션을 서명하지 못합니다. - 증명 preimage를 보호하세요. 사용자의 private 입력이 담겨 있으므로, 로그에 남기거나 사용자가 고르지 않은 prover에 보내지 마세요.
- 자격 증명을
getConfiguration에서 빼세요. API 키나 토큰이 담기지 않은 엔드포인트만 보고하세요. - 키를 페이지 밖에 두세요. seed와 Wallet SDK는 DApp의 페이지가 닿을 수 없는 컨텍스트에 보관하세요.
Additional resources
- DApp Connector API specification, version 4.0.1
- DApp Connector API reference
- DApp Connector API 4.0.1 release notes
- Midnight Wallet SDK guide
- Running a wallet in a Node.js service
DAppConnectorWalletAdapterin testkit-js. Wallet SDK 위에서 connected API를 구현하는 테스트 지갑입니다. 적합성 기준이 아니라 테스트 도우미입니다. 예를 들어 그signData는 필수 접두사를 붙이지 않습니다.- Integrate a wallet. connector의 DApp 쪽입니다.