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 }