package sphinx import ( "bytes" "crypto/ecdsa" "crypto/hmac" "crypto/sha256" "fmt" "io" "math/big" "github.com/btcsuite/btcd/btcec" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcutil" ) const ( // addressSize is the length of the serialized address used to uniquely // identify the next hop to forward the onion to. BOLT 04 defines this // as 8 byte channel_id. AddressSize = 8 // RealmByteSize is the number of bytes that the realm byte occupies. RealmByteSize = 1 // AmtForwardSize is the number of bytes that the amount to forward // occupies. AmtForwardSize = 8 // OutgoingCLTVSize is the number of bytes that the outgoing CLTV value // occupies. OutgoingCLTVSize = 4 // NumPaddingBytes is the number of padding bytes in the hopData. These // bytes are currently unused within the protocol, and are reserved for // future use. However, if a hop contains extra data, then we'll // utilize this space to pack in the unrolled bytes. NumPaddingBytes = 12 // LegacyHopDataSize is the fixed size of hop_data. BOLT 04 currently // specifies this to be 1 byte realm, 8 byte channel_id, 8 byte amount // to forward, 4 byte outgoing CLTV value, 12 bytes padding and 32 bytes // HMAC for a total of 65 bytes per hop. LegacyHopDataSize = (RealmByteSize + AddressSize + AmtForwardSize + OutgoingCLTVSize + NumPaddingBytes + HMACSize) // MaxPayloadSize is the maximum size a payload for a single hop can be. // This is the worst case scenario of a single hop, consuming all // available space. We need to know this in order to generate a // sufficiently long stream of pseudo-random bytes when // encrypting/decrypting the payload. MaxPayloadSize = routingInfoSize // routingInfoSize is the fixed size of the the routing info. This // consists of a addressSize byte address and a HMACSize byte HMAC for // each hop of the route, the first pair in cleartext and the following // pairs increasingly obfuscated. If not all space is used up, the // remainder is padded with null-bytes, also obfuscated. routingInfoSize = 1300 // numStreamBytes is the number of bytes produced by our CSPRG for the // key stream implementing our stream cipher to encrypt/decrypt the mix // header. The MaxPayloadSize bytes at the end are used to // encrypt/decrypt the fillers when processing the packet of generating // the HMACs when creating the packet. numStreamBytes = routingInfoSize * 2 // keyLen is the length of the keys used to generate cipher streams and // encrypt payloads. Since we use SHA256 to generate the keys, the // maximum length currently is 32 bytes. keyLen = 32 // baseVersion represent the current supported version of onion packet. baseVersion = 0 ) var ( ErrMaxRoutingInfoSizeExceeded = fmt.Errorf( "max routing info size of %v bytes exceeded", routingInfoSize) ) // OnionPacket is the onion wrapped hop-to-hop routing information necessary to // propagate a message through the mix-net without intermediate nodes having // knowledge of their position within the route, the source, the destination, // and finally the identities of the past/future nodes in the route. At each // hop the ephemeral key is used by the node to perform ECDH between itself and // the source node. This derived secret key is used to check the MAC of the // entire mix header, decrypt the next set of routing information, and // re-randomize the ephemeral key for the next node in the path. This per-hop // re-randomization allows us to only propagate a single group element through // the onion route. type OnionPacket struct { // Version denotes the version of this onion packet. The version // indicates how a receiver of the packet should interpret the bytes // following this version byte. Currently, a version of 0x00 is the // only defined version type. Version byte // EphemeralKey is the public key that each hop will used in // combination with the private key in an ECDH to derive the shared // secret used to check the HMAC on the packet and also decrypted the // routing information. EphemeralKey *btcec.PublicKey // RoutingInfo is the full routing information for this onion packet. // This encodes all the forwarding instructions for this current hop // and all the hops in the route. RoutingInfo [routingInfoSize]byte // HeaderMAC is an HMAC computed with the shared secret of the routing // data and the associated data for this route. Including the // associated data lets each hop authenticate higher-level data that is // critical for the forwarding of this HTLC. HeaderMAC [HMACSize]byte } // generateSharedSecrets by the given nodes pubkeys, generates the shared // secrets. func generateSharedSecrets(paymentPath []*btcec.PublicKey, sessionKey *btcec.PrivateKey) []Hash256 { // Each hop performs ECDH with our ephemeral key pair to arrive at a // shared secret. Additionally, each hop randomizes the group element // for the next hop by multiplying it by the blinding factor. This way // we only need to transmit a single group element, and hops can't link // a session back to us if they have several nodes in the path. numHops := len(paymentPath) hopSharedSecrets := make([]Hash256, numHops) // Compute the triplet for the first hop outside of the main loop. // Within the loop each new triplet will be computed recursively based // off of the blinding factor of the last hop. lastEphemeralPubKey := sessionKey.PubKey() hopSharedSecrets[0] = generateSharedSecret(paymentPath[0], sessionKey) lastBlindingFactor := computeBlindingFactor(lastEphemeralPubKey, hopSharedSecrets[0][:]) // The cached blinding factor will contain the running product of the // session private key x and blinding factors b_i, computed as // c_0 = x // c_i = c_{i-1} * b_{i-1} (mod |F(G)|). // = x * b_0 * b_1 * ... * b_{i-1} (mod |F(G)|). // // We begin with just the session private key x, so that base case // c_0 = x. At the beginning of each iteration, the previous blinding // factor is aggregated into the modular product, and used as the scalar // value in deriving the hop ephemeral keys and shared secrets. var cachedBlindingFactor big.Int cachedBlindingFactor.SetBytes(sessionKey.D.Bytes()) // Now recursively compute the cached blinding factor, ephemeral ECDH // pub keys, and shared secret for each hop. var nextBlindingFactor big.Int for i := 1; i <= numHops-1; i++ { // Update the cached blinding factor with b_{i-1}. nextBlindingFactor.SetBytes(lastBlindingFactor[:]) cachedBlindingFactor.Mul(&cachedBlindingFactor, &nextBlindingFactor) cachedBlindingFactor.Mod(&cachedBlindingFactor, btcec.S256().Params().N) // a_i = g ^ c_i // = g^( x * b_0 * ... * b_{i-1} ) // = X^( b_0 * ... * b_{i-1} ) // X_our_session_pub_key x all prev blinding factors lastEphemeralPubKey = blindBaseElement(cachedBlindingFactor.Bytes()) // e_i = Y_i ^ c_i // = ( Y_i ^ x )^( b_0 * ... * b_{i-1} ) // (Y_their_pub_key x x_our_priv) x all prev blinding factors hopBlindedPubKey := blindGroupElement( paymentPath[i], cachedBlindingFactor.Bytes(), ) // s_i = sha256( e_i ) // = sha256( Y_i ^ (x * b_0 * ... * b_{i-1} ) hopSharedSecrets[i] = sha256.Sum256(hopBlindedPubKey.SerializeCompressed()) // Only need to evaluate up to the penultimate blinding factor. if i >= numHops-1 { break } // b_i = sha256( a_i || s_i ) lastBlindingFactor = computeBlindingFactor( lastEphemeralPubKey, hopSharedSecrets[i][:], ) } return hopSharedSecrets } // NewOnionPacket creates a new onion packet which is capable of obliviously // routing a message through the mix-net path outline by 'paymentPath'. func NewOnionPacket(paymentPath *PaymentPath, sessionKey *btcec.PrivateKey, assocData []byte, pktFiller PacketFiller) (*OnionPacket, error) { // Check whether total payload size doesn't exceed the hard maximum. if paymentPath.TotalPayloadSize() > routingInfoSize { return nil, ErrMaxRoutingInfoSizeExceeded } // If we don't actually have a partially populated route, then we'll // exit early. numHops := paymentPath.TrueRouteLength() if numHops == 0 { return nil, fmt.Errorf("route of length zero passed in") } // We'll force the caller to provide a packet filler, as otherwise we // may default to an insecure filling method (which should only really // be used to generate test vectors). if pktFiller == nil { return nil, fmt.Errorf("packet filler must be specified") } hopSharedSecrets := generateSharedSecrets( paymentPath.NodeKeys(), sessionKey, ) // Generate the padding, called "filler strings" in the paper. filler := generateHeaderPadding("rho", paymentPath, hopSharedSecrets) // Allocate zero'd out byte slices to store the final mix header packet // and the hmac for each hop. var ( mixHeader [routingInfoSize]byte nextHmac [HMACSize]byte hopPayloadBuf bytes.Buffer ) // Fill the packet using the caller specified methodology. if err := pktFiller(sessionKey, &mixHeader); err != nil { return nil, err } // Now we compute the routing information for each hop, along with a // MAC of the routing info using the shared key for that hop. for i := numHops - 1; i >= 0; i-- { // We'll derive the two keys we need for each hop in order to: // generate our stream cipher bytes for the mixHeader, and // calculate the MAC over the entire constructed packet. rhoKey := generateKey("rho", &hopSharedSecrets[i]) muKey := generateKey("mu", &hopSharedSecrets[i]) // The HMAC for the final hop is simply zeroes. This allows the // last hop to recognize that it is the destination for a // particular payment. paymentPath[i].HopPayload.HMAC = nextHmac // Next, using the key dedicated for our stream cipher, we'll // generate enough bytes to obfuscate this layer of the onion // packet. streamBytes := generateCipherStream(rhoKey, routingInfoSize) payload := paymentPath[i].HopPayload // Before we assemble the packet, we'll shift the current // mix-header to the right in order to make room for this next // per-hop data. shiftSize := payload.NumBytes() rightShift(mixHeader[:], shiftSize) err := payload.Encode(&hopPayloadBuf) if err != nil { return nil, err } copy(mixHeader[:], hopPayloadBuf.Bytes()) // Once the packet for this hop has been assembled, we'll // re-encrypt the packet by XOR'ing with a stream of bytes // generated using our shared secret. xor(mixHeader[:], mixHeader[:], streamBytes[:]) // If this is the "last" hop, then we'll override the tail of // the hop data. if i == numHops-1 { copy(mixHeader[len(mixHeader)-len(filler):], filler) } // The packet for this hop consists of: mixHeader. When // calculating the MAC, we'll also include the optional // associated data which can allow higher level applications to // prevent replay attacks. packet := append(mixHeader[:], assocData...) nextHmac = calcMac(muKey, packet) hopPayloadBuf.Reset() } return &OnionPacket{ Version: baseVersion, EphemeralKey: sessionKey.PubKey(), RoutingInfo: mixHeader, HeaderMAC: nextHmac, }, nil } // rightShift shifts the byte-slice by the given number of bytes to the right // and 0-fill the resulting gap. func rightShift(slice []byte, num int) { for i := len(slice) - num - 1; i >= 0; i-- { slice[num+i] = slice[i] } for i := 0; i < num; i++ { slice[i] = 0 } } // generateHeaderPadding derives the bytes for padding the mix header to ensure // it remains fixed sized throughout route transit. At each step, we add // 'frameSize*frames' padding of zeroes, concatenate it to the previous filler, // then decrypt it (XOR) with the secret key of the current hop. When // encrypting the mix header we essentially do the reverse of this operation: // we "encrypt" the padding, and drop 'frameSize*frames' number of zeroes. As // nodes process the mix header they add the padding ('frameSize*frames') in // order to check the MAC and decrypt the next routing information eventually // leaving only the original "filler" bytes produced by this function at the // last hop. Using this methodology, the size of the field stays constant at // each hop. func generateHeaderPadding(key string, path *PaymentPath, sharedSecrets []Hash256) []byte { numHops := path.TrueRouteLength() // We have to generate a filler that matches all but the last hop (the // last hop won't generate an HMAC) fillerSize := path.TotalPayloadSize() - path[numHops-1].HopPayload.NumBytes() filler := make([]byte, fillerSize) for i := 0; i < numHops-1; i++ { // Sum up how many frames were used by prior hops. fillerStart := routingInfoSize for _, p := range path[:i] { fillerStart -= p.HopPayload.NumBytes() } // The filler is the part dangling off of the end of the // routingInfo, so offset it from there, and use the current // hop's frame count as its size. fillerEnd := routingInfoSize + path[i].HopPayload.NumBytes() streamKey := generateKey(key, &sharedSecrets[i]) streamBytes := generateCipherStream(streamKey, numStreamBytes) xor(filler, filler, streamBytes[fillerStart:fillerEnd]) } return filler } // Encode serializes the raw bytes of the onion packet into the passed // io.Writer. The form encoded within the passed io.Writer is suitable for // either storing on disk, or sending over the network. func (f *OnionPacket) Encode(w io.Writer) error { ephemeral := f.EphemeralKey.SerializeCompressed() if _, err := w.Write([]byte{f.Version}); err != nil { return err } if _, err := w.Write(ephemeral); err != nil { return err } if _, err := w.Write(f.RoutingInfo[:]); err != nil { return err } if _, err := w.Write(f.HeaderMAC[:]); err != nil { return err } return nil } // Decode fully populates the target ForwardingMessage from the raw bytes // encoded within the io.Reader. In the case of any decoding errors, an error // will be returned. If the method success, then the new OnionPacket is ready // to be processed by an instance of SphinxNode. func (f *OnionPacket) Decode(r io.Reader) error { var err error var buf [1]byte if _, err := io.ReadFull(r, buf[:]); err != nil { return err } f.Version = buf[0] // If version of the onion packet protocol unknown for us than in might // lead to improperly decoded data. if f.Version != baseVersion { return ErrInvalidOnionVersion } var ephemeral [33]byte if _, err := io.ReadFull(r, ephemeral[:]); err != nil { return err } f.EphemeralKey, err = btcec.ParsePubKey(ephemeral[:], btcec.S256()) if err != nil { return ErrInvalidOnionKey } if _, err := io.ReadFull(r, f.RoutingInfo[:]); err != nil { return err } if _, err := io.ReadFull(r, f.HeaderMAC[:]); err != nil { return err } return nil } // ProcessCode is an enum-like type which describes to the high-level package // user which action should be taken after processing a Sphinx packet. type ProcessCode int const ( // ExitNode indicates that the node which processed the Sphinx packet // is the destination hop in the route. ExitNode = iota // MoreHops indicates that there are additional hops left within the // route. Therefore the caller should forward the packet to the node // denoted as the "NextHop". MoreHops // Failure indicates that a failure occurred during packet processing. Failure ) // String returns a human readable string for each of the ProcessCodes. func (p ProcessCode) String() string { switch p { case ExitNode: return "ExitNode" case MoreHops: return "MoreHops" case Failure: return "Failure" default: return "Unknown" } } // ProcessedPacket encapsulates the resulting state generated after processing // an OnionPacket. A processed packet communicates to the caller what action // should be taken after processing. type ProcessedPacket struct { // Action represents the action the caller should take after processing // the packet. Action ProcessCode // ForwardingInstructions is the per-hop payload recovered from the // initial encrypted onion packet. It details how the packet should be // forwarded and also includes information that allows the processor of // the packet to authenticate the information passed within the HTLC. // // NOTE: This field will only be populated iff the above Action is // MoreHops. ForwardingInstructions *HopData // Payload is the raw payload as extracted from the packet. If the // ForwardingInstructions field above is nil, then this is a modern TLV // payload. As a result, the caller should parse the contents to obtain // the new set of forwarding instructions. Payload HopPayload // NextPacket is the onion packet that should be forwarded to the next // hop as denoted by the ForwardingInstructions field. // // NOTE: This field will only be populated iff the above Action is // MoreHops. NextPacket *OnionPacket } // Router is an onion router within the Sphinx network. The router is capable // of processing incoming Sphinx onion packets thereby "peeling" a layer off // the onion encryption which the packet is wrapped with. type Router struct { nodeID [AddressSize]byte nodeAddr *btcutil.AddressPubKeyHash onionKey *btcec.PrivateKey log ReplayLog } // NewRouter creates a new instance of a Sphinx onion Router given the node's // currently advertised onion private key, and the target Bitcoin network. func NewRouter(nodeKey *btcec.PrivateKey, net *chaincfg.Params, log ReplayLog) *Router { var nodeID [AddressSize]byte copy(nodeID[:], btcutil.Hash160(nodeKey.PubKey().SerializeCompressed())) // Safe to ignore the error here, nodeID is 20 bytes. nodeAddr, _ := btcutil.NewAddressPubKeyHash(nodeID[:], net) return &Router{ nodeID: nodeID, nodeAddr: nodeAddr, onionKey: &btcec.PrivateKey{ PublicKey: ecdsa.PublicKey{ Curve: btcec.S256(), X: nodeKey.X, Y: nodeKey.Y, }, D: nodeKey.D, }, log: log, } } // Start starts / opens the ReplayLog's channeldb and its accompanying // garbage collector goroutine. func (r *Router) Start() error { return r.log.Start() } // Stop stops / closes the ReplayLog's channeldb and its accompanying // garbage collector goroutine. func (r *Router) Stop() { r.log.Stop() } // ProcessOnionPacket processes an incoming onion packet which has been forward // to the target Sphinx router. If the encoded ephemeral key isn't on the // target Elliptic Curve, then the packet is rejected. Similarly, if the // derived shared secret has been seen before the packet is rejected. Finally // if the MAC doesn't check the packet is again rejected. // // In the case of a successful packet processing, and ProcessedPacket struct is // returned which houses the newly parsed packet, along with instructions on // what to do next. func (r *Router) ProcessOnionPacket(onionPkt *OnionPacket, assocData []byte, incomingCltv uint32) (*ProcessedPacket, error) { // Compute the shared secret for this onion packet. sharedSecret, err := r.generateSharedSecret(onionPkt.EphemeralKey) if err != nil { return nil, err } // Additionally, compute the hash prefix of the shared secret, which // will serve as an identifier for detecting replayed packets. hashPrefix := hashSharedSecret(&sharedSecret) // Continue to optimistically process this packet, deferring replay // protection until the end to reduce the penalty of multiple IO // operations. packet, err := processOnionPacket(onionPkt, &sharedSecret, assocData, r) if err != nil { return nil, err } // Atomically compare this hash prefix with the contents of the on-disk // log, persisting it only if this entry was not detected as a replay. if err := r.log.Put(hashPrefix, incomingCltv); err != nil { return nil, err } return packet, nil } // ReconstructOnionPacket rederives the subsequent onion packet. // // NOTE: This method does not do any sort of replay protection, and should only // be used to reconstruct packets that were successfully processed previously. func (r *Router) ReconstructOnionPacket(onionPkt *OnionPacket, assocData []byte) (*ProcessedPacket, error) { // Compute the shared secret for this onion packet. sharedSecret, err := r.generateSharedSecret(onionPkt.EphemeralKey) if err != nil { return nil, err } return processOnionPacket(onionPkt, &sharedSecret, assocData, r) } // unwrapPacket wraps a layer of the passed onion packet using the specified // shared secret and associated data. The associated data will be used to check // the HMAC at each hop to ensure the same data is passed along with the onion // packet. This function returns the next inner onion packet layer, along with // the hop data extracted from the outer onion packet. func unwrapPacket(onionPkt *OnionPacket, sharedSecret *Hash256, assocData []byte) (*OnionPacket, *HopPayload, error) { dhKey := onionPkt.EphemeralKey routeInfo := onionPkt.RoutingInfo headerMac := onionPkt.HeaderMAC // Using the derived shared secret, ensure the integrity of the routing // information by checking the attached MAC without leaking timing // information. message := append(routeInfo[:], assocData...) calculatedMac := calcMac(generateKey("mu", sharedSecret), message) if !hmac.Equal(headerMac[:], calculatedMac[:]) { return nil, nil, ErrInvalidOnionHMAC } // Attach the padding zeroes in order to properly strip an encryption // layer off the routing info revealing the routing information for the // next hop. streamBytes := generateCipherStream( generateKey("rho", sharedSecret), numStreamBytes, ) zeroBytes := bytes.Repeat([]byte{0}, MaxPayloadSize) headerWithPadding := append(routeInfo[:], zeroBytes...) var hopInfo [numStreamBytes]byte xor(hopInfo[:], headerWithPadding, streamBytes) // Randomize the DH group element for the next hop using the // deterministic blinding factor. blindingFactor := computeBlindingFactor(dhKey, sharedSecret[:]) nextDHKey := blindGroupElement(dhKey, blindingFactor[:]) // With the MAC checked, and the payload decrypted, we can now parse // out the payload so we can derive the specified forwarding // instructions. var hopPayload HopPayload if err := hopPayload.Decode(bytes.NewReader(hopInfo[:])); err != nil { return nil, nil, err } // With the necessary items extracted, we'll copy of the onion packet // for the next node, snipping off our per-hop data. var nextMixHeader [routingInfoSize]byte copy(nextMixHeader[:], hopInfo[hopPayload.NumBytes():]) innerPkt := &OnionPacket{ Version: onionPkt.Version, EphemeralKey: nextDHKey, RoutingInfo: nextMixHeader, HeaderMAC: hopPayload.HMAC, } return innerPkt, &hopPayload, nil } // processOnionPacket performs the primary key derivation and handling of onion // packets. The processed packets returned from this method should only be used // if the packet was not flagged as a replayed packet. func processOnionPacket(onionPkt *OnionPacket, sharedSecret *Hash256, assocData []byte, sharedSecretGen sharedSecretGenerator) (*ProcessedPacket, error) { // First, we'll unwrap an initial layer of the onion packet. Typically, // we'll only have a single layer to unwrap, However, if the sender has // additional data for us within the Extra Onion Blobs (EOBs), then we // may have to unwrap additional layers. By default, the inner most // mix header is the one that we'll want to pass onto the next hop so // they can properly check the HMAC and unwrap a layer for their // handoff hop. innerPkt, outerHopPayload, err := unwrapPacket( onionPkt, sharedSecret, assocData, ) if err != nil { return nil, err } // By default we'll assume that there are additional hops in the route. // However if the uncovered 'nextMac' is all zeroes, then this // indicates that we're the final hop in the route. var action ProcessCode = MoreHops if bytes.Compare(zeroHMAC[:], outerHopPayload.HMAC[:]) == 0 { action = ExitNode } hopData, err := outerHopPayload.HopData() if err != nil { return nil, err } // Finally, we'll return a fully processed packet with the outer most // hop data (where the primary forwarding instructions lie) and the // inner most onion packet that we unwrapped. return &ProcessedPacket{ Action: action, ForwardingInstructions: hopData, Payload: *outerHopPayload, NextPacket: innerPkt, }, nil } // Tx is a transaction consisting of a number of sphinx packets to be atomically // written to the replay log. This structure helps to coordinate construction of // the underlying Batch object, and to ensure that the result of the processing // is idempotent. type Tx struct { // batch is the set of packets to be incrementally processed and // ultimately committed in this transaction batch *Batch // router is a reference to the sphinx router that created this // transaction. Committing this transaction will utilize this router's // replay log. router *Router // packets contains a potentially sparse list of optimistically processed // packets for this batch. The contents of a particular index should // only be accessed if the index is *not* included in the replay set, or // otherwise failed any other stage of the processing. packets []ProcessedPacket } // BeginTxn creates a new transaction that can later be committed back to the // sphinx router's replay log. // // NOTE: The nels parameter should represent the maximum number of that could // be added to the batch, using sequence numbers that match or exceed this // value could result in an out-of-bounds panic. func (r *Router) BeginTxn(id []byte, nels int) *Tx { return &Tx{ batch: NewBatch(id), router: r, packets: make([]ProcessedPacket, nels), } } // ProcessOnionPacket processes an incoming onion packet which has been forward // to the target Sphinx router. If the encoded ephemeral key isn't on the // target Elliptic Curve, then the packet is rejected. Similarly, if the // derived shared secret has been seen before the packet is rejected. Finally // if the MAC doesn't check the packet is again rejected. // // In the case of a successful packet processing, and ProcessedPacket struct is // returned which houses the newly parsed packet, along with instructions on // what to do next. func (t *Tx) ProcessOnionPacket(seqNum uint16, onionPkt *OnionPacket, assocData []byte, incomingCltv uint32) error { // Compute the shared secret for this onion packet. sharedSecret, err := t.router.generateSharedSecret( onionPkt.EphemeralKey, ) if err != nil { return err } // Additionally, compute the hash prefix of the shared secret, which // will serve as an identifier for detecting replayed packets. hashPrefix := hashSharedSecret(&sharedSecret) // Continue to optimistically process this packet, deferring replay // protection until the end to reduce the penalty of multiple IO // operations. packet, err := processOnionPacket( onionPkt, &sharedSecret, assocData, t.router, ) if err != nil { return err } // Add the hash prefix to pending batch of shared secrets that will be // written later via Commit(). err = t.batch.Put(seqNum, hashPrefix, incomingCltv) if err != nil { return err } // If we successfully added this packet to the batch, cache the // processed packet within the Tx which can be accessed after // committing if this sequence number does not appear in the replay // set. t.packets[seqNum] = *packet return nil } // Commit writes this transaction's batch of sphinx packets to the replay log, // performing a final check against the log for replays. func (t *Tx) Commit() ([]ProcessedPacket, *ReplaySet, error) { if t.batch.IsCommitted { return t.packets, t.batch.ReplaySet, nil } rs, err := t.router.log.PutBatch(t.batch) return t.packets, rs, err }