Web3-Tiny

 view release on metacpan or  search on metacpan

lib/Web3/Tiny/Wallet.pm  view on Meta::CPAN

package Web3::Tiny::Wallet;

use strict;
use warnings;

use Web3::Tiny::Secp256k1 qw(privkey_to_pubkey pubkey_to_address sign_hash);
use Web3::Tiny::Keccak256 qw(keccak256);
use Web3::Tiny::RLP       qw(rlp_encode int_to_bytes);

our $VERSION = '0.01';

sub _hex_to_bytes {
    my ($val) = @_;
    return '' unless defined $val && length($val);
    if ($val =~ /^0x([0-9a-fA-F]*)$/) {
        my $hex = $1;
        $hex = '0' . $hex if length($hex) % 2;
        return pack('H*', $hex);
    }
    return $val;
}

sub _strip_leading_zeros {
    my ($bytes) = @_;
    $bytes =~ s/^\x00+//;
    return $bytes;
}

# Web3::Tiny::Wallet->new(private_key => '0x...' or 32 raw bytes)
sub new {
    my ($class, %opts) = @_;
    die "Web3::Tiny::Wallet: 'private_key' is required\n" unless defined $opts{private_key};

    my $priv = _hex_to_bytes($opts{private_key});
    die "Web3::Tiny::Wallet: private_key must be 32 bytes\n" unless length($priv) == 32;

    my ($x, $y) = privkey_to_pubkey($priv);
    my $addr = pubkey_to_address($x, $y);

    return bless {
        priv    => $priv,
        pub_x   => $x,
        pub_y   => $y,
        address => $addr,
    }, $class;
}

# address() -> "0x" + EIP-55 checksummed 40-hex-char address
sub address {
    my ($self) = @_;
    return to_checksum_address($self->{address});
}

# to_checksum_address($addr) -- accepts "0x..." hex or 20 raw bytes
sub to_checksum_address {
    my ($addr) = @_;
    my $bytes = (length($addr) == 20) ? $addr : _hex_to_bytes($addr);
    my $hex   = lc unpack('H*', $bytes);
    my $hash  = unpack('H*', keccak256($hex));

    my $out = '';
    for my $i (0 .. length($hex) - 1) {
        my $c = substr($hex, $i, 1);
        if ($c =~ /[a-f]/ && hex(substr($hash, $i, 1)) >= 8) {
            $out .= uc $c;
        }
        else {
            $out .= $c;
        }
    }
    return '0x' . $out;
}

# sign_transaction(%tx) -> "0x..." raw signed legacy (EIP-155) transaction
#
# Required: nonce, gas_price, gas, chain_id
# Optional: to (omit/empty for contract creation), value (default 0),
#           data (default ''; raw bytes or "0x..." hex)
#
# Pass large numeric fields (gas_price, value) as decimal strings or
# Math::BigInt objects if they might exceed ~2^53 -- Perl numeric
# literals like 1e18 are floats and will silently lose precision.
sub sign_transaction {
    my ($self, %tx) = @_;

    for my $req (qw(nonce gas_price gas chain_id)) {
        die "Web3::Tiny::Wallet: '$req' is required\n" unless defined $tx{$req};
    }

    my $to_bytes = defined($tx{to}) && length($tx{to}) ? _hex_to_bytes($tx{to}) : '';
    die "Web3::Tiny::Wallet: 'to' must be 20 bytes\n"
        if length($to_bytes) && length($to_bytes) != 20;

    my $data = defined($tx{data}) ? _hex_to_bytes($tx{data}) : '';
    my $value = $tx{value} // 0;

    my @unsigned = (
        int_to_bytes($tx{nonce}),
        int_to_bytes($tx{gas_price}),
        int_to_bytes($tx{gas}),
        $to_bytes,
        int_to_bytes($value),
        $data,
        int_to_bytes($tx{chain_id}),
        '',
        '',
    );

    my $hash = keccak256(rlp_encode(\@unsigned));
    my ($r, $s, $recid) = sign_hash($hash, $self->{priv});
    my $v = $tx{chain_id} * 2 + 35 + $recid;

    my @signed = (
        int_to_bytes($tx{nonce}),
        int_to_bytes($tx{gas_price}),
        int_to_bytes($tx{gas}),
        $to_bytes,
        int_to_bytes($value),
        $data,
        int_to_bytes($v),
        _strip_leading_zeros($r),
        _strip_leading_zeros($s),
    );

    return '0x' . unpack('H*', rlp_encode(\@signed));
}

1;

__END__

=head1 NAME

Web3::Tiny::Wallet - Private key handling and transaction signing

=head1 SYNOPSIS

    use Web3::Tiny::Wallet;

    my $wallet = Web3::Tiny::Wallet->new(private_key => '0x' . ('11' x 32));
    print $wallet->address, "\n";    # EIP-55 checksummed

    my $raw_tx = $wallet->sign_transaction(
        nonce     => 0,
        gas_price => '20000000000',
        gas       => 21000,
        to        => '0x5B38Da6a701c568545dCfcB03FcB875f56beddC4',
        value     => '1000000000000000000',
        chain_id  => 1,
    );
    # broadcast $raw_tx via eth_sendRawTransaction

=head1 DESCRIPTION

Signs legacy (pre-EIP-1559) Ethereum transactions with EIP-155 replay
protection. EIP-1559 (type-2) transactions are not supported in this
release -- legacy transactions are accepted on every EVM chain, so this
covers the common case.

=cut



( run in 0.555 second using v1.01-cache-2.11-cpan-6aa56a78535 )