xyqon 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -67,7 +67,8 @@ xyqon send \
67
67
  ```
68
68
 
69
69
  The transaction is broadcast to reachable public nodes. A miner must include it in a block before the receiver sees the confirmed balance.
70
- The client checks the best reachable public chain before broadcasting and refuses sends that exceed the wallet's confirmed XYQON balance.
70
+ The client checks the best reachable public chain before broadcasting, refuses sends that exceed the wallet's confirmed XYQON balance, and now requires at least one node to verify the transaction as pending or confirmed before returning success.
71
+ If every reachable node rejects the transaction or cannot return a transaction status, the send fails with the node's rejection reason when available.
71
72
 
72
73
  ## Create And Send Coins
73
74
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xyqon",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Friendly wallet CLI and JavaScript client for the XYQON network.",
5
5
  "keywords": [
6
6
  "xyqon",
package/src/cli.js CHANGED
@@ -42,7 +42,7 @@ ${color('bold', 'Usage')}
42
42
  xyqon wallet new --name <NAME> [--out <FILE>]
43
43
  xyqon wallet show [--wallet <FILE>] [--private]
44
44
  xyqon balance [--wallet <FILE>] [--address <PUBLIC_KEY>]
45
- xyqon send --to <PUBLIC_KEY> --amount <AMOUNT> [--wallet <FILE>]
45
+ xyqon send --to <PUBLIC_KEY> --amount <AMOUNT> [--fee <AMOUNT>] [--wallet <FILE>]
46
46
  xyqon coin create --symbol <SYMBOL> --name <NAME> --supply <AMOUNT> [--wallet <FILE>]
47
47
  xyqon coin send --symbol <SYMBOL> --to <PUBLIC_KEY> --amount <AMOUNT> [--wallet <FILE>]
48
48
  xyqon nft mint --collection <SYMBOL> --token-id <ID> --name <NAME> [--image-url <URL>] [--wallet <FILE>]
@@ -58,6 +58,16 @@ ${color('bold', 'Defaults')}
58
58
  `);
59
59
  }
60
60
 
61
+ function printDelivery(result, label = 'Delivered') {
62
+ console.log(`${color('green', `${label}:`)} ${result.verifiedBy}/${result.peers.length} nodes verified pending or confirmed`);
63
+ if (result.rejectedBy > 0) {
64
+ console.log(`${color('red', 'Rejected:')} ${result.rejectedBy}/${result.peers.length} nodes`);
65
+ }
66
+ if (result.unverifiedBy > 0) {
67
+ console.log(`${color('yellow', 'Unverified:')} ${result.unverifiedBy}/${result.peers.length} nodes did not return a transaction status`);
68
+ }
69
+ }
70
+
61
71
  function parseArgs(argv) {
62
72
  const values = { _: [] };
63
73
  for (let index = 0; index < argv.length; index += 1) {
@@ -152,15 +162,17 @@ async function run() {
152
162
  wallet,
153
163
  recipient: options.to,
154
164
  amount: options.amount,
165
+ fee: options.fee,
155
166
  peers: peersFromOptions(options)
156
167
  });
157
168
 
158
169
  printHero('Transaction Sent');
159
170
  console.log(`${color('bold', 'Amount:')} ${result.transaction.amount} XYQON`);
171
+ console.log(`${color('bold', 'Fee:')} ${result.transaction.fee} XYQON`);
160
172
  console.log(`${color('bold', 'To:')} ${result.transaction.recipient}`);
161
173
  console.log(`${color('bold', 'Transaction ID:')} ${result.transactionId}`);
162
174
  console.log(`${color('dim', 'Checked balance:')} ${result.preflight.balance} XYQON at block ${result.preflight.blockHeight}`);
163
- console.log(`${color('green', 'Delivered:')} ${result.acceptedBy}/${result.peers.length} reachable nodes`);
175
+ printDelivery(result);
164
176
  console.log(color('yellow', 'The receiver balance updates after a miner includes this transaction in a block.'));
165
177
  return;
166
178
  }
@@ -189,7 +201,7 @@ async function run() {
189
201
  console.log(`${color('bold', 'Symbol:')} ${result.transaction.asset_operation.CreateCoin.symbol}`);
190
202
  console.log(`${color('bold', 'Supply:')} ${result.transaction.asset_operation.CreateCoin.supply}`);
191
203
  console.log(`${color('bold', 'Transaction ID:')} ${result.transactionId}`);
192
- console.log(`${color('green', 'Broadcast:')} ${result.acceptedBy}/${result.peers.length} reachable nodes`);
204
+ printDelivery(result, 'Broadcast');
193
205
  console.log(color('yellow', 'The coin exists after a miner includes this transaction in a block.'));
194
206
  return;
195
207
  }
@@ -219,7 +231,7 @@ async function run() {
219
231
  console.log(`${color('bold', 'To:')} ${result.transaction.recipient}`);
220
232
  console.log(`${color('bold', 'Transaction ID:')} ${result.transactionId}`);
221
233
  console.log(`${color('dim', 'Checked balance:')} ${result.preflight.balance} ${result.preflight.symbol} at block ${result.preflight.blockHeight}`);
222
- console.log(`${color('green', 'Delivered:')} ${result.acceptedBy}/${result.peers.length} reachable nodes`);
234
+ printDelivery(result);
223
235
  return;
224
236
  }
225
237
 
@@ -247,7 +259,7 @@ async function run() {
247
259
  printHero('NFT Minted');
248
260
  console.log(`${color('bold', 'NFT:')} ${result.transaction.asset_operation.MintNft.collection}:${result.transaction.asset_operation.MintNft.token_id}`);
249
261
  console.log(`${color('bold', 'Transaction ID:')} ${result.transactionId}`);
250
- console.log(`${color('green', 'Broadcast:')} ${result.acceptedBy}/${result.peers.length} reachable nodes`);
262
+ printDelivery(result, 'Broadcast');
251
263
  console.log(color('yellow', 'The NFT exists after a miner includes this transaction in a block.'));
252
264
  return;
253
265
  }
@@ -276,7 +288,7 @@ async function run() {
276
288
  console.log(`${color('bold', 'NFT:')} ${result.transaction.asset_operation.TransferNft.collection}:${result.transaction.asset_operation.TransferNft.token_id}`);
277
289
  console.log(`${color('bold', 'To:')} ${result.transaction.recipient}`);
278
290
  console.log(`${color('bold', 'Transaction ID:')} ${result.transactionId}`);
279
- console.log(`${color('green', 'Broadcast:')} ${result.acceptedBy}/${result.peers.length} reachable nodes`);
291
+ printDelivery(result, 'Broadcast');
280
292
  return;
281
293
  }
282
294
 
package/src/index.js CHANGED
@@ -9,6 +9,7 @@ export const DEFAULT_PEERS = [
9
9
  '147.182.138.183:7101'
10
10
  ];
11
11
  const XYQON_EPSILON = 0.00000001;
12
+ export const DEFAULT_XYQON_TRANSACTION_FEE = 0.001;
12
13
 
13
14
  export const DEFAULT_WALLET_PATH = 'xyqon.wallet.json';
14
15
 
@@ -103,8 +104,11 @@ function stringifyAssetOperationForSigning(assetOperation) {
103
104
  throw new Error('unknown asset operation');
104
105
  }
105
106
 
106
- export function transactionPayload(sender, recipient, amount, senderPublicKey, assetOperation = null) {
107
- const base = `${sender}|${recipient}|${Number(amount).toFixed(8)}|${senderPublicKey}`;
107
+ export function transactionPayload(sender, recipient, amount, senderPublicKey, assetOperation = null, fee = 0) {
108
+ let base = `${sender}|${recipient}|${Number(amount).toFixed(8)}|${senderPublicKey}`;
109
+ if (Math.abs(Number(fee)) > XYQON_EPSILON) {
110
+ base = `${base}|fee:${Number(fee).toFixed(8)}`;
111
+ }
108
112
  const serializedAsset = stringifyAssetOperationForSigning(assetOperation);
109
113
  return serializedAsset ? `${base}|${serializedAsset}` : base;
110
114
  }
@@ -174,22 +178,27 @@ export function normalizeTokenAmount(amount, label = 'amount') {
174
178
  return numeric;
175
179
  }
176
180
 
177
- export function createSignedTransaction(wallet, recipient, amount) {
181
+ export function createSignedTransaction(wallet, recipient, amount, fee = DEFAULT_XYQON_TRANSACTION_FEE) {
178
182
  const numericAmount = Number(amount);
179
183
  if (!Number.isFinite(numericAmount) || numericAmount <= 0) {
180
184
  throw new Error('amount must be a positive number');
181
185
  }
186
+ const numericFee = Number(fee);
187
+ if (!Number.isFinite(numericFee) || numericFee < 0) {
188
+ throw new Error('fee must be a non-negative number');
189
+ }
182
190
 
183
191
  const privateKey = hexToBytes(wallet.private_key);
184
192
  const senderPublicKey = assertWalletMatchesPrivateKey(wallet, privateKey);
185
193
 
186
- const payload = transactionPayload(wallet.name, recipient, numericAmount, senderPublicKey);
194
+ const payload = transactionPayload(wallet.name, recipient, numericAmount, senderPublicKey, null, numericFee);
187
195
  const signature = ed25519.sign(new TextEncoder().encode(payload), privateKey);
188
196
 
189
197
  return {
190
198
  sender: wallet.name,
191
199
  recipient,
192
200
  amount: numericAmount,
201
+ fee: numericFee,
193
202
  sender_public_key: senderPublicKey,
194
203
  signature: bytesToHex(signature)
195
204
  };
@@ -325,6 +334,62 @@ export function sendNetworkMessage(peer, message, timeoutMs = 5000) {
325
334
  });
326
335
  }
327
336
 
337
+ export function sendTransactionMessage(peer, transaction, timeoutMs = 5000) {
338
+ return new Promise((resolve) => {
339
+ const normalizedPeer = normalizePeer(peer);
340
+ const [host, portText] = normalizedPeer.split(':');
341
+ const socket = net.createConnection({ host, port: Number(portText) });
342
+ const startedAt = Date.now();
343
+ const id = transactionId(transaction);
344
+ let data = '';
345
+ let wrote = false;
346
+ let settled = false;
347
+
348
+ const finish = (result) => {
349
+ if (settled) {
350
+ return;
351
+ }
352
+ settled = true;
353
+ socket.destroy();
354
+ resolve({ peer: normalizedPeer, latencyMs: Date.now() - startedAt, ...result });
355
+ };
356
+
357
+ socket.setTimeout(timeoutMs);
358
+ socket.on('connect', () => {
359
+ socket.write(`${JSON.stringify({ NewTransaction: transaction })}\n`, () => {
360
+ wrote = true;
361
+ });
362
+ });
363
+ socket.on('data', (chunk) => {
364
+ data += chunk.toString('utf8');
365
+ if (!data.includes('\n')) {
366
+ return;
367
+ }
368
+
369
+ try {
370
+ const parsed = JSON.parse(data.trim());
371
+ const status = parsed.TransactionResponse;
372
+ if (!status || status.id !== id) {
373
+ finish({ ok: false, submitted: wrote, error: 'peer returned an unexpected transaction response' });
374
+ return;
375
+ }
376
+
377
+ finish({
378
+ ok: status.status === 'pending' || status.status === 'confirmed',
379
+ submitted: status.status === 'pending' || status.status === 'confirmed',
380
+ verified: true,
381
+ status: status.status,
382
+ error: status.error ?? undefined
383
+ });
384
+ } catch (error) {
385
+ finish({ ok: false, submitted: wrote, error: error.message });
386
+ }
387
+ });
388
+ socket.on('timeout', () => finish({ ok: false, submitted: wrote, verified: false, error: 'timeout waiting for transaction response' }));
389
+ socket.on('error', (error) => finish({ ok: false, submitted: wrote, error: error.message }));
390
+ });
391
+ }
392
+
328
393
  export async function requestChain(peer) {
329
394
  const result = await requestMessage(peer, 'RequestChain', 'ChainResponse');
330
395
  return { ...result, chain: result.payload };
@@ -338,6 +403,16 @@ export async function requestPeers(peer) {
338
403
  };
339
404
  }
340
405
 
406
+ export async function requestTransactionStatus(peer, id) {
407
+ const result = await requestMessage(peer, { RequestTransaction: id }, 'TransactionResponse');
408
+ return {
409
+ ...result,
410
+ transaction: result.payload,
411
+ status: result.payload?.status,
412
+ verified: result.ok && (result.payload?.status === 'pending' || result.payload?.status === 'confirmed')
413
+ };
414
+ }
415
+
341
416
  export async function discoverPeers(seedPeers = DEFAULT_PEERS) {
342
417
  const discovered = new Set(normalizePeers(seedPeers));
343
418
  let frontier = [...discovered];
@@ -402,7 +477,7 @@ export function calculateBalances(chain) {
402
477
  if (transaction.asset_operation) {
403
478
  continue;
404
479
  }
405
- debit(transaction.sender_public_key, transaction.amount);
480
+ debit(transaction.sender_public_key, transaction.amount + (transaction.fee ?? 0));
406
481
  credit(transaction.recipient, transaction.amount);
407
482
  }
408
483
  }
@@ -525,20 +600,24 @@ export async function getBalance(addressOrWallet, seedPeers = DEFAULT_PEERS) {
525
600
  };
526
601
  }
527
602
 
528
- export async function assertSpendableXyqonBalance(wallet, amount, seedPeers = DEFAULT_PEERS) {
603
+ export async function assertSpendableXyqonBalance(wallet, amount, seedPeers = DEFAULT_PEERS, fee = 0) {
529
604
  const numericAmount = Number(amount);
605
+ const numericFee = Number(fee);
530
606
  const { chain, sourcePeer, peers } = await getBestChain(seedPeers);
531
607
  const balances = calculateBalances(chain);
532
608
  const balance = balances.get(wallet.public_key) ?? 0;
609
+ const totalDebit = numericAmount + numericFee;
533
610
 
534
- if (balance + XYQON_EPSILON < numericAmount) {
611
+ if (balance + XYQON_EPSILON < totalDebit) {
535
612
  throw new Error(
536
- `insufficient confirmed XYQON balance; balance is ${balance}, attempted to spend ${numericAmount}`
613
+ `insufficient confirmed XYQON balance; balance is ${balance}, attempted to spend ${numericAmount} plus fee ${numericFee}`
537
614
  );
538
615
  }
539
616
 
540
617
  return {
541
618
  balance,
619
+ fee: numericFee,
620
+ totalDebit,
542
621
  blockHeight: chain.chain.at(-1)?.index ?? 0,
543
622
  sourcePeer,
544
623
  peers
@@ -613,32 +692,50 @@ export async function getCoinHoldings(addressOrWallet, seedPeers = DEFAULT_PEERS
613
692
 
614
693
  export async function broadcastTransaction(transaction, seedPeers = DEFAULT_PEERS) {
615
694
  const peers = await discoverPeers(seedPeers);
616
- const message = { NewTransaction: transaction };
617
- const results = await Promise.all(peers.map((peer) => sendNetworkMessage(peer, message)));
695
+ const id = transactionId(transaction);
696
+ const results = await Promise.all(peers.map((peer) => sendTransactionMessage(peer, transaction)));
697
+ const verifiedResults = results.filter((result) => result.verified);
698
+ const rejectedResults = results.filter((result) => result.status === 'rejected');
618
699
 
619
700
  return {
620
- transactionId: transactionId(transaction),
701
+ transactionId: id,
621
702
  peers,
622
703
  results,
623
- acceptedBy: results.filter((result) => result.ok).length
704
+ acceptedBy: verifiedResults.length,
705
+ verifiedBy: verifiedResults.length,
706
+ rejectedBy: rejectedResults.length,
707
+ unverifiedBy: results.filter((result) => result.submitted && !result.verified).length
624
708
  };
625
709
  }
626
710
 
627
- export async function sendTransaction({ wallet, recipient, amount, peers = DEFAULT_PEERS }) {
628
- const transaction = createSignedTransaction(wallet, recipient, amount);
629
- const preflight = await assertSpendableXyqonBalance(wallet, transaction.amount, peers);
711
+ function assertBroadcastVerified(broadcast) {
712
+ if (broadcast.verifiedBy > 0) {
713
+ return broadcast;
714
+ }
715
+
716
+ const rejection = broadcast.results.find((result) => result.status === 'rejected' && result.error);
717
+ const failure = rejection ?? broadcast.results.find((result) => result.error);
718
+ const detail = failure ? ` ${failure.peer}: ${failure.error}` : '';
719
+ throw new Error(`transaction was not accepted by any reachable XYQON node.${detail}`);
720
+ }
721
+
722
+ export async function sendTransaction({ wallet, recipient, amount, fee = DEFAULT_XYQON_TRANSACTION_FEE, peers = DEFAULT_PEERS }) {
723
+ const transaction = createSignedTransaction(wallet, recipient, amount, fee);
724
+ const preflight = await assertSpendableXyqonBalance(wallet, transaction.amount, peers, transaction.fee);
725
+ const broadcast = assertBroadcastVerified(await broadcastTransaction(transaction, peers));
630
726
  return {
631
727
  transaction,
632
728
  preflight,
633
- ...(await broadcastTransaction(transaction, peers))
729
+ ...broadcast
634
730
  };
635
731
  }
636
732
 
637
733
  export async function createCoin({ wallet, symbol, name, supply, peers = DEFAULT_PEERS }) {
638
734
  const transaction = createCoinTransaction(wallet, { symbol, name, supply });
735
+ const broadcast = assertBroadcastVerified(await broadcastTransaction(transaction, peers));
639
736
  return {
640
737
  transaction,
641
- ...(await broadcastTransaction(transaction, peers))
738
+ ...broadcast
642
739
  };
643
740
  }
644
741
 
@@ -650,25 +747,28 @@ export async function sendCoin({ wallet, recipient, symbol, amount, peers = DEFA
650
747
  transaction.asset_operation.TransferCoin.amount,
651
748
  peers
652
749
  );
750
+ const broadcast = assertBroadcastVerified(await broadcastTransaction(transaction, peers));
653
751
  return {
654
752
  transaction,
655
753
  preflight,
656
- ...(await broadcastTransaction(transaction, peers))
754
+ ...broadcast
657
755
  };
658
756
  }
659
757
 
660
758
  export async function mintNft({ wallet, collection, tokenId, name, imageUrl = null, peers = DEFAULT_PEERS }) {
661
759
  const transaction = createNftMintTransaction(wallet, { collection, tokenId, name, imageUrl });
760
+ const broadcast = assertBroadcastVerified(await broadcastTransaction(transaction, peers));
662
761
  return {
663
762
  transaction,
664
- ...(await broadcastTransaction(transaction, peers))
763
+ ...broadcast
665
764
  };
666
765
  }
667
766
 
668
767
  export async function transferNft({ wallet, recipient, collection, tokenId, peers = DEFAULT_PEERS }) {
669
768
  const transaction = createNftTransferTransaction(wallet, { recipient, collection, tokenId });
769
+ const broadcast = assertBroadcastVerified(await broadcastTransaction(transaction, peers));
670
770
  return {
671
771
  transaction,
672
- ...(await broadcastTransaction(transaction, peers))
772
+ ...broadcast
673
773
  };
674
774
  }