verifySignedPsbt function

VerifyResult verifySignedPsbt(
  1. VerifySignedPsbtArgs args
)

The crypto-psbt reply carries NO request id — this comparison IS the anti-replay binding for Bitcoin. It is not optional.

The unsigned transaction is compared byte for byte, which pins the input set and order, the outputs, their amounts, the version and the locktime in one shot — and therefore the txid. The device only ADDS per-input signature fields, so a legitimate reply always matches.

Implementation

VerifyResult verifySignedPsbt(VerifySignedPsbtArgs args) {
  ParsedPsbt sent;
  ParsedPsbt signed;
  try {
    sent = parsePsbt(args.sentPsbt);
  } catch (e) {
    return failed('the PSBT we sent is not readable: ${_message(e)}');
  }
  try {
    signed = parsePsbt(args.signedPsbt);
  } catch (e) {
    return failed(
        'the PSBT the device returned is not readable: ${_message(e)}');
  }

  if (!equalBytes(sent.unsignedTx, signed.unsignedTx)) {
    return failed(
        'the returned PSBT is a different transaction from the one approved');
  }

  // A finalized field carries the COMPLETE scriptSig/witness that will be
  // broadcast, and the unsigned-tx comparison above does not cover it (it
  // lives per input, the unsigned tx in the global map). An input that comes
  // back finalized must have been SENT that way, with byte-identical
  // values — the device echoes these fields, it never authors them.
  const finalizedTypes = [
    PsbtInputType.finalScriptSig,
    PsbtInputType.finalScriptWitness,
  ];
  for (var i = 0; i < signed.inputs.length; i++) {
    for (final type in finalizedTypes) {
      if (!inputHas(signed, i, type)) continue;
      if (!inputHas(sent, i, type)) {
        return failed(
          'input $i came back finalized (type 0x${type.toRadixString(16)}) '
          'and was not sent that way — the script it would broadcast is not ours',
        );
      }
      final a = inputEntries(sent, i, type);
      final b = inputEntries(signed, i, type);
      var same = a.length == b.length;
      if (same) {
        for (var k = 0; k < a.length; k++) {
          if (!equalBytes(a[k].value, b[k].value)) {
            same = false;
            break;
          }
        }
      }
      if (!same) {
        return failed(
          'input $i came back with a different finalized script than the one we sent',
        );
      }
    }
  }

  bool isSigned(int i) =>
      inputHas(signed, i, PsbtInputType.partialSig) ||
      inputHas(signed, i, PsbtInputType.taprootKeySpendSignature) ||
      inputHas(signed, i, PsbtInputType.taprootScriptSpendSignature);

  final indexes = List<int>.generate(signed.inputs.length, (i) => i);
  if (args.requireEveryInputSigned) {
    if (!indexes.every(isSigned)) {
      return failed('the device signed only part of the transaction');
    }
  } else if (!indexes.any(isSigned)) {
    return failed('the returned PSBT carries no signature');
  }
  return verified;
}