diff --git a/router/java/src/net/i2p/data/i2np/I2NPMessageImpl.java b/router/java/src/net/i2p/data/i2np/I2NPMessageImpl.java
index 91acead40ebb4c94f7d91dd2f1e8f443298415b2..a9f29464ab6bafff94b11da6506fe62aa98809cf 100644
--- a/router/java/src/net/i2p/data/i2np/I2NPMessageImpl.java
+++ b/router/java/src/net/i2p/data/i2np/I2NPMessageImpl.java
@@ -445,6 +445,37 @@ public abstract class I2NPMessageImpl extends DataStructureImpl implements I2NPM
         }
     }
 
+    /**
+     *  Read the message with a short 9-byte header.
+     *  THe header consists of a one-byte type, 4-byte ID, and a 4-byte expiration in seconds only.
+     *  Used by NTCP2 only!
+     *  @since 0.9.35
+     */
+    public static I2NPMessage fromRawByteArrayNTCP2(I2PAppContext ctx, byte buffer[], int offset,
+                                                    int len, I2NPMessageHandler handler) throws I2NPMessageException {
+        int type = buffer[offset] & 0xff;
+        offset++;
+        I2NPMessage msg = createMessage(ctx, type);
+        if (msg == null)
+            throw new I2NPMessageException("Unknown message type: " + type);
+
+        try {
+            long id = DataHelper.fromLong(buffer, offset, 4);
+            offset += 4;
+            // January 19 2038? No, unsigned, good until Feb. 7 2106
+            // in seconds, round up so we don't lose time every hop
+            long expiration = (DataHelper.fromLong(buffer, offset, 4) * 1000) + 500;
+            offset += 4;
+            int dataSize = len - 9;
+            msg.readMessage(buffer, offset, dataSize, type, handler);
+            msg.setUniqueId(id);
+            msg.setMessageExpiration(expiration);
+            return msg;
+        } catch (IllegalArgumentException iae) {
+            throw new I2NPMessageException("Corrupt message (negative expiration)", iae);
+        }
+    }
+
     /**
      * Yes, this is fairly ugly, but its the only place it ever happens.
      *
diff --git a/router/java/src/net/i2p/router/transport/ntcp/NTCP2Payload.java b/router/java/src/net/i2p/router/transport/ntcp/NTCP2Payload.java
new file mode 100644
index 0000000000000000000000000000000000000000..6dc7759830dff3ae5943ec3eb0b7bc604e13575b
--- /dev/null
+++ b/router/java/src/net/i2p/router/transport/ntcp/NTCP2Payload.java
@@ -0,0 +1,229 @@
+package net.i2p.router.transport.ntcp;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.util.List;
+
+import net.i2p.I2PAppContext;
+import net.i2p.data.DataFormatException;
+import net.i2p.data.DataHelper;
+import net.i2p.data.i2np.I2NPMessage;
+import net.i2p.data.i2np.I2NPMessageException;
+import net.i2p.data.i2np.I2NPMessageImpl;
+import net.i2p.data.router.RouterInfo;
+
+/**
+ *
+ *  NTCP2 Payload generation and parsing
+ *
+ *  @since 0.9.35
+ */
+class NTCP2Payload {
+
+    private static final int BLOCK_DATETIME = 0;
+    private static final int BLOCK_OPTIONS = 1;
+    private static final int BLOCK_ROUTERINFO = 2;
+    private static final int BLOCK_I2NP = 3;
+    private static final int BLOCK_TERMINATION = 4;
+    private static final int BLOCK_PADDING = 254;
+
+    public interface PayloadCallback {
+        public void gotDateTime(long time);
+        public void gotI2NP(I2NPMessage msg);
+        public void gotOptions(byte[] options, boolean isHandshake);
+        public void gotRI(RouterInfo ri, boolean isHandshake);
+        public void gotTermination(int reason);
+        public void gotUnknown(int type, int len);
+    }
+
+    /**
+     *  Incoming payload. Calls the callback for each received block.
+     *
+     *  @return number of blocks processed
+     *  @throws IOException on major errors
+     *  @throws DataFormatException on parsing of individual blocks
+     *  @throws I2NPMessageException on parsing of I2NP block
+     */
+    public static int processPayload(I2PAppContext ctx, PayloadCallback cb,
+                                     byte[] payload, int length, boolean isHandshake)
+                                    throws IOException, DataFormatException, I2NPMessageException {
+        int blocks = 0;
+        boolean gotRI = false;
+        boolean gotPadding = false;
+        int i = 0;
+        while (i < length) {
+            int type = payload[i++] & 0xff;
+            if (gotPadding)
+                throw new IOException("Illegal block after padding: " + type);
+            int len = (int) DataHelper.fromLong(payload, i, 2);
+            i += 2;
+            if (i + len > length)
+                throw new IOException("Block runs over frame");
+            switch (type) {
+                case BLOCK_DATETIME:
+                    if (isHandshake)
+                        throw new IOException("Illegal block in handshake: " + type);
+                    if (len != 4)
+                        throw new IOException("Bad length for DATETIME: " + len);
+                    long time = DataHelper.fromLong(payload, i, 4) * 1000;
+                    cb.gotDateTime(time);
+                    break;
+
+                case BLOCK_OPTIONS:
+                    byte[] options = null;
+                    cb.gotOptions(options, isHandshake);
+                    break;
+
+                case BLOCK_ROUTERINFO:
+                    int flag = payload[i] & 0xff;
+                    RouterInfo alice = new RouterInfo();
+                    // TODO limit
+                    ByteArrayInputStream bais = new ByteArrayInputStream(payload, i + 1, len - 1);
+                    alice.readBytes(bais, true);
+                    // TODO validate Alice static key, pass back somehow
+                    cb.gotRI(alice, isHandshake);
+                    gotRI = true;
+                    break;
+
+                case BLOCK_I2NP:
+                    if (isHandshake)
+                        throw new IOException("Illegal block in handshake: " + type);
+                    I2NPMessage msg = I2NPMessageImpl.fromRawByteArrayNTCP2(ctx, payload, i, len, null);
+                    cb.gotI2NP(msg);
+                    break;
+
+                case BLOCK_TERMINATION:
+                    if (isHandshake)
+                        throw new IOException("Illegal block in handshake: " + type);
+                    if (len != 9)
+                        throw new IOException("Bad length for TERMINATION: " + len);
+                    int rsn = payload[i] & 0xff;
+                    cb.gotTermination(rsn);
+                    break;
+
+                case BLOCK_PADDING:
+                    gotPadding = true;
+                    break;
+
+                default:
+                    if (isHandshake)
+                        throw new IOException("Illegal block in handshake: " + type);
+                    cb.gotUnknown(type, len);
+                    break;
+
+            }
+            i += len;
+            blocks++;
+        }
+        if (isHandshake && !gotRI)
+            throw new IOException("No RI block in handshake");
+        return blocks;
+    }
+
+    /**
+     *  @param payload writes to it starting at off
+     *  @return the new offset
+     */
+    public int writePayload(byte[] payload, int off, List<Block> blocks) {
+        for (Block block : blocks) {
+            off = block.write(payload, off);
+        }
+        return off;
+    }
+
+    public static abstract class Block {
+        private final int type;
+
+        public Block(int ttype) {
+            type = ttype;
+        }
+
+        public int write(byte[] tgt, int off) {
+            tgt[off++] = (byte) type;
+            DataHelper.toLong(tgt, off, 2, getDataLength());
+            return writeData(tgt, off + 2);
+        }
+
+        public abstract int getDataLength();
+
+        public abstract int writeData(byte[] tgt, int off);
+    }
+
+    public static class RIBlock extends Block {
+        private final byte[] data;
+
+        public RIBlock(RouterInfo ri) {
+            super(BLOCK_ROUTERINFO);
+            data = ri.toByteArray();
+        }
+
+        public int getDataLength() {
+            return 1 + data.length;
+        }
+
+        public int writeData(byte[] tgt, int off) {
+            tgt[off++] = 0;    // flag
+            System.arraycopy(data, 0, tgt, off, data.length);
+            return off + data.length;
+        }
+    }
+
+    public static class I2NPBlock extends Block {
+        private final byte[] data;
+
+        public I2NPBlock(I2NPMessage msg) {
+            super(BLOCK_I2NP);
+            data = msg.toByteArray();
+        }
+
+        public int getDataLength() {
+            return data.length - 7;
+        }
+
+        public int writeData(byte[] tgt, int off) {
+            // type, ID, first 4 bytes of exp
+            System.arraycopy(data, 0, tgt, off, 9);
+            // skip last 4 bytes of exp, sz, checksum
+            System.arraycopy(data, 16, tgt, off + 9, data.length - 16);
+            return off + data.length - 7;
+        }
+    }
+
+    public static class PaddingBlock extends Block {
+        private final byte[] data;
+
+        public PaddingBlock(I2PAppContext ctx, int size) {
+            super(BLOCK_PADDING);
+            data = new byte[size];
+            if (size > 0)
+                ctx.random().nextBytes(data);
+        }
+
+        public int getDataLength() {
+            return data.length;
+        }
+
+        public int writeData(byte[] tgt, int off) {
+            System.arraycopy(tgt, off, data, 0, data.length);
+            return off + data.length;
+        }
+    }
+
+    public static class DateTimeBlock extends Block {
+        private final long now;
+
+        public DateTimeBlock(I2PAppContext ctx) {
+            super(BLOCK_DATETIME);
+            now = ctx.clock().now();
+        }
+
+        public int getDataLength() {
+            return 4;
+        }
+
+        public int writeData(byte[] tgt, int off) {
+            DataHelper.toLong(tgt, off, 4, now / 1000);
+            return off + 4;
+        }
+    }
+}