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 2a80676c7e2ba3c13cdc1c865ae41906610ab541..8ee70c1db1ffb0316ba79ec27b543c4c715cecb3 100644
--- a/router/java/src/net/i2p/router/transport/udp/EstablishmentManager.java
+++ b/router/java/src/net/i2p/router/transport/udp/EstablishmentManager.java
@@ -1,5 +1,6 @@
 package net.i2p.router.transport.udp;
 
+import java.net.DatagramPacket;
 import java.net.InetAddress;
 import java.net.UnknownHostException;
 import java.security.GeneralSecurityException;
@@ -10,7 +11,10 @@ import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
 
+import com.southernstorm.noise.protocol.ChaChaPolyCipherState;
+
 import net.i2p.data.Base64;
+import net.i2p.data.DataHelper;
 import net.i2p.data.Hash;
 import net.i2p.data.router.RouterAddress;
 import net.i2p.data.router.RouterIdentity;
@@ -27,9 +31,11 @@ import net.i2p.router.transport.TransportUtil;
 import net.i2p.router.transport.crypto.DHSessionKeyBuilder;
 import static net.i2p.router.transport.udp.InboundEstablishState.InboundState.*;
 import static net.i2p.router.transport.udp.OutboundEstablishState.OutboundState.*;
+import static net.i2p.router.transport.udp.SSU2Util.*;
 import net.i2p.router.util.DecayingHashSet;
 import net.i2p.router.util.DecayingBloomFilter;
 import net.i2p.util.Addresses;
+import net.i2p.util.HexDump;
 import net.i2p.util.I2PThread;
 import net.i2p.util.LHMCache;
 import net.i2p.util.Log;
@@ -1371,6 +1377,8 @@ class EstablishmentManager {
      *  Called from UDPReceiver.
      *  Accelerate response to RelayResponse if we haven't sent it yet.
      *
+     *  SSU 1 only.
+     *
      *  @since 0.9.15
      */
     void receiveHolePunch(InetAddress from, int fromPort) {
@@ -1393,6 +1401,66 @@ class EstablishmentManager {
         }
     }
 
+    /**
+     *  Called from PacketHandler.
+     *  Accelerate response to RelayResponse if we haven't sent it yet.
+     *
+     *  SSU 2 only.
+     *
+     *  @param id non-null
+     *  @param packet header already decrypted
+     *  @since 0.9.55
+     */
+    void receiveHolePunch(RemoteHostId id, UDPPacket packet) {
+        DatagramPacket pkt = packet.getPacket();
+        int off = pkt.getOffset();
+        int len = pkt.getLength();
+        byte data[] = pkt.getData();
+        long rcvConnID = DataHelper.fromLong8(data, off);
+        long sendConnID = DataHelper.fromLong8(data, off + SRC_CONN_ID_OFFSET);
+        int type = data[off + TYPE_OFFSET] & 0xff;
+        if (type != HOLE_PUNCH_FLAG_BYTE)
+            return;
+        byte[] introKey = _transport.getSSU2StaticIntroKey();
+        ChaChaPolyCipherState chacha = new ChaChaPolyCipherState();
+        chacha.initializeKey(introKey, 0);
+        long n = DataHelper.fromLong(data, off + PKT_NUM_OFFSET, 4);
+        chacha.setNonce(n);
+        try {
+            // decrypt in-place
+            chacha.decryptWithAd(data, off, LONG_HEADER_SIZE,
+                                 data, off + LONG_HEADER_SIZE, data, off + LONG_HEADER_SIZE, len - LONG_HEADER_SIZE);
+            int payloadLen = len - (LONG_HEADER_SIZE + MAC_LEN);
+            SSU2Payload.PayloadCallback cb = new HPCallback(id);
+            SSU2Payload.processPayload(_context, cb, data, off + LONG_HEADER_SIZE, payloadLen, false);
+            // TODO process cb fields
+        } catch (Exception e) {
+            if (_log.shouldWarn())
+                _log.warn("Bad HolePunch packet:\n" + HexDump.dump(data, off, len), e);
+            return;
+        } finally {
+            chacha.destroy();
+        }
+
+        // TODO now we can look up by nonce instead if we want
+        OutboundEstablishState state = _outboundStates.get(id);
+        if (state != null) {
+            boolean sendNow = state.receiveHolePunch();
+            if (sendNow) {
+                if (_log.shouldDebug())
+                    _log.debug("Hole punch from " + state + ", sending SessionRequest now");
+                notifyActivity();
+            } else {
+                if (_log.shouldLog(Log.INFO))
+                    _log.info("Hole punch from " + state + ", already sent SessionRequest");
+            }
+        } else {
+            // HolePunch received before RelayResponse, and we didn't know the IP/port, or it changed
+            if (_log.shouldLog(Log.INFO))
+                _log.info("No state found for hole punch from " + id);
+        }
+    }
+
     /**
      *  Are IP and port valid? This is only for checking the relay response.
      *  Allow IPv6 as of 0.9.50.
@@ -1963,6 +2031,89 @@ class EstablishmentManager {
         }
     }
 
+    /**
+     *  Process SSU2 hole punch payload
+     *
+     *  @since 0.9.55
+     */
+    private class HPCallback implements SSU2Payload.PayloadCallback {
+        private final RemoteHostId _from;
+        public long _timeReceived;
+        public byte[] _aliceIP;
+        public int _alicePort;
+        public int _respCode;
+        public byte[] _respData;
+
+        public HPCallback(RemoteHostId from) {
+            _from = from;
+        }
+
+        public void gotDateTime(long time) {
+            _timeReceived = time;
+        }
+
+        public void gotOptions(byte[] options, boolean isHandshake) {}
+
+        public void gotRI(RouterInfo ri, boolean isHandshake, boolean flood) {
+            throw new IllegalStateException("Bad block in HP");
+        }
+
+        public void gotRIFragment(byte[] data, boolean isHandshake, boolean flood, boolean isGzipped, int frag, int totalFrags) {
+            throw new IllegalStateException("Bad block in HP");
+        }
+
+        public void gotAddress(byte[] ip, int port) {
+            _aliceIP = ip;
+            _alicePort = port;
+        }
+
+        public void gotRelayTagRequest() {
+            throw new IllegalStateException("Bad block in HP");
+        }
+
+        public void gotRelayTag(long tag) {
+            throw new IllegalStateException("Bad block in HP");
+        }
+
+        public void gotRelayRequest(byte[] data) {
+            throw new IllegalStateException("Bad block in HP");
+        }
+
+        public void gotRelayResponse(int status, byte[] data) {
+            _respCode = status;
+            _respData = data;
+        }
+
+        public void gotRelayIntro(Hash aliceHash, byte[] data) {
+            throw new IllegalStateException("Bad block in HP");
+        }
+
+        public void gotPeerTest(int msg, int status, Hash h, byte[] data) {
+            throw new IllegalStateException("Bad block in HP");
+        }
+
+        public void gotToken(long token, long expires) {
+            throw new IllegalStateException("Bad block in HP");
+        }
+
+        public void gotI2NP(I2NPMessage msg) {
+            throw new IllegalStateException("Bad block in HP");
+        }
+
+        public void gotFragment(byte[] data, int off, int len, long messageId,int frag, boolean isLast) {
+            throw new IllegalStateException("Bad block in HP");
+        }
+
+        public void gotACK(long ackThru, int acks, byte[] ranges) {
+            throw new IllegalStateException("Bad block in HP");
+        }
+
+        public void gotTermination(int reason, long count) {
+            throw new IllegalStateException("Bad block in HP");
+        }
+    }
+
+
     //// End SSU 2 ////
 
 
diff --git a/router/java/src/net/i2p/router/transport/udp/PacketHandler.java b/router/java/src/net/i2p/router/transport/udp/PacketHandler.java
index 4d1f03c2837dda9ee373ca1ccba3fddc66a2be6e..5bf7f266dd051f43b0492de5f94e5485bbb38806 100644
--- a/router/java/src/net/i2p/router/transport/udp/PacketHandler.java
+++ b/router/java/src/net/i2p/router/transport/udp/PacketHandler.java
@@ -810,7 +810,7 @@ class PacketHandler {
         SSU2Header.Header header;
         int type;
         if (state == null) {
-            // Session Request, Token Request, or Peer Test
+            // Session Request, Token Request, Peer Test 5-7, or Hole Punch
             k2 = k1;
             header = SSU2Header.trialDecryptHandshakeHeader(packet, k1, k2);
             if (header == null ||
@@ -818,7 +818,7 @@ class PacketHandler {
                 header.getVersion() != 2 ||
                 header.getNetID() != _networkID) {
                 if (header != null && _log.shouldInfo())
-                    _log.info("Does not decrypt as Session Request, attempt to decrypt as Token Request/Peer Test: " + header + " from " + from);
+                    _log.info("Does not decrypt as Session Request, attempt to decrypt as TokenRequest/PeerTest/HolePunch: " + header + " from " + from);
                 // The first 32 bytes were fine, but it corrupted the next 32 bytes
                 // TODO make this more efficient, just take the first 32 bytes
                 header = SSU2Header.trialDecryptLongHeader(packet, k1, k2);
@@ -916,6 +916,11 @@ class PacketHandler {
                 _log.debug("Got a Peer Test");
             if (SSU2Util.ENABLE_PEER_TEST)
                 _testManager.receiveTest(from, packet);
+        } else if (type == SSU2Util.HOLE_PUNCH_FLAG_BYTE) {
+            if (_log.shouldDebug())
+                _log.debug("Got a Hole Punch");
+            if (SSU2Util.ENABLE_RELAY)
+                _establisher.receiveHolePunch(from, packet);
         } else {
             if (_log.shouldWarn())
                 _log.warn("Got unknown message " + header + " on " + state);