Skip to content

Commit 70f03e9

Browse files
TinyChain --> TinyEthSecp256k1 added. / chainId support added.
1 parent d920f2a commit 70f03e9

8 files changed

Lines changed: 267 additions & 21 deletions

File tree

package-lock.json

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
"bs58check": "^4.0.0",
8585
"elliptic": "^6.6.1",
8686
"fake-indexeddb": "^6.0.0",
87+
"js-sha3": "^0.9.3",
8788
"lodash": "^4.17.21",
8889
"node-forge": "^1.3.1",
8990
"node-polyfill-webpack-plugin": "^4.1.0",

src/TinyChain/Block.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import TinyCryptoParser from '../lib/TinyCryptoParser.mjs';
5151
* hash: string,
5252
* reward: bigint,
5353
* miner: string|null,
54+
* chainId: bigint,
5455
* txs: TxIndexMap,
5556
* }} GetTransactionData
5657
*/
@@ -216,6 +217,7 @@ class TinyChainBlock {
216217
* @param {bigint | number | string} [options.difficulty=1n] - Mining difficulty.
217218
* @param {bigint | number | string} [options.reward=0n] - Block reward.
218219
* @param {bigint | number | string} [options.nonce=0n] - Starting nonce.
220+
* @param {bigint | number | string} [options.chainId] - The chain ID.
219221
* @param {TxIndexMap} [options.txs] - A map where each key is a transaction index.
220222
* @param {number} [options.timestamp=Date.now()] - Unix timestamp of the block.
221223
* @param {string|null} [options.hash=null] - Optional precomputed hash.
@@ -226,6 +228,7 @@ class TinyChainBlock {
226228
payloadString = true,
227229
parser = new TinyCryptoParser(),
228230
timestamp = Date.now(),
231+
chainId,
229232
txs,
230233
data,
231234
index = 0n,
@@ -267,6 +270,10 @@ class TinyChainBlock {
267270
throw new Error('miner must be a string or null.');
268271
this.miner = typeof miner === 'string' ? miner : null;
269272

273+
if (typeof chainId !== 'bigint' && !(typeof chainId === 'string' && /^[0-9]+$/.test(chainId)))
274+
throw new Error('chainId must be a bigint or a numeric string.');
275+
this.chainId = typeof chainId === 'bigint' ? chainId : BigInt(chainId);
276+
270277
this.data = this.#dataValidator(data);
271278
if (this.data.length === 0) throw new Error('The block data cannot be empty.');
272279
this.txs = this.#validateTxIndexObject(txs);
@@ -292,6 +299,7 @@ class TinyChainBlock {
292299
*/
293300
get() {
294301
return {
302+
chainId: this.chainId,
295303
index: this.index,
296304
timestamp: this.timestamp,
297305
data: this.data,
@@ -325,6 +333,7 @@ class TinyChainBlock {
325333
value += this.#parser.serializeDeep(this.data);
326334
value += this.index.toString();
327335
value += this.nonce.toString();
336+
value += this.chainId.toString();
328337
return createHash('sha256').update(Buffer.from(value, 'utf-8')).digest('hex');
329338
}
330339

src/TinyChain/Instance.mjs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ class TinyChainInstance {
232232
* registered immediately.
233233
*
234234
* @param {Object} [options] - Configuration options for the blockchain instance.
235+
* @param {string|number|bigint} [options.chainId=0] - The chain ID.
235236
* @param {string|number|bigint} [options.transferGas=15000] - Fixed gas cost per transfer operation (symbolic).
236237
* @param {string|number|bigint} [options.baseFeePerGas=21000] - Base gas fee per unit (in gwei).
237238
* @param {string|number|bigint} [options.priorityFeeDefault=2] - Default priority tip per gas unit (in gwei).
@@ -248,6 +249,7 @@ class TinyChainInstance {
248249
* @throws {Error} Throws an error if any parameter has an invalid type or value.
249250
*/
250251
constructor({
252+
chainId = 0,
251253
transferGas = 15000, // symbolic per transfer, varies in real EVM
252254
baseFeePerGas = 21000,
253255
priorityFeeDefault = 2,
@@ -262,6 +264,8 @@ class TinyChainInstance {
262264
admins = [],
263265
} = {}) {
264266
// Validation for each parameter
267+
if (typeof chainId !== 'bigint' && typeof chainId !== 'number' && typeof chainId !== 'string')
268+
throw new Error('Invalid type for chainId. Expected bigint, number, or string.');
265269
if (
266270
typeof transferGas !== 'bigint' &&
267271
typeof transferGas !== 'number' &&
@@ -355,6 +359,13 @@ class TinyChainInstance {
355359
*/
356360
this.transferGas = BigInt(transferGas);
357361

362+
/**
363+
* The chain id applied per transfer operation.
364+
*
365+
* @type {bigint}
366+
*/
367+
this.chainId = BigInt(chainId);
368+
358369
/**
359370
* The base fee per unit of gas, similar to Ethereum's `baseFeePerGas`.
360371
*
@@ -513,6 +524,7 @@ class TinyChainInstance {
513524
return new TinyChainBlock({
514525
payloadString: this.#payloadString,
515526
parser: this.#parser,
527+
chainId: this.chainId,
516528
...options,
517529
});
518530
}

src/TinyChain/Secp256k1/Btc.mjs

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class TinyBtcSecp256k1 extends TinySecp256k1 {
1111
* Creates an instance of TinyBtcSecp256k1.
1212
*
1313
* @param {Object} [options] - Optional parameters for the instance.
14-
* @param {string|null} [options.msgPrefix=null] - Message prefix used during message signing.
14+
* @param {string|null} [options.msgPrefix='Bitcoin Signed Message:\n'] - Message prefix used during message signing.
1515
* @param {string|null} [options.privateKey=null] - String representation of the private key.
1616
* @param {BufferEncoding} [options.privateKeyEncoding='hex'] - Encoding used for the privateKey string.
1717
*/
@@ -23,20 +23,6 @@ class TinyBtcSecp256k1 extends TinySecp256k1 {
2323
super({ msgPrefix, privateKey, privateKeyEncoding });
2424
}
2525

26-
/**
27-
* @param {string|Buffer} message
28-
* @param {Buffer} signature
29-
* @param {Object} [options]
30-
* @param {BufferEncoding} [options.encoding]
31-
* @param {string} [options.prefix]
32-
* @param {Buffer} [options.publicKey]
33-
* @returns {string}
34-
* @throws {Error}
35-
*/
36-
recoverMessageKey(message, signature, options = {}) {
37-
throw new Error('recoverMessageKey is disabled!');
38-
}
39-
4026
/**
4127
* Initializes the internal elliptic key pair using the private key.
4228
*
@@ -286,7 +272,7 @@ class TinyBtcSecp256k1 extends TinySecp256k1 {
286272
* @param {BufferEncoding} [options.encoding='utf8'] - Encoding for input message if it is a string.
287273
* @param {string} [options.prefix] - Optional prefix (defaults to Bitcoin prefix or instance default).
288274
* @returns {Buffer} The signature.
289-
* @throws {Error} If no private key is available.
275+
* @throws {Error} If recovery param could not be calculated.
290276
*/
291277
signMessage(message, options = {}) {
292278
const keyPair = this.getKeyPair();
@@ -299,7 +285,10 @@ class TinyBtcSecp256k1 extends TinySecp256k1 {
299285

300286
// Calculate recid (recovery param)
301287
const recid = signature.recoveryParam;
302-
if (recid === null) throw new Error('No!');
288+
if (recid === null)
289+
throw new Error(
290+
'Failed to calculate recovery param (recid) from signature. Signature may be invalid or keyPair is not properly initialized.',
291+
);
303292
const header = 27 + recid;
304293
const sigBuffer = Buffer.concat([Buffer.from([header]), r, s]);
305294
return sigBuffer;

src/TinyChain/Secp256k1/Eth.mjs

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { Buffer } from 'buffer';
2+
import TinySecp256k1 from './index.mjs';
3+
4+
class TinyEthSecp256k1 extends TinySecp256k1 {
5+
/** @typedef {import('js-sha3')} JsSha3 */
6+
/** @typedef {import('elliptic').ec.KeyPair} KeyPair */
7+
8+
/**
9+
* Creates an instance of TinyEthSecp256k1.
10+
*
11+
* @param {Object} [options] - Optional parameters for the instance.
12+
* @param {string|null} [options.msgPrefix='\x19Ethereum Signed Message:\n'] - Message prefix used during message signing.
13+
* @param {string|null} [options.privateKey=null] - String representation of the private key.
14+
* @param {BufferEncoding} [options.privateKeyEncoding='hex'] - Encoding used for the privateKey string.
15+
*/
16+
constructor({
17+
msgPrefix = '\x19Ethereum Signed Message:\n',
18+
privateKey = null,
19+
privateKeyEncoding = 'hex',
20+
} = {}) {
21+
super({ msgPrefix, privateKey, privateKeyEncoding });
22+
}
23+
24+
/**
25+
* Initializes the internal elliptic key pair using the private key.
26+
*
27+
* @returns {Promise<KeyPair>} The elliptic key pair.
28+
*/
29+
async init() {
30+
await Promise.all([this.fetchElliptic(), this.fetchJsSha3()]);
31+
const ec = this.getEc();
32+
this.keyPair = ec.keyFromPrivate(this.privateKey);
33+
return this.keyPair;
34+
}
35+
36+
/**
37+
* Dynamically imports the `jsSha3` module and stores it in the instance.
38+
* Ensures the module is loaded only once (lazy singleton).
39+
*
40+
* @returns {Promise<JsSha3>} The loaded `jsSha3` module.
41+
*/
42+
async fetchJsSha3() {
43+
if (!this.jsSha3) {
44+
const jsSha3 = await import(/* webpackMode: "eager" */ 'js-sha3').catch(() => {
45+
console.warn(
46+
'[JsSha3] Warning: "js-sha3" is not installed. ' +
47+
'JsSha3 requires "js-sha3" to function properly. ' +
48+
'Please install it with "npm install js-sha3".',
49+
);
50+
return null;
51+
});
52+
if (jsSha3) {
53+
// @ts-ignore
54+
this.jsSha3 = jsSha3?.default ?? jsSha3;
55+
}
56+
}
57+
return this.getJsSha3();
58+
}
59+
60+
/**
61+
* Returns the initialized `jsSha3` instance from the jsSha3 module.
62+
*
63+
* @returns {JsSha3} The jsSha3 instance.
64+
* @throws Will throw an error if `jsSha3` is not initialized.
65+
*/
66+
getJsSha3() {
67+
if (typeof this.jsSha3 === 'undefined' || this.jsSha3 === null)
68+
throw new Error(
69+
`Failed to initialize JsSha3: Module is ${this.jsSha3 !== null ? 'undefined' : 'null'}.\n` +
70+
'Please make sure "js-sha3" is installed.\n' +
71+
'You can install it by running: npm install js-sha3',
72+
);
73+
// @ts-ignore
74+
return this.jsSha3;
75+
}
76+
77+
/**
78+
* Returns the public key as a buffer.
79+
* @param {boolean} [compressed=false] - Ethereum não usa chaves comprimidas.
80+
* @returns {Buffer}
81+
*/
82+
#getPublicKeyBuffer(compressed = false) {
83+
return Buffer.from(this.getKeyPair().getPublic(compressed, 'array'));
84+
}
85+
86+
/**
87+
88+
* Apply EIP-55 checksum to a lowercase address.
89+
* @param {string} address - Hex string without 0x prefix.
90+
* @returns {string}
91+
*/
92+
#toChecksumAddress(address) {
93+
const { keccak256 } = this.getJsSha3();
94+
const hash = keccak256(address.toLowerCase());
95+
let checksumAddress = '0x';
96+
for (let i = 0; i < address.length; i++) {
97+
const char = address[i];
98+
const hashChar = parseInt(hash[i], 16);
99+
checksumAddress += hashChar >= 8 ? char.toUpperCase() : char.toLowerCase();
100+
}
101+
102+
return checksumAddress;
103+
}
104+
105+
/**
106+
* Generate the Ethereum address from the public key.
107+
*
108+
* @param {Buffer} pubKey
109+
* @returns {string}
110+
*/
111+
#getAddress(pubKey) {
112+
const { keccak256 } = this.getJsSha3();
113+
const addressBuf = Buffer.from(keccak256.arrayBuffer(pubKey)).subarray(-20);
114+
const addressHex = [...addressBuf].map((b) => b.toString(16).padStart(2, '0')).join('');
115+
return this.#toChecksumAddress(addressHex);
116+
}
117+
118+
/**
119+
* Returns the public key in hexadecimal.
120+
* @returns {string}
121+
*/
122+
getPublicKeyHex() {
123+
return this.getKeyPair().getPublic(false, 'hex');
124+
}
125+
126+
/**
127+
* Generate the Ethereum address from the public key.
128+
* @returns {string}
129+
*/
130+
getAddress() {
131+
const pubKey = this.#getPublicKeyBuffer(false).subarray(1); // remove byte 0x04
132+
return this.#getAddress(pubKey);
133+
}
134+
135+
/**
136+
* Sign a message using Ethereum prefix.
137+
* @param {string|Buffer} message - The message to be signed.
138+
* @param {Object} [options] - Optional signing parameters.
139+
* @param {BufferEncoding} [options.encoding='utf8'] - Encoding for input message if it is a string.
140+
* @param {string} [options.prefix] - Optional prefix (defaults to Ethereum prefix or instance default).
141+
* @returns {Buffer} The signature.
142+
*/
143+
signMessage(message, options = {}) {
144+
const { keccak256 } = this.getJsSha3();
145+
const keyPair = this.getKeyPair();
146+
const { prefix = this.msgPrefix, encoding = 'utf8' } = options;
147+
const msgBuffer = Buffer.isBuffer(message) ? message : Buffer.from(message, encoding);
148+
const ethMessage = Buffer.concat([Buffer.from(`${prefix}${msgBuffer.length}`), msgBuffer]);
149+
const msgHash = Buffer.from(keccak256.arrayBuffer(ethMessage));
150+
151+
const sig = keyPair.sign(msgHash, { canonical: true });
152+
153+
// Calculate recid (recovery param)
154+
const recid = sig.recoveryParam;
155+
if (recid === null)
156+
throw new Error(
157+
'Failed to calculate recovery param (recid) from signature. Signature may be invalid or keyPair is not properly initialized.',
158+
);
159+
160+
const r = sig.r.toArrayLike(Buffer, 'be', 32);
161+
const s = sig.s.toArrayLike(Buffer, 'be', 32);
162+
const v = Buffer.from([recid + 27]);
163+
return Buffer.concat([r, s, v]);
164+
}
165+
166+
/**
167+
* Recovers the address from the message and the signature.
168+
* @param {string|Buffer} message The original message.
169+
* @param {Buffer|string} signature - Signature in DER format (base64-encoded or Buffer).
170+
* @param {Object} [options] - Optional signing parameters.
171+
* @param {BufferEncoding} [options.encoding='utf8'] - Encoding for input message if it is a string.
172+
* @param {string} [options.prefix] - Optional prefix (defaults to Ethereum prefix or instance default).
173+
* @returns {string|null}
174+
*/
175+
recoverMessage(message, signature, options = {}) {
176+
const { keccak256 } = this.getJsSha3();
177+
const { prefix = this.msgPrefix, encoding = 'utf8' } = options;
178+
const sigBuf = typeof signature === 'string' ? Buffer.from(signature, 'hex') : signature;
179+
if (sigBuf.length !== 65) return null;
180+
181+
const r = sigBuf.subarray(0, 32);
182+
const s = sigBuf.subarray(32, 64);
183+
const vRaw = sigBuf[64];
184+
if (vRaw !== 27 && vRaw !== 28) return null;
185+
const v = vRaw - 27;
186+
187+
const msgBuffer = Buffer.isBuffer(message) ? message : Buffer.from(message, encoding);
188+
const ethMessage = Buffer.concat([Buffer.from(`${prefix}${msgBuffer.length}`), msgBuffer]);
189+
const msgHash = Buffer.from(keccak256.arrayBuffer(ethMessage));
190+
191+
const ec = this.getEc();
192+
const pubKey = ec.recoverPubKey(msgHash, { r, s }, v);
193+
const pubBuffer = Buffer.from(pubKey.encode('array', false)).subarray(1);
194+
return this.#getAddress(pubBuffer);
195+
}
196+
}
197+
198+
export default TinyEthSecp256k1;

src/TinyChain/index.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import TinySecp256k1 from './Secp256k1/index.mjs';
44
import TinyChainEvents from './Events.mjs';
55
import TinyChainBlock from './Block.mjs';
66
import TinyChainInstance from './Instance.mjs';
7+
import TinyEthSecp256k1 from './Secp256k1/Eth.mjs';
78

89
/**
910
*
@@ -20,6 +21,7 @@ class TinyChain {
2021
static Events = TinyChainEvents;
2122
static Secp256k1 = TinySecp256k1;
2223
static Btc256k1 = TinyBtcSecp256k1;
24+
static Eth256k1 = TinyEthSecp256k1;
2325

2426
/**
2527
* This constructor is intentionally blocked.

0 commit comments

Comments
 (0)