add dnssec resolver for bip353 names and associated psbt output field for dnssec proof

This commit is contained in:
Craig Raw 2025-07-24 14:30:04 +02:00
parent 2a456dd602
commit 58cc096f8e
15 changed files with 651 additions and 7 deletions

View file

@ -25,7 +25,9 @@ dependencies {
implementation ('de.mkammerer:argon2-jvm:2.11') {
exclude group: 'net.java.dev.jna', module: 'jna'
}
implementation ('net.java.dev.jna:jna:5.13.0')
implementation('dnsjava:dnsjava:3.6.3')
implementation('com.github.ben-manes.caffeine:caffeine:3.0.1')
implementation ('net.java.dev.jna:jna:5.16.0')
implementation ('ch.qos.logback:logback-classic:1.5.18') {
exclude group: 'org.slf4j'
}

View file

@ -0,0 +1,28 @@
package com.sparrowwallet.drongo.dns;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import org.xbill.DNS.*;
import org.xbill.DNS.Record;
import static com.sparrowwallet.drongo.dns.RecordUtils.fromWire;
public record DnsPayment(String hrn, BitcoinURI bitcoinURI, byte[] proofChain) {
public String toString() {
return "" + hrn;
}
public long getTTL() {
long ttl = DnsPaymentCache.MAX_TTL_SECONDS;
DNSInput in = new DNSInput(proofChain);
while(in.remaining() > 0) {
try {
Record record = fromWire(in, Section.ANSWER, false);
ttl = Math.min(ttl, record.getTTL());
} catch(WireParseException e) {
//ignore
}
}
return ttl;
}
}

View file

@ -0,0 +1,42 @@
package com.sparrowwallet.drongo.dns;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.Expiry;
import com.sparrowwallet.drongo.address.Address;
import org.checkerframework.checker.index.qual.NonNegative;
import org.checkerframework.checker.nullness.qual.NonNull;
import java.util.concurrent.TimeUnit;
public class DnsPaymentCache {
public static final long MAX_TTL_SECONDS = 604800L;
public static final long MIN_TTL_SECONDS = 1800L;
private static final Cache<@NonNull Address, @NonNull DnsPayment> dnsPayments = Caffeine.newBuilder().expireAfter(new Expiry<@NonNull Address, @NonNull DnsPayment>() {
@Override
public long expireAfterCreate(@NonNull Address address, @NonNull DnsPayment dnsPayment, long currentTime) {
return TimeUnit.SECONDS.toNanos(Math.max(dnsPayment.getTTL(), MIN_TTL_SECONDS));
}
@Override
public long expireAfterUpdate(@NonNull Address address, @NonNull DnsPayment dnsPayment, long currentTime, @NonNegative long currentDuration) {
return expireAfterCreate(address, dnsPayment, currentTime);
}
@Override
public long expireAfterRead(@NonNull Address address, @NonNull DnsPayment dnsPayment, long currentTime, @NonNegative long currentDuration) {
return currentDuration;
}
}).build();
private DnsPaymentCache() {}
public static DnsPayment getDnsPayment(Address address) {
return dnsPayments.getIfPresent(address);
}
public static void putDnsPayment(Address address, DnsPayment dnsPayment) {
dnsPayments.put(address, dnsPayment);
}
}

View file

@ -0,0 +1,180 @@
package com.sparrowwallet.drongo.dns;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.uri.BitcoinURIParseException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xbill.DNS.*;
import org.xbill.DNS.Record;
import org.xbill.DNS.dnssec.ValidatingResolver;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.*;
import static com.sparrowwallet.drongo.uri.BitcoinURI.BITCOIN_SCHEME;
public class DnsPaymentResolver {
private static final Logger log = LoggerFactory.getLogger(DnsPaymentResolver.class);
private static final String BITCOIN_URI_PREFIX = BITCOIN_SCHEME + ":";
private static final String DEFAULT_RESOLVER_IP_ADDRESS = "8.8.8.8";
static String ROOT = ". IN DS 20326 8 2 E06D44B80B8F1D39A95C0B0D7C65D08458E880409BBC683457104237C7F8EC8D\n" +
". IN DS 38696 8 2 683D2D0ACB8C9B712A1948B27F741219298D0A450D612C483AF444A4C0FB2B16";
private final String hrn;
private final String domain;
public DnsPaymentResolver(String hrn) {
if(!StandardCharsets.US_ASCII.newEncoder().canEncode(hrn)) {
throw new IllegalArgumentException("Invalid HRN containing non-ASCII characters: " + hrn);
}
this.hrn = hrn;
String[] parts = hrn.split("@");
if(parts.length != 2) {
throw new IllegalArgumentException("Invalid HRN: " + hrn);
}
this.domain = parts[0] + ".user._bitcoin-payment." + parts[1];
}
public Optional<DnsPayment> resolve() throws IOException, DnsPaymentValidationException, BitcoinURIParseException {
return resolve(DEFAULT_RESOLVER_IP_ADDRESS);
}
/**
* Performs online resolution of the BIP 353 HRN via the configured resolver
*
* @param resolverIpAddress the IP address of the resolver to use for the DNS lookup
* @return The DNS payment instruction, if present
* @throws IOException Thrown for a general I/O error
* @throws DnsPaymentValidationException Thrown for a DNSSEC or BIP 353 validation failure
* @throws BitcoinURIParseException Thrown for an invalid BIP 21 URI
*/
public Optional<DnsPayment> resolve(String resolverIpAddress) throws IOException, DnsPaymentValidationException, BitcoinURIParseException {
log.debug("Resolving payment record for: " + domain);
PersistingResolver persistingResolver = new PersistingResolver(resolverIpAddress);
ValidatingResolver resolver = new ValidatingResolver(persistingResolver);
resolver.loadTrustAnchors(new ByteArrayInputStream(ROOT.getBytes(StandardCharsets.US_ASCII)));
resolver.setEDNS(0, 0, ExtendedFlags.DO);
Lookup lookup = new Lookup(domain, Type.TXT);
lookup.setResolver(resolver);
Message query = getQuery();
Message response = resolver.send(query);
if(response.getSection(Section.ANSWER).isEmpty()) {
return Optional.empty();
}
checkResponse(response, new ArrayList<>(persistingResolver.getChain()));
String strBitcoinUri = getBitcoinURI(response.getSection(Section.ANSWER));
if(strBitcoinUri.isEmpty()) {
return Optional.empty();
}
BitcoinURI bitcoinURI = new BitcoinURI(strBitcoinUri);
validateResponse(response, new ArrayList<>(persistingResolver.getChain()));
return Optional.of(new DnsPayment(hrn, bitcoinURI, persistingResolver.chainToWire()));
}
/**
* Performs offline resolution of the BIP 353 HRN via the provided authentication chain
*
* @param proofChain authentication chain of unsorted DNS records in wire format
* @return The DNS payment instruction, if present
* @throws IOException Thrown for a general I/O error
* @throws DnsPaymentValidationException Thrown for a DNSSEC or BIP 353 validation failure
* @throws BitcoinURIParseException Thrown for an invalid BIP 21 URI
*/
public Optional<DnsPayment> resolve(byte[] proofChain) throws IOException, DnsPaymentValidationException, BitcoinURIParseException {
OfflineResolver offlineResolver = new OfflineResolver(proofChain);
ValidatingResolver offlineValidatingResolver = new ValidatingResolver(offlineResolver);
offlineValidatingResolver.loadTrustAnchors(new ByteArrayInputStream(ROOT.getBytes(StandardCharsets.US_ASCII)));
Instant now = Instant.now();
Instant oneHourAgo = now.minusSeconds(3600);
for(Record record : offlineResolver.getCachedSigs()) {
if(record instanceof RRSIGRecord rrsig) {
if(rrsig.getTimeSigned().isAfter(now)) {
throw new DnsPaymentValidationException("Invalid RRSIG record signed in the future");
} else if(rrsig.getExpire().isBefore(oneHourAgo)) {
throw new DnsPaymentValidationException("Invalid RRSIG record expired earlier than 1 hour ago");
}
}
}
Message query = getQuery();
Message offlineResponse = offlineValidatingResolver.send(query);
if(offlineResponse.getSection(Section.ANSWER).isEmpty()) {
return Optional.empty();
}
checkResponse(offlineResponse, offlineResolver.getRecords());
String strBitcoinUri = getBitcoinURI(offlineResponse.getSection(Section.ANSWER));
if(strBitcoinUri.isEmpty()) {
throw new BitcoinURIParseException("The DNS record for " + hrn + " did not contain a Bitcoin URI");
}
BitcoinURI bitcoinURI = new BitcoinURI(strBitcoinUri);
validateResponse(offlineResponse, offlineResolver.getRecords());
return Optional.of(new DnsPayment(hrn, bitcoinURI, proofChain));
}
private Message getQuery() throws TextParseException {
Name queryName = Name.fromString(domain + ".");
Record question = Record.newRecord(queryName, Type.TXT, DClass.IN);
return Message.newQuery(question);
}
private void checkResponse(Message response, List<Record> records) throws DnsPaymentValidationException {
if(response.getRcode() != Rcode.NOERROR) {
StringBuilder reason = new StringBuilder();
for(RRset set : response.getSectionRRsets(Section.ADDITIONAL)) {
if(set.getName().equals(Name.root) && set.getType() == Type.TXT && set.getDClass() == ValidatingResolver.VALIDATION_REASON_QCLASS) {
reason.append(((TXTRecord) set.first()).getStrings().getFirst());
}
}
throw new DnsPaymentValidationException("DNS query for " + domain + " failed, " + (reason.isEmpty() ? "rcode was " + response.getRcode() : reason.toString()));
}
}
private void validateResponse(Message response, List<Record> records) throws DnsPaymentValidationException {
boolean isValidated = response.getHeader().getFlag(Flags.AD);
if(!isValidated) {
throw new DnsPaymentValidationException("DNSSEC validation failed, could not authenticate the payment instruction");
}
Map<Record, String> securityWarnings = RecordUtils.checkSecurityConstraints(records);
if(!securityWarnings.isEmpty()) {
Optional<String> optWarning = securityWarnings.entrySet().stream().map(e -> e.getKey().getName() + ": " + e.getValue()).reduce((a, b) -> a + "\n" + b);
throw new DnsPaymentValidationException("DNSSEC validation failed with the following errors:\n" + optWarning.get());
}
}
private String getBitcoinURI(List<Record> answers) throws DnsPaymentValidationException {
StringBuilder uriBuilder = new StringBuilder();
for(Record record : answers) {
if(record.getType() == Type.TXT) {
TXTRecord txt = (TXTRecord)record;
List<String> strings = txt.getStrings();
log.debug("Found TXT records for " + domain + ": " + strings);
if(strings.isEmpty() || !strings.getFirst().startsWith(BITCOIN_URI_PREFIX)) {
continue;
}
if(strings.getFirst().startsWith(BITCOIN_URI_PREFIX) && !uriBuilder.isEmpty()) {
throw new DnsPaymentValidationException("Multiple TXT records found starting with " + BITCOIN_URI_PREFIX);
}
for(String s : strings) {
uriBuilder.append(s);
}
}
}
return uriBuilder.toString();
}
}

View file

@ -0,0 +1,7 @@
package com.sparrowwallet.drongo.dns;
public class DnsPaymentValidationException extends Exception {
public DnsPaymentValidationException(String message) {
super(message);
}
}

View file

@ -0,0 +1,137 @@
package com.sparrowwallet.drongo.dns;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executor;
import org.xbill.DNS.*;
import org.xbill.DNS.Record;
import static com.sparrowwallet.drongo.dns.RecordUtils.fromWire;
public class OfflineResolver implements Resolver {
private final List<Record> cachedRrs = new ArrayList<>();
private final List<RRSIGRecord> cachedSigs = new ArrayList<>();
public OfflineResolver(byte[] chain) throws WireParseException {
DNSInput in = new DNSInput(chain);
while(in.remaining() > 0) {
Record record = fromWire(in, Section.ANSWER, false);
if(record instanceof RRSIGRecord rrsig) {
cachedSigs.add(rrsig);
} else {
cachedRrs.add(record);
}
}
}
@Override
public void setPort(int port) {
throw new UnsupportedOperationException("Unsupported");
}
@Override
public void setTCP(boolean flag) {
throw new UnsupportedOperationException("Unsupported");
}
// No-op
@Override
public void setIgnoreTruncation(boolean flag) {}
// No-op
@Override
public void setEDNS(int level, int payloadSize, int flags, List<EDNSOption> options) {}
@Override
public void setTSIGKey(TSIG key) {
throw new UnsupportedOperationException("Unsupported");
}
@Override
public void setTimeout(Duration timeout) {
throw new UnsupportedOperationException("Unsupported");
}
@Override
public CompletionStage<Message> sendAsync(Message query, Executor executor) {
Record question = query.getQuestion();
List<Record> records = new ArrayList<>();
for(Record it : cachedRrs) {
if(it.getName().equals(question.getName()) && it.getType() == question.getType() && it.getDClass() == question.getDClass()) {
records.add(it);
}
}
for(RRSIGRecord it : cachedSigs) {
if(it.getName().equals(question.getName()) && it.getTypeCovered() == question.getType() && it.getDClass() == question.getDClass()) {
records.add(it);
}
}
Message response;
if(records.isEmpty()) {
response = makeEmptyResponse(query);
} else {
response = makeResponseForRecords(records, query);
}
return CompletableFuture.completedFuture(response);
}
private Message makeEmptyResponse(Message query) {
Header messageHeader = new Header();
messageHeader.setID(query.getHeader().getID());
messageHeader.setRcode(Rcode.NXDOMAIN);
messageHeader.setFlag(Flags.QR);
messageHeader.setFlag(Flags.CD);
messageHeader.setFlag(Flags.RD);
messageHeader.setFlag(Flags.RA);
Message answerMessage = new Message();
answerMessage.setHeader(messageHeader);
return answerMessage;
}
private Message makeResponseForRecords(List<Record> records, Message query) {
Message answerMessage = new Message();
Header messageHeader = new Header();
messageHeader.setID(query.getHeader().getID());
messageHeader.setRcode(Rcode.NOERROR);
messageHeader.setFlag(Flags.QR);
messageHeader.setFlag(Flags.CD);
messageHeader.setFlag(Flags.RD);
messageHeader.setFlag(Flags.RA);
answerMessage.setHeader(messageHeader);
for(Record record : query.getSection(Section.QUESTION)) {
answerMessage.addRecord(record, Section.QUESTION);
}
for(Record record : records) {
answerMessage.addRecord(record, Section.ANSWER);
}
return answerMessage;
}
public List<Record> getCachedRrs() {
return cachedRrs;
}
public List<RRSIGRecord> getCachedSigs() {
return cachedSigs;
}
public List<Record> getRecords() {
List<Record> records = new ArrayList<>(cachedRrs);
records.addAll(cachedSigs);
return records;
}
}

View file

@ -0,0 +1,60 @@
package com.sparrowwallet.drongo.dns;
import org.xbill.DNS.*;
import org.xbill.DNS.Record;
import java.io.ByteArrayOutputStream;
import java.net.UnknownHostException;
import java.util.*;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executor;
public class PersistingResolver extends SimpleResolver {
private final Set<Record> chain = new LinkedHashSet<>();
public PersistingResolver(String hostname) throws UnknownHostException {
super(hostname);
}
@Override
public CompletionStage<Message> sendAsync(Message query, Executor executor) {
CompletionStage<Message> result = super.sendAsync(query, executor);
return result.thenApply(response -> {
addAnswerSectionToChain(response.getSection(Section.ANSWER));
addAuthoritySectionToChain(response.getSection(Section.AUTHORITY));
return response;
});
}
private void addAnswerSectionToChain(List<org.xbill.DNS.Record> section) {
if(section != null) {
chain.addAll(section);
}
}
private void addAuthoritySectionToChain(List<Record> section) {
if(section != null) {
for(Record r : section) {
if((r.getType() == Type.RRSIG && r.getRRsetType() == Type.NSEC && r.getRRsetType() == Type.NSEC3)|| r.getType() == Type.NSEC || r.getType() == Type.NSEC3) {
chain.add(r);
}
}
}
}
public Set<Record> getChain() {
return chain;
}
public byte[] chainToWire() {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
List<Record> sorted = new ArrayList<>(chain);
Collections.sort(sorted);
for(Record record : sorted) {
baos.writeBytes(record.toWireCanonical());
}
return baos.toByteArray();
}
}

View file

@ -0,0 +1,66 @@
package com.sparrowwallet.drongo.dns;
import org.xbill.DNS.*;
import org.xbill.DNS.Record;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class RecordUtils {
public static org.xbill.DNS.Record fromWire(DNSInput in, int section, boolean isUpdate) throws WireParseException {
int type;
int dclass;
long ttl;
int length;
Name name;
name = new Name(in);
type = in.readU16();
dclass = in.readU16();
if(section == Section.QUESTION) {
return org.xbill.DNS.Record.newRecord(name, type, dclass);
}
ttl = in.readU32();
length = in.readU16();
if(length == 0 && isUpdate && (section == Section.PREREQ || section == Section.UPDATE)) {
return org.xbill.DNS.Record.newRecord(name, type, dclass, ttl);
}
return Record.newRecord(name, type, dclass, ttl, length, in.readByteArray(length));
}
public static Map<Record, String> checkSecurityConstraints(List<Record> section) {
Map<Record, String> warnings = new HashMap<>();
if(section != null) {
for(Record record : section) {
if(record.getType() == Type.RRSIG) {
RRSIGRecord rrsig = (RRSIGRecord)record;
if(rrsig.getAlgorithm() == DNSSEC.Algorithm.RSASHA1 || rrsig.getAlgorithm() == DNSSEC.Algorithm.RSA_NSEC3_SHA1) {
warnings.put(record, "Record contains weak SHA-1 based signature");
}
} else if(record.getType() == Type.DNSKEY) {
DNSKEYRecord dnskey = (DNSKEYRecord)record;
if(dnskey.getAlgorithm() == DNSSEC.Algorithm.RSASHA1 || dnskey.getAlgorithm() == DNSSEC.Algorithm.RSA_NSEC3_SHA1 ||
dnskey.getAlgorithm() == DNSSEC.Algorithm.RSASHA256 || dnskey.getAlgorithm() == DNSSEC.Algorithm.RSASHA512) {
try {
java.security.PublicKey publicKey = dnskey.getPublicKey();
if(publicKey instanceof java.security.interfaces.RSAPublicKey rsaKey) {
int keyLength = rsaKey.getModulus().bitLength();
if(keyLength < 1024) {
warnings.put(record, "Record contains weak RSA public key with key length of " + keyLength + " bits");
}
}
} catch(DNSSEC.DNSSECException e) {
warnings.put(record, "Record contains invalid public key");
}
}
}
}
}
return warnings;
}
}

View file

@ -144,6 +144,7 @@ public class PSBT {
}
List<WalletNode> outputNodes = new ArrayList<>();
List<Map<String, byte[]>> dnsProofs = new ArrayList<>();
for(TransactionOutput txOutput : transaction.getOutputs()) {
try {
Address address = txOutput.getScript().getToAddresses()[0];
@ -152,16 +153,18 @@ public class PSBT {
} else if(walletTransaction.getChangeMap().keySet().stream().anyMatch(changeNode -> changeNode.getAddress().equals(address))) {
outputNodes.add(walletTransaction.getChangeMap().keySet().stream().filter(changeNode -> changeNode.getAddress().equals(address)).findFirst().orElse(null));
}
dnsProofs.add(walletTransaction.getAddressDnsProofMap().get(address));
} catch(NonStandardScriptException e) {
//Ignore, likely OP_RETURN output
outputNodes.add(null);
dnsProofs.add(null);
}
}
for(int outputIndex = 0; outputIndex < outputNodes.size(); outputIndex++) {
WalletNode outputNode = outputNodes.get(outputIndex);
if(outputNode == null) {
PSBTOutput externalRecipientOutput = new PSBTOutput(this, outputIndex, null, null, null, Collections.emptyMap(), Collections.emptyMap(), null);
PSBTOutput externalRecipientOutput = new PSBTOutput(this, outputIndex, null, null, null, Collections.emptyMap(), Collections.emptyMap(), null, dnsProofs.get(outputIndex));
psbtOutputs.add(externalRecipientOutput);
} else {
TransactionOutput txOutput = transaction.getOutputs().get(outputIndex);
@ -192,7 +195,7 @@ public class PSBT {
}
}
PSBTOutput walletOutput = new PSBTOutput(this, outputIndex, recipientWallet.getScriptType(), redeemScript, witnessScript, derivedPublicKeys, Collections.emptyMap(), tapInternalKey);
PSBTOutput walletOutput = new PSBTOutput(this, outputIndex, recipientWallet.getScriptType(), redeemScript, witnessScript, derivedPublicKeys, Collections.emptyMap(), tapInternalKey, null);
psbtOutputs.add(walletOutput);
}
}

View file

@ -9,6 +9,7 @@ import com.sparrowwallet.drongo.protocol.VarInt;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@ -144,6 +145,39 @@ public class PSBTEntry {
return baos.toByteArray();
}
public static Map<String, byte[]> parseDnssecProof(byte[] data) throws PSBTParseException {
if(data.length == 0) {
throw new PSBTParseException("No data provided for DNSSEC proof");
}
ByteBuffer bb = ByteBuffer.wrap(data);
int strLen = bb.get();
if(data.length < strLen + 1) {
throw new PSBTParseException("Invalid string length of " + strLen + " provided for DNSSEC proof");
}
byte[] strBytes = new byte[strLen];
bb.get(strBytes);
String hrn = new String(strBytes, StandardCharsets.US_ASCII);
byte[] proof = new byte[bb.remaining()];
bb.get(proof);
return Map.of(hrn, proof);
}
public static byte[] serializeDnssecProof(Map<String, byte[]> dnssecProof) {
if(dnssecProof.isEmpty()) {
throw new IllegalArgumentException("No DNSSEC proof provided");
}
String hrn = dnssecProof.keySet().iterator().next();
byte[] proof = dnssecProof.get(hrn);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
baos.write(hrn.length());
baos.writeBytes(hrn.getBytes(StandardCharsets.US_ASCII));
baos.writeBytes(proof);
return baos.toByteArray();
}
static PSBTEntry populateEntry(int type, byte[] keyData, byte[] data) {
ByteArrayOutputStream baos = new ByteArrayOutputStream(1 + (keyData == null ? 0 : keyData.length));
baos.writeBytes(writeCompactInt(type));

View file

@ -3,10 +3,15 @@ package com.sparrowwallet.drongo.psbt;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.dns.DnsPayment;
import com.sparrowwallet.drongo.dns.DnsPaymentResolver;
import com.sparrowwallet.drongo.dns.DnsPaymentValidationException;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.uri.BitcoinURIParseException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.*;
import static com.sparrowwallet.drongo.protocol.ScriptType.*;
@ -20,6 +25,7 @@ public class PSBTOutput {
public static final byte PSBT_OUT_SCRIPT = 0x04;
public static final byte PSBT_OUT_TAP_INTERNAL_KEY = 0x05;
public static final byte PSBT_OUT_TAP_BIP32_DERIVATION = 0x07;
public static final byte PSBT_OUT_DNSSEC_PROOF = 0x35;
public static final byte PSBT_OUT_PROPRIETARY = (byte)0xfc;
private Script redeemScript;
@ -28,6 +34,7 @@ public class PSBTOutput {
private final Map<String, String> proprietary = new LinkedHashMap<>();
private Map<ECKey, Map<KeyDerivation, List<Sha256Hash>>> tapDerivedPublicKeys = new LinkedHashMap<>();
private ECKey tapInternalKey;
private Map<String, byte[]> dnssecProof;
//PSBTv2 fields
private Long amount;
@ -43,7 +50,8 @@ public class PSBTOutput {
this.index = index;
}
PSBTOutput(PSBT psbt, int index, ScriptType scriptType, Script redeemScript, Script witnessScript, Map<ECKey, KeyDerivation> derivedPublicKeys, Map<String, String> proprietary, ECKey tapInternalKey) {
PSBTOutput(PSBT psbt, int index, ScriptType scriptType, Script redeemScript, Script witnessScript, Map<ECKey, KeyDerivation> derivedPublicKeys,
Map<String, String> proprietary, ECKey tapInternalKey, Map<String, byte[]> dnssecProof) {
this(psbt, index);
this.redeemScript = redeemScript;
@ -61,6 +69,8 @@ public class PSBTOutput {
KeyDerivation tapKeyDerivation = derivedPublicKeys.values().iterator().next();
tapDerivedPublicKeys.put(this.tapInternalKey, Map.of(tapKeyDerivation, Collections.emptyList()));
}
this.dnssecProof = dnssecProof;
}
PSBTOutput(PSBT psbt, List<PSBTEntry> outputEntries, int index) throws PSBTParseException {
@ -119,6 +129,10 @@ public class PSBTOutput {
}
}
break;
case PSBT_OUT_DNSSEC_PROOF:
entry.checkOneByteKey();
this.dnssecProof = parseDnssecProof(entry.getData());
break;
default:
log.warn("PSBT output not recognized key type: " + entry.getKeyType());
}
@ -165,6 +179,10 @@ public class PSBTOutput {
entries.add(populateEntry(PSBT_OUT_TAP_INTERNAL_KEY, null, tapInternalKey.getPubKeyXCoord()));
}
if(dnssecProof != null) {
entries.add(populateEntry(PSBT_OUT_DNSSEC_PROOF, null, serializeDnssecProof(dnssecProof)));
}
return entries;
}
@ -272,6 +290,24 @@ public class PSBTOutput {
this.tapInternalKey = tapInternalKey;
}
public Map<String, byte[]> getDnssecProof() {
return dnssecProof;
}
public Optional<DnsPayment> getDnsPayment() throws DnsPaymentValidationException, IOException, BitcoinURIParseException {
if(dnssecProof == null || dnssecProof.isEmpty()) {
return Optional.empty();
}
String hrn = dnssecProof.keySet().iterator().next();
DnsPaymentResolver resolver = new DnsPaymentResolver(hrn);
return resolver.resolve(dnssecProof.get(hrn));
}
public void setDnssecProof(Map<String, byte[]> dnssecProof) {
this.dnssecProof = dnssecProof;
}
public TransactionOutput getOutput() {
return psbt.getTransaction().getOutputs().get(index);
}

View file

@ -75,6 +75,8 @@ public class BitcoinURI {
public static final DecimalFormat BTC_FORMAT = new DecimalFormat("0", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
public static final int SMALLEST_UNIT_EXPONENT = 8;
private final String uriString;
/**
* Contains all the parameters in the order in which they were processed
*/
@ -135,9 +137,7 @@ public class BitcoinURI {
}
}
if(addressToken.isEmpty() && getPaymentRequestUrl() == null) {
throw new BitcoinURIParseException("No address and no r= parameter found");
}
this.uriString = input;
}
/**
@ -315,6 +315,10 @@ public class BitcoinURI {
return builder.toString();
}
public String toURIString() {
return uriString;
}
public Payment toPayment() {
long amount = getAmount() == null ? -1 : getAmount();
return new Payment(getAddress(), getLabel(), amount, false);

View file

@ -1,6 +1,8 @@
package com.sparrowwallet.drongo.wallet;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.dns.DnsPayment;
import com.sparrowwallet.drongo.dns.DnsPaymentCache;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.PSBT;
@ -22,6 +24,7 @@ public class WalletTransaction {
private final List<Output> outputs;
private Map<Wallet, Map<Address, WalletNode>> addressNodeMap = new HashMap<>();
private Map<Address, Map<String, byte[]>> addressDnsProofMap = new HashMap<>();
public WalletTransaction(Wallet wallet, Transaction transaction, List<UtxoSelector> utxoSelectors, List<Map<BlockTransactionHashIndex, WalletNode>> selectedUtxoSets, List<Payment> payments, long fee) {
this(wallet, transaction, utxoSelectors, selectedUtxoSets, payments, Collections.emptyMap(), fee);
@ -234,6 +237,21 @@ public class WalletTransaction {
return walletAddressNodeMap;
}
public Map<Address, Map<String, byte[]>> getAddressDnsProofMap() {
for(Payment payment : payments) {
if(addressDnsProofMap.containsKey(payment.getAddress())) {
continue;
}
DnsPayment dnsPayment = DnsPaymentCache.getDnsPayment(payment.getAddress());
if(dnsPayment != null && dnsPayment.bitcoinURI().getAddress() != null) {
addressDnsProofMap.put(dnsPayment.bitcoinURI().getAddress(), Map.of(dnsPayment.hrn(), dnsPayment.proofChain()));
}
}
return addressDnsProofMap;
}
private List<Output> calculateOutputs() {
List<Output> outputs = new ArrayList<>();

View file

@ -6,6 +6,9 @@ open module com.sparrowwallet.drongo {
requires org.slf4j;
requires ch.qos.logback.core;
requires ch.qos.logback.classic;
requires org.dnsjava;
requires com.github.benmanes.caffeine;
requires org.checkerframework.checker.qual;
exports com.sparrowwallet.drongo;
exports com.sparrowwallet.drongo.psbt;
exports com.sparrowwallet.drongo.protocol;
@ -16,6 +19,7 @@ open module com.sparrowwallet.drongo {
exports com.sparrowwallet.drongo.policy;
exports com.sparrowwallet.drongo.uri;
exports com.sparrowwallet.drongo.bip47;
exports com.sparrowwallet.drongo.dns;
exports com.sparrowwallet.drongo.wallet.slip39;
exports org.bitcoin;
}

File diff suppressed because one or more lines are too long