diff --git a/phoenixd-base/pom.xml b/phoenixd-base/pom.xml
index a22d1a8..6fe8df4 100644
--- a/phoenixd-base/pom.xml
+++ b/phoenixd-base/pom.xml
@@ -4,7 +4,7 @@
payment-adapter 0.17.2 and later decodes a RECEIVE quote's invoice to get the hash it + * then looks up under /payments/incoming. Without the hash every lookup failed and the mint + * answered 500 (phoenixd-java#67). The hash is, in order: the one this mock issued with the + * invoice, the one in the invoice's {@code p} field, or a SHA-256 of the invoice text, so + * the same string always decodes to the same hash. + * + *
{@code amount} and {@code description} keep their old fixed values so existing callers
+ * see no change.
+ */
private void handleDecodeInvoice(HttpExchange exchange) throws IOException {
- writeJson(exchange, "{\"amount\":1000,\"description\":\"1 Blockaccino\"}");
+ String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
+ String invoice = formParam(body, "invoice");
+ String paymentHash = decodePaymentHash(invoice);
+ writeJson(exchange, objectMapper.createObjectNode()
+ .put("chain", "mainnet")
+ .put("amount", 1000)
+ .put("paymentHash", paymentHash)
+ .put("description", "1 Blockaccino")
+ .put("minFinalCltvExpiryDelta", 18)
+ .put("timestampSeconds", System.currentTimeMillis() / 1000)
+ .toString());
+ }
+
+ /**
+ * Handles {@code GET /payments/incoming/{paymentHash}}, which payment-adapter calls with the
+ * hash from /decodeinvoice. Unknown hashes get 404, as from phoenixd.
+ */
+ private void handleGetIncomingPayment(HttpExchange exchange) throws IOException {
+ String path = exchange.getRequestURI().getPath();
+ String paymentHash = path.substring(path.lastIndexOf('/') + 1);
+ InvoiceInfo invoice = invoices.get(paymentHash);
+ if (invoice == null) {
+ writeJsonWithStatus(exchange, 404, "{\"error\":\"payment not found\"}");
+ return;
+ }
+ var json = objectMapper.createObjectNode()
+ .put("paymentHash", invoice.paymentHash)
+ .put("isPaid", invoice.settled)
+ .put("receivedSat", invoice.settled ? invoice.amountSat : 0)
+ .put("fees", 0)
+ .put("invoice", invoice.serialized)
+ .put("createdAt", System.currentTimeMillis());
+ if (invoice.externalId != null) {
+ json.put("externalId", invoice.externalId);
+ }
+ writeJson(exchange, json.toString());
+ }
+
+ /** The payment hash for a BOLT11 string: issued, then encoded, then a deterministic digest. */
+ String decodePaymentHash(String invoice) {
+ String text = invoice == null ? "" : invoice.trim();
+ for (InvoiceInfo info : invoices.values()) {
+ if (info.serialized.equalsIgnoreCase(text)) {
+ return info.paymentHash;
+ }
+ }
+ String fromInvoice = paymentHashFromBolt11(text);
+ if (fromInvoice != null) {
+ return fromInvoice;
+ }
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256")
+ .digest(text.toLowerCase().getBytes(StandardCharsets.UTF_8));
+ return HexFormat.of().formatHex(digest);
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ /**
+ * Reads the {@code p} (payment hash) tagged field out of a BOLT11 string, or null when the
+ * string is not a BOLT11 invoice or has no such field. The checksum is not verified.
+ */
+ static String paymentHashFromBolt11(String invoice) {
+ if (invoice == null) {
+ return null;
+ }
+ String text = invoice.toLowerCase();
+ int sep = text.lastIndexOf('1');
+ if (!text.startsWith("ln") || sep < 0) {
+ return null;
+ }
+ String data = text.substring(sep + 1);
+ // timestamp(7) ... tagged fields ... signature(104) checksum(6)
+ int end = data.length() - SIGNATURE_CHARS - 6;
+ int pos = 7;
+ while (pos + 3 <= end) {
+ int type = BECH32_CHARSET.indexOf(data.charAt(pos));
+ int hi = BECH32_CHARSET.indexOf(data.charAt(pos + 1));
+ int lo = BECH32_CHARSET.indexOf(data.charAt(pos + 2));
+ if (type < 0 || hi < 0 || lo < 0) {
+ return null;
+ }
+ int len = hi * 32 + lo;
+ pos += 3;
+ if (pos + len > end) {
+ return null;
+ }
+ if (data.charAt(pos - 3) == 'p' && len == 52) {
+ byte[] bytes = fromBech32Chars(data.substring(pos, pos + len));
+ return bytes == null ? null : HexFormat.of().formatHex(bytes, 0, 32);
+ }
+ pos += len;
+ }
+ return null;
+ }
+
+ private static String toBech32Chars(byte[] bytes) {
+ StringBuilder out = new StringBuilder();
+ int acc = 0;
+ int bits = 0;
+ for (byte b : bytes) {
+ acc = (acc << 8) | (b & 0xff);
+ bits += 8;
+ while (bits >= 5) {
+ bits -= 5;
+ out.append(BECH32_CHARSET.charAt((acc >> bits) & 31));
+ }
+ }
+ if (bits > 0) {
+ out.append(BECH32_CHARSET.charAt((acc << (5 - bits)) & 31));
+ }
+ return out.toString();
+ }
+
+ private static byte[] fromBech32Chars(String chars) {
+ byte[] out = new byte[chars.length() * 5 / 8];
+ int acc = 0;
+ int bits = 0;
+ int idx = 0;
+ for (int i = 0; i < chars.length(); i++) {
+ int v = BECH32_CHARSET.indexOf(chars.charAt(i));
+ if (v < 0) {
+ return null;
+ }
+ acc = (acc << 5) | v;
+ bits += 5;
+ if (bits >= 8) {
+ bits -= 8;
+ out[idx++] = (byte) ((acc >> bits) & 0xff);
+ }
+ }
+ return out;
+ }
+
+ private static String formParam(String body, String name) {
+ if (body == null) {
+ return null;
+ }
+ for (String param : body.split("&")) {
+ String[] pair = param.split("=", 2);
+ if (pair.length == 2 && name.equals(pair[0])) {
+ return java.net.URLDecoder.decode(pair[1], StandardCharsets.UTF_8);
+ }
+ }
+ return null;
}
/**
diff --git a/phoenixd-mock/src/test/java/xyz/tcheeric/phoenixd/mock/MockLnServerTest.java b/phoenixd-mock/src/test/java/xyz/tcheeric/phoenixd/mock/MockLnServerTest.java
index bbb3c26..ec2b05b 100644
--- a/phoenixd-mock/src/test/java/xyz/tcheeric/phoenixd/mock/MockLnServerTest.java
+++ b/phoenixd-mock/src/test/java/xyz/tcheeric/phoenixd/mock/MockLnServerTest.java
@@ -187,6 +187,77 @@ void shouldDecodeInvoice() throws Exception {
assertThat(json.get("description").asText()).isEqualTo("1 Blockaccino");
}
+ // An invoice this mock issued decodes to the payment hash it was issued with, which is
+ // what payment-adapter then looks up (phoenixd-java#67).
+ @Test
+ void decodeInvoiceReturnsTheIssuedPaymentHash() throws Exception {
+ JsonNode created = post("/createinvoice", "amountSat=21&externalId=quote-67");
+ String bolt11 = created.get("serialized").asText();
+ String issuedHash = created.get("paymentHash").asText();
+
+ JsonNode decoded = post("/decodeinvoice", "invoice=" + bolt11);
+
+ assertThat(issuedHash).matches("[0-9a-f]{64}");
+ assertThat(decoded.get("paymentHash").asText()).isEqualTo(issuedHash);
+ }
+
+ // A real BOLT11 invoice the mock never saw (the spec's first test vector) decodes to the
+ // payment hash written inside it.
+ @Test
+ void decodeInvoiceReadsThePaymentHashFromAForeignInvoice() throws Exception {
+ String specVector = "lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap9us6v52vjjsrvywa6rt52cm9r9zqt8r2t7mlcwspyetp5h2tztugp9lfyql";
+
+ JsonNode decoded = post("/decodeinvoice", "invoice=" + specVector);
+
+ assertThat(decoded.get("paymentHash").asText())
+ .isEqualTo("0001020304050607080900010203040506070809000102030405060708090102");
+ }
+
+ // A string that is not a decodable invoice still gets a payment hash, and always the same
+ // one, so lookups never fail for want of a hash.
+ @Test
+ void decodeInvoiceGivesUnknownStringsADeterministicHash() throws Exception {
+ String first = post("/decodeinvoice", "invoice=lnbc10n...").get("paymentHash").asText();
+ String second = post("/decodeinvoice", "invoice=lnbc10n...").get("paymentHash").asText();
+
+ assertThat(first).matches("[0-9a-f]{64}").isEqualTo(second);
+ }
+
+ // The decoded hash leads to /payments/incoming, which reports whether it was paid: unpaid
+ // at first, paid with the amount after /mockpay. This is payment-adapter's lookup path.
+ @Test
+ void decodedHashLooksUpTheIncomingPayment() throws Exception {
+ JsonNode created = post("/createinvoice", "amountSat=42&externalId=quote-67b");
+ String hash = post("/decodeinvoice", "invoice=" + created.get("serialized").asText())
+ .get("paymentHash").asText();
+
+ HttpResponse {@code paymentHash} is read from the response: callers such as payment-adapter look the
+ * payment up by it. Fields this class does not model are ignored, so a newer phoenixd that
+ * adds fields does not break decoding.
+ */
@Data
@NoArgsConstructor
+@JsonIgnoreProperties(ignoreUnknown = true)
public class DecodeInvoiceResponse implements Response {
private Integer amount;
@@ -17,7 +26,6 @@ public class DecodeInvoiceResponse implements Response {
@JsonIgnore
private String chain;
- @JsonIgnore
private String paymentHash;
@JsonIgnore
diff --git a/phoenixd-model/src/test/java/xyz/tcheeric/phoenixd/model/response/DecodeInvoiceResponseTest.java b/phoenixd-model/src/test/java/xyz/tcheeric/phoenixd/model/response/DecodeInvoiceResponseTest.java
index 7a496c8..8ccf1f4 100644
--- a/phoenixd-model/src/test/java/xyz/tcheeric/phoenixd/model/response/DecodeInvoiceResponseTest.java
+++ b/phoenixd-model/src/test/java/xyz/tcheeric/phoenixd/model/response/DecodeInvoiceResponseTest.java
@@ -1,5 +1,6 @@
package xyz.tcheeric.phoenixd.model.response;
+import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.List;
@@ -58,5 +59,18 @@ void settersGettersAndToString() {
assertThat(response.toString()).contains("amount=1", "description=desc", "chain=chain", "paymentMetadata=null");
}
-}
+ // phoenixd's /decodeinvoice JSON carries paymentHash, and the client must keep it:
+ // payment-adapter looks the payment up by it. It used to be @JsonIgnore'd, so it read null
+ // (phoenixd-java#67). Fields the class does not model must not break decoding either.
+ @Test
+ void deserializesPaymentHashFromPhoenixdJson() throws Exception {
+ String json = "{\"chain\":\"mainnet\",\"amount\":1000,\"paymentHash\":\"ab12\","
+ + "\"description\":\"x\",\"someNewField\":true}";
+
+ DecodeInvoiceResponse response = new ObjectMapper().readValue(json, DecodeInvoiceResponse.class);
+
+ assertThat(response.getPaymentHash()).isEqualTo("ab12");
+ assertThat(response.getAmount()).isEqualTo(1000);
+ }
+}
diff --git a/phoenixd-rest/pom.xml b/phoenixd-rest/pom.xml
index 923c7ef..2291d49 100644
--- a/phoenixd-rest/pom.xml
+++ b/phoenixd-rest/pom.xml
@@ -4,12 +4,12 @@