Repository: hyperledger/quilt
Pull request #426 (2020-01-30 22:20:03)

Introduce Local SPSP Stream Receiver Link
This PR introduces a local SPSP Receiver link that can be used by an ILP node to fulfill SPSP packets without forwarding to another host.

This PR also fixes a variety of minor issues, such as:

* Update library versions in the README
* Add Ping packets to the PacketEmitter tool.


Patch:
diff --git a/README.md b/README.md
index fcad770..d2a1a36 100644
--- a/README.md
+++ b/README.md
@@ -108,7 +108,7 @@ Modules in this library can be included in your Maven project by adding the Mave
   <dependency>
     <groupId>org.interledger</groupId>
     <artifactId>ilp-core</artifactId>
-    <version>1.0.2</version>
+    <version>1.0.3</version>
   </dependency>
   ...
 </dependencies>
@@ -120,7 +120,7 @@ Modules in this library can be included in your Gradle project by adding the Mav
 ```
 dependencies {
     ...
-    compile group: 'org.interledger', name: 'ilp-core', version: '1.0.2'
+    compile group: 'org.interledger', name: 'ilp-core', version: '1.0.3'
     ...
 }
 ```
diff --git a/examples-parent/ilp-emitters/pom.xml b/examples-parent/ilp-emitters/pom.xml
index af82c25..62a951c 100644
--- a/examples-parent/ilp-emitters/pom.xml
+++ b/examples-parent/ilp-emitters/pom.xml
@@ -55,6 +55,10 @@
       <groupId>org.interledger</groupId>
       <artifactId>codecs-ildcp</artifactId>
     </dependency>
+    <dependency>
+      <groupId>org.interledger</groupId>
+      <artifactId>link-core</artifactId>
+    </dependency>
     <dependency>
       <groupId>org.interledger</groupId>
       <artifactId>stream-core</artifactId>
diff --git a/examples-parent/ilp-emitters/src/main/java/org/interledger/examples/packets/IlpPacketEmitter.java b/examples-parent/ilp-emitters/src/main/java/org/interledger/examples/packets/IlpPacketEmitter.java
index 8ca81b5..2bf1b80 100644
--- a/examples-parent/ilp-emitters/src/main/java/org/interledger/examples/packets/IlpPacketEmitter.java
+++ b/examples-parent/ilp-emitters/src/main/java/org/interledger/examples/packets/IlpPacketEmitter.java
@@ -16,6 +16,7 @@ import org.interledger.core.InterledgerPreparePacket;
 import org.interledger.core.InterledgerPreparePacketBuilder;
 import org.interledger.core.InterledgerRejectPacket;
 import org.interledger.core.SharedSecret;
+import org.interledger.link.PingLoopbackLink;
 import org.interledger.stream.Denomination;
 import org.interledger.stream.StreamPacket;
 import org.interledger.stream.crypto.JavaxStreamEncryptionService;
@@ -50,9 +51,11 @@ public class IlpPacketEmitter {
 
   private static final Logger LOGGER = LoggerFactory.getLogger(IlpPacketEmitter.class);
 
+  private static final InterledgerAddress PING_DESTINATION_ADDRESS = InterledgerAddress.of("test.connie");
+
   private static final InterledgerAddress DESTINATION_ADDRESS = InterledgerAddress
-      .of("example.connie.bob.QeJvQtFp7eRiNhnoAg9PkusR");
-  private static final InterledgerAddress OPERATOR_ADDRESS = InterledgerAddress.of("example.connie");
+      .of("test.connie.bob.QeJvQtFp7eRiNhnoAg9PkusR");
+  private static final InterledgerAddress OPERATOR_ADDRESS = InterledgerAddress.of("test.connie");
 
   private static final SharedSecret SHARED_SECRET = SharedSecret
       .of(Base64.getDecoder().decode("nHYRcu5KM5pyw8XehssZtvhEgCgkKP4Do5kJUpk84G4"));
@@ -62,6 +65,7 @@ public class IlpPacketEmitter {
   public static void main(String[] args) throws IOException {
     emitPacketsWithNoData();
     emitPacketsWithStreamPayloads();
+    emitUnidrectionalPingPacket();
   }
 
   private static void emitPacketsWithNoData() {
@@ -81,6 +85,18 @@ public class IlpPacketEmitter {
     emitPacketToFile("/tmp/testFulfillPacket.bin", fulfillPacket);
   }
 
+  private static void emitUnidrectionalPingPacket() {
+
+    final InterledgerPreparePacket preparePacket = InterledgerPreparePacket.builder()
+        .destination(PING_DESTINATION_ADDRESS)
+        .expiresAt(Instant.now().plus(365, ChronoUnit.DAYS))
+        .executionCondition(PingLoopbackLink.PING_PROTOCOL_CONDITION)
+        .amount(UnsignedLong.ONE)
+        .build();
+
+    emitPacketToFile("/tmp/testUnidirectionalPingPacket.bin", preparePacket);
+  }
+
   private static void emitPacketsWithStreamPayloads() throws IOException {
     final InterledgerPreparePacket preparePacketWithStreamFrames = preparePacketWithStreamFrames().build();
     emitPacketToFile("/tmp/testPreparePacketWithStreamFrames.bin", preparePacketWithStreamFrames);
diff --git a/link-parent/link-stateless-spsp-receiver/pom.xml b/link-parent/link-stateless-spsp-receiver/pom.xml
new file mode 100644
index 0000000..9217fba
--- /dev/null
+++ b/link-parent/link-stateless-spsp-receiver/pom.xml
@@ -0,0 +1,80 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <parent>
+    <groupId>org.interledger</groupId>
+    <artifactId>link-parent</artifactId>
+    <version>HEAD-SNAPSHOT</version>
+  </parent>
+  <modelVersion>4.0.0</modelVersion>
+
+  <name>Quilt :: Link :: Stateless SPSP Receiver</name>
+  <artifactId>link-stateless-spsp-receiver</artifactId>
+  <description>A Link implementation for a stateless SPSP receiver.</description>
+  <packaging>jar</packaging>
+
+  <dependencies>
+    <dependency>
+      <groupId>${project.groupId}</groupId>
+      <artifactId>codecs-framework</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>${project.groupId}</groupId>
+      <artifactId>ilp-core</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>${project.groupId}</groupId>
+      <artifactId>link-core</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>${project.groupId}</groupId>
+      <artifactId>stream-receiver</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>com.auth0</groupId>
+      <artifactId>java-jwt</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>com.fasterxml.jackson.core</groupId>
+      <artifactId>jackson-databind</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>com.google.guava</groupId>
+      <artifactId>guava</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>com.squareup.okhttp3</groupId>
+      <artifactId>okhttp</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>org.assertj</groupId>
+      <artifactId>assertj-core</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.immutables</groupId>
+      <artifactId>value</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>junit</groupId>
+      <artifactId>junit</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.mockito</groupId>
+      <artifactId>mockito-core</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.slf4j</groupId>
+      <artifactId>slf4j-api</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>org.zalando</groupId>
+      <artifactId>problem</artifactId>
+    </dependency>
+  </dependencies>
+
+</project>
diff --git a/link-parent/link-stateless-spsp-receiver/src/main/java/org/interledger/link/spsp/StatelessSpspReceiverLink.java b/link-parent/link-stateless-spsp-receiver/src/main/java/org/interledger/link/spsp/StatelessSpspReceiverLink.java
new file mode 100644
index 0000000..d2f8a3b
--- /dev/null
+++ b/link-parent/link-stateless-spsp-receiver/src/main/java/org/interledger/link/spsp/StatelessSpspReceiverLink.java
@@ -0,0 +1,78 @@
+package org.interledger.link.spsp;
+
+import org.interledger.core.InterledgerAddress;
+import org.interledger.core.InterledgerPreparePacket;
+import org.interledger.core.InterledgerResponsePacket;
+import org.interledger.link.AbstractLink;
+import org.interledger.link.Link;
+import org.interledger.link.LinkHandler;
+import org.interledger.link.LinkType;
+import org.interledger.link.exceptions.LinkHandlerAlreadyRegisteredException;
+import org.interledger.stream.Denomination;
+import org.interledger.stream.receiver.StreamReceiver;
+
+import java.util.Objects;
+import java.util.function.Supplier;
+
+/**
+ * <p>A {@link Link} that attempts to fulfill packets using an SPSP receiver.</p>
+ */
+public class StatelessSpspReceiverLink extends AbstractLink<StatelessSpspReceiverLinkSettings>
+    implements Link<StatelessSpspReceiverLinkSettings> {
+
+  public static final String LINK_TYPE_STRING = "STATELESS_SPSP_RECEIVER";
+  public static final LinkType LINK_TYPE = LinkType.of(LINK_TYPE_STRING);
+
+  private final StreamReceiver streamReceiver;
+  private final Denomination denomination;
+
+  /**
+   * Required-Args Constructor.
+   *
+   * @param operatorAddressSupplier A supplier for the ILP address of this node operating this Link. This value may be
+   *                                uninitialized, for example, in cases where the Link obtains its address from a
+   *                                parent node using IL-DCP. If an ILP address has not been assigned, or it has not
+   *                                been obtained via IL-DCP, then this value will by default be {@link Link#SELF}.
+   * @param linkSettings            A {@link StatelessSpspReceiverLinkSettings} for this Link.
+   * @param streamReceiver          A {@link StreamReceiver} that can fulfill packets.
+   */
+  public StatelessSpspReceiverLink(
+      final Supplier<InterledgerAddress> operatorAddressSupplier,
+      final StatelessSpspReceiverLinkSettings linkSettings,
+      final StreamReceiver streamReceiver
+  ) {
+    super(operatorAddressSupplier, linkSettings);
+    this.denomination = Denomination.builder()
+        .assetCode(linkSettings.assetCode())
+        .assetScale((short) linkSettings.assetScale())
+        .build();
+    this.streamReceiver = Objects.requireNonNull(streamReceiver);
+  }
+
+  @Override
+  public void registerLinkHandler(final LinkHandler ilpDataHandler) throws LinkHandlerAlreadyRegisteredException {
+    throw new RuntimeException(
+        "StatelessSpspReceiver links never emit data, and thus should not have a registered DataHandler."
+    );
+  }
+
+  @Override
+  public InterledgerResponsePacket sendPacket(final InterledgerPreparePacket preparePacket) {
+    Objects.requireNonNull(preparePacket, "preparePacket must not be null");
+
+    return streamReceiver.receiveMoney(preparePacket, this.getOperatorAddressSupplier().get(), this.denomination)
+        .map(fulfillPacket -> {
+              if (logger.isDebugEnabled()) {
+                logger.debug("Packet fulfilled! preparePacket={} fulfillPacket={}", preparePacket, fulfillPacket);
+              }
+              return fulfillPacket;
+            },
+            rejectPacket -> {
+              if (logger.isDebugEnabled()) {
+                logger.debug("Packet rejected! preparePacket={} rejectPacket={}", preparePacket, rejectPacket);
+              }
+              return rejectPacket;
+            }
+        );
+  }
+}
diff --git a/link-parent/link-stateless-spsp-receiver/src/main/java/org/interledger/link/spsp/StatelessSpspReceiverLinkFactory.java b/link-parent/link-stateless-spsp-receiver/src/main/java/org/interledger/link/spsp/StatelessSpspReceiverLinkFactory.java
new file mode 100644
index 0000000..8e47402
--- /dev/null
+++ b/link-parent/link-stateless-spsp-receiver/src/main/java/org/interledger/link/spsp/StatelessSpspReceiverLinkFactory.java
@@ -0,0 +1,70 @@
+package org.interledger.link.spsp;
+
+import org.interledger.core.InterledgerAddress;
+import org.interledger.link.Link;
+import org.interledger.link.LinkFactory;
+import org.interledger.link.LinkId;
+import org.interledger.link.LinkSettings;
+import org.interledger.link.LinkType;
+import org.interledger.link.PacketRejector;
+import org.interledger.link.exceptions.LinkException;
+import org.interledger.stream.receiver.StatelessStreamReceiver;
+
+import com.google.common.base.Preconditions;
+
+import java.util.Objects;
+import java.util.function.Supplier;
+
+/**
+ * An implementation of {@link LinkFactory} for constructing instances of {@link StatelessSpspReceiverLink}.
+ */
+public class StatelessSpspReceiverLinkFactory implements LinkFactory {
+
+  private final PacketRejector packetRejector;
+  private final StatelessStreamReceiver statelessStreamReceiver;
+
+  /**
+   * Required-args Constructor.
+   *
+   * @param packetRejector          An instance of {@link PacketRejector}.
+   * @param statelessStreamReceiver A {@link StatelessStreamReceiver} for encrypting/decrypting STREAM packets.
+   */
+  public StatelessSpspReceiverLinkFactory(
+      final PacketRejector packetRejector, final StatelessStreamReceiver statelessStreamReceiver
+  ) {
+    this.packetRejector = Objects.requireNonNull(packetRejector, "packetRejector must not be null");
+    this.statelessStreamReceiver = Objects
+        .requireNonNull(statelessStreamReceiver, "statelessStreamReceiver must not be null");
+  }
+
+
+  @Override
+  public Link<?> constructLink(
+      final Supplier<InterledgerAddress> operatorAddressSupplier, final LinkSettings linkSettings
+  ) {
+    Objects.requireNonNull(operatorAddressSupplier, "operatorAddressSupplier must not be null");
+    Objects.requireNonNull(linkSettings, "linkSettings must not be null");
+
+    if (!this.supports(linkSettings.getLinkType())) {
+      throw new LinkException(
+          String.format("LinkType not supported by this factory. linkType=%s", linkSettings.getLinkType()),
+          LinkId.of("n/a")
+      );
+    }
+
+    Preconditions.checkArgument(
+        StatelessSpspReceiverLinkSettings.class.isAssignableFrom(linkSettings.getClass()),
+        "Constructing an instance of StatelessSpspReceiverLink requires an instance of StatelessSpspReceiverLinkSettings"
+    );
+
+    return new StatelessSpspReceiverLink(
+        operatorAddressSupplier, (StatelessSpspReceiverLinkSettings) linkSettings, statelessStreamReceiver
+    );
+  }
+
+  @Override
+  public boolean supports(LinkType linkType) {
+    return StatelessSpspReceiverLink.LINK_TYPE.equals(linkType);
+  }
+
+}
diff --git a/link-parent/link-stateless-spsp-receiver/src/main/java/org/interledger/link/spsp/StatelessSpspReceiverLinkSettings.java b/link-parent/link-stateless-spsp-receiver/src/main/java/org/interledger/link/spsp/StatelessSpspReceiverLinkSettings.java
new file mode 100644
index 0000000..8a96c1e
--- /dev/null
+++ b/link-parent/link-stateless-spsp-receiver/src/main/java/org/interledger/link/spsp/StatelessSpspReceiverLinkSettings.java
@@ -0,0 +1,50 @@
+package org.interledger.link.spsp;
+
+import org.interledger.link.LinkSettings;
+import org.interledger.link.LinkType;
+
+import org.immutables.value.Value;
+import org.immutables.value.Value.Derived;
+
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * An extension of {@link LinkSettings} for Stateless SPSP receiver links.
+ */
+public interface StatelessSpspReceiverLinkSettings extends LinkSettings {
+
+  static ImmutableStatelessSpspReceiverLinkSettings.Builder builder() {
+    return ImmutableStatelessSpspReceiverLinkSettings.builder();
+  }
+
+  @Override
+  default LinkType getLinkType() {
+    return StatelessSpspReceiverLink.LINK_TYPE;
+  }
+
+  /**
+   * Currency code or other asset identifier that will be used to select the correct rate for this account.
+   */
+  String assetCode();
+
+  /**
+   * Interledger amounts are integers, but most currencies are typically represented as # fractional units, e.g. cents.
+   * This property defines how many Interledger units make # up one regular unit. For dollars, this would usually be set
+   * to 9, so that Interledger # amounts are expressed in nano-dollars.
+   *
+   * @return an int representing this account's asset scale.
+   */
+  int assetScale();
+
+  @Value.Immutable
+  abstract class AbstractStatelessSpspReceiverLinkSettings implements StatelessSpspReceiverLinkSettings {
+
+    @Derived
+    @Override
+    public LinkType getLinkType() {
+      return StatelessSpspReceiverLink.LINK_TYPE;
+    }
+
+  }
+}
diff --git a/link-parent/pom.xml b/link-parent/pom.xml
index 649f4fc..c747c85 100644
--- a/link-parent/pom.xml
+++ b/link-parent/pom.xml
@@ -16,6 +16,7 @@
   <modules>
     <module>link-core</module>
     <module>link-ilp-over-http</module>
+    <module>link-stateless-spsp-receiver</module>
   </modules>
 
 </project>
diff --git a/quilt-bom/pom.xml b/quilt-bom/pom.xml
index 93776dd..817f364 100644
--- a/quilt-bom/pom.xml
+++ b/quilt-bom/pom.xml
@@ -71,6 +71,11 @@
         <artifactId>link-ilp-over-http</artifactId>
         <version>${project.version}</version>
       </dependency>
+      <dependency>
+        <groupId>${project.groupId}</groupId>
+        <artifactId>link-stateless-spsp-receiver</artifactId>
+        <version>${project.version}</version>
+      </dependency>
       <dependency>
         <groupId>${project.groupId}</groupId>
         <artifactId>spsp-client</artifactId>


Test patch:
diff --git a/link-parent/link-stateless-spsp-receiver/src/test/java/org/interledger/link/spsp/StatelessSpspReceiverLinkFactoryTest.java b/link-parent/link-stateless-spsp-receiver/src/test/java/org/interledger/link/spsp/StatelessSpspReceiverLinkFactoryTest.java
new file mode 100644
index 0000000..9c27eb7
--- /dev/null
+++ b/link-parent/link-stateless-spsp-receiver/src/test/java/org/interledger/link/spsp/StatelessSpspReceiverLinkFactoryTest.java
@@ -0,0 +1,112 @@
+package org.interledger.link.spsp;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.interledger.core.InterledgerAddress;
+import org.interledger.link.LinkId;
+import org.interledger.link.LinkSettings;
+import org.interledger.link.LinkType;
+import org.interledger.link.PacketRejector;
+import org.interledger.link.exceptions.LinkException;
+import org.interledger.stream.receiver.StatelessStreamReceiver;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+/**
+ * Unit tests for {@link StatelessSpspReceiverLinkFactory}.
+ */
+public class StatelessSpspReceiverLinkFactoryTest {
+
+  private static final InterledgerAddress OPERATOR_ADDRESS = InterledgerAddress.of("test.operator");
+  private final LinkId linkId = LinkId.of("foo");
+
+  @Rule
+  public ExpectedException expectedException = ExpectedException.none();
+
+  @Mock
+  private LinkSettings linkSettingsMock;
+
+  @Mock
+  private PacketRejector packetRejectorMock;
+
+  @Mock
+  private StatelessStreamReceiver statelessStreamReceiverMock;
+
+  private StatelessSpspReceiverLinkFactory statelessSpspReceiverLinkFactory;
+
+  @Before
+  public void setUp() {
+    MockitoAnnotations.initMocks(this);
+    this.statelessSpspReceiverLinkFactory = new StatelessSpspReceiverLinkFactory(
+        packetRejectorMock, statelessStreamReceiverMock
+    );
+  }
+
+  @Test
+  public void constructWithNulPacketRejector() {
+    expectedException.expect(NullPointerException.class);
+    expectedException.expectMessage("packetRejector must not be null");
+
+    new StatelessSpspReceiverLinkFactory(null, statelessStreamReceiverMock);
+  }
+
+  @Test
+  public void constructWithNulStatelessStreamReceiver() {
+    expectedException.expect(NullPointerException.class);
+    expectedException.expectMessage("statelessStreamReceiver must not be null");
+
+    new StatelessSpspReceiverLinkFactory(packetRejectorMock, null);
+  }
+
+  @Test
+  public void supports() {
+    assertThat(statelessSpspReceiverLinkFactory.supports(StatelessSpspReceiverLink.LINK_TYPE)).isEqualTo(true);
+    assertThat(statelessSpspReceiverLinkFactory.supports(LinkType.of("foo"))).isEqualTo(false);
+  }
+
+  @Test
+  public void constructLinkWithNullOperator() {
+    expectedException.expect(NullPointerException.class);
+    expectedException.expectMessage("operatorAddressSupplier must not be null");
+
+    statelessSpspReceiverLinkFactory.constructLink(null, linkSettingsMock);
+  }
+
+  @Test
+  public void constructLinkWithNullLinkSettings() {
+    expectedException.expect(NullPointerException.class);
+    expectedException.expectMessage("linkSettings must not be null");
+
+    statelessSpspReceiverLinkFactory.constructLink(() -> OPERATOR_ADDRESS, null);
+  }
+
+  @Test
+  public void constructLinkWithUnsupportedLinkType() {
+    expectedException.expect(LinkException.class);
+    expectedException.expectMessage("LinkType not supported by this factory. linkType=LinkType(FOO)");
+
+    LinkSettings linkSettings = LinkSettings.builder()
+        .linkType(LinkType.of("foo"))
+        .build();
+    statelessSpspReceiverLinkFactory.constructLink(() -> OPERATOR_ADDRESS, linkSettings);
+  }
+
+  @Test
+  public void constructLink() {
+    LinkSettings linkSettings = StatelessSpspReceiverLinkSettings.builder()
+        .assetCode("USD")
+        .assetScale((short) 9)
+        .build();
+    StatelessSpspReceiverLink link = (StatelessSpspReceiverLink) statelessSpspReceiverLinkFactory
+        .constructLink(() -> OPERATOR_ADDRESS, linkSettings);
+
+    assertThat(link.getLinkSettings().assetCode()).isEqualTo("USD");
+    assertThat(link.getLinkSettings().assetScale()).isEqualTo(9);
+    assertThat(link.getOperatorAddressSupplier().get()).isEqualTo(OPERATOR_ADDRESS);
+  }
+}
diff --git a/link-parent/link-stateless-spsp-receiver/src/test/java/org/interledger/link/spsp/StatelessSpspReceiverLinkTest.java b/link-parent/link-stateless-spsp-receiver/src/test/java/org/interledger/link/spsp/StatelessSpspReceiverLinkTest.java
new file mode 100644
index 0000000..ffc9006
--- /dev/null
+++ b/link-parent/link-stateless-spsp-receiver/src/test/java/org/interledger/link/spsp/StatelessSpspReceiverLinkTest.java
@@ -0,0 +1,105 @@
+package org.interledger.link.spsp;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.interledger.core.InterledgerConstants.ALL_ZEROS_FULFILLMENT;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import org.interledger.core.DateUtils;
+import org.interledger.core.InterledgerAddress;
+import org.interledger.core.InterledgerConstants;
+import org.interledger.core.InterledgerFulfillPacket;
+import org.interledger.core.InterledgerPreparePacket;
+import org.interledger.link.LinkId;
+import org.interledger.stream.receiver.StreamReceiver;
+
+import com.google.common.primitives.UnsignedLong;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Unit tests for {@link StatelessSpspReceiverLink}.
+ */
+public class StatelessSpspReceiverLinkTest {
+
+  private static final InterledgerAddress OPERATOR_ADDRESS = InterledgerAddress.of("test.foo");
+
+  private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+  @Rule
+  public ExpectedException expectedException = ExpectedException.none();
+
+  @Mock
+  private StreamReceiver streamReceiverMock;
+
+  private StatelessSpspReceiverLink link;
+
+  @Before
+  public void setUp() {
+    MockitoAnnotations.initMocks(this);
+
+    this.link = new StatelessSpspReceiverLink(
+        () -> OPERATOR_ADDRESS,
+        StatelessSpspReceiverLinkSettings.builder().assetCode("XRP").assetScale((short) 9).build(),
+        streamReceiverMock
+    );
+    link.setLinkId(LinkId.of("foo"));
+  }
+
+  @Test(expected = RuntimeException.class)
+  public void registerLinkHandler() {
+    try {
+      link.registerLinkHandler(incomingPreparePacket -> null);
+    } catch (Exception e) {
+      assertThat(e.getMessage())
+          .isEqualTo("StatelessSpspReceiver links never emit data, and thus should not have a registered DataHandler.");
+      throw e;
+    }
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void sendPacketWithNull() {
+    try {
+      link.sendPacket(null);
+    } catch (NullPointerException e) {
+      assertThat(e.getMessage()).isEqualTo("preparePacket must not be null");
+      throw e;
+    }
+  }
+
+  @Test
+  public void sendPacket() {
+    final InterledgerFulfillPacket actualFulfillPacket = InterledgerFulfillPacket.builder()
+        .fulfillment(ALL_ZEROS_FULFILLMENT)
+        .build();
+    when(streamReceiverMock.receiveMoney(any(), any(), any())).thenReturn(actualFulfillPacket);
+
+    final InterledgerPreparePacket preparePacket = preparePacket();
+    link.sendPacket(preparePacket).handle(
+        fulfillPacket -> {
+          assertThat(fulfillPacket).isEqualTo(actualFulfillPacket);
+        },
+        rejectPacket -> {
+          logger.error("rejectPacket={}", rejectPacket);
+          fail("Expected a Fulfill");
+        }
+    );
+  }
+
+  private InterledgerPreparePacket preparePacket() {
+    return InterledgerPreparePacket.builder()
+        .amount(UnsignedLong.valueOf(10L))
+        .executionCondition(InterledgerConstants.ALL_ZEROS_CONDITION)
+        .destination(OPERATOR_ADDRESS)
+        .expiresAt(DateUtils.now())
+        .data(new byte[32])
+        .build();
+  }
+}