diff --git a/router/java/src/net/i2p/router/transport/udp/EstablishmentManager.java b/router/java/src/net/i2p/router/transport/udp/EstablishmentManager.java
index 72e249ba4ed39999b67ba74be12ac82ae3cbd4cc..07c89f40e6f7e40e850ee9c9ec06faa3edf26c83 100644
--- a/router/java/src/net/i2p/router/transport/udp/EstablishmentManager.java
+++ b/router/java/src/net/i2p/router/transport/udp/EstablishmentManager.java
@@ -638,27 +638,21 @@ class EstablishmentManager {
         }
 
         if (isNew) {
-          /**** TODO
-            // Don't offer to relay to privileged ports.
-            // Only offer for an IPv4 session.
-            // TODO if already we have their RI, only offer if they need it (no 'C' cap)
-            // if extended options, only if they asked for it
-            if (state.isIntroductionRequested() &&
-                state.getSentPort() >= 1024 &&
-                _transport.canIntroduce(state.getSentIP().length == 16)) {
-                // ensure > 0
-                long tag = 1 + _context.random().nextLong(MAX_TAG_VALUE);
-                state.setSentRelayTag(tag);
-            } else {
-                // we got an IB even though we were firewalled, hidden, not high cap, etc.
-            }
-          ****/
             if (_log.shouldInfo())
                 _log.info("Received NEW session/token request " + state);
         } else {
             if (_log.shouldDebug())
                 _log.debug("Receive DUP session/token request from: " + state);
         }
+        // call for both Session and Token request, why not
+        if (SSU2Util.ENABLE_RELAY &&
+            state.isIntroductionRequested() &&
+            state.getSentRelayTag() == 0 &&     // only set once
+            state.getSentPort() >= 1024 &&
+            _transport.canIntroduce(state.getSentIP().length == 16)) {
+            long tag = 1 + _context.random().nextLong(MAX_TAG_VALUE);
+            state.setSentRelayTag(tag);
+        }
         notifyActivity();
     }
     
@@ -1059,7 +1053,6 @@ class EstablishmentManager {
                                  state.getSentIP(), state.getSentPort(), remote.calculateHash(), false, state.getRTT());
             peer.setCurrentCipherKey(state.getCipherKey());
             peer.setCurrentMACKey(state.getMACKey());
-            peer.setTheyRelayToUsAs(state.getReceivedRelayTag());
             int mtu = state.getRemoteAddress().getMTU();
             if (mtu > 0)
                 peer.setHisMTU(mtu);
@@ -1068,6 +1061,7 @@ class EstablishmentManager {
             // OES2 sets PS2 MTU
             peer = state2.getPeerState();
         }
+        peer.setTheyRelayToUsAs(state.getReceivedRelayTag());
         // 0 is the default
         //peer.setWeRelayToThemAs(0);
         
diff --git a/router/java/src/net/i2p/router/transport/udp/InboundEstablishState.java b/router/java/src/net/i2p/router/transport/udp/InboundEstablishState.java
index a45099c6dee0594c9c0a1ed2d2da2c9f905247bd..805476e77d6efee9cdde5f22bf3167569a86525e 100644
--- a/router/java/src/net/i2p/router/transport/udp/InboundEstablishState.java
+++ b/router/java/src/net/i2p/router/transport/udp/InboundEstablishState.java
@@ -63,8 +63,8 @@ class InboundEstablishState {
     private final Queue<OutNetMessage> _queuedMessages;
     // count for backoff
     protected int _createdSentCount;
-    // default true
-    protected boolean _introductionRequested = true;
+    // default true for SSU 1, false for SSU 2
+    protected boolean _introductionRequested;
 
     protected int _rtt;
     
@@ -127,6 +127,7 @@ class InboundEstablishState {
         _establishBegin = ctx.clock().now();
         _keyBuilder = dh;
         _queuedMessages = new LinkedBlockingQueue<OutNetMessage>();
+        _introductionRequested = true;
         receiveSessionRequest(req);
     }
 
diff --git a/router/java/src/net/i2p/router/transport/udp/InboundEstablishState2.java b/router/java/src/net/i2p/router/transport/udp/InboundEstablishState2.java
index b6d8b83b729141fe6f4fc02529fe80ccea285c53..3ad8298d1491e08a95d56b6db69a87d1aaff74fd 100644
--- a/router/java/src/net/i2p/router/transport/udp/InboundEstablishState2.java
+++ b/router/java/src/net/i2p/router/transport/udp/InboundEstablishState2.java
@@ -80,7 +80,6 @@ class InboundEstablishState2 extends InboundEstablishState implements SSU2Payloa
         _rcvHeaderEncryptKey1 = introKey;
         //_sendHeaderEncryptKey2 set below
         //_rcvHeaderEncryptKey2 set below
-        _introductionRequested = false; // todo
         int off = pkt.getOffset();
         int len = pkt.getLength();
         byte data[] = pkt.getData();
@@ -321,12 +320,43 @@ class InboundEstablishState2 extends InboundEstablishState implements SSU2Payloa
     }
 
     public void gotRelayTagRequest() {
+        if (!ENABLE_RELAY)
+            return;
         if (_log.shouldDebug())
             _log.debug("Got relay tag request");
+        _introductionRequested = true;
     }
 
     public void gotRelayTag(long tag) {
-        throw new IllegalStateException("Relay tag in Handshake");
+        // shouldn't happen for inbound
+    }
+
+    public void gotRelayRequest(byte[] data) {
+        if (!ENABLE_RELAY)
+            return;
+        if (_receivedConfirmedIdentity == null)
+            throw new IllegalStateException("RI must be first");
+    }
+
+    public void gotRelayResponse(int status, byte[] data) {
+        if (!ENABLE_RELAY)
+            return;
+        if (_receivedConfirmedIdentity == null)
+            throw new IllegalStateException("RI must be first");
+    }
+
+    public void gotRelayIntro(Hash aliceHash, byte[] data) {
+        if (!ENABLE_RELAY)
+            return;
+        if (_receivedConfirmedIdentity == null)
+            throw new IllegalStateException("RI must be first");
+    }
+
+    public void gotPeerTest(int msg, int status, Hash h, byte[] data) {
+        if (!ENABLE_PEER_TEST)
+            return;
+        if (_receivedConfirmedIdentity == null)
+            throw new IllegalStateException("RI must be first");
     }
 
     public void gotToken(long token, long expires) {
diff --git a/router/java/src/net/i2p/router/transport/udp/IntroductionManager.java b/router/java/src/net/i2p/router/transport/udp/IntroductionManager.java
index d31c4ae2ce0eac1a5d5e203fc402c38b83370779..88e9c258c4236e4afe0c725b05661449b4543b4d 100644
--- a/router/java/src/net/i2p/router/transport/udp/IntroductionManager.java
+++ b/router/java/src/net/i2p/router/transport/udp/IntroductionManager.java
@@ -126,7 +126,7 @@ class IntroductionManager {
     public void add(PeerState peer) {
         if (peer == null) return;
         // Skip SSU2 until we have support for relay
-        if (peer.getVersion() != 1)
+        if (peer.getVersion() != 1 && !SSU2Util.ENABLE_RELAY)
             return;
         // let's not use an introducer on a privileged port, sounds like trouble
         if (!TransportUtil.isValidPort(peer.getRemotePort()))
diff --git a/router/java/src/net/i2p/router/transport/udp/OutboundEstablishState.java b/router/java/src/net/i2p/router/transport/udp/OutboundEstablishState.java
index 29ef7b5d692231b10f2cac81b4dfd825b16ee5ea..3bd8011cbdef083def2eefe60a85869e48bc9a7d 100644
--- a/router/java/src/net/i2p/router/transport/udp/OutboundEstablishState.java
+++ b/router/java/src/net/i2p/router/transport/udp/OutboundEstablishState.java
@@ -37,7 +37,7 @@ class OutboundEstablishState {
     private byte _receivedY[];
     protected byte _aliceIP[];
     protected int _alicePort;
-    private long _receivedRelayTag;
+    protected long _receivedRelayTag;
     private long _receivedSignedOnTime;
     private SessionKey _sessionKey;
     private SessionKey _macKey;
diff --git a/router/java/src/net/i2p/router/transport/udp/OutboundEstablishState2.java b/router/java/src/net/i2p/router/transport/udp/OutboundEstablishState2.java
index c2cae324e2470ec077325868c2167c87efb14b4f..6edd9176a8208b817b5337e6ea2adac579e9c58a 100644
--- a/router/java/src/net/i2p/router/transport/udp/OutboundEstablishState2.java
+++ b/router/java/src/net/i2p/router/transport/udp/OutboundEstablishState2.java
@@ -16,6 +16,7 @@ import net.i2p.crypto.HKDF;
 import net.i2p.data.Base64;
 import net.i2p.data.DataFormatException;
 import net.i2p.data.DataHelper;
+import net.i2p.data.Hash;
 import net.i2p.data.SessionKey;
 import net.i2p.data.i2np.I2NPMessage;
 import net.i2p.data.router.RouterAddress;
@@ -215,8 +216,27 @@ class OutboundEstablishState2 extends OutboundEstablishState implements SSU2Payl
     }
 
     public void gotRelayTag(long tag) {
+        if (!ENABLE_RELAY)
+            return;
         if (_log.shouldDebug())
             _log.debug("Got relay tag " + tag);
+        _receivedRelayTag = tag;
+    }
+
+    public void gotRelayRequest(byte[] data) {
+        // won't be called, SSU2Payload will throw
+    }
+
+    public void gotRelayResponse(int status, byte[] data) {
+        // won't be called, SSU2Payload will throw
+    }
+
+    public void gotRelayIntro(Hash aliceHash, byte[] data) {
+        // won't be called, SSU2Payload will throw
+    }
+
+    public void gotPeerTest(int msg, int status, Hash h, byte[] data) {
+        // won't be called, SSU2Payload will throw
     }
 
     public void gotToken(long token, long expires) {
diff --git a/router/java/src/net/i2p/router/transport/udp/PeerState2.java b/router/java/src/net/i2p/router/transport/udp/PeerState2.java
index d7db35b360697bb2cc8e8e96ab1dd5b621d1fd39..d416810e4db181061d3c9a3d11c6b20e9f53634a 100644
--- a/router/java/src/net/i2p/router/transport/udp/PeerState2.java
+++ b/router/java/src/net/i2p/router/transport/udp/PeerState2.java
@@ -386,9 +386,41 @@ public class PeerState2 extends PeerState implements SSU2Payload.PayloadCallback
     }
 
     public void gotRelayTagRequest() {
+        if (!ENABLE_RELAY)
+            return;
     }
 
     public void gotRelayTag(long tag) {
+        if (!ENABLE_RELAY)
+            return;
+        long old = getTheyRelayToUsAs();
+        if (old != 0) {
+            if (_log.shouldWarn())
+                _log.warn("Got new tag " + tag + " but had previous tag " + old + " on " + this);
+            return;
+        }
+        setTheyRelayToUsAs(tag);
+        _transport.getIntroManager().add(this);
+    }
+
+    public void gotRelayRequest(byte[] data) {
+        if (!ENABLE_RELAY)
+            return;
+    }
+
+    public void gotRelayResponse(int status, byte[] data) {
+        if (!ENABLE_RELAY)
+            return;
+    }
+
+    public void gotRelayIntro(Hash aliceHash, byte[] data) {
+        if (!ENABLE_RELAY)
+            return;
+    }
+
+    public void gotPeerTest(int msg, int status, Hash h, byte[] data) {
+        if (!ENABLE_PEER_TEST)
+            return;
     }
 
     public void gotToken(long token, long expires) {
diff --git a/router/java/src/net/i2p/router/transport/udp/SSU2Payload.java b/router/java/src/net/i2p/router/transport/udp/SSU2Payload.java
index 356710370c6710feb950ee4cb1ccfec85a577698..ef3c671bda5c74d8dc12f9f6ef70b1fe5cd952ff 100644
--- a/router/java/src/net/i2p/router/transport/udp/SSU2Payload.java
+++ b/router/java/src/net/i2p/router/transport/udp/SSU2Payload.java
@@ -8,6 +8,7 @@ import java.util.List;
 import net.i2p.I2PAppContext;
 import net.i2p.data.DataFormatException;
 import net.i2p.data.DataHelper;
+import net.i2p.data.Hash;
 import net.i2p.data.i2np.I2NPMessage;
 import net.i2p.data.i2np.I2NPMessageException;
 import net.i2p.data.i2np.I2NPMessageImpl;
@@ -94,6 +95,30 @@ class SSU2Payload {
 
         public void gotRelayTag(long tag);
 
+        /**
+         *  @param data excludes flag, includes signature
+         */
+        public void gotRelayRequest(byte[] data);
+
+        /**
+         *  @param status 0 = accept, 1-255 = reject
+         *  @param data excludes flag, includes signature
+         */
+        public void gotRelayResponse(int status, byte[] data);
+
+        /**
+         *  @param data excludes flag, includes signature
+         */
+        public void gotRelayIntro(Hash aliceHash, byte[] data);
+
+        /**
+         *  @param msg 1-7
+         *  @param status 0 = accept, 1-255 = reject
+         *  @param h Alice or Charlie hash
+         *  @param data excludes flag, includes signature
+         */
+        public void gotPeerTest(int msg, int status, Hash h, byte[] data);
+
         public void gotToken(long token, long expires);
 
         /**
@@ -114,6 +139,7 @@ class SSU2Payload {
     /**
      *  Incoming payload. Calls the callback for each received block.
      *
+     *  @param isHandshake true for Token Req, Retry, Sess Req, Sess Created; false for Sess Confirmed
      *  @return number of blocks processed
      *  @throws IOException on major errors
      *  @throws DataFormatException on parsing of individual blocks
@@ -256,6 +282,55 @@ class SSU2Payload {
                     cb.gotRelayTag(tag);
                     break;
 
+                case BLOCK_RELAYREQ: {
+                    if (isHandshake)
+                        throw new IOException("Illegal block in handshake: " + type);
+                    if (len < 61) // 21 byte data w/ IPv4 + 40 byte DSA sig
+                        throw new IOException("Bad length for RELAYREQ: " + len);
+                    byte[] data = new byte[len - 1]; // skip flag
+                    System.arraycopy(payload, i + 1, data, 0, len - 1);
+                    cb.gotRelayRequest(data);
+                    break;
+                }
+
+                case BLOCK_RELAYRESP: {
+                    if (isHandshake)
+                        throw new IOException("Illegal block in handshake: " + type);
+                    if (len < 62) // 22 byte data w/ IPv4 + 40 byte DSA sig
+                        throw new IOException("Bad length for RELAYRESP: " + len);
+                    int resp = payload[i + 1] & 0xff; // skip flag
+                    byte[] data = new byte[len - 2];
+                    System.arraycopy(payload, i + 2, data, 0, len - 2);
+                    cb.gotRelayResponse(resp, data);
+                    break;
+                }
+
+                case BLOCK_RELAYINTRO: {
+                    if (isHandshake)
+                        throw new IOException("Illegal block in handshake: " + type);
+                    if (len < 93) // 32 byte hash + 21 byte data w/ IPv4 + 40 byte DSA sig
+                        throw new IOException("Bad length for RELAYINTRO: " + len);
+                    Hash h = Hash.create(payload, i + 1); // skip flag
+                    byte[] data = new byte[len - (1 + Hash.HASH_LENGTH)]; // skip flag
+                    System.arraycopy(payload, i + 1 + Hash.HASH_LENGTH, data, 0, data.length);
+                    cb.gotRelayIntro(h, data);
+                    break;
+                }
+
+                case BLOCK_PEERTEST: {
+                    if (isHandshake)
+                        throw new IOException("Illegal block in handshake: " + type);
+                    if (len < 92) // 32 byte hash + 20 byte data w/ IPv4 + 40 byte DSA sig
+                        throw new IOException("Bad length for PEERTEST: " + len);
+                    int mnum = payload[i] & 0xff;
+                    int resp = payload[i + 1] & 0xff;
+                    Hash h = Hash.create(payload, i + 3); // skip flag
+                    byte[] data = new byte[len - (3 + Hash.HASH_LENGTH)];
+                    System.arraycopy(payload, i + 3 + Hash.HASH_LENGTH, data, 0, data.length);
+                    cb.gotPeerTest(mnum, resp, h, data);
+                    break;
+                }
+
                 case BLOCK_NEWTOKEN:
                     if (len < 12)
                         throw new IOException("Bad length for NEWTOKEN: " + len);
@@ -644,6 +719,78 @@ class SSU2Payload {
         }
     }
 
+    public static class RelayRequestBlock extends Block {
+        private final byte[] d;
+
+        public RelayRequestBlock(byte[] data) {
+            super(BLOCK_RELAYREQ);
+            d = data;
+        }
+
+        public int getDataLength() {
+            return d.length;
+        }
+
+        public int writeData(byte[] tgt, int off) {
+            System.arraycopy(d, 0, tgt, off, d.length);
+            return off + d.length;
+        }
+    }
+
+    public static class RelayResponseBlock extends Block {
+        private final byte[] d;
+
+        public RelayResponseBlock(byte[] data) {
+            super(BLOCK_RELAYRESP);
+            d = data;
+        }
+
+        public int getDataLength() {
+            return d.length;
+        }
+
+        public int writeData(byte[] tgt, int off) {
+            System.arraycopy(d, 0, tgt, off, d.length);
+            return off + d.length;
+        }
+    }
+
+    public static class RelayIntroBlock extends Block {
+        private final byte[] d;
+
+        public RelayIntroBlock(byte[] data) {
+            super(BLOCK_RELAYINTRO);
+            d = data;
+        }
+
+        public int getDataLength() {
+            return d.length;
+        }
+
+        public int writeData(byte[] tgt, int off) {
+            System.arraycopy(d, 0, tgt, off, d.length);
+            return off + d.length;
+        }
+    }
+
+    public static class PeerTestBlock extends Block {
+        private final byte[] d;
+
+        public PeerTestBlock(byte[] data) {
+            super(BLOCK_PEERTEST);
+            d = data;
+        }
+
+        public int getDataLength() {
+            return d.length;
+        }
+
+        public int writeData(byte[] tgt, int off) {
+            System.arraycopy(d, 0, tgt, off, d.length);
+            return off + d.length;
+        }
+    }
+
     public static class NewTokenBlock extends Block {
         private final long t, e;
 
diff --git a/router/java/src/net/i2p/router/transport/udp/SSU2Util.java b/router/java/src/net/i2p/router/transport/udp/SSU2Util.java
index d8eacf585d3db0e0a5c25134c64c9245cdbac1e7..51c6e78eae8b9bd4587ee386a757dabf005a987c 100644
--- a/router/java/src/net/i2p/router/transport/udp/SSU2Util.java
+++ b/router/java/src/net/i2p/router/transport/udp/SSU2Util.java
@@ -3,6 +3,12 @@ package net.i2p.router.transport.udp;
 import net.i2p.I2PAppContext;
 import net.i2p.crypto.EncType;
 import net.i2p.crypto.HKDF;
+import net.i2p.crypto.SigType;
+import net.i2p.data.DataHelper;
+import net.i2p.data.Hash;
+import net.i2p.data.Signature;
+import net.i2p.data.SigningPrivateKey;
+import net.i2p.data.SigningPublicKey;
 
 /**
  *  SSU2 Utils and constants
@@ -12,6 +18,11 @@ import net.i2p.crypto.HKDF;
 final class SSU2Util {
     public static final int PROTOCOL_VERSION = 2;
 
+    // features
+    public static final boolean ENABLE_RELAY = false;
+    public static final boolean ENABLE_PEER_TEST = false;
+    public static final boolean ENABLE_PATH_CHALLENGE = false;
+
     // lengths
     /** 32 */
     public static final int KEY_LEN = EncType.ECIES_X25519.getPubkeyLen();
@@ -87,6 +98,12 @@ final class SSU2Util {
     public static final byte[] ZEROLEN = new byte[0];
     public static final byte[] ZEROKEY = new byte[KEY_LEN];
 
+    // relay and peer test
+    public static final byte[] RELAY_REQUEST_PROLOGUE = DataHelper.getASCII("RelayRequestData");
+    public static final byte[] RELAY_RESPONSE_PROLOGUE = DataHelper.getASCII("RelayAgreementOK");
+    public static final byte[] PEER_TEST_PROLOGUE = DataHelper.getASCII("PeerTestValidate");
+
+
     private SSU2Util() {}
 
     /**
@@ -98,4 +115,37 @@ final class SSU2Util {
         hkdf.calculate(key, ZEROLEN, info, rv);
         return rv;
     }
+
+    /**
+     *  Sign the relay or peer test data, using
+     *  the prologue and hash as the initial data,
+     *  and then the provided data.
+     *
+     *  @return null on failure
+     */
+    public static Signature sign(I2PAppContext ctx, byte[] prologue, Hash h, byte[] data, SigningPrivateKey spk) {
+        byte[] buf = new byte[prologue.length + Hash.HASH_LENGTH + data.length];
+        System.arraycopy(prologue, 0, buf, 0, prologue.length);
+        System.arraycopy(h.getData(), 0, buf, prologue.length, Hash.HASH_LENGTH);
+        System.arraycopy(data, 0, buf, prologue.length + Hash.HASH_LENGTH, data.length);
+        return ctx.dsa().sign(buf, spk);
+    }
+
+    /**
+     *  Validate the signed relay or peer test data, using
+     *  the prologue and hash as the initial data,
+     *  and then the provided data which ends with a signature of the specified type.
+     */
+    public static boolean validateSig(I2PAppContext ctx, byte[] prologue, Hash h, byte[] data, SigningPublicKey spk) {
+        SigType type = spk.getType();
+        int siglen = type.getSigLen();
+        byte[] buf = new byte[prologue.length + Hash.HASH_LENGTH + data.length - siglen];
+        System.arraycopy(prologue, 0, buf, 0, prologue.length);
+        System.arraycopy(h.getData(), 0, buf, prologue.length, Hash.HASH_LENGTH);
+        System.arraycopy(data, 0, buf, prologue.length + Hash.HASH_LENGTH, data.length - siglen);
+        byte[] bsig = new byte[siglen];
+        System.arraycopy(data, data.length - siglen, bsig, 0, siglen);
+        Signature sig = new Signature(type, bsig);
+        return ctx.dsa().verifySignature(sig, buf, spk);
+    }
 }
diff --git a/router/java/src/net/i2p/router/transport/udp/UDPTransport.java b/router/java/src/net/i2p/router/transport/udp/UDPTransport.java
index e0f6c30af0138c15bcc67fd3c6f66f53bcdccf6d..2f07e7b31ff44db7adf348ce8466003b4eb64266 100644
--- a/router/java/src/net/i2p/router/transport/udp/UDPTransport.java
+++ b/router/java/src/net/i2p/router/transport/udp/UDPTransport.java
@@ -3433,6 +3433,20 @@ public class UDPTransport extends TransportImpl implements TimedWeightedPriority
         return _packetBuilder2;
     }
 
+    /**
+     *  @since 0.9.54
+     */
+    IntroductionManager getIntroManager() {
+        return _introManager;
+    }
+
+    /**
+     *  @since 0.9.54
+     */
+    PeerTestManager getPeerTestManager() {
+        return _testManager;
+    }
+
     /**
      * Does nothing
      * @deprecated as of 0.9.31