diff --git a/core/src/main/java/haveno/core/api/CoreApi.java b/core/src/main/java/haveno/core/api/CoreApi.java
index 99fb3bc7..e8e83978 100644
--- a/core/src/main/java/haveno/core/api/CoreApi.java
+++ b/core/src/main/java/haveno/core/api/CoreApi.java
@@ -413,21 +413,22 @@ public class CoreApi {
     }
 
     public void postOffer(String currencyCode,
-                                   String directionAsString,
-                                   String priceAsString,
-                                   boolean useMarketBasedPrice,
-                                   double marketPriceMargin,
-                                   long amountAsLong,
-                                   long minAmountAsLong,
-                                   double securityDepositPct,
-                                   String triggerPriceAsString,
-                                   boolean reserveExactAmount,
-                                   String paymentAccountId,
-                                   boolean isPrivateOffer,
-                                   boolean buyerAsTakerWithoutDeposit,
-                                   String extraInfo,
-                                   Consumer<Offer> resultHandler,
-                                   ErrorMessageHandler errorMessageHandler) {
+                            String directionAsString,
+                            String priceAsString,
+                            boolean useMarketBasedPrice,
+                            double marketPriceMargin,
+                            long amountAsLong,
+                            long minAmountAsLong,
+                            double securityDepositPct,
+                            String triggerPriceAsString,
+                            boolean reserveExactAmount,
+                            String paymentAccountId,
+                            boolean isPrivateOffer,
+                            boolean buyerAsTakerWithoutDeposit,
+                            String extraInfo,
+                            String sourceOfferId,
+                            Consumer<Offer> resultHandler,
+                            ErrorMessageHandler errorMessageHandler) {
         coreOffersService.postOffer(currencyCode,
                 directionAsString,
                 priceAsString,
@@ -442,6 +443,7 @@ public class CoreApi {
                 isPrivateOffer,
                 buyerAsTakerWithoutDeposit,
                 extraInfo,
+                sourceOfferId,
                 resultHandler,
                 errorMessageHandler);
     }
diff --git a/core/src/main/java/haveno/core/api/CoreOffersService.java b/core/src/main/java/haveno/core/api/CoreOffersService.java
index f9c450b8..3ee7e047 100644
--- a/core/src/main/java/haveno/core/api/CoreOffersService.java
+++ b/core/src/main/java/haveno/core/api/CoreOffersService.java
@@ -43,6 +43,7 @@ import static haveno.common.util.MathUtils.exactMultiply;
 import static haveno.common.util.MathUtils.roundDoubleToLong;
 import static haveno.common.util.MathUtils.scaleUpByPowerOf10;
 import haveno.core.locale.CurrencyUtil;
+import haveno.core.locale.Res;
 import haveno.core.monetary.CryptoMoney;
 import haveno.core.monetary.Price;
 import haveno.core.monetary.TraditionalMoney;
@@ -66,9 +67,7 @@ import java.math.BigInteger;
 import java.util.ArrayList;
 import java.util.Comparator;
 import static java.util.Comparator.comparing;
-import java.util.HashSet;
 import java.util.List;
-import java.util.Set;
 import java.util.function.Consumer;
 import java.util.function.Supplier;
 import java.util.stream.Collectors;
@@ -124,7 +123,6 @@ public class CoreOffersService {
                     return result.isValid() || result == Result.HAS_NO_PAYMENT_ACCOUNT_VALID_FOR_OFFER;
                 })
                 .collect(Collectors.toList());
-        offers.removeAll(getOffersWithDuplicateKeyImages(offers));
         return offers;
     }
 
@@ -143,12 +141,9 @@ public class CoreOffersService {
     }
 
     List<OpenOffer> getMyOffers() {
-        List<OpenOffer> offers = openOfferManager.getOpenOffers().stream()
+        return openOfferManager.getOpenOffers().stream()
                 .filter(o -> o.getOffer().isMyOffer(keyRing))
                 .collect(Collectors.toList());
-        Set<Offer> offersWithDuplicateKeyImages = getOffersWithDuplicateKeyImages(offers.stream().map(OpenOffer::getOffer).collect(Collectors.toList())); // TODO: this is hacky way of filtering offers with duplicate key images
-        Set<String> offerIdsWithDuplicateKeyImages = offersWithDuplicateKeyImages.stream().map(Offer::getId).collect(Collectors.toSet());
-        return offers.stream().filter(o -> !offerIdsWithDuplicateKeyImages.contains(o.getId())).collect(Collectors.toList());
     };
 
     List<OpenOffer> getMyOffers(String direction, String currencyCode) {
@@ -179,15 +174,31 @@ public class CoreOffersService {
                              boolean isPrivateOffer,
                              boolean buyerAsTakerWithoutDeposit,
                              String extraInfo,
+                             String sourceOfferId,
                              Consumer<Offer> resultHandler,
                              ErrorMessageHandler errorMessageHandler) {
         coreWalletsService.verifyWalletsAreAvailable();
         coreWalletsService.verifyEncryptedWalletIsUnlocked();
 
         PaymentAccount paymentAccount = user.getPaymentAccount(paymentAccountId);
-        if (paymentAccount == null)
-            throw new IllegalArgumentException(format("payment account with id %s not found", paymentAccountId));
+        if (paymentAccount == null) throw new IllegalArgumentException(format("payment account with id %s not found", paymentAccountId));
 
+        // clone offer if sourceOfferId given
+        if (!sourceOfferId.isEmpty()) {
+            cloneOffer(sourceOfferId,
+                    currencyCode,
+                    priceAsString,
+                    useMarketBasedPrice,
+                    marketPriceMargin,
+                    triggerPriceAsString,
+                    paymentAccountId,
+                    extraInfo,
+                    resultHandler,
+                    errorMessageHandler);
+            return;
+        }
+
+        // create new offer
         String upperCaseCurrencyCode = currencyCode.toUpperCase();
         String offerId = createOfferService.getRandomOfferId();
         OfferDirection direction = OfferDirection.valueOf(directionAsString.toUpperCase());
@@ -210,17 +221,70 @@ public class CoreOffersService {
 
         verifyPaymentAccountIsValidForNewOffer(offer, paymentAccount);
 
-        // We don't support atm funding from external wallet to keep it simple.
-        boolean useSavingsWallet = true;
-        //noinspection ConstantConditions
         placeOffer(offer,
                 triggerPriceAsString,
-                useSavingsWallet,
+                true,
                 reserveExactAmount,
+                null,
                 transaction -> resultHandler.accept(offer),
                 errorMessageHandler);
     }
 
+    private void cloneOffer(String sourceOfferId,
+                    String currencyCode,
+                    String priceAsString,
+                    boolean useMarketBasedPrice,
+                    double marketPriceMargin,
+                    String triggerPriceAsString,
+                    String paymentAccountId,
+                    String extraInfo,
+                    Consumer<Offer> resultHandler,
+                    ErrorMessageHandler errorMessageHandler) {
+
+        // get source offer
+        OpenOffer sourceOpenOffer = getMyOffer(sourceOfferId);
+        Offer sourceOffer = sourceOpenOffer.getOffer();
+
+        // get trade currency (default source currency)
+        if (currencyCode.isEmpty()) currencyCode = sourceOffer.getOfferPayload().getBaseCurrencyCode();
+        if (currencyCode.equalsIgnoreCase(Res.getBaseCurrencyCode())) currencyCode = sourceOffer.getOfferPayload().getCounterCurrencyCode();
+        String upperCaseCurrencyCode = currencyCode.toUpperCase();
+
+        // get price (default source price)
+        Price price = useMarketBasedPrice ? null : priceAsString.isEmpty() ? sourceOffer.isUseMarketBasedPrice() ? null : sourceOffer.getPrice() : Price.parse(upperCaseCurrencyCode, priceAsString);
+        if (price == null) useMarketBasedPrice = true;
+
+        // get payment account
+        if (paymentAccountId.isEmpty()) paymentAccountId = sourceOffer.getOfferPayload().getMakerPaymentAccountId();
+        PaymentAccount paymentAccount = user.getPaymentAccount(paymentAccountId);
+        if (paymentAccount == null) throw new IllegalArgumentException(format("payment acRcount with id %s not found", paymentAccountId));
+
+        // get extra info
+        if (extraInfo.isEmpty()) extraInfo = sourceOffer.getOfferPayload().getExtraInfo();
+
+        // create cloned offer
+        Offer offer = createOfferService.createClonedOffer(sourceOffer,
+                upperCaseCurrencyCode,
+                price,
+                useMarketBasedPrice,
+                exactMultiply(marketPriceMargin, 0.01),
+                paymentAccount,
+                extraInfo);
+        
+        // verify cloned offer
+        verifyPaymentAccountIsValidForNewOffer(offer, paymentAccount);
+
+        // place offer
+        placeOffer(offer,
+                triggerPriceAsString,
+                true,
+                false, // ignored when cloning
+                sourceOfferId,
+                transaction -> resultHandler.accept(offer),
+                errorMessageHandler);
+    }
+
+    // TODO: this implementation is missing; implement.
     Offer editOffer(String offerId,
                     String currencyCode,
                     OfferDirection direction,
@@ -256,27 +320,6 @@ public class CoreOffersService {
 
     // -------------------------- PRIVATE HELPERS -----------------------------
 
-    private Set<Offer> getOffersWithDuplicateKeyImages(List<Offer> offers) {
-        Set<Offer> duplicateFundedOffers = new HashSet<Offer>();
-        Set<String> seenKeyImages = new HashSet<String>();
-        for (Offer offer : offers) {
-            if (offer.getOfferPayload().getReserveTxKeyImages() == null) continue;
-            for (String keyImage : offer.getOfferPayload().getReserveTxKeyImages()) {
-                if (!seenKeyImages.add(keyImage)) {
-                    for (Offer offer2 : offers) {
-                        if (offer == offer2) continue;
-                        if (offer2.getOfferPayload().getReserveTxKeyImages() == null) continue;
-                        if (offer2.getOfferPayload().getReserveTxKeyImages().contains(keyImage)) {
-                            log.warn("Key image {} belongs to multiple offers, seen in offer {} and {}", keyImage, offer.getId(), offer2.getId());
-                            duplicateFundedOffers.add(offer2);
-                        }
-                    }
-                }
-            }
-        }
-        return duplicateFundedOffers;
-    }
-
     private void verifyPaymentAccountIsValidForNewOffer(Offer offer, PaymentAccount paymentAccount) {
         if (!isPaymentAccountValidForOffer(offer, paymentAccount)) {
             String error = format("cannot create %s offer with payment account %s",
@@ -290,6 +333,7 @@ public class CoreOffersService {
                             String triggerPriceAsString,
                             boolean useSavingsWallet,
                             boolean reserveExactAmount,
+                            String sourceOfferId,
                             Consumer<Transaction> resultHandler,
                             ErrorMessageHandler errorMessageHandler) {
         long triggerPriceAsLong = PriceUtil.getMarketPriceAsLong(triggerPriceAsString, offer.getCurrencyCode());
@@ -298,6 +342,7 @@ public class CoreOffersService {
                 triggerPriceAsLong,
                 reserveExactAmount,
                 true,
+                sourceOfferId,
                 resultHandler::accept,
                 errorMessageHandler);
     }
diff --git a/core/src/main/java/haveno/core/api/XmrConnectionService.java b/core/src/main/java/haveno/core/api/XmrConnectionService.java
index 346298f5..a1e12191 100644
--- a/core/src/main/java/haveno/core/api/XmrConnectionService.java
+++ b/core/src/main/java/haveno/core/api/XmrConnectionService.java
@@ -32,6 +32,7 @@ import haveno.core.xmr.nodes.XmrNodes.XmrNode;
 import haveno.core.xmr.nodes.XmrNodesSetupPreferences;
 import haveno.core.xmr.setup.DownloadListener;
 import haveno.core.xmr.setup.WalletsSetup;
+import haveno.core.xmr.wallet.XmrKeyImagePoller;
 import haveno.network.Socks5ProxyProvider;
 import haveno.network.p2p.P2PService;
 import haveno.network.p2p.P2PServiceListener;
@@ -71,6 +72,8 @@ public final class XmrConnectionService {
     private static final int MIN_BROADCAST_CONNECTIONS = 0; // TODO: 0 for stagenet, 5+ for mainnet
     private static final long REFRESH_PERIOD_HTTP_MS = 20000; // refresh period when connected to remote node over http
     private static final long REFRESH_PERIOD_ONION_MS = 30000; // refresh period when connected to remote node over tor
+    private static final long KEY_IMAGE_REFRESH_PERIOD_MS_LOCAL = 20000; // 20 seconds
+    private static final long KEY_IMAGE_REFRESH_PERIOD_MS_REMOTE = 300000; // 5 minutes
 
     public enum XmrConnectionError {
         LOCAL,
@@ -115,6 +118,7 @@ public final class XmrConnectionService {
     @Getter
     private boolean isShutDownStarted;
     private List<MoneroConnectionManagerListener> listeners = new ArrayList<>();
+    private XmrKeyImagePoller keyImagePoller;
 
     // connection switching
     private static final int EXCLUDE_CONNECTION_SECONDS = 180;
@@ -403,6 +407,17 @@ public final class XmrConnectionService {
         return lastInfo.getTargetHeight() == 0 ? chainHeight.get() : lastInfo.getTargetHeight(); // monerod sync_info's target_height returns 0 when node is fully synced
     }
 
+    public XmrKeyImagePoller getKeyImagePoller() {
+        synchronized (lock) {
+            if (keyImagePoller == null) keyImagePoller = new XmrKeyImagePoller();
+            return keyImagePoller;
+        }
+    }
+
+    private long getKeyImageRefreshPeriodMs() {
+        return isConnectionLocalHost() ? KEY_IMAGE_REFRESH_PERIOD_MS_LOCAL : KEY_IMAGE_REFRESH_PERIOD_MS_REMOTE;
+    }
+
     // ----------------------------- APP METHODS ------------------------------
 
     public ReadOnlyIntegerProperty numConnectionsProperty() {
@@ -488,6 +503,13 @@ public final class XmrConnectionService {
 
     private void initialize() {
 
+        // initialize key image poller
+        getKeyImagePoller();
+        new Thread(() -> {
+            HavenoUtils.waitFor(20000);
+            keyImagePoller.poll(); // TODO: keep or remove first poll?s
+        }).start();
+
         // initialize connections
         initializeConnections();
 
@@ -693,6 +715,10 @@ public final class XmrConnectionService {
                 numUpdates.set(numUpdates.get() + 1);
             });
         }
+
+        // update key image poller
+        keyImagePoller.setDaemon(getDaemon());
+        keyImagePoller.setRefreshPeriodMs(getKeyImageRefreshPeriodMs());
         
         // update polling
         doPollDaemon();
diff --git a/core/src/main/java/haveno/core/offer/CreateOfferService.java b/core/src/main/java/haveno/core/offer/CreateOfferService.java
index bca44682..fab64643 100644
--- a/core/src/main/java/haveno/core/offer/CreateOfferService.java
+++ b/core/src/main/java/haveno/core/offer/CreateOfferService.java
@@ -92,6 +92,7 @@ public class CreateOfferService {
                 Version.VERSION.replace(".", "");
     }
 
+    // TODO: add trigger price?
     public Offer createAndGetOffer(String offerId,
                                    OfferDirection direction,
                                    String currencyCode,
@@ -105,7 +106,7 @@ public class CreateOfferService {
                                    boolean isPrivateOffer,
                                    boolean buyerAsTakerWithoutDeposit,
                                    String extraInfo) {
-        log.info("create and get offer with offerId={}, " +
+        log.info("Create and get offer with offerId={}, " +
                         "currencyCode={}, " +
                         "direction={}, " +
                         "fixedPrice={}, " +
@@ -238,6 +239,99 @@ public class CreateOfferService {
         return offer;
     }
 
+    // TODO: add trigger price?
+    public Offer createClonedOffer(Offer sourceOffer,
+                            String currencyCode,
+                            Price fixedPrice,
+                            boolean useMarketBasedPrice,
+                            double marketPriceMargin,
+                            PaymentAccount paymentAccount,
+                            String extraInfo) {
+        log.info("Cloning offer with sourceId={}, " +
+                        "currencyCode={}, " +
+                        "fixedPrice={}, " +
+                        "useMarketBasedPrice={}, " +
+                        "marketPriceMargin={}, " +
+                        "extraInfo={}",
+                sourceOffer.getId(),
+                currencyCode,
+                fixedPrice == null ? null : fixedPrice.getValue(),
+                useMarketBasedPrice,
+                marketPriceMargin,
+                extraInfo);
+
+        OfferPayload sourceOfferPayload = sourceOffer.getOfferPayload();
+        String newOfferId = OfferUtil.getRandomOfferId();
+        Offer editedOffer = createAndGetOffer(newOfferId,
+                sourceOfferPayload.getDirection(),
+                currencyCode,
+                BigInteger.valueOf(sourceOfferPayload.getAmount()),
+                BigInteger.valueOf(sourceOfferPayload.getMinAmount()),
+                fixedPrice,
+                useMarketBasedPrice,
+                marketPriceMargin,
+                sourceOfferPayload.getSellerSecurityDepositPct(),
+                paymentAccount,
+                sourceOfferPayload.isPrivateOffer(),
+                sourceOfferPayload.isBuyerAsTakerWithoutDeposit(),
+                extraInfo);
+
+        // generate one-time challenge for private offer
+        String challenge = null;
+        String challengeHash = null;
+        if (sourceOfferPayload.isPrivateOffer()) {
+            challenge = HavenoUtils.generateChallenge();
+            challengeHash = HavenoUtils.getChallengeHash(challenge);
+        }
+        
+        OfferPayload editedOfferPayload = editedOffer.getOfferPayload();
+        long date = new Date().getTime();
+        OfferPayload clonedOfferPayload = new OfferPayload(newOfferId,
+                date,
+                sourceOfferPayload.getOwnerNodeAddress(),
+                sourceOfferPayload.getPubKeyRing(),
+                sourceOfferPayload.getDirection(),
+                editedOfferPayload.getPrice(),
+                editedOfferPayload.getMarketPriceMarginPct(),
+                editedOfferPayload.isUseMarketBasedPrice(),
+                sourceOfferPayload.getAmount(),
+                sourceOfferPayload.getMinAmount(),
+                sourceOfferPayload.getMakerFeePct(),
+                sourceOfferPayload.getTakerFeePct(),
+                sourceOfferPayload.getPenaltyFeePct(),
+                sourceOfferPayload.getBuyerSecurityDepositPct(),
+                sourceOfferPayload.getSellerSecurityDepositPct(),
+                editedOfferPayload.getBaseCurrencyCode(),
+                editedOfferPayload.getCounterCurrencyCode(),
+                editedOfferPayload.getPaymentMethodId(),
+                editedOfferPayload.getMakerPaymentAccountId(),
+                editedOfferPayload.getCountryCode(),
+                editedOfferPayload.getAcceptedCountryCodes(),
+                editedOfferPayload.getBankId(),
+                editedOfferPayload.getAcceptedBankIds(),
+                editedOfferPayload.getVersionNr(),
+                sourceOfferPayload.getBlockHeightAtOfferCreation(),
+                editedOfferPayload.getMaxTradeLimit(),
+                editedOfferPayload.getMaxTradePeriod(),
+                sourceOfferPayload.isUseAutoClose(),
+                sourceOfferPayload.isUseReOpenAfterAutoClose(),
+                sourceOfferPayload.getLowerClosePrice(),
+                sourceOfferPayload.getUpperClosePrice(),
+                sourceOfferPayload.isPrivateOffer(),
+                challengeHash,
+                editedOfferPayload.getExtraDataMap(),
+                sourceOfferPayload.getProtocolVersion(),
+                null,
+                null,
+                sourceOfferPayload.getReserveTxKeyImages(),
+                editedOfferPayload.getExtraInfo());
+        Offer clonedOffer = new Offer(clonedOfferPayload);
+        clonedOffer.setPriceFeedService(priceFeedService);
+        clonedOffer.setChallenge(challenge);
+        clonedOffer.setState(Offer.State.AVAILABLE);
+        return clonedOffer;
+    }
+
     ///////////////////////////////////////////////////////////////////////////////////////////
     // Private
     ///////////////////////////////////////////////////////////////////////////////////////////
diff --git a/core/src/main/java/haveno/core/offer/OfferBookService.java b/core/src/main/java/haveno/core/offer/OfferBookService.java
index 7698aeb1..16faa81e 100644
--- a/core/src/main/java/haveno/core/offer/OfferBookService.java
+++ b/core/src/main/java/haveno/core/offer/OfferBookService.java
@@ -36,6 +36,9 @@ package haveno.core.offer;
 
 import com.google.inject.Inject;
 import com.google.inject.name.Named;
+
+import haveno.common.ThreadUtils;
+import haveno.common.Timer;
 import haveno.common.UserThread;
 import haveno.common.config.Config;
 import haveno.common.file.JsonFileManager;
@@ -45,45 +48,51 @@ import haveno.core.api.XmrConnectionService;
 import haveno.core.filter.FilterManager;
 import haveno.core.locale.Res;
 import haveno.core.provider.price.PriceFeedService;
-import haveno.core.trade.HavenoUtils;
 import haveno.core.util.JsonUtil;
+import haveno.core.xmr.wallet.Restrictions;
 import haveno.core.xmr.wallet.XmrKeyImageListener;
-import haveno.core.xmr.wallet.XmrKeyImagePoller;
 import haveno.network.p2p.BootstrapListener;
 import haveno.network.p2p.P2PService;
 import haveno.network.p2p.storage.HashMapChangedListener;
 import haveno.network.p2p.storage.payload.ProtectedStorageEntry;
+import haveno.network.utils.Utils;
+import lombok.extern.slf4j.Slf4j;
+
 import java.io.File;
+import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Set;
 import java.util.stream.Collectors;
 import javax.annotation.Nullable;
 import monero.daemon.model.MoneroKeyImageSpentStatus;
 
 /**
- * Handles storage and retrieval of offers.
- * Uses an invalidation flag to only request the full offer map in case there was a change (anyone has added or removed an offer).
+ * Handles validation and announcement of offers added or removed.
  */
+@Slf4j
 public class OfferBookService {
 
+    private final static long INVALID_OFFERS_TIMEOUT = 5 * 60 * 1000; // 5 minutes
+
     private final P2PService p2PService;
     private final PriceFeedService priceFeedService;
     private final List<OfferBookChangedListener> offerBookChangedListeners = new LinkedList<>();
     private final FilterManager filterManager;
     private final JsonFileManager jsonFileManager;
     private final XmrConnectionService xmrConnectionService;
-
-    // poll key images of offers
-    private XmrKeyImagePoller keyImagePoller;
-    private static final long KEY_IMAGE_REFRESH_PERIOD_MS_LOCAL = 20000; // 20 seconds
-    private static final long KEY_IMAGE_REFRESH_PERIOD_MS_REMOTE = 300000; // 5 minutes
+    private final List<Offer> validOffers = new ArrayList<Offer>();
+    private final List<Offer> invalidOffers = new ArrayList<Offer>();
+    private final Map<String, Timer> invalidOfferTimers = new HashMap<>();
 
     public interface OfferBookChangedListener {
         void onAdded(Offer offer);
-
         void onRemoved(Offer offer);
     }
 
@@ -104,51 +113,45 @@ public class OfferBookService {
         this.xmrConnectionService = xmrConnectionService;
         jsonFileManager = new JsonFileManager(storageDir);
 
-        // listen for connection changes to monerod
-        xmrConnectionService.addConnectionListener((connection) -> {
-            maybeInitializeKeyImagePoller();
-            keyImagePoller.setDaemon(xmrConnectionService.getDaemon());
-            keyImagePoller.setRefreshPeriodMs(getKeyImageRefreshPeriodMs());
-        });
-
         // listen for offers
         p2PService.addHashSetChangedListener(new HashMapChangedListener() {
             @Override
             public void onAdded(Collection<ProtectedStorageEntry> protectedStorageEntries) {
-                UserThread.execute(() -> {
+                ThreadUtils.execute(() -> {
                     protectedStorageEntries.forEach(protectedStorageEntry -> {
                         if (protectedStorageEntry.getProtectedStoragePayload() instanceof OfferPayload) {
                             OfferPayload offerPayload = (OfferPayload) protectedStorageEntry.getProtectedStoragePayload();
-                            maybeInitializeKeyImagePoller();
-                            keyImagePoller.addKeyImages(offerPayload.getReserveTxKeyImages());
                             Offer offer = new Offer(offerPayload);
                             offer.setPriceFeedService(priceFeedService);
-                            setReservedFundsSpent(offer);
-                            synchronized (offerBookChangedListeners) {
-                                offerBookChangedListeners.forEach(listener -> listener.onAdded(offer));
+                            synchronized (validOffers) {
+                                try {
+                                    validateOfferPayload(offerPayload);
+                                    replaceValidOffer(offer);
+                                    announceOfferAdded(offer);
+                                } catch (IllegalArgumentException e) {
+                                    // ignore illegal offers
+                                } catch (RuntimeException e) {
+                                    replaceInvalidOffer(offer); // offer can become valid later
+                                }
                             }
                         }
                     });
-                });
+                }, OfferBookService.class.getSimpleName());
             }
 
             @Override
             public void onRemoved(Collection<ProtectedStorageEntry> protectedStorageEntries) {
-                UserThread.execute(() -> {
+                ThreadUtils.execute(() -> {
                     protectedStorageEntries.forEach(protectedStorageEntry -> {
                         if (protectedStorageEntry.getProtectedStoragePayload() instanceof OfferPayload) {
                             OfferPayload offerPayload = (OfferPayload) protectedStorageEntry.getProtectedStoragePayload();
-                            maybeInitializeKeyImagePoller();
-                            keyImagePoller.removeKeyImages(offerPayload.getReserveTxKeyImages());
+                            removeValidOffer(offerPayload.getId());
                             Offer offer = new Offer(offerPayload);
                             offer.setPriceFeedService(priceFeedService);
-                            setReservedFundsSpent(offer);
-                            synchronized (offerBookChangedListeners) {
-                                offerBookChangedListeners.forEach(listener -> listener.onRemoved(offer));
-                            }
+                            announceOfferRemoved(offer);
                         }
                     });
-                });
+                }, OfferBookService.class.getSimpleName());
             }
         });
 
@@ -171,6 +174,16 @@ public class OfferBookService {
                 }
             });
         }
+
+        // listen for changes to key images
+        xmrConnectionService.getKeyImagePoller().addListener(new XmrKeyImageListener() {
+            @Override
+            public void onSpentStatusChanged(Map<String, MoneroKeyImageSpentStatus> spentStatuses) {
+                for (String keyImage : spentStatuses.keySet()) {
+                    updateAffectedOffers(keyImage);
+                }
+            }
+        });
     }
 
 
@@ -178,6 +191,10 @@ public class OfferBookService {
     // API
     ///////////////////////////////////////////////////////////////////////////////////////////
 
+    public boolean hasOffer(String offerId) {
+        return hasValidOffer(offerId);
+    }
+    
     public void addOffer(Offer offer, ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) {
         if (filterManager.requireUpdateToNewVersionForTrading()) {
             errorMessageHandler.handleErrorMessage(Res.get("popup.warning.mandatoryUpdate.trading"));
@@ -233,16 +250,9 @@ public class OfferBookService {
     }
 
     public List<Offer> getOffers() {
-        return p2PService.getDataMap().values().stream()
-                .filter(data -> data.getProtectedStoragePayload() instanceof OfferPayload)
-                .map(data -> {
-                    OfferPayload offerPayload = (OfferPayload) data.getProtectedStoragePayload();
-                    Offer offer = new Offer(offerPayload);
-                    offer.setPriceFeedService(priceFeedService);
-                    setReservedFundsSpent(offer);
-                    return offer;
-                })
-                .collect(Collectors.toList());
+        synchronized (validOffers) {
+            return new ArrayList<>(validOffers);
+        }
     }
 
     public List<Offer> getOffersByCurrency(String direction, String currencyCode) {
@@ -266,7 +276,7 @@ public class OfferBookService {
     }
 
     public void shutDown() {
-        if (keyImagePoller != null) keyImagePoller.clearKeyImages();
+        xmrConnectionService.getKeyImagePoller().removeKeyImages(OfferBookService.class.getName());
     }
 
 
@@ -274,37 +284,145 @@ public class OfferBookService {
     // Private
     ///////////////////////////////////////////////////////////////////////////////////////////
 
-    private synchronized void maybeInitializeKeyImagePoller() {
-        if (keyImagePoller != null) return;
-        keyImagePoller = new XmrKeyImagePoller(xmrConnectionService.getDaemon(), getKeyImageRefreshPeriodMs());
+    private void announceOfferAdded(Offer offer) {
+        xmrConnectionService.getKeyImagePoller().addKeyImages(offer.getOfferPayload().getReserveTxKeyImages(), OfferBookService.class.getSimpleName());
+        updateReservedFundsSpentStatus(offer);
+        synchronized (offerBookChangedListeners) {
+            offerBookChangedListeners.forEach(listener -> listener.onAdded(offer));
+        }
+    }
 
-        // handle when key images spent
-        keyImagePoller.addListener(new XmrKeyImageListener() {
-            @Override
-            public void onSpentStatusChanged(Map<String, MoneroKeyImageSpentStatus> spentStatuses) {
-                UserThread.execute(() -> {
-                    for (String keyImage : spentStatuses.keySet()) {
-                        updateAffectedOffers(keyImage);
-                    }
-                });
+    private void announceOfferRemoved(Offer offer) {
+        updateReservedFundsSpentStatus(offer);
+        removeKeyImages(offer);
+        synchronized (offerBookChangedListeners) {
+            offerBookChangedListeners.forEach(listener -> listener.onRemoved(offer));
+        }
+
+        // check if invalid offers are now valid
+        synchronized (invalidOffers) {
+            for (Offer invalidOffer : new ArrayList<Offer>(invalidOffers)) {
+                try {
+                    validateOfferPayload(invalidOffer.getOfferPayload());
+                    removeInvalidOffer(invalidOffer.getId());
+                    replaceValidOffer(invalidOffer);
+                    announceOfferAdded(invalidOffer);
+                } catch (Exception e) {
+                    // ignore
+                }
             }
-        });
-
-        // first poll after 20s
-        // TODO: remove?
-        new Thread(() -> {
-            HavenoUtils.waitFor(20000);
-            keyImagePoller.poll();
-        }).start();
+        }
     }
 
-    private long getKeyImageRefreshPeriodMs() {
-        return xmrConnectionService.isConnectionLocalHost() ? KEY_IMAGE_REFRESH_PERIOD_MS_LOCAL : KEY_IMAGE_REFRESH_PERIOD_MS_REMOTE;
+    private boolean hasValidOffer(String offerId) {
+        for (Offer offer : getOffers()) {
+            if (offer.getId().equals(offerId)) {
+                return true;
+            }
+        }
+        return false;
+    }
+    
+    private void replaceValidOffer(Offer offer) {
+        synchronized (validOffers) {
+            removeValidOffer(offer.getId());
+            validOffers.add(offer);
+        }
     }
 
+    private void replaceInvalidOffer(Offer offer) {
+        synchronized (invalidOffers) {
+            removeInvalidOffer(offer.getId());
+            invalidOffers.add(offer);
+
+            // remove invalid offer after timeout
+            synchronized (invalidOfferTimers) {
+                Timer timer = invalidOfferTimers.get(offer.getId());
+                if (timer != null) timer.stop();
+                timer = UserThread.runAfter(() -> {
+                    removeInvalidOffer(offer.getId());
+                }, INVALID_OFFERS_TIMEOUT);
+                invalidOfferTimers.put(offer.getId(), timer);
+            }
+        }
+    }
+
+    private void removeValidOffer(String offerId) {
+        synchronized (validOffers) {
+            validOffers.removeIf(offer -> offer.getId().equals(offerId));
+        }
+    }
+
+    private void removeInvalidOffer(String offerId) {
+        synchronized (invalidOffers) {
+            invalidOffers.removeIf(offer -> offer.getId().equals(offerId));
+
+            // remove timeout
+            synchronized (invalidOfferTimers) {
+                Timer timer = invalidOfferTimers.get(offerId);
+                if (timer != null) timer.stop();
+                invalidOfferTimers.remove(offerId);
+            }
+        }
+    }
+
+    private void validateOfferPayload(OfferPayload offerPayload) {
+
+        // validate offer is not banned
+        if (filterManager.isOfferIdBanned(offerPayload.getId())) {
+            throw new IllegalArgumentException("Offer is banned with offerId=" + offerPayload.getId());
+        }
+
+        // validate v3 node address compliance
+        boolean isV3NodeAddressCompliant = !OfferRestrictions.requiresNodeAddressUpdate() || Utils.isV3Address(offerPayload.getOwnerNodeAddress().getHostName());
+        if (!isV3NodeAddressCompliant) {
+            throw new IllegalArgumentException("Offer with non-V3 node address is not allowed with offerId=" + offerPayload.getId());
+        }
+
+        // validate against existing offers
+        synchronized (validOffers) {
+            int numOffersWithSharedKeyImages = 0;
+            for (Offer offer : validOffers) {
+
+                // validate that no offer has overlapping but different key images
+                if (!offer.getOfferPayload().getReserveTxKeyImages().equals(offerPayload.getReserveTxKeyImages()) && 
+                        !Collections.disjoint(offer.getOfferPayload().getReserveTxKeyImages(), offerPayload.getReserveTxKeyImages())) {
+                    throw new RuntimeException("Offer with overlapping key images already exists with offerId=" + offer.getId());
+                }
+    
+                // validate that no offer has same key images, payment method, and currency
+                if (!offer.getId().equals(offerPayload.getId()) && 
+                        offer.getOfferPayload().getReserveTxKeyImages().equals(offerPayload.getReserveTxKeyImages()) &&
+                        offer.getOfferPayload().getPaymentMethodId().equals(offerPayload.getPaymentMethodId()) &&
+                        offer.getOfferPayload().getBaseCurrencyCode().equals(offerPayload.getBaseCurrencyCode()) &&
+                        offer.getOfferPayload().getCounterCurrencyCode().equals(offerPayload.getCounterCurrencyCode())) {
+                    throw new RuntimeException("Offer with same key images, payment method, and currency already exists with offerId=" + offer.getId());
+                }
+    
+                // count offers with same key images
+                if (!offer.getId().equals(offerPayload.getId()) && !Collections.disjoint(offer.getOfferPayload().getReserveTxKeyImages(), offerPayload.getReserveTxKeyImages())) numOffersWithSharedKeyImages = Math.max(2, numOffersWithSharedKeyImages + 1);
+            }
+    
+            // validate max offers with same key images
+            if (numOffersWithSharedKeyImages > Restrictions.MAX_OFFERS_WITH_SHARED_FUNDS) throw new RuntimeException("More than " + Restrictions.MAX_OFFERS_WITH_SHARED_FUNDS + " offers exist with same same key images as new offerId=" + offerPayload.getId());
+        }
+    }
+
+    private void removeKeyImages(Offer offer) {
+        Set<String> unsharedKeyImages = new HashSet<>(offer.getOfferPayload().getReserveTxKeyImages());
+        synchronized (validOffers) {
+            for (Offer validOffer : validOffers) {
+                if (validOffer.getId().equals(offer.getId())) continue;
+                unsharedKeyImages.removeAll(validOffer.getOfferPayload().getReserveTxKeyImages());
+            }
+        }
+        xmrConnectionService.getKeyImagePoller().removeKeyImages(unsharedKeyImages, OfferBookService.class.getSimpleName());
+    }
+    
     private void updateAffectedOffers(String keyImage) {
         for (Offer offer : getOffers()) {
             if (offer.getOfferPayload().getReserveTxKeyImages().contains(keyImage)) {
+                updateReservedFundsSpentStatus(offer);
                 synchronized (offerBookChangedListeners) {
                     offerBookChangedListeners.forEach(listener -> {
                         listener.onRemoved(offer);
@@ -315,10 +433,9 @@ public class OfferBookService {
         }
     }
 
-    private void setReservedFundsSpent(Offer offer) {
-        if (keyImagePoller == null) return;
+    private void updateReservedFundsSpentStatus(Offer offer) {
         for (String keyImage : offer.getOfferPayload().getReserveTxKeyImages()) {
-            if (Boolean.TRUE.equals(keyImagePoller.isSpent(keyImage))) {
+            if (Boolean.TRUE.equals(xmrConnectionService.getKeyImagePoller().isSpent(keyImage))) {
                 offer.setReservedFundsSpent(true);
             }
         }
diff --git a/core/src/main/java/haveno/core/offer/OfferPayload.java b/core/src/main/java/haveno/core/offer/OfferPayload.java
index 8da91b4b..1db2cca9 100644
--- a/core/src/main/java/haveno/core/offer/OfferPayload.java
+++ b/core/src/main/java/haveno/core/offer/OfferPayload.java
@@ -347,6 +347,10 @@ public final class OfferPayload implements ProtectedStoragePayload, ExpirablePay
         return Restrictions.getMinSecurityDeposit().max(securityDepositUnadjusted);
     }
 
+    public boolean isBuyerAsTakerWithoutDeposit() {
+        return getDirection() == OfferDirection.SELL && getBuyerSecurityDepositPct() == 0;
+    }
+
     ///////////////////////////////////////////////////////////////////////////////////////////
     // PROTO BUFFER
     ///////////////////////////////////////////////////////////////////////////////////////////
diff --git a/core/src/main/java/haveno/core/offer/OpenOffer.java b/core/src/main/java/haveno/core/offer/OpenOffer.java
index fc4365ec..f493b1b5 100644
--- a/core/src/main/java/haveno/core/offer/OpenOffer.java
+++ b/core/src/main/java/haveno/core/offer/OpenOffer.java
@@ -48,6 +48,7 @@ import java.util.ArrayList;
 import java.util.Date;
 import java.util.List;
 import java.util.Optional;
+import java.util.UUID;
 
 @EqualsAndHashCode
 public final class OpenOffer implements Tradable {
@@ -113,6 +114,9 @@ public final class OpenOffer implements Tradable {
     @Getter
     @Setter
     private boolean deactivatedByTrigger;
+    @Getter
+    @Setter
+    private String groupId;
 
     public OpenOffer(Offer offer) {
         this(offer, 0, false);
@@ -127,6 +131,7 @@ public final class OpenOffer implements Tradable {
         this.triggerPrice = triggerPrice;
         this.reserveExactAmount = reserveExactAmount;
         this.challenge = offer.getChallenge();
+        this.groupId = UUID.randomUUID().toString();
         state = State.PENDING;
     }
 
@@ -146,6 +151,7 @@ public final class OpenOffer implements Tradable {
         this.reserveTxKey = openOffer.reserveTxKey;
         this.challenge = openOffer.challenge;
         this.deactivatedByTrigger = openOffer.deactivatedByTrigger;
+        this.groupId = openOffer.groupId;
     }
 
     ///////////////////////////////////////////////////////////////////////////////////////////
@@ -164,7 +170,8 @@ public final class OpenOffer implements Tradable {
                       @Nullable String reserveTxHex,
                       @Nullable String reserveTxKey,
                       @Nullable String challenge,
-                      boolean deactivatedByTrigger) {
+                      boolean deactivatedByTrigger,
+                      @Nullable String groupId) {
         this.offer = offer;
         this.state = state;
         this.triggerPrice = triggerPrice;
@@ -177,6 +184,8 @@ public final class OpenOffer implements Tradable {
         this.reserveTxKey = reserveTxKey;
         this.challenge = challenge;
         this.deactivatedByTrigger = deactivatedByTrigger;
+        if (groupId == null) groupId = UUID.randomUUID().toString(); // initialize groupId if not set (added in v1.0.19)
+        this.groupId = groupId;
 
         // reset reserved state to available
         if (this.state == State.RESERVED) setState(State.AVAILABLE);
@@ -199,6 +208,7 @@ public final class OpenOffer implements Tradable {
         Optional.ofNullable(reserveTxHex).ifPresent(e -> builder.setReserveTxHex(reserveTxHex));
         Optional.ofNullable(reserveTxKey).ifPresent(e -> builder.setReserveTxKey(reserveTxKey));
         Optional.ofNullable(challenge).ifPresent(e -> builder.setChallenge(challenge));
+        Optional.ofNullable(groupId).ifPresent(e -> builder.setGroupId(groupId));
 
         return protobuf.Tradable.newBuilder().setOpenOffer(builder).build();
     }
@@ -216,7 +226,8 @@ public final class OpenOffer implements Tradable {
                 ProtoUtil.stringOrNullFromProto(proto.getReserveTxHex()),
                 ProtoUtil.stringOrNullFromProto(proto.getReserveTxKey()),
                 ProtoUtil.stringOrNullFromProto(proto.getChallenge()),
-                proto.getDeactivatedByTrigger());
+                proto.getDeactivatedByTrigger(),
+                ProtoUtil.stringOrNullFromProto(proto.getGroupId()));
         return openOffer;
     }
 
@@ -282,6 +293,7 @@ public final class OpenOffer implements Tradable {
                 ",\n     reserveExactAmount=" + reserveExactAmount +
                 ",\n     scheduledAmount=" + scheduledAmount +
                 ",\n     splitOutputTxFee=" + splitOutputTxFee +
+                ",\n     groupId=" + groupId +
                 "\n}";
     }
 }
diff --git a/core/src/main/java/haveno/core/offer/OpenOfferManager.java b/core/src/main/java/haveno/core/offer/OpenOfferManager.java
index e68f90e4..c6a5847f 100644
--- a/core/src/main/java/haveno/core/offer/OpenOfferManager.java
+++ b/core/src/main/java/haveno/core/offer/OpenOfferManager.java
@@ -55,7 +55,7 @@ import haveno.core.api.CoreContext;
 import haveno.core.api.XmrConnectionService;
 import haveno.core.exceptions.TradePriceOutOfToleranceException;
 import haveno.core.filter.FilterManager;
-import haveno.core.offer.OfferBookService.OfferBookChangedListener;
+import haveno.core.locale.Res;
 import haveno.core.offer.messages.OfferAvailabilityRequest;
 import haveno.core.offer.messages.OfferAvailabilityResponse;
 import haveno.core.offer.messages.SignOfferRequest;
@@ -97,7 +97,6 @@ import haveno.network.p2p.peers.PeerManager;
 import java.math.BigInteger;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -136,6 +135,9 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
     private static final long REPUBLISH_INTERVAL_MS = TimeUnit.MINUTES.toMillis(30);
     private static final long REFRESH_INTERVAL_MS = OfferPayload.TTL / 2;
     private static final int NUM_ATTEMPTS_THRESHOLD = 5; // process offer only on republish cycle after this many attempts
+    private static final long SHUTDOWN_TIMEOUT_MS = 60000;
+    private static final String OPEN_OFFER_GROUP_KEY_IMAGE_ID = OpenOffer.class.getSimpleName();
+    private static final String SIGNED_OFFER_KEY_IMAGE_GROUP_ID = SignedOffer.class.getSimpleName();
 
     private final CoreContext coreContext;
     private final KeyRing keyRing;
@@ -169,12 +171,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
     @Getter
     private final AccountAgeWitnessService accountAgeWitnessService;
 
-    // poll key images of signed offers
-    private XmrKeyImagePoller signedOfferKeyImagePoller;
-    private static final long SHUTDOWN_TIMEOUT_MS = 60000;
-    private static final long KEY_IMAGE_REFRESH_PERIOD_MS_LOCAL = 20000; // 20 seconds
-    private static final long KEY_IMAGE_REFRESH_PERIOD_MS_REMOTE = 300000; // 5 minutes
-
     private Object processOffersLock = new Object(); // lock for processing offers
 
 
@@ -227,27 +223,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
 
         this.persistenceManager.initialize(openOffers, "OpenOffers", PersistenceManager.Source.PRIVATE);
         this.signedOfferPersistenceManager.initialize(signedOffers, "SignedOffers", PersistenceManager.Source.PRIVATE); // arbitrator stores reserve tx for signed offers
-
-        // listen for connection changes to monerod
-        xmrConnectionService.addConnectionListener((connection) -> maybeInitializeKeyImagePoller());
-
-        // close open offer if reserved funds spent
-        offerBookService.addOfferBookChangedListener(new OfferBookChangedListener() {
-            @Override
-            public void onAdded(Offer offer) {
-
-                // cancel offer if reserved funds spent
-                Optional<OpenOffer> openOfferOptional = getOpenOffer(offer.getId());
-                if (openOfferOptional.isPresent() && openOfferOptional.get().getState() != OpenOffer.State.RESERVED && offer.isReservedFundsSpent()) {
-                    log.warn("Canceling open offer because reserved funds have been spent, offerId={}, state={}", offer.getId(), openOfferOptional.get().getState());
-                    cancelOpenOffer(openOfferOptional.get(), null, null);
-                }
-            }
-            @Override
-            public void onRemoved(Offer offer) {
-                // nothing to do
-            }
-        });
     }
 
     @Override
@@ -268,34 +243,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
         completeHandler);
     }
 
-    private synchronized void maybeInitializeKeyImagePoller() {
-        if (signedOfferKeyImagePoller != null) return;
-        signedOfferKeyImagePoller = new XmrKeyImagePoller(xmrConnectionService.getDaemon(), getKeyImageRefreshPeriodMs());
-
-        // handle when key images confirmed spent
-        signedOfferKeyImagePoller.addListener(new XmrKeyImageListener() {
-            @Override
-            public void onSpentStatusChanged(Map<String, MoneroKeyImageSpentStatus> spentStatuses) {
-                for (Entry<String, MoneroKeyImageSpentStatus> entry : spentStatuses.entrySet()) {
-                    if (entry.getValue() == MoneroKeyImageSpentStatus.CONFIRMED) {
-                        removeSignedOffers(entry.getKey());
-                    }
-                }
-            }
-        });
-
-        // first poll in 5s
-        // TODO: remove?
-        new Thread(() -> {
-            HavenoUtils.waitFor(5000);
-            signedOfferKeyImagePoller.poll();
-        }).start();
-    }
-
-    private long getKeyImageRefreshPeriodMs() {
-        return xmrConnectionService.isConnectionLocalHost() ? KEY_IMAGE_REFRESH_PERIOD_MS_LOCAL : KEY_IMAGE_REFRESH_PERIOD_MS_REMOTE;
-    }
-
     public void onAllServicesInitialized() {
         p2PService.addDecryptedDirectMessageListener(this);
 
@@ -330,7 +277,8 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
         stopped = true;
         p2PService.getPeerManager().removeListener(this);
         p2PService.removeDecryptedDirectMessageListener(this);
-        if (signedOfferKeyImagePoller != null) signedOfferKeyImagePoller.clearKeyImages();
+        xmrConnectionService.getKeyImagePoller().removeKeyImages(OPEN_OFFER_GROUP_KEY_IMAGE_ID);
+        xmrConnectionService.getKeyImagePoller().removeKeyImages(SIGNED_OFFER_KEY_IMAGE_GROUP_ID);
 
         stopPeriodicRefreshOffersTimer();
         stopPeriodicRepublishOffersTimer();
@@ -385,11 +333,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
         removeOpenOffers(getObservableList(), completeHandler);
     }
 
-    public void removeOpenOffer(OpenOffer openOffer, @Nullable Runnable completeHandler) {
-        removeOpenOffers(List.of(openOffer), completeHandler);
-    }
-
-    public void removeOpenOffers(List<OpenOffer> openOffers, @Nullable Runnable completeHandler) {
+    private void removeOpenOffers(List<OpenOffer> openOffers, @Nullable Runnable completeHandler) {
         int size = openOffers.size();
         // Copy list as we remove in the loop
         List<OpenOffer> openOffersList = new ArrayList<>(openOffers);
@@ -442,6 +386,19 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
 
         maybeUpdatePersistedOffers();
 
+        // listen for spent key images to close open and signed offers
+        xmrConnectionService.getKeyImagePoller().addListener(new XmrKeyImageListener() {
+            @Override
+            public void onSpentStatusChanged(Map<String, MoneroKeyImageSpentStatus> spentStatuses) {
+                for (Entry<String, MoneroKeyImageSpentStatus> entry : spentStatuses.entrySet()) {
+                    if (XmrKeyImagePoller.isSpent(entry.getValue())) {
+                        cancelOpenOffersOnSpent(entry.getKey());
+                        removeSignedOffers(entry.getKey());
+                    }
+                }
+            }
+        });
+
         // run off user thread so app is not blocked from starting
         ThreadUtils.submitToPool(() -> {
 
@@ -492,12 +449,14 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
                     }
                 });
 
-                // initialize key image poller for signed offers
-                maybeInitializeKeyImagePoller();
+                // poll spent status of open offer key images
+                for (OpenOffer openOffer : getOpenOffers()) {
+                    xmrConnectionService.getKeyImagePoller().addKeyImages(openOffer.getOffer().getOfferPayload().getReserveTxKeyImages(), OPEN_OFFER_GROUP_KEY_IMAGE_ID);
+                }
 
-                // poll spent status of key images
+                // poll spent status of signed offer key images
                 for (SignedOffer signedOffer : signedOffers.getList()) {
-                    signedOfferKeyImagePoller.addKeyImages(signedOffer.getReserveTxKeyImages());
+                    xmrConnectionService.getKeyImagePoller().addKeyImages(signedOffer.getReserveTxKeyImages(), SIGNED_OFFER_KEY_IMAGE_GROUP_ID);
                 }
             }, THREAD_ID);
         });
@@ -544,17 +503,59 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
                            long triggerPrice,
                            boolean reserveExactAmount,
                            boolean resetAddressEntriesOnError,
+                           String sourceOfferId,
                            TransactionResultHandler resultHandler,
                            ErrorMessageHandler errorMessageHandler) {
 
+        // check source offer and clone limit
+        OpenOffer sourceOffer = null;
+        if (sourceOfferId != null) {
+
+            // get source offer
+            Optional<OpenOffer> sourceOfferOptional = getOpenOffer(sourceOfferId);
+            if (!sourceOfferOptional.isPresent()) {
+                errorMessageHandler.handleErrorMessage("Source offer not found to clone, offerId=" + sourceOfferId);
+                return;
+            }
+            sourceOffer = sourceOfferOptional.get();
+    
+            // check clone limit
+            int numClones = getOpenOfferGroup(sourceOffer.getGroupId()).size();
+            if (numClones >= Restrictions.MAX_OFFERS_WITH_SHARED_FUNDS) {
+                errorMessageHandler.handleErrorMessage("Cannot create offer because maximum number of " + Restrictions.MAX_OFFERS_WITH_SHARED_FUNDS + " cloned offers with shared funds reached.");
+                return;
+            }
+        }
+
         // create open offer
-        OpenOffer openOffer = new OpenOffer(offer, triggerPrice, reserveExactAmount);
+        OpenOffer openOffer = new OpenOffer(offer, triggerPrice, sourceOffer == null ? reserveExactAmount : sourceOffer.isReserveExactAmount());
+
+        // set state from source offer
+        if (sourceOffer != null) {
+            openOffer.setReserveTxHash(sourceOffer.getReserveTxHash());
+            openOffer.setReserveTxHex(sourceOffer.getReserveTxHex());
+            openOffer.setReserveTxKey(sourceOffer.getReserveTxKey());
+            openOffer.setGroupId(sourceOffer.getGroupId());
+            openOffer.getOffer().getOfferPayload().setReserveTxKeyImages(sourceOffer.getOffer().getOfferPayload().getReserveTxKeyImages());
+            xmrWalletService.cloneAddressEntries(sourceOffer.getOffer().getId(), openOffer.getOffer().getId());
+            if (hasConflictingClone(openOffer)) openOffer.setState(OpenOffer.State.DEACTIVATED);
+        }
+
+        // add the open offer
+        synchronized (processOffersLock) {
+            addOpenOffer(openOffer);
+        }
+
+        // done if source offer is pending
+        if (sourceOffer != null && sourceOffer.isPending()) {
+            resultHandler.handleResult(null);
+            return;
+        }
 
         // schedule or post offer
         ThreadUtils.execute(() -> {
             synchronized (processOffersLock) {
                 CountDownLatch latch = new CountDownLatch(1);
-                addOpenOffer(openOffer);
                 processOffer(getOpenOffers(), openOffer, (transaction) -> {
                     requestPersistence();
                     latch.countDown();
@@ -591,18 +592,30 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
         if (openOffer.isPending()) {
             resultHandler.handleResult(); // ignore if pending
         } else if (offersToBeEdited.containsKey(openOffer.getId())) {
-            errorMessageHandler.handleErrorMessage("You can't activate an offer that is currently edited.");
+            errorMessageHandler.handleErrorMessage(Res.get("offerbook.cannotActivateEditedOffer.warning"));
+        } else if (hasConflictingClone(openOffer)) {
+            errorMessageHandler.handleErrorMessage(Res.get("offerbook.hasConflictingClone.warning"));
         } else {
-            Offer offer = openOffer.getOffer();
-            offerBookService.activateOffer(offer,
-                    () -> {
-                        openOffer.setState(OpenOffer.State.AVAILABLE);
-                        applyTriggerState(openOffer);
-                        requestPersistence();
-                        log.debug("activateOpenOffer, offerId={}", offer.getId());
-                        resultHandler.handleResult();
-                    },
-                    errorMessageHandler);
+            try {
+
+                // validate arbitrator signature
+                validateSignedState(openOffer);
+
+                // activate offer on offer book
+                Offer offer = openOffer.getOffer();
+                offerBookService.activateOffer(offer,
+                        () -> {
+                            openOffer.setState(OpenOffer.State.AVAILABLE);
+                            applyTriggerState(openOffer);
+                            requestPersistence();
+                            log.debug("activateOpenOffer, offerId={}", offer.getId());
+                            resultHandler.handleResult();
+                        },
+                        errorMessageHandler);
+            } catch (Exception e) {
+                errorMessageHandler.handleErrorMessage(e.getMessage());
+                return;
+            }
         }
     }
 
@@ -655,7 +668,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
                 });
             }
         } else {
-            if (errorMessageHandler != null) errorMessageHandler.handleErrorMessage("You can't remove an offer that is currently edited.");
+            if (errorMessageHandler != null) errorMessageHandler.handleErrorMessage("You can't cancel an offer that is currently edited.");
         }
     }
 
@@ -699,29 +712,44 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
 
             OpenOffer editedOpenOffer = new OpenOffer(editedOffer, triggerPrice, openOffer);
             if (originalState == OpenOffer.State.DEACTIVATED && openOffer.isDeactivatedByTrigger()) {
-                editedOpenOffer.setState(OpenOffer.State.AVAILABLE);
+                if (hasConflictingClone(editedOpenOffer)) {
+                    editedOpenOffer.setState(OpenOffer.State.DEACTIVATED);
+                } else {
+                    editedOpenOffer.setState(OpenOffer.State.AVAILABLE);
+                }
                 applyTriggerState(editedOpenOffer);
             } else {
-                editedOpenOffer.setState(originalState);
+                if (originalState == OpenOffer.State.AVAILABLE && hasConflictingClone(editedOpenOffer)) {
+                    editedOpenOffer.setState(OpenOffer.State.DEACTIVATED);
+                } else {
+                    editedOpenOffer.setState(originalState);
+                }
             }
 
             addOpenOffer(editedOpenOffer);
 
-            // reset arbitrator signature if invalid
+            // check for valid arbitrator signature after editing
             Arbitrator arbitrator = user.getAcceptedArbitratorByAddress(editedOpenOffer.getOffer().getOfferPayload().getArbitratorSigner());
             if (arbitrator == null || !HavenoUtils.isArbitratorSignatureValid(editedOpenOffer.getOffer().getOfferPayload(), arbitrator)) {
+
+                // reset arbitrator signature
                 editedOpenOffer.getOffer().getOfferPayload().setArbitratorSignature(null);
                 editedOpenOffer.getOffer().getOfferPayload().setArbitratorSigner(null);
-            }
 
-            // process offer which might sign and publish
-            processOffer(getOpenOffers(), editedOpenOffer, (transaction) -> {
+                // process offer to sign and publish
+                processOffer(getOpenOffers(), editedOpenOffer, (transaction) -> {
+                    offersToBeEdited.remove(openOffer.getId());
+                    requestPersistence();
+                    resultHandler.handleResult();
+                }, (errorMsg) -> {
+                    errorMessageHandler.handleErrorMessage(errorMsg);
+                });
+            } else {
+                maybeRepublishOffer(editedOpenOffer, null);
                 offersToBeEdited.remove(openOffer.getId());
                 requestPersistence();
                 resultHandler.handleResult();
-            }, (errorMsg) -> {
-                errorMessageHandler.handleErrorMessage(errorMsg);
-            });
+            }
         } else {
             errorMessageHandler.handleErrorMessage("There is no offer with this id existing to be published.");
         }
@@ -753,26 +781,33 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
         Offer offer = openOffer.getOffer();
         offer.setState(Offer.State.REMOVED);
         openOffer.setState(OpenOffer.State.CANCELED);
+        boolean hasClonedOffer = hasClonedOffer(offer.getId()); // record before removing open offer
         removeOpenOffer(openOffer); 
-        closedTradableManager.add(openOffer); // TODO: don't add these to closed tradables?
+        if (!hasClonedOffer) closedTradableManager.add(openOffer); // do not add clones to closed trades TODO: don't add canceled offers to closed tradables?
         if (resetAddressEntries) xmrWalletService.resetAddressEntriesForOpenOffer(offer.getId());
         requestPersistence();
-        xmrWalletService.thawOutputs(offer.getOfferPayload().getReserveTxKeyImages());
+        if (!hasClonedOffer) xmrWalletService.thawOutputs(offer.getOfferPayload().getReserveTxKeyImages());
     }
 
-    // close open offer after key images spent
-    public void closeOpenOffer(Offer offer) {
+    // close open offer group after key images spent
+    public void closeSpentOffer(Offer offer) {
         getOpenOffer(offer.getId()).ifPresent(openOffer -> {
-            removeOpenOffer(openOffer);
-            openOffer.setState(OpenOffer.State.CLOSED);
-            xmrWalletService.resetAddressEntriesForOpenOffer(offer.getId());
-            offerBookService.removeOffer(openOffer.getOffer().getOfferPayload(),
-                    () -> log.info("Successfully removed offer {}", offer.getId()),
-                    log::error);
-            requestPersistence();
+            for (OpenOffer groupOffer: getOpenOfferGroup(openOffer.getGroupId())) {
+                doCloseOpenOffer(groupOffer);
+            }
         });
     }
 
+    private void doCloseOpenOffer(OpenOffer openOffer) {
+        removeOpenOffer(openOffer);
+        openOffer.setState(OpenOffer.State.CLOSED);
+        xmrWalletService.resetAddressEntriesForOpenOffer(openOffer.getId());
+        offerBookService.removeOffer(openOffer.getOffer().getOfferPayload(),
+                () -> log.info("Successfully removed offer {}", openOffer.getId()),
+                log::error);
+        requestPersistence();
+    }
+
     public void reserveOpenOffer(OpenOffer openOffer) {
         openOffer.setState(OpenOffer.State.RESERVED);
         requestPersistence();
@@ -783,6 +818,37 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
         requestPersistence();
     }
 
+    public boolean hasConflictingClone(OpenOffer openOffer) {
+        for (OpenOffer clonedOffer : getOpenOfferGroup(openOffer.getGroupId())) {
+            if (clonedOffer.getId().equals(openOffer.getId())) continue;
+            if (clonedOffer.isDeactivated()) continue; // deactivated offers do not conflict
+
+            // pending offers later in the order do not conflict
+            List<OpenOffer> openOffers = getOpenOffers();
+            if (clonedOffer.isPending() && openOffers.indexOf(clonedOffer) > openOffers.indexOf(openOffer)) {
+                continue;
+            }
+
+            // conflicts if same payment method and currency
+            if (samePaymentMethodAndCurrency(clonedOffer.getOffer(), openOffer.getOffer())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    public boolean hasConflictingClone(Offer offer, OpenOffer sourceOffer) {
+        return getOpenOfferGroup(sourceOffer.getGroupId()).stream()
+                .filter(openOffer -> !openOffer.isDeactivated()) // we only check with activated offers
+                .anyMatch(openOffer -> samePaymentMethodAndCurrency(openOffer.getOffer(), offer));
+    }
+
+    private boolean samePaymentMethodAndCurrency(Offer offer1, Offer offer2) {
+        return offer1.getPaymentMethodId().equalsIgnoreCase(offer2.getPaymentMethodId()) &&
+                offer1.getCounterCurrencyCode().equalsIgnoreCase(offer2.getCounterCurrencyCode()) &&
+                offer1.getBaseCurrencyCode().equalsIgnoreCase(offer2.getBaseCurrencyCode());
+    }
+
     ///////////////////////////////////////////////////////////////////////////////////////////
     // Getters
     ///////////////////////////////////////////////////////////////////////////////////////////
@@ -791,7 +857,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
         return offer.isMyOffer(keyRing);
     }
 
-    public boolean hasOpenOffers() {
+    public boolean hasAvailableOpenOffers() {
         synchronized (openOffers) {
             for (OpenOffer openOffer : getOpenOffers()) {
                 if (openOffer.getState() == OpenOffer.State.AVAILABLE) {
@@ -808,13 +874,38 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
         }
     }
 
+    public List<OpenOffer> getOpenOfferGroup(String groupId) {
+        if (groupId == null) throw new IllegalArgumentException("groupId cannot be null");
+        synchronized (openOffers) {
+            return getOpenOffers().stream()
+                    .filter(openOffer -> groupId.equals(openOffer.getGroupId()))
+                    .collect(Collectors.toList());
+        }
+    }
+
+    public boolean hasClonedOffer(String offerId) {
+        OpenOffer openOffer = getOpenOffer(offerId).orElse(null);
+        if (openOffer == null) return false;
+        return getOpenOfferGroup(openOffer.getGroupId()).size() > 1;
+    }
+
+    public boolean hasClonedOffers() {
+        synchronized (openOffers) {
+            for (OpenOffer openOffer : getOpenOffers()) {
+                if (getOpenOfferGroup(openOffer.getGroupId()).size() > 1) {
+                    return true;
+                }
+            }
+            return false;
+        }
+    }
+
     public List<SignedOffer> getSignedOffers() {
         synchronized (signedOffers) {
             return new ArrayList<>(signedOffers.getObservableList());
         }
     }
 
-
     public ObservableList<SignedOffer> getObservableSignedOffersList() {
         synchronized (signedOffers) {
             return signedOffers.getObservableList();
@@ -846,6 +937,9 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
         synchronized (openOffers) {
             openOffers.add(openOffer);
         }
+        if (openOffer.getOffer().getOfferPayload().getReserveTxKeyImages() != null) {
+            xmrConnectionService.getKeyImagePoller().addKeyImages(openOffer.getOffer().getOfferPayload().getReserveTxKeyImages(), OPEN_OFFER_GROUP_KEY_IMAGE_ID);
+        }
     }
 
     private void removeOpenOffer(OpenOffer openOffer) {
@@ -857,6 +951,18 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
             PlaceOfferProtocol protocol = placeOfferProtocols.remove(openOffer.getId());
             if (protocol != null) protocol.cancelOffer();
         }
+        if (openOffer.getOffer().getOfferPayload().getReserveTxKeyImages() != null) {
+            xmrConnectionService.getKeyImagePoller().removeKeyImages(openOffer.getOffer().getOfferPayload().getReserveTxKeyImages(), OPEN_OFFER_GROUP_KEY_IMAGE_ID);
+        }
+    }
+
+    private void cancelOpenOffersOnSpent(String keyImage) {
+        for (OpenOffer openOffer : getOpenOffers()) {
+            if (openOffer.getOffer().getOfferPayload().getReserveTxKeyImages() != null && openOffer.getOffer().getOfferPayload().getReserveTxKeyImages().contains(keyImage)) {
+                log.warn("Canceling open offer because reserved funds have been spent, offerId={}, state={}", openOffer.getId(), openOffer.getState());
+                cancelOpenOffer(openOffer, null, null);
+            }
+        }
     }
 
     private void addSignedOffer(SignedOffer signedOffer) {
@@ -870,7 +976,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
 
             // add new signed offer
             signedOffers.add(signedOffer);
-            signedOfferKeyImagePoller.addKeyImages(signedOffer.getReserveTxKeyImages());
+            xmrConnectionService.getKeyImagePoller().addKeyImages(signedOffer.getReserveTxKeyImages(), SIGNED_OFFER_KEY_IMAGE_GROUP_ID);
         }
     }
 
@@ -878,7 +984,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
         log.info("Removing SignedOffer for offer {}", signedOffer.getOfferId());
         synchronized (signedOffers) {
             signedOffers.remove(signedOffer);
-            signedOfferKeyImagePoller.removeKeyImages(signedOffer.getReserveTxKeyImages());
+            xmrConnectionService.getKeyImagePoller().removeKeyImages(signedOffer.getReserveTxKeyImages(), SIGNED_OFFER_KEY_IMAGE_GROUP_ID);
         }
     }
 
@@ -900,7 +1006,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
             List<String> errorMessages = new ArrayList<String>();
             synchronized (processOffersLock) {
                 List<OpenOffer> openOffers = getOpenOffers();
-                removeOffersWithDuplicateKeyImages(openOffers);
                 for (OpenOffer offer : openOffers) {
                     if (skipOffersWithTooManyAttempts && offer.getNumProcessingAttempts() > NUM_ATTEMPTS_THRESHOLD) continue; // skip offers with too many attempts
                     CountDownLatch latch = new CountDownLatch(1);
@@ -922,28 +1027,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
         }, THREAD_ID);
     }
 
-    private void removeOffersWithDuplicateKeyImages(List<OpenOffer> openOffers) {
-
-        // collect offers with duplicate key images
-        Set<String> keyImages = new HashSet<>();
-        Set<OpenOffer> offersToRemove = new HashSet<>();
-        for (OpenOffer openOffer : openOffers) {
-            if (openOffer.getOffer().getOfferPayload().getReserveTxKeyImages() == null) continue;
-            if (Collections.disjoint(keyImages, openOffer.getOffer().getOfferPayload().getReserveTxKeyImages())) {
-                keyImages.addAll(openOffer.getOffer().getOfferPayload().getReserveTxKeyImages());
-            } else {
-                offersToRemove.add(openOffer);
-            }
-        }
-
-        // remove offers with duplicate key images
-        for (OpenOffer offerToRemove : offersToRemove) {
-            log.warn("Removing open offer which has duplicate key images with other open offers: {}", offerToRemove.getId());
-            doCancelOffer(offerToRemove);
-            openOffers.remove(offerToRemove);
-        }
-    }
-
     private void processOffer(List<OpenOffer> openOffers, OpenOffer openOffer, TransactionResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) {
         
         // skip if already processing
@@ -993,33 +1076,40 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
                     return;
                 }
 
-                // validate non-pending state
-                if (!openOffer.isPending()) {
-                    boolean isValid = true;
-                    Arbitrator arbitrator = user.getAcceptedArbitratorByAddress(openOffer.getOffer().getOfferPayload().getArbitratorSigner());
-                    if (openOffer.getOffer().getOfferPayload().getArbitratorSigner() == null) {
-                        isValid = false;
-                    } else if (arbitrator == null) {
-                        log.warn("Offer {} signed by unavailable arbitrator, reposting", openOffer.getId());
-                        isValid = false;
-                    } else if (!HavenoUtils.isArbitratorSignatureValid(openOffer.getOffer().getOfferPayload(), arbitrator)) {
-                        log.warn("Offer {} has invalid arbitrator signature, reposting", openOffer.getId());
-                        isValid = false;
+                // handle pending offer
+                if (openOffer.isPending()) {
+
+                    // only process the first offer of a pending clone group
+                    if (openOffer.getGroupId() != null) {
+                        List<OpenOffer> openOfferClones = getOpenOfferGroup(openOffer.getGroupId());
+                        if (openOfferClones.size() > 1 && !openOfferClones.get(0).getId().equals(openOffer.getId()) && openOfferClones.get(0).isPending()) {
+                            resultHandler.handleResult(null);
+                            return;
+                        }
                     }
-                    if ((openOffer.getOffer().getOfferPayload().getReserveTxKeyImages() != null || openOffer.getOffer().getOfferPayload().getReserveTxKeyImages().isEmpty()) && (openOffer.getReserveTxHash() == null || openOffer.getReserveTxHash().isEmpty())) {
-                        log.warn("Offer {} is missing reserve tx hash but has reserved key images, reposting", openOffer.getId());
-                        isValid = false;
-                    }
-                    if (isValid) {
-                        resultHandler.handleResult(null);
+                } else {
+
+                    // validate non-pending state
+                    try {
+                        validateSignedState(openOffer);
+                        resultHandler.handleResult(null); // done processing if non-pending state is valid
                         return;
-                    } else {
+                    } catch (Exception e) {
+                        log.warn(e.getMessage());
+
+                        // reset arbitrator signature
                         openOffer.getOffer().getOfferPayload().setArbitratorSignature(null);
                         openOffer.getOffer().getOfferPayload().setArbitratorSigner(null);
                         if (openOffer.isAvailable()) openOffer.setState(OpenOffer.State.PENDING);
                     }
                 }
 
+                // sign and post offer if already funded
+                if (openOffer.getReserveTxHash() != null) {
+                    signAndPostOffer(openOffer, false, resultHandler, errorMessageHandler);
+                    return;
+                }
+
                 // cancel offer if scheduled txs unavailable
                 if (openOffer.getScheduledTxHashes() != null) {
                     boolean scheduledTxsAvailable = true;
@@ -1037,12 +1127,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
                     }
                 }
 
-                // sign and post offer if already funded
-                if (openOffer.getReserveTxHash() != null) {
-                    signAndPostOffer(openOffer, false, resultHandler, errorMessageHandler);
-                    return;
-                }
-
                 // get amount needed to reserve offer
                 BigInteger amountNeeded = openOffer.getOffer().getAmountNeeded();
 
@@ -1084,6 +1168,21 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
         }).start();
     }
 
+    private void validateSignedState(OpenOffer openOffer) {
+        Arbitrator arbitrator = user.getAcceptedArbitratorByAddress(openOffer.getOffer().getOfferPayload().getArbitratorSigner());
+        if (openOffer.getOffer().getOfferPayload().getArbitratorSigner() == null) {
+            throw new IllegalArgumentException("Offer " + openOffer.getId() + " has no arbitrator signer");
+        } else if (openOffer.getOffer().getOfferPayload().getArbitratorSignature() == null) {
+            throw new IllegalArgumentException("Offer " + openOffer.getId() + " has no arbitrator signature");
+        } else if (arbitrator == null) {
+            throw new IllegalArgumentException("Offer " + openOffer.getId() + " signed by unavailable arbitrator");
+        } else if (!HavenoUtils.isArbitratorSignatureValid(openOffer.getOffer().getOfferPayload(), arbitrator)) {
+            throw new IllegalArgumentException("Offer " + openOffer.getId() + " has invalid arbitrator signature");
+        } else if (openOffer.getOffer().getOfferPayload().getReserveTxKeyImages() == null || openOffer.getOffer().getOfferPayload().getReserveTxKeyImages().isEmpty() || openOffer.getReserveTxHash() == null || openOffer.getReserveTxHash().isEmpty()) {
+            throw new IllegalArgumentException("Offer " + openOffer.getId() + " is missing reserve tx hash or key images");
+        }
+    }
+
     private MoneroTxWallet getSplitOutputFundingTx(List<OpenOffer> openOffers, OpenOffer openOffer) {
         XmrAddressEntry addressEntry = xmrWalletService.getOrCreateAddressEntry(openOffer.getId(), XmrAddressEntry.Context.OFFER_FUNDING);
         return getSplitOutputFundingTx(openOffers, openOffer, openOffer.getOffer().getAmountNeeded(), addressEntry.getSubaddressIndex());
@@ -2047,7 +2146,10 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
 
     private boolean preventedFromPublishing(OpenOffer openOffer) {
         if (!Boolean.TRUE.equals(xmrConnectionService.isConnected())) return true;
-        return openOffer.isDeactivated() || openOffer.isCanceled() || openOffer.getOffer().getOfferPayload().getArbitratorSigner() == null;
+        return openOffer.isDeactivated() ||
+                openOffer.isCanceled() ||
+                openOffer.getOffer().getOfferPayload().getArbitratorSigner() == null ||
+                hasConflictingClone(openOffer);
     }
 
     private void startPeriodicRepublishOffersTimer() {
diff --git a/core/src/main/java/haveno/core/offer/placeoffer/tasks/MakerReserveOfferFunds.java b/core/src/main/java/haveno/core/offer/placeoffer/tasks/MakerReserveOfferFunds.java
index e873d1e5..60eaa64c 100644
--- a/core/src/main/java/haveno/core/offer/placeoffer/tasks/MakerReserveOfferFunds.java
+++ b/core/src/main/java/haveno/core/offer/placeoffer/tasks/MakerReserveOfferFunds.java
@@ -19,7 +19,9 @@ package haveno.core.offer.placeoffer.tasks;
 
 import java.math.BigInteger;
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 
 import haveno.common.taskrunner.Task;
 import haveno.common.taskrunner.TaskRunner;
@@ -78,6 +80,12 @@ public class MakerReserveOfferFunds extends Task<PlaceOfferModel> {
                 XmrAddressEntry fundingEntry = model.getXmrWalletService().getAddressEntry(offer.getId(), XmrAddressEntry.Context.OFFER_FUNDING).orElse(null);
                 Integer preferredSubaddressIndex = fundingEntry == null ? null : fundingEntry.getSubaddressIndex();
 
+                // copy address entries to clones
+                for (OpenOffer offerClone : model.getOpenOfferManager().getOpenOfferGroup(model.getOpenOffer().getGroupId())) {
+                    if (offerClone.getId().equals(offer.getId())) continue; // skip self
+                    model.getXmrWalletService().cloneAddressEntries(openOffer.getId(), offerClone.getId());
+                }
+
                 // attempt creating reserve tx
                 MoneroTxWallet reserveTx = null;
                 try {
@@ -120,23 +128,42 @@ public class MakerReserveOfferFunds extends Task<PlaceOfferModel> {
                 List<String> reservedKeyImages = new ArrayList<String>();
                 for (MoneroOutput input : reserveTx.getInputs()) reservedKeyImages.add(input.getKeyImage().getHex());
 
-                // update offer state
-                openOffer.setReserveTxHash(reserveTx.getHash());
-                openOffer.setReserveTxHex(reserveTx.getFullHex());
-                openOffer.setReserveTxKey(reserveTx.getKey());
-                offer.getOfferPayload().setReserveTxKeyImages(reservedKeyImages);
+                // update offer state including clones
+                if (openOffer.getGroupId() == null) {
+                    openOffer.setReserveTxHash(reserveTx.getHash());
+                    openOffer.setReserveTxHex(reserveTx.getFullHex());
+                    openOffer.setReserveTxKey(reserveTx.getKey());
+                    offer.getOfferPayload().setReserveTxKeyImages(reservedKeyImages);
+                } else {
+                    for (OpenOffer offerClone : model.getOpenOfferManager().getOpenOfferGroup(model.getOpenOffer().getGroupId())) {
+                        offerClone.setReserveTxHash(reserveTx.getHash());
+                        offerClone.setReserveTxHex(reserveTx.getFullHex());
+                        offerClone.setReserveTxKey(reserveTx.getKey());
+                        offerClone.getOffer().getOfferPayload().setReserveTxKeyImages(reservedKeyImages);
+                    }
+                }
 
-                // reset offer funding address entry if unused
+                // reset offer funding address entries if unused
                 if (fundingEntry != null) {
+
+                    // get reserve tx inputs
                     List<MoneroOutputWallet> inputs = model.getXmrWalletService().getOutputs(reservedKeyImages);
-                    boolean usesFundingEntry = false;
+
+                    // collect subaddress indices of inputs
+                    Set<Integer> inputSubaddressIndices = new HashSet<>();
                     for (MoneroOutputWallet input : inputs) {
-                        if (input.getAccountIndex() == 0 && input.getSubaddressIndex() == fundingEntry.getSubaddressIndex()) {
-                            usesFundingEntry = true;
-                            break;
+                        if (input.getAccountIndex() == 0) inputSubaddressIndices.add(input.getSubaddressIndex());
+                    }
+
+                    // swap funding address entries to available if unused
+                    for (OpenOffer clone : model.getOpenOfferManager().getOpenOfferGroup(model.getOpenOffer().getGroupId())) {
+                        XmrAddressEntry cloneFundingEntry = model.getXmrWalletService().getAddressEntry(clone.getId(), XmrAddressEntry.Context.OFFER_FUNDING).orElse(null);
+                        if (cloneFundingEntry != null && !inputSubaddressIndices.contains(cloneFundingEntry.getSubaddressIndex())) {
+                            if (inputSubaddressIndices.contains(cloneFundingEntry.getSubaddressIndex())) {
+                                model.getXmrWalletService().swapAddressEntryToAvailable(offer.getId(), XmrAddressEntry.Context.OFFER_FUNDING);
+                            }
                         }
                     }
-                    if (!usesFundingEntry) model.getXmrWalletService().swapAddressEntryToAvailable(offer.getId(), XmrAddressEntry.Context.OFFER_FUNDING);
                 }
             }
             complete();
diff --git a/core/src/main/java/haveno/core/offer/placeoffer/tasks/MaybeAddToOfferBook.java b/core/src/main/java/haveno/core/offer/placeoffer/tasks/MaybeAddToOfferBook.java
index 8e3e3c23..2f8a1010 100644
--- a/core/src/main/java/haveno/core/offer/placeoffer/tasks/MaybeAddToOfferBook.java
+++ b/core/src/main/java/haveno/core/offer/placeoffer/tasks/MaybeAddToOfferBook.java
@@ -36,6 +36,16 @@ public class MaybeAddToOfferBook extends Task<PlaceOfferModel> {
         try {
             runInterceptHook();
             checkNotNull(model.getSignOfferResponse().getSignedOfferPayload().getArbitratorSignature(), "Offer's arbitrator signature is null: " + model.getOpenOffer().getOffer().getId());
+
+            // deactivate if conflicting offer exists
+            if (model.getOpenOfferManager().hasConflictingClone(model.getOpenOffer())) {
+                model.getOpenOffer().setState(OpenOffer.State.DEACTIVATED);
+                model.setOfferAddedToOfferBook(false);
+                complete();
+                return;
+            }
+
+            // add to offer book and activate if pending or available
             if (model.getOpenOffer().isPending() || model.getOpenOffer().isAvailable()) {
                 model.getOfferBookService().addOffer(new Offer(model.getSignOfferResponse().getSignedOfferPayload()),
                         () -> {
diff --git a/core/src/main/java/haveno/core/support/dispute/mediation/MediationManager.java b/core/src/main/java/haveno/core/support/dispute/mediation/MediationManager.java
index 56686faa..3ae1d995 100644
--- a/core/src/main/java/haveno/core/support/dispute/mediation/MediationManager.java
+++ b/core/src/main/java/haveno/core/support/dispute/mediation/MediationManager.java
@@ -197,7 +197,7 @@ public final class MediationManager extends DisputeManager<MediationDisputeList>
             }
         } else {
             Optional<OpenOffer> openOfferOptional = openOfferManager.getOpenOffer(tradeId);
-            openOfferOptional.ifPresent(openOffer -> openOfferManager.closeOpenOffer(openOffer.getOffer()));
+            openOfferOptional.ifPresent(openOffer -> openOfferManager.closeSpentOffer(openOffer.getOffer()));
         }
         sendAckMessage(chatMessage, dispute.getAgentPubKeyRing(), true, null);
 
diff --git a/core/src/main/java/haveno/core/support/dispute/refund/RefundManager.java b/core/src/main/java/haveno/core/support/dispute/refund/RefundManager.java
index 034eac6d..8748def3 100644
--- a/core/src/main/java/haveno/core/support/dispute/refund/RefundManager.java
+++ b/core/src/main/java/haveno/core/support/dispute/refund/RefundManager.java
@@ -197,7 +197,7 @@ public final class RefundManager extends DisputeManager<RefundDisputeList> {
             }
         } else {
             Optional<OpenOffer> openOfferOptional = openOfferManager.getOpenOffer(tradeId);
-            openOfferOptional.ifPresent(openOffer -> openOfferManager.closeOpenOffer(openOffer.getOffer()));
+            openOfferOptional.ifPresent(openOffer -> openOfferManager.closeSpentOffer(openOffer.getOffer()));
         }
         sendAckMessage(chatMessage, dispute.getAgentPubKeyRing(), true, null);
 
@@ -206,7 +206,7 @@ public final class RefundManager extends DisputeManager<RefundDisputeList> {
             tradeManager.closeDisputedTrade(tradeId, Trade.DisputeState.REFUND_REQUEST_CLOSED);
         } else {
             Optional<OpenOffer> openOfferOptional = openOfferManager.getOpenOffer(tradeId);
-            openOfferOptional.ifPresent(openOffer -> openOfferManager.closeOpenOffer(openOffer.getOffer()));
+            openOfferOptional.ifPresent(openOffer -> openOfferManager.closeSpentOffer(openOffer.getOffer()));
         }
 
         requestPersistence();
diff --git a/core/src/main/java/haveno/core/trade/Trade.java b/core/src/main/java/haveno/core/trade/Trade.java
index 114edcab..41027600 100644
--- a/core/src/main/java/haveno/core/trade/Trade.java
+++ b/core/src/main/java/haveno/core/trade/Trade.java
@@ -710,7 +710,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model {
                     maybePublishTradeStatistics();
 
                     // reset address entries
-                    processModel.getXmrWalletService().resetAddressEntriesForTrade(getId());
+                    processModel.getXmrWalletService().swapPayoutAddressEntryToAvailable(getId());
                 }
 
                 // handle when payout unlocks
@@ -1755,7 +1755,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model {
         // close open offer
         if (this instanceof MakerTrade && processModel.getOpenOfferManager().getOpenOffer(getId()).isPresent()) {
             log.info("Closing open offer because {} {} was restored after protocol error", getClass().getSimpleName(), getShortId());
-            processModel.getOpenOfferManager().closeOpenOffer(checkNotNull(getOffer()));
+            processModel.getOpenOfferManager().closeSpentOffer(checkNotNull(getOffer()));
         }
 
         // re-freeze outputs
@@ -2371,7 +2371,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model {
     }
 
     public boolean hasBuyerAsTakerWithoutDeposit() {
-        return getBuyer() == getTaker() && BigInteger.ZERO.equals(getBuyerSecurityDepositBeforeMiningFee());
+        return getOffer().getOfferPayload().isBuyerAsTakerWithoutDeposit();
     }
 
     @Override
@@ -2945,7 +2945,7 @@ public abstract class Trade extends XmrWalletBase implements Tradable, Model {
 
         // close open offer or reset address entries
         if (this instanceof MakerTrade) {
-            processModel.getOpenOfferManager().closeOpenOffer(getOffer());
+            processModel.getOpenOfferManager().closeSpentOffer(getOffer());
             HavenoUtils.notificationService.sendTradeNotification(this, Phase.DEPOSITS_PUBLISHED, "Offer Taken", "Your offer " + offer.getId() + " has been accepted"); // TODO (woodser): use language translation
         } else {
             getXmrWalletService().resetAddressEntriesForOpenOffer(getId());
diff --git a/core/src/main/java/haveno/core/trade/TradeManager.java b/core/src/main/java/haveno/core/trade/TradeManager.java
index c98978ae..975378ce 100644
--- a/core/src/main/java/haveno/core/trade/TradeManager.java
+++ b/core/src/main/java/haveno/core/trade/TradeManager.java
@@ -977,7 +977,7 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi
         removeTrade(trade, true);
 
         // TODO The address entry should have been removed already. Check and if its the case remove that.
-        xmrWalletService.resetAddressEntriesForTrade(trade.getId());
+        xmrWalletService.swapPayoutAddressEntryToAvailable(trade.getId());
         requestPersistence();
     }
 
@@ -1011,7 +1011,7 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi
         if (tradeOptional.isPresent()) {
             Trade trade = tradeOptional.get();
             trade.setDisputeState(disputeState);
-            xmrWalletService.resetAddressEntriesForTrade(trade.getId());
+            xmrWalletService.swapPayoutAddressEntryToAvailable(trade.getId());
             requestPersistence();
         }
     }
diff --git a/core/src/main/java/haveno/core/trade/protocol/tasks/TakerReserveTradeFunds.java b/core/src/main/java/haveno/core/trade/protocol/tasks/TakerReserveTradeFunds.java
index aa0fc9df..9a461b2d 100644
--- a/core/src/main/java/haveno/core/trade/protocol/tasks/TakerReserveTradeFunds.java
+++ b/core/src/main/java/haveno/core/trade/protocol/tasks/TakerReserveTradeFunds.java
@@ -89,7 +89,7 @@ public class TakerReserveTradeFunds extends TradeTask {
                     } catch (Exception e) {
 
                         // reset state with wallet lock
-                        model.getXmrWalletService().resetAddressEntriesForTrade(trade.getId());
+                        model.getXmrWalletService().swapPayoutAddressEntryToAvailable(trade.getId());
                         if (reserveTx != null) {
                             model.getXmrWalletService().thawOutputs(HavenoUtils.getInputKeyImages(reserveTx));
                             trade.getSelf().setReserveTxKeyImages(null);
diff --git a/core/src/main/java/haveno/core/xmr/model/XmrAddressEntry.java b/core/src/main/java/haveno/core/xmr/model/XmrAddressEntry.java
index 025d6cda..d863c92e 100644
--- a/core/src/main/java/haveno/core/xmr/model/XmrAddressEntry.java
+++ b/core/src/main/java/haveno/core/xmr/model/XmrAddressEntry.java
@@ -122,12 +122,12 @@ public final class XmrAddressEntry implements PersistablePayload {
         return context == Context.OFFER_FUNDING;
     }
 
-    public boolean isTrade() {
+    public boolean isTradePayout() {
         return context == Context.TRADE_PAYOUT;
     }
 
     public boolean isTradable() {
-        return isOpenOffer() || isTrade();
+        return isOpenOffer() || isTradePayout();
     }
 
     public Coin getCoinLockedInMultiSig() {
diff --git a/core/src/main/java/haveno/core/xmr/model/XmrAddressEntryList.java b/core/src/main/java/haveno/core/xmr/model/XmrAddressEntryList.java
index 73f7379d..984f38bd 100644
--- a/core/src/main/java/haveno/core/xmr/model/XmrAddressEntryList.java
+++ b/core/src/main/java/haveno/core/xmr/model/XmrAddressEntryList.java
@@ -110,10 +110,25 @@ public final class XmrAddressEntryList implements PersistableEnvelope, Persisted
     }
 
     public void swapToAvailable(XmrAddressEntry addressEntry) {
-        boolean setChangedByRemove = entrySet.remove(addressEntry);
-        boolean setChangedByAdd = entrySet.add(new XmrAddressEntry(addressEntry.getSubaddressIndex(), addressEntry.getAddressString(),
-                XmrAddressEntry.Context.AVAILABLE));
-        if (setChangedByRemove || setChangedByAdd) {
+        log.info("swapToAvailable addressEntry to swap={}", addressEntry);
+        if (entrySet.remove(addressEntry)) {
+            requestPersistence();
+        }
+        // If we have an address entry which shared the address with another one (shared funding use case)
+        // then we do not swap to available as we need to protect the address of the remaining entry.
+        boolean entryWithSameContextStillExists = entrySet.stream().anyMatch(entry -> {
+            if (addressEntry.getAddressString() != null) {
+                return addressEntry.getAddressString().equals(entry.getAddressString()) &&
+                        addressEntry.getContext() == entry.getContext();
+            }
+            return false;
+        });
+        if (entryWithSameContextStillExists) {
+            return;
+        }
+        // no other uses of the address context remain, so make it available
+        if (entrySet.add(new XmrAddressEntry(addressEntry.getSubaddressIndex(), addressEntry.getAddressString(),
+                XmrAddressEntry.Context.AVAILABLE))) {
             requestPersistence();
         }
     }
diff --git a/core/src/main/java/haveno/core/xmr/wallet/Restrictions.java b/core/src/main/java/haveno/core/xmr/wallet/Restrictions.java
index aefb92c4..5cd181a4 100644
--- a/core/src/main/java/haveno/core/xmr/wallet/Restrictions.java
+++ b/core/src/main/java/haveno/core/xmr/wallet/Restrictions.java
@@ -31,6 +31,7 @@ public class Restrictions {
     public static BigInteger MIN_TRADE_AMOUNT = HavenoUtils.xmrToAtomicUnits(0.1);
     public static BigInteger MIN_SECURITY_DEPOSIT = HavenoUtils.xmrToAtomicUnits(0.1);
     public static int MAX_EXTRA_INFO_LENGTH = 1500;
+    public static int MAX_OFFERS_WITH_SHARED_FUNDS = 10;
 
     // At mediation we require a min. payout to the losing party to keep incentive for the trader to accept the
     // mediated payout. For Refund agent cases we do not have that restriction.
diff --git a/core/src/main/java/haveno/core/xmr/wallet/XmrKeyImagePoller.java b/core/src/main/java/haveno/core/xmr/wallet/XmrKeyImagePoller.java
index 731c1311..1cde8415 100644
--- a/core/src/main/java/haveno/core/xmr/wallet/XmrKeyImagePoller.java
+++ b/core/src/main/java/haveno/core/xmr/wallet/XmrKeyImagePoller.java
@@ -36,15 +36,13 @@ import haveno.core.trade.HavenoUtils;
 
 /**
  * Poll for changes to the spent status of key images.
- *
- * TODO: move to monero-java?
  */
 @Slf4j
 public class XmrKeyImagePoller {
 
     private MoneroDaemon daemon;
     private long refreshPeriodMs;
-    private List<String> keyImages = new ArrayList<String>();
+    private Map<String, Set<String>> keyImageGroups = new HashMap<String, Set<String>>();
     private Set<XmrKeyImageListener> listeners = new HashSet<XmrKeyImageListener>();
     private TaskLooper looper;
     private Map<String, MoneroKeyImageSpentStatus> lastStatuses = new HashMap<String, MoneroKeyImageSpentStatus>();
@@ -53,9 +51,6 @@ public class XmrKeyImagePoller {
 
     /**
      * Construct the listener.
-     *
-     * @param refreshPeriodMs - refresh period in milliseconds
-     * @param keyImages - key images to listen to
      */
     public XmrKeyImagePoller() {
         looper = new TaskLooper(() -> poll());
@@ -64,14 +59,13 @@ public class XmrKeyImagePoller {
     /**
      * Construct the listener.
      *
+     * @param daemon - the Monero daemon to poll
      * @param refreshPeriodMs - refresh period in milliseconds
-     * @param keyImages - key images to listen to
      */
-    public XmrKeyImagePoller(MoneroDaemon daemon, long refreshPeriodMs, String... keyImages) {
+    public XmrKeyImagePoller(MoneroDaemon daemon, long refreshPeriodMs) {
         looper = new TaskLooper(() -> poll());
         setDaemon(daemon);
         setRefreshPeriodMs(refreshPeriodMs);
-        setKeyImages(keyImages);
     }
 
     /**
@@ -131,36 +125,13 @@ public class XmrKeyImagePoller {
         return refreshPeriodMs;
     }
 
-    /**
-     * Get a copy of the key images being listened to.
-     *
-     * @return the key images to listen to
-     */
-    public Collection<String> getKeyImages() {
-        synchronized (keyImages) {
-            return new ArrayList<String>(keyImages);
-        }
-    }
-
-    /**
-     * Set the key images to listen to.
-     *
-     * @return the key images to listen to
-     */
-    public void setKeyImages(String... keyImages) {
-        synchronized (this.keyImages) {
-            this.keyImages.clear();
-            addKeyImages(keyImages);
-        }
-    }
-
     /**
      * Add a key image to listen to.
      *
      * @param keyImage - the key image to listen to
      */
-    public void addKeyImage(String keyImage) {
-        addKeyImages(keyImage);
+    public void addKeyImage(String keyImage, String groupId) {
+        addKeyImages(Arrays.asList(keyImage), groupId);
     }
 
     /**
@@ -168,50 +139,26 @@ public class XmrKeyImagePoller {
      *
      * @param keyImages - key images to listen to
      */
-    public void addKeyImages(String... keyImages) {
-        addKeyImages(Arrays.asList(keyImages));
-    }
-
-    /**
-     * Add key images to listen to.
-     *
-     * @param keyImages - key images to listen to
-     */
-    public void addKeyImages(Collection<String> keyImages) {
-        synchronized (this.keyImages) {
-            for (String keyImage : keyImages) if (!this.keyImages.contains(keyImage)) this.keyImages.add(keyImage);
+    public void addKeyImages(Collection<String> keyImages, String groupId) {
+        synchronized (this.keyImageGroups) {
+            if (!keyImageGroups.containsKey(groupId)) keyImageGroups.put(groupId, new HashSet<String>());
+            Set<String> keyImagesGroup = keyImageGroups.get(groupId);
+            keyImagesGroup.addAll(keyImages);
             refreshPolling();
         }
     }
 
-    /**
-     * Remove a key image to listen to.
-     *
-     * @param keyImage - the key image to unlisten to
-     */
-    public void removeKeyImage(String keyImage) {
-        removeKeyImages(keyImage);
-    }
-
     /**
      * Remove key images to listen to.
      *
      * @param keyImages - key images to unlisten to
      */
-    public void removeKeyImages(String... keyImages) {
-        removeKeyImages(Arrays.asList(keyImages));
-    }
-
-    /**
-     * Remove key images to listen to.
-     *
-     * @param keyImages - key images to unlisten to
-     */
-    public void removeKeyImages(Collection<String> keyImages) {
-        synchronized (this.keyImages) {
-            Set<String> containedKeyImages = new HashSet<String>(keyImages);
-            containedKeyImages.retainAll(this.keyImages);
-            this.keyImages.removeAll(containedKeyImages);
+    public void removeKeyImages(Collection<String> keyImages, String groupId) {
+        synchronized (keyImageGroups) {
+            Set<String> keyImagesGroup = keyImageGroups.get(groupId);
+            if (keyImagesGroup == null) return;
+            keyImagesGroup.removeAll(keyImages);
+            if (keyImagesGroup.isEmpty()) keyImageGroups.remove(groupId);
             synchronized (lastStatuses) {
                 for (String lastKeyImage : new HashSet<>(lastStatuses.keySet())) lastStatuses.remove(lastKeyImage);
             }
@@ -219,11 +166,34 @@ public class XmrKeyImagePoller {
         }
     }
 
+    public void removeKeyImages(String groupId) {
+        synchronized (keyImageGroups) {
+            Set<String> keyImagesGroup = keyImageGroups.get(groupId);
+            if (keyImagesGroup == null) return;
+            keyImageGroups.remove(groupId);
+            Set<String> keyImages = getKeyImages();
+            synchronized (lastStatuses) {
+                for (String keyImage : keyImagesGroup) {
+                    if (lastStatuses.containsKey(keyImage) && !keyImages.contains(keyImage)) {
+                        lastStatuses.remove(keyImage);
+                    }
+                }
+            }
+            refreshPolling();
+        }
+    }
+
     /**
      * Clear the key images which stops polling.
      */
     public void clearKeyImages() {
-        setKeyImages();
+        synchronized (keyImageGroups) {
+            keyImageGroups.clear();
+            synchronized (lastStatuses) {
+                lastStatuses.clear();
+            }
+            refreshPolling();
+        }
     }
 
     /**
@@ -235,10 +205,20 @@ public class XmrKeyImagePoller {
     public Boolean isSpent(String keyImage) {
         synchronized (lastStatuses) {
             if (!lastStatuses.containsKey(keyImage)) return null;
-            return lastStatuses.get(keyImage) != MoneroKeyImageSpentStatus.NOT_SPENT;
+            return XmrKeyImagePoller.isSpent(lastStatuses.get(keyImage));
         }
     }
 
+    /**
+     * Indicates if the given key image spent status is spent.
+     * 
+     * @param status the key image spent status to check
+     * @return true if the key image is spent, false if unspent
+     */
+    public static boolean isSpent(MoneroKeyImageSpentStatus status) {
+        return status != MoneroKeyImageSpentStatus.NOT_SPENT;
+    }
+
     /**
      * Get the last known spent status for the given key image.
      * 
@@ -257,16 +237,11 @@ public class XmrKeyImagePoller {
             return;
         }
 
-        // get copy of key images to fetch
-        List<String> keyImages = new ArrayList<String>(getKeyImages());
-
         // fetch spent statuses
         List<MoneroKeyImageSpentStatus> spentStatuses = null;
+        List<String> keyImages = new ArrayList<String>(getKeyImages());
         try {
-            if (keyImages.isEmpty()) spentStatuses = new ArrayList<MoneroKeyImageSpentStatus>();
-            else {
-                spentStatuses = daemon.getKeyImageSpentStatuses(keyImages); // TODO monero-java: if order of getKeyImageSpentStatuses is guaranteed, then it should take list parameter
-            }
+            spentStatuses = keyImages.isEmpty() ? new ArrayList<MoneroKeyImageSpentStatus>() : daemon.getKeyImageSpentStatuses(keyImages); // TODO monero-java: if order of getKeyImageSpentStatuses is guaranteed, then it should take list parameter
         } catch (Exception e) {
 
             // limit error logging
@@ -297,8 +272,8 @@ public class XmrKeyImagePoller {
     }
 
     private void refreshPolling() {
-        synchronized (keyImages) {
-            setIsPolling(keyImages.size() > 0 && listeners.size() > 0);
+        synchronized (keyImageGroups) {
+            setIsPolling(!getKeyImages().isEmpty() && listeners.size() > 0);
         }
     }
 
@@ -313,4 +288,14 @@ public class XmrKeyImagePoller {
             looper.stop();
         }
     }
+
+    private Set<String> getKeyImages() {
+        Set<String> allKeyImages = new HashSet<String>();
+        synchronized (keyImageGroups) {
+            for (Set<String> keyImagesGroup : keyImageGroups.values()) {
+                allKeyImages.addAll(keyImagesGroup);
+            }
+        }
+        return allKeyImages;
+    }
 }
diff --git a/core/src/main/java/haveno/core/xmr/wallet/XmrWalletService.java b/core/src/main/java/haveno/core/xmr/wallet/XmrWalletService.java
index 7bfc37f8..f015280b 100644
--- a/core/src/main/java/haveno/core/xmr/wallet/XmrWalletService.java
+++ b/core/src/main/java/haveno/core/xmr/wallet/XmrWalletService.java
@@ -1008,12 +1008,21 @@ public class XmrWalletService extends XmrWalletBase {
     public synchronized void swapAddressEntryToAvailable(String offerId, XmrAddressEntry.Context context) {
         Optional<XmrAddressEntry> addressEntryOptional = getAddressEntryListAsImmutableList().stream().filter(e -> offerId.equals(e.getOfferId())).filter(e -> context == e.getContext()).findAny();
         addressEntryOptional.ifPresent(e -> {
-            log.info("swap addressEntry with address {} and offerId {} from context {} to available", e.getAddressString(), e.getOfferId(), context);
             xmrAddressEntryList.swapToAvailable(e);
             saveAddressEntryList();
         });
     }
 
+    public synchronized void cloneAddressEntries(String offerId, String cloneOfferId) {
+        List<XmrAddressEntry> entries = getAddressEntryListAsImmutableList().stream().filter(e -> offerId.equals(e.getOfferId())).collect(Collectors.toList());
+        for (XmrAddressEntry entry : entries) {
+            XmrAddressEntry clonedEntry = new XmrAddressEntry(entry.getSubaddressIndex(), entry.getAddressString(), entry.getContext(), cloneOfferId, null);
+            Optional<XmrAddressEntry> existingEntry = getAddressEntry(clonedEntry.getOfferId(), clonedEntry.getContext());
+            if (existingEntry.isPresent()) continue;
+            xmrAddressEntryList.addAddressEntry(clonedEntry);
+        }
+    }
+
     public synchronized void resetAddressEntriesForOpenOffer(String offerId) {
         log.info("resetAddressEntriesForOpenOffer offerId={}", offerId);
 
@@ -1031,7 +1040,7 @@ public class XmrWalletService extends XmrWalletBase {
         if (trade == null || trade.isPayoutUnlocked()) swapAddressEntryToAvailable(offerId, XmrAddressEntry.Context.TRADE_PAYOUT);
     }
 
-    public synchronized void resetAddressEntriesForTrade(String offerId) {
+    public synchronized void swapPayoutAddressEntryToAvailable(String offerId) {
         swapAddressEntryToAvailable(offerId, XmrAddressEntry.Context.TRADE_PAYOUT);
     }
 
@@ -1191,26 +1200,34 @@ public class XmrWalletService extends XmrWalletBase {
 
     // TODO (woodser): update balance and other listening
     public void addBalanceListener(XmrBalanceListener listener) {
-        if (!balanceListeners.contains(listener)) balanceListeners.add(listener);
+        if (listener == null) throw new IllegalArgumentException("Cannot add null balance listener");
+        synchronized (balanceListeners) {
+            if (!balanceListeners.contains(listener)) balanceListeners.add(listener);
+        }
     }
 
     public void removeBalanceListener(XmrBalanceListener listener) {
-        balanceListeners.remove(listener);
+        if (listener == null) throw new IllegalArgumentException("Cannot add null balance listener");
+        synchronized (balanceListeners) {
+            balanceListeners.remove(listener);
+        }
     }
 
     public void updateBalanceListeners() {
         BigInteger availableBalance = getAvailableBalance();
-        for (XmrBalanceListener balanceListener : balanceListeners) {
-            BigInteger balance;
-            if (balanceListener.getSubaddressIndex() != null && balanceListener.getSubaddressIndex() != 0) balance = getBalanceForSubaddress(balanceListener.getSubaddressIndex());
-            else balance = availableBalance;
-            ThreadUtils.submitToPool(() -> {
-                try {
-                    balanceListener.onBalanceChanged(balance);
-                } catch (Exception e) {
-                    log.warn("Failed to notify balance listener of change: {}\n", e.getMessage(), e);
-                }
-            });
+        synchronized (balanceListeners) {
+            for (XmrBalanceListener balanceListener : balanceListeners) {
+                BigInteger balance;
+                if (balanceListener.getSubaddressIndex() != null && balanceListener.getSubaddressIndex() != 0) balance = getBalanceForSubaddress(balanceListener.getSubaddressIndex());
+                else balance = availableBalance;
+                ThreadUtils.submitToPool(() -> {
+                    try {
+                        balanceListener.onBalanceChanged(balance);
+                    } catch (Exception e) {
+                        log.warn("Failed to notify balance listener of change: {}\n", e.getMessage(), e);
+                    }
+                });
+            }
         }
     }
 
diff --git a/core/src/main/resources/i18n/displayStrings.properties b/core/src/main/resources/i18n/displayStrings.properties
index 3a321d56..457323e9 100644
--- a/core/src/main/resources/i18n/displayStrings.properties
+++ b/core/src/main/resources/i18n/displayStrings.properties
@@ -103,7 +103,6 @@ shared.XMRMinMax=XMR (min - max)
 shared.removeOffer=Remove offer
 shared.dontRemoveOffer=Don't remove offer
 shared.editOffer=Edit offer
-shared.duplicateOffer=Duplicate offer
 shared.openLargeQRWindow=Open large QR code window
 shared.chooseTradingAccount=Choose trading account
 shared.faq=Visit FAQ page
@@ -385,6 +384,21 @@ offerbook.xmrAutoConf=Is auto-confirm enabled
 offerbook.buyXmrWith=Buy XMR with:
 offerbook.sellXmrFor=Sell XMR for:
 
+offerbook.cloneOffer=Clone offer with shared funds
+offerbook.clonedOffer.tooltip=This is a cloned offer with shared funds.\n\Group ID: {0}
+offerbook.nonClonedOffer.tooltip=Regular offer without shared funds.\n\Maker reserve transaction ID: {0}
+offerbook.hasConflictingClone.warning=This cloned offer with shared funds cannot be activated because it uses \
+  the same payment method and currency as another active offer.\n\n\
+  You need to edit the offer and change the \
+  payment method or currency or deactivate the offer which has the same payment method and currency.
+offerbook.cannotActivateEditedOffer.warning=You can't activate an offer that is currently edited.
+offerbook.clonedOffer.headline=Cloning an offer
+offerbook.clonedOffer.info=Cloning an offer creates a copy without reserving additional funds.\n\n\
+  This helps reduce locked capital, making it easier to list the same offer across multiple markets or payment methods.\n\n\
+  If one of the cloned offers is taken, the others will close automatically, since they all share the same reserved funds.\n\n\
+  Cloned offers must use the same trade amount and security deposit, but they must differ in payment method or currency.\n\n\
+  For more information about cloning offers see: [HYPERLINK:https://docs.haveno.exchange/haveno-ui/Cloning_an_offer/]
+
 offerbook.timeSinceSigning.help=When you successfully complete a trade with a peer who has a signed payment account, your payment account is signed.\n\
    {0} days later, the initial limit of {1} is lifted and your account can sign other peers'' payment accounts.
 offerbook.timeSinceSigning.notSigned=Not signed yet
@@ -443,8 +457,9 @@ offerbook.warning.requireUpdateToNewVersion=Your version of Haveno is not compat
 offerbook.warning.offerWasAlreadyUsedInTrade=You cannot take this offer because you already took it earlier. \
   It could be that your previous take-offer attempt resulted in a failed trade.
 
-offerbook.warning.arbitratorNotValidated=This offer cannot be taken because the arbitrator is invalid
-offerbook.warning.signatureNotValidated=This offer cannot be taken because the arbitrator's signature is invalid
+offerbook.warning.arbitratorNotValidated=This offer cannot be taken because the arbitrator is invalid.
+offerbook.warning.signatureNotValidated=This offer cannot be taken because the arbitrator's signature is invalid.
+offerbook.warning.reserveFundsSpent=This offer cannot be taken because the reserved funds were already spent.
 
 offerbook.info.sellAtMarketPrice=You will sell at market price (updated every minute).
 offerbook.info.buyAtMarketPrice=You will buy at market price (updated every minute).
@@ -600,6 +615,7 @@ takeOffer.tac=With taking this offer I agree to the trade conditions as defined
 ####################################################################
 
 openOffer.header.triggerPrice=Trigger price
+openOffer.header.groupId=Group ID
 openOffer.triggerPrice=Trigger price {0}
 openOffer.triggered=The offer has been deactivated because the market price reached your trigger price.\n\
   Please edit the offer to define a new trigger price
@@ -610,6 +626,21 @@ editOffer.publishOffer=Publishing your offer.
 editOffer.failed=Editing of offer failed:\n{0}
 editOffer.success=Your offer has been successfully edited.
 editOffer.invalidDeposit=The buyer's security deposit is not within the constraints defined by Haveno and can no longer be edited.
+editOffer.openTabWarning=You have already the \"Edit Offer\" tab open.
+editOffer.hasConflictingClone=You have edited an offer which uses shared funding with another offer and your edit \
+  made the payment method and currency now the same as another active cloned offer. Your edited offer will be \
+  deactivated because it is not permitted to publish 2 offers sharing the funds with the same payment method \
+  and currency.\n\n\
+  You can edit the offer again at \"Portfolio/My open offers\" to fulfill the requirements to activate it.
+
+cloneOffer.clone=Clone offer
+cloneOffer.publishOffer=Publishing cloned offer.
+cloneOffer.success=Your offer has been successfully cloned.
+cloneOffer.hasConflictingClone=You have not changed the payment method or the currency. You still can clone the offer, but it will \
+  be deactivated and not published.\n\n\
+  You can edit the offer later again at \"Portfolio/My open offers\" to fulfill the requirements to activate it.\n\n\
+  Do you still want to clone the offer?
+cloneOffer.openTabWarning=You have already the \"Clone Offer\" tab open.
 
 ####################################################################
 # Portfolio
@@ -620,7 +651,8 @@ portfolio.tab.pendingTrades=Open trades
 portfolio.tab.history=History
 portfolio.tab.failed=Failed
 portfolio.tab.editOpenOffer=Edit offer
-portfolio.tab.duplicateOffer=Duplicate offer
+portfolio.tab.duplicateOffer=Create offer
+portfolio.tab.cloneOpenOffer=Clone offer
 portfolio.context.offerLikeThis=Create new offer like this...
 portfolio.context.notYourOffer=You can only duplicate offers where you were the maker.
 
diff --git a/core/src/main/resources/i18n/displayStrings_cs.properties b/core/src/main/resources/i18n/displayStrings_cs.properties
index f7114029..a3b1974d 100644
--- a/core/src/main/resources/i18n/displayStrings_cs.properties
+++ b/core/src/main/resources/i18n/displayStrings_cs.properties
@@ -103,7 +103,6 @@ shared.XMRMinMax=XMR (min - max)
 shared.removeOffer=Odstranit nabídku
 shared.dontRemoveOffer=Neodstraňovat nabídku
 shared.editOffer=Upravit nabídku
-shared.duplicateOffer=Duplikovat nabídku
 shared.openLargeQRWindow=Otevřít velké okno s QR kódem
 shared.chooseTradingAccount=Vyberte obchodní účet
 shared.faq=Navštívit stránku FAQ
diff --git a/core/src/main/resources/i18n/displayStrings_tr.properties b/core/src/main/resources/i18n/displayStrings_tr.properties
index eafef04d..9ffb43d6 100644
--- a/core/src/main/resources/i18n/displayStrings_tr.properties
+++ b/core/src/main/resources/i18n/displayStrings_tr.properties
@@ -103,7 +103,6 @@ shared.XMRMinMax=XMR (min - max)
 shared.removeOffer=Teklifi kaldır
 shared.dontRemoveOffer=Teklifi kaldırma
 shared.editOffer=Teklifi düzenle
-shared.duplicateOffer=Teklifi çoğalt
 shared.openLargeQRWindow=Büyük QR kodu penceresini aç
 shared.chooseTradingAccount=İşlem hesabını seç
 shared.faq=SSS sayfasını ziyaret et
diff --git a/daemon/src/main/java/haveno/daemon/grpc/GrpcOffersService.java b/daemon/src/main/java/haveno/daemon/grpc/GrpcOffersService.java
index cbc6b6b2..1c6b8b8d 100644
--- a/daemon/src/main/java/haveno/daemon/grpc/GrpcOffersService.java
+++ b/daemon/src/main/java/haveno/daemon/grpc/GrpcOffersService.java
@@ -157,6 +157,7 @@ class GrpcOffersService extends OffersImplBase {
                     req.getIsPrivateOffer(),
                     req.getBuyerAsTakerWithoutDeposit(),
                     req.getExtraInfo(),
+                    req.getSourceOfferId(),
                     offer -> {
                         // This result handling consumer's accept operation will return
                         // the new offer to the gRPC client after async placement is done.
diff --git a/desktop/src/main/java/haveno/desktop/app/HavenoApp.java b/desktop/src/main/java/haveno/desktop/app/HavenoApp.java
index 3e41aa2f..16370c2c 100644
--- a/desktop/src/main/java/haveno/desktop/app/HavenoApp.java
+++ b/desktop/src/main/java/haveno/desktop/app/HavenoApp.java
@@ -366,7 +366,7 @@ public class HavenoApp extends Application implements UncaughtExceptionHandler {
         }
 
         // check for open offers
-        if (injector.getInstance(OpenOfferManager.class).hasOpenOffers()) {
+        if (injector.getInstance(OpenOfferManager.class).hasAvailableOpenOffers()) {
             String key = "showOpenOfferWarnPopupAtShutDown";
             if (injector.getInstance(Preferences.class).showAgain(key) && !DevEnv.isDevMode()) {
                 new Popup().warning(Res.get("popup.info.shutDownWithOpenOffers"))
diff --git a/desktop/src/main/java/haveno/desktop/haveno.css b/desktop/src/main/java/haveno/desktop/haveno.css
index 2f50f8d0..e3cfac8c 100644
--- a/desktop/src/main/java/haveno/desktop/haveno.css
+++ b/desktop/src/main/java/haveno/desktop/haveno.css
@@ -822,6 +822,10 @@ tree-table-view:focused {
     -fx-text-fill: -bs-rd-error-red;
 }
 
+.icon {
+    -fx-fill: -bs-text-color;
+}
+
 .opaque-icon {
     -fx-fill: -bs-color-gray-bbb;
     -fx-opacity: 1;
diff --git a/desktop/src/main/java/haveno/desktop/main/funds/deposit/DepositView.java b/desktop/src/main/java/haveno/desktop/main/funds/deposit/DepositView.java
index e7c4c89a..884df454 100644
--- a/desktop/src/main/java/haveno/desktop/main/funds/deposit/DepositView.java
+++ b/desktop/src/main/java/haveno/desktop/main/funds/deposit/DepositView.java
@@ -346,7 +346,7 @@ public class DepositView extends ActivatableView<VBox, Void> {
         List<XmrAddressEntry> addressEntries = xmrWalletService.getAddressEntries();
         List<DepositListItem> items = new ArrayList<>();
         for (XmrAddressEntry addressEntry : addressEntries) {
-            if (addressEntry.isTrade()) continue; // skip reserved for trade
+            if (addressEntry.isTradePayout()) continue; // do not show trade payout addresses
             items.add(new DepositListItem(addressEntry, xmrWalletService, formatter));
         }
 
diff --git a/desktop/src/main/java/haveno/desktop/main/funds/withdrawal/WithdrawalListItem.java b/desktop/src/main/java/haveno/desktop/main/funds/withdrawal/WithdrawalListItem.java
index ed9a0f0a..add84853 100644
--- a/desktop/src/main/java/haveno/desktop/main/funds/withdrawal/WithdrawalListItem.java
+++ b/desktop/src/main/java/haveno/desktop/main/funds/withdrawal/WithdrawalListItem.java
@@ -77,7 +77,7 @@ class WithdrawalListItem {
     public final String getLabel() {
         if (addressEntry.isOpenOffer())
             return Res.getWithCol("shared.offerId") + " " + addressEntry.getShortOfferId();
-        else if (addressEntry.isTrade())
+        else if (addressEntry.isTradePayout())
             return Res.getWithCol("shared.tradeId") + " " + addressEntry.getShortOfferId();
         else if (addressEntry.getContext() == XmrAddressEntry.Context.ARBITRATOR)
             return Res.get("funds.withdrawal.arbitrationFee");
diff --git a/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferDataModel.java b/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferDataModel.java
index 68c5edc7..47bfee00 100644
--- a/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferDataModel.java
+++ b/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferDataModel.java
@@ -82,7 +82,7 @@ import lombok.Getter;
 import org.jetbrains.annotations.NotNull;
 
 public abstract class MutableOfferDataModel extends OfferDataModel {
-    private final CreateOfferService createOfferService;
+    protected final CreateOfferService createOfferService;
     protected final OpenOfferManager openOfferManager;
     private final XmrWalletService xmrWalletService;
     private final Preferences preferences;
@@ -115,7 +115,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
 
     protected PaymentAccount paymentAccount;
     boolean isTabSelected;
-    protected double marketPriceMargin = 0;
+    protected double marketPriceMarginPct = 0;
     @Getter
     private boolean marketPriceAvailable;
     protected boolean allowAmountUpdate = true;
@@ -189,12 +189,12 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
     }
 
     private void addListeners() {
-        xmrWalletService.addBalanceListener(xmrBalanceListener);
+        if (xmrBalanceListener != null) xmrWalletService.addBalanceListener(xmrBalanceListener);
         user.getPaymentAccountsAsObservable().addListener(paymentAccountsChangeListener);
     }
 
     private void removeListeners() {
-        xmrWalletService.removeBalanceListener(xmrBalanceListener);
+        if (xmrBalanceListener != null) xmrWalletService.removeBalanceListener(xmrBalanceListener);
         user.getPaymentAccountsAsObservable().removeListener(paymentAccountsChangeListener);
     }
 
@@ -204,14 +204,16 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
     ///////////////////////////////////////////////////////////////////////////////////////////
 
     // called before activate()
-    public boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency) {
-        addressEntry = xmrWalletService.getOrCreateAddressEntry(offerId, XmrAddressEntry.Context.OFFER_FUNDING);
-        xmrBalanceListener = new XmrBalanceListener(getAddressEntry().getSubaddressIndex()) {
-            @Override
-            public void onBalanceChanged(BigInteger balance) {
-                updateBalances();
-            }
-        };
+    public boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency, boolean initAddressEntry) {
+        if (initAddressEntry) {
+            addressEntry = xmrWalletService.getOrCreateAddressEntry(offerId, XmrAddressEntry.Context.OFFER_FUNDING);
+            xmrBalanceListener = new XmrBalanceListener(getAddressEntry().getSubaddressIndex()) {
+                @Override
+                public void onBalanceChanged(BigInteger balance) {
+                    updateBalances();
+                }
+            };
+        }
 
         this.direction = direction;
         this.tradeCurrency = tradeCurrency;
@@ -278,6 +280,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
     }
 
     protected void updateBalances() {
+        if (addressEntry == null) return;
         super.updateBalances();
 
         // update remaining balance
@@ -302,7 +305,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
                 minAmount.get(),
                 useMarketBasedPrice.get() ? null : price.get(),
                 useMarketBasedPrice.get(),
-                useMarketBasedPrice.get() ? marketPriceMargin : 0,
+                useMarketBasedPrice.get() ? marketPriceMarginPct : 0,
                 securityDepositPct.get(),
                 paymentAccount,
                 buyerAsTakerWithoutDeposit.get(), // private offer if buyer as taker without deposit
@@ -316,6 +319,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
                 triggerPrice,
                 reserveExactAmount,
                 false, // desktop ui resets address entries on cancel
+                null,
                 resultHandler,
                 errorMessageHandler);
     }
@@ -387,7 +391,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
                 volume.set(null);
                 minVolume.set(null);
                 price.set(null);
-                marketPriceMargin = 0;
+                marketPriceMarginPct = 0;
             }
 
             this.tradeCurrency = tradeCurrency;
@@ -416,10 +420,6 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
         updateBalances();
     }
 
-    protected void setMarketPriceMarginPct(double marketPriceMargin) {
-        this.marketPriceMargin = marketPriceMargin;
-    }
-
     ///////////////////////////////////////////////////////////////////////////////////////////
     // Getters
     ///////////////////////////////////////////////////////////////////////////////////////////
@@ -469,7 +469,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
     }
 
     public double getMarketPriceMarginPct() {
-        return marketPriceMargin;
+        return marketPriceMarginPct;
     }
 
     long getMaxTradeLimit() {
@@ -609,6 +609,10 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
         this.triggerPrice = triggerPrice;
     }
 
+    public void setMarketPriceMarginPct(double marketPriceMarginPct) {
+        this.marketPriceMarginPct = marketPriceMarginPct;
+    }
+
     public void setReserveExactAmount(boolean reserveExactAmount) {
         this.reserveExactAmount = reserveExactAmount;
     }
@@ -684,6 +688,14 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
         return Restrictions.getMinSecurityDeposit().max(value);
     }
 
+    protected double getSecurityAsPercent(Offer offer) {
+        BigInteger offerSellerSecurityDeposit = getBoundedSecurityDeposit(offer.getMaxSellerSecurityDeposit());
+        double offerSellerSecurityDepositAsPercent = CoinUtil.getAsPercentPerXmr(offerSellerSecurityDeposit,
+                offer.getAmount());
+        return Math.min(offerSellerSecurityDepositAsPercent,
+                Restrictions.getMaxSecurityDepositAsPercent());
+    }
+
     ReadOnlyObjectProperty<BigInteger> totalToPayAsProperty() {
         return totalToPay;
     }
diff --git a/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferView.java b/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferView.java
index abdd2d0e..31c02bdc 100644
--- a/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferView.java
+++ b/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferView.java
@@ -297,11 +297,13 @@ public abstract class MutableOfferView<M extends MutableOfferViewModel<?>> exten
         model.getDataModel().onTabSelected(isSelected);
     }
 
-    public void initWithData(OfferDirection direction, TradeCurrency tradeCurrency,
+    public void initWithData(OfferDirection direction,
+                             TradeCurrency tradeCurrency,
+                             boolean initAddressEntry,
                              OfferView.OfferActionHandler offerActionHandler) {
         this.offerActionHandler = offerActionHandler;
 
-        boolean result = model.initWithData(direction, tradeCurrency);
+        boolean result = model.initWithData(direction, tradeCurrency, initAddressEntry);
 
         if (!result) {
             new Popup().headLine(Res.get("popup.warning.noTradingAccountSetup.headline"))
diff --git a/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferViewModel.java b/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferViewModel.java
index d49b4f2d..fa6cf417 100644
--- a/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferViewModel.java
+++ b/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferViewModel.java
@@ -601,8 +601,8 @@ public abstract class MutableOfferViewModel<M extends MutableOfferDataModel> ext
     // API
     ///////////////////////////////////////////////////////////////////////////////////////////
 
-    boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency) {
-        boolean result = dataModel.initWithData(direction, tradeCurrency);
+    boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency, boolean initAddressEntry) {
+        boolean result = dataModel.initWithData(direction, tradeCurrency, initAddressEntry);
         if (dataModel.getAddressEntry() != null) {
             addressAsString = dataModel.getAddressEntry().getAddressString();
         }
diff --git a/desktop/src/main/java/haveno/desktop/main/offer/createoffer/CreateOfferView.java b/desktop/src/main/java/haveno/desktop/main/offer/createoffer/CreateOfferView.java
index ba9f3d31..f88bed18 100644
--- a/desktop/src/main/java/haveno/desktop/main/offer/createoffer/CreateOfferView.java
+++ b/desktop/src/main/java/haveno/desktop/main/offer/createoffer/CreateOfferView.java
@@ -47,11 +47,10 @@ public class CreateOfferView extends MutableOfferView<CreateOfferViewModel> {
         super(model, navigation, preferences, offerDetailsWindow, btcFormatter);
     }
 
-    @Override
     public void initWithData(OfferDirection direction,
                              TradeCurrency tradeCurrency,
                              OfferView.OfferActionHandler offerActionHandler) {
-        super.initWithData(direction, tradeCurrency, offerActionHandler);
+        super.initWithData(direction, tradeCurrency, true, offerActionHandler);
     }
 
     @Override
diff --git a/desktop/src/main/java/haveno/desktop/main/offer/offerbook/OfferBook.java b/desktop/src/main/java/haveno/desktop/main/offer/offerbook/OfferBook.java
index 2367f51c..2e92a9d4 100644
--- a/desktop/src/main/java/haveno/desktop/main/offer/offerbook/OfferBook.java
+++ b/desktop/src/main/java/haveno/desktop/main/offer/offerbook/OfferBook.java
@@ -19,13 +19,12 @@ package haveno.desktop.main.offer.offerbook;
 
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import haveno.core.filter.FilterManager;
+
+import haveno.common.UserThread;
 import haveno.core.offer.Offer;
 import haveno.core.offer.OfferBookService;
 import static haveno.core.offer.OfferDirection.BUY;
-import haveno.core.offer.OfferRestrictions;
 import haveno.network.p2p.storage.P2PDataStorage;
-import haveno.network.utils.Utils;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -49,7 +48,6 @@ public class OfferBook {
     private final ObservableList<OfferBookListItem> offerBookListItems = FXCollections.observableArrayList();
     private final Map<String, Integer> buyOfferCountMap = new HashMap<>();
     private final Map<String, Integer> sellOfferCountMap = new HashMap<>();
-    private final FilterManager filterManager;
 
 
     ///////////////////////////////////////////////////////////////////////////////////////////
@@ -57,64 +55,47 @@ public class OfferBook {
     ///////////////////////////////////////////////////////////////////////////////////////////
 
     @Inject
-    OfferBook(OfferBookService offerBookService, FilterManager filterManager) {
+    OfferBook(OfferBookService offerBookService) {
         this.offerBookService = offerBookService;
-        this.filterManager = filterManager;
 
         offerBookService.addOfferBookChangedListener(new OfferBookService.OfferBookChangedListener() {
             @Override
             public void onAdded(Offer offer) {
-                printOfferBookListItems("Before onAdded");
-                // We get onAdded called every time a new ProtectedStorageEntry is received.
-                // Mostly it is the same OfferPayload but the ProtectedStorageEntry is different.
-                // We filter here to only add new offers if the same offer (using equals) was not already added and it
-                // is not banned.
+                UserThread.execute(() -> {
+                    printOfferBookListItems("Before onAdded");
 
-                if (filterManager.isOfferIdBanned(offer.getId())) {
-                    log.debug("Ignored banned offer. ID={}", offer.getId());
-                    return;
-                }
-
-                if (OfferRestrictions.requiresNodeAddressUpdate() && !Utils.isV3Address(offer.getMakerNodeAddress().getHostName())) {
-                    log.debug("Ignored offer with Tor v2 node address. ID={}", offer.getId());
-                    return;
-                }
-
-                // Use offer.equals(offer) to see if the OfferBook list contains an exact
-                // match -- offer.equals(offer) includes comparisons of payload, state
-                // and errorMessage.
-                synchronized (offerBookListItems) {
-                    boolean hasSameOffer = offerBookListItems.stream().anyMatch(item -> item.getOffer().equals(offer));
-                    if (!hasSameOffer) {
-                        OfferBookListItem newOfferBookListItem = new OfferBookListItem(offer);
-                        removeDuplicateItem(newOfferBookListItem);
-                        offerBookListItems.add(newOfferBookListItem);  // Add replacement.
-                        if (log.isDebugEnabled()) {  // TODO delete debug stmt in future PR.
-                            log.debug("onAdded: Added new offer {}\n"
-                                            + "\twith newItem.payloadHash: {}",
-                                    offer.getId(),
-                                    newOfferBookListItem.hashOfPayload.getHex());
+                    // Use offer.equals(offer) to see if the OfferBook list contains an exact
+                    // match -- offer.equals(offer) includes comparisons of payload, state
+                    // and errorMessage.
+                    synchronized (offerBookListItems) {
+                        boolean hasSameOffer = offerBookListItems.stream().anyMatch(item -> item.getOffer().equals(offer));
+                        if (!hasSameOffer) {
+                            OfferBookListItem newOfferBookListItem = new OfferBookListItem(offer);
+                            removeDuplicateItem(newOfferBookListItem);
+                            offerBookListItems.add(newOfferBookListItem);  // Add replacement.
+                            if (log.isDebugEnabled()) {  // TODO delete debug stmt in future PR.
+                                log.debug("onAdded: Added new offer {}\n"
+                                                + "\twith newItem.payloadHash: {}",
+                                        offer.getId(),
+                                        newOfferBookListItem.hashOfPayload.getHex());
+                            }
+                        } else {
+                            log.debug("We have the exact same offer already in our list and ignore the onAdded call. ID={}", offer.getId());
                         }
-                    } else {
-                        log.debug("We have the exact same offer already in our list and ignore the onAdded call. ID={}", offer.getId());
+                        printOfferBookListItems("After onAdded");
                     }
-                    printOfferBookListItems("After onAdded");
-                }
+                });
             }
 
             @Override
             public void onRemoved(Offer offer) {
-                synchronized (offerBookListItems) {
-                    printOfferBookListItems("Before onRemoved");
-                    removeOffer(offer);
-                    printOfferBookListItems("After onRemoved");
-                }
-            }
-        });
-
-        filterManager.filterProperty().addListener((observable, oldValue, newValue) -> {
-            if (newValue != null) {
-                // any notifications
+                UserThread.execute(() -> {
+                    synchronized (offerBookListItems) {
+                        printOfferBookListItems("Before onRemoved");
+                        removeOffer(offer);
+                        printOfferBookListItems("After onRemoved");
+                    }
+                });
             }
         });
     }
@@ -212,7 +193,6 @@ public class OfferBook {
                 // Investigate why....
                 offerBookListItems.clear();
                 offerBookListItems.addAll(offerBookService.getOffers().stream()
-                        .filter(this::isOfferAllowed)
                         .map(OfferBookListItem::new)
                         .collect(Collectors.toList()));
 
@@ -248,13 +228,6 @@ public class OfferBook {
         return sellOfferCountMap;
     }
 
-    private boolean isOfferAllowed(Offer offer) {
-        boolean isBanned = filterManager.isOfferIdBanned(offer.getId());
-        boolean isV3NodeAddressCompliant = !OfferRestrictions.requiresNodeAddressUpdate()
-                || Utils.isV3Address(offer.getMakerNodeAddress().getHostName());
-        return !isBanned && isV3NodeAddressCompliant;
-    }
-
     private void fillOfferCountMaps() {
         buyOfferCountMap.clear();
         sellOfferCountMap.clear();
diff --git a/desktop/src/main/java/haveno/desktop/main/offer/offerbook/OfferBookView.java b/desktop/src/main/java/haveno/desktop/main/offer/offerbook/OfferBookView.java
index af47d9be..0248b9c5 100644
--- a/desktop/src/main/java/haveno/desktop/main/offer/offerbook/OfferBookView.java
+++ b/desktop/src/main/java/haveno/desktop/main/offer/offerbook/OfferBookView.java
@@ -695,8 +695,13 @@ abstract public class OfferBookView<R extends GridPane, M extends OfferBookViewM
             case SIGNATURE_NOT_VALIDATED:
                 new Popup().warning(Res.get("offerbook.warning.signatureNotValidated")).show();
                 break;
+            case RESERVE_FUNDS_SPENT:
+                new Popup().warning(Res.get("offerbook.warning.reserveFundsSpent")).show();
+                break;
             case VALID:
+                break;
             default:
+                log.warn("Unhandled offer filter service result: " + result);
                 break;
         }
     }
diff --git a/desktop/src/main/java/haveno/desktop/main/offer/offerbook/OfferBookViewModel.java b/desktop/src/main/java/haveno/desktop/main/offer/offerbook/OfferBookViewModel.java
index c93dfa8d..9bd1da02 100644
--- a/desktop/src/main/java/haveno/desktop/main/offer/offerbook/OfferBookViewModel.java
+++ b/desktop/src/main/java/haveno/desktop/main/offer/offerbook/OfferBookViewModel.java
@@ -173,6 +173,11 @@ abstract class OfferBookViewModel extends ActivatableViewModel {
 
         tradeCurrencyListChangeListener = c -> fillCurrencies();
 
+        // refresh filter on changes
+        offerBook.getOfferBookListItems().addListener((ListChangeListener<OfferBookListItem>) c -> {
+            filterOffers();
+        });
+
         filterItemsListener = c -> {
             final Optional<OfferBookListItem> highestAmountOffer = filteredItems.stream()
                     .max(Comparator.comparingLong(o -> o.getOffer().getAmount().longValueExact()));
diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/PortfolioView.java b/desktop/src/main/java/haveno/desktop/main/portfolio/PortfolioView.java
index 3b17109a..c3a1e767 100644
--- a/desktop/src/main/java/haveno/desktop/main/portfolio/PortfolioView.java
+++ b/desktop/src/main/java/haveno/desktop/main/portfolio/PortfolioView.java
@@ -30,6 +30,8 @@ import haveno.desktop.common.view.CachingViewLoader;
 import haveno.desktop.common.view.FxmlView;
 import haveno.desktop.common.view.View;
 import haveno.desktop.main.MainView;
+import haveno.desktop.main.overlays.popups.Popup;
+import haveno.desktop.main.portfolio.cloneoffer.CloneOfferView;
 import haveno.desktop.main.portfolio.closedtrades.ClosedTradesView;
 import haveno.desktop.main.portfolio.duplicateoffer.DuplicateOfferView;
 import haveno.desktop.main.portfolio.editoffer.EditOfferView;
@@ -49,7 +51,7 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
 
     @FXML
     Tab openOffersTab, pendingTradesTab, closedTradesTab;
-    private Tab editOpenOfferTab, duplicateOfferTab;
+    private Tab editOpenOfferTab, duplicateOfferTab, cloneOpenOfferTab;
     private final Tab failedTradesTab = new Tab(Res.get("portfolio.tab.failed").toUpperCase());
     private Tab currentTab;
     private Navigation.Listener navigationListener;
@@ -61,7 +63,8 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
     private final FailedTradesManager failedTradesManager;
     private EditOfferView editOfferView;
     private DuplicateOfferView duplicateOfferView;
-    private boolean editOpenOfferViewOpen;
+    private CloneOfferView cloneOfferView;
+    private boolean editOpenOfferViewOpen, cloneOpenOfferViewOpen;
     private OpenOffer openOffer;
     private OpenOffersView openOffersView;
 
@@ -99,12 +102,16 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
                 navigation.navigateTo(MainView.class, PortfolioView.class, EditOfferView.class);
             else if (newValue == duplicateOfferTab) {
                 navigation.navigateTo(MainView.class, PortfolioView.class, DuplicateOfferView.class);
+            } else if (newValue == cloneOpenOfferTab) {
+                navigation.navigateTo(MainView.class, PortfolioView.class, CloneOfferView.class);
             }
 
             if (oldValue != null && oldValue == editOpenOfferTab)
                 editOfferView.onTabSelected(false);
             if (oldValue != null && oldValue == duplicateOfferTab)
                 duplicateOfferView.onTabSelected(false);
+            if (oldValue != null && oldValue == cloneOpenOfferTab)
+                cloneOfferView.onTabSelected(false);
 
         };
 
@@ -115,6 +122,8 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
                 onEditOpenOfferRemoved();
             if (removedTabs.size() == 1 && removedTabs.get(0).equals(duplicateOfferTab))
                 onDuplicateOfferRemoved();
+            if (removedTabs.size() == 1 && removedTabs.get(0).equals(cloneOpenOfferTab))
+                onCloneOpenOfferRemoved();
         };
     }
 
@@ -137,6 +146,16 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
         navigation.navigateTo(MainView.class, this.getClass(), OpenOffersView.class);
     }
 
+    private void onCloneOpenOfferRemoved() {
+        cloneOpenOfferViewOpen = false;
+        if (cloneOfferView != null) {
+            cloneOfferView.onClose();
+            cloneOfferView = null;
+        }
+
+        navigation.navigateTo(MainView.class, this.getClass(), OpenOffersView.class);
+    }
+
     @Override
     protected void activate() {
         failedTradesManager.getObservableList().addListener((ListChangeListener<Trade>) c -> {
@@ -166,6 +185,9 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
         } else if (root.getSelectionModel().getSelectedItem() == duplicateOfferTab) {
             navigation.navigateTo(MainView.class, PortfolioView.class, DuplicateOfferView.class);
             if (duplicateOfferView != null) duplicateOfferView.onTabSelected(true);
+        } else if (root.getSelectionModel().getSelectedItem() == cloneOpenOfferTab) {
+            navigation.navigateTo(MainView.class, PortfolioView.class, CloneOfferView.class);
+            if (cloneOfferView != null) cloneOfferView.onTabSelected(true);
         }
     }
 
@@ -178,10 +200,9 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
     }
 
     private void loadView(Class<? extends View> viewClass, @Nullable Object data) {
-        // we want to get activate/deactivate called, so we remove the old view on tab change
-        // TODO Don't understand the check for currentTab != editOpenOfferTab
-        if (currentTab != null && currentTab != editOpenOfferTab)
-            currentTab.setContent(null);
+
+        // nullify current tab to trigger activate/deactivate
+        if (currentTab != null) currentTab.setContent(null);
 
         View view = viewLoader.load(viewClass);
 
@@ -235,6 +256,28 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
                 view = viewLoader.load(OpenOffersView.class);
                 selectOpenOffersView((OpenOffersView) view);
             }
+        } else if (view instanceof CloneOfferView) {
+            if (data instanceof OpenOffer) {
+                openOffer = (OpenOffer) data;
+            }
+            if (openOffer != null) {
+                if (cloneOfferView == null) {
+                    cloneOfferView = (CloneOfferView) view;
+                    cloneOfferView.applyOpenOffer(openOffer);
+                    cloneOpenOfferTab = new Tab(Res.get("portfolio.tab.cloneOpenOffer").toUpperCase());
+                    cloneOfferView.setCloseHandler(() -> {
+                        root.getTabs().remove(cloneOpenOfferTab);
+                    });
+                    root.getTabs().add(cloneOpenOfferTab);
+                }
+                if (currentTab != cloneOpenOfferTab)
+                    cloneOfferView.onTabSelected(true);
+
+                currentTab = cloneOpenOfferTab;
+            } else {
+                view = viewLoader.load(OpenOffersView.class);
+                selectOpenOffersView((OpenOffersView) view);
+            }
         }
 
         currentTab.setContent(view.getRoot());
@@ -245,20 +288,35 @@ public class PortfolioView extends ActivatableView<TabPane, Void> {
         openOffersView = view;
         currentTab = openOffersTab;
 
-        OpenOfferActionHandler openOfferActionHandler = openOffer -> {
+        EditOpenOfferHandler editOpenOfferHandler = openOffer -> {
             if (!editOpenOfferViewOpen) {
                 editOpenOfferViewOpen = true;
                 PortfolioView.this.openOffer = openOffer;
                 navigation.navigateTo(MainView.class, PortfolioView.this.getClass(), EditOfferView.class);
             } else {
-                log.error("You have already a \"Edit Offer\" tab open.");
+                new Popup().warning(Res.get("editOffer.openTabWarning")).show();
             }
         };
-        openOffersView.setOpenOfferActionHandler(openOfferActionHandler);
+        openOffersView.setEditOpenOfferHandler(editOpenOfferHandler);
+
+        CloneOpenOfferHandler cloneOpenOfferHandler = openOffer -> {
+            if (!cloneOpenOfferViewOpen) {
+                cloneOpenOfferViewOpen = true;
+                PortfolioView.this.openOffer = openOffer;
+                navigation.navigateTo(MainView.class, PortfolioView.this.getClass(), CloneOfferView.class);
+            } else {
+                new Popup().warning(Res.get("cloneOffer.openTabWarning")).show();
+            }
+        };
+        openOffersView.setCloneOpenOfferHandler(cloneOpenOfferHandler);
     }
 
-    public interface OpenOfferActionHandler {
+    public interface EditOpenOfferHandler {
         void onEditOpenOffer(OpenOffer openOffer);
     }
+
+    public interface CloneOpenOfferHandler {
+        void onCloneOpenOffer(OpenOffer openOffer);
+    }
 }
 
diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferDataModel.java b/desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferDataModel.java
new file mode 100644
index 00000000..24d79200
--- /dev/null
+++ b/desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferDataModel.java
@@ -0,0 +1,195 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package haveno.desktop.main.portfolio.cloneoffer;
+
+
+import haveno.desktop.Navigation;
+import haveno.desktop.main.offer.MutableOfferDataModel;
+import haveno.core.account.witness.AccountAgeWitnessService;
+import haveno.core.locale.CurrencyUtil;
+import haveno.core.locale.TradeCurrency;
+import haveno.core.offer.CreateOfferService;
+import haveno.core.offer.Offer;
+import haveno.core.offer.OfferDirection;
+import haveno.core.offer.OfferUtil;
+import haveno.core.offer.OpenOffer;
+import haveno.core.offer.OpenOfferManager;
+import haveno.core.payment.PaymentAccount;
+import haveno.core.proto.persistable.CorePersistenceProtoResolver;
+import haveno.core.provider.price.PriceFeedService;
+import haveno.core.trade.statistics.TradeStatisticsManager;
+import haveno.core.user.Preferences;
+import haveno.core.user.User;
+import haveno.core.util.FormattingUtils;
+import haveno.core.util.coin.CoinFormatter;
+import haveno.core.xmr.wallet.XmrWalletService;
+import haveno.network.p2p.P2PService;
+
+import haveno.common.handlers.ErrorMessageHandler;
+import haveno.common.handlers.ResultHandler;
+
+import com.google.inject.Inject;
+import com.google.inject.name.Named;
+
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+class CloneOfferDataModel extends MutableOfferDataModel {
+
+    private final CorePersistenceProtoResolver corePersistenceProtoResolver;
+    private OpenOffer sourceOpenOffer;
+
+    @Inject
+    CloneOfferDataModel(CreateOfferService createOfferService,
+                        OpenOfferManager openOfferManager,
+                        OfferUtil offerUtil,
+                        XmrWalletService xmrWalletService,
+                        Preferences preferences,
+                        User user,
+                        P2PService p2PService,
+                        PriceFeedService priceFeedService,
+                        AccountAgeWitnessService accountAgeWitnessService,
+                        @Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter xmrFormatter,
+                        CorePersistenceProtoResolver corePersistenceProtoResolver,
+                        TradeStatisticsManager tradeStatisticsManager,
+                        Navigation navigation) {
+
+        super(createOfferService,
+                openOfferManager,
+                offerUtil,
+                xmrWalletService,
+                preferences,
+                user,
+                p2PService,
+                priceFeedService,
+                accountAgeWitnessService,
+                xmrFormatter,
+                tradeStatisticsManager,
+                navigation);
+        this.corePersistenceProtoResolver = corePersistenceProtoResolver;
+    }
+
+    public void reset() {
+        direction = null;
+        tradeCurrency = null;
+        tradeCurrencyCode.set(null);
+        useMarketBasedPrice.set(false);
+        amount.set(null);
+        minAmount.set(null);
+        price.set(null);
+        volume.set(null);
+        minVolume.set(null);
+        securityDepositPct.set(0);
+        paymentAccounts.clear();
+        paymentAccount = null;
+        marketPriceMarginPct = 0;
+        sourceOpenOffer = null;
+    }
+
+    public void applyOpenOffer(OpenOffer openOffer) {
+        this.sourceOpenOffer = openOffer;
+
+        Offer offer = openOffer.getOffer();
+        direction = offer.getDirection();
+        CurrencyUtil.getTradeCurrency(offer.getCurrencyCode())
+                .ifPresent(c -> this.tradeCurrency = c);
+        tradeCurrencyCode.set(offer.getCurrencyCode());
+
+        PaymentAccount tmpPaymentAccount = user.getPaymentAccount(openOffer.getOffer().getMakerPaymentAccountId());
+        Optional<TradeCurrency> optionalTradeCurrency = CurrencyUtil.getTradeCurrency(openOffer.getOffer().getCurrencyCode());
+        if (optionalTradeCurrency.isPresent() && tmpPaymentAccount != null) {
+            TradeCurrency selectedTradeCurrency = optionalTradeCurrency.get();
+            this.paymentAccount = PaymentAccount.fromProto(tmpPaymentAccount.toProtoMessage(), corePersistenceProtoResolver);
+            if (paymentAccount.getSingleTradeCurrency() != null)
+                paymentAccount.setSingleTradeCurrency(selectedTradeCurrency);
+            else
+                paymentAccount.setSelectedTradeCurrency(selectedTradeCurrency);
+        }
+
+        allowAmountUpdate = false;
+    }
+
+    public boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency) {
+        try {
+            return super.initWithData(direction, tradeCurrency, false);
+        } catch (NullPointerException e) {
+            if (e.getMessage().contains("tradeCurrency")) {
+                throw new IllegalArgumentException("Offers of removed assets cannot be edited. You can only cancel it.", e);
+            }
+            return false;
+        }
+    }
+
+    @Override
+    protected Set<PaymentAccount> getUserPaymentAccounts() {
+        return Objects.requireNonNull(user.getPaymentAccounts()).stream()
+                .filter(account -> !account.getPaymentMethod().isBsqSwap())
+                .collect(Collectors.toSet());
+    }
+
+    @Override
+    protected PaymentAccount getPreselectedPaymentAccount() {
+        return paymentAccount;
+    }
+
+    public void populateData() {
+        Offer offer = sourceOpenOffer.getOffer();
+        // Min amount need to be set before amount as if minAmount is null it would be set by amount
+        setMinAmount(offer.getMinAmount());
+        setAmount(offer.getAmount());
+        setPrice(offer.getPrice());
+        setVolume(offer.getVolume());
+        setUseMarketBasedPrice(offer.isUseMarketBasedPrice());
+        setTriggerPrice(sourceOpenOffer.getTriggerPrice());
+        if (offer.isUseMarketBasedPrice()) {
+            setMarketPriceMarginPct(offer.getMarketPriceMarginPct());
+        }
+        setBuyerAsTakerWithoutDeposit(offer.hasBuyerAsTakerWithoutDeposit());
+        setSecurityDepositPct(getSecurityAsPercent(offer));
+        setExtraInfo(offer.getOfferExtraInfo());
+    }
+
+    public void onCloneOffer(ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) {
+        Offer clonedOffer = createClonedOffer();
+        openOfferManager.placeOffer(clonedOffer,
+                false,
+                triggerPrice,
+                false,
+                true,
+                sourceOpenOffer.getId(),
+                transaction -> resultHandler.handleResult(),
+                errorMessageHandler);
+    }
+
+    private Offer createClonedOffer() {
+        return createOfferService.createClonedOffer(sourceOpenOffer.getOffer(),
+                tradeCurrencyCode.get(),
+                useMarketBasedPrice.get() ? null : price.get(),
+                useMarketBasedPrice.get(),
+                useMarketBasedPrice.get() ? marketPriceMarginPct : 0,
+                paymentAccount,
+                extraInfo.get());
+    }
+
+    public boolean hasConflictingClone() {
+        Offer clonedOffer = createClonedOffer();
+        return openOfferManager.hasConflictingClone(clonedOffer, sourceOpenOffer);
+    }
+}
\ No newline at end of file
diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferView.fxml b/desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferView.fxml
new file mode 100644
index 00000000..80c57192
--- /dev/null
+++ b/desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferView.fxml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<!--
+  ~ This file is part of Bisq.
+  ~
+  ~ Bisq is free software: you can redistribute it and/or modify it
+  ~ under the terms of the GNU Affero General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or (at
+  ~ your option) any later version.
+  ~
+  ~ Bisq is distributed in the hope that it will be useful, but WITHOUT
+  ~ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+  ~ FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+  ~ License for more details.
+  ~
+  ~ You should have received a copy of the GNU Affero General Public License
+  ~ along with Bisq. If not, see <http://www.gnu.org/licenses/>.
+  -->
+
+<?import javafx.scene.layout.AnchorPane?>
+<AnchorPane fx:id="root" fx:controller="haveno.desktop.main.portfolio.cloneoffer.CloneOfferView"
+            xmlns:fx="http://javafx.com/fxml">
+
+</AnchorPane>
\ No newline at end of file
diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferView.java b/desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferView.java
new file mode 100644
index 00000000..e48bdf80
--- /dev/null
+++ b/desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferView.java
@@ -0,0 +1,261 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package haveno.desktop.main.portfolio.cloneoffer;
+
+import haveno.desktop.Navigation;
+import haveno.desktop.common.view.FxmlView;
+import haveno.desktop.components.AutoTooltipButton;
+import haveno.desktop.components.BusyAnimation;
+import haveno.desktop.main.offer.MutableOfferView;
+import haveno.desktop.main.overlays.popups.Popup;
+import haveno.desktop.main.overlays.windows.OfferDetailsWindow;
+
+import haveno.core.locale.CurrencyUtil;
+import haveno.core.locale.Res;
+import haveno.core.offer.OpenOffer;
+import haveno.core.payment.PaymentAccount;
+import haveno.core.user.DontShowAgainLookup;
+import haveno.core.user.Preferences;
+import haveno.core.util.FormattingUtils;
+import haveno.core.util.coin.CoinFormatter;
+import haveno.common.UserThread;
+import haveno.common.util.Tuple4;
+
+import com.google.inject.Inject;
+import com.google.inject.name.Named;
+
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.image.ImageView;
+import javafx.scene.layout.GridPane;
+import javafx.scene.layout.HBox;
+
+import javafx.geometry.HPos;
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+
+import javafx.collections.ObservableList;
+
+import static haveno.desktop.util.FormBuilder.addButtonBusyAnimationLabelAfterGroup;
+
+@FxmlView
+public class CloneOfferView extends MutableOfferView<CloneOfferViewModel> {
+
+    private BusyAnimation busyAnimation;
+    private Button cloneButton;
+    private Button cancelButton;
+    private Label spinnerInfoLabel;
+
+    ///////////////////////////////////////////////////////////////////////////////////////////
+    // Constructor, lifecycle
+    ///////////////////////////////////////////////////////////////////////////////////////////
+
+    @Inject
+    private CloneOfferView(CloneOfferViewModel model,
+                        Navigation navigation,
+                        Preferences preferences,
+                        OfferDetailsWindow offerDetailsWindow,
+                        @Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter) {
+        super(model, navigation, preferences, offerDetailsWindow, btcFormatter);
+    }
+
+    @Override
+    protected void initialize() {
+        super.initialize();
+
+        addCloneGroup();
+        renameAmountGroup();
+    }
+
+    private void renameAmountGroup() {
+        amountTitledGroupBg.setText(Res.get("editOffer.setPrice"));
+    }
+
+    @Override
+    protected void doSetFocus() {
+        // Don't focus in any field before data was set
+    }
+
+    @Override
+    protected void doActivate() {
+        super.doActivate();
+
+
+        addBindings();
+
+        hideOptionsGroup();
+        hideNextButtons();
+
+        // Lock amount field as it would require bigger changes to support increased amount values.
+        amountTextField.setDisable(true);
+        amountBtcLabel.setDisable(true);
+        minAmountTextField.setDisable(true);
+        minAmountBtcLabel.setDisable(true);
+        volumeTextField.setDisable(true);
+        volumeCurrencyLabel.setDisable(true);
+
+        // Workaround to fix margin on top of amount group
+        gridPane.setPadding(new Insets(-20, 25, -1, 25));
+
+        updatePriceToggle();
+        updateElementsWithDirection();
+
+        model.isNextButtonDisabled.setValue(false);
+        cancelButton.setDisable(false);
+
+        model.onInvalidateMarketPriceMargin();
+        model.onInvalidatePrice();
+
+        // To force re-validation of payment account validation
+        onPaymentAccountsComboBoxSelected();
+    }
+
+    @Override
+    protected void deactivate() {
+        super.deactivate();
+
+        removeBindings();
+    }
+
+    @Override
+    public void onClose() {
+    }
+
+    ///////////////////////////////////////////////////////////////////////////////////////////
+    // API
+    ///////////////////////////////////////////////////////////////////////////////////////////
+
+    public void applyOpenOffer(OpenOffer openOffer) {
+        model.applyOpenOffer(openOffer);
+
+        initWithData(openOffer.getOffer().getDirection(),
+                CurrencyUtil.getTradeCurrency(openOffer.getOffer().getCurrencyCode()).get(),
+                false,
+                null);
+
+        if (!model.isSecurityDepositValid()) {
+            new Popup().warning(Res.get("editOffer.invalidDeposit"))
+                    .onClose(this::close)
+                    .show();
+        }
+    }
+
+    ///////////////////////////////////////////////////////////////////////////////////////////
+    // Bindings, Listeners
+    ///////////////////////////////////////////////////////////////////////////////////////////
+
+    private void addBindings() {
+        cloneButton.disableProperty().bind(model.isNextButtonDisabled);
+    }
+
+    private void removeBindings() {
+        cloneButton.disableProperty().unbind();
+    }
+
+    @Override
+    protected ObservableList<PaymentAccount> filterPaymentAccounts(ObservableList<PaymentAccount> paymentAccounts) {
+        return paymentAccounts;
+    }
+
+
+    ///////////////////////////////////////////////////////////////////////////////////////////
+    // Build UI elements
+    ///////////////////////////////////////////////////////////////////////////////////////////
+
+    private void addCloneGroup() {
+        Tuple4<Button, BusyAnimation, Label, HBox> tuple4 = addButtonBusyAnimationLabelAfterGroup(gridPane, 6, Res.get("cloneOffer.clone"));
+
+        HBox hBox = tuple4.fourth;
+        hBox.setAlignment(Pos.CENTER_LEFT);
+        GridPane.setHalignment(hBox, HPos.LEFT);
+
+        cloneButton = tuple4.first;
+        cloneButton.setMinHeight(40);
+        cloneButton.setPadding(new Insets(0, 20, 0, 20));
+        cloneButton.setGraphicTextGap(10);
+
+        busyAnimation = tuple4.second;
+        spinnerInfoLabel = tuple4.third;
+
+        cancelButton = new AutoTooltipButton(Res.get("shared.cancel"));
+        cancelButton.setDefaultButton(false);
+        cancelButton.setOnAction(event -> close());
+        hBox.getChildren().add(cancelButton);
+
+        cloneButton.setOnAction(e -> {
+            cloneButton.requestFocus();   // fix issue #5460 (when enter key used, focus is wrong)
+            onClone();
+        });
+    }
+
+    private void onClone() {
+        if (model.dataModel.hasConflictingClone()) {
+            new Popup().warning(Res.get("cloneOffer.hasConflictingClone"))
+                    .actionButtonText(Res.get("shared.yes"))
+                    .onAction(this::doClone)
+                    .closeButtonText(Res.get("shared.no"))
+                    .show();
+        } else {
+            doClone();
+        }
+    }
+
+    private void doClone() {
+        if (model.isPriceInRange()) {
+            model.isNextButtonDisabled.setValue(true);
+            cancelButton.setDisable(true);
+            busyAnimation.play();
+            spinnerInfoLabel.setText(Res.get("cloneOffer.publishOffer"));
+            model.onCloneOffer(() -> {
+                UserThread.execute(() -> {
+                    String key = "cloneOfferSuccess";
+                    if (DontShowAgainLookup.showAgain(key)) {
+                        new Popup()
+                                .feedback(Res.get("cloneOffer.success"))
+                                .dontShowAgainId(key)
+                                .show();
+                    }
+                    spinnerInfoLabel.setText("");
+                    busyAnimation.stop();
+                    close();
+                });
+            },
+            errorMessage -> {
+                UserThread.execute(() -> {
+                    log.error(errorMessage);
+                    spinnerInfoLabel.setText("");
+                    busyAnimation.stop();
+                    model.isNextButtonDisabled.setValue(false);
+                    cancelButton.setDisable(false);
+                    new Popup().warning(errorMessage).show();
+                });
+            });
+        }
+    }
+
+    ///////////////////////////////////////////////////////////////////////////////////////////
+    // Utils
+    ///////////////////////////////////////////////////////////////////////////////////////////
+
+    private void updateElementsWithDirection() {
+        ImageView iconView = new ImageView();
+        iconView.setId(model.isShownAsSellOffer() ? "image-sell-white" : "image-buy-white");
+        cloneButton.setGraphic(iconView);
+        cloneButton.setId(model.isShownAsSellOffer() ? "sell-button-big" : "buy-button-big");
+    }
+}
\ No newline at end of file
diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferViewModel.java b/desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferViewModel.java
new file mode 100644
index 00000000..f36e18da
--- /dev/null
+++ b/desktop/src/main/java/haveno/desktop/main/portfolio/cloneoffer/CloneOfferViewModel.java
@@ -0,0 +1,120 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package haveno.desktop.main.portfolio.cloneoffer;
+
+import haveno.desktop.Navigation;
+import haveno.desktop.main.offer.MutableOfferViewModel;
+import haveno.desktop.main.offer.OfferViewUtil;
+
+import haveno.core.account.witness.AccountAgeWitnessService;
+import haveno.core.offer.OfferUtil;
+import haveno.core.offer.OpenOffer;
+import haveno.core.payment.validation.FiatVolumeValidator;
+import haveno.core.payment.validation.SecurityDepositValidator;
+import haveno.core.payment.validation.XmrValidator;
+import haveno.core.provider.price.PriceFeedService;
+import haveno.core.user.Preferences;
+import haveno.core.util.FormattingUtils;
+import haveno.core.util.PriceUtil;
+import haveno.core.util.coin.CoinFormatter;
+import haveno.core.util.validation.AmountValidator4Decimals;
+import haveno.core.util.validation.AmountValidator8Decimals;
+import haveno.common.handlers.ErrorMessageHandler;
+import haveno.common.handlers.ResultHandler;
+
+import com.google.inject.Inject;
+import com.google.inject.name.Named;
+
+class CloneOfferViewModel extends MutableOfferViewModel<CloneOfferDataModel> {
+
+    @Inject
+    public CloneOfferViewModel(CloneOfferDataModel dataModel,
+                            FiatVolumeValidator fiatVolumeValidator,
+                            AmountValidator4Decimals priceValidator4Decimals,
+                            AmountValidator8Decimals priceValidator8Decimals,
+                            XmrValidator xmrValidator,
+                            SecurityDepositValidator securityDepositValidator,
+                            PriceFeedService priceFeedService,
+                            AccountAgeWitnessService accountAgeWitnessService,
+                            Navigation navigation,
+                            Preferences preferences,
+                            @Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter,
+                            OfferUtil offerUtil) {
+        super(dataModel,
+                fiatVolumeValidator,
+                priceValidator4Decimals,
+                priceValidator8Decimals,
+                xmrValidator,
+                securityDepositValidator,
+                priceFeedService,
+                accountAgeWitnessService,
+                navigation,
+                preferences,
+                btcFormatter,
+                offerUtil);
+        syncMinAmountWithAmount = false;
+    }
+
+    @Override
+    public void activate() {
+        super.activate();
+
+        dataModel.populateData();
+
+        long triggerPriceAsLong = dataModel.getTriggerPrice();
+        dataModel.setTriggerPrice(triggerPriceAsLong);
+        if (triggerPriceAsLong > 0) {
+            triggerPrice.set(PriceUtil.formatMarketPrice(triggerPriceAsLong, dataModel.getCurrencyCode()));
+        } else {
+            triggerPrice.set("");
+        }
+        onTriggerPriceTextFieldChanged();
+    }
+
+    public void applyOpenOffer(OpenOffer openOffer) {
+        dataModel.reset();
+        dataModel.applyOpenOffer(openOffer);
+    }
+
+    public void onCloneOffer(ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) {
+        dataModel.onCloneOffer(resultHandler, errorMessageHandler);
+    }
+
+    public void onInvalidateMarketPriceMargin() {
+        marketPriceMargin.set(FormattingUtils.formatToPercent(dataModel.getMarketPriceMarginPct()));
+    }
+
+    public void onInvalidatePrice() {
+        price.set(FormattingUtils.formatPrice(null));
+        price.set(FormattingUtils.formatPrice(dataModel.getPrice().get()));
+    }
+
+    public boolean isSecurityDepositValid() {
+        return securityDepositValidator.validate(securityDeposit.get()).isValid;
+    }
+
+    @Override
+    public void triggerFocusOutOnAmountFields() {
+        // do not update BTC Amount or minAmount here
+        // issue 2798: "after a few edits of offer the BTC amount has increased"
+    }
+
+    public boolean isShownAsSellOffer() {
+        return OfferViewUtil.isShownAsSellOffer(getTradeCurrency(), dataModel.getDirection());
+    }
+}
\ No newline at end of file
diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/closedtrades/ClosedTradesView.java b/desktop/src/main/java/haveno/desktop/main/portfolio/closedtrades/ClosedTradesView.java
index 3257d210..ab92f845 100644
--- a/desktop/src/main/java/haveno/desktop/main/portfolio/closedtrades/ClosedTradesView.java
+++ b/desktop/src/main/java/haveno/desktop/main/portfolio/closedtrades/ClosedTradesView.java
@@ -444,7 +444,7 @@ public class ClosedTradesView extends ActivatableViewAndModel<VBox, ClosedTrades
                                 if (item != null && !empty && isMyOfferAsMaker(item.getTradable().getOffer().getOfferPayload())) {
                                     if (button == null) {
                                         button = FormBuilder.getRegularIconButton(MaterialDesignIcon.CONTENT_COPY);
-                                        button.setTooltip(new Tooltip(Res.get("shared.duplicateOffer")));
+                                        button.setTooltip(new Tooltip(Res.get("portfolio.context.offerLikeThis")));
                                         setGraphic(button);
                                     }
                                     button.setOnAction(event -> onDuplicateOffer(item.getTradable().getOffer()));
diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/duplicateoffer/DuplicateOfferDataModel.java b/desktop/src/main/java/haveno/desktop/main/portfolio/duplicateoffer/DuplicateOfferDataModel.java
index 5828b943..4db019c0 100644
--- a/desktop/src/main/java/haveno/desktop/main/portfolio/duplicateoffer/DuplicateOfferDataModel.java
+++ b/desktop/src/main/java/haveno/desktop/main/portfolio/duplicateoffer/DuplicateOfferDataModel.java
@@ -35,13 +35,10 @@ import haveno.core.user.Preferences;
 import haveno.core.user.User;
 import haveno.core.util.FormattingUtils;
 import haveno.core.util.coin.CoinFormatter;
-import haveno.core.util.coin.CoinUtil;
-import haveno.core.xmr.wallet.Restrictions;
 import haveno.core.xmr.wallet.XmrWalletService;
 import haveno.desktop.Navigation;
 import haveno.desktop.main.offer.MutableOfferDataModel;
 import haveno.network.p2p.P2PService;
-import java.math.BigInteger;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
@@ -101,14 +98,6 @@ class DuplicateOfferDataModel extends MutableOfferDataModel {
         if (openOffer != null) setTriggerPrice(openOffer.getTriggerPrice());
     }
 
-    private double getSecurityAsPercent(Offer offer) {
-        BigInteger offerSellerSecurityDeposit = getBoundedSecurityDeposit(offer.getMaxSellerSecurityDeposit());
-        double offerSellerSecurityDepositAsPercent = CoinUtil.getAsPercentPerXmr(offerSellerSecurityDeposit,
-                offer.getAmount());
-        return Math.min(offerSellerSecurityDepositAsPercent,
-                Restrictions.getMaxSecurityDepositAsPercent());
-    }
-
     @Override
     protected Set<PaymentAccount> getUserPaymentAccounts() {
         return Objects.requireNonNull(user.getPaymentAccounts()).stream()
diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/duplicateoffer/DuplicateOfferView.java b/desktop/src/main/java/haveno/desktop/main/portfolio/duplicateoffer/DuplicateOfferView.java
index 33285e7c..7cb24ca3 100644
--- a/desktop/src/main/java/haveno/desktop/main/portfolio/duplicateoffer/DuplicateOfferView.java
+++ b/desktop/src/main/java/haveno/desktop/main/portfolio/duplicateoffer/DuplicateOfferView.java
@@ -70,6 +70,7 @@ public class DuplicateOfferView extends MutableOfferView<DuplicateOfferViewModel
     public void initWithData(OfferPayload offerPayload) {
         initWithData(offerPayload.getDirection(),
                 CurrencyUtil.getTradeCurrency(offerPayload.getCurrencyCode()).get(),
+                true,
                 null);
         model.initWithData(offerPayload);
     }
diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/editoffer/EditOfferDataModel.java b/desktop/src/main/java/haveno/desktop/main/portfolio/editoffer/EditOfferDataModel.java
index 1e7f3585..530cad9c 100644
--- a/desktop/src/main/java/haveno/desktop/main/portfolio/editoffer/EditOfferDataModel.java
+++ b/desktop/src/main/java/haveno/desktop/main/portfolio/editoffer/EditOfferDataModel.java
@@ -21,7 +21,6 @@ package haveno.desktop.main.portfolio.editoffer;
 import com.google.inject.Inject;
 import com.google.inject.name.Named;
 
-import haveno.common.UserThread;
 import haveno.common.handlers.ErrorMessageHandler;
 import haveno.common.handlers.ResultHandler;
 import haveno.core.account.witness.AccountAgeWitnessService;
@@ -56,6 +55,7 @@ class EditOfferDataModel extends MutableOfferDataModel {
     private final CorePersistenceProtoResolver corePersistenceProtoResolver;
     private OpenOffer openOffer;
     private OpenOffer.State initialState;
+    private Offer editedOffer;
 
     @Inject
     EditOfferDataModel(CreateOfferService createOfferService,
@@ -100,7 +100,7 @@ class EditOfferDataModel extends MutableOfferDataModel {
         securityDepositPct.set(0);
         paymentAccounts.clear();
         paymentAccount = null;
-        marketPriceMargin = 0;
+        marketPriceMarginPct = 0;
     }
 
     public void applyOpenOffer(OpenOffer openOffer) {
@@ -142,10 +142,9 @@ class EditOfferDataModel extends MutableOfferDataModel {
         extraInfo.set(offer.getOfferExtraInfo());
     }
 
-    @Override
     public boolean initWithData(OfferDirection direction, TradeCurrency tradeCurrency) {
         try {
-            return super.initWithData(direction, tradeCurrency);
+            return super.initWithData(direction, tradeCurrency, false);
         } catch (NullPointerException e) {
             if (e.getMessage().contains("tradeCurrency")) {
                 throw new IllegalArgumentException("Offers of removed assets cannot be edited. You can only cancel it.", e);
@@ -225,15 +224,16 @@ class EditOfferDataModel extends MutableOfferDataModel {
                 offerPayload.getReserveTxKeyImages(),
                 newOfferPayload.getExtraInfo());
 
-        final Offer editedOffer = new Offer(editedPayload);
+        editedOffer = new Offer(editedPayload);
         editedOffer.setPriceFeedService(priceFeedService);
         editedOffer.setState(Offer.State.AVAILABLE);
 
         openOfferManager.editOpenOfferPublish(editedOffer, triggerPrice, initialState, () -> {
+            resultHandler.handleResult(); // process result before nullifying state
             openOffer = null;
-            UserThread.execute(() -> resultHandler.handleResult());
+            editedOffer = null;
         }, (errorMsg) -> {
-            UserThread.execute(() -> errorMessageHandler.handleErrorMessage(errorMsg));
+            errorMessageHandler.handleErrorMessage(errorMsg);
         });
     }
 
@@ -243,6 +243,15 @@ class EditOfferDataModel extends MutableOfferDataModel {
             }, errorMessageHandler);
     }
 
+    public boolean hasConflictingClone() {
+        Optional<OpenOffer> editedOpenOffer = openOfferManager.getOpenOffer(openOffer.getId());
+        if (!editedOpenOffer.isPresent()) {
+            log.warn("Edited open offer is no longer present");
+            return false;
+        }
+        return openOfferManager.hasConflictingClone(editedOpenOffer.get());
+    }
+
     @Override
     protected Set<PaymentAccount> getUserPaymentAccounts() {
         throw new RuntimeException("Edit offer not supported with XMR");
diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/editoffer/EditOfferView.java b/desktop/src/main/java/haveno/desktop/main/portfolio/editoffer/EditOfferView.java
index bc804b55..8b1d9775 100644
--- a/desktop/src/main/java/haveno/desktop/main/portfolio/editoffer/EditOfferView.java
+++ b/desktop/src/main/java/haveno/desktop/main/portfolio/editoffer/EditOfferView.java
@@ -19,6 +19,8 @@ package haveno.desktop.main.portfolio.editoffer;
 
 import com.google.inject.Inject;
 import com.google.inject.name.Named;
+
+import haveno.common.UserThread;
 import haveno.common.util.Tuple4;
 import haveno.core.locale.CurrencyUtil;
 import haveno.core.locale.Res;
@@ -140,6 +142,7 @@ public class EditOfferView extends MutableOfferView<EditOfferViewModel> {
 
         initWithData(openOffer.getOffer().getDirection(),
                 CurrencyUtil.getTradeCurrency(openOffer.getOffer().getCurrencyCode()).get(),
+                false,
                 null);
 
         model.onStartEditOffer(errorMessage -> {
@@ -208,23 +211,31 @@ public class EditOfferView extends MutableOfferView<EditOfferViewModel> {
 
                 // edit offer
                 model.onPublishOffer(() -> {
-                    String key = "editOfferSuccess";
-                    if (DontShowAgainLookup.showAgain(key)) {
-                        new Popup()
-                                .feedback(Res.get("editOffer.success"))
-                                .dontShowAgainId(key)
-                                .show();
+                    if (model.dataModel.hasConflictingClone()) {
+                        new Popup().warning(Res.get("editOffer.hasConflictingClone")).show();
+                    } else {
+                        String key = "editOfferSuccess";
+                        if (DontShowAgainLookup.showAgain(key)) {
+                            new Popup()
+                                    .feedback(Res.get("editOffer.success"))
+                                    .dontShowAgainId(key)
+                                    .show();
+                        }
                     }
-                    spinnerInfoLabel.setText("");
-                    busyAnimation.stop();
-                    close();
+                    UserThread.execute(() -> {
+                        spinnerInfoLabel.setText("");
+                        busyAnimation.stop();
+                        close();
+                    });
                 }, (message) -> {
-                    log.error(message);
-                    spinnerInfoLabel.setText("");
-                    busyAnimation.stop();
-                    model.isNextButtonDisabled.setValue(false);
-                    cancelButton.setDisable(false);
-                    new Popup().warning(Res.get("editOffer.failed", message)).show();
+                    UserThread.execute(() -> {
+                        log.error(message);
+                        spinnerInfoLabel.setText("");
+                        busyAnimation.stop();
+                        model.isNextButtonDisabled.setValue(false);
+                        cancelButton.setDisable(false);
+                        new Popup().warning(Res.get("editOffer.failed", message)).show();
+                    });
                 });
             }
         });
diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/editoffer/EditOfferViewModel.java b/desktop/src/main/java/haveno/desktop/main/portfolio/editoffer/EditOfferViewModel.java
index 34b78be6..53febf4d 100644
--- a/desktop/src/main/java/haveno/desktop/main/portfolio/editoffer/EditOfferViewModel.java
+++ b/desktop/src/main/java/haveno/desktop/main/portfolio/editoffer/EditOfferViewModel.java
@@ -44,7 +44,7 @@ class EditOfferViewModel extends MutableOfferViewModel<EditOfferDataModel> {
                               FiatVolumeValidator fiatVolumeValidator,
                               AmountValidator4Decimals priceValidator4Decimals,
                               AmountValidator8Decimals priceValidator8Decimals,
-                              XmrValidator btcValidator,
+                              XmrValidator xmrValidator,
                               SecurityDepositValidator securityDepositValidator,
                               PriceFeedService priceFeedService,
                               AccountAgeWitnessService accountAgeWitnessService,
@@ -56,7 +56,7 @@ class EditOfferViewModel extends MutableOfferViewModel<EditOfferDataModel> {
                 fiatVolumeValidator,
                 priceValidator4Decimals,
                 priceValidator8Decimals,
-                btcValidator,
+                xmrValidator,
                 securityDepositValidator,
                 priceFeedService,
                 accountAgeWitnessService,
diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOfferListItem.java b/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOfferListItem.java
index 67869a9f..cb437ace 100644
--- a/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOfferListItem.java
+++ b/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOfferListItem.java
@@ -39,4 +39,8 @@ class OpenOfferListItem {
     public Offer getOffer() {
         return openOffer.getOffer();
     }
+
+    public String getGroupId() {
+        return openOffer.getGroupId();
+    }
 }
diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOffersView.fxml b/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOffersView.fxml
index 30da4d0c..035ec5fb 100644
--- a/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOffersView.fxml
+++ b/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOffersView.fxml
@@ -42,7 +42,8 @@
     </HBox>
     <TableView fx:id="tableView" VBox.vgrow="ALWAYS">
         <columns>
-            <TableColumn fx:id="offerIdColumn" minWidth="110" maxWidth="120"/>
+            <TableColumn fx:id="offerIdColumn" minWidth="100" maxWidth="120"/>
+            <TableColumn fx:id="groupIdColumn" minWidth="70"/>
             <TableColumn fx:id="dateColumn" minWidth="170"/>
             <TableColumn fx:id="marketColumn" minWidth="75"/>
             <TableColumn fx:id="priceColumn" minWidth="100"/>
@@ -50,11 +51,13 @@
             <TableColumn fx:id="triggerPriceColumn" minWidth="90"/>
             <TableColumn fx:id="amountColumn" minWidth="110"/>
             <TableColumn fx:id="volumeColumn" minWidth="110"/>
-            <TableColumn fx:id="paymentMethodColumn" minWidth="120" maxWidth="170"/>
+            <TableColumn fx:id="paymentMethodColumn" minWidth="110" maxWidth="170"/>
             <TableColumn fx:id="directionColumn" minWidth="70"/>
             <TableColumn fx:id="deactivateItemColumn" minWidth="60" maxWidth="60" sortable="false"/>
             <TableColumn fx:id="editItemColumn" minWidth="30" maxWidth="30" sortable="false"/>
             <TableColumn fx:id="triggerIconColumn" minWidth="30" maxWidth="30" sortable="false"/>
+            <TableColumn fx:id="duplicateItemColumn" minWidth="30" maxWidth="30" sortable="false"/>
+            <TableColumn fx:id="cloneItemColumn" minWidth="30" maxWidth="30" sortable="false"/>
             <TableColumn fx:id="removeItemColumn" minWidth="30" maxWidth="30" sortable="false"/>
         </columns>
     </TableView>
diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOffersView.java b/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOffersView.java
index 7c41eef8..3ad6a8cb 100644
--- a/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOffersView.java
+++ b/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOffersView.java
@@ -22,8 +22,8 @@ import com.googlecode.jcsv.writer.CSVEntryConverter;
 import de.jensd.fx.glyphs.materialdesignicons.MaterialDesignIcon;
 import haveno.core.locale.Res;
 import haveno.core.offer.Offer;
-import haveno.core.offer.OfferPayload;
 import haveno.core.offer.OpenOffer;
+import haveno.core.offer.OpenOfferManager;
 import haveno.core.user.DontShowAgainLookup;
 import haveno.desktop.Navigation;
 import haveno.desktop.common.view.ActivatableViewAndModel;
@@ -40,8 +40,9 @@ import haveno.desktop.main.funds.withdrawal.WithdrawalView;
 import haveno.desktop.main.overlays.popups.Popup;
 import haveno.desktop.main.overlays.windows.OfferDetailsWindow;
 import haveno.desktop.main.portfolio.PortfolioView;
-import haveno.desktop.main.portfolio.duplicateoffer.DuplicateOfferView;
+import haveno.desktop.main.portfolio.presentation.PortfolioUtil;
 import static haveno.desktop.util.FormBuilder.getRegularIconButton;
+import haveno.desktop.util.FormBuilder;
 import haveno.desktop.util.GUIUtil;
 import java.util.Comparator;
 import java.util.HashMap;
@@ -51,13 +52,11 @@ import java.util.stream.Collectors;
 import javafx.beans.binding.Bindings;
 import javafx.beans.property.ReadOnlyObjectWrapper;
 import javafx.beans.value.ChangeListener;
-import javafx.collections.ObservableList;
+import javafx.collections.ListChangeListener;
 import javafx.collections.transformation.FilteredList;
 import javafx.collections.transformation.SortedList;
 import javafx.fxml.FXML;
 import javafx.geometry.Insets;
-import javafx.scene.Node;
-import javafx.scene.Parent;
 import javafx.scene.control.Button;
 import javafx.scene.control.ContextMenu;
 import javafx.scene.control.Label;
@@ -73,6 +72,7 @@ import javafx.scene.layout.Pane;
 import javafx.scene.layout.Priority;
 import javafx.scene.layout.Region;
 import javafx.scene.layout.VBox;
+import javafx.scene.text.Text;
 import javafx.stage.Stage;
 import javafx.util.Callback;
 import org.jetbrains.annotations.NotNull;
@@ -80,12 +80,39 @@ import org.jetbrains.annotations.NotNull;
 @FxmlView
 public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersViewModel> {
 
+    private enum ColumnNames {
+        OFFER_ID(Res.get("shared.offerId")),
+        GROUP_ID(Res.get("openOffer.header.groupId")),
+        DATE(Res.get("shared.dateTime")),
+        MARKET(Res.get("shared.market")),
+        PRICE(Res.get("shared.price")),
+        DEVIATION(Res.get("shared.deviation")),
+        TRIGGER_PRICE(Res.get("openOffer.header.triggerPrice")),
+        AMOUNT(Res.get("shared.XMRMinMax")),
+        VOLUME(Res.get("shared.amountMinMax")),
+        PAYMENT_METHOD(Res.get("shared.paymentMethod")),
+        DIRECTION(Res.get("shared.offerType")),
+        STATUS(Res.get("shared.state"));
+
+        private final String text;
+
+        ColumnNames(String text) {
+            this.text = text;
+        }
+
+        @Override
+        public String toString() {
+            return text;
+        }
+    }
+
     @FXML
     TableView<OpenOfferListItem> tableView;
     @FXML
     TableColumn<OpenOfferListItem, OpenOfferListItem> priceColumn, deviationColumn, amountColumn, volumeColumn,
-            marketColumn, directionColumn, dateColumn, offerIdColumn, deactivateItemColumn,
-            removeItemColumn, editItemColumn, triggerPriceColumn, triggerIconColumn, paymentMethodColumn;
+            marketColumn, directionColumn, dateColumn, offerIdColumn, deactivateItemColumn, groupIdColumn,
+            removeItemColumn, editItemColumn, triggerPriceColumn, triggerIconColumn, paymentMethodColumn, duplicateItemColumn,
+            cloneItemColumn;
     @FXML
     HBox searchBox;
     @FXML
@@ -108,37 +135,48 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
     private SortedList<OpenOfferListItem> sortedList;
     private FilteredList<OpenOfferListItem> filteredList;
     private ChangeListener<String> filterTextFieldListener;
-    private PortfolioView.OpenOfferActionHandler openOfferActionHandler;
+    private final OpenOfferManager openOfferManager;
+    private PortfolioView.EditOpenOfferHandler editOpenOfferHandler;
+    private PortfolioView.CloneOpenOfferHandler cloneOpenOfferHandler;
     private ChangeListener<Number> widthListener;
+    private ListChangeListener<OpenOfferListItem> sortedListChangedListener;
 
     private Map<String, ChangeListener<OpenOffer.State>> offerStateChangeListeners = new HashMap<String, ChangeListener<OpenOffer.State>>();
 
     @Inject
-    public OpenOffersView(OpenOffersViewModel model, Navigation navigation, OfferDetailsWindow offerDetailsWindow) {
+    public OpenOffersView(OpenOffersViewModel model,
+                          OpenOfferManager openOfferManager,
+                          Navigation navigation,
+                          OfferDetailsWindow offerDetailsWindow) {
         super(model);
         this.navigation = navigation;
         this.offerDetailsWindow = offerDetailsWindow;
+        this.openOfferManager = openOfferManager;
     }
 
     @Override
     public void initialize() {
         widthListener = (observable, oldValue, newValue) -> onWidthChange((double) newValue);
-        paymentMethodColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.paymentMethod")));
-        priceColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.price")));
-        deviationColumn.setGraphic(new AutoTooltipTableColumn<>(Res.get("shared.deviation"),
+        groupIdColumn.setGraphic(new AutoTooltipLabel(ColumnNames.GROUP_ID.toString()));
+        paymentMethodColumn.setGraphic(new AutoTooltipLabel(ColumnNames.PAYMENT_METHOD.toString()));
+        priceColumn.setGraphic(new AutoTooltipLabel(ColumnNames.PRICE.toString()));
+        deviationColumn.setGraphic(new AutoTooltipTableColumn<>(ColumnNames.DEVIATION.toString(),
                 Res.get("portfolio.closedTrades.deviation.help")).getGraphic());
-        amountColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.XMRMinMax")));
-        volumeColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.amountMinMax")));
-        marketColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.market")));
-        directionColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.offerType")));
-        dateColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.dateTime")));
-        offerIdColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.offerId")));
-        triggerPriceColumn.setGraphic(new AutoTooltipLabel(Res.get("openOffer.header.triggerPrice")));
-        deactivateItemColumn.setGraphic(new AutoTooltipLabel(Res.get("shared.enabled")));
+        triggerPriceColumn.setGraphic(new AutoTooltipLabel(ColumnNames.TRIGGER_PRICE.toString()));
+        amountColumn.setGraphic(new AutoTooltipLabel(ColumnNames.AMOUNT.toString()));
+        volumeColumn.setGraphic(new AutoTooltipLabel(ColumnNames.VOLUME.toString()));
+        marketColumn.setGraphic(new AutoTooltipLabel(ColumnNames.MARKET.toString()));
+        directionColumn.setGraphic(new AutoTooltipLabel(ColumnNames.DIRECTION.toString()));
+        dateColumn.setGraphic(new AutoTooltipLabel(ColumnNames.DATE.toString()));
+        offerIdColumn.setGraphic(new AutoTooltipLabel(ColumnNames.OFFER_ID.toString()));
+        deactivateItemColumn.setGraphic(new AutoTooltipLabel(ColumnNames.STATUS.toString()));
         editItemColumn.setGraphic(new AutoTooltipLabel(""));
+        duplicateItemColumn.setText("");
+        cloneItemColumn.setText("");
         removeItemColumn.setGraphic(new AutoTooltipLabel(""));
 
         setOfferIdColumnCellFactory();
+        setGroupIdCellFactory();
         setDirectionColumnCellFactory();
         setMarketColumnCellFactory();
         setPriceColumnCellFactory();
@@ -151,12 +189,15 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
         setEditColumnCellFactory();
         setTriggerIconColumnCellFactory();
         setTriggerPriceColumnCellFactory();
+        setDuplicateColumnCellFactory();
+        setCloneColumnCellFactory();
         setRemoveColumnCellFactory();
 
         tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY);
         tableView.setPlaceholder(new AutoTooltipLabel(Res.get("table.placeholder.noItems", Res.get("shared.openOffers"))));
 
         offerIdColumn.setComparator(Comparator.comparing(o -> o.getOffer().getId()));
+        groupIdColumn.setComparator(Comparator.comparing(o -> o.getOpenOffer().getReserveTxHash()));
         directionColumn.setComparator(Comparator.comparing(o -> o.getOffer().getDirection()));
         marketColumn.setComparator(Comparator.comparing(model::getMarketLabel));
         amountColumn.setComparator(Comparator.comparing(o -> o.getOffer().getAmount()));
@@ -168,23 +209,21 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
         dateColumn.setComparator(Comparator.comparing(o -> o.getOffer().getDate()));
         paymentMethodColumn.setComparator(Comparator.comparing(o -> Res.get(o.getOffer().getPaymentMethod().getId())));
 
-        dateColumn.setSortType(TableColumn.SortType.DESCENDING);
+        dateColumn.setSortType(TableColumn.SortType.ASCENDING);
         tableView.getSortOrder().add(dateColumn);
 
         tableView.setRowFactory(
                 tableView -> {
                     final TableRow<OpenOfferListItem> row = new TableRow<>();
                     final ContextMenu rowMenu = new ContextMenu();
-                    MenuItem editItem = new MenuItem(Res.get("portfolio.context.offerLikeThis"));
-                    editItem.setOnAction((event) -> {
-                        try {
-                            OfferPayload offerPayload = row.getItem().getOffer().getOfferPayload();
-                            navigation.navigateToWithData(offerPayload, MainView.class, PortfolioView.class, DuplicateOfferView.class);
-                        } catch (NullPointerException e) {
-                            log.warn("Unable to get offerPayload - {}", e.toString());
-                        }
-                    });
-                    rowMenu.getItems().add(editItem);
+
+                    MenuItem duplicateOfferMenuItem = new MenuItem(Res.get("portfolio.context.offerLikeThis"));
+                    duplicateOfferMenuItem.setOnAction((event) -> onDuplicateOffer(row.getItem()));
+                    rowMenu.getItems().add(duplicateOfferMenuItem);
+
+                    MenuItem cloneOfferMenuItem = new MenuItem(Res.get("offerbook.cloneOffer"));
+                    cloneOfferMenuItem.setOnAction((event) -> onCloneOffer(row.getItem()));
+                    rowMenu.getItems().add(cloneOfferMenuItem);
                     row.contextMenuProperty().bind(
                             Bindings.when(Bindings.isNotNull(row.itemProperty()))
                                     .then(rowMenu)
@@ -207,6 +246,15 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
         HBox.setHgrow(footerSpacer, Priority.ALWAYS);
         HBox.setMargin(exportButton, new Insets(0, 10, 0, 0));
         exportButton.updateText(Res.get("shared.exportCSV"));
+
+        sortedListChangedListener = c -> {
+            c.next();
+            if (c.wasAdded() || c.wasRemoved()) {
+                updateNumberOfOffers();
+                updateGroupIdColumnVisibility();
+                updateTriggerColumnVisibility();
+            }
+        };
     }
 
     @Override
@@ -214,8 +262,11 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
         filteredList = new FilteredList<>(model.getList());
         sortedList = new SortedList<>(filteredList);
         sortedList.comparatorProperty().bind(tableView.comparatorProperty());
+        sortedList.addListener(sortedListChangedListener);
         tableView.setItems(sortedList);
 
+        updateGroupIdColumnVisibility();
+        updateTriggerColumnVisibility();
         updateSelectToggleButtonState();
 
         selectToggleButton.setOnAction(event -> {
@@ -231,37 +282,27 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
 
         numItems.setText(Res.get("shared.numItemsLabel", sortedList.size()));
         exportButton.setOnAction(event -> {
-            ObservableList<TableColumn<OpenOfferListItem, ?>> tableColumns = tableView.getColumns();
-            int reportColumns = tableColumns.size() - 3;    // CSV report excludes the last columns (icons)
             CSVEntryConverter<OpenOfferListItem> headerConverter = item -> {
-                String[] columns = new String[reportColumns];
-                for (int i = 0; i < columns.length; i++) {
-                    Node graphic = tableColumns.get(i).getGraphic();
-                    if (graphic instanceof AutoTooltipLabel) {
-                        columns[i] = ((AutoTooltipLabel) graphic).getText();
-                    } else if (graphic instanceof HBox) {
-                        // Deviation has a Hbox with AutoTooltipLabel as first child in header
-                        columns[i] = ((AutoTooltipLabel) ((Parent) graphic).getChildrenUnmodifiable().get(0)).getText();
-                    } else {
-                        // Not expected
-                        columns[i] = "N/A";
-                    }
+                String[] columns = new String[ColumnNames.values().length];
+                for (ColumnNames m : ColumnNames.values()) {
+                    columns[m.ordinal()] = m.toString();
                 }
                 return columns;
             };
             CSVEntryConverter<OpenOfferListItem> contentConverter = item -> {
-                String[] columns = new String[reportColumns];
-                columns[0] = model.getOfferId(item);
-                columns[1] = model.getDate(item);
-                columns[2] = model.getMarketLabel(item);
-                columns[3] = model.getPrice(item);
-                columns[4] = model.getPriceDeviation(item);
-                columns[5] = model.getTriggerPrice(item);
-                columns[6] = model.getAmount(item);
-                columns[7] = model.getVolume(item);
-                columns[8] = model.getPaymentMethod(item);
-                columns[9] = model.getDirectionLabel(item);
-                columns[10] = String.valueOf(!item.getOpenOffer().isDeactivated());
+                String[] columns = new String[ColumnNames.values().length];
+                columns[ColumnNames.OFFER_ID.ordinal()] = model.getOfferId(item);
+                columns[ColumnNames.GROUP_ID.ordinal()] = openOfferManager.hasClonedOffer(item.getOffer().getId()) ? getShortenedGroupId(item.getGroupId()) : "";
+                columns[ColumnNames.DATE.ordinal()] = model.getDate(item);
+                columns[ColumnNames.MARKET.ordinal()] = model.getMarketLabel(item);
+                columns[ColumnNames.PRICE.ordinal()] = model.getPrice(item);
+                columns[ColumnNames.DEVIATION.ordinal()] = model.getPriceDeviation(item);
+                columns[ColumnNames.TRIGGER_PRICE.ordinal()] = model.getTriggerPrice(item);
+                columns[ColumnNames.AMOUNT.ordinal()] = model.getAmount(item);
+                columns[ColumnNames.VOLUME.ordinal()] = model.getVolume(item);
+                columns[ColumnNames.PAYMENT_METHOD.ordinal()] = model.getPaymentMethod(item);
+                columns[ColumnNames.DIRECTION.ordinal()] = model.getDirectionLabel(item);
+                columns[ColumnNames.STATUS.ordinal()] = String.valueOf(!item.getOpenOffer().isDeactivated());
                 return columns;
             };
 
@@ -280,9 +321,24 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
         onWidthChange(root.getWidth());
     }
 
+    private void updateNumberOfOffers() {
+        numItems.setText(Res.get("shared.numItemsLabel", sortedList.size()));
+    }
+
+    private void updateGroupIdColumnVisibility() {
+        groupIdColumn.setVisible(openOfferManager.hasClonedOffers());
+    }
+
+    private void updateTriggerColumnVisibility() {
+        triggerIconColumn.setVisible(model.dataModel.getList().stream()
+                .mapToLong(item -> item.getOpenOffer().getTriggerPrice())
+                .sum() > 0);
+    }
+
     @Override
     protected void deactivate() {
         sortedList.comparatorProperty().unbind();
+        sortedList.removeListener(sortedListChangedListener);
         exportButton.setOnAction(null);
 
         filterTextField.textProperty().removeListener(filterTextFieldListener);
@@ -352,7 +408,7 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
     }
 
     private void onWidthChange(double width) {
-        triggerPriceColumn.setVisible(width > 1200);
+        triggerPriceColumn.setVisible(width > 1300);
     }
 
     private void onDeactivateOpenOffer(OpenOffer openOffer) {
@@ -361,7 +417,7 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
                     () -> log.debug("Deactivate offer was successful"),
                     (message) -> {
                         log.error(message);
-                        new Popup().warning(Res.get("offerbook.deactivateOffer.failed", message)).show();
+                        new Popup().warning(message).show();
                     });
             updateSelectToggleButtonState();
         }
@@ -397,12 +453,18 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
     }
 
     private void doRemoveOpenOffer(OpenOffer openOffer) {
+        boolean hasClonedOffer = openOfferManager.hasClonedOffer(openOffer.getId());
         model.onRemoveOpenOffer(openOffer,
                 () -> {
                     log.debug("Remove offer was successful");
 
                     tableView.refresh();
 
+                    // We do not show the popup if it's a cloned offer with shared maker reserve tx
+                    if (hasClonedOffer) {
+                        return;
+                    }
+
                     String key = "WithdrawFundsAfterRemoveOfferInfo";
                     if (DontShowAgainLookup.showAgain(key)) {
                         new Popup().instruction(Res.get("offerbook.withdrawFundsHint", Res.get("navigation.funds.availableForWithdrawal")))
@@ -420,10 +482,46 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
 
     private void onEditOpenOffer(OpenOffer openOffer) {
         if (model.isBootstrappedOrShowPopup()) {
-            openOfferActionHandler.onEditOpenOffer(openOffer);
+            editOpenOfferHandler.onEditOpenOffer(openOffer);
         }
     }
 
+    private void onDuplicateOffer(OpenOfferListItem item) {
+        if (item == null || item.getOffer().getOfferPayload() == null) {
+            return;
+        }
+        if (model.isBootstrappedOrShowPopup()) {
+            PortfolioUtil.duplicateOffer(navigation, item.getOffer().getOfferPayload());
+        }
+    }
+
+    private void onCloneOffer(OpenOfferListItem item) {
+        if (item == null) {
+            return;
+        }
+        if (model.isBootstrappedOrShowPopup()) {
+            String key = "clonedOfferInfo";
+            if (DontShowAgainLookup.showAgain(key)) {
+                new Popup().headLine(Res.get("offerbook.clonedOffer.headline"))
+                        .instruction(Res.get("offerbook.clonedOffer.info"))
+                        .useIUnderstandButton()
+                        .dontShowAgainId(key)
+                        .onClose(() -> doCloneOffer(item))
+                        .show();
+            } else {
+                doCloneOffer(item);
+            }
+        }   
+    }
+
+    private void doCloneOffer(OpenOfferListItem item) {
+        OpenOffer openOffer = item.getOpenOffer();
+        if (openOffer == null || openOffer.getOffer() == null || openOffer.getOffer().getOfferPayload() == null) {
+            return;
+        }
+        cloneOpenOfferHandler.onCloneOpenOffer(openOffer);
+    }
+
     private void setOfferIdColumnCellFactory() {
         offerIdColumn.setCellValueFactory((openOfferListItem) -> new ReadOnlyObjectWrapper<>(openOfferListItem.getValue()));
         offerIdColumn.getStyleClass().addAll("number-column", "first-column");
@@ -434,21 +532,28 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
                     public TableCell<OpenOfferListItem, OpenOfferListItem> call(TableColumn<OpenOfferListItem,
                             OpenOfferListItem> column) {
                         return new TableCell<>() {
-                            private HyperlinkWithIcon field;
+                            private HyperlinkWithIcon hyperlinkWithIcon;
 
                             @Override
                             public void updateItem(final OpenOfferListItem item, boolean empty) {
                                 super.updateItem(item, empty);
-
                                 if (item != null && !empty) {
-                                    field = new HyperlinkWithIcon(model.getOfferId(item));
-                                    field.setOnAction(event -> offerDetailsWindow.show(item.getOffer()));
-                                    field.setTooltip(new Tooltip(Res.get("tooltip.openPopupForDetails")));
-                                    setGraphic(field);
+                                    hyperlinkWithIcon = new HyperlinkWithIcon(item.getOffer().getShortId());
+                                    if (model.isDeactivated(item)) {
+                                        // getStyleClass().add("offer-disabled"); does not work with hyperlinkWithIcon;-(
+                                        hyperlinkWithIcon.setStyle("-fx-text-fill: -bs-color-gray-3;");
+                                        hyperlinkWithIcon.getIcon().setOpacity(0.2);
+                                    }
+                                    hyperlinkWithIcon.setOnAction(event -> {
+                                        offerDetailsWindow.show(item.getOffer());
+                                    });
+
+                                    hyperlinkWithIcon.setTooltip(new Tooltip(Res.get("tooltip.openPopupForDetails")));
+                                    setGraphic(hyperlinkWithIcon);
                                 } else {
                                     setGraphic(null);
-                                    if (field != null)
-                                        field.setOnAction(null);
+                                    if (hyperlinkWithIcon != null)
+                                        hyperlinkWithIcon.setOnAction(null);
                                 }
                             }
                         };
@@ -456,6 +561,55 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
                 });
     }
 
+    private void setGroupIdCellFactory() {
+        groupIdColumn.setCellValueFactory((offerListItem) -> new ReadOnlyObjectWrapper<>(offerListItem.getValue()));
+        groupIdColumn.setCellFactory(
+                new Callback<>() {
+                    @Override
+                    public TableCell<OpenOfferListItem, OpenOfferListItem> call(
+                            TableColumn<OpenOfferListItem, OpenOfferListItem> column) {
+
+                        return new TableCell<>() {
+                            @Override
+                            public void updateItem(final OpenOfferListItem item, boolean empty) {
+                                super.updateItem(item, empty);
+                                getStyleClass().removeAll("offer-disabled");
+                                if (item != null) {
+                                    Label label;
+                                    Text icon;
+                                    if (openOfferManager.hasClonedOffer(item.getOpenOffer().getId())) {
+                                        label = new Label(getShortenedGroupId(item.getOpenOffer().getGroupId()));
+                                        icon = FormBuilder.getRegularIconForLabel(MaterialDesignIcon.LINK, label, "icon");
+                                        icon.setVisible(true);
+                                        setTooltip(new Tooltip(Res.get("offerbook.clonedOffer.tooltip", item.getOpenOffer().getReserveTxHash())));
+                                    } else {
+                                        label = new Label("");
+                                        icon = FormBuilder.getRegularIconForLabel(MaterialDesignIcon.LINK_OFF, label, "icon");
+                                        icon.setVisible(false);
+                                        setTooltip(new Tooltip(Res.get("offerbook.nonClonedOffer.tooltip", item.getOpenOffer().getReserveTxHash())));
+                                    }
+
+                                    if (model.isDeactivated(item)) {
+                                        getStyleClass().add("offer-disabled");
+                                        icon.setOpacity(0.2);
+                                    }
+                                    setGraphic(label);
+                                } else {
+                                    setGraphic(null);
+                                }
+                            }
+                        };
+                    }
+                });
+    }
+
+    private String getShortenedGroupId(String groupId) {
+        if (groupId.length() > 5) {
+            return groupId.substring(0, 5);
+        }
+        return groupId;
+    }
+
     private void setDateColumnCellFactory() {
         dateColumn.setCellValueFactory((openOfferListItem) -> new ReadOnlyObjectWrapper<>(openOfferListItem.getValue()));
         dateColumn.setCellFactory(
@@ -779,6 +933,74 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
                 });
     }
 
+    private void setDuplicateColumnCellFactory() {
+        duplicateItemColumn.getStyleClass().add("avatar-column");
+        duplicateItemColumn.setCellValueFactory((offerListItem) -> new ReadOnlyObjectWrapper<>(offerListItem.getValue()));
+        duplicateItemColumn.setCellFactory(
+                new Callback<>() {
+                    @Override
+                    public TableCell<OpenOfferListItem, OpenOfferListItem> call(TableColumn<OpenOfferListItem, OpenOfferListItem> column) {
+                        return new TableCell<>() {
+                            Button button;
+
+                            @Override
+                            public void updateItem(final OpenOfferListItem item, boolean empty) {
+                                super.updateItem(item, empty);
+
+                                if (item != null && !empty) {
+                                    if (button == null) {
+                                        button = getRegularIconButton(MaterialDesignIcon.CONTENT_COPY);
+                                        button.setTooltip(new Tooltip(Res.get("portfolio.context.offerLikeThis")));
+                                        setGraphic(button);
+                                    }
+                                    button.setOnAction(event -> onDuplicateOffer(item));
+                                } else {
+                                    setGraphic(null);
+                                    if (button != null) {
+                                        button.setOnAction(null);
+                                        button = null;
+                                    }
+                                }
+                            }
+                        };
+                    }
+                });
+    }
+
+    private void setCloneColumnCellFactory() {
+        cloneItemColumn.getStyleClass().add("avatar-column");
+        cloneItemColumn.setCellValueFactory((offerListItem) -> new ReadOnlyObjectWrapper<>(offerListItem.getValue()));
+        cloneItemColumn.setCellFactory(
+                new Callback<>() {
+                    @Override
+                    public TableCell<OpenOfferListItem, OpenOfferListItem> call(TableColumn<OpenOfferListItem, OpenOfferListItem> column) {
+                        return new TableCell<>() {
+                            Button button;
+
+                            @Override
+                            public void updateItem(final OpenOfferListItem item, boolean empty) {
+                                super.updateItem(item, empty);
+
+                                if (item != null && !empty) {
+                                    if (button == null) {
+                                        button = getRegularIconButton(MaterialDesignIcon.BOX_SHADOW);
+                                        button.setTooltip(new Tooltip(Res.get("offerbook.cloneOffer")));
+                                        setGraphic(button);
+                                    }
+                                    button.setOnAction(event -> onCloneOffer(item));
+                                } else {
+                                    setGraphic(null);
+                                    if (button != null) {
+                                        button.setOnAction(null);
+                                        button = null;
+                                    }
+                                }
+                            }
+                        };
+                    }
+                });
+    }
+
     private void setTriggerIconColumnCellFactory() {
         triggerIconColumn.setCellValueFactory((offerListItem) -> new ReadOnlyObjectWrapper<>(offerListItem.getValue()));
         triggerIconColumn.setCellFactory(
@@ -854,8 +1076,12 @@ public class OpenOffersView extends ActivatableViewAndModel<VBox, OpenOffersView
                 });
     }
 
-    public void setOpenOfferActionHandler(PortfolioView.OpenOfferActionHandler openOfferActionHandler) {
-        this.openOfferActionHandler = openOfferActionHandler;
+    public void setEditOpenOfferHandler(PortfolioView.EditOpenOfferHandler editOpenOfferHandler) {
+        this.editOpenOfferHandler = editOpenOfferHandler;
+    }
+
+    public void setCloneOpenOfferHandler(PortfolioView.CloneOpenOfferHandler cloneOpenOfferHandler) {
+        this.cloneOpenOfferHandler = cloneOpenOfferHandler;
     }
 }
 
diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOffersViewModel.java b/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOffersViewModel.java
index 68ae7385..616537f5 100644
--- a/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOffersViewModel.java
+++ b/desktop/src/main/java/haveno/desktop/main/portfolio/openoffer/OpenOffersViewModel.java
@@ -84,6 +84,10 @@ class OpenOffersViewModel extends ActivatableWithDataModel<OpenOffersDataModel>
         return item.getOffer().getShortId();
     }
 
+    String getGroupId(OpenOfferListItem item) {
+        return item.getGroupId();
+    }
+
     String getAmount(OpenOfferListItem item) {
         return (item != null) ? DisplayUtils.formatAmount(item.getOffer(), btcFormatter) : "";
     }
diff --git a/desktop/src/main/java/haveno/desktop/util/FormBuilder.java b/desktop/src/main/java/haveno/desktop/util/FormBuilder.java
index 7df49216..ae3e7ed2 100644
--- a/desktop/src/main/java/haveno/desktop/util/FormBuilder.java
+++ b/desktop/src/main/java/haveno/desktop/util/FormBuilder.java
@@ -2340,8 +2340,8 @@ public class FormBuilder {
         return getRegularIconForLabel(icon, label, null);
     }
 
-    public static Text getRegularIconForLabel(GlyphIcons icon, Label label, String style) {
-        return getIconForLabel(icon, "1.231em", label, style);
+    public static Text getRegularIconForLabel(GlyphIcons icon, Label label, String styleClass) {
+        return getIconForLabel(icon, "1.231em", label, styleClass);
     }
 
     public static Text getIcon(GlyphIcons icon) {
diff --git a/desktop/src/test/java/haveno/desktop/main/offer/createoffer/CreateOfferDataModelTest.java b/desktop/src/test/java/haveno/desktop/main/offer/createoffer/CreateOfferDataModelTest.java
index 33d43b7b..e9d39f47 100644
--- a/desktop/src/test/java/haveno/desktop/main/offer/createoffer/CreateOfferDataModelTest.java
+++ b/desktop/src/test/java/haveno/desktop/main/offer/createoffer/CreateOfferDataModelTest.java
@@ -88,7 +88,7 @@ public class CreateOfferDataModelTest {
         when(user.getPaymentAccounts()).thenReturn(paymentAccounts);
         when(preferences.getSelectedPaymentAccountForCreateOffer()).thenReturn(revolutAccount);
 
-        model.initWithData(OfferDirection.BUY, new TraditionalCurrency("USD"));
+        model.initWithData(OfferDirection.BUY, new TraditionalCurrency("USD"), true);
         assertEquals("USD", model.getTradeCurrencyCode().get());
     }
 
@@ -109,7 +109,7 @@ public class CreateOfferDataModelTest {
         when(user.findFirstPaymentAccountWithCurrency(new TraditionalCurrency("USD"))).thenReturn(zelleAccount);
         when(preferences.getSelectedPaymentAccountForCreateOffer()).thenReturn(revolutAccount);
 
-        model.initWithData(OfferDirection.BUY, new TraditionalCurrency("USD"));
+        model.initWithData(OfferDirection.BUY, new TraditionalCurrency("USD"), true);
         assertEquals("USD", model.getTradeCurrencyCode().get());
     }
 }
diff --git a/desktop/src/test/java/haveno/desktop/main/offer/createoffer/CreateOfferViewModelTest.java b/desktop/src/test/java/haveno/desktop/main/offer/createoffer/CreateOfferViewModelTest.java
index a8c6ede5..19756fc5 100644
--- a/desktop/src/test/java/haveno/desktop/main/offer/createoffer/CreateOfferViewModelTest.java
+++ b/desktop/src/test/java/haveno/desktop/main/offer/createoffer/CreateOfferViewModelTest.java
@@ -117,7 +117,7 @@ public class CreateOfferViewModelTest {
             coinFormatter,
             tradeStats,
             null);
-        dataModel.initWithData(OfferDirection.BUY, new CryptoCurrency("XMR", "monero"));
+        dataModel.initWithData(OfferDirection.BUY, new CryptoCurrency("XMR", "monero"), true);
         dataModel.activate();
 
         model = new CreateOfferViewModel(dataModel,
diff --git a/proto/src/main/proto/grpc.proto b/proto/src/main/proto/grpc.proto
index 75ad4a06..b9615b5b 100644
--- a/proto/src/main/proto/grpc.proto
+++ b/proto/src/main/proto/grpc.proto
@@ -528,6 +528,7 @@ message PostOfferRequest {
     bool is_private_offer = 12;
     bool buyer_as_taker_without_deposit = 13;
     string extra_info = 14;
+    string source_offer_id = 15;
 }
 
 message PostOfferReply {
diff --git a/proto/src/main/proto/pb.proto b/proto/src/main/proto/pb.proto
index f919f4f1..5cdde1f0 100644
--- a/proto/src/main/proto/pb.proto
+++ b/proto/src/main/proto/pb.proto
@@ -1422,6 +1422,7 @@ message OpenOffer {
     string reserve_tx_key = 11;
     string challenge = 12;
     bool deactivated_by_trigger = 13;
+    string group_id = 14;
 }
 
 message Tradable {