/ tools / tx.pl
tx.pl
  1  #!/usr/bin/env perl
  2  
  3  # v4.0.2
  4  
  5  use JSON;
  6  use warnings;
  7  use strict;
  8  
  9  use Getopt::Std;
 10  use File::Basename;
 11  use Digest::SHA qw( sha256 sha256_hex );
 12  use Crypt::RIPEMD160;
 13  
 14  our %opt;
 15  getopts('dpst', \%opt);
 16  
 17  my $proc = basename($0);
 18  my $dirname = dirname($0);
 19  my $OPENSSL_SIGN = "${dirname}/openssl-sign.sh";
 20  my $OPENSSL_PRIV_TO_PUB = index(`$ENV{SHELL} -i -c 'openssl version;exit' 2>/dev/null`, 'OpenSSL 3.') != -1;
 21  
 22  if (@ARGV < 1) {
 23  	print STDERR "usage: $proc [-d] [-p] [-s] [-t] <tx-type> <privkey> <values> [<key-value pairs>]\n";
 24  	print STDERR "-d: debug, -p: process (broadcast) transaction, -s: sign, -t: testnet\n";
 25  	print STDERR "example: $proc PAYMENT P22kW91AJfDNBj32nVii292hhfo5AgvUYPz5W12ExsjE QxxQZiK7LZBjmpGjRz1FAZSx9MJDCoaHqz 0.1\n";
 26  	print STDERR "example: $proc JOIN_GROUP X92h3hf9k20kBj32nVnoh3XT14o5AgvUYPz5W12ExsjE 3\n";
 27  	print STDERR "example: BASE_URL=node10.fork.net $proc JOIN_GROUP CB2DW91AJfd47432nVnoh3XT14o5AgvUYPz5W12ExsjE 3\n";
 28  	print STDERR "example: $proc -p sign C4ifh827ffDNBj32nVnoh3XT14o5AgvUYPz5W12ExsjE 111jivxUwerRw...Fjtu\n";
 29  	print STDERR "for help: $proc all\n";
 30  	print STDERR "for help: $proc REGISTER_NAME\n";
 31  	exit 2;
 32  }
 33  
 34  our @b58 = qw{
 35        1 2 3 4 5 6 7 8 9
 36      A B C D E F G H   J K L M N   P Q R S T U V W X Y Z
 37      a b c d e f g h i j k   m n o p q r s t u v w x y z
 38  };
 39  our %b58 = map { $b58[$_] => $_ } 0 .. 57;
 40  our %reverseb58 = reverse %b58;
 41  
 42  our $BASE_URL = $ENV{BASE_URL} || ($opt{t} ? 'http://localhost:60391' : 'http://localhost:10391');
 43  our $DEFAULT_FEE = 0.01;
 44  
 45  our %TRANSACTION_TYPES = (
 46  	payment => {
 47  		url => 'payments/pay',
 48  		required => [qw(recipient amount)],
 49  		key_name => 'senderPublicKey',
 50  	},
 51  	# groups
 52  	set_group => {
 53  		url => 'groups/setdefault',
 54  		required => [qw(defaultGroupId)],
 55  		key_name => 'creatorPublicKey',
 56  	},
 57  	create_group => {
 58  		url => 'groups/create',
 59  		required => [qw(groupName description isOpen approvalThreshold)],
 60  		defaults => { minimumBlockDelay => 10, maximumBlockDelay => 30 },
 61  		key_name => 'creatorPublicKey',
 62  	},
 63  	update_group => {
 64  		url => 'groups/update',
 65  		required => [qw(groupId newOwner newDescription newIsOpen newApprovalThreshold)],
 66  		key_name => 'ownerPublicKey',
 67  	},
 68  	join_group => {
 69  		url => 'groups/join',
 70  		required => [qw(groupId)],
 71  		key_name => 'joinerPublicKey',
 72  	},
 73  	leave_group => {
 74  		url => 'groups/leave',
 75  		required => [qw(groupId)],
 76  		key_name => 'leaverPublicKey',
 77  	},
 78  	group_invite => {
 79  		url => 'groups/invite',
 80  		required => [qw(groupId invitee)],
 81  		key_name => 'adminPublicKey',
 82  	},
 83  	group_kick => {
 84  		url => 'groups/kick',
 85  		required => [qw(groupId member reason)],
 86  		key_name => 'adminPublicKey',
 87  	},
 88  	add_group_admin => {
 89  		url => 'groups/addadmin',
 90  		required => [qw(groupId txGroupId member)],
 91  		key_name => 'ownerPublicKey',
 92  	},
 93  	remove_group_admin => {
 94  		url => 'groups/removeadmin',
 95  		required => [qw(groupId txGroupId admin)],
 96  		key_name => 'ownerPublicKey',
 97  	},
 98  	group_approval => {
 99  		url => 'groups/approval',
100  		required => [qw(pendingSignature approval)],
101  		key_name => 'adminPublicKey',
102  	},
103  	# assets
104  	issue_asset => {
105  		url => 'assets/issue',
106  		required => [qw(assetName description quantity isDivisible)],
107  		key_name => 'issuerPublicKey',
108  	},
109  	update_asset => {
110  		url => 'assets/update',
111  		required => [qw(assetId newOwner)],
112  		key_name => 'ownerPublicKey',
113  	},
114  	transfer_asset => {
115  		url => 'assets/transfer',
116  		required => [qw(recipient amount assetId)],
117  		key_name => 'senderPublicKey',
118  	},
119  	create_order => {
120  		url => 'assets/order',
121  		required => [qw(haveAssetId wantAssetId amount price)],
122  		key_name => 'creatorPublicKey',
123  	},
124  	# names
125  	register_name => {
126  		url => 'names/register',
127  		required => [qw(name data)],
128  		key_name => 'registrantPublicKey',
129  	},
130  	update_name => {
131  		url => 'names/update',
132  		required => [qw(name newName newData)],
133  		key_name => 'ownerPublicKey',
134  	},
135  	# reward-shares
136  	reward_share => {
137  		url => 'addresses/rewardshare',
138  		required => [qw(recipient rewardSharePublicKey sharePercent)],
139  		key_name => 'minterPublicKey',
140  	},
141  	# arbitrary
142  	arbitrary => {
143  		url => 'arbitrary',
144  		required => [qw(service dataType data)],
145  		key_name => 'senderPublicKey',
146  	},
147  	# chat
148  	chat => {
149  		url => 'chat',
150  		required => [qw(data)],
151  		optional => [qw(recipient isText isEncrypted)],
152  		key_name => 'senderPublicKey',
153  		defaults => { isText => 'true' },
154  		pow_url => 'chat/compute',
155  	},
156  	# misc
157  	publicize => {
158  		url => 'addresses/publicize',
159  		required => [],
160  		key_name => 'senderPublicKey',
161  		pow_url => 'addresses/publicize/compute',
162  	},
163  	# AT
164  	deploy_at => {
165  		url => 'at',
166  		required => [qw(name description aTType tags creationBytes amount)],
167  		optional => [qw(assetId)],
168  		key_name => 'creatorPublicKey',
169  		defaults => { assetId => 0 },
170  	},
171  	# Cross-chain trading
172  	create_trade => {
173  		url => 'crosschain/tradebot/create',
174  		required => [qw(frkAmount fundingFrkAmount foreignAmount receivingAddress)],
175  		optional => [qw(tradeTimeout foreignBlockchain)],
176  		key_name => 'creatorPublicKey',
177  		defaults => { tradeTimeout => 1440, foreignBlockchain => 'LITECOIN' },
178  	},
179  	trade_recipient => {
180  		url => 'crosschain/tradeoffer/recipient',
181  		required => [qw(atAddress recipient)],
182  		key_name => 'creatorPublicKey',
183  		remove => [qw(timestamp reference fee)],
184  	},
185  	trade_secret => {
186  		url => 'crosschain/tradeoffer/secret',
187  		required => [qw(atAddress secret)],
188  		key_name => 'recipientPublicKey',
189  		remove => [qw(timestamp reference fee)],
190  	},
191  	# These are fake transaction types to provide utility functions:
192  	sign => {
193  		url => 'transactions/sign',
194  		required => [qw{transactionBytes}],
195  	},
196  );
197  
198  my $tx_type = lc(shift(@ARGV));
199  
200  if ($tx_type eq 'all') {
201  	printf STDERR "Transaction types: %s\n", join(', ', sort { $a cmp $b } keys %TRANSACTION_TYPES);
202  	exit 2;
203  }
204  
205  my $tx_info = $TRANSACTION_TYPES{$tx_type};
206  
207  if (!$tx_info) {
208  	printf STDERR "Transaction type '%s' unknown\n", uc($tx_type);
209  	exit 1;
210  }
211  
212  my @required = @{$tx_info->{required}};
213  
214  if (@ARGV < @required + 1) {
215  	printf STDERR "usage: %s %s <privkey> %s", $proc, uc($tx_type), join(' ', map { "<$_>"} @required);
216  	printf STDERR " %s", join(' ', map { "[$_ <$_>]" } @{$tx_info->{optional}}) if exists $tx_info->{optional};
217  	print "\n";
218  	exit 2;
219  }
220  
221  my $priv_key = shift @ARGV;
222  
223  my $account;
224  my $raw;
225  
226  if ($tx_type ne 'sign') {
227  	my %extras;
228  
229  	foreach my $required_arg (@required) {
230  		$extras{$required_arg} = shift @ARGV;
231  	}
232  
233  	# For CHAT we use a random reference
234  	if ($tx_type eq 'chat') {
235  		$extras{reference} = api('utils/random?length=64');
236  	}
237  
238  	%extras = (%extras, %{$tx_info->{defaults}}) if exists $tx_info->{defaults};
239  
240  	%extras = (%extras, @ARGV);
241  
242  	$account = account($priv_key, %extras);
243  
244  	$raw = build_raw($tx_type, $account, %extras);
245  	printf "Raw: %s\n", $raw if $opt{d} || (!$opt{s} && !$opt{p});
246  
247  	# Some transaction types require proof-of-work, e.g. CHAT
248  	if (exists $tx_info->{pow_url}) {
249  		$raw = api($tx_info->{pow_url}, $raw);
250  		printf "Raw with PoW: %s\n", $raw if $opt{d};
251  	}
252  } else {
253  	$raw = shift @ARGV;
254  	$opt{s}++;
255  }
256  
257  if ($opt{s}) {
258  	my $signed = sign($priv_key, $raw);
259  	printf "Signed: %s\n", $signed if $opt{d} || $tx_type eq 'sign';
260  
261  	if ($opt{p}) {
262  		my $processed = process($signed);
263  		printf "Processed: %s\n", $processed if $opt{d};
264  	}
265  
266  	my $hex = api('utils/frombase58', $signed);
267  	# sig is last 64 bytes / 128 chars
268  	my $sighex = substr($hex, -128);
269  
270  	my $sig58 = api('utils/tobase58/{hex}', '', '{hex}', $sighex);
271  	printf "Signature: %s\n", $sig58;
272  }
273  
274  sub account {
275  	my ($privkey, %extras) = @_;
276  
277  	my $account = { private => $privkey };
278  	$account->{public} = $extras{publickey} || priv_to_pub($privkey);
279  	$account->{address} = $extras{address} || pubkey_to_address($account->{public}); # api('addresses/convert/{publickey}', '', '{publickey}', $account->{public});
280  
281  	return $account;
282  }
283  
284  sub priv_to_pub {
285  	my ($privkey) = @_;
286  
287  	if ($OPENSSL_PRIV_TO_PUB) {
288  		return openssl_priv_to_pub($privkey);
289  	} else {
290  		return api('utils/publickey', $privkey);
291  	}
292  }
293  
294  sub build_raw {
295  	my ($type, $account, %extras) = @_;
296  
297  	my $tx_info = $TRANSACTION_TYPES{$type};
298  	die("unknown tx type: $type\n") unless defined $tx_info;
299  
300  	my $ref = exists $extras{reference} ? $extras{reference} : lastref($account->{address});
301  
302  	my %json = (
303  		timestamp => time * 1000,
304  		reference => $ref,
305  		fee => $DEFAULT_FEE,
306  	);
307  
308  	$json{$tx_info->{key_name}} = $account->{public} if exists $tx_info->{key_name}; 
309  
310  	foreach my $required (@{$tx_info->{required}}) {
311  		die("missing tx field: $required\n") unless exists $extras{$required};
312  	}
313  
314  	while (my ($key, $value) = each %extras) {
315  		$json{$key} = $value;
316  	}
317  
318  	if (exists $tx_info->{remove}) {
319  		foreach my $key (@{$tx_info->{remove}}) {
320  			delete $json{$key};
321  		}
322  	}
323  
324  	my $json = "{\n";
325  	while (my ($key, $value) = each %json) {
326  		if (ref($value) eq 'ARRAY') {
327  			$json .= "\t\"$key\": [],\n";
328  		} else {
329  			$json .= "\t\"$key\": \"$value\",\n";
330  		}
331  	}
332  	# remove final comma
333  	substr($json, -2, 1) = '';
334  	$json .= "}\n";
335  
336  	printf "%s:\n%s\n", $type, $json if $opt{d};
337  
338  	my $raw = api($tx_info->{url}, $json);
339  	return $raw;
340  }
341  
342  sub sign {
343  	my ($private, $raw) = @_;
344  
345  	if (-x "$OPENSSL_SIGN") {
346  		my $private_hex = decode_base58($private);
347  		chomp $private_hex;
348  
349  		my $raw_hex = decode_base58($raw);
350  		chomp $raw_hex;
351  
352  		my $sig = `${OPENSSL_SIGN} ${private_hex} ${raw_hex}`;
353  		chomp $sig;
354  
355  		my $sig58 = encode_base58(${raw_hex} . ${sig});
356  		chomp $sig58;
357  		return $sig58;
358  	}
359  
360  	my $json = <<"	__JSON__";
361  	{
362  		"privateKey": "$private",
363  		"transactionBytes": "$raw"
364  	}
365  	__JSON__
366  
367  	return api('transactions/sign', $json);
368  }
369  
370  sub process {
371  	my ($signed) = @_;
372  	
373  	return api('transactions/process', $signed);
374  }
375  
376  sub lastref {
377  	my ($address) = @_;
378  
379  	return api('addresses/lastreference/{address}', '', '{address}', $address)
380  }
381  
382  sub api {
383  	my ($endpoint, $postdata, @args) = @_;
384  
385  	my $url = $endpoint;
386  	my $method = 'GET';
387  
388  	for (my $i = 0; $i < @args; $i += 2) {
389  		my $placemarker = $args[$i];
390  		my $value = $args[$i + 1];
391  		
392  		$url =~ s/$placemarker/$value/g;
393  	}
394  
395  	my $curl = "curl --silent --output - --url '$BASE_URL/$url'";
396  	if (defined $postdata && $postdata ne '') {
397  		$postdata =~ tr|\n| |s;
398  
399  		if ($postdata =~ /^\s*\{/so) {
400  			$curl .= " --header 'Content-Type: application/json'";
401  		} else {
402  			$curl .= " --header 'Content-Type: text/plain'";
403  		}
404  
405  		$curl .= " --data-binary '$postdata'";
406  		$method = 'POST';
407  	}
408  	my $response = `$curl 2>/dev/null`; 
409  	chomp $response;
410  
411  	if ($response eq '' || substr($response, 0, 6) eq '<html>' || $response =~ m/(^\{|,)"error":(\d+)[,}]/) {
412  		die("API call '$method $BASE_URL/$endpoint' failed:\n$response\n");
413  	}
414  
415  	return $response;
416  }
417  
418  sub encode_base58 {
419      use integer;
420      my @in = map { hex($_) } ($_[0] =~ /(..)/g);
421      my $bzeros = length($1) if join('', @in) =~ /^(0*)/;
422      my @out;
423      my $size = 2 * scalar @in;
424      for my $c (@in) {
425          for (my $j = $size; $j--; ) {
426              $c += 256 * ($out[$j] // 0);
427              $out[$j] = $c % 58;
428              $c /= 58;
429          }
430      }
431      my $out = join('', map { $reverseb58{$_} } @out);
432      return $1 if $out =~ /(1{$bzeros}[^1].*)/;
433      return $1 if $out =~ /(1{$bzeros})/;
434      die "Invalid base58!\n";
435  }
436  
437  
438  sub decode_base58 {
439      use integer;
440      my @out;
441      my $azeros = length($1) if $_[0] =~ /^(1*)/;
442      for my $c ( map { $b58{$_} } $_[0] =~ /./g ) {
443  	die("Invalid character!\n") unless defined $c;
444          for (my $j = length($_[0]); $j--; ) {
445              $c += 58 * ($out[$j] // 0);
446              $out[$j] = $c % 256;
447              $c /= 256;
448          }
449      }
450      shift @out while @out && $out[0] == 0;
451      unshift(@out, (0) x $azeros);
452      return sprintf('%02x' x @out, @out);
453  }
454  
455  sub openssl_priv_to_pub {
456  	my ($privkey) = @_;
457  
458  	my $privkey_hex = decode_base58($privkey);
459  
460  	my $key_type = "04"; # hex
461  	my $length = "20"; # hex
462  
463  	my $asn1 = <<"__ASN1__";
464  asn1=SEQUENCE:private_key
465  
466  [private_key]
467  version=INTEGER:0
468  included=SEQUENCE:key_info
469  raw=FORMAT:HEX,OCTETSTRING:${key_type}${length}${privkey_hex}
470  
471  [key_info]
472  type=OBJECT:ED25519
473  
474  __ASN1__
475  
476  	my $output = `echo "${asn1}" | openssl asn1parse -i -genconf - -out - | openssl pkey -in - -inform der -noout -text_pub`;
477  
478  	# remove colons
479  	my $pubkey = '';
480  	$pubkey .= $1 while $output =~ m/([0-9a-f]{2})(?::|$)/g;
481  
482  	return encode_base58($pubkey);
483  }
484  
485  sub pubkey_to_address {
486  	my ($pubkey) = @_;
487  
488  	my $pubkey_hex = decode_base58($pubkey);
489  	my $pubkey_raw = pack('H*', $pubkey_hex);
490  
491  	my $pkh_hex = Crypt::RIPEMD160->hexhash(sha256($pubkey_raw));
492  	$pkh_hex =~ tr/ //ds;
493  
494  	my $version = '3a'; # hex
495  
496  	my $raw = pack('H*', $version . $pkh_hex);
497  	my $chksum = substr(sha256_hex(sha256($raw)), 0, 8);
498  
499  	return encode_base58($version . $pkh_hex . $chksum);
500  }