From 11ed086d1e2568dbb3dbd1d2b866894c2133c7fa Mon Sep 17 00:00:00 2001 From: Sami Date: Mon, 15 Jun 2026 14:24:24 -0700 Subject: [PATCH] wallet(HD): native BIP39/BIP32 HD wallet - daemon side Adds deterministic HD key derivation (path m/44'/2222'/0'/0/i, matching the TRIdock web wallet) wired into CWallet: HD seed stored in wallet.dat (encrypted with the wallet master key when the wallet is encrypted), keypool derived from the seed, and new RPC commands hdnew/hdrestore/hdshow/hdinfo. Crypto core verified standalone against the official BIP39 vector and triWallet.js addresses. --- src/CMakeLists.txt | 1 + src/bip39_english.h | 263 +++++++++++++++++++++++++++++++++++++++++++ src/hdwallet.cpp | 223 ++++++++++++++++++++++++++++++++++++ src/hdwallet.h | 50 ++++++++ src/rpcwallet.cpp | 75 ++++++++++++ src/trianglesrpc.cpp | 4 + src/trianglesrpc.h | 4 + src/wallet.cpp | 94 +++++++++++++++- src/wallet.h | 17 +++ src/walletdb.cpp | 21 +++- src/walletdb.h | 15 +++ 11 files changed, 764 insertions(+), 3 deletions(-) create mode 100644 src/bip39_english.h create mode 100644 src/hdwallet.cpp create mode 100644 src/hdwallet.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index e433d6b..13865f2 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -42,6 +42,7 @@ set(CORE_SOURCES bootstrap.cpp checkpoints.cpp crypter.cpp + hdwallet.cpp crypto_ecdh.cpp crypto_ecdsa.cpp db.cpp diff --git a/src/bip39_english.h b/src/bip39_english.h new file mode 100644 index 0000000..2403641 --- /dev/null +++ b/src/bip39_english.h @@ -0,0 +1,263 @@ +// BIP39 English wordlist (2048 words, canonical). Auto-generated; do not edit. +#ifndef TRIANGLES_BIP39_ENGLISH_H +#define TRIANGLES_BIP39_ENGLISH_H +static const char* const BIP39_WORDLIST_EN[2048] = { +"abandon","ability","able","about","above","absent","absorb","abstract", +"absurd","abuse","access","accident","account","accuse","achieve","acid", +"acoustic","acquire","across","act","action","actor","actress","actual", +"adapt","add","addict","address","adjust","admit","adult","advance", +"advice","aerobic","affair","afford","afraid","again","age","agent", +"agree","ahead","aim","air","airport","aisle","alarm","album", +"alcohol","alert","alien","all","alley","allow","almost","alone", +"alpha","already","also","alter","always","amateur","amazing","among", +"amount","amused","analyst","anchor","ancient","anger","angle","angry", +"animal","ankle","announce","annual","another","answer","antenna","antique", +"anxiety","any","apart","apology","appear","apple","approve","april", +"arch","arctic","area","arena","argue","arm","armed","armor", +"army","around","arrange","arrest","arrive","arrow","art","artefact", +"artist","artwork","ask","aspect","assault","asset","assist","assume", +"asthma","athlete","atom","attack","attend","attitude","attract","auction", +"audit","august","aunt","author","auto","autumn","average","avocado", +"avoid","awake","aware","away","awesome","awful","awkward","axis", +"baby","bachelor","bacon","badge","bag","balance","balcony","ball", +"bamboo","banana","banner","bar","barely","bargain","barrel","base", +"basic","basket","battle","beach","bean","beauty","because","become", +"beef","before","begin","behave","behind","believe","below","belt", +"bench","benefit","best","betray","better","between","beyond","bicycle", +"bid","bike","bind","biology","bird","birth","bitter","black", +"blade","blame","blanket","blast","bleak","bless","blind","blood", +"blossom","blouse","blue","blur","blush","board","boat","body", +"boil","bomb","bone","bonus","book","boost","border","boring", +"borrow","boss","bottom","bounce","box","boy","bracket","brain", +"brand","brass","brave","bread","breeze","brick","bridge","brief", +"bright","bring","brisk","broccoli","broken","bronze","broom","brother", +"brown","brush","bubble","buddy","budget","buffalo","build","bulb", +"bulk","bullet","bundle","bunker","burden","burger","burst","bus", +"business","busy","butter","buyer","buzz","cabbage","cabin","cable", +"cactus","cage","cake","call","calm","camera","camp","can", +"canal","cancel","candy","cannon","canoe","canvas","canyon","capable", +"capital","captain","car","carbon","card","cargo","carpet","carry", +"cart","case","cash","casino","castle","casual","cat","catalog", +"catch","category","cattle","caught","cause","caution","cave","ceiling", +"celery","cement","census","century","cereal","certain","chair","chalk", +"champion","change","chaos","chapter","charge","chase","chat","cheap", +"check","cheese","chef","cherry","chest","chicken","chief","child", +"chimney","choice","choose","chronic","chuckle","chunk","churn","cigar", +"cinnamon","circle","citizen","city","civil","claim","clap","clarify", +"claw","clay","clean","clerk","clever","click","client","cliff", +"climb","clinic","clip","clock","clog","close","cloth","cloud", +"clown","club","clump","cluster","clutch","coach","coast","coconut", +"code","coffee","coil","coin","collect","color","column","combine", +"come","comfort","comic","common","company","concert","conduct","confirm", +"congress","connect","consider","control","convince","cook","cool","copper", +"copy","coral","core","corn","correct","cost","cotton","couch", +"country","couple","course","cousin","cover","coyote","crack","cradle", +"craft","cram","crane","crash","crater","crawl","crazy","cream", +"credit","creek","crew","cricket","crime","crisp","critic","crop", +"cross","crouch","crowd","crucial","cruel","cruise","crumble","crunch", +"crush","cry","crystal","cube","culture","cup","cupboard","curious", +"current","curtain","curve","cushion","custom","cute","cycle","dad", +"damage","damp","dance","danger","daring","dash","daughter","dawn", +"day","deal","debate","debris","decade","december","decide","decline", +"decorate","decrease","deer","defense","define","defy","degree","delay", +"deliver","demand","demise","denial","dentist","deny","depart","depend", +"deposit","depth","deputy","derive","describe","desert","design","desk", +"despair","destroy","detail","detect","develop","device","devote","diagram", +"dial","diamond","diary","dice","diesel","diet","differ","digital", +"dignity","dilemma","dinner","dinosaur","direct","dirt","disagree","discover", +"disease","dish","dismiss","disorder","display","distance","divert","divide", +"divorce","dizzy","doctor","document","dog","doll","dolphin","domain", +"donate","donkey","donor","door","dose","double","dove","draft", +"dragon","drama","drastic","draw","dream","dress","drift","drill", +"drink","drip","drive","drop","drum","dry","duck","dumb", +"dune","during","dust","dutch","duty","dwarf","dynamic","eager", +"eagle","early","earn","earth","easily","east","easy","echo", +"ecology","economy","edge","edit","educate","effort","egg","eight", +"either","elbow","elder","electric","elegant","element","elephant","elevator", +"elite","else","embark","embody","embrace","emerge","emotion","employ", +"empower","empty","enable","enact","end","endless","endorse","enemy", +"energy","enforce","engage","engine","enhance","enjoy","enlist","enough", +"enrich","enroll","ensure","enter","entire","entry","envelope","episode", +"equal","equip","era","erase","erode","erosion","error","erupt", +"escape","essay","essence","estate","eternal","ethics","evidence","evil", +"evoke","evolve","exact","example","excess","exchange","excite","exclude", +"excuse","execute","exercise","exhaust","exhibit","exile","exist","exit", +"exotic","expand","expect","expire","explain","expose","express","extend", +"extra","eye","eyebrow","fabric","face","faculty","fade","faint", +"faith","fall","false","fame","family","famous","fan","fancy", +"fantasy","farm","fashion","fat","fatal","father","fatigue","fault", +"favorite","feature","february","federal","fee","feed","feel","female", +"fence","festival","fetch","fever","few","fiber","fiction","field", +"figure","file","film","filter","final","find","fine","finger", +"finish","fire","firm","first","fiscal","fish","fit","fitness", +"fix","flag","flame","flash","flat","flavor","flee","flight", +"flip","float","flock","floor","flower","fluid","flush","fly", +"foam","focus","fog","foil","fold","follow","food","foot", +"force","forest","forget","fork","fortune","forum","forward","fossil", +"foster","found","fox","fragile","frame","frequent","fresh","friend", +"fringe","frog","front","frost","frown","frozen","fruit","fuel", +"fun","funny","furnace","fury","future","gadget","gain","galaxy", +"gallery","game","gap","garage","garbage","garden","garlic","garment", +"gas","gasp","gate","gather","gauge","gaze","general","genius", +"genre","gentle","genuine","gesture","ghost","giant","gift","giggle", +"ginger","giraffe","girl","give","glad","glance","glare","glass", +"glide","glimpse","globe","gloom","glory","glove","glow","glue", +"goat","goddess","gold","good","goose","gorilla","gospel","gossip", +"govern","gown","grab","grace","grain","grant","grape","grass", +"gravity","great","green","grid","grief","grit","grocery","group", +"grow","grunt","guard","guess","guide","guilt","guitar","gun", +"gym","habit","hair","half","hammer","hamster","hand","happy", +"harbor","hard","harsh","harvest","hat","have","hawk","hazard", +"head","health","heart","heavy","hedgehog","height","hello","helmet", +"help","hen","hero","hidden","high","hill","hint","hip", +"hire","history","hobby","hockey","hold","hole","holiday","hollow", +"home","honey","hood","hope","horn","horror","horse","hospital", +"host","hotel","hour","hover","hub","huge","human","humble", +"humor","hundred","hungry","hunt","hurdle","hurry","hurt","husband", +"hybrid","ice","icon","idea","identify","idle","ignore","ill", +"illegal","illness","image","imitate","immense","immune","impact","impose", +"improve","impulse","inch","include","income","increase","index","indicate", +"indoor","industry","infant","inflict","inform","inhale","inherit","initial", +"inject","injury","inmate","inner","innocent","input","inquiry","insane", +"insect","inside","inspire","install","intact","interest","into","invest", +"invite","involve","iron","island","isolate","issue","item","ivory", +"jacket","jaguar","jar","jazz","jealous","jeans","jelly","jewel", +"job","join","joke","journey","joy","judge","juice","jump", +"jungle","junior","junk","just","kangaroo","keen","keep","ketchup", +"key","kick","kid","kidney","kind","kingdom","kiss","kit", +"kitchen","kite","kitten","kiwi","knee","knife","knock","know", +"lab","label","labor","ladder","lady","lake","lamp","language", +"laptop","large","later","latin","laugh","laundry","lava","law", +"lawn","lawsuit","layer","lazy","leader","leaf","learn","leave", +"lecture","left","leg","legal","legend","leisure","lemon","lend", +"length","lens","leopard","lesson","letter","level","liar","liberty", +"library","license","life","lift","light","like","limb","limit", +"link","lion","liquid","list","little","live","lizard","load", +"loan","lobster","local","lock","logic","lonely","long","loop", +"lottery","loud","lounge","love","loyal","lucky","luggage","lumber", +"lunar","lunch","luxury","lyrics","machine","mad","magic","magnet", +"maid","mail","main","major","make","mammal","man","manage", +"mandate","mango","mansion","manual","maple","marble","march","margin", +"marine","market","marriage","mask","mass","master","match","material", +"math","matrix","matter","maximum","maze","meadow","mean","measure", +"meat","mechanic","medal","media","melody","melt","member","memory", +"mention","menu","mercy","merge","merit","merry","mesh","message", +"metal","method","middle","midnight","milk","million","mimic","mind", +"minimum","minor","minute","miracle","mirror","misery","miss","mistake", +"mix","mixed","mixture","mobile","model","modify","mom","moment", +"monitor","monkey","monster","month","moon","moral","more","morning", +"mosquito","mother","motion","motor","mountain","mouse","move","movie", +"much","muffin","mule","multiply","muscle","museum","mushroom","music", +"must","mutual","myself","mystery","myth","naive","name","napkin", +"narrow","nasty","nation","nature","near","neck","need","negative", +"neglect","neither","nephew","nerve","nest","net","network","neutral", +"never","news","next","nice","night","noble","noise","nominee", +"noodle","normal","north","nose","notable","note","nothing","notice", +"novel","now","nuclear","number","nurse","nut","oak","obey", +"object","oblige","obscure","observe","obtain","obvious","occur","ocean", +"october","odor","off","offer","office","often","oil","okay", +"old","olive","olympic","omit","once","one","onion","online", +"only","open","opera","opinion","oppose","option","orange","orbit", +"orchard","order","ordinary","organ","orient","original","orphan","ostrich", +"other","outdoor","outer","output","outside","oval","oven","over", +"own","owner","oxygen","oyster","ozone","pact","paddle","page", +"pair","palace","palm","panda","panel","panic","panther","paper", +"parade","parent","park","parrot","party","pass","patch","path", +"patient","patrol","pattern","pause","pave","payment","peace","peanut", +"pear","peasant","pelican","pen","penalty","pencil","people","pepper", +"perfect","permit","person","pet","phone","photo","phrase","physical", +"piano","picnic","picture","piece","pig","pigeon","pill","pilot", +"pink","pioneer","pipe","pistol","pitch","pizza","place","planet", +"plastic","plate","play","please","pledge","pluck","plug","plunge", +"poem","poet","point","polar","pole","police","pond","pony", +"pool","popular","portion","position","possible","post","potato","pottery", +"poverty","powder","power","practice","praise","predict","prefer","prepare", +"present","pretty","prevent","price","pride","primary","print","priority", +"prison","private","prize","problem","process","produce","profit","program", +"project","promote","proof","property","prosper","protect","proud","provide", +"public","pudding","pull","pulp","pulse","pumpkin","punch","pupil", +"puppy","purchase","purity","purpose","purse","push","put","puzzle", +"pyramid","quality","quantum","quarter","question","quick","quit","quiz", +"quote","rabbit","raccoon","race","rack","radar","radio","rail", +"rain","raise","rally","ramp","ranch","random","range","rapid", +"rare","rate","rather","raven","raw","razor","ready","real", +"reason","rebel","rebuild","recall","receive","recipe","record","recycle", +"reduce","reflect","reform","refuse","region","regret","regular","reject", +"relax","release","relief","rely","remain","remember","remind","remove", +"render","renew","rent","reopen","repair","repeat","replace","report", +"require","rescue","resemble","resist","resource","response","result","retire", +"retreat","return","reunion","reveal","review","reward","rhythm","rib", +"ribbon","rice","rich","ride","ridge","rifle","right","rigid", +"ring","riot","ripple","risk","ritual","rival","river","road", +"roast","robot","robust","rocket","romance","roof","rookie","room", +"rose","rotate","rough","round","route","royal","rubber","rude", +"rug","rule","run","runway","rural","sad","saddle","sadness", +"safe","sail","salad","salmon","salon","salt","salute","same", +"sample","sand","satisfy","satoshi","sauce","sausage","save","say", +"scale","scan","scare","scatter","scene","scheme","school","science", +"scissors","scorpion","scout","scrap","screen","script","scrub","sea", +"search","season","seat","second","secret","section","security","seed", +"seek","segment","select","sell","seminar","senior","sense","sentence", +"series","service","session","settle","setup","seven","shadow","shaft", +"shallow","share","shed","shell","sheriff","shield","shift","shine", +"ship","shiver","shock","shoe","shoot","shop","short","shoulder", +"shove","shrimp","shrug","shuffle","shy","sibling","sick","side", +"siege","sight","sign","silent","silk","silly","silver","similar", +"simple","since","sing","siren","sister","situate","six","size", +"skate","sketch","ski","skill","skin","skirt","skull","slab", +"slam","sleep","slender","slice","slide","slight","slim","slogan", +"slot","slow","slush","small","smart","smile","smoke","smooth", +"snack","snake","snap","sniff","snow","soap","soccer","social", +"sock","soda","soft","solar","soldier","solid","solution","solve", +"someone","song","soon","sorry","sort","soul","sound","soup", +"source","south","space","spare","spatial","spawn","speak","special", +"speed","spell","spend","sphere","spice","spider","spike","spin", +"spirit","split","spoil","sponsor","spoon","sport","spot","spray", +"spread","spring","spy","square","squeeze","squirrel","stable","stadium", +"staff","stage","stairs","stamp","stand","start","state","stay", +"steak","steel","stem","step","stereo","stick","still","sting", +"stock","stomach","stone","stool","story","stove","strategy","street", +"strike","strong","struggle","student","stuff","stumble","style","subject", +"submit","subway","success","such","sudden","suffer","sugar","suggest", +"suit","summer","sun","sunny","sunset","super","supply","supreme", +"sure","surface","surge","surprise","surround","survey","suspect","sustain", +"swallow","swamp","swap","swarm","swear","sweet","swift","swim", +"swing","switch","sword","symbol","symptom","syrup","system","table", +"tackle","tag","tail","talent","talk","tank","tape","target", +"task","taste","tattoo","taxi","teach","team","tell","ten", +"tenant","tennis","tent","term","test","text","thank","that", +"theme","then","theory","there","they","thing","this","thought", +"three","thrive","throw","thumb","thunder","ticket","tide","tiger", +"tilt","timber","time","tiny","tip","tired","tissue","title", +"toast","tobacco","today","toddler","toe","together","toilet","token", +"tomato","tomorrow","tone","tongue","tonight","tool","tooth","top", +"topic","topple","torch","tornado","tortoise","toss","total","tourist", +"toward","tower","town","toy","track","trade","traffic","tragic", +"train","transfer","trap","trash","travel","tray","treat","tree", +"trend","trial","tribe","trick","trigger","trim","trip","trophy", +"trouble","truck","true","truly","trumpet","trust","truth","try", +"tube","tuition","tumble","tuna","tunnel","turkey","turn","turtle", +"twelve","twenty","twice","twin","twist","two","type","typical", +"ugly","umbrella","unable","unaware","uncle","uncover","under","undo", +"unfair","unfold","unhappy","uniform","unique","unit","universe","unknown", +"unlock","until","unusual","unveil","update","upgrade","uphold","upon", +"upper","upset","urban","urge","usage","use","used","useful", +"useless","usual","utility","vacant","vacuum","vague","valid","valley", +"valve","van","vanish","vapor","various","vast","vault","vehicle", +"velvet","vendor","venture","venue","verb","verify","version","very", +"vessel","veteran","viable","vibrant","vicious","victory","video","view", +"village","vintage","violin","virtual","virus","visa","visit","visual", +"vital","vivid","vocal","voice","void","volcano","volume","vote", +"voyage","wage","wagon","wait","walk","wall","walnut","want", +"warfare","warm","warrior","wash","wasp","waste","water","wave", +"way","wealth","weapon","wear","weasel","weather","web","wedding", +"weekend","weird","welcome","west","wet","whale","what","wheat", +"wheel","when","where","whip","whisper","wide","width","wife", +"wild","will","win","window","wine","wing","wink","winner", +"winter","wire","wisdom","wise","wish","witness","wolf","woman", +"wonder","wood","wool","word","work","world","worry","worth", +"wrap","wreck","wrestle","wrist","write","wrong","yard","year", +"yellow","you","young","youth","zebra","zero","zone","zoo", + +}; +#endif diff --git a/src/hdwallet.cpp b/src/hdwallet.cpp new file mode 100644 index 0000000..4a92e54 --- /dev/null +++ b/src/hdwallet.cpp @@ -0,0 +1,223 @@ +// Copyright (c) 2026 The Triangles developers +// Distributed under the MIT/X11 software license. +#include "hdwallet.h" +#include "bip39_english.h" + +#include +#include + +#include +#include +#include +#include + +#include + +namespace hd { + +// ---- secp256k1 context (self-contained; independent of crypto_ecdsa) ------ +static secp256k1_context* HDContext() +{ + static secp256k1_context* ctx = NULL; + if (!ctx) + ctx = secp256k1_context_create(SECP256K1_CONTEXT_SIGN | SECP256K1_CONTEXT_VERIFY); + return ctx; +} + +static void HmacSha512(const unsigned char* key, size_t keylen, + const unsigned char* data, size_t datalen, + unsigned char out[64]) +{ + unsigned int len = 64; + HMAC(EVP_sha512(), key, (int)keylen, data, datalen, out, &len); +} + +// Binary search the (lexicographically sorted) BIP39 English wordlist. +static int WordIndex(const std::string& w) +{ + int lo = 0, hi = 2047; + while (lo <= hi) { + int mid = (lo + hi) / 2; + int c = w.compare(BIP39_WORDLIST_EN[mid]); + if (c == 0) return mid; + if (c < 0) hi = mid - 1; else lo = mid + 1; + } + return -1; +} + +static std::vector SplitWords(const std::string& s) +{ + std::vector out; + size_t i = 0, n = s.size(); + while (i < n) { + while (i < n && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r')) i++; + size_t j = i; + while (j < n && !(s[j] == ' ' || s[j] == '\t' || s[j] == '\n' || s[j] == '\r')) j++; + if (j > i) out.push_back(s.substr(i, j - i)); + i = j; + } + return out; +} + +// ---- BIP39 ---------------------------------------------------------------- +std::string GenerateMnemonic(int strengthBits) +{ + if (strengthBits != 128 && strengthBits != 256) strengthBits = 256; + int entBytes = strengthBits / 8; + std::vector ent(entBytes); + if (RAND_bytes(&ent[0], entBytes) != 1) return std::string(); + + // checksum = first (ENT/32) bits of SHA256(entropy) + unsigned char hash[32]; + SHA256(&ent[0], entBytes, hash); + int csBits = strengthBits / 32; + + // bit buffer = entropy || checksum bits + std::vector bits = ent; + bits.push_back(hash[0]); // up to 8 checksum bits live in hash[0] + + int totalBits = strengthBits + csBits; + int words = totalBits / 11; + std::string out; + for (int i = 0; i < words; i++) { + int idx = 0; + for (int b = 0; b < 11; b++) { + int bitpos = i * 11 + b; + int byte = bitpos / 8, off = 7 - (bitpos % 8); + int bit = (bits[byte] >> off) & 1; + idx = (idx << 1) | bit; + } + if (i) out += ' '; + out += BIP39_WORDLIST_EN[idx]; + } + return out; +} + +bool CheckMnemonic(const std::string& mnemonic) +{ + std::vector w = SplitWords(mnemonic); + size_t nw = w.size(); + if (nw != 12 && nw != 15 && nw != 18 && nw != 21 && nw != 24) return false; + + int totalBits = (int)nw * 11; + int csBits = totalBits / 33; + int entBits = totalBits - csBits; + if (entBits % 8 != 0) return false; + int entBytes = entBits / 8; + + // unpack 11-bit indices into a bit buffer + std::vector buf((totalBits + 7) / 8, 0); + for (size_t i = 0; i < nw; i++) { + int idx = WordIndex(w[i]); + if (idx < 0) return false; + for (int b = 0; b < 11; b++) { + int bit = (idx >> (10 - b)) & 1; + int bitpos = (int)i * 11 + b; + int byte = bitpos / 8, off = 7 - (bitpos % 8); + if (bit) buf[byte] |= (1 << off); + } + } + std::vector ent(buf.begin(), buf.begin() + entBytes); + unsigned char hash[32]; + SHA256(&ent[0], entBytes, hash); + // compare csBits checksum bits + for (int b = 0; b < csBits; b++) { + int bitpos = entBits + b; + int byte = bitpos / 8, off = 7 - (bitpos % 8); + int got = (buf[byte] >> off) & 1; + int want = (hash[b / 8] >> (7 - (b % 8))) & 1; + if (got != want) return false; + } + return true; +} + +bool MnemonicToSeed(const std::string& mnemonic, const std::string& passphrase, + unsigned char seed64[64]) +{ + std::string salt = "mnemonic" + passphrase; + int rc = PKCS5_PBKDF2_HMAC(mnemonic.c_str(), (int)mnemonic.size(), + (const unsigned char*)salt.c_str(), (int)salt.size(), + 2048, EVP_sha512(), 64, seed64); + return rc == 1; +} + +// ---- BIP32 ---------------------------------------------------------------- +bool MasterFromSeed(const unsigned char* seed, size_t seedlen, ExtKey& out) +{ + unsigned char I[64]; + HmacSha512((const unsigned char*)"Bitcoin seed", 12, seed, seedlen, I); + memcpy(out.key, I, 32); + memcpy(out.chaincode, I + 32, 32); + if (!secp256k1_ec_seckey_verify(HDContext(), out.key)) return false; + out.valid = true; + return true; +} + +bool CKDpriv(const ExtKey& parent, uint32_t index, ExtKey& child) +{ + if (!parent.valid) return false; + secp256k1_context* ctx = HDContext(); + unsigned char data[37]; + size_t dlen = 0; + if (index & HARDENED) { + data[0] = 0x00; + memcpy(data + 1, parent.key, 32); + dlen = 33; + } else { + // serP(point(parent.key)) = 33-byte compressed pubkey + secp256k1_pubkey pk; + if (!secp256k1_ec_pubkey_create(ctx, &pk, parent.key)) return false; + size_t plen = 33; + secp256k1_ec_pubkey_serialize(ctx, data, &plen, &pk, SECP256K1_EC_COMPRESSED); + dlen = 33; + } + data[dlen + 0] = (index >> 24) & 0xff; + data[dlen + 1] = (index >> 16) & 0xff; + data[dlen + 2] = (index >> 8) & 0xff; + data[dlen + 3] = index & 0xff; + dlen += 4; + + unsigned char I[64]; + HmacSha512(parent.chaincode, 32, data, dlen, I); + + memcpy(child.key, parent.key, 32); + // child = (IL + parent) mod n ; rejects invalid (IL>=n or result 0) + if (!secp256k1_ec_seckey_tweak_add(ctx, child.key, I)) return false; + memcpy(child.chaincode, I + 32, 32); + child.valid = true; + return true; +} + +bool DerivePath(const ExtKey& master, const std::vector& path, ExtKey& out) +{ + ExtKey cur = master; + for (size_t i = 0; i < path.size(); i++) { + ExtKey nxt; + if (!CKDpriv(cur, path[i], nxt)) return false; + cur = nxt; + } + out = cur; + return true; +} + +bool DeriveTriangles(const std::string& mnemonic, const std::string& passphrase, + uint32_t account, uint32_t change, uint32_t index, + unsigned char privOut[32]) +{ + unsigned char seed[64]; + if (!MnemonicToSeed(mnemonic, passphrase, seed)) return false; + ExtKey master; + if (!MasterFromSeed(seed, 64, master)) return false; + std::vector path; + path.push_back(44u | HARDENED); + path.push_back(TRI_COIN_TYPE | HARDENED); + path.push_back(account | HARDENED); + path.push_back(change); + path.push_back(index); + ExtKey leaf; + if (!DerivePath(master, path, leaf)) return false; + memcpy(privOut, leaf.key, 32); + return true; +} + +} // namespace hd diff --git a/src/hdwallet.h b/src/hdwallet.h new file mode 100644 index 0000000..a8d6eb8 --- /dev/null +++ b/src/hdwallet.h @@ -0,0 +1,50 @@ +// Copyright (c) 2026 The Triangles developers +// Distributed under the MIT/X11 software license. +// +// Native BIP39 (mnemonic) + BIP32 (HD) key derivation for Triangles. +// Produces keys identical to the TRIdock web wallet (derivation path +// m/44'/2222'/0'/0/i, coin type 2222), so a 24-word phrase round-trips +// between the Qt/daemon wallet and the web wallet. +#ifndef TRIANGLES_HDWALLET_H +#define TRIANGLES_HDWALLET_H + +#include +#include +#include +#include + +namespace hd { + +static const uint32_t HARDENED = 0x80000000u; +static const uint32_t TRI_COIN_TYPE = 2222u; // matches triWallet.js + +// A BIP32 extended private key (private scalar + chain code). +struct ExtKey { + unsigned char key[32]; + unsigned char chaincode[32]; + bool valid; + ExtKey() : valid(false) { } +}; + +// ---- BIP39 ---------------------------------------------------------------- +// Generate a new mnemonic. strengthBits must be 128 (12 words) or 256 (24). +std::string GenerateMnemonic(int strengthBits = 256); +// Validate word membership + checksum. +bool CheckMnemonic(const std::string& mnemonic); +// PBKDF2-HMAC-SHA512(mnemonic, "mnemonic"+passphrase, 2048) -> 64-byte seed. +bool MnemonicToSeed(const std::string& mnemonic, const std::string& passphrase, + unsigned char seed64[64]); + +// ---- BIP32 ---------------------------------------------------------------- +bool MasterFromSeed(const unsigned char* seed, size_t seedlen, ExtKey& out); +bool CKDpriv(const ExtKey& parent, uint32_t index, ExtKey& child); +bool DerivePath(const ExtKey& master, const std::vector& path, ExtKey& out); + +// ---- High level ----------------------------------------------------------- +// Derive the 32-byte private scalar for m/44'/coinType'/account'/change/index. +bool DeriveTriangles(const std::string& mnemonic, const std::string& passphrase, + uint32_t account, uint32_t change, uint32_t index, + unsigned char privOut[32]); + +} // namespace hd +#endif // TRIANGLES_HDWALLET_H diff --git a/src/rpcwallet.cpp b/src/rpcwallet.cpp index 6c3c707..1e41ba2 100644 --- a/src/rpcwallet.cpp +++ b/src/rpcwallet.cpp @@ -1858,3 +1858,78 @@ Value makekeypair(const Array& params, bool fHelp) result.push_back(Pair("PublicKey", HexStr(key.GetPubKey().Raw()))); return result; } + + +Value hdinfo(const Array& params, bool fHelp) +{ + if (fHelp || params.size() != 0) + throw runtime_error("hdinfo\nReturns HD (BIP39/BIP32) wallet status."); + Object obj; + obj.push_back(Pair("hdenabled", pwalletMain->IsHDEnabled())); + obj.push_back(Pair("coin_type", 2222)); + obj.push_back(Pair("derivation_path", "m/44'/2222'/0'/0/i")); + obj.push_back(Pair("nextindex", (int64_t)pwalletMain->nHDChainIndex)); + return obj; +} + +Value hdnew(const Array& params, bool fHelp) +{ + if (fHelp || params.size() > 1) + throw runtime_error( + "hdnew [passphrase]\n" + "Generate a NEW 24-word HD seed phrase, activate it as this wallet's\n" + "deterministic seed, and return the phrase. WRITE IT DOWN: it is the\n" + "only backup of every address this wallet derives."); + EnsureWalletIsUnlocked(); + if (pwalletMain->IsHDEnabled()) + throw JSONRPCError(RPC_WALLET_ERROR, "Wallet already has an HD seed; use 'hdshow' to back it up."); + string passphrase = params.size() > 0 ? params[0].get_str() : ""; + string mnemonic, strError; + if (!pwalletMain->SetHDSeed("", passphrase, true, mnemonic, strError)) + throw JSONRPCError(RPC_WALLET_ERROR, strError); + pwalletMain->TopUpKeyPool(); + Object obj; + obj.push_back(Pair("mnemonic", mnemonic)); + obj.push_back(Pair("words", 24)); + obj.push_back(Pair("warning", "Write these 24 words down and keep them secret and offline. Anyone with them can spend your coins.")); + return obj; +} + +Value hdrestore(const Array& params, bool fHelp) +{ + if (fHelp || params.size() < 1 || params.size() > 2) + throw runtime_error( + "hdrestore \"mnemonic\" [passphrase]\n" + "Activate an HD seed from an existing 24-word phrase, derive the keypool\n" + "and rescan the chain for funds on the derived addresses."); + EnsureWalletIsUnlocked(); + string mnemonic = params[0].get_str(); + string passphrase = params.size() > 1 ? params[1].get_str() : ""; + string out, strError; + if (!pwalletMain->SetHDSeed(mnemonic, passphrase, false, out, strError)) + throw JSONRPCError(RPC_WALLET_ERROR, strError); + pwalletMain->TopUpKeyPool(); + { + LOCK2(cs_main, pwalletMain->cs_wallet); + pwalletMain->ScanForWalletTransactions(pindexGenesisBlock, true); + pwalletMain->ReacceptWalletTransactions(); + } + Object obj; + obj.push_back(Pair("restored", true)); + return obj; +} + +Value hdshow(const Array& params, bool fHelp) +{ + if (fHelp || params.size() != 0) + throw runtime_error( + "hdshow\nReveal the wallet's HD mnemonic for backup. The wallet must be unlocked."); + EnsureWalletIsUnlocked(); + string mnemonic; + if (!pwalletMain->GetHDMnemonic(mnemonic)) + throw JSONRPCError(RPC_WALLET_ERROR, "Wallet has no HD seed (use 'hdnew' to create one)."); + Object obj; + obj.push_back(Pair("mnemonic", mnemonic)); + obj.push_back(Pair("warning", "Keep these words secret and offline.")); + return obj; +} diff --git a/src/trianglesrpc.cpp b/src/trianglesrpc.cpp index 3e1042f..7fc8fdb 100644 --- a/src/trianglesrpc.cpp +++ b/src/trianglesrpc.cpp @@ -305,6 +305,10 @@ static const CRPCCommand vRPCCommands[] = { "settxfee", &settxfee, false, false }, { "listsinceblock", &listsinceblock, false, false }, { "dumpprivkey", &dumpprivkey, false, false }, + { "hdnew", &hdnew, false, false }, + { "hdrestore", &hdrestore, false, false }, + { "hdshow", &hdshow, false, false }, + { "hdinfo", &hdinfo, true, false }, { "dumpwallet", &dumpwallet, true, false }, { "importwallet", &importwallet, false, false }, { "importprivkey", &importprivkey, false, false }, diff --git a/src/trianglesrpc.h b/src/trianglesrpc.h index cb45c3f..210d8b2 100644 --- a/src/trianglesrpc.h +++ b/src/trianglesrpc.h @@ -155,6 +155,10 @@ extern json_spirit::Value getwalletinfo(const json_spirit::Array& params, bool f extern json_spirit::Value dumpwallet(const json_spirit::Array& params, bool fHelp); extern json_spirit::Value importwallet(const json_spirit::Array& params, bool fHelp); extern json_spirit::Value dumpprivkey(const json_spirit::Array& params, bool fHelp); // in rpcdump.cpp +extern json_spirit::Value hdnew(const json_spirit::Array& params, bool fHelp); +extern json_spirit::Value hdrestore(const json_spirit::Array& params, bool fHelp); +extern json_spirit::Value hdshow(const json_spirit::Array& params, bool fHelp); +extern json_spirit::Value hdinfo(const json_spirit::Array& params, bool fHelp); extern json_spirit::Value importprivkey(const json_spirit::Array& params, bool fHelp); extern json_spirit::Value getsubsidy(const json_spirit::Array& params, bool fHelp); diff --git a/src/wallet.cpp b/src/wallet.cpp index 2592554..ff4a197 100644 --- a/src/wallet.cpp +++ b/src/wallet.cpp @@ -7,12 +7,14 @@ #include "wallet.h" #include "walletdb.h" #include "crypter.h" +#include "hdwallet.h" #include "ui_interface.h" #include "base58.h" #include "kernel.h" #include "coincontrol.h" #include "addressindex.h" #include "util.h" +#include #include #include #include @@ -129,7 +131,12 @@ CPubKey CWallet::GenerateNewKey() RandAddSeedPerfmon(); CKey key; - key.MakeNewKey(fCompressed); + bool fUsedHD = false; + if (fHDEnabled && !hdMnemonic.empty()) { + if (DeriveHDKey(nHDChainIndex, key)) { fUsedHD = true; fCompressed = true; } + } + if (!fUsedHD) + key.MakeNewKey(fCompressed); // Compressed public keys were introduced in version 0.6.0 if (fCompressed) @@ -145,6 +152,11 @@ CPubKey CWallet::GenerateNewKey() if (!AddKey(key)) throw std::runtime_error("CWallet::GenerateNewKey() : AddKey failed"); + if (fUsedHD) { + nHDChainIndex++; + if (fFileBacked) + CWalletDB(strWalletFile).WriteHDChain(nHDChainIndex); + } return key.GetPubKey(); } @@ -209,6 +221,9 @@ bool CWallet::Lock() if (fDebug) printf("Locking wallet.\n"); + if (IsCrypted()) + hdMnemonic.clear(); // keep only the encrypted copy while locked + { LOCK(cs_wallet); CWalletDB wdb(strWalletFile); @@ -233,8 +248,14 @@ bool CWallet::Unlock(const SecureString& strWalletPassphrase) return false; if (!crypter.Decrypt(pMasterKey.second.vchCryptedKey, vMasterKey)) return false; - if (CCryptoKeyStore::Unlock(vMasterKey)) + if (CCryptoKeyStore::Unlock(vMasterKey)) { + if (fHDEnabled && hdMnemonic.empty() && !vchCryptedHDMnemonic.empty()) { + CSecret sec; + if (DecryptSecret(vMasterKey, vchCryptedHDMnemonic, hdMnemonicIV, sec)) + hdMnemonic.assign(sec.begin(), sec.end()); + } return true; + } } SecureMsgWalletUnlocked(); @@ -407,6 +428,15 @@ bool CWallet::EncryptWallet(const SecureString& strWalletPassphrase) return false; } + if (fHDEnabled && !hdMnemonic.empty()) { + CSecret sec(hdMnemonic.begin(), hdMnemonic.end()); + uint256 iv = GetRandHash(); + std::vector cipher; + if (!EncryptSecret(vMasterKey, sec, iv, cipher)) { dbEnc->TxnAbort(); return false; } + hdMnemonicIV = iv; vchCryptedHDMnemonic = cipher; + dbEnc->WriteHDCryptedMnemonic(iv, cipher); + } + SetMinVersion(WalletFeature::WalletCrypt, dbEnc.get(), true); if (!dbEnc->TxnCommit()) @@ -2848,3 +2878,63 @@ void CWallet::GetKeyBirthTimes(std::map &mapKeyBirth) const { } +// ---- HD wallet (BIP39/BIP32) implementation ---- +bool CWallet::DeriveHDKey(int64_t index, CKey& keyOut) const +{ + if (hdMnemonic.empty()) + return false; + unsigned char priv[32]; + if (!hd::DeriveTriangles(hdMnemonic, "", 0, 0, (uint32_t)index, priv)) + return false; + CSecret secret(priv, priv + 32); + memset(priv, 0, sizeof(priv)); + keyOut.SetSecret(secret, true); // HD keys are compressed + return true; +} + +bool CWallet::GetHDMnemonic(std::string& mnemonicOut) const +{ + if (!fHDEnabled || hdMnemonic.empty()) + return false; + mnemonicOut = hdMnemonic; + return true; +} + +bool CWallet::SetHDSeed(const std::string& mnemonicIn, const std::string& passphrase, bool fGenerate, std::string& mnemonicOut, std::string& strError) +{ + LOCK(cs_wallet); + if (IsLocked()) { strError = "Wallet is locked; unlock it before setting an HD seed."; return false; } + + std::string m = mnemonicIn; + if (m.empty()) { + if (!fGenerate) { strError = "No mnemonic supplied."; return false; } + m = hd::GenerateMnemonic(256); + if (m.empty()) { strError = "Failed to generate mnemonic."; return false; } + } + if (!hd::CheckMnemonic(m)) { strError = "Invalid mnemonic (unknown word or bad checksum)."; return false; } + + unsigned char priv[32]; + if (!hd::DeriveTriangles(m, passphrase, 0, 0, 0, priv)) { strError = "Key derivation failed."; return false; } + memset(priv, 0, sizeof(priv)); + + hdMnemonic = m; + fHDEnabled = true; + nHDChainIndex = 0; + + if (fFileBacked) { + CWalletDB wdb(strWalletFile); + if (IsCrypted()) { + CSecret sec(m.begin(), m.end()); + uint256 iv = GetRandHash(); + std::vector cipher; + if (!EncryptSecret(vMasterKey, sec, iv, cipher)) { strError = "Failed to encrypt seed."; return false; } + hdMnemonicIV = iv; vchCryptedHDMnemonic = cipher; + wdb.WriteHDCryptedMnemonic(iv, cipher); + } else { + wdb.WriteHDMnemonic(m); + } + wdb.WriteHDChain(nHDChainIndex); + } + mnemonicOut = m; + return true; +} diff --git a/src/wallet.h b/src/wallet.h index 3a58036..6a1c45a 100644 --- a/src/wallet.h +++ b/src/wallet.h @@ -105,6 +105,8 @@ public: fFileBacked = false; nMasterKeyMaxID = 0; pwalletdbEncryption = nullptr; + fHDEnabled = false; + nHDChainIndex = 0; nOrderPosNext = 0; nCachedStakeWeight = 0; nCachedStakeWeightTime = 0; @@ -124,6 +126,13 @@ public: CPubKey vchDefaultKey; int64_t nTimeFirstKey; + // ---- HD (BIP39/BIP32) wallet state ---- + bool fHDEnabled; // an HD seed has been set + int64_t nHDChainIndex; // next external index (m/44'/2222'/0'/0/n) + std::string hdMnemonic; // in-memory phrase (present when unlocked/unencrypted) + std::vector vchCryptedHDMnemonic; // encrypted phrase (loaded, decrypted on unlock) + uint256 hdMnemonicIV; // IV for the encrypted phrase + // check whether we are allowed to upgrade (or already support) to the named feature bool CanSupportFeature(WalletFeature wf) { return nWalletMaxVersion >= static_cast(wf); } @@ -133,6 +142,14 @@ public: // keystore implementation // Generate a new key CPubKey GenerateNewKey(); + + // ---- HD wallet (BIP39/BIP32) ---- + bool IsHDEnabled() const { return fHDEnabled; } + bool SetHDSeed(const std::string& mnemonicIn, const std::string& passphrase, bool fGenerate, std::string& mnemonicOut, std::string& strError); + bool GetHDMnemonic(std::string& mnemonicOut) const; + bool DeriveHDKey(int64_t index, CKey& keyOut) const; + bool LoadHDMnemonic(const std::string& m) { hdMnemonic = m; fHDEnabled = true; return true; } + bool LoadCryptedHDMnemonic(const uint256& iv, const std::vector& cipher) { hdMnemonicIV = iv; vchCryptedHDMnemonic = cipher; fHDEnabled = true; return true; } // Adds a key to the store, and saves it to disk. bool AddKey(const CKey& key); // Adds a key to the store, without saving it to disk (used by LoadWallet) diff --git a/src/walletdb.cpp b/src/walletdb.cpp index e86e732..d235ee0 100644 --- a/src/walletdb.cpp +++ b/src/walletdb.cpp @@ -429,6 +429,24 @@ ReadKeyValue(CWallet* pwallet, CDataStream& ssKey, CDataStream& ssValue, pwallet->mapKeyMetadata[keyid] = CKeyMetadata(keypool.nTime); } + else if (strType == "hdmnemonic") + { + std::string m; + ssValue >> m; + pwallet->LoadHDMnemonic(m); + } + else if (strType == "hdcmnemonic") + { + std::pair > cm; + ssValue >> cm; + pwallet->LoadCryptedHDMnemonic(cm.first, cm.second); + } + else if (strType == "hdchain") + { + int64_t n; + ssValue >> n; + pwallet->nHDChainIndex = n; + } else if (strType == "version") { ssValue >> wss.nFileVersion; @@ -461,7 +479,8 @@ ReadKeyValue(CWallet* pwallet, CDataStream& ssKey, CDataStream& ssValue, static bool IsKeyType(string strType) { return (strType== "key" || strType == "wkey" || - strType == "mkey" || strType == "ckey"); + strType == "mkey" || strType == "ckey" || + strType == "hdmnemonic" || strType == "hdcmnemonic"); } DBErrors CWalletDB::LoadWallet(CWallet* pwallet) diff --git a/src/walletdb.h b/src/walletdb.h index b1b9428..6b6d835 100644 --- a/src/walletdb.h +++ b/src/walletdb.h @@ -169,6 +169,21 @@ public: return Write(std::string("defaultkey"), vchPubKey.Raw()); } + bool WriteHDMnemonic(const std::string& mnemonic) { + nWalletDBUpdated++; + Erase(std::string("hdcmnemonic")); + return Write(std::string("hdmnemonic"), mnemonic); + } + bool WriteHDCryptedMnemonic(const uint256& iv, const std::vector& cipher) { + nWalletDBUpdated++; + Erase(std::string("hdmnemonic")); + return Write(std::string("hdcmnemonic"), std::make_pair(iv, cipher)); + } + bool WriteHDChain(int64_t nIndex) { + nWalletDBUpdated++; + return Write(std::string("hdchain"), nIndex); + } + bool ReadPool(int64_t nPool, CKeyPool& keypool) { return Read(std::make_pair(std::string("pool"), nPool), keypool);