Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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 ./
Expand All @@ -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"]
45 changes: 40 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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**

Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>co.nxtgrid</groupId>
<artifactId>nxt-sts</artifactId>
<version>1.0.2</version>
<version>1.0.3</version>

<parent>
<groupId>org.springframework.boot</groupId>
Expand Down
7 changes: 7 additions & 0 deletions src/main/java/co/nxtgrid/api/StsExceptionHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
20 changes: 15 additions & 5 deletions src/main/java/co/nxtgrid/api/TokenRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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(
Expand Down
28 changes: 21 additions & 7 deletions src/main/java/co/nxtgrid/token/domain/Amount.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() ;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public DecoderKeyGeneratorAlgorithm04(BaseDate baseDate, TariffIndex tariffIndex
}

public String getName() {
return "DKGA02";
return "DKGA04";
}

public BaseDate getBaseDate() {
Expand Down
10 changes: 9 additions & 1 deletion src/main/java/co/nxtgrid/token/generators/utils/Utils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
66 changes: 66 additions & 0 deletions src/test/java/co/nxtgrid/TokenControllerValidationTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
27 changes: 27 additions & 0 deletions src/test/java/co/nxtgrid/token/domain/AmountTest.java
Original file line number Diff line number Diff line change
@@ -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));
}
}