끄적끄적

Typescript type 을 활용해 BIP44 Path 를 컴파일 타임에 검사하기 본문

개발/js & ts & node.js

Typescript type 을 활용해 BIP44 Path 를 컴파일 타임에 검사하기

코리이 2023. 7. 16. 18:40

최근에 타입스크립트 타입 챌린지 라는 것을 알게 되어 하루에 한두문제씩 계속 공부중이다. 하지만 실상 개발할 때는 어떻게 활용해야 할까 고민도 많고 익숙하지 않아서 쓰는데 어려움이 많았다. 그래서 어디 적용해보면서 공부를 해볼까 고민하던 중 이전에 블록체인 private key 를 추출하는 개발을 했었는데, 이 때 BIP44 Path 를 이상하게 입력하는 바람에 오랜 시간 삽질했던 경험이 떠올랐다. 그래서 이를 직접적인 타입으로 잡아볼까 해서 한번 만들어 보았다. 참고로 BIP44 에서 path 는 아래와 같은 형식을 정의하고 있다.

# purpose 는 BIP44 에서는 44 로 고정한다.
m / purpose' / coin_type' / account' / change / address_index

이번 포스팅에서는 각각의 내용 자체에 집중하기 보다 회사에서 사용하는 형식에 맞게 어떻게 넣을 수 있을까 하는 고민이다. 이때 많은 회사에서는 확장키를 기반으로 address_index 들을 변경하면서 여러 키를 만들어 낸다. 즉 아래와 같이 분리할 수 있다고 가정해보자.

# BIP32 Derivation Path (xPrivPath)
m/44'/{CoinType}'/{Account}'/{Change: 0 혹은 1}

# Address index
{0 ~ xxx}/xxx/xxx

이제 타입을 정의해보자

ChangeType & 인덱스 기본 타입

우선 가장 쉽게 만들수 있는 ChangeType 과 모든 path 마다 기본적으로 적용되어야 하는 "positive 한 string number + 0 타입" 을 정의해보자. 

ChangeType 의 경우 0 혹은 1 밖에 가질 수 없으므로 쉽게 정의할 수 있다. 하지만 positive string number 타입을 적용하기 위해서는 아래처럼 몇가지 검사를 해야 한다.

 

  1. '0' 인 경우에는 그대로 타입을 적용한다.
  2. string number 타입인 경우가 아니라면 never(사용할 수 없음 - 에러 비슷) 를 반환한다.
  3. 그 후 positive 조건에 맞지 않는 경우에는 never(사용할 수 없음 - 에러 비슷) 를 반환한다.
  4. 모든 조건에 부합하면 타입을 정의한다.
export type Bip44ChangeType = '0' | '1';

export type BIP44IndexInteger<T extends string> = T extends '0' ? T
    : T extends `${number}`
        ? `${T}` extends `-${any}` | `${any}.${any}` | `0${any}`? never
        : T
    : never

그럼 타입이 잘 적용되는지 확인해보자. 그림에서 나와있듯이 조건에 안맞는 경우에는 에러를 확실하게 반환하고 있음을 알 수 있다.

CoinType 적용하기

CoinType 의 경우 BIP44 에서 코인마다 지정된 값이 존재한다. 이를 정확하게 코인마다 타입을 정의해주어야 한다.

그래서 우선적으로 회사에서 사용하고 있는 코인 타입들을 정의해야 한다. (아마 다른 코딩을 하기 위해서도 이미 정의된 회사가 많을 것 같다.) 그 후 코인마다 지정된 값을 정의하고 타입에는 이 둘을 매핑해준다.

export type Coin = 'BITCOIN' | 'ETHEREUM' | 'MATIC' | 'KlAYTN';
export interface IBip44CoinType {
    BITCOIN: 0,
    ETHEREUM: 60,
    MATIC: 966,
    KlAYTN: 8217,
}
export type Bip44CoinType<C extends Coin> = IBip44CoinType[C];

아래처럼 정의된 코인을 사용하지 않거나 매핑이 정확히 이루어지지 않았다면 에러를 반환하게 된다. 

AddressIndexType 적용하기

AddressIndexType 의 경우 이제 root(xPriv) 로부터 tree 형태로 계속해서 확장될 수 있다고 가정한다. 즉 이 타입은 "BIP44IndexInteger/ " 두 가지의 조합된 형태이면서 숫자 사이에만 / 이 들어갈 수 있다. 즉, 코드를 읽어보자면 중간에 "/" 가 존재하는 경우 뒷부분 재귀를 타면서 반복적으로 체크해보는 작업을 한다.

export type AddressIndexType<T extends string> = T extends BIP44IndexInteger<T> ? T
    : T extends `${infer F}/${infer Rest}`
        ? F extends `${BIP44IndexInteger<F>}` ? `${F}/${AddressIndexType<Rest>}` : never
    : never

여러 테스트를 해보게 되면 조건에 맞지 않는 경우 전부 에러가 나는 것을 확인할 수 있다.

XPrivPathType

위에서 AddressIndexPath 을 정의했으니 이제 xPrivPath 를 정의하면 Bip44 Path 에 관한 타입 세이프한 함수를 만들어 낼 수 있다. xPrivePath 의 경우 앞에서도 이야기한 포멧을 가지며 필자의 경우 실수했던 부분이 작은 따옴표(') 가 붙는 다는걸 깜빡해서 였다. 이를 주의해서 만들어보자. 다시 말하자면 포멧은 "m/44'/{CoinType}'/{Account}'/{Change}" 를 가진다. 참고로 Account 타입의 경우에는 단순히 "BIP44IndexInteger" 을 가진다.

아래 코드는 각각의 타입이 올바른지 검사하고 아니라면 never 를 반환하는 형태를 띄고 있다.

export type BIP44XPrivPath<C extends Coin, T extends string> =
    T extends `m/44'/${Bip44CoinType<C>}'/${infer Account}'/${infer Change}`
        ? Account extends BIP44IndexInteger<Account>
            ? Change extends Bip44ChangeType ? T : never
            : never
        : never;

결과를 보면 단순히 string 으로 적고 사용했을때 오류날 포멧을 컴파일 타임에 잘 잡아주고 있는 것을 확인할 수 있다.

정의된 타입을 활용한 함수

그럼 이제 서론에서 이야기 했던 bip44 path 를 통해 키를 뽑아내는 함수 타입을 만들어보자. 구현의 경우 알맞게 구현하면 되므로 현재는 그냥 throw 를 사용하였다. (type 으로 정의하고 테스트 해봐도 된다.) 

export const makePrivateKey = <
    XPrivPath extends string,
    AddressIndex extends string,
>(params: {
    mnemonic: string;
    xPrivateKeyPath: Bip44XPrivPathType<Coin, XPrivPath>,
    addressIndex: Bip44AddressIndexPathType<AddressIndex>,
}): string => {
    // 임시 에러
    throw new Error('not implement');
}

이를 사용할때 타입에 맞지 않는 값을 넣으면 아래 그림처럼 컴파일 타임에 에러를 내뿜는다. 즉 개발 중(런타임 전)에도 잘못된 포맷을 입력했는지 확인할 수 있어 실수를 줄일 수 있게 된다.

컴파일 타임에 타입검사가 엄격하게 이루어진다.

만약 이를 단순 string 으로 정의한다면 아래 그림처럼 컴파일 타임이 아니라 런타임에 잘못된 포맷인지를 잡아내야 한다.

// string 으로 정의
export const makePrivateKey = (params: {
    mnemonic: string;
    xPrivateKeyPath: string,
    addressIndex: string,
}): string => {
    // 임시 에러
    throw new Error('not implement');
}

 

잘못된 포맷인데 정상적인 포맷으로 판단한다.

결론

아무래도 커리어를 자바로 시작하게 되면서 제네릭은 단순히 타입을 추상화해서 사용할 수 있다고만 생각하고 있었다. 하지만 타입스크립트의 경우 타입 표현을 매우 다양하게 표현할 수 있다는 사실을 최근에 계속해서 깨달으면서 신기하다고 느끼는 중이다. 몇년간 타입스크립트를 사용해왔지만 이런 점을 몰랐다는 점이 부끄럽기도 했지만 느슨해진 내 자신을 조금 더 채찍질 하는 계기가 되기도 했다. 물론 이런 타입 포맷 검사의 경우 테스트코드 등을 활용해서 최대한 잡아낼 수는 있기 때문에 따로 공부하지 않아도 언어 자체를 사용하는데에는 문제가 없지만 "타입스크립트" 를 쓰는 개발자라면 언어 특수성은 조금 파보는 것도 좋을 것 같다.