diff --git a/Dockerfile b/Dockerfile
index 2383512..72c42f6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
# Stage 1: Build
-FROM eclipse-temurin:17-jdk-alpine AS build
+FROM eclipse-temurin:17-jdk-jammy AS build
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
@@ -8,13 +8,17 @@ COPY src/ src/
RUN ./mvnw package -DskipTests -q
# Stage 2: Runtime
-FROM eclipse-temurin:17-jre-alpine AS runtime
-RUN addgroup -S sts && adduser -S sts -G sts
+FROM eclipse-temurin:17-jre-jammy AS runtime
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends curl \
+ && rm -rf /var/lib/apt/lists/* \
+ && groupadd --system sts \
+ && useradd --system --gid sts --no-create-home sts
WORKDIR /app
COPY --from=build /app/target/nxt-sts-*.jar app.jar
USER sts
ENV SERVER_PORT=8080
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \
- CMD wget -qO- "http://127.0.0.1:${SERVER_PORT}/actuator/health" || exit 1
+ CMD curl -fsS "http://127.0.0.1:${SERVER_PORT}/actuator/health" || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]
diff --git a/README.md b/README.md
index 692093a..2b4acd4 100644
--- a/README.md
+++ b/README.md
@@ -266,18 +266,27 @@ Generates a prepayment token.
| `issueDate` | `string` | Yes | ISO 8601 datetime (see note below) |
| `randomNumber` | `integer` | Yes | STS 4-bit RND field — **must be 0–15** (see note below) |
| `decoderKey` | `string` | Yes | Meter decoder key as a hexadecimal string (16 hex chars = 8 bytes) |
-| `kwh` | `number` | For `TOP_UP_KWH` | Amount of electricity credit in kWh (also required when using deprecated `TOP_UP`) |
+| `kwh` | `number` | For `TOP_UP_KWH` | Amount of electricity credit in kWh (also required when using deprecated `TOP_UP`; see quantization note below) |
| `powerLimit` | `integer` | For `SET_POWER_LIMIT` | Maximum power limit value |
-> **`randomNumber` — STS protocol constraint**
+> **`randomNumber` — STS protocol constraint (and same-minute uniqueness)**
>
> This field maps directly to the 4-bit RND field in the IEC 62055-41 token structure.
> The protocol defines it as a 4-bit value, so **only 0–15 is valid** — this is not an
> arbitrary API limit. Values outside this range will be rejected with HTTP 400.
>
-> Vary this value between consecutive token issues for the same meter. Meters reject
-> tokens with the same `randomNumber` as the most recently accepted token to prevent
-> replay attacks. A value of 0 is valid but should not be reused immediately.
+> The STS token identifier (TID) is **minute-granular**: only the UTC date and minute of
+> `issueDate` enter the TID (seconds and sub-seconds are ignored). So `10:30:00` and
+> `10:30:59` produce the same TID. With identical `decoderKey`, amount, and other
+> fields, two requests in the same wall-clock minute produce a **byte-identical token**
+> unless `randomNumber` differs.
+>
+> Callers should therefore **track the last-used RND per meter** and advance it for each
+> new issue (especially when vending more than once in the same minute). Meters also
+> reject a token that reuses the same `randomNumber` as the most recently accepted token
+> (anti-replay). A value of 0 is valid but should not be reused immediately. With only
+> 16 possible RND values, high-frequency same-minute vending on one meter will exhaust
+> the space unless the caller waits for the next minute or otherwise avoids collisions.
>
> See the full schema in the [Swagger UI](http://localhost:8080/swagger).
@@ -287,6 +296,32 @@ Generates a prepayment token.
> or `"2026-07-07T10:12:54.289Z"`. Optional fractional seconds and UTC/offset suffixes
> are allowed. Any time-zone offset is **ignored**; the date and time fields are
> interpreted as **UTC** for TID calculation, independent of the server's timezone.
+>
+> TID uses **minute resolution** only — changing seconds within the same minute does not
+> change the token. For uniqueness of consecutive issues, vary `randomNumber` (see above).
+
+> **`kwh` — amount quantization (0.1 kWh steps)**
+>
+> The STS transfer-amount field does not store an arbitrary floating-point kWh value.
+> Credit is encoded in **tenths of a kWh** (0.1 kWh steps). Before packing into the
+> token, this service maps the request `kwh` onto that grid as follows (inherited from
+> [NectarAPI/tokens-service](https://github.com/NectarAPI/tokens-service); unchanged in NXT STS):
+>
+> | Requested `kwh` | Mapping | Effective credit on the token |
+> |---|---|---|
+> | `< 1` | ceil to the next 0.1 kWh | e.g. `0.01` → **0.1**, `0.11` → **0.2**, `0.5` → **0.5** |
+> | `≥ 1` | truncate toward zero to a 0.1 kWh step | e.g. `1.19` → **1.1**, `1.99` → **1.9** |
+>
+> Very small top-ups therefore cannot encode as zero (`0.01` becomes `0.1`). Larger
+> amounts drop any leftover fraction of a tenth rather than rounding up.
+>
+> **Recommendation for callers / MPM:** send `kwh` values that are already multiples of
+> `0.1` so the mapping is exact, and treat billing/ledger amounts as that quantized
+> value (not an unrounded intermediate float). Changing this rule would alter token
+> output for the same inputs and break compatibility with existing meters and systems.
+>
+> **Maximum:** `kwh` must not exceed **1820162.4** (the STS 16-bit amount field maximum).
+> Larger values are rejected with HTTP 400.
**Example — TOP_UP_KWH**
diff --git a/pom.xml b/pom.xml
index 23f3a1b..9fff290 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
co.nxtgrid
nxt-sts
- 1.0.2
+ 1.0.3
org.springframework.boot
diff --git a/src/main/java/co/nxtgrid/api/StsExceptionHandler.java b/src/main/java/co/nxtgrid/api/StsExceptionHandler.java
index 855a4c8..abd764e 100644
--- a/src/main/java/co/nxtgrid/api/StsExceptionHandler.java
+++ b/src/main/java/co/nxtgrid/api/StsExceptionHandler.java
@@ -18,6 +18,7 @@
import com.fasterxml.jackson.databind.exc.InvalidFormatException;
import co.nxtgrid.token.exceptions.InvalidRangeException;
+import co.nxtgrid.token.exceptions.InvalidUnitsPurchasedException;
@RestControllerAdvice
public class StsExceptionHandler {
@@ -49,6 +50,12 @@ public ErrorResponse handleDomainRange(InvalidRangeException ex) {
return new ErrorResponse(ex.getMessage(), null);
}
+ @ExceptionHandler(InvalidUnitsPurchasedException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public ErrorResponse handleInvalidUnitsPurchased(InvalidUnitsPurchasedException ex) {
+ return new ErrorResponse(ex.getMessage(), "kwh");
+ }
+
@ExceptionHandler(UnsupportedTokenTypeException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleUnsupportedType(UnsupportedTokenTypeException ex) {
diff --git a/src/main/java/co/nxtgrid/api/TokenRequest.java b/src/main/java/co/nxtgrid/api/TokenRequest.java
index ac55a9a..a6bd813 100644
--- a/src/main/java/co/nxtgrid/api/TokenRequest.java
+++ b/src/main/java/co/nxtgrid/api/TokenRequest.java
@@ -7,6 +7,7 @@
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.AssertTrue;
+import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
@@ -37,7 +38,9 @@ public class TokenRequest {
@Schema(
description = "Token issue date/time in ISO 8601 format. Optional fractional seconds and "
+ "UTC/offset suffixes are accepted; any offset is ignored and the wall-clock date "
- + "and time fields are interpreted as UTC for token generation.",
+ + "and time fields are interpreted as UTC for TID. TID is minute-granular — "
+ + "seconds do not differentiate tokens; vary randomNumber for same-minute issues. "
+ + "See README.",
example = "2024-03-15T10:30:00",
type = "string",
format = "date-time"
@@ -48,8 +51,10 @@ public class TokenRequest {
@Schema(
description = "STS RND field (4 bits). Must be an integer from 0 to 15. "
- + "Vary between token issues to avoid duplicate-token rejection on the meter. "
- + "This is not a meter serial number or other large identifier.",
+ + "TID is minute-granular, so identical inputs in the same UTC minute produce "
+ + "the same token unless this value differs — track last-used RND per meter "
+ + "and advance it between issues. Also avoids meter anti-replay rejection. "
+ + "This is not a meter serial number or other large identifier. See README.",
minimum = "0",
maximum = "15",
example = "3"
@@ -61,11 +66,16 @@ public class TokenRequest {
@Schema(
description = "Amount of electricity credit in kWh. Required when type is TOP_UP_KWH "
- + "(or deprecated alias TOP_UP). Must be zero or greater.",
+ + "(or deprecated alias TOP_UP). Must be zero or greater and at most 1820162.4 "
+ + "(STS 16-bit amount maximum). Encoded in 0.1 kWh steps: values below 1 kWh "
+ + "are ceiled to the next tenth; values at or above 1 kWh are truncated to a "
+ + "tenth. Prefer multiples of 0.1. See README.",
example = "0.5",
- minimum = "0"
+ minimum = "0",
+ maximum = "1820162.4"
)
@PositiveOrZero(message = "kwh must be zero or greater")
+ @DecimalMax(value = "1820162.4", message = "kwh must not exceed 1820162.4")
private Double kwh;
@Schema(
diff --git a/src/main/java/co/nxtgrid/token/domain/Amount.java b/src/main/java/co/nxtgrid/token/domain/Amount.java
index f479dcb..08e1e20 100755
--- a/src/main/java/co/nxtgrid/token/domain/Amount.java
+++ b/src/main/java/co/nxtgrid/token/domain/Amount.java
@@ -16,14 +16,22 @@ public class Amount implements Entity {
public Amount() {}
+ /**
+ * Maximum encodable STS transfer amount in kWh. The 16-bit amount field (exponent 0–3,
+ * mantissa 0–16383) tops out at 18_201_624 tenths of a unit, i.e. 1_820_162.4 kWh.
+ * Comparing against 18_201_624 here would be off by 10× (that constant is in tenths).
+ */
+ private static final double UNITS_PURCHASED_MIN_KWH = 0;
+ private static final double UNITS_PURCHASED_MAX_KWH = 1_820_162.4;
+ /** Max value after scaling kWh → tenths; must fit STS amount encoding (exp ≤ 3). */
+ private static final long MAX_AMOUNT_TENTHS = 18_201_624L;
+
public Amount(double unitsPurchased)
throws InvalidUnitsPurchasedException, InvalidRangeException, InvalidBitStringException {
- final int UNITS_PURCHASED_MIN = 0;
- final int UNITS_PURCHASED_MAX = 18201624;
-
- if (unitsPurchased < UNITS_PURCHASED_MIN
- || unitsPurchased > UNITS_PURCHASED_MAX)
- throw new InvalidUnitsPurchasedException("Invalid number of units purchased!");
+ if (unitsPurchased < UNITS_PURCHASED_MIN_KWH
+ || unitsPurchased > UNITS_PURCHASED_MAX_KWH)
+ throw new InvalidUnitsPurchasedException(
+ "kwh must be between 0 and 1820162.4 (STS maximum)");
setAmountPurchased(unitsPurchased);
generateAmountBitString() ;
@@ -58,7 +66,13 @@ private void setAmountPurchased(double unitsPurchased) {
private void generateAmountBitString()
throws InvalidUnitsPurchasedException, InvalidRangeException, InvalidBitStringException {
- double refactoredAmountBits = unitsPurchased < 1 ? (int) Math.ceil(unitsPurchased * 10) : (int) (unitsPurchased * 10);
+ double refactoredAmountBits = unitsPurchased < 1
+ ? (int) Math.ceil(unitsPurchased * 10)
+ : (int) (unitsPurchased * 10);
+ if (refactoredAmountBits > MAX_AMOUNT_TENTHS) {
+ throw new InvalidUnitsPurchasedException(
+ "kwh must be between 0 and 1820162.4 (STS maximum)");
+ }
BitString generatedAmountBitString = Utils.convertToBitString(refactoredAmountBits) ;
generatedAmountBitString.setLength(NO_OF_BITS);
setBitString(generatedAmountBitString);
diff --git a/src/main/java/co/nxtgrid/token/generators/decoderkeygenerator/DecoderKeyGeneratorAlgorithm04.java b/src/main/java/co/nxtgrid/token/generators/decoderkeygenerator/DecoderKeyGeneratorAlgorithm04.java
index cd80391..4f4db6f 100755
--- a/src/main/java/co/nxtgrid/token/generators/decoderkeygenerator/DecoderKeyGeneratorAlgorithm04.java
+++ b/src/main/java/co/nxtgrid/token/generators/decoderkeygenerator/DecoderKeyGeneratorAlgorithm04.java
@@ -38,7 +38,7 @@ public DecoderKeyGeneratorAlgorithm04(BaseDate baseDate, TariffIndex tariffIndex
}
public String getName() {
- return "DKGA02";
+ return "DKGA04";
}
public BaseDate getBaseDate() {
diff --git a/src/main/java/co/nxtgrid/token/generators/utils/Utils.java b/src/main/java/co/nxtgrid/token/generators/utils/Utils.java
index 8cde94a..f7ad97f 100755
--- a/src/main/java/co/nxtgrid/token/generators/utils/Utils.java
+++ b/src/main/java/co/nxtgrid/token/generators/utils/Utils.java
@@ -37,7 +37,15 @@ public static BitString convertToBitString(double unitsPurchased) {
mantissa /= 10;
}
- return new BitString((exponent << 14) + (long) Math.ceil(mantissa));
+ long encoded = (exponent << 14) + (long) Math.ceil(mantissa);
+ // Exponent is only 2 bits in the STS amount field; values that need exp > 3
+ // (or otherwise exceed 16 bits) are not representable — reject rather than
+ // emit bits the decoder will refuse (see convertToDouble).
+ if (exponent > 3 || encoded > 0xFFFFL) {
+ throw new IllegalArgumentException(
+ "amount exceeds the maximum encodable STS 16-bit value");
+ }
+ return new BitString(encoded);
}
public static double convertToDouble(BitString amountBitString)
diff --git a/src/test/java/co/nxtgrid/TokenControllerValidationTest.java b/src/test/java/co/nxtgrid/TokenControllerValidationTest.java
index da436dc..45e5b8b 100644
--- a/src/test/java/co/nxtgrid/TokenControllerValidationTest.java
+++ b/src/test/java/co/nxtgrid/TokenControllerValidationTest.java
@@ -216,6 +216,72 @@ void rejectsNegativeKwhForTopUp() throws Exception {
.andExpect(jsonPath("$.field").value("kwh"));
}
+ @Test
+ void acceptsMaximumEncodableKwhForTopUp() throws Exception {
+ mockMvc.perform(
+ post("/token")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(
+ """
+ {
+ "type": "TOP_UP_KWH",
+ "issueDate": "2024-03-15T10:30:00",
+ "randomNumber": 3,
+ "decoderKey": "0123456789ABCDEF",
+ "kwh": 1820162.4
+ }
+ """
+ )
+ )
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.token").isString())
+ .andExpect(jsonPath("$.token").isNotEmpty());
+ }
+
+ @Test
+ void rejectsKwhAboveStsMaximumAsBadRequest() throws Exception {
+ mockMvc.perform(
+ post("/token")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(
+ """
+ {
+ "type": "TOP_UP_KWH",
+ "issueDate": "2024-03-15T10:30:00",
+ "randomNumber": 3,
+ "decoderKey": "0123456789ABCDEF",
+ "kwh": 1820163
+ }
+ """
+ )
+ )
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.error").value("kwh must not exceed 1820162.4"))
+ .andExpect(jsonPath("$.field").value("kwh"));
+ }
+
+ @Test
+ void rejectsKwhFarAboveFormerIncorrectGuardAsBadRequest() throws Exception {
+ mockMvc.perform(
+ post("/token")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(
+ """
+ {
+ "type": "TOP_UP_KWH",
+ "issueDate": "2024-03-15T10:30:00",
+ "randomNumber": 3,
+ "decoderKey": "0123456789ABCDEF",
+ "kwh": 18201625
+ }
+ """
+ )
+ )
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.error").value("kwh must not exceed 1820162.4"))
+ .andExpect(jsonPath("$.field").value("kwh"));
+ }
+
@Test
void acceptsZeroPowerLimitForSetPowerLimit() throws Exception {
mockMvc.perform(
diff --git a/src/test/java/co/nxtgrid/token/domain/AmountTest.java b/src/test/java/co/nxtgrid/token/domain/AmountTest.java
new file mode 100644
index 0000000..3fdfc01
--- /dev/null
+++ b/src/test/java/co/nxtgrid/token/domain/AmountTest.java
@@ -0,0 +1,27 @@
+package co.nxtgrid.token.domain;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+
+import co.nxtgrid.token.exceptions.InvalidUnitsPurchasedException;
+
+class AmountTest {
+
+ @Test
+ void acceptsMaximumEncodableKwh() {
+ assertDoesNotThrow(() -> new Amount(1_820_162.4));
+ }
+
+ @Test
+ void rejectsAboveMaximumEncodableKwh() {
+ assertThrows(InvalidUnitsPurchasedException.class, () -> new Amount(1_820_163));
+ }
+
+ @Test
+ void rejectsFormerIncorrectGuardBand() {
+ // Previously accepted (guard was 18_201_624 kWh) but not STS-encodable.
+ assertThrows(InvalidUnitsPurchasedException.class, () -> new Amount(2_000_000));
+ }
+}