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 @@ xyz.tcheeric phoenixd-java - 0.3.2 + 0.3.3 phoenixd-base diff --git a/phoenixd-mock/pom.xml b/phoenixd-mock/pom.xml index 356d98c..133c5a7 100644 --- a/phoenixd-mock/pom.xml +++ b/phoenixd-mock/pom.xml @@ -6,7 +6,7 @@ xyz.tcheeric phoenixd-java - 0.3.2 + 0.3.3 phoenixd-mock diff --git a/phoenixd-mock/src/main/java/xyz/tcheeric/phoenixd/mock/MockLnServer.java b/phoenixd-mock/src/main/java/xyz/tcheeric/phoenixd/mock/MockLnServer.java index ecee62f..b2c87d8 100644 --- a/phoenixd-mock/src/main/java/xyz/tcheeric/phoenixd/mock/MockLnServer.java +++ b/phoenixd-mock/src/main/java/xyz/tcheeric/phoenixd/mock/MockLnServer.java @@ -15,7 +15,10 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; +import java.util.HexFormat; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; @@ -40,6 +43,9 @@ public class MockLnServer { */ private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + /** Length of a BOLT11 signature (65 bytes) in bech32 characters. */ + private static final int SIGNATURE_CHARS = 104; + /** * Tracks created invoices by payment hash for auto-settlement. */ @@ -123,6 +129,7 @@ public void start() throws IOException { server.createContext("/getinvoice", this::handleGetInvoice); server.createContext("/decodeinvoice", this::handleDecodeInvoice); server.createContext("/payinvoice", this::handlePayInvoice); + server.createContext("/payments/incoming/", this::handleGetIncomingPayment); server.createContext("/delete", this::handleDelete); server.createContext("/patch", this::handlePatch); server.createContext("/mockpay", this::handleMockPay); @@ -174,9 +181,12 @@ private void handleCreateInvoice(HttpExchange exchange) throws IOException { } // Generate a unique mock bolt11 invoice with valid Bech32 encoding - String invoiceId = Long.toHexString(System.nanoTime()); - String paymentHash = "hash" + invoiceId; - String bolt11 = generateValidBolt11Invoice(amountSat); + // A real 32-byte payment hash, written into the invoice's p field, so /decodeinvoice + // can read it back the way phoenixd does. + byte[] hashBytes = new byte[32]; + SECURE_RANDOM.nextBytes(hashBytes); + String paymentHash = HexFormat.of().formatHex(hashBytes); + String bolt11 = generateValidBolt11Invoice(amountSat, hashBytes); // Track the invoice for auto-settlement InvoiceInfo invoiceInfo = new InvoiceInfo(paymentHash, bolt11, amountSat, externalId); @@ -248,15 +258,28 @@ private void handleGetInvoice(HttpExchange exchange) throws IOException { * the sale costs, refused every invoice this mock issued. */ String generateValidBolt11Invoice(long amountSat) { - String hrp = "lnbc" + bolt11Amount(amountSat); + byte[] hashBytes = new byte[32]; + SECURE_RANDOM.nextBytes(hashBytes); + return generateValidBolt11Invoice(amountSat, hashBytes); + } - // Generate random payment hash (32 bytes = 52 chars in bech32, roughly) - // For a minimal valid invoice, we need at least timestamp + payment hash - // Bech32 charset: qpzry9x8gf2tvdw0s3jn54khce6mua7l + /** + * Same as {@link #generateValidBolt11Invoice(long)}, but lays the data out as BOLT11 does: + * a 7-character timestamp, a {@code p} tagged field holding {@code paymentHash}, then a + * random 104-character stand-in for the signature. {@link #paymentHashFromBolt11} reads the + * hash back out. + */ + String generateValidBolt11Invoice(long amountSat, byte[] paymentHash) { + String hrp = "lnbc" + bolt11Amount(amountSat); StringBuilder data = new StringBuilder(); - - // Generate 52 random bech32 characters (represents ~32 bytes of data) - for (int i = 0; i < 52; i++) { + long timestamp = System.currentTimeMillis() / 1000; + for (int i = 6; i >= 0; i--) { + data.append(BECH32_CHARSET.charAt((int) ((timestamp >> (5 * i)) & 31))); + } + // Tag p, data length 52 (written as two 5-bit groups: 1, 20). + data.append('p').append(BECH32_CHARSET.charAt(1)).append(BECH32_CHARSET.charAt(20)); + data.append(toBech32Chars(paymentHash)); + for (int i = 0; i < SIGNATURE_CHARS; i++) { data.append(BECH32_CHARSET.charAt(SECURE_RANDOM.nextInt(BECH32_CHARSET.length()))); } @@ -324,8 +347,165 @@ private int polymod(int[] values) { return chk; } + /** + * Handles /decodeinvoice in phoenixd's shape, including {@code paymentHash}. + * + *

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 incoming = get("/payments/incoming/" + hash); + HttpResponse unknown = get("/payments/incoming/" + "0".repeat(64)); + + assertThat(incoming.statusCode()).isEqualTo(200); + JsonNode json = objectMapper.readTree(incoming.body()); + assertThat(json.get("paymentHash").asText()).isEqualTo(hash); + assertThat(json.get("externalId").asText()).isEqualTo("quote-67b"); + assertThat(json.has("isPaid")).isTrue(); + assertThat(unknown.statusCode()).isEqualTo(404); + } + + private JsonNode post(String path, String form) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + path)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(form)) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(200); + return objectMapper.readTree(response.body()); + } + + private HttpResponse get(String path) throws Exception { + HttpRequest request = HttpRequest.newBuilder().uri(URI.create(BASE_URL + path)).GET().build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + @Test void shouldPayInvoice() throws Exception { // Act diff --git a/phoenixd-model/pom.xml b/phoenixd-model/pom.xml index 9a35a51..132aa7d 100644 --- a/phoenixd-model/pom.xml +++ b/phoenixd-model/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric phoenixd-java - 0.3.2 + 0.3.3 phoenixd-model diff --git a/phoenixd-model/src/main/java/xyz/tcheeric/phoenixd/model/response/DecodeInvoiceResponse.java b/phoenixd-model/src/main/java/xyz/tcheeric/phoenixd/model/response/DecodeInvoiceResponse.java index c665382..5d519bd 100644 --- a/phoenixd-model/src/main/java/xyz/tcheeric/phoenixd/model/response/DecodeInvoiceResponse.java +++ b/phoenixd-model/src/main/java/xyz/tcheeric/phoenixd/model/response/DecodeInvoiceResponse.java @@ -1,14 +1,23 @@ package xyz.tcheeric.phoenixd.model.response; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import lombok.Data; import lombok.NoArgsConstructor; import xyz.tcheeric.phoenixd.common.rest.Response; import java.util.List; +/** + * phoenixd's answer to {@code /decodeinvoice}. + * + *

{@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 @@ xyz.tcheeric phoenixd-java - 0.3.2 + 0.3.3 phoenixd-rest jar - 0.3.2 + 0.3.3 phoenixd-rest https://maven.apache.org diff --git a/phoenixd-test/pom.xml b/phoenixd-test/pom.xml index e9a856a..692ccb7 100644 --- a/phoenixd-test/pom.xml +++ b/phoenixd-test/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric phoenixd-java - 0.3.2 + 0.3.3 phoenixd-test diff --git a/pom.xml b/pom.xml index e973b29..a2fc16d 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 xyz.tcheeric phoenixd-java - 0.3.2 + 0.3.3 pom