저는 몇 달 전부터 비트코인과 같은 암호화폐 관련 주제를 팔로우하고 있는데, 그 동안 벌어진 모든 일에 매우 흥미를 느끼고 있습니다.
웹 애플리케이션 개발자로서 제가 특히 관심을 갖고 있는 주제 중 하나는 암호화폐 거래소와 이를 만드는 방법에 관한 것입니다. 겉으로 보기에 이러한 애플리케이션은 계정을 관리하고, 비트코인을 USD와 같은 법정화폐로 변환하고, 다른 사람에게 비트코인을 송금하는 도구인 것 같지만, 그 이상의 기능이 있을까요?
Node.js와 NoSQL 데이터베이스를 사용한 몇 가지 예를 살펴보겠습니다, 카우치베이스를 통해 암호화폐 거래소를 모델로 한 주제를 다룹니다.
관련 주제에 대한 업데이트:
면책 조항: 저는 암호화폐 전문가가 아니며 금융 서비스나 거래소 관련 개발에 참여한 적도 없습니다. 저는 해당 주제에 대한 애호가이며 이 글에서 얻은 모든 정보는 본인의 책임하에 적절히 테스트하고 사용해야 합니다.
요점
이 글에서 얻을 수 있는 것과 얻지 못할 것이 몇 가지 있습니다. 예를 들어, 이 글에서 얻을 수 없는 내용부터 시작하겠습니다:
- USD와 같은 명목 화폐를 이체하도록 은행 또는 신용카드 서비스를 구성하지 않습니다.
- 비트코인 네트워크에 서명된 거래를 브로드캐스팅하여 송금을 완료하지 않습니다.
이 글에서 기대할 수 있는 몇 가지 사항을 소개합니다:
- 주어진 시드에 대해 각각 사용자 지갑을 나타내는 키를 무제한으로 생성할 수 있는 계층적 결정론적(HD) 지갑을 만들겠습니다.
- 마스터 시드를 기반으로 지갑이 있는 사용자 계정을 각각 생성합니다.
- 실제로 법정화폐로 작업하지 않고 거래소에서 입금, 출금, 자금 이체를 나타내는 거래를 생성합니다.
- 비트코인 네트워크에서 잔액을 조회합니다.
- 비트코인 네트워크에서 브로드캐스트할 서명된 트랜잭션을 생성합니다.
이 글에서 더 개선할 수 있는 부분이 많다는 것을 알게 될 것입니다. 개선할 수 있는 부분이 있다면 댓글로 공유해 주세요. 앞서 말했듯이 저는 이 주제에 대한 전문가가 아니라 팬일 뿐입니다.
프로젝트 요구 사항
이 프로젝트를 성공적으로 수행하려면 몇 가지 요구 사항을 충족해야 합니다:
- Node.js 6 이상을 설치 및 구성해야 합니다.
- Couchbase 5.1+가 설치되어 있고 버킷 및 RBAC 프로필을 사용할 수 있도록 구성되어 있어야 합니다.
요점은 Couchbase를 시작하고 실행하는 방법을 살펴보지 않겠다는 것입니다. 어려운 과정은 아니지만 애플리케이션 계정으로 설정된 버킷과 N1QL로 쿼리하기 위한 인덱스가 필요합니다.
종속성이 있는 Node.js 애플리케이션 만들기
로직 추가를 시작하기 전에 새 Node.js 애플리케이션을 만들고 종속성을 다운로드하겠습니다. 컴퓨터 어딘가에 프로젝트 디렉토리를 만들고 해당 디렉터리 내의 CLI에서 다음 명령을 실행합니다:
1 2 3 4 5 6 7 8 9 |
npm init -y npm 설치 카우치베이스 --저장 npm 설치 express --저장 npm 설치 body-파서 --저장 npm 설치 joi --저장 npm 설치 요청 요청-약속 --저장 npm 설치 uuid --저장 npm 설치 비트코어-lib --저장 npm 설치 비트코어-니모닉 --저장 |
한 줄로 모든 종속성 설치를 완료할 수도 있었지만 읽기 쉽도록 명확하게 하고 싶었습니다. 그렇다면 위의 명령어에서 무엇을 하고 있을까요?
먼저, 새 Node.js 프로젝트를 생성하여 초기화합니다. package.json 파일을 생성합니다. 그런 다음 종속성을 다운로드하고 이를 package.json 파일을 통해 --저장
플래그.
이 예제에서는 Express 프레임워크를 사용하겠습니다. 이 예제에서는 express
, 본문 파서
및 joi
패키지는 모두 요청 데이터를 수락하고 검증하는 것과 관련이 있습니다. 퍼블릭 비트코인 노드와 통신할 것이기 때문에, 저희는 요청
그리고 요청-약속
약속 포장 패키지. 매우 인기 있는 비트코어 라이브러리
패키지를 사용하면 지갑을 생성하고 트랜잭션에 서명할 수 있습니다. 비트코어-니모닉
패키지를 사용하면 HD 지갑 키에 사용할 수 있는 시드를 생성할 수 있습니다. 마지막으로 카우치베이스
그리고 uuid
데이터베이스 작업에 사용됩니다.
이제 프로젝트를 더 잘 구성하고 싶을 것입니다. 프로젝트 디렉토리에 다음 디렉터리와 파일이 없는 경우 추가합니다:
1 2 3 4 5 6 7 8 9 |
패키지.json 구성.json 앱.js 경로 계정.js 트랜잭션.js 유틸리티.js 클래스 도우미.js |
모든 API 엔드포인트는 카테고리로 나뉘어 각각의 적절한 라우팅 파일에 배치됩니다. 이렇게 할 필요는 없지만, 프로젝트를 조금 더 깔끔하게 만들 수 있습니다. 경로에서 수많은 비트코인과 데이터베이스 로직을 제거하기 위해, 데이터 유효성 검사가 아닌 모든 것을 우리의 classes/helper.js 파일입니다. The config.json 파일에는 니모닉 시드뿐만 아니라 모든 데이터베이스 정보가 들어 있습니다. 현실적인 시나리오에서 이 파일은 금과 같이 취급되어야 하며 가능한 한 많은 보호를 받아야 합니다. 이 파일은 app.js 파일에는 경로 연결, 데이터베이스 연결 등을 위한 모든 구성 및 부트스트랩 로직이 있습니다.
편의를 위해 프로젝트에 종속성을 하나 더 추가하고 설정해 보겠습니다:
1 |
npm 설치 노데몬 --저장-dev |
그리고 노데몬
패키지를 사용하면 파일을 변경할 때마다 프로젝트를 핫 로드할 수 있습니다. 필수 사항은 아니지만 빌드하는 동안 시간을 절약할 수 있습니다.
열기 package.json 파일을 열고 다음 스크립트를 추가하여 실행합니다:
1 2 3 4 5 6 |
... "스크립트": { "test": "echo \"오류: 지정된 테스트 없음\" && exit 1", "시작": "./node_modules/nodemon/bin/nodemon.js app.js" }, ... |
이 시점에서 애플리케이션의 개발 프로세스를 시작할 수 있습니다.
데이터베이스 및 비트코인 로직 개발
애플리케이션을 개발할 때 API 엔드포인트에 대해 걱정하기 전에 데이터베이스와 비트코인 관련 로직을 만들려고 합니다.
우리는 이 프로젝트의 classes/helper.js 파일을 만듭니다. 파일을 열고 다음을 포함합니다:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
const 카우치베이스 = require("couchbase"); const 요청 = require("요청-약속"); const UUID = require("uuid"); const 비트코어 = require("bitcore-lib"); 클래스 도우미 { 생성자(호스트, 버킷, 사용자 이름, 비밀번호, seed) { 이.클러스터 = new 카우치베이스.클러스터("couchbase://" + 호스트); 이.클러스터.인증(사용자 이름, 비밀번호); 이.버킷 = 이.클러스터.오픈버킷(버킷); 이.마스터 = seed; } createKeyPair(계정) { } getWalletBalance(주소) { } getAddressBalance(주소) { } getAddressUtxo(주소) { } 삽입(데이터, id = UUID.v4()) { } createAccount(데이터) { } 추가주소(계정) { } 계정 잔액 가져오기(계정) { } getMasterAddresses() { } getMasterKeyPairs() { } getMasterAddressWithMinimum(주소, 금액) { } getMasterChangeAddress() { } getAddresses(계정) { } getPrivateKeyFromAddress(계정, 주소) { } 계정에서 생성된 트랜잭션(계정, 출처, 목적지, 금액) { } createTransactionFromMaster(계정, 목적지, 금액) { } } 모듈.수출 = 도우미; |
이 클래스를 애플리케이션의 싱글톤으로 전달할 것입니다. 이 클래스는 생성자
메서드를 사용하여 데이터베이스 클러스터에 대한 연결을 설정하고, 버킷을 열고, 인증합니다. 열린 버킷은 이 헬퍼 클래스 전체에서 사용됩니다.
데이터베이스 로직보다 먼저 비트코인 로직을 살펴봅시다.
HD 지갑에 대해 잘 모르시겠지만, 기본적으로 하나의 시드에서 파생된 지갑입니다. 이 씨앗을 사용해 자식을 낳고, 그 자식이 또 자식을 낳는 식으로 계속해서 자식을 낳을 수 있습니다.
1 2 3 4 5 |
createKeyPair(계정) { var 계정 = 이.마스터.파생자식(계정); var 키 = 계정.파생자식(수학.무작위() * 10000 + 1); 반환 { "비밀": 키.privateKey.toWIF().toString(), "주소": 키.privateKey.toAddress().toString() } } |
그리고 마스터
변수의 createKeyPair
함수는 최상위 시드 키를 나타냅니다. 각 사용자 계정은 해당 키의 직접 자식이 되므로 이 키를 기반으로 자식을 파생합니다. 계정
값입니다. The 계정
값은 사람 번호이며, 생성되는 모든 계정은 증분 번호를 갖게 됩니다. 하지만 계정 키를 생성한 후 여기서 끝내지는 않을 것입니다. 대신 동일한 키를 두 번 이상 사용하고 싶지 않을 경우를 대비해 각 계정 키에는 10,000개의 개인 키와 공개 키가 있습니다. 무작위로 키를 생성한 후에는 키를 반환합니다.
마찬가지로, 저희는 getMasterChangeAddress
함수를 다음과 같이 사용할 수 있습니다:
1 2 3 4 5 |
getMasterChangeAddress() { var 계정 = 이.마스터.파생자식(0); var 키 = 계정.파생자식(수학.무작위() * 10 + 1); 반환 { "비밀": 키.privateKey.toWIF().toString(), "주소": 키.privateKey.toAddress().toString() } } |
계정 생성을 시작하면 0부터 시작하여 거래소나 웹 애플리케이션 또는 원하는 이름을 지정할 수 있습니다. 또한 이 계정에 10개의 가능한 주소를 할당합니다. 이 주소는 두 가지 일을 할 수 있습니다. 첫 번째는 다른 계좌로 이체하기 위해 비트코인을 보관하는 것이고, 두 번째는 잔돈, 즉 잔돈을 받는 것입니다. 비트코인 거래에서는 원하는 금액보다 적더라도 미사용 거래 산출량(UTXO)은 모두 사용해야 한다는 점을 기억하세요. 즉, 원하는 금액은 목적지로 전송되고 나머지는 이 10개의 주소 중 하나로 다시 전송됩니다.
이 작업을 수행하는 다른 방법이나 더 좋은 방법이 있나요? 물론이지만 이 예제에서는 이 방법이 효과적입니다.
HD 시드를 사용하여 사용하거나 생성한 주소의 잔액을 확인하려면 공개 비트코인 탐색기를 사용할 수 있습니다:
1 2 3 |
getAddressBalance(주소) { 반환 요청("https://insight.bitpay.com/api/addr/" + 주소); } |
위의 함수는 주소를 받아 사토시뿐만 아니라 십진수 형식의 잔액을 가져옵니다. 앞으로는 사토시 값만이 관련성이 있는 유일한 값입니다. 특정 계정의 주소가 X개라면 이와 같은 함수를 사용해 총 잔액을 구할 수 있습니다:
1 2 3 4 5 6 7 8 9 10 11 12 |
getWalletBalance(주소) { var 약속 = []; 에 대한(var i = 0; i < 주소.길이; i++) { 약속.push(요청("https://insight.bitpay.com/api/addr/" + 주소[i])); } 반환 약속.모두(약속).다음(결과 => { var balance = 결과.감소((a, b) => a + JSON.parse(b).balanceSat, 0); 반환 new 약속((해결, 거부) => { 해결({ "balance": balance }); }); }); } |
위의 예에서 getWalletBalance
함수를 사용하여 각 주소에 대한 요청을 하고, 요청이 모두 완료되면 잔액을 추가하여 반환할 수 있습니다.
암호화폐를 송금하려면 주소 잔액만으로는 부족합니다. 대신 특정 주소의 미사용 트랜잭션 출력(UTXO)을 알아야 합니다. 이는 BitPay의 동일한 API를 사용하여 확인할 수 있습니다:
1 2 3 4 5 6 7 8 9 10 |
getAddressUtxo(주소) { 반환 요청("https://insight.bitpay.com/api/addr/" + 주소 + "/utxo").다음(utxo => { 반환 new 약속((해결, 거부) => { 만약(JSON.parse(utxo).길이 == 0) { 거부({ "메시지": "사용 가능한 미사용 거래가 없습니다." }); } 해결(JSON.parse(utxo)); }); }); } |
미사용 트랜잭션 출력이 없으면 전송할 수 있는 트랜잭션이 없다는 뜻이므로 대신 오류를 발생시켜야 합니다. 전송할 수 있는 양이 충분하다는 것은 다른 이야기입니다.
예를 들어 다음과 같은 작업을 수행할 수 있습니다:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
getMasterAddressWithMinimum(주소, 금액) { var 약속 = []; 에 대한(var i = 0; i < 주소.길이; i++) { 약속.push(요청("https://insight.bitpay.com/api/addr/" + 주소[i])); } 반환 약속.모두(약속).다음(결과 => { 에 대한(var i = 0; i < 결과.길이; i++) { 만약(결과[i].balanceSat >= 금액) { 반환 해결({ "주소": 결과[i].addrStr }); } } 거부({ "메시지": "교환 자금이 충분하지 않음" }); }); } |
위의 함수에서는 주소 목록을 가져와서 어떤 주소가 제공한 임계값보다 더 많은 금액을 보유하고 있는지 확인합니다. 잔액이 충분한 주소가 하나도 없다면 해당 메시지를 전달해야 합니다.
마지막 유틸리티 관련 기능은 이미 보신 적이 있는 기능입니다:
1 2 3 4 5 6 7 8 9 10 |
getMasterKeyPairs() { var 키쌍 = []; var 키; var 계정 = 이.마스터.파생자식(0); 에 대한(var i = 1; i <= 10; i++) { 키 = 계정.파생자식(i); 키쌍.push({ "비밀": 키.privateKey.toWIF().toString(), "주소": 키.privateKey.toAddress().toString() }); } 반환 키쌍; } |
위의 기능을 사용하면 서명 및 값 확인에 유용한 마스터 키를 모두 얻을 수 있습니다.
다시 한 번 말씀드리자면, 저는 생성되는 키 수에 유한 값을 사용하고 있습니다. 여러분도 똑같이 할 수도 있고 원하지 않을 수도 있습니다.
이제 애플리케이션 데이터를 저장하기 위한 몇 가지 NoSQL 로직에 대해 자세히 알아보겠습니다.
현재로서는 데이터베이스에 데이터가 없습니다. 첫 번째 논리 단계는 데이터를 생성하는 것입니다. 독립적으로 특별히 어렵지는 않지만 다음과 같은 함수를 만들 수 있습니다:
1 2 3 4 5 6 7 8 9 10 11 |
삽입(데이터, id = UUID.v4()) { 반환 new 약속((해결, 거부) => { 이.버킷.삽입(id, 데이터, (오류, 결과) => { 만약(오류) { 거부({ "code": 오류.코드, "메시지": 오류.메시지 }); } 데이터.id = id; 해결(데이터); }); }); } |
기본적으로 문서 키로 사용할 객체와 ID를 받습니다. 문서 키가 제공되지 않으면 자동으로 문서 키를 생성합니다. 모든 작업이 완료되면 응답에 ID를 포함하여 생성된 내용을 반환합니다.
사용자 계정을 만들려고 한다고 가정해 보겠습니다. 다음과 같이 하면 됩니다:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
createAccount(데이터) { 반환 new 약속((해결, 거부) => { 이.버킷.카운터("계정::총계", 1, { "initial": 1 }, (오류, 결과) => { 만약(오류) { 거부({ "code": 오류.코드, "메시지": 오류.메시지 }); } 데이터.계정 = 결과.값; 이.삽입(데이터).다음(결과 => { 해결(결과); }, 오류 => { 거부(오류); }); }); }); } |
이 예제에서 계정은 자동 증가 숫자 값에 의해 구동된다는 점을 기억하세요. 증분값을 생성하는 방법은 카운터
를 호출합니다. 카운터가 존재하지 않으면 1로 초기화하고 다음 호출마다 카운터를 증가시킵니다. 0은 애플리케이션 키를 위해 예약되어 있다는 점을 기억하세요.
카운터 값을 얻은 후 전달받은 객체에 값을 추가하고 삽입 함수를 호출하면 이 경우 고유 ID가 생성됩니다.
엔드포인트가 없기 때문에 아직 확인하지 못했지만 계정을 만들 때 주소 정보는 없고 계정 식별자만 있다고 가정해 보겠습니다. 사용자의 주소를 추가하고 싶을 수 있습니다:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
추가주소(계정) { 반환 new 약속((해결, 거부) => { 이.버킷.get(계정, (오류, 결과) => { 만약(오류) { 거부({ "code": 오류.코드, "메시지": 오류.메시지 }); } var 키페어 = 이.createKeyPair(결과.값.계정); 이.버킷.mutateIn(계정).배열 추가("주소", 키페어, true).실행((오류, 결과) => { 만약(오류) { 거부({ "code": 오류.코드, "메시지": 오류.메시지 }); } 해결({ "주소": 키페어.주소 }); }); }); }); } |
주소를 추가할 때는 먼저 문서 ID로 사용자를 찾습니다. 문서가 검색되면 숫자 계정 값을 가져와 10,000개의 옵션으로 구성된 새로운 키쌍을 만듭니다. 주소에 하위 문서 작업을 수행하면 문서를 다운로드하거나 조작할 필요 없이 사용자 문서에 키쌍을 추가할 수 있습니다.
우리가 방금 한 일에 대해 매우 심각한 점을 주목해야 합니다.
암호화되지 않은 개인 키와 공개 주소를 사용자 문서에 저장하고 있습니다. 이는 프로덕션에서는 절대 안 되는 일입니다. 사람들이 키를 도난당했다는 기사를 읽은 적이 있으신가요? 실제로는 데이터를 삽입하기 전에 암호화하고 싶을 것입니다. Node.js 암호화 라이브러리를 사용하거나 Couchbase Server 5.5를 사용하는 경우 Couchbase용 Node.js SDK에서 암호화를 제공할 수 있습니다. 하지만 여기서는 다루지 않겠습니다.
이제 데이터베이스에 계정 데이터와 주소가 생겼습니다. 해당 데이터를 쿼리해 보겠습니다:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
getAddresses(계정) { var 문, 매개변수; 만약(계정) { 문 = "SELECT VALUE addresses.address FROM " + 이.버킷._name + " AS 계정 사용 키 $id UNNEST 계정.주소를 주소로"; 매개변수 = { "id": 계정 }; } else { 문 = "SELECT VALUE addresses.address FROM " + 이.버킷._name + " AS 계정 UNNEST 계정.주소를 주소로 WHERE 계정.유형 = '계정'"; } var 쿼리 = 카우치베이스.N1qlQuery.fromString(문); 반환 new 약속((해결, 거부) => { 이.버킷.쿼리(쿼리, 매개변수, (오류, 결과) => { 만약(오류) { 거부({ "code": 오류.코드, "메시지": 오류.메시지 }); } 해결(결과); }); }); } |
위 getAddresses
함수는 두 가지 중 하나를 수행할 수 있습니다. 계정이 제공된 경우 N1QL 쿼리를 사용하여 해당 특정 계정의 모든 주소를 가져옵니다. 계정이 제공되지 않은 경우 데이터베이스의 모든 계정에 대한 모든 주소를 가져옵니다. 두 시나리오 모두 공개 주소만 가져오며 민감한 정보는 가져오지 않습니다. 매개변수화된 N1QL 쿼리를 사용하면 데이터베이스 결과를 클라이언트에 다시 반환할 수 있습니다.
쿼리에서 주목해야 할 사항입니다.
사용자 문서의 배열에 주소를 저장하고 있습니다. 사용자 문서에 UNNEST
연산자를 사용하면 해당 주소를 평평하게 만들고 응답을 더 매력적으로 만들 수 있습니다.
이제 주소가 있고 해당 개인 키를 가져오고 싶다고 가정해 보겠습니다. 다음과 같이 할 수 있습니다:
1 2 3 4 5 6 7 8 9 10 11 12 |
getPrivateKeyFromAddress(계정, 주소) { var 문 = "SELECT VALUE keypairs.secret FROM " + 이.버킷._name + " AS 계정 사용 키 $account UNNEST 계정.주소 AS 키쌍 WHERE 키쌍.주소 = $address"; var 쿼리 = 카우치베이스.N1qlQuery.fromString(문); 반환 new 약속((해결, 거부) => { 이.버킷.쿼리(쿼리, { "계정": 계정, "주소": 주소 }, (오류, 결과) => { 만약(오류) { 거부({ "code": 오류.코드, "메시지": 오류.메시지 }); } 해결({ "비밀": 결과[0] }); }); }); } |
특정 계정이 주어지면 이전에 본 것과 유사한 쿼리를 만듭니다. 이번에는 UNNEST
를 사용하여 어디
조건을 사용하여 일치하는 주소에 대해서만 결과를 제공합니다. 원한다면 배열 연산을 대신 수행할 수도 있었을 것입니다. 카우치베이스와 N1QL을 사용하면 문제를 해결할 수 있는 다양한 방법이 있습니다.
여기서 조금 기어를 바꾸려고 합니다. 지금까지는 NoSQL 데이터베이스에서 계정 중심의 작업을 수행했습니다. 또 다른 중요한 측면은 트랜잭션입니다. 예를 들어 사용자 X가 비트코인으로 USD 통화를 입금하고 사용자 Y가 출금한다고 가정해 보겠습니다. 우리는 해당 거래 정보를 저장하고 쿼리해야 합니다.
API 엔드포인트 함수는 트랜잭션 데이터를 저장하지만 여전히 쿼리할 수 있습니다.
1 2 3 4 5 6 7 8 9 10 11 12 |
계정 잔액 가져오기(계정) { var 문 = "SELECT SUM(tx.satoshis) AS balance FROM " + 이.버킷._name + " AS tx WHERE tx.type = '트랜잭션' AND tx.account = $account"; var 쿼리 = 카우치베이스.N1qlQuery.fromString(문); 반환 new 약속((해결, 거부) => { 이.버킷.쿼리(쿼리, { "계정": 계정 }, (오류, 결과) => { 만약(오류) { 거부({ "code": 오류.코드, "메시지": 오류.메시지 }); } 해결({ "balance": 결과[0].balance }); }); }); } |
계정이 주어졌을 때 특정 사용자의 계정 잔액을 가져오고 싶습니다.
잠깐만요, 계정 잔액 기능을 이미 만들지 않았나요? 엄밀히 말하면 만들긴 했지만, 계정 잔액이 아니라 지갑 잔액을 확인하기 위한 기능이었죠.
여기서부터 제 경험의 일부가 회색 지대로 변합니다. 비트코인을 이체할 때마다 수수료가 발생하고 때로는 수수료가 상당히 비쌉니다. 입금할 때 채굴자 수수료가 부과되기 때문에 지갑으로 돈을 이체하는 것은 비용 효율적이지 않습니다. 그러면 인출하고 다시 이체하는 데에도 수수료가 부과됩니다. 그 시점에는 이미 비트코인의 대부분을 잃은 상태입니다.
대신 거래소에는 증권사 머니마켓 계좌와 유사한 보유 계좌가 있다고 생각합니다. 계좌에 있어야 할 돈의 기록이 있지만 엄밀히 말해 지갑에 있는 것은 아닙니다. 이체를 원할 때는 사용자 주소가 아닌 애플리케이션 주소에서 이체하는 것입니다. 인출할 때는 해당 금액이 차감되는 것입니다.
다시 말하지만, 이것이 정말 그렇게 작동하는 방식인지는 모르겠지만 모든 곳에서 수수료를 피하기 위해 그렇게 하는 것이 제 방식입니다.
다시 돌아가서 계정 잔액 가져오기
함수를 호출합니다. 모든 거래의 합계를 취하고 있습니다. 입금은 양수 값을 가지며, 송금과 인출은 음수 값을 갖습니다. 이 정보를 합산하면 지갑 잔액을 제외한 정확한 숫자를 알 수 있습니다. 나중에 지갑 잔액이 있는 계정을 만들 예정입니다.
계정 잔액에 대해 아는 것이 거의 없으므로 지갑에서 거래를 생성해 볼 수 있습니다:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
계정에서 생성된 트랜잭션(계정, 출처, 목적지, 금액) { 반환 new 약속((해결, 거부) => { 이.getAddressBalance(출처).다음(sourceAddress => { 만약(sourceAddress.balanceSat < 금액) { 반환 거부({ "메시지": "계정에 잔액이 부족합니다." }); } 이.getPrivateKeyFromAddress(계정, 출처).다음(키페어 => { 이.getAddressUtxo(출처).다음(utxo => { var 트랜잭션 = new 비트코어.거래(); 에 대한(var i = 0; i < utxo.길이; i++) { 트랜잭션.에서(utxo[i]); } 트랜잭션.에(목적지, 금액); 이.추가주소(계정).다음(변경 => { 트랜잭션.변경(변경.주소); 트랜잭션.sign(키페어.비밀); 해결(트랜잭션); }, 오류 => 거부(오류)); }, 오류 => 거부(오류)); }, 오류 => 거부(오류)); }, 오류 => 거부(오류)); }); } |
소스 주소, 목적지 주소, 금액이 제공되면 나중에 비트코인 네트워크에 브로드캐스트할 트랜잭션을 생성하고 서명할 수 있습니다.
먼저 해당 소스 주소의 잔액을 확인합니다. 예상 송금액을 충족하기에 충분한 UTXO가 있는지 확인해야 합니다. 이 예에서는 단일 주소 트랜잭션을 수행한다는 점에 유의하세요. 더 복잡하게 만들고 싶다면 단일 트랜잭션에서 여러 주소로 전송할 수 있습니다. 여기서는 그렇게 하지 않겠습니다. 단일 주소에 충분한 자금이 있으면 해당 주소의 개인 키와 UTXO 데이터를 받습니다. UTXO 데이터로 비트코인 트랜잭션을 생성하고, 목적지 주소와 변경 주소를 적용한 다음, 개인 키를 사용해 트랜잭션에 서명할 수 있습니다. 응답을 브로드캐스트할 수 있습니다.
마찬가지로 보유 계좌에서 비트코인을 이체하고 싶다고 가정해 보겠습니다:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
createTransactionFromMaster(계정, 목적지, 금액) { 반환 new 약속((해결, 거부) => { 이.계정 잔액 가져오기(계정).다음(계정 잔액 => { 만약(계정 잔액.balance < 금액) { 거부({ "메시지": "계정에 잔액이 부족합니다." }); } var m키쌍 = 이.getMasterKeyPairs(); var 마스터 주소 = m키쌍.지도(a => a.주소); 이.getMasterAddressWithMinimum(마스터 주소, 금액).다음(자금 => { 이.getAddressUtxo(자금.주소).다음(utxo => { var 트랜잭션 = new 비트코어.거래(); 에 대한(var i = 0; i < utxo.길이; i++) { 트랜잭션.에서(utxo[i]); } 트랜잭션.에(목적지, 금액); var 변경 = 도우미.getMasterChangeAddress(); 트랜잭션.변경(변경.주소); 에 대한(var j = 0; j < m키쌍.길이; j ++) { 만약(m키쌍[j].주소 == 자금.주소) { 트랜잭션.sign(m키쌍[j].비밀); } } var tx = { 계정: 계정, 사토시: (금액 * -1), 타임스탬프: (new 날짜()).getTime(), 상태: "transfer", 유형: "트랜잭션" }; 이.삽입(tx).다음(결과 => { 해결(트랜잭션); }, 오류 => 거부(오류)); }, 오류 => 거부(오류)); }, 오류 => 거부(오류)); }, 오류 => 거부(오류)); }); } |
거래소 주소가 수요를 충족하기 위해 엄청난 양의 비트코인으로 가득 차 있다고 가정해 보겠습니다.
첫 번째 단계는 보유 계좌에 자금이 있는지 확인하는 것입니다. 각 트랜잭션을 합산하는 쿼리를 실행하여 유효한 숫자를 얻을 수 있습니다. 자금이 충분하다면 마스터 키 쌍 10개와 주소를 모두 얻을 수 있습니다. 어느 주소에 송금할 자금이 충분한지 확인해야 합니다. 여기서 단일 주소 트랜잭션은 더 많을 수 있다는 점을 기억하세요.
주소에 충분한 자금이 있으면 UTXO 데이터를 가져와 트랜잭션을 시작합니다. 이번에는 지갑을 소스 주소로 사용하는 대신 거래소의 지갑을 사용합니다. 서명된 트랜잭션을 받은 후 데이터베이스에 트랜잭션을 생성하여 송금하는 가치를 빼고자 합니다.
API 엔드포인트로 넘어가기 전에 몇 가지 사항을 다시 한 번 강조하고 싶습니다:
- 인기 있는 거래소는 지갑 주소에 부과되는 수수료를 피하기 위해 홀딩 계좌를 가지고 있다고 가정하고 있습니다.
- 이 예에서는 보유하고 있는 것을 집계하는 대신 단일 주소 트랜잭션을 사용하고 있습니다.
- 계정 문서에 있는 주요 데이터를 암호화해야 하는데 암호화하지 않고 있습니다.
- 트랜잭션을 브로드캐스트하는 것이 아니라 생성하는 것뿐입니다.
이제 간단한 부분인 API 엔드포인트에 집중해 보겠습니다.
Express 프레임워크로 RESTful API 엔드포인트 설계하기
처음에 구성한 것처럼 엔드포인트는 그룹 역할을 하는 세 개의 파일로 나뉩니다. 가장 작고 단순한 엔드포인트 그룹부터 시작하겠습니다. 이 엔드포인트는 다른 무엇보다도 유용합니다.
프로젝트의 routes/utility.js 파일을 열고 다음을 포함하세요:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
const 비트코어 = require("bitcore-lib"); const 니모닉 = require("비트코어-니모닉"); 모듈.수출 = (앱) => { 앱.get("/니모닉", (요청, 응답) => { 응답.보내기({ "니모닉": (new 니모닉(니모닉.단어.영어)).toString() }); }); 앱.get("/밸런스/값", (요청, 응답) => { 요청("https://api.coinmarketcap.com/v1/ticker/bitcoin/").다음(시장 => { 응답.보내기({ "value": "$" + (JSON.parse(시장)[0].가격_USD * 요청.쿼리.balance).toFixed(2) }); }, 오류 => { 응답.상태(500).보내기(오류); }); }); } |
여기에는 니모닉 시드를 생성하는 엔드포인트와 비트코인 잔액의 법정화폐 가치를 가져오는 엔드포인트가 있습니다. 둘 다 꼭 필요한 것은 아니지만, 처음 시작할 때 시드 값을 생성하여 나중에 구성 파일에 저장하는 것이 좋을 수 있습니다.
이제 프로젝트의 경로/계정.js 파일에 저장하여 계정 정보를 처리할 수 있도록 합니다:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
const 요청 = require("요청-약속"); const Joi = require("joi"); const 도우미 = require("../app").도우미; 모듈.수출 = (앱) => { 앱.post("/계정", (요청, 응답) => { }); 앱.put("/계정/주소/:ID", (요청, 응답) => { }); 앱.get("/계정/주소/:id", (요청, 응답) => { }); 앱.get("/주소", (요청, 응답) => { }); 앱.get("/계정/잔액/:ID", (요청, 응답) => { }); 앱.get("/주소/잔액/:ID", (요청, 응답) => { }); } |
헬퍼 클래스에서 헬퍼 클래스를 가져오고 있음을 알 수 있습니다. app.js 파일에서 아직 시작하지 않았습니다. 특별한 것은 아니지만 지금은 그냥 사용해도 나중에 이해가 될 것입니다.
계정을 만들 때 고려해야 할 사항은 다음과 같습니다:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
앱.post("/계정", (요청, 응답) => { var 모델 = Joi.객체().키({ 이름: Joi.문자열().필수(), 성: Joi.문자열().필수(), 유형: Joi.문자열().금지됨().기본값("계정") }); Joi.유효성 검사(요청.body, 모델, { stripUnknown: true }, (오류, 값) => { 만약(오류) { 반환 응답.상태(500).보내기(오류); } 도우미.createAccount(값).다음(결과 => { 응답.보내기(값); }, 오류 => { 응답.상태(500).보내기(오류); }); }); }); |
Joi를 사용하면 요청 본문의 유효성을 검사하고 올바르지 않은 경우 오류를 발생시킬 수 있습니다. 요청 본문이 올바르다고 가정하면, 요청 본문이 올바르다고 가정하면 createAccount
함수를 사용하여 데이터베이스에 새 계정을 저장할 수 있습니다.
계정을 만들었으면 주소를 몇 개 추가할 수 있습니다:
1 2 3 4 5 6 7 |
앱.put("/계정/주소/:ID", (요청, 응답) => { 도우미.추가주소(요청.매개변수.id).다음(결과 => { 응답.보내기(결과); }, 오류 => { 반환 응답.상태(500).보내기(오류); }); }); |
전달받은 계정 ID를 사용하여 다음을 수행할 수 있습니다. 추가주소
함수를 사용하여 문서에서 하위 문서 작업을 사용할 수 있습니다.
나쁘지 않죠?
특정 계정의 모든 주소를 가져오려면 다음과 같은 방법이 있을 수 있습니다:
1 2 3 4 5 6 7 |
앱.get("/계정/주소/:id", (요청, 응답) => { 도우미.getAddresses(요청.매개변수.id).다음(결과 => { 응답.보내기(결과); }, 오류 => { 응답.상태(500).보내기(오류); }); }); |
또는 아이디를 제공하지 않는 경우 다음 엔드포인트 함수를 사용하여 모든 계정의 모든 주소를 가져올 수 있습니다:
1 2 3 4 5 6 7 |
앱.get("/주소", (요청, 응답) => { 도우미.getAddresses().다음(결과 => { 응답.보내기(결과); }, 오류 => { 응답.상태(500).보내기(오류); }); }); |
이제 가장 까다로운 엔드포인트 기능을 살펴보겠습니다. 보유 계정과 각 지갑 주소를 포함한 계정의 잔액을 조회하고 싶다고 가정해 보겠습니다. 다음과 같이 하면 됩니다:
1 2 3 4 5 6 7 8 9 10 11 |
앱.get("/계정/잔액/:ID", (요청, 응답) => { 도우미.getAddresses(요청.매개변수.id).다음(주소 => 도우미.getWalletBalance(주소)).다음(balance => { 도우미.계정 잔액 가져오기(요청.매개변수.id).다음(결과 => { 응답.보내기({ "balance": balance.balance + 결과.balance }); }, 오류 => { 응답.상태(500).보내기({ "code": 오류.코드, "메시지": 오류.메시지 }); }); }, 오류 => { 응답.상태(500).보내기({ "code": 오류.코드, "메시지": 오류.메시지 }); }); }); |
위의 두 함수를 모두 호출하여 잔액을 구하고 그 결과를 합산하여 하나의 거대한 잔액을 얻습니다.
계정 엔드포인트는 특별히 흥미롭지 않았습니다. 트랜잭션 생성은 조금 더 흥미로웠습니다.
프로젝트의 경로/트랜잭션.js 파일을 열고 다음을 포함하세요:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
const 요청 = require("요청-약속"); const Joi = require("joi"); const 비트코어 = require("bitcore-lib"); const 도우미 = require("../app").도우미; 모듈.수출 = (앱) => { 앱.post("/draw", (요청, 응답) => { }); 앱.post("/예치", (요청, 응답) => { }); 앱.post("/전송", (요청, 응답) => { }); } |
거래에는 세 가지 유형이 있습니다. 법정화폐로 비트코인 입금, 법정화폐로 비트코인 출금, 새로운 지갑 주소로 비트코인 이체가 가능합니다.
입금 엔드포인트를 살펴보겠습니다:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
앱.post("/예치", (요청, 응답) => { var 모델 = Joi.객체().키({ usd: Joi.숫자().필수(), id: Joi.문자열().필수() }); Joi.유효성 검사(요청.body, 모델, { stripUnknown: true }, (오류, 값) => { 만약(오류) { 반환 응답.상태(500).보내기(오류); } 요청("https://api.coinmarketcap.com/v1/ticker/bitcoin/").다음(시장 => { var btc = 값.usd / JSON.parse(시장)[0].가격_미국; var 트랜잭션 = { 계정: 값.id, usd: 값.usd, 사토시: 비트코어.단위.fromBTC(btc).사토시(), 타임스탬프: (new 날짜()).getTime(), 상태: "deposit", 유형: "트랜잭션" }; 도우미.삽입(트랜잭션).다음(결과 => { 응답.보내기(결과); }, 오류 => { 응답.상태(500).보내기(오류); }); }, 오류 => { 응답.상태(500).보내기(오류); }); }); }); |
입력값을 확인한 후 코인마켓캡을 통해 비트코인의 현재 USD 가치를 확인합니다. 응답의 데이터를 사용하여 입금된 USD 금액을 기준으로 얼마나 많은 비트코인을 받아야 하는지 계산할 수 있습니다.
데이터베이스 트랜잭션을 생성한 후 저장하면 양수이므로 쿼리할 때 양수 잔액으로 돌아옵니다.
이제 비트코인에서 돈을 인출하고 싶다고 가정해 보겠습니다:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
앱.post("/draw", (요청, 응답) => { var 모델 = Joi.객체().키({ 사토시: Joi.숫자().필수(), id: Joi.문자열().필수() }); Joi.유효성 검사(요청.body, 모델, { stripUnknown: true }, (오류, 값) => { 만약(오류) { 반환 응답.상태(500).보내기(오류); } 도우미.계정 잔액 가져오기(값.id).다음(결과 => { 만약(결과.balance == null || (결과.balance - 값.사토시) < 0) { 반환 응답.상태(500).보내기({ "메시지": "`가 없습니다." + 값.사토시 + "`사토시 출금 가능" }); } 요청("https://api.coinmarketcap.com/v1/ticker/bitcoin/").다음(시장 => { var usd = (비트코어.단위.에서사토시스(값.사토시).toBTC() * JSON.parse(시장)[0].가격_미국).toFixed(2); var 트랜잭션 = { 계정: 값.id, 사토시: (값.사토시 * -1), usd: parseFloat(usd), 타임스탬프: (new 날짜()).getTime(), 상태: "철수", 유형: "트랜잭션" }; 도우미.삽입(트랜잭션).다음(결과 => { 응답.보내기(결과); }, 오류 => { 응답.상태(500).보내기(오류); }); }, 오류 => { 응답.상태(500).보내기(오류); }); }, 오류 => { 반환 응답.상태(500).보내기(오류); }); }); }); |
여기에서도 비슷한 이벤트가 발생하고 있습니다. 요청 본문을 확인한 후 계정 잔액을 확인하고 인출하려는 금액이 잔액보다 작거나 같은지 확인합니다. 그렇다면 코인마켓캡의 현재 가격을 기준으로 다시 변환할 수 있습니다. 음수 값을 사용하여 트랜잭션을 생성하고 데이터베이스에 저장합니다.
이 두 가지 경우 모두 과거에 부정적인 논란이 있었던 코인마켓캡에 의존하고 있습니다. 전환을 위해 다른 리소스를 선택하는 것이 좋습니다.
마지막으로 전송이 있습니다:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
앱.post("/전송", (요청, 응답) => { var 모델 = Joi.객체().키({ 금액: Joi.숫자().필수(), 소스 주소: Joi.문자열().선택 사항(), 목적지 주소: Joi.문자열().필수(), id: Joi.문자열().필수() }); Joi.유효성 검사(요청.body, 모델, { stripUnknown: true }, (오류, 값) => { 만약(오류) { 반환 응답.상태(500).보내기(오류); } 만약(값.소스 주소) { 도우미.계정에서 생성된 트랜잭션(값.id, 값.소스 주소, 값.목적지 주소, 값.금액).다음(결과 => { 응답.보내기(결과); }, 오류 => { 응답.상태(500).보내기(오류); }); } else { 도우미.createTransactionFromMaster(값.id, 값.목적지 주소, 값.금액).다음(결과 => { 응답.보내기(결과); }, 오류 => { 응답.상태(500).보내기(오류); }); } }); }); |
요청에 소스 주소가 포함되어 있으면 자체 지갑에서 이체하고, 그렇지 않으면 거래소가 관리하는 지갑에서 이체합니다.
이 모든 것은 이전에 만든 기능을 기반으로 합니다.
엔드포인트가 제거되면 애플리케이션 부트스트랩에 집중하여 결론에 도달할 수 있습니다.
Express 프레임워크 애플리케이션 부트스트랩
지금은 이 예제에서 손대지 않은 두 개의 파일이 있습니다. 엔드포인트를 부트스트랩하기 위한 구성이나 구동 로직을 추가하지 않았습니다.
프로젝트의 config.json 파일을 열고 다음과 같은 내용을 포함하세요:
1 2 3 4 5 6 7 |
{ "니모닉": "10월 감자 생각 병원 어깨를 다듬다 피곤한 캥거루", "host": "localhost", "bucket": "bitbase", "username": "bitbase", "비밀번호": "123456" } |
이 파일은 매우 민감한 파일이라는 점을 기억하세요. 파일을 잠그거나 다른 방법을 사용하는 것도 고려해 보세요. 시드가 노출되면 모든 사용자 계정과 거래소 계정의 모든 개인 키를 아무런 노력 없이 얻을 수 있습니다.
이제 프로젝트의 app.js 파일을 열고 다음을 포함하세요:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
const Express = require("express"); const 바디파서 = require("body-parser"); const 비트코어 = require("bitcore-lib"); const 니모닉 = require("비트코어-니모닉"); const 구성 = require("./config"); const 도우미 = require("./classes/helper"); var 앱 = Express(); 앱.사용(바디파서.json()); 앱.사용(바디파서.urlencoded({ 확장: true })); var 니모닉 = new 니모닉(구성.니모닉); var 마스터 = new 비트코어.HDPrivateKey(니모닉.toHDPrivateKey()); 모듈.수출.도우미 = new 도우미(구성.호스트, 구성.버킷, 구성.사용자 이름, 구성.비밀번호, 마스터); require("./routes/account.js")(앱); require("./routes/transaction.js")(앱); require("./routes/utility.js")(앱); var 서버 = 앱.듣기(3000, () => { 콘솔.로그("듣기:" + 서버.주소().포트 + "..."); }); |
우리가 하는 일은 Express를 초기화하고, 구성 정보를 로드하고, 경로를 연결하는 것입니다. 그리고 module.exports.helper
변수는 다른 모든 자바스크립트 파일에서 사용되는 싱글톤입니다.
결론
방금 나만의 암호화폐 거래소 구축을 시작하는 방법을 살펴보셨습니다. Node.js 사용 그리고 카우치베이스 를 NoSQL 데이터베이스로 사용했습니다. HD 지갑 생성부터 복잡한 데이터베이스 로직이 포함된 엔드포인트 생성까지 많은 부분을 다뤘습니다.
아무리 강조해도 지나치지 않습니다. 저는 암호화폐 애호가이며 금융 분야에 대한 진정한 경험이 없습니다. 제가 공유한 내용도 효과가 있겠지만 훨씬 더 좋은 방법이 있을 수 있습니다. 키를 암호화하고 시드를 안전하게 보관하는 것을 잊지 마세요. 자신의 작업을 테스트하고 자신이 어떤 상황에 처해 있는지 파악하세요.
이 프로젝트를 다운로드하려면 다음에서 확인하세요. GitHub. 이 주제에 대한 인사이트, 경험 등을 공유하고 싶으시다면 댓글로 공유해 주세요. 커뮤니티가 힘을 합쳐 멋진 무언가를 만들 수 있습니다!
Golang의 팬이시라면, 제가 만든 비슷한 프로젝트를 이전 튜토리얼.
IMHO 비트코인 거래소는 카우치베이스에 대한 기사에는 적합하지 않은 선택입니다. 돈을 만지는 모든 것은 ACID에서 신뢰할 수있는 "D"가 필요합니다 (즉, 최소한 4 이상 9 이상). Couchbase에는 그런 기능이 없다고 생각합니다(노드가 죽고 자동 페일오버가 발생하여 사람들이 돈을 잃는 것을 생각해보세요). 또 다른 문제는 일관성입니다. 위의 코드는 잔액 확인과 출금 거래 사이에 경합을 벌이고 있으며, 이는 매우 명확하게 강조됩니다.
또 다른 문제는 잔액 확인이 계정 트랜잭션 수에서 O(N)이라는 점입니다. CouchDB 구체화된 뷰는 계정 잔액 가져오기를 O(로그 N)으로 수행할 수 있지만, 물론 이것은 여전히 매우 느린 로그 N이며 니클라 인덱스는 이를 수행할 수 없습니다.
이 글은 결국 카우치베이스의 약점을 강조하는 것으로 끝납니다. 즉, 카우치베이스는 비트코인 거래소의 백엔드로 적합하지 않기 때문입니다. 이런 기사는 바퀴벌레DB나 클라우드 스패너 같은 것에 적합할 수 있습니다.
이 글에서는 시연하지 않았지만 SDK를 통해 고유한 내구성 요구 사항을 정의할 수 있습니다. 일관성 및 비용 손실이 우려되는 경우 디스크에 지속된 후에만 응답하거나 X회 복제된 후에만 응답하도록 설정을 변경할 수 있습니다.
https://developer.couchbase.com/documentation/server/current/sdk/durability.html
쿼리할 때 쿼리 일관성을 설정할 수 있습니다. 원하는 경우 인덱스가 새로 고쳐질 때까지 기다릴 수 있습니다.
https://developer.couchbase.com/documentation/server/current/indexes/performance-consistency.html
무슨 말인지 알겠지만 생각만큼 큰 문제는 아니라고 생각합니다.
두 분의 의견을 공유해 주셔서 감사합니다 :-)
죄송합니다. 비공개로 설정해 두었습니다. 이제 공개로 변경되었습니다.