filter offers with spent or duplicate funds using key images

reserve tx does not remain in arbitrator pool
This commit is contained in:
woodser 2021-09-17 16:29:54 -04:00
parent b9228585c7
commit 6798630dfc
18 changed files with 166 additions and 71 deletions

View file

@ -17,6 +17,7 @@
package bisq.core.api;
import bisq.core.btc.wallet.XmrWalletService;
import bisq.core.monetary.Altcoin;
import bisq.core.monetary.Price;
import bisq.core.offer.CreateOfferService;
@ -39,14 +40,17 @@ import javax.inject.Inject;
import javax.inject.Singleton;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
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;
import lombok.extern.slf4j.Slf4j;
import monero.daemon.model.MoneroKeyImageSpentStatus;
import static bisq.common.util.MathUtils.exactMultiply;
import static bisq.common.util.MathUtils.roundDoubleToLong;
@ -77,6 +81,7 @@ class CoreOffersService {
private final OpenOfferManager openOfferManager;
private final OfferUtil offerUtil;
private final User user;
private final XmrWalletService xmrWalletService;
@Inject
public CoreOffersService(CoreContext coreContext,
@ -87,7 +92,8 @@ class CoreOffersService {
OfferFilter offerFilter,
OpenOfferManager openOfferManager,
OfferUtil offerUtil,
User user) {
User user,
XmrWalletService xmrWalletService) {
this.coreContext = coreContext;
this.keyRing = keyRing;
this.coreWalletsService = coreWalletsService;
@ -97,6 +103,7 @@ class CoreOffersService {
this.openOfferManager = openOfferManager;
this.offerUtil = offerUtil;
this.user = user;
this.xmrWalletService = xmrWalletService;
}
Offer getOffer(String id) {
@ -117,20 +124,62 @@ class CoreOffersService {
}
List<Offer> getOffers(String direction, String currencyCode) {
return offerBookService.getOffers().stream()
List<Offer> offers = offerBookService.getOffers().stream()
.filter(o -> !o.isMyOffer(keyRing))
.filter(o -> offerMatchesDirectionAndCurrency(o, direction, currencyCode))
.filter(o -> offerFilter.canTakeOffer(o, coreContext.isApiUser()).isValid())
.sorted(priceComparator(direction))
.collect(Collectors.toList());
offers.removeAll(getUnreservedOffers(offers));
return offers;
}
List<Offer> getMyOffers(String direction, String currencyCode) {
return offerBookService.getOffers().stream()
List<Offer> offers = offerBookService.getOffers().stream()
.filter(o -> o.isMyOffer(keyRing))
.filter(o -> offerMatchesDirectionAndCurrency(o, direction, currencyCode))
.sorted(priceComparator(direction))
.collect(Collectors.toList());
Set<Offer> unreservedOffers = getUnreservedOffers(offers);
offers.removeAll(unreservedOffers);
// remove my unreserved offers from offer manager
List<OpenOffer> unreservedOpenOffers = new ArrayList<OpenOffer>();
for (Offer unreservedOffer : unreservedOffers) {
unreservedOpenOffers.add(openOfferManager.getOpenOfferById(unreservedOffer.getId()).get());
}
openOfferManager.removeOpenOffers(unreservedOpenOffers, null);
return offers;
}
private Set<Offer> getUnreservedOffers(List<Offer> offers) {
Set<Offer> unreservedOffers = new HashSet<Offer>();
// collect reserved key images and check for duplicate funds
List<String> allKeyImages = new ArrayList<String>();
for (Offer offer : offers) {
for (String keyImage : offer.getOfferPayload().getReserveTxKeyImages()) {
if (!allKeyImages.add(keyImage)) unreservedOffers.add(offer);
}
}
// get spent key images
// TODO (woodser): paginate offers and only check key images of current page
List<String> spentKeyImages = new ArrayList<String>();
List<MoneroKeyImageSpentStatus> spentStatuses = allKeyImages.isEmpty() ? new ArrayList<MoneroKeyImageSpentStatus>() : xmrWalletService.getDaemon().getKeyImageSpentStatuses(allKeyImages);
for (int i = 0; i < spentStatuses.size(); i++) {
if (spentStatuses.get(i) != MoneroKeyImageSpentStatus.NOT_SPENT) spentKeyImages.add(allKeyImages.get(i));
}
// check for offers with spent key images
for (Offer offer : offers) {
if (unreservedOffers.contains(offer)) continue;
for (String keyImage : offer.getOfferPayload().getReserveTxKeyImages()) {
if (spentKeyImages.contains(keyImage)) unreservedOffers.add(offer);
}
}
return unreservedOffers;
}
OpenOffer getMyOpenOffer(String id) {

View file

@ -140,7 +140,7 @@ public class WalletConfig extends AbstractIdleService {
protected volatile BlockChain vChain;
protected volatile SPVBlockStore vStore;
protected volatile MoneroDaemon vXmrDaemon;
protected volatile MoneroWallet vXmrWallet;
protected volatile MoneroWalletRpc vXmrWallet;
protected volatile Wallet vBtcWallet;
protected volatile Wallet vBsqWallet;
protected volatile PeerGroup vPeerGroup;
@ -287,7 +287,7 @@ public class WalletConfig extends AbstractIdleService {
// Meant to be overridden by subclasses
}
public MoneroWallet createWallet(MoneroWalletConfig config) {
public MoneroWalletRpc createWallet(MoneroWalletConfig config) {
// start monero-wallet-rpc instance
MoneroWalletRpc walletRpc = startWalletRpcInstance();
@ -304,7 +304,7 @@ public class WalletConfig extends AbstractIdleService {
}
}
public MoneroWallet openWallet(MoneroWalletConfig config) {
public MoneroWalletRpc openWallet(MoneroWalletConfig config) {
// start monero-wallet-rpc instance
MoneroWalletRpc walletRpc = startWalletRpcInstance();
@ -362,7 +362,7 @@ public class WalletConfig extends AbstractIdleService {
}
System.out.println("Monero wallet path: " + vXmrWallet.getPath());
System.out.println("Monero wallet address: " + vXmrWallet.getPrimaryAddress());
System.out.println("Monero mnemonic: " + vXmrWallet.getMnemonic());
System.out.println("Monero wallet uri: " + vXmrWallet.getRpcConnection().getUri());
// vXmrWallet.rescanSpent();
// vXmrWallet.rescanBlockchain();
vXmrWallet.sync();

View file

@ -232,6 +232,7 @@ public class CreateOfferService {
extraDataMap,
Version.TRADE_PROTOCOL_VERSION,
arbitrator.getNodeAddress(),
null,
null);
Offer offer = new Offer(offerPayload);
offer.setPriceFeedService(priceFeedService);

View file

@ -171,9 +171,12 @@ public final class OfferPayload implements ProtectedStoragePayload, ExpirablePay
// address and signature of signing arbitrator
@Setter
private NodeAddress arbitratorNodeAddress;
@Nullable
@Setter
@Nullable
private String arbitratorSignature;
@Setter
@Nullable
private List<String> reserveTxKeyImages;
///////////////////////////////////////////////////////////////////////////////////////////
@ -217,7 +220,8 @@ public final class OfferPayload implements ProtectedStoragePayload, ExpirablePay
@Nullable Map<String, String> extraDataMap,
int protocolVersion,
NodeAddress arbitratorSigner,
@Nullable String arbitratorSignature) {
@Nullable String arbitratorSignature,
@Nullable List<String> reserveTxKeyImages) {
this.id = id;
this.date = date;
this.ownerNodeAddress = ownerNodeAddress;
@ -256,6 +260,7 @@ public final class OfferPayload implements ProtectedStoragePayload, ExpirablePay
this.protocolVersion = protocolVersion;
this.arbitratorNodeAddress = arbitratorSigner;
this.arbitratorSignature = arbitratorSignature;
this.reserveTxKeyImages = reserveTxKeyImages;
}
///////////////////////////////////////////////////////////////////////////////////////////
@ -293,7 +298,8 @@ public final class OfferPayload implements ProtectedStoragePayload, ExpirablePay
.setLowerClosePrice(lowerClosePrice)
.setUpperClosePrice(upperClosePrice)
.setIsPrivateOffer(isPrivateOffer)
.setProtocolVersion(protocolVersion);
.setProtocolVersion(protocolVersion)
.setArbitratorNodeAddress(arbitratorNodeAddress.toProtoMessage());
builder.setOfferFeePaymentTxId(checkNotNull(offerFeePaymentTxId,
"OfferPayload is in invalid state: offerFeePaymentTxID is not set when adding to P2P network."));
@ -304,9 +310,8 @@ public final class OfferPayload implements ProtectedStoragePayload, ExpirablePay
Optional.ofNullable(acceptedCountryCodes).ifPresent(builder::addAllAcceptedCountryCodes);
Optional.ofNullable(hashOfChallenge).ifPresent(builder::setHashOfChallenge);
Optional.ofNullable(extraDataMap).ifPresent(builder::putAllExtraData);
builder.setArbitratorNodeAddress(arbitratorNodeAddress.toProtoMessage());
Optional.ofNullable(arbitratorSignature).ifPresent(builder::setArbitratorSignature);
Optional.ofNullable(reserveTxKeyImages).ifPresent(builder::addAllReserveTxKeyImages);
return protobuf.StoragePayload.newBuilder().setOfferPayload(builder).build();
}
@ -358,7 +363,8 @@ public final class OfferPayload implements ProtectedStoragePayload, ExpirablePay
extraDataMapMap,
proto.getProtocolVersion(),
NodeAddress.fromProto(proto.getArbitratorNodeAddress()),
ProtoUtil.stringOrNullFromProto(proto.getArbitratorSignature()));
ProtoUtil.stringOrNullFromProto(proto.getArbitratorSignature()),
proto.getReserveTxKeyImagesList() == null ? null : new ArrayList<String>(proto.getReserveTxKeyImagesList()));
}

View file

@ -282,7 +282,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
removeOpenOffers(getObservableList(), completeHandler);
}
private void removeOpenOffers(List<OpenOffer> openOffers, @Nullable Runnable completeHandler) {
public 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);
@ -670,8 +670,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
return;
}
// verify reserve tx not signed before
// verify maker's reserve tx (double spend, trade fee, trade amount, mining fee)
Offer offer = new Offer(request.getOfferPayload());
BigInteger tradeFee = ParsingUtils.coinToAtomicUnits(offer.getMakerFee());
@ -685,6 +683,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
request.getReserveTxHash(),
request.getReserveTxHex(),
request.getReserveTxKey(),
request.getReserveTxKeyImages(),
true);
// arbitrator signs offer to certify they have valid reserve tx
@ -1034,7 +1033,8 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
updatedExtraDataMap,
protocolVersion,
originalOfferPayload.getArbitratorNodeAddress(),
originalOfferPayload.getArbitratorSignature());
originalOfferPayload.getArbitratorSignature(),
originalOfferPayload.getReserveTxKeyImages());
// Save states from original data to use for the updated
Offer.State originalOfferState = originalOffer.getState();

View file

@ -21,6 +21,8 @@ import bisq.common.crypto.PubKeyRing;
import bisq.core.offer.OfferPayload;
import bisq.network.p2p.DirectMessage;
import bisq.network.p2p.NodeAddress;
import java.util.ArrayList;
import java.util.List;
import lombok.EqualsAndHashCode;
import lombok.Value;
@ -35,6 +37,7 @@ public final class SignOfferRequest extends OfferMessage implements DirectMessag
private final String reserveTxHash;
private final String reserveTxHex;
private final String reserveTxKey;
private final List<String> reserveTxKeyImages;
private final String payoutAddress;
public SignOfferRequest(String offerId,
@ -48,6 +51,7 @@ public final class SignOfferRequest extends OfferMessage implements DirectMessag
String reserveTxHash,
String reserveTxHex,
String reserveTxKey,
List<String> reserveTxKeyImages,
String payoutAddress) {
super(messageVersion, offerId, uid);
this.senderNodeAddress = senderNodeAddress;
@ -58,6 +62,7 @@ public final class SignOfferRequest extends OfferMessage implements DirectMessag
this.reserveTxHash = reserveTxHash;
this.reserveTxHex = reserveTxHex;
this.reserveTxKey = reserveTxKey;
this.reserveTxKeyImages = reserveTxKeyImages;
this.payoutAddress = payoutAddress;
}
@ -79,6 +84,7 @@ public final class SignOfferRequest extends OfferMessage implements DirectMessag
.setReserveTxHash(reserveTxHash)
.setReserveTxHex(reserveTxHex)
.setReserveTxKey(reserveTxKey)
.addAllReserveTxKeyImages(reserveTxKeyImages)
.setPayoutAddress(payoutAddress);
return getNetworkEnvelopeBuilder().setSignOfferRequest(builder).build();
@ -97,6 +103,7 @@ public final class SignOfferRequest extends OfferMessage implements DirectMessag
proto.getReserveTxHash(),
proto.getReserveTxHex(),
proto.getReserveTxKey(),
new ArrayList<String>(proto.getReserveTxKeyImagesList()),
proto.getPayoutAddress());
}
@ -109,6 +116,7 @@ public final class SignOfferRequest extends OfferMessage implements DirectMessag
",\n reserveTxHash='" + reserveTxHash +
",\n reserveTxHex='" + reserveTxHex +
",\n reserveTxKey='" + reserveTxKey +
",\n reserveTxKeyImages='" + reserveTxKeyImages +
",\n payoutAddress='" + payoutAddress +
"\n} " + super.toString();
}

View file

@ -53,16 +53,17 @@ public class MakerReservesTradeFunds extends Task<PlaceOfferModel> {
// freeze reserved outputs
// TODO (woodser): synchronize to handle potential race condition where concurrent trades freeze each other's outputs
List<String> frozenKeyImages = new ArrayList<String>();
List<String> reservedKeyImages = new ArrayList<String>();
MoneroWallet wallet = model.getXmrWalletService().getWallet();
for (MoneroOutput input : reserveTx.getInputs()) {
frozenKeyImages.add(input.getKeyImage().getHex());
reservedKeyImages.add(input.getKeyImage().getHex());
wallet.freezeOutput(input.getKeyImage().getHex());
}
// save offer state
// TODO (woodser): persist
model.setReserveTx(reserveTx);
offer.getOfferPayload().setReserveTxKeyImages(reservedKeyImages);
offer.setOfferFeePaymentTxId(reserveTx.getHash()); // TODO (woodser): rename this to reserve tx id
offer.setState(Offer.State.OFFER_FEE_RESERVED);
complete();

View file

@ -17,6 +17,8 @@
package bisq.core.offer.placeoffer.tasks;
import static com.google.common.base.Preconditions.checkNotNull;
import bisq.common.app.Version;
import bisq.common.taskrunner.Task;
import bisq.common.taskrunner.TaskRunner;
@ -32,8 +34,6 @@ import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static com.google.common.base.Preconditions.checkNotNull;
public class MakerSendsSignOfferRequest extends Task<PlaceOfferModel> {
private static final Logger log = LoggerFactory.getLogger(MakerSendsSignOfferRequest.class);
@ -64,6 +64,7 @@ public class MakerSendsSignOfferRequest extends Task<PlaceOfferModel> {
model.getReserveTx().getHash(),
model.getReserveTx().getFullHex(),
model.getReserveTx().getKey(),
offer.getOfferPayload().getReserveTxKeyImages(),
returnAddress);
// get signing arbitrator

View file

@ -17,14 +17,6 @@
package bisq.core.trade;
import bisq.core.btc.model.XmrAddressEntry;
import bisq.core.btc.wallet.XmrWalletService;
import bisq.core.offer.OfferPayload;
import bisq.core.offer.OfferPayload.Direction;
import bisq.core.support.dispute.mediation.mediator.Mediator;
import bisq.core.trade.messages.InitTradeRequest;
import common.utils.JsonUtils;
import static com.google.common.base.Preconditions.checkNotNull;
import bisq.common.crypto.KeyRing;
@ -32,9 +24,20 @@ import bisq.common.crypto.PubKeyRing;
import bisq.common.crypto.Sig;
import bisq.common.util.Tuple2;
import bisq.common.util.Utilities;
import bisq.core.btc.model.XmrAddressEntry;
import bisq.core.btc.wallet.XmrWalletService;
import bisq.core.offer.OfferPayload;
import bisq.core.offer.OfferPayload.Direction;
import bisq.core.support.dispute.mediation.mediator.Mediator;
import bisq.core.trade.messages.InitTradeRequest;
import common.utils.JsonUtils;
import java.math.BigInteger;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import monero.daemon.MoneroDaemon;
import monero.daemon.model.MoneroOutput;
import monero.daemon.model.MoneroSubmitTxResult;
import monero.daemon.model.MoneroTx;
import monero.wallet.MoneroWallet;
@ -182,7 +185,7 @@ public class TradeUtils {
/**
* Process a reserve or deposit transaction used during trading.
* Checks double spends, deposit amount and destination, trade fee, and mining fee.
* The transaction is submitted but not relayed to the pool.
* The transaction is submitted but not relayed to the pool then flushed.
*
* @param daemon is the Monero daemon to check for double spends
* @param wallet is the Monero wallet to verify the tx
@ -192,9 +195,12 @@ public class TradeUtils {
* @param txHash is the transaction hash
* @param txHex is the transaction hex
* @param txKey is the transaction key
* @param isReserveTx indicates if the tx is a reserve tx, which requires fee padding
* @param keyImages are expected key images of inputs, ignored if null
* @param miningFeePadding verifies depositAmount has additional funds to cover mining fee increase
*/
public static void processTradeTx(MoneroDaemon daemon, MoneroWallet wallet, String depositAddress, BigInteger depositAmount, BigInteger tradeFee, String txHash, String txHex, String txKey, boolean isReserveTx) {
public static void processTradeTx(MoneroDaemon daemon, MoneroWallet wallet, String depositAddress, BigInteger depositAmount, BigInteger tradeFee, String txHash, String txHex, String txKey, List<String> keyImages, boolean miningFeePadding) {
boolean submittedToPool = false;
try {
// get tx from daemon
MoneroTx tx = daemon.getTx(txHash);
@ -203,8 +209,17 @@ public class TradeUtils {
if (tx == null) {
MoneroSubmitTxResult result = daemon.submitTxHex(txHex, true); // TODO (woodser): invert doNotRelay flag to relay for library consistency?
if (!result.isGood()) throw new RuntimeException("Failed to submit tx to daemon: " + JsonUtils.serialize(result));
submittedToPool = true;
tx = daemon.getTx(txHash);
} else if (tx.isRelayed()) {
throw new RuntimeException("Reserve tx must not be relayed");
throw new RuntimeException("Trade tx must not be relayed");
}
// verify reserved key images
if (keyImages != null) {
Set<String> txKeyImages = new HashSet<String>();
for (MoneroOutput input : tx.getInputs()) txKeyImages.add(input.getKeyImage().getHex());
if (!txKeyImages.equals(new HashSet<String>(keyImages))) throw new Error("Reserve tx's inputs do not match claimed key images");
}
// verify trade fee
@ -225,8 +240,13 @@ public class TradeUtils {
check = wallet.checkTxKey(txHash, txKey, depositAddress);
if (!check.isGood()) throw new RuntimeException("Invalid proof of deposit amount");
BigInteger depositThreshold = depositAmount;
if (isReserveTx) depositThreshold = depositThreshold.add(feeThreshold.multiply(BigInteger.valueOf(3l))); // prove reserve of at least deposit amount + (3 * min mining fee)
if (miningFeePadding) depositThreshold = depositThreshold.add(feeThreshold.multiply(BigInteger.valueOf(3l))); // prove reserve of at least deposit amount + (3 * min mining fee)
if (check.getReceivedAmount().compareTo(depositThreshold) < 0) throw new RuntimeException("Deposit amount is not enough, needed " + depositThreshold + " but was " + check.getReceivedAmount());
} finally {
// flush tx from pool if we added it
if (submittedToPool) daemon.flushTxPool(txHash);
}
}
/**

View file

@ -64,6 +64,7 @@ public class ArbitratorProcessesReserveTx extends TradeTask {
request.getReserveTxHash(),
request.getReserveTxHex(),
request.getReserveTxKey(),
null,
true);
// save reserve tx to model

View file

@ -90,7 +90,7 @@ public class ProcessDepositRequest extends TradeTask {
MoneroDaemon daemon = trade.getXmrWalletService().getDaemon();
daemon.flushTxPool(trader.getReserveTxHash());
// process and verify deposit tx which submits to the pool
// process and verify deposit tx
TradeUtils.processTradeTx(
daemon,
trade.getXmrWalletService().getWallet(),
@ -100,6 +100,7 @@ public class ProcessDepositRequest extends TradeTask {
trader.getDepositTxHash(),
request.getDepositTxHex(),
request.getDepositTxKey(),
null,
false);
// sychronize to send only one response
@ -114,8 +115,8 @@ public class ProcessDepositRequest extends TradeTask {
if (processModel.getMaker().getDepositTxHex() != null && processModel.getTaker().getDepositTxHex() != null) {
// relay txs
daemon.relayTxByHash(processModel.getMaker().getDepositTxHash());
daemon.relayTxByHash(processModel.getTaker().getDepositTxHash());
daemon.submitTxHex(processModel.getMaker().getDepositTxHex());
daemon.submitTxHex(processModel.getTaker().getDepositTxHex());
// create deposit response
DepositResponse response = new DepositResponse(

View file

@ -73,6 +73,7 @@ public class OfferMaker {
null,
0,
null,
null,
null));
public static final Maker<Offer> btcUsdOffer = a(Offer);

View file

@ -175,9 +175,9 @@ class GrpcTradesService extends TradesImplBase {
.or(() -> Optional.of(CallRateMeteringInterceptor.valueOf(
new HashMap<>() {{
put(getGetTradeMethod().getFullMethodName(), new GrpcCallRateMeter(1, SECONDS));
put(getTakeOfferMethod().getFullMethodName(), new GrpcCallRateMeter(1, MINUTES));
put(getConfirmPaymentStartedMethod().getFullMethodName(), new GrpcCallRateMeter(1, MINUTES));
put(getConfirmPaymentReceivedMethod().getFullMethodName(), new GrpcCallRateMeter(1, MINUTES));
put(getTakeOfferMethod().getFullMethodName(), new GrpcCallRateMeter(1, SECONDS));
put(getConfirmPaymentStartedMethod().getFullMethodName(), new GrpcCallRateMeter(1, SECONDS));
put(getConfirmPaymentReceivedMethod().getFullMethodName(), new GrpcCallRateMeter(1, SECONDS));
put(getKeepFundsMethod().getFullMethodName(), new GrpcCallRateMeter(1, MINUTES));
put(getWithdrawFundsMethod().getFullMethodName(), new GrpcCallRateMeter(1, MINUTES));
}}

View file

@ -224,7 +224,8 @@ class EditOfferDataModel extends MutableOfferDataModel {
offerPayload.getExtraDataMap(),
offerPayload.getProtocolVersion(),
offerPayload.getArbitratorNodeAddress(),
offerPayload.getArbitratorSignature());
offerPayload.getArbitratorSignature(),
offerPayload.getReserveTxKeyImages());
final Offer editedOffer = new Offer(editedPayload);
editedOffer.setPriceFeedService(priceFeedService);

View file

@ -97,6 +97,7 @@ public class TradesChartsViewModelTest {
null,
1,
null,
null,
null
);

View file

@ -621,6 +621,7 @@ public class OfferBookViewModelTest {
null,
1,
null,
null,
null));
}
}

View file

@ -77,6 +77,7 @@ public class OfferMaker {
null,
0,
null,
null,
null));
public static final Maker<Offer> btcUsdOffer = a(Offer);

View file

@ -177,7 +177,8 @@ message SignOfferRequest {
string reserve_tx_hash = 8;
string reserve_tx_hex = 9;
string reserve_tx_key = 10;
string payout_address = 11;
repeated string reserve_tx_key_images = 11;
string payout_address = 12;
}
message SignOfferResponse {
@ -940,6 +941,7 @@ message OfferPayload {
NodeAddress arbitrator_node_address = 1001;
string arbitrator_signature = 1002;
repeated string reserve_tx_key_images = 1003;
}
message AccountAgeWitness {