Skip to content

Commit fc69a78

Browse files
committed
Add get_locktime, get transaction methods to PSBT
1 parent 3455ffb commit fc69a78

3 files changed

Lines changed: 303 additions & 3 deletions

File tree

Changes

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ Revision history for Perl extension Bitcoin::Crypto.
55
- Bitcoin::Crypto::Transaction::Input:
66
- added serialized_witness method
77
- added set_serialized_witness method
8+
- Bitcoin::Crypto::PSBT:
9+
- added get_locktime method
10+
- added get_transaction method
811

912
4.005 Thu Apr 23, 2026
1013
[Public interface changes]

lib/Bitcoin/Crypto/PSBT.pm

Lines changed: 137 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ use Types::Common -sigs;
88
use List::Util qw(any);
99
use Scalar::Util qw(blessed);
1010

11+
use Bitcoin::Crypto qw(btc_transaction);
1112
use Bitcoin::Crypto::PSBT::Map;
1213
use Bitcoin::Crypto::PSBT::Field;
1314
use Bitcoin::Crypto::PSBT::FieldType;
1415
use Bitcoin::Crypto::Types -types;
1516
use Bitcoin::Crypto::Constants qw(:psbt);
17+
use Bitcoin::Crypto::Util qw(to_format);
1618
use Bitcoin::Crypto::Exception;
1719

1820
has field 'maps' => (
@@ -87,9 +89,17 @@ sub get_field
8789
my ($self, $type, $index, $key) = @_;
8890

8991
my @values = $self->get_all_fields($type, $index, $key);
90-
Bitcoin::Crypto::Exception::PSBT->raise(
91-
'Could not get value for field ' . $type->name . ': found ' . @values . ' values in PSBT'
92-
) unless @values == 1;
92+
if (@values != 1) {
93+
my $id = $type->name;
94+
$id .= ", index $index" if defined $index;
95+
$id .= ', key ' . to_format [hex => $key] if defined $key;
96+
97+
my $count = @values;
98+
99+
Bitcoin::Crypto::Exception::PSBT->raise(
100+
"Could not get value for field $id: found $count values in PSBT"
101+
);
102+
}
93103

94104
return $values[0];
95105
}
@@ -291,6 +301,112 @@ sub check
291301
return $self;
292302
}
293303

304+
sub get_locktime
305+
{
306+
my ($self) = @_;
307+
my $version = $self->version;
308+
309+
if ($version == 0) {
310+
return $self->get_transaction->locktime;
311+
}
312+
elsif ($version == 2) {
313+
314+
# NOTE: this logic is based on:
315+
# https://github.com/bitcoin/bips/blob/master/bip-0370.mediawiki#determining-lock-time
316+
317+
my $time_locktimes = 0;
318+
my $height_locktimes = 0;
319+
my $max_time_locktime = 0;
320+
my $max_height_locktime = 0;
321+
322+
foreach my $input_index (0 .. $self->input_count - 1) {
323+
my $time = $self->get_all_fields('PSBT_IN_REQUIRED_TIME_LOCKTIME', $input_index);
324+
my $height = $self->get_all_fields('PSBT_IN_REQUIRED_HEIGHT_LOCKTIME', $input_index);
325+
326+
$time_locktimes += defined $time;
327+
$height_locktimes += defined $height;
328+
329+
$max_time_locktime = $time->value
330+
if $time && $time->value > $max_time_locktime;
331+
$max_height_locktime = $height->value
332+
if $height && $height->value > $max_height_locktime;
333+
}
334+
335+
if ($time_locktimes > 0 && $height_locktimes > 0) {
336+
if ($time_locktimes == $self->input_count && $time_locktimes > $height_locktimes) {
337+
return $max_time_locktime;
338+
}
339+
else {
340+
return $max_height_locktime;
341+
}
342+
}
343+
elsif ($time_locktimes > $height_locktimes) {
344+
return $max_time_locktime;
345+
}
346+
elsif ($height_locktimes > $time_locktimes) {
347+
return $max_height_locktime;
348+
}
349+
else {
350+
my $fallback = $self->get_all_fields('PSBT_GLOBAL_FALLBACK_LOCKTIME');
351+
352+
return $fallback ? $fallback->value : 0;
353+
}
354+
}
355+
}
356+
357+
sub get_transaction
358+
{
359+
my ($self) = @_;
360+
361+
$self->check;
362+
my $version = $self->version;
363+
364+
my $tx;
365+
if ($version == 0) {
366+
$tx = $self->get_field('PSBT_GLOBAL_UNSIGNED_TX')->value;
367+
}
368+
elsif ($version == 2) {
369+
$tx = btc_transaction->new(
370+
version => $self->get_field('PSBT_GLOBAL_TX_VERSION')->value,
371+
locktime => $self->get_locktime,
372+
);
373+
374+
foreach my $input_index (0 .. $self->input_count - 1) {
375+
my $utxo_txid = $self->get_field('PSBT_IN_PREVIOUS_TXID', $input_index)->value;
376+
my $utxo_output = $self->get_field('PSBT_IN_OUTPUT_INDEX', $input_index)->value;
377+
my $sequence_field = $self->get_all_fields('PSBT_IN_SEQUENCE', $input_index);
378+
379+
$tx->add_input(
380+
utxo => [$utxo_txid, $utxo_output],
381+
(defined $sequence_field ? (sequence_no => $sequence_field->value) : ()),
382+
);
383+
}
384+
385+
foreach my $output_index (0 .. $self->output_count - 1) {
386+
my $value = $self->get_field('PSBT_OUT_AMOUNT', $output_index)->value;
387+
my $script = $self->get_field('PSBT_OUT_SCRIPT', $output_index)->value;
388+
389+
$tx->add_output(
390+
locking_script => $script,
391+
value => $value,
392+
);
393+
}
394+
}
395+
396+
foreach my $input_index (0 .. $self->input_count - 1) {
397+
my $signature_field = $self->get_all_fields('PSBT_IN_FINAL_SCRIPTSIG', $input_index);
398+
my $witness_field = $self->get_all_fields('PSBT_IN_FINAL_SCRIPTWITNESS', $input_index);
399+
400+
$tx->inputs->[$input_index]->set_signature_script($signature_field->value)
401+
if $signature_field;
402+
403+
$tx->inputs->[$input_index]->set_serialized_witness($witness_field->value)
404+
if $witness_field;
405+
}
406+
407+
return $tx;
408+
}
409+
294410
sub dump
295411
{
296412
my ($self) = @_;
@@ -461,6 +577,24 @@ may use this data to loop through PSBT fields.
461577
Checks the internal state of PSBT fields and throws an exception if it is
462578
invalid. Returns the object itself.
463579
580+
=head3 get_locktime
581+
582+
$int = $object->get_locktime()
583+
584+
Returns the locktime encoded in the PSBT as an integer. For version 0 PSBTs,
585+
this simply returns the locktime in the unsigned transaction field. For version
586+
2, it follows L<procedure described in
587+
BIP370|https://github.com/bitcoin/bips/blob/master/bip-0370.mediawiki#determining-lock-time>.
588+
589+
Time or height-based threshold can be determined by comparing the result with
590+
L<Bitcoin::Crypto::Constants/LOCKTIME_HEIGHT_THRESHOLD>.
591+
592+
=head3 get_transaction
593+
594+
$transaction = $object->get_transaction()
595+
596+
Builds a new L<Bitcoin::Crypto::Transaction> object based on the contents of the PSBT.
597+
464598
=head3 to_serialized
465599
466600
$serialized = $object->to_serialized()

t/PSBT/integration.t

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
use Test2::V0;
2+
use Bitcoin::Crypto qw(btc_psbt btc_transaction);
3+
use Bitcoin::Crypto::Constants qw(:transaction);
4+
use Bitcoin::Crypto::Util qw(to_format);
5+
6+
################################################################################
7+
# This tests for integration of PSBTs with other parts of Bitcoin::Crypto
8+
################################################################################
9+
10+
# NOTE: segwit transaction, signatures do not affect hash
11+
my $expected_minimal = '866caf39ed25aaf2b8a61eba9cecffc5a258275879935454c743ad7fee73d09e';
12+
my $minimal_hex =
13+
'0200000000010151f687ad557af0ea4833bc2124e9fd0f8b54642ca308e7ad609383e42a01391d0000000000fdffffff01684c030000000000160014c6d14eae8a0e593ae7f358f29255f2a3932090300140dfb73d96c1056dddb0240225ca75658bcb99ed9101240bd7d672ad1cf512709bc1d0c2ea0e28d01342c7359e47f86071344ad70ee5871ac6d5d7625726a7909500000000';
14+
15+
my @minimal_v0 = (
16+
{
17+
type => 'PSBT_GLOBAL_UNSIGNED_TX',
18+
value => btc_transaction->new(version => 2)
19+
->add_input(
20+
utxo => [[hex => '1d39012ae4839360ade708a32c64548b0ffde92421bc3348eaf07a55ad87f651'], 0],
21+
sequence_no => RBF_SEQUENCE_NO_THRESHOLD,
22+
)
23+
->add_output(
24+
locking_script => [address => 'bc1qcmg5at52pevn4elntrefy40j5wfjpypsxvuamf'],
25+
value => 216168,
26+
),
27+
},
28+
);
29+
30+
my @minimal_v2 = (
31+
{
32+
type => 'PSBT_GLOBAL_VERSION',
33+
value => 2,
34+
},
35+
{
36+
type => 'PSBT_GLOBAL_TX_VERSION',
37+
value => 2,
38+
},
39+
{
40+
type => 'PSBT_GLOBAL_INPUT_COUNT',
41+
value => 1,
42+
},
43+
{
44+
type => 'PSBT_GLOBAL_OUTPUT_COUNT',
45+
value => 1,
46+
},
47+
{
48+
type => 'PSBT_IN_PREVIOUS_TXID',
49+
index => 0,
50+
value => [hex => '1d39012ae4839360ade708a32c64548b0ffde92421bc3348eaf07a55ad87f651'],
51+
},
52+
{
53+
type => 'PSBT_IN_OUTPUT_INDEX',
54+
index => 0,
55+
value => 0,
56+
},
57+
{
58+
type => 'PSBT_IN_SEQUENCE',
59+
index => 0,
60+
value => RBF_SEQUENCE_NO_THRESHOLD,
61+
},
62+
{
63+
type => 'PSBT_OUT_AMOUNT',
64+
index => 0,
65+
value => 216168,
66+
},
67+
{
68+
type => 'PSBT_OUT_SCRIPT',
69+
index => 0,
70+
value => [address => 'bc1qcmg5at52pevn4elntrefy40j5wfjpypsxvuamf'],
71+
},
72+
);
73+
74+
subtest 'should build valid transaction from minimal psbt' => sub {
75+
is to_format [hex => build_psbt(@minimal_v0)->get_transaction->txid], $expected_minimal, 'psbtv0 ok';
76+
is to_format [hex => build_psbt(@minimal_v2)->get_transaction->txid], $expected_minimal, 'psbtv2 ok';
77+
};
78+
79+
subtest 'should build valid transaction with final signatures' => sub {
80+
my $tx = btc_transaction->from_serialized([hex => $minimal_hex]);
81+
82+
my $psbt = build_psbt(
83+
@minimal_v0,
84+
{
85+
type => 'PSBT_IN_FINAL_SCRIPTWITNESS',
86+
index => 0,
87+
value => $tx->inputs->[0]->serialized_witness,
88+
}
89+
);
90+
91+
my $psbt_witness = $psbt->get_transaction->inputs->[0]->witness;
92+
is $psbt_witness, $tx->inputs->[0]->witness, 'witness ok';
93+
};
94+
95+
subtest 'should return valid height-based locktime from version 2 based on input' => sub {
96+
my $psbt = build_psbt(
97+
@minimal_v2,
98+
{
99+
type => 'PSBT_IN_REQUIRED_HEIGHT_LOCKTIME',
100+
index => 0,
101+
value => 555,
102+
}
103+
);
104+
105+
is $psbt->get_locktime, 555, 'locktime ok';
106+
};
107+
108+
subtest 'should return valid time-based locktime from version 2 based on input' => sub {
109+
my $psbt = build_psbt(
110+
@minimal_v2,
111+
{
112+
type => 'PSBT_IN_REQUIRED_TIME_LOCKTIME',
113+
index => 0,
114+
value => LOCKTIME_HEIGHT_THRESHOLD + 555,
115+
}
116+
);
117+
118+
is $psbt->get_locktime, LOCKTIME_HEIGHT_THRESHOLD + 555, 'locktime ok';
119+
};
120+
121+
subtest 'should return valid height-based locktime from version 2 based on input (both options)' => sub {
122+
my $psbt = build_psbt(
123+
@minimal_v2,
124+
{
125+
type => 'PSBT_IN_REQUIRED_HEIGHT_LOCKTIME',
126+
index => 0,
127+
value => 555,
128+
},
129+
{
130+
type => 'PSBT_IN_REQUIRED_TIME_LOCKTIME',
131+
index => 0,
132+
value => LOCKTIME_HEIGHT_THRESHOLD + 555,
133+
}
134+
);
135+
136+
is $psbt->get_locktime, 555, 'locktime ok';
137+
};
138+
139+
subtest 'should return valid height-based locktime from version 2 based on fallback' => sub {
140+
my $psbt = build_psbt(
141+
@minimal_v2,
142+
{
143+
type => 'PSBT_GLOBAL_FALLBACK_LOCKTIME',
144+
value => 555,
145+
},
146+
);
147+
148+
is $psbt->get_locktime, 555, 'locktime ok';
149+
};
150+
151+
done_testing;
152+
153+
sub build_psbt
154+
{
155+
my $psbt = btc_psbt->new;
156+
157+
foreach my $field (@_) {
158+
$psbt->add_field(%$field);
159+
}
160+
161+
return $psbt;
162+
}
163+

0 commit comments

Comments
 (0)