After five successful years, LocalCryptos announced its closure in October 2022. We would like to extend a massive thank you to our users. We greatly appreciate the feedback and support many of you have given us over the years.
LocalCryptos began as a hobby project and experiment in 2017, then called LocalEthereum. We had the idea of using an Ethereum smart contract as a peer-to-peer trading escrow mechanism, rather than a middle-man, and using a new cryptography-in-the-browser technology to power a self-custodial web wallet that can interact with that smart contract.
Before LocalCryptos, there were other cryptography-powered peer-to-peer marketplaces similar to it, but these required users to download an application onto their computer in order to access them, making it near-impossible for self-custodial P2P trading to reach ordinary people and achieve mainstream adoption. LocalCryptos brought those ideas to the web browser so everyone, regardless of technical know-how, had the ability to trade crypto without the risks of hacks and thefts that plague centralized exchanges.
The experiment quickly turned out to be a success. Later, we invented new ways to create functionally similar self-custodial escrow accounts using Bitcoin's Script language, and so we renamed ourselves LocalCryptos, bringing self-custodial P2P trading to Bitcoin and other UTXO-based cryptocurrencies (Litecoin, Dash, and Bitcoin Cash).
While we had much more planned for LocalCryptos, unfortunately we believe that internal and external factors prevent our small team from fully achieving our vision.
This web page serves two purposes:
LocalCryptos had a self-custodial web wallet built into its website. If you backed up your wallet, as recommended, you can access your cryptocurrency using the open source LocalCryptos Wallet Backup Explorer utility. Following the instructions included in the GitHub repository, you will be able to regain access to any stored cryptocurrency in your self-custodial wallets or decentralized escrow accounts.
For those who didn't back up their wallets, we will continue to make our self-custodial web wallet available for you to do so — however you should not rely on it to remain available or bug-free indefinitely. You can access the web wallet here.
You can continue to access your trade history here. We encourage you to use the 'Export History' tool located in your Account page.
LocalCryptos' customer support ticket system remains available for general inquiries and questions. If you have a general inqury, please open a support ticket.
For inquiries from law enforcement or in relation to our Service Terms or Privacy Policy, we ask that you use the relevant contact method outlined in the footers of our Service Terms or Privacy Policy.
LocalCryptos was a self-custodial application powered by decentralized technology and cryptography. Unlike traditional marketplaces, LocalCryptos never touched users' funds — instead, users exchanged money directly with one another, and cryptocurrency was transferred using decentralized smart contracts and self-custodial wallets.
Now that LocalCryptos has closed, we intend to use this page as a resource to explain the novel technology which underpinned the marketplace.
Although LocalCryptos introduced new concepts, it couldn't have existed without the ideas and initiatives of others. In particular, we would especially like to acknowledge the efforts of:
One of the major differences between LocalCryptos and other peer-to-peer trading platforms was that LocalCryptos was secured by cryptography and decentralized technology.
LocalCryptos was decentralized in the way that matters most — the marketplace couldn't see your private keys, couldn't access your cryptocurrency, and couldn't read your messages without consent, and it didn't have the power to withdraw from escrow accounts. Additionally, all trades were peer-to-peer and transfers were made off-platform using external payment services, which means the marketplace wasn't trusted with either crypto or dollars.
But like everything you interact with online, LocalCryptos was not a completely decentralized system. It was made up of a mixture of centralized and decentralized components to strike a balance between strong security and usability. The platform was centralized in the same way that end‐to‐end encrypted applications such as Signal, Telegram, Wickr, Viber and WhatsApp each rely on centralized servers to deliver code, transport and store encrypted payloads, and perform housekeeping tasks that would be infeasible or unnecessary to execute in a self-custodial or end-to-end encrypted manner.
Unlike most websites on the internet, many important operations on the marketplace were performed inside your browser rather than by servers. Nevertheless, the marketplace did have servers that were responsible for certain tasks — however their role was as limited as feasibly possible with available technology.
We made the choice to avoid a fully decentralized system to be able to adhere to important design constraints. That meant, primarily, making the product available to everyone, regardless of their knowledge and experience with cryptography and cryptocurrency.
As a quick summary, the following components of LocalCryptos can be summed up as either decentralized or centralized:
LocalCryptos was built using the relatively modern Web Cryptography API, which has made it possible to perform cryptographic operations, such as encryption and decryption, from within a web application. As of 2023, Web Cryptography is supported by the latest version of all major desktop and mobile web browsers.
LocalCryptos is among only a few well-known web applications that took full advantage of the Web Cryptography API to build a new category of website backed by cryptography.
Cryptography was used by the LocalCryptos web app in various ways to secure information, and to enable end‐to‐end encrypted messaging. Our goal had always been to allow users to retain complete control over the most important aspects of their account, while only revealing minimal metadata to the marketplace's servers.
You can log in to your account with a username and password. However, while the log‐in process might look and feel like an ordinary website's, it actually involves complex cryptography under the hood. The password you have is actually a cryptographic passphrase, and it never leaves your device.
The password selected by you when you first created an account was transformed into a cryptographic key using a key-stretching process known as PBKDF2. Then, your "master key" — which essentially unlocks your account — was encrypted using that newly stretched passphrase.
When you log in, your passphrase goes through the same key-stretching process as it did when you signed up, and then it's used to attempt to decrypt your "master key". It's the ownership of the master key that allows you to decrypt other keys, such as the private keys to your wallet or the keys that are used to encrypt messages with other users.
The reason why everything important is encrypted with this "master key", rather than encrypting everything with your passphrase directly, is so that it's possible to later change your password. Changing your password simply involves re-encrypting the master key with a new passphrase (rather than the alternative which would be to re-encrypt everything).
When a user created a LocalCryptos account, a cryptographic key was generated on the device of the user, known as a "master key". This "master key" would later be used to derive other keys — such as a key for encrypting other private keys used to store cryptocurrency, or a key for signing information.
When creating an account, the first step is to generate a securely random 32-byte "master key" (AccountKeyMaster
) using the Web Cryptography API.
Once the AccountKeyMaster
is created, two more keys can be derived from it:
AccountKeyIdentityPrivate
is the function of SHA256(AccountKeyMaster, "identity")
. This will be used for digitally signing messages using elliptic curve cryptography.AccountKeyEnc
is the function of SHA256(AccountKeyMaster, "enc")
. This key will be used to encrypt other keys using AES symmetric encryption.The AccountKeyMaster
key must be encrypted using the user-inputted password. However, the password alone will not suffice: it must be first transformed into a secure key suitable for encryption using the key derivation function PBKDF2. This process, known as "key stretching", adds complex computational work to make password cracking very difficult. Passphrase
is stretched using PBKDF2 using at least 100,000 iterations with the salt PassphraseSalt
to create SecretKey
.
Once SecretKey
is derived, AccountKeyMaster
is encrypted using it with AES-256-CBC. The initialization vector (16 random bytes) is kept as SecretIv
.
Before the sign up process can be completed, LocalCryptos required a unique message to be signed to prove that you know the private key. The browser obtained the unique message needed to be signed from the LocalCryptos API. This is known as an ephemeral registration "token" and it contains:
TokenId
— A unique identifier to the registration token.TokenNonce
— The message needed to be signed with the new key pair you are about to create.Once obtained, a digital signature of the registration token is created using the AccountKeyIdentityPrivate
key. TokenSignature
is an ECDSA signature of the Keccak-256 hash of TokenNonce
.
The non-sensitive information is then submitted to the API and a new LocalCryptos account would be created. (Everything marked "secret" below is not uploaded.)
Passphrase
(secret) — User-inputted plain password.SecretKey
(secret) — A more secure 32-byte key derived from stretching Passphrase
using PBKDF2 with the salt PassphraseSalt
using at least 100,000 iterations.AccountKeyMaster
(secret) — A randomly generated 32-byte account master key. This is the seed to everything in your account; it's crucial to keep this secure.AccountKeyIdentityPrivate
(secret) — A key that is the SHA-256 hash of the concatenation of AccountKeyMaster
and "identity". Used for signing and authentication.AccountKeyEnc
(secret) — A key that is the SHA-256 hash of the concatenation of AccountKeyMaster
and "enc". Used for encryption.AccountKeyIdentityPublic
— Using the secp256k1 curve, an ECDSA public key that corresponds to AccountKeyIdentityPrivate
. Uniqueness is enforced here; no two users can have the same AccountKeyIdentityPublic
.PassphraseSalt
— The random 16-byte salt used for the PBKDF2 process.PassphrasePBKDF2Iterations
— The number of PBKDF2 iterations to use to stretch the Passphrase
to more secure key material. A higher number results in a slower key stretching process, weakening brute-force attempts.SecretIv
— The randomly generated 16-byte initialization vector used to encrypt AccountKeyMaster
.SecretCiphertext
— The ciphertext of AccountKeyMaster
encrypted to SecretKey
using AES-256-CBC.TokenSignature
— A digital signature of the Keccak-256 hash of TokenNonce
by AccountKeyIdentityPrivate
, to verify ownership of AccountKeyIdentityPublic
.Logging into an account requires two-factor authentication. E‐mail is the default two‐factor authentication method and there is optional support for time-synchronized OTP (e.g. apps like Google Authenticator). SMS is not supported as a two-factor method because SMS is not safe.
Two-factor authentication is necessary to protect the encrypted version of AccountKeyMaster
(i.e. SecretCiphertext
and SecretIv
). Although the decryption process has been slowed by the key stretching process, passwords are still theoretically susceptible to brute‐force attacks (especially dictionary‐based attacks on extremely weak passwords). Hiding the key behind a two‐factor method virtually eliminates the hypothetical attack vector of another user obtaining your passphrase by brute-force.
The two-factor authentication process is executed by the API, meaning that there is a small element of trust here. If the database were to be hacked, an attacker could get all the encrypted private keys. Still, these passwords are salted and stretched so rainbow-table attacks aren't possible and other brute-force attacks are very hard. In this hypothetical hack scenario, it would be difficult for the hacker to obtain passwords except those that are very weak (e.g. "password", or a re-used password from a separate compromised database).
This is the log-in process for two-factor authentication by e-mail:
SecretCiphertext
, SecretIv
, PassphraseSalt
, and PassphrasePBKDF2Iterations
.SecretKey
. The stretching process must be exactly the same as the process used when signing up.SecretKey
can be used to calculate AccountKeyMaster
correctly and hence AccountKeyIdentityPublic
, then the login attempt was successful. If not, the user is prompted to try another password and step 5 is repeated.AccountKeyIdentityPrivate
to prove ownership of the key pair, and a new session is issued.AccountKeyMaster
is stored in the browser's storage for the duration of the session.Changing an account password involves re-encrypting AccountKeyMaster
with a new SecretKey
. The AccountKeyMaster
key can never be changed, hence neither can the identity key or account encryption key be changed.
There are some known limitations of our implementation to keep in mind.
Messages between users on the LocalCryptos P2P trading platform were end-to-end encrypted. That means that messages were encrypted in transit from the sender's computer to the recipient's, without allowing anyone in between to intercept and read the conversation in transit, including the marketplace.
The platform accomplishes this using the Web Cryptography API, a modern JavaScript API that brings advanced cryptographic operations such as encryption, decryption, signature verification and secure key generation, to web browsers.
Although sending and receiving messages on LocalCryptos might feel the same as any other website, what's going on 'under the hood' on your device's CPU is vastly different to nearly every other website out there.
LocalCryptos was among only a few well-known web applications that implement secure end-to-end encryption technology inside a website, such as WhatsApp Web.
Revelations about mass communications surveillance and major database breaches have made consumers more cautious of their privacy on the internet.
Prominent online services have been the subject of intrusions by anonymous hackers. Troves of sensitive information have been stolen from companies such as Apple, TikTok, Target, Equifax, Yahoo, eBay, Ancestry.com, AOL, Adobe, LinkedIn, Microsoft, YouTube, Instagram, Quora, Airtel, Nintendo, Dropbox, T-Mobile, Uber, the United States Postal Service and more in some of the largest data breaches in recent history.
In addition, it was revealed in 2013 that the U.S. government had been secretly collecting data from internet companies. The NSA's PRISM program siphoned data from a broad range of companies including Google, Microsoft, Yahoo, Facebook, YouTube, Skype, and numerous consumer upstream providers.
Early instant messaging apps provided hardly any protection from these types of breaches. Although messages were sent over encrypted channels, the channel was usually between the client and a service provider. The service provider, such as Facebook, Skype, or Google, was able to read the contents of all messages delivered through the service. If the provider was breached, histories of plain‐text messages could be extracted en masse.
In response to these threats, developers and cryptographers accelerated research into new techniques to provide security in a modern digital world where neither internet upstream providers or service providers can be trusted. Many widespread instant messaging apps now employ advanced cryptographic techniques to achieve end‐to‐end confidentiality, including OTR, Apple's iMessage, WhatsApp, Signal, Telegram, and even Facebook Messenger.
LocalCryptos applies the techniques of end‐to‐end encryption so that its users' financial details and personal information are kept confidential between the involved parties. Messages between users are end‐to‐end encrypted, meaning that even in the event of a compromised database, no sensitive information such as bank account information could be extracted from historic messages.
The developers and researchers behind the Off‐the‐Record Messaging (OTR) instant messaging protocol inspired the technology behind end-to-end encrypted messages on LocalCryptos.
LocalCryptos cannot read the contents of each message without permission from one of the participants, however it can see metadata related to those messages. For example, the server knows when a message was sent, who it was sent to, and whether and when a particular message was read.
If you think of end-to-end encrypted messages as letters, LocalCryptos is the mail service that delivers the envelopes containing each letter. Although we can't read the letters, we need to know where to deliver an envelope.
By handling the envelopes, we can also make some educated guesses about a letter without reading it:
In order for two users to send encrypted messages back and forth, they must both agree on a shared secret to encrypt and decrypt those messages with. However, they can't allow anyone else to see that shared secret key, so passing that key via LocalCryptos' server as an intermediary is out of the question.
The LocalCryptos application used an anonymous key agreement scheme known as an Elliptic Curve Diffie–Hellman Key Exchange (ECDH). This cryptographic scheme allows two people with public key pairs to derive the same shared secret using one person's private key and the other's public key.
For this to work, LocalCryptos acts a key server to distribute cryptographic public keys to users. (Users' web browsers also remembered the public keys of other users on their device's storage to mitigate against a breach of the key server.)
Users must already know the other's public identity keys or fetch them from the LocalCryptos key server. These identity key pairs, which never change, aren't used in the ECDH process; however they are used to authenticate another set of key pairs that are used in the Diffie–Hellman method.
In addition to the public identity keys, users silently generate a large number of cryptographically secure 'pre keys' (also 'maker keys') when they first register on the platform. These are ephemeral public-private key pairs that are digitally signed using the user identity keys, then uploaded to the LocalCryptos key server. The purpose of these 'pre keys' is to enable a totally asynchronous key exchange (i.e. allow users to receive messages while offline).
When a user initiates a conversation, they will generate a third ephemeral key pair on the fly. We call this the 'taker key'. This key pair is the last piece of the puzzle to enable the secure and asynchronous key agreement scheme to work flawlessly.
The step-by-step process for initiating a secure end-to-end encryption protocol is:
SharedSecretRoot
).SharedSecretRoot
is used as the seed to calculate more secure keys using a key derivation function. Two further keys are derived from the root for different purposes:SharedSecretEnc
— For message encryption using AES‐256SharedSecretMac
— For message authentication using HMAC‐SHA256When Bob obtains her public key, he'll have enough information to be able to discover the same shared secret Alice had computed.
The Diffie–Hellman method produces the same shared secret when given reversed public and private key inputs. For any two private keys pk
and p'k
and their corresponding ECDSA public keys pu
and p'u
:
The shared secret (SharedSecretRoot
) is the ECDH function using the parameters of one party's private key and the other's public key. For maker key pair mk
and mu
and taker key pair tk
and tu
, the shared secret root sr
is:
This means that when Bob logs in to check his messages:
SharedSecretRoot
as Alice had generated.SharedSecretEnc
and SharedSecretMac
and use these to decrypt the encrypted message sent by Alice.When a user writes a message, it's compiled into a JSON-encoded payload before the encryption process begins. There are two types of message payloads: plain-text messages and attachments. Messages are simple plain‐text messages and attachments are for larger file uploads, for example images or documents.
A message payload is a simple JSON structure. There are three properties that must be included in every payload regardless of the type:
type
— This can be "message" or "attachment".trade_id
— The unique identifier of this trade. This is to prevent complex replay attacks (i.e. copying messages from other trades and pretending they were meant for this one.)client_timestamp
— The current timestamp on the sender's computer (ISO 8601). This is also to detect complex replay attacks and it allows the arbitrator to call out a bad actor if they claim to have sent a message later or earlier than reality.Here's an example:
{
"type": "message",
"client_timestamp": "2022-01-13T00:00:00Z",
"trade_id": "xxxx-xxxx-xxxx-xxx",
"message": "Hello! This is my encrypted message."
}
Sometimes users want to send each other attachments, for example images and documents.
However, including the attachment contents inside the encrypted message could result in a painful user experience, especially for users with poor internet connections. That would mean that the recipient would need to download the entire attachment (which could be several megabytes) before it can even know that it is an attachment.
Instead, we separate the two: the file is encrypted and uploaded separately, and the encrypted message simply points to the location of the encrypted attachment and instructions on how to decrypt it. This gives the recipient the choice to not download the attachment.
The process of sending an attachment is:
AttachmentKey
and AttachmentIv
).AttachmentKey
and AttachmentIv
.AttachmentCiphertext
is generated to protect the integrity of the attachment (AttachmentHash
).AttachmentCiphertext
to cloud storage and is given a unique identifier (AttachmentBlobKey
).AttachmentBlobKey
, AttachmentKey
, AttachmentIv
, and AttachmentHash
). They also include metadata, including the original file name, extension, and size of the attachment.Here's an example:
{
"type": "attachment",
"client_timestamp": "2022-01-13T00:00:00Z",
"trade_id": "xxxx-xxxx-xxxx-xxx",
"attachment_blob_key": "AttachmentBlobKey",
"attachment_sha256": "AttachmentHash",
"attachment_iv": "AttachmentIv",
"attachment_key": "AttachmentKey",
"filename": "UploadedFile.jpg",
"filesize": 1000000
}
These JSON-encoded message payloads are encrypted using the conversation's shared secret, signed using the sender's account identity key, and hashed using the shared MAC key. This enables the other user to verify that the message was sent by the sender and hasn't been tampered with along the way.
Here's how that works:
MessageSignature
) of a hash of MessagePayload
.MessageIv
).MessagePackage
) containing MessagePayload
and MessageSignature
is created.MessagePackage
is encrypted using AES256‐CBC to SharedSecretEnc
with IV MessageIv
and padding (MessageCiphertext
).MessageMac
) as HMAC‐SHA256(SharedSecretMac, MessageCiphertext)
.MessageCiphertext
, MessageIv
, and MessageMac
to the API, which delivers it to the recipient.There is no such thing as an encrypted messaging protocol that can make it impossible for the recipient of a message to share it with somebody else. You can't prevent the person you're sending a message to from sharing your message, just as you can't stop someone from taking a screenshot. If somebody decides to share their conversation, including with us, they can do so.
To enable the marketplace to fully investigate claims of fraud or abuse, or deal with payment disputes, users could share the contents of a conversation. Sharing the shared secret of a conversation with us allows someone to go back and decrypt the transcript for that particular conversation, however it doesn't impact the privacy of your other conversations. This is because each trade uses a unique set of key pairs to derive that shared secret, making shared secrets also unique.
There are some experts who advocate against building web-based encryption applications that are designed to respect the user's interests rather than the web provider's, which is exactly what we've done. While we ultimately disagree with this blanket recommendation because we believe the benefits outweigh the risks and web-based security will improve over time, the arguments against using the Web Cryptography API in websites are not without merit.
The primary argument against using cryptography within web browsers is that web-applications are ultimately less secure than programs and browser extensions when it comes to delivering application code. This is due to the way web browsing works: when you visit this site or any other, your web browser will fetch LocalCryptos' latest application code directly from LocalCryptos' web server and run it—without doing much to authenticate that the code is truly the official LocalCryptos code and hasn't been modified.
You might think that using HTTPS solves this authentication problem, making it so that LocalCryptos can digitally sign the code it delivers, and you are partially correct, however only to an certain extent. While HTTPS practically eliminates the vast majority of theoretical man-in-the-middle attacks against web users, it doesn't solve for the threat that the LocalCryptos web server could itself be compromised. An intelligent hacker could break into LocalCryptos' web servers and carefully modify one of the JavaScript files delivered to end-users, perhaps to insert a few lines of code to siphon private keys.
LocalCryptos did a lot to minimize that risk, including utilizing all the HTTP, DNS, and BGP security mechanisms available. However, it's not possible to totally eliminate that threat. The same can be said about literally every other website and web application out there; there is always the possibility of any website or application being vulnerable to a highly sophisticated attack. At the end of the day, cybersecurity is always a sliding scale that requires a nuanced and honest discussion, and anyone who tells you that a certain piece of software can't be hacked is always wrong.
So what would happen to the privacy of your sent and received private messages if the LocalCryptos website suffered a major hack? It really depends. If your web browser executes maliciously poisoned code injected by a sophisticated hacker, there is a possibility that they could gain access to your private key material and decrypt your conversation history. However, if your web browser doesn't execute that hypothetically poisoned code, then your secret conversations would remain secret regardless of the website's breach. In other words, if you visit the site and log in during an active attack, you could be at risk; however, if you're not actively visiting the website at the time of the attack you wouldn't be at any risk. That's because your web browser only executes a website's code when you intentionally navigate to it.
By comparison, if a centralized messaging service was hacked, everyone would be at risk instantaneously. The attacker would be able to download the plain-text of every sent and received message, because every message would simply be stored in a database in plain-text. The fallout from an attack on an unencrypted messaging service would be far more devastating than an attack on an end-to-end encrypted web application.
In the end, we believe the security benefits of using end-to-end encryption technology on the client-side greatly outweigh the risk of a targeted and sophisticated attack, and that the security of application delivery in web-applications will improve over time as new security technologies become available to modern browsers.
While some implementations of end-to-end encrypted messaging such as Signal take key renewals a step further with a Diffie–Hellman agreement after each message roundtrip, that practice doesn't benefit LocalCryptos' implementation and is actually counterproductive since it complicates the dispute resolution process.
Message‐level key renewals are unnecessary for trades since the sessions don't last for long. It makes sense to keep exchanging new keys for long‐lived conversations (e.g. WhatsApp or Facebook conversations that can go on for years), but most trades are over very quickly.
For Bitcoin escrows, a Bitcoin Script was used that relied on a commit-and-reveal scheme to safely and securely escrow funds without a custodial intermediary.
This Script was used for other UTXO cryptocurrencies, such as Litecoin and Dash, but not Bitcoin Cash. However, in the following explanation we will often refer to just Bitcoin for simplicity.
When the seller put funds into an escrow account, they would generate a P2SH address that would implement complex spending conditions, allowing the funds to end up in the hands of the buyer in the case of a successful trade, or back in the hands of the seller in the case of an agreeable cancellation. Most importantly, it allows a third party arbitrator the ability to allow the buyer or seller claim the output, but makes it impossible for the arbitrator to take the cryptocurrency for themselves.
LocalCryptos didn't use traditional multi-signature outputs, which rely on the OP_CHECKMULTISIG
op code.
In a multi-signature Script, each signer is a party to the spending transaction. Under the LocalCryptos model, the arbitrator never has to sign any part of the transaction—even in a payment dispute. In true peer-to-peer fashion, the marketplace is never a signing party to the transaction, which makes it impossible for it to impose spending conditions on escrows—such as how and when the receiving party can spend Bitcoin.
Our mechanism is especially useful for in-person exchanges. Using this mechanism, it would be simple to build out a feature that enables users to release Bitcoin from escrow accounts without access to the internet. For example, it is possible to build software that allows a user to trigger a release by sending an SMS with a unique code, or by showing a QR to the buyer.
Whenever someone registered a LocalCryptos account, they would generate a large number of cryptographic keys and corresponding signatures. Of course, this happens entirely in the background, hidden from the end-user to not interupt the seamless user experience.
These ephemeral keys come in three categories, two of which are relevant to Bitcoin escrows.
The unrelated category of random keys created at sign-up are end-to-end encryption keys. These keys allowed people to begin encrypted conversations with you while you're offline. LocalCryptos borrows this idea from popular forward-secret encrypted messaging apps such as Signal and OTR.
When you generated a Bitcoin address in your self-custodial LocalCryptos wallet, you also uploaded a signed version to LocalCryptos' servers. When the web wallet was setting itself up, it created and signed hundreds of Bitcoin addresses.
This allowed others to open trades with you and fund escrows, even while you were offline. Users fetched one of your addresses from us, and checked the signature against your public key. Doing so helps avoid the risk of sophisticated man-in-the-middle attacks.
Bitcoin 'escrow keys' are 32-byte secret codes which you can reveal later. You generate a hash of the secret code (using hash160) and sign the hash. Then, you upload the hashed code, signature, and an encrypted secret code to the marketplace.
To put BTC in escrow, the seller creates a Bitcoin transaction containing two outputs. One output is for the amount being escrowed, and the other is the marketplace's refundable fee.
Before doing so, the seller needs to fetch some details from the marketplace:
The seller will confirm each of the buyer's signatures are valid before proceeding. An incorrect signature means that a hacker has attempted to tamper with the escrow.
The escrow output, which carries the amount for the buyer, is a P2SH-P2WSH address for the following Script:
OP_DUP OP_1 OP_EQUAL
OP_IF
# Release by seller
OP_DROP
<BuyerPubKeyHash>
<ReleaseCodeFromSellerHash>
OP_ELSE
OP_DUP OP_2 OP_EQUAL
OP_IF
# Release by arbitrator
OP_DROP
<BuyerPubKeyHash>
<ReleaseCodeFromArbitratorHash>
OP_ELSE
OP_DUP OP_3 OP_EQUAL
OP_IF
# Return by buyer
OP_DROP
<SellerPubKeyHash>
<ReturnCodeFromBuyerHash>
OP_ELSE
OP_4 OP_EQUALVERIFY
# Return by arbitrator
<SellerPubKeyHash>
<ReturnCodeFromArbitratorHash>
OP_ENDIF
OP_ENDIF
OP_ENDIF
OP_ROT
OP_HASH160
OP_EQUALVERIFY
OP_OVER
OP_HASH160
OP_EQUALVERIFY
OP_CHECKSIG
The fee output carries an amount of Bitcoin approximately 1% of the trade's size. The marketplace will claim the fee if the trade is successful. If there's a cancellation, the seller can unlock the output to claim a full refund.
The fee output is a P2SH-P2WSH address for the folowing Script:
OP_DUP OP_3 OP_EQUAL
OP_IF
# Return by buyer
OP_DROP
OP_HASH160
<ReturnCodeFromBuyerHash>
OP_EQUALVERIFY
OP_DUP
OP_HASH160
<SellerPubKeyHash>
OP_EQUALVERIFY
OP_CHECKSIG
OP_ELSE
OP_DUP OP_4 OP_EQUAL
OP_IF
# Return by arbitrator
OP_DROP
OP_HASH160
<ReturnCodeFromArbitratorHash>
OP_EQUALVERIFY
OP_DUP
OP_HASH160
<SellerPubKeyHash>
OP_EQUALVERIFY
OP_CHECKSIG
OP_ELSE
# Spend by LocalCryptos
OP_DUP
OP_HASH160
<ArbitratorPubKeyHash>
OP_EQUALVERIFY
OP_CHECKSIG
OP_ENDIF
OP_ENDIF
Marketplaces should in their user-interface require most Bitcoin escrows to have at least one block confirmation, in order to prevent double-spending attacks. Larger transfers may warrant more confirmations depending on the value of the exchange and the seller's risk appetite.
The buyer already has one of the two inputs needed to unlock the escrow transaction: their own public key. The only missing piece is the seller's secret "releaseBySeller" code.
Likewise, the seller is only one input away from unlocking the escrow. If they get their hands on the buyer's secret "returnByBuyer" code, they can recall the amount in escrow.
The arbitrator holds the secret code to the "releaseByArbitrator" and "returnByArbitrator" escrow keys. If a payment dispute arises, the arbitrator can make a resolution by revealing one of the secrets.
This is the crux of the self-custodial escrow system. The hashed script includes the hash of each secret code, but not the secret code itself.
To spend an escrow output, the receiver needs to compile a signature with the following items:
The first two items are identical to spending a regular P2PKH input, and the next two are unique to this Script.
The "action" byte will tell the script which hashes to check. In a standard trade, a buyer will use a "release by seller" code (0x01
). If the first item is 0x01
, the script will expect the buyer's public key and the seller's release code.
There are four escrow actions, representing all the scenarios of an escrow:
Action | Byte | Expected code | Expected PubKey |
---|---|---|---|
ReleaseBySeller | 0x01 | Seller's | Buyer's |
ReleaseByArbitrator | 0x02 | Arbitrator's | Buyer's |
ReturnByBuyer | 0x03 | Buyer's | Seller's |
ReturnByArbitrator | 0x04 | Arbitrator's | Seller's |
In the case of an escrow cancellation, the marketplace doesn't (and cannot) take a fee. With a return escrow code, the above signature is also compatible with fee output. The seller can spend the fee output in the same way they spend a recalled escrow UTXO.
Clicking "Release" in a LocalCryptos Bitcoin escrow doesn't change anything on the blockchain. It allows the receiving party to spend the coins from escrow (i.e. unlock the UTXO).
The receiving party can choose to spend these coins immediately, or they can wait. In the user-interface, an escrow UTXO will appear in the web wallet next to your regular addresses.
After spending the coins, the escrow's completion is permanently etched into the blockchain.
All the buyer needs is the seller's secret code to claim the escrow, it is possible to allow secure trades to be conducted even in instances where the seller lacks an internet connection. The code is too long to write on paper, but it's a perfect length to store in a phone or to display in a QR code.
LocalCryptos never implemented this feature, however it would be possible to enable two features:
The best supercomputer can't break a standard P2PKH address, which uses a single hash. The key space of a hash160 is 160 bits. We use two hash160s: one for the P2PKH part, and one for the hashed secret code. This brings the key space to 320 bits for external attackers. (The math is more nuanced than that, but you get the idea.)
LocalCryptos Bitcoin escrow addresses are virtually impossible to break.
Bitcoin Cash is a fork of BTC, which means it has similar Bitcoin script smart contract capabilities. Unlike our self-custodial escrow scripts for Bitcoin, Litecoin, and Dash, LocalCryptos' Script for Bitcoin Cash leverages the OP_CHECKDATASIG
op code, which is only available in Bitcoin Cash transactions.
A regular user won't notice a difference between this escrow type, as the outcome is virtually identical to LocalCryptos' self-custodial Bitcoin escrow script. However from a programmer's perspective, the self-custodial Bitcoin Cash P2SH script is more simplistic and intuitive, and it comes with some advantages. (The commit-and-reveal Script described above for Bitcoin escrows would also function for Bitcoin Cash.)
As with all escrows on LocalCryptos, it is technically impossible for the marketplace to steal Bitcoin Cash from escrow. The arbitrator only becomes involved when there is a payment dispute, and once involved it only has the ability to allow the BCH to be redeemed by the buyer or seller.
This Script works in a way that is similar to a multi-signature Bitcoin transaction, except that the parties involved don't need to agree upon how and when the Bitcoin Cash outputs are spent. The oracle — which can be the seller, buyer, or arbitrator, depending on the circumstances of the trade — doesn't have the ability to place conditions on the transaction, unlike with traditional multi-signature wallets.
This is due to the fact that with a traditional multi-signature wallet, all parties must sign a full transaction including all outputs and inputs, whereas with a self-custodial escrow transaction that uses OP_CHECKDATASIG
, the oracle simply needs to give the winner a signature which they can use at any time to unlock the BCH in any way they choose.
This type of on-chain escrow mechanism gives the buyer and seller the ability to exchange without permission, and the arbitrator the ability to intervene as a self-custodial mediator in the case of a payment dispute.
Bitcoin Cash users and developers will be glad to know that the Permission Software Foundation has built upon LocalCryptos' Bitcoin Cash escrow mechanism, porting it to CashScript and creating developer walk-throughs and implementation examples. You can find this in their GitHub repository.
To move Bitcoin Cash into escrow, the seller generates a transaction with two outputs. One output is the escrow to the buyer and the other is a refundable fee. If the trade is not successful, the fee can be claimed back by the seller.
In ordinary trades, the seller will allow the buyer to spend the escrow output. This doesn't require our intervention. Similarly, if the buyer chooses to cancel the trade on their own accord, the seller can spend the output without our help. The first scenario is a "release" and the second is a "return".
In the event of a payment dispute, an arbitrator can step in and act as a mediator. The arbitrator can only allow either the seller or the buyer to spend, by design. The fee script allows the arbitrator to collect a fee after a released escrow, or for seller to claim a refund if the trade is unsuccessful.
OP_DUP # We need to use the byte again afterwards
# Get the hashed public keys we need to compare against (ours, and the oracle)
OP_1
OP_EQUAL
OP_IF
<hash160(SellerPubKey)> # Oracle pub key
<hash160(BuyerPubKey)> # Spender pub key
OP_ELSE
OP_DUP
OP_2 # = release from arbitrator
OP_EQUAL
OP_IF
<hash160(ArbPubKey)> # Oracle pub key
<hash160(BuyerPubKey)> # Spender pub key
OP_ELSE
OP_DUP
OP_3 # = return from buyer
OP_EQUAL
OP_IF
<hash160(BuyerPubKey)> # Oracle pub key
<hash160(SellerPubKey)> # Spender pub key
OP_ELSE
OP_DUP
OP_4 # = return from arbitrator
OP_EQUALVERIFY # must be true, else the message is unknown
<hash160(ArbPubKey)> # Oracle pub key
<hash160(SellerPubKey)> # Spender pub key
OP_ENDIF
OP_ENDIF
OP_ENDIF
# Put the hashed public keys on the alt stack
OP_TOALTSTACK
OP_TOALTSTACK # Stack is effectively reset to the input
# On the alt stack we have: [ hash160(SpenderPubKey), hash160(OraclePubKey) ]
<EscrowKey> # Append the nonce to the escrow key to make the message
OP_CAT # Stack is [ ..., <OraclePubKey>, <0x01 || EscrowKey> ]
OP_SWAP # Use this later; verify the oracle public key hash first
OP_DUP
OP_HASH160
OP_FROMALTSTACK # Grab hashed pub key from alt stack
OP_EQUALVERIFY # Public key checks out; now verify the oracle signature
OP_CHECKDATASIGVERIFY # Now verify the sender
OP_DUP
OP_HASH160
OP_FROMALTSTACK
OP_EQUALVERIFY
OP_CHECKSIG
OP_DEPTH # Count stack size
OP_2
OP_EQUAL # Does the input stack only have two items?
OP_IF # If yes, this is the owner collecting fee; simple PKH
OP_DUP
OP_HASH160
<hash160(ArbPubKey)>
OP_EQUALVERIFY
OP_CHECKSIG
OP_ELSE # Seller is spending a "returned" (i.e. canceled) escrow
OP_DUP
OP_3 # = return from buyer
OP_EQUAL
OP_IF
<hash160(BuyerPubKey)> # Oracle pub key
OP_ELSE
OP_DUP
OP_4 # = return from arbitrator
OP_EQUALVERIFY # must be true, else the message is unknown
<hash160(ArbPubKey)> # Oracle pub key
OP_ENDIF
<hash160(SellerPubKey)> # Spender pub key
# Put the hashed public keys on the alt stack
OP_TOALTSTACK
OP_TOALTSTACK # Stack is effectively reset to the input
# On the alt stack we have: [ hash160(SpenderPubKey), hash160(OraclePubKey) ]
<EscrowKey> # Append the nonce to the escrow key to make the message
OP_CAT # Stack is [ ..., <OraclePubKey>, <0x01 || EscrowKey> ]
OP_SWAP # Use this later; verify the oracle public key hash first
OP_DUP
OP_HASH160
OP_FROMALTSTACK # Grab hashed pub key from alt stack
OP_EQUALVERIFY # Public key checks out; now verify the oracle signature
OP_CHECKDATASIGVERIFY # Verify the sender
OP_DUP
OP_HASH160
OP_FROMALTSTACK
OP_EQUALVERIFY
OP_CHECKSIG
OP_ENDIF
To spend an escrow output, the spender must provide in their Bitcoin Cash transaction's script signature:
<Sig> <SpenderPubKey> <OracleSignature> <OraclePubKey> <ActionByte>
# Example: <Sig> <OwnPubkey> <SignatureFromSeller> <SellerPubKey> OP_1
<ActionByte>
is a byte corresponding with the situation being executed.1
: Escrow is being released by the seller2
: Escrow is being released by the arbitrator3
: Escrow is being returned by the buyer4
: Escrow is being returned by the arbitrator<OraclePubKey>
is the public key of the person signing the release/return message.1
: <OraclePubKey> = <SellerPubKey>
2
: <OraclePubKey> = <ArbPubKey>
3
: <OraclePubKey> = <BuyerPubKey>
4
: <OraclePubKey> = <ArbPubKey>
<OracleSignature>
is a signature from the oracle of ECDSA(<ActionByte> || <EscrowKey>)
. The <EscrowKey>
is unique so that signatures cannot be re-used across escrows.<SpenderPubKey>
is the buyer's public key if a release, otherwise the seller's public key.<Sig>
is the transaction signature from the spender.Escrows on LocalCryptos were conducted using an open source Ethereum smart contract. Similar to the Bitcoin and Bitcoin Cash mechanisms, it is technically impossible for LocalCryptos to take control of decentralized ETH escrow accounts. The only time it can step is when it receives explicit permission to resolve a payment dispute, and even then it can only direct the escrowed ETH to one of the parties of the trade.
The Solidity code is available below.
/**
Copyright (c) 2021 LocalCryptos.com
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.7.6;
abstract contract Token {
function transfer(address _to, uint _value) public virtual returns (bool success);
function transferFrom(address _from, address _to, uint _value) public virtual returns (bool success);
function virtualapprove(address _spender, uint _value) public virtual returns (bool success);
function approve(address spender, uint256 value) public virtual returns (bool);
}
/// @title LocalCryptos ETH Escrows
/// @author LocalCryptos
contract LocalCryptosETHEscrows {
/***********************
+ Global settings +
***********************/
// Address of the arbitrator (currently always localethereum staff)
address public arbitrator;
// Address of the owner (who can withdraw collected fees)
address public owner;
address public inviterAddress;
// Addresses of the relayers (which addresses are allowed to forward signed instructions from parties)
mapping (address => bool) public relayers;
uint32 public requestCancellationMinimumTime;
// Cumulative balance of collected fees
uint256 public feesAvailableForWithdraw;
/***********************
+ Instruction types +
***********************/
// Called when the buyer marks payment as sent. Locks funds in escrow
uint8 constant INSTRUCTION_SELLER_CANNOT_CANCEL = 0x01;
// Buyer cancelling
uint8 constant INSTRUCTION_BUYER_CANCEL = 0x02;
// Seller cancelling
uint8 constant INSTRUCTION_SELLER_CANCEL = 0x03;
// Seller requesting to cancel. Begins a window for buyer to object
uint8 constant INSTRUCTION_SELLER_REQUEST_CANCEL = 0x04;
// Seller releasing funds to the buyer
uint8 constant INSTRUCTION_RELEASE = 0x05;
// Either party permitting the arbitrator to resolve a dispute
uint8 constant INSTRUCTION_RESOLVE = 0x06;
/***********************
+ Events +
***********************/
event Created(bytes32 indexed _tradeHash);
event SellerCancelDisabled(bytes32 indexed _tradeHash);
event SellerRequestedCancel(bytes32 indexed _tradeHash);
event CancelledBySeller(bytes32 indexed _tradeHash);
event CancelledByBuyer(bytes32 indexed _tradeHash);
event Released(bytes32 indexed _tradeHash);
event DisputeResolved(bytes32 indexed _tradeHash);
struct Escrow {
// So we know the escrow exists
bool exists;
// This is the timestamp in whic hthe seller can cancel the escrow after.
// It has two special values:
// 0 : Permanently locked by the buyer (i.e. marked as paid; the seller can never cancel)
// 1 : The seller can only request to cancel, which will change this value to a timestamp.
// This option is avaialble for complex trade terms such as cash-in-person where a
// payment window is inappropriate
uint32 sellerCanCancelAfter;
// Cumulative cost of gas incurred by the relayer. This amount will be refunded to the owner
// in the way of fees once the escrow has completed
uint128 totalGasFeesSpentByRelayer;
}
// Mapping of active trades. The key here is a hash of the trade proprties
mapping (bytes32 => Escrow) public escrows;
modifier onlyOwner() {
require(msg.sender == owner, "Must be owner");
_;
}
modifier onlyArbitrator() {
require(msg.sender == arbitrator, "Must be arbitrator");
_;
}
/// @notice Initialize the contract.
constructor() {
owner = msg.sender;
arbitrator = msg.sender;
inviterAddress = msg.sender;
requestCancellationMinimumTime = 0 seconds;
}
/// @notice Create and fund a new escrow.
/// @param _tradeID The unique ID of the trade, generated by localethereum.com
/// @param _seller The selling party
/// @param _buyer The buying party
/// @param _value The amount of the escrow, exclusive of the fee
/// @param _fee Localethereum's commission in 1/10000ths
/// @param _paymentWindowInSeconds The time in seconds from escrow creation that the seller can cancel after
/// @param _expiry This transaction must be created before this time
/// @param _v Signature "v" component
/// @param _r Signature "r" component
/// @param _s Signature "s" component
function createEscrow(
bytes16 _tradeID,
address _seller,
address _buyer,
uint256 _value,
uint16 _fee,
uint32 _paymentWindowInSeconds,
uint32 _expiry,
uint8 _v,
bytes32 _r,
bytes32 _s
) payable external {
// The trade hash is created by tightly-concatenating and hashing properties of the trade.
// This hash becomes the identifier of the escrow, and hence all these variables must be
// supplied on future contract calls
bytes32 _tradeHash = keccak256(abi.encodePacked(_tradeID, _seller, _buyer, _value, _fee));
// Require that trade does not already exist
require(!escrows[_tradeHash].exists, "Trade already exists");
// A signature (v, r and s) must come from localethereum to open an escrow
bytes32 _invitationHash = keccak256(abi.encodePacked(
_tradeHash,
_paymentWindowInSeconds,
_expiry
));
require(recoverAddress(_invitationHash, _v, _r, _s) == inviterAddress, "Invitation signature was not valid");
// These signatures come with an expiry stamp
require(block.timestamp < _expiry, "Signature has expired");
// Check transaction value against signed _value and make sure is not 0
require(msg.value == _value && msg.value > 0, "Incorrect ETH sent");
uint32 _sellerCanCancelAfter = _paymentWindowInSeconds == 0
? 1
: uint32(block.timestamp) + _paymentWindowInSeconds;
// Add the escrow to the public mapping
escrows[_tradeHash] = Escrow(true, _sellerCanCancelAfter, 0);
emit Created(_tradeHash);
}
uint16 constant GAS_doResolveDispute = 45368;
/// @notice Called by the arbitrator to resolve a dispute. Requires a signature from either party.
/// @param _tradeID Escrow "tradeID" parameter
/// @param _seller Escrow "seller" parameter
/// @param _buyer Escrow "buyer" parameter
/// @param _value Escrow "value" parameter
/// @param _fee Escrow "fee parameter
/// @param _v Signature "v" component
/// @param _r Signature "r" component
/// @param _s Signature "s" component
/// @param _buyerPercent What % should be distributed to the buyer (this is usually 0 or 100)
function resolveDispute(
bytes16 _tradeID,
address payable _seller,
address payable _buyer,
uint256 _value,
uint16 _fee,
uint8 _v,
bytes32 _r,
bytes32 _s,
uint8 _buyerPercent
) external onlyArbitrator {
address _signature = recoverAddress(keccak256(abi.encodePacked(
_tradeID,
INSTRUCTION_RESOLVE
)), _v, _r, _s);
require(_signature == _buyer || _signature == _seller, "Must be buyer or seller");
Escrow memory _escrow;
bytes32 _tradeHash;
(_escrow, _tradeHash) = getEscrowAndHash(_tradeID, _seller, _buyer, _value, _fee);
require(_escrow.exists, "Escrow does not exist");
require(_buyerPercent <= 100, "_buyerPercent must be 100 or lower");
uint256 _totalFees = _escrow.totalGasFeesSpentByRelayer + (GAS_doResolveDispute * uint128(tx.gasprice));
require(_value - _totalFees <= _value, "Overflow error"); // Prevent underflow
feesAvailableForWithdraw += _totalFees; // Add the the pot for localethereum to withdraw
delete escrows[_tradeHash];
emit DisputeResolved(_tradeHash);
if (_buyerPercent > 0)
payable(_buyer).transfer((_value - _totalFees) * _buyerPercent / 100);
if (_buyerPercent < 100)
payable(_seller).transfer((_value - _totalFees) * (100 - _buyerPercent) / 100);
}
/// @notice Release ETH in escrow to the buyer. Direct call option.
/// @param _tradeID Escrow "tradeID" parameter
/// @param _seller Escrow "seller" parameter
/// @param _buyer Escrow "buyer" parameter
/// @param _value Escrow "value" parameter
/// @param _fee Escrow "fee parameter
/// @return bool
function release(
bytes16 _tradeID,
address _seller,
address payable _buyer,
uint256 _value,
uint16 _fee
) external returns (bool) {
require(msg.sender == _seller, "Must be seller");
return doRelease(_tradeID, _seller, _buyer, _value, _fee, 0);
}
/// @notice Disable the seller from cancelling (i.e. "mark as paid"). Direct call option.
/// @param _tradeID Escrow "tradeID" parameter
/// @param _seller Escrow "seller" parameter
/// @param _buyer Escrow "buyer" parameter
/// @param _value Escrow "value" parameter
/// @param _fee Escrow "fee parameter
/// @return bool
function disableSellerCancel(
bytes16 _tradeID,
address _seller,
address _buyer,
uint256 _value,
uint16 _fee
) external returns (bool) {
require(msg.sender == _buyer, "Must be buyer");
return doDisableSellerCancel(_tradeID, _seller, _buyer, _value, _fee, 0);
}
/// @notice Cancel the escrow as a buyer. Direct call option.
/// @param _tradeID Escrow "tradeID" parameter
/// @param _seller Escrow "seller" parameter
/// @param _buyer Escrow "buyer" parameter
/// @param _value Escrow "value" parameter
/// @param _fee Escrow "fee parameter
/// @return bool
function buyerCancel(
bytes16 _tradeID,
address payable _seller,
address _buyer,
uint256 _value,
uint16 _fee
) external returns (bool) {
require(msg.sender == _buyer, "Must be buyer");
return doBuyerCancel(_tradeID, _seller, _buyer, _value, _fee, 0);
}
/// @notice Cancel the escrow as a seller. Direct call option.
/// @param _tradeID Escrow "tradeID" parameter
/// @param _seller Escrow "seller" parameter
/// @param _buyer Escrow "buyer" parameter
/// @param _value Escrow "value" parameter
/// @param _fee Escrow "fee parameter
/// @return bool
function sellerCancel(
bytes16 _tradeID,
address payable _seller,
address _buyer,
uint256 _value,
uint16 _fee
) external returns (bool) {
require(msg.sender == _seller, "Must be seller");
return doSellerCancel(_tradeID, _seller, _buyer, _value, _fee, 0);
}
/// @notice Request to cancel as a seller. Direct call option.
/// @param _tradeID Escrow "tradeID" parameter
/// @param _seller Escrow "seller" parameter
/// @param _buyer Escrow "buyer" parameter
/// @param _value Escrow "value" parameter
/// @param _fee Escrow "fee parameter
/// @return bool
function sellerRequestCancel(
bytes16 _tradeID,
address _seller,
address _buyer,
uint256 _value,
uint16 _fee
) external returns (bool) {
require(msg.sender == _seller, "Must be seller");
return doSellerRequestCancel(_tradeID, _seller, _buyer, _value, _fee, 0);
}
uint16 constant GAS_batchRelayBase = 32720;
/// @notice Relay multiple signed instructions from parties of escrows.
/// @param _tradeID List of _tradeID values
/// @param _seller List of _seller values
/// @param _buyer List of _buyer values
/// @param _value List of _value values
/// @param _fee List of _fee values
/// @param _maximumGasPrice List of _maximumGasPrice values
/// @param _v List of signature "v" components
/// @param _r List of signature "r" components
/// @param _s List of signature "s" components
/// @param _instructionByte List of _instructionByte values
/// @return bool List of results
function batchRelay(
bytes16[] memory _tradeID,
address payable[] memory _seller,
address payable[] memory _buyer,
uint256[] memory _value,
uint16[] memory _fee,
uint128[] memory _maximumGasPrice,
uint8[] memory _v,
bytes32[] memory _r,
bytes32[] memory _s,
uint8[] memory _instructionByte
) public returns (bool[] memory) {
bool[] memory _results = new bool[](_tradeID.length);
uint128 _additionalGas = uint128(relayers[msg.sender] == true ? (GAS_batchRelayBase / _tradeID.length) : 0);
for (uint8 i=0; i<_tradeID.length; i++) {
_results[i] = relay(
_tradeID[i],
_seller[i],
_buyer[i],
_value[i],
_fee[i],
_maximumGasPrice[i],
_v[i],
_r[i],
_s[i],
_instructionByte[i],
_additionalGas
);
}
return _results;
}
/// @notice Withdraw fees collected by the contract. Only the owner can call this.
/// @param _to Address to withdraw fees in to
/// @param _amount Amount to withdraw
function withdrawFees(address payable _to, uint256 _amount) onlyOwner external {
// This check also prevents underflow
require(_amount <= feesAvailableForWithdraw, "Amount is higher than amount available");
feesAvailableForWithdraw -= _amount;
payable(_to).transfer(_amount);
}
/// @notice Set the arbitrator to a new address. Only the owner can call this.
/// @param _newArbitrator Address of the replacement arbitrator
function setArbitrator(address _newArbitrator) onlyOwner external {
arbitrator = _newArbitrator;
}
/// @notice Change the owner to a new address. Only the owner can call this.
/// @param _newOwner Address of the replacement owner
function setOwner(address _newOwner) onlyOwner external {
owner = _newOwner;
}
/// @notice Enable or disable a relayer address. Only the owner can call this.
/// @param _newRelayer Address of the relayer
/// @param _enabled Whether the relayer is enabled
function setRelayer(address _newRelayer, bool _enabled) onlyOwner external {
relayers[_newRelayer] = _enabled;
}
/// @notice Change the inviter to a new address. Only the owner can call this.
/// @param _newInviterAddress Address of the inviter address
function setInviterAddress(address _newInviterAddress) onlyOwner external {
inviterAddress = _newInviterAddress;
}
/// @notice Change the requestCancellationMinimumTime. Only the owner can call this.
/// @param _newRequestCancellationMinimumTime Replacement
function setRequestCancellationMinimumTime(
uint32 _newRequestCancellationMinimumTime
) onlyOwner external {
requestCancellationMinimumTime = _newRequestCancellationMinimumTime;
}
/// @notice Send ERC20 tokens away. This function allows the owner to withdraw stuck ERC20 tokens.
/// @param _tokenContract Token contract
/// @param _transferTo Recipient
/// @param _value Value
function transferToken(
Token _tokenContract,
address _transferTo,
uint256 _value
) onlyOwner external {
_tokenContract.transfer(_transferTo, _value);
}
/// @notice Send ERC20 tokens away. This function allows the owner to withdraw stuck ERC20 tokens.
/// @param _tokenContract Token contract
/// @param _transferTo Recipient
/// @param _transferFrom Sender
/// @param _value Value
function transferTokenFrom(
Token _tokenContract,
address _transferTo,
address _transferFrom,
uint256 _value
) onlyOwner external {
_tokenContract.transferFrom(_transferTo, _transferFrom, _value);
}
/// @notice Send ERC20 tokens away. This function allows the owner to withdraw stuck ERC20 tokens.
/// @param _tokenContract Token contract
/// @param _spender Spender address
/// @param _value Value
function approveToken(
Token _tokenContract,
address _spender,
uint256 _value
) onlyOwner external {
_tokenContract.approve(_spender, _value);
}
/// @notice Relay a signed instruction from a party of an escrow.
/// @param _tradeID Escrow "tradeID" parameter
/// @param _seller Escrow "seller" parameter
/// @param _buyer Escrow "buyer" parameter
/// @param _value Escrow "value" parameter
/// @param _fee Escrow "fee parameter
/// @param _maximumGasPrice Maximum gas price permitted for the relayer (set by the instructor)
/// @param _v Signature "v" component
/// @param _r Signature "r" component
/// @param _s Signature "s" component
/// @param _additionalGas Additional gas to be deducted after this operation
/// @return bool
function relay(
bytes16 _tradeID,
address payable _seller,
address payable _buyer,
uint256 _value,
uint16 _fee,
uint128 _maximumGasPrice,
uint8 _v,
bytes32 _r,
bytes32 _s,
uint8 _instructionByte,
uint128 _additionalGas
) private returns (bool) {
address _relayedSender = getRelayedSender(
_tradeID,
_instructionByte,
_maximumGasPrice,
_v,
_r,
_s
);
if (_relayedSender == _buyer) {
// Buyer's instructions:
if (_instructionByte == INSTRUCTION_SELLER_CANNOT_CANCEL) {
// Disable seller from cancelling
return doDisableSellerCancel(_tradeID, _seller, _buyer, _value, _fee, _additionalGas);
} else if (_instructionByte == INSTRUCTION_BUYER_CANCEL) {
// Cancel
return doBuyerCancel(_tradeID, _seller, _buyer, _value, _fee, _additionalGas);
}
} else if (_relayedSender == _seller) {
// Seller's instructions:
if (_instructionByte == INSTRUCTION_RELEASE) {
// Release
return doRelease(_tradeID, _seller, _buyer, _value, _fee, _additionalGas);
} else if (_instructionByte == INSTRUCTION_SELLER_CANCEL) {
// Cancel
return doSellerCancel(_tradeID, _seller, _buyer, _value, _fee, _additionalGas);
} else if (_instructionByte == INSTRUCTION_SELLER_REQUEST_CANCEL) {
// Request to cancel
return doSellerRequestCancel(_tradeID, _seller, _buyer, _value, _fee, _additionalGas);
}
}
return false;
}
/// @notice Increase the amount of gas to be charged later on completion of an escrow
/// @param _tradeHash Trade hash
/// @param _gas Gas cost
function increaseGasSpent(bytes32 _tradeHash, uint128 _gas) private {
escrows[_tradeHash].totalGasFeesSpentByRelayer += _gas * uint128(tx.gasprice);
}
/// @notice Transfer the value of an escrow, minus the fees, minus the gas costs incurred by relay
/// @param _to Recipient address
/// @param _value Value of the transfer
/// @param _totalGasFeesSpentByRelayer Total gas fees spent by the relayer
/// @param _fee Commission in 1/10000ths
function transferMinusFees(
address payable _to,
uint256 _value,
uint128 _totalGasFeesSpentByRelayer,
uint16 _fee
) private {
uint256 _totalFees = (_value * _fee / 10000) + _totalGasFeesSpentByRelayer;
// Prevent underflow
if(_value - _totalFees > _value) {
return;
}
// Add fees to the pot for localethereum to withdraw
feesAvailableForWithdraw += _totalFees;
payable(_to).transfer(_value - _totalFees);
}
uint16 constant GAS_doRelease = 12664;
/// @notice Release escrow to the buyer. This completes it and removes it from the mapping.
/// @param _tradeID Escrow "tradeID" parameter
/// @param _seller Escrow "seller" parameter
/// @param _buyer Escrow "buyer" parameter
/// @param _value Escrow "value" parameter
/// @param _fee Escrow "fee parameter
/// @param _additionalGas Additional gas to be deducted after this operation
/// @return bool
function doRelease(
bytes16 _tradeID,
address _seller,
address payable _buyer,
uint256 _value,
uint16 _fee,
uint128 _additionalGas
) private returns (bool) {
Escrow memory _escrow;
bytes32 _tradeHash;
(_escrow, _tradeHash) = getEscrowAndHash(_tradeID, _seller, _buyer, _value, _fee);
if (!_escrow.exists) return false;
uint128 _gasFees = _escrow.totalGasFeesSpentByRelayer
+ (relayers[msg.sender] == true
? (GAS_doRelease + _additionalGas ) * uint128(tx.gasprice)
: 0
);
delete escrows[_tradeHash];
emit Released(_tradeHash);
transferMinusFees(_buyer, _value, _gasFees, _fee);
return true;
}
uint16 constant GAS_doDisableSellerCancel = 16568;
/// @notice Prevents the seller from cancelling an escrow. Used to "mark as paid" by the buyer.
/// @param _tradeID Escrow "tradeID" parameter
/// @param _seller Escrow "seller" parameter
/// @param _buyer Escrow "buyer" parameter
/// @param _value Escrow "value" parameter
/// @param _fee Escrow "fee parameter
/// @param _additionalGas Additional gas to be deducted after this operation
/// @return bool
function doDisableSellerCancel(
bytes16 _tradeID,
address _seller,
address _buyer,
uint256 _value,
uint16 _fee,
uint128 _additionalGas
) private returns (bool) {
Escrow memory _escrow;
bytes32 _tradeHash;
(_escrow, _tradeHash) = getEscrowAndHash(_tradeID, _seller, _buyer, _value, _fee);
if (!_escrow.exists) return false;
if(_escrow.sellerCanCancelAfter == 0) return false;
escrows[_tradeHash].sellerCanCancelAfter = 0;
emit SellerCancelDisabled(_tradeHash);
if (relayers[msg.sender] == true) {
increaseGasSpent(_tradeHash, GAS_doDisableSellerCancel + _additionalGas);
}
return true;
}
uint16 constant GAS_doBuyerCancel = 12648;
/// @notice Cancels the trade and returns the ETH to the seller. Can only be called the buyer.
/// @param _tradeID Escrow "tradeID" parameter
/// @param _seller Escrow "seller" parameter
/// @param _buyer Escrow "buyer" parameter
/// @param _value Escrow "value" parameter
/// @param _fee Escrow "fee parameter
/// @param _additionalGas Additional gas to be deducted after this operation
/// @return bool
function doBuyerCancel(
bytes16 _tradeID,
address payable _seller,
address _buyer,
uint256 _value,
uint16 _fee,
uint128 _additionalGas
) private returns (bool) {
Escrow memory _escrow;
bytes32 _tradeHash;
(_escrow, _tradeHash) = getEscrowAndHash(_tradeID, _seller, _buyer, _value, _fee);
if (!_escrow.exists) {
return false;
}
uint128 _gasFees = _escrow.totalGasFeesSpentByRelayer
+ (relayers[msg.sender] == true
? (GAS_doBuyerCancel + _additionalGas ) * uint128(tx.gasprice)
: 0
);
delete escrows[_tradeHash];
emit CancelledByBuyer(_tradeHash);
transferMinusFees(_seller, _value, _gasFees, 0);
return true;
}
uint16 constant GAS_doSellerCancel = 13714;
/// @notice Returns the ETH in escrow to the seller. Called by the seller. Sometimes unavailable.
/// @param _tradeID Escrow "tradeID" parameter
/// @param _seller Escrow "seller" parameter
/// @param _buyer Escrow "buyer" parameter
/// @param _value Escrow "value" parameter
/// @param _fee Escrow "fee parameter
/// @param _additionalGas Additional gas to be deducted after this operation
/// @return bool
function doSellerCancel(
bytes16 _tradeID,
address payable _seller,
address _buyer,
uint256 _value,
uint16 _fee,
uint128 _additionalGas
) private returns (bool) {
Escrow memory _escrow;
bytes32 _tradeHash;
(_escrow, _tradeHash) = getEscrowAndHash(_tradeID, _seller, _buyer, _value, _fee);
if (!_escrow.exists) {
return false;
}
if(_escrow.sellerCanCancelAfter <= 1 || _escrow.sellerCanCancelAfter > block.timestamp) {
return false;
}
if (relayers[msg.sender] == false && _escrow.sellerCanCancelAfter + 12 hours > block.timestamp) {
return false;
}
uint128 _gasFees = _escrow.totalGasFeesSpentByRelayer
+ (relayers[msg.sender] == true
? (GAS_doSellerCancel + _additionalGas ) * uint128(tx.gasprice)
: 0
);
delete escrows[_tradeHash];
emit CancelledBySeller(_tradeHash);
transferMinusFees(_seller, _value, _gasFees, 0);
return true;
}
uint16 constant GAS_doSellerRequestCancel = 17004;
/// @notice Request to cancel. Used if the buyer is unresponsive. Begins a countdown timer.
/// @param _tradeID Escrow "tradeID" parameter
/// @param _seller Escrow "seller" parameter
/// @param _buyer Escrow "buyer" parameter
/// @param _value Escrow "value" parameter
/// @param _fee Escrow "fee parameter
/// @param _additionalGas Additional gas to be deducted after this operation
/// @return bool
function doSellerRequestCancel(
bytes16 _tradeID,
address _seller,
address _buyer,
uint256 _value,
uint16 _fee,
uint128 _additionalGas
) private returns (bool) {
// Called on unlimited payment window trades where the buyer is not responding
Escrow memory _escrow;
bytes32 _tradeHash;
(_escrow, _tradeHash) = getEscrowAndHash(_tradeID, _seller, _buyer, _value, _fee);
if (!_escrow.exists) {
return false;
}
if(_escrow.sellerCanCancelAfter != 1) {
return false;
}
escrows[_tradeHash].sellerCanCancelAfter = uint32(block.timestamp)
+ requestCancellationMinimumTime;
emit SellerRequestedCancel(_tradeHash);
if (relayers[msg.sender] == true) {
increaseGasSpent(_tradeHash, GAS_doSellerRequestCancel + _additionalGas);
}
return true;
}
/// @notice Get the sender of the signed instruction.
/// @param _tradeID Identifier of the trade
/// @param _instructionByte Identifier of the instruction
/// @param _maximumGasPrice Maximum gas price permitted by the sender
/// @param _v Signature "v" component
/// @param _r Signature "r" component
/// @param _s Signature "s" component
/// @return address
function getRelayedSender(
bytes16 _tradeID,
uint8 _instructionByte,
uint128 _maximumGasPrice,
uint8 _v,
bytes32 _r,
bytes32 _s
) view private returns (address) {
bytes32 _hash = keccak256(abi.encodePacked(
_tradeID,
_instructionByte,
_maximumGasPrice
));
if(tx.gasprice > _maximumGasPrice) {
return (address)(0);
}
return recoverAddress(_hash, _v, _r, _s);
}
/// @notice Hashes the values and returns the matching escrow object and trade hash.
/// @dev Returns an empty escrow struct and 0 _tradeHash if not found.
/// @param _tradeID Escrow "tradeID" parameter
/// @param _seller Escrow "seller" parameter
/// @param _buyer Escrow "buyer" parameter
/// @param _value Escrow "value" parameter
/// @param _fee Escrow "fee parameter
/// @return Escrow
function getEscrowAndHash(
bytes16 _tradeID,
address _seller,
address _buyer,
uint256 _value,
uint16 _fee
) view private returns (Escrow memory, bytes32) {
bytes32 _tradeHash = keccak256(abi.encodePacked(
_tradeID,
_seller,
_buyer,
_value,
_fee
));
return (escrows[_tradeHash], _tradeHash);
}
/// @notice Returns an empty escrow struct and 0 _tradeHash if not found.
/// @param _h Data to be hashed
/// @param _v Signature "v" component
/// @param _r Signature "r" component
/// @param _s Signature "s" component
/// @return address
function recoverAddress(
bytes32 _h,
uint8 _v,
bytes32 _r,
bytes32 _s
) private pure returns (address) {
bytes memory _prefix = "\x19Ethereum Signed Message:\n32";
bytes32 _prefixedHash = keccak256(abi.encodePacked(_prefix, _h));
return ecrecover(_prefixedHash, _v, _r, _s);
}
}
Using LocalCryptos, an ordinary trade works like this:
The smart contract allows users to safely exchange ETH with one another, and to name a trusted third-party to mediate a trade if a dispute arises.
Technically, escrows are not directly linked to trades on LocalCryptos, and, if we allowed it, the contract could be utilized to escrow transactions outside of LocalCryptos (perhaps for other real-world goods and services). When you first open a trade with somebody using LocalCryptos, no escrow exists on the blockchain... until the seller initiates and funds it in a single transaction.
Every escrow first requires a signed invitation from marketplace, which has the superfluous purpose of keeping the contract clean of spam and accidental transfers. The seller can request one of these signatures from LocalCryptos's API when they're ready to place their ETH in escrow. The temporary invitation contains a signature of the trade's properties, including:
Creating an escrow requires making a call to the external createEscrow
function with these parameters and the signed invitation. The function can be called from any address.
Once the initial createEscrow
transaction has been confirmed by the network, a verifiable decentralized escrow account exists on the blockchain.
Peer-to-peer marketplaces are many people's first interaction with Ethereum and cryptocurrencies in general, which complicates things. It costs ETH to interact with the blockchain in terms of gas, and somebody who doesn't yet have any ETH is unable to interact with smart contracts directly.
With this in mind, we designed a system in which traders could interact the smart contract for "free" using a proxy. The cost of gas was paid up-front by us to relay digital signatures which authorised instructions on a user's behalf, with the expectation that we'd be reimbursed at the end of the escrow.
At first glance this system may sound exploitable. What's to stop users from racking up debt with no intention to pay? There are a few reasons why fronting gas costs is not very gameable:
During the course of an escrow, there are a few actions each party can take. Ordinarily, these actions are performed via relay, but the parties have the choice to make calls externally if they prefer. Each "action" has a single-byte identifier, which are defined as constants near the beginning of the smart contract's source code.
To invoke an action via relay, the caller simply needs to sign a Keccak hash of the concatenation of the trade ID, the action byte and a maximum gas price that the relayer is allowed to spend for the action. (The gas price cap prevents a theoretical attack vector whereby the relayer could overpay for gas and cause the parties to burn more than a reasonable amount on network fees.)
Releasing funds (action 0x05
).The seller can release the funds to the buyer at any time during the escrow. This will end the escrow, and its balance, minus network fees and the marketplace's fee, will be sent to the buyer's address.
Cancelling as a buyer (action 0x02
).The buyer can cancel the escrow at any time, returning the funds to the seller. The marketplace fee won't be deducted from the balance, but any unpaid network fees covered by the relayer will be taken from the total.
Preventing the seller from cancelling (action 0x01
).The buyer can halt the payment window countdown and prevent the seller from being able to cancel the escrow. This is done to indicate that the buyer has made payment, and he expects the ETH to be released soon.
Once the escrow has entered this stage where the buyer has essentially locked the ETH in escrow, there are three ways the escrow can end:
When a buyer asks the relayer to send this action to the blockchain, the relayer doesn't do so immediately. In fact, in most successful escrows, this action is never broadcasted to the network. This is because it's only important to relay this before the payment window has expired, and there's no benefit to doing it much earlier. The servers held on to the relay request and only sent it roughly twenty minutes before the payment window was due to expire (which is more than enough time for the transaction to safely propagate and confirm). In most successful escrows, the seller has already released the escrow by this time, and so this action becomes redundant.
By not relaying unnecessary actions immediately, this allows the traders to save on network fees. (Most successful trades invoke only createEscrow
and the seller's release action.)
Cancelling as a seller (action 0x03
).The seller cannot as easily cancel as the buyer, and can only do so if the buyer allows it. The seller first needs to wait until the payment window has expired before they can withdraw their funds from escrow.
Allocating storage in an Ethereum smart contract can be really expensive if you're not careful. Of all of the operations available in the EVM, writing to fresh storage is by far the most costly operation.
The LocalCryptos Solidity smart contract is designed to keep a minimal storage footprint. Each escrow in the contract requires an allocation of only two 256-bit words, or 64 bytes in total. This is accomplished by tightly packing static properties of the trade into a "trade hash", and then using that hash as the unique key to the escrow in the public mapping. By packing these into the hash, we avoid the need for allocating extra space for each static property.
Escrows are stored in a public variable named escrows
which is a mapping of the custom Escrow
struct:
mapping (bytes32 => Escrow) public escrows;
The 32-byte keys to the mapping are Keccak hash functions of the tightly-packed concatenation of:
Once we have all of the static properties of the trade, we can generate the trade hash with a simple Keccak-256 function in Solidity.
Information about each escrow is stored in an Escrow
struct. This struct fits an allocation of one word; although only 161 bits of storage are needed, the EVM allocates storage only in 256-bit blocks.
struct Escrow {
bool exists;
uint32 sellerCanCancelAfter;
uint128 totalGasFeesSpentByRelayer;
}
The struct contains these variables:
0
indicates that the seller is not allowed to cancel at any time. The value can be set to 0
at any time at the buyer's request to lock the funds in the escrow until the seller releases the funds or the arbitrator steps in to resolve the trade. This is typically done when buyer indicates he has made payment, and expects the funds to be released.1
, which is a special value to mean infinity. When sellerCanCancelAfter
equals 1
, the seller can't cancel, but can initiate a request to cancel. When the seller indicates that he'd like to cancel the trade, the buyer is given adequate time to dispute the request before the seller-cancellation is permitted.In the case of a dispute, either party needs to give the arbitrator their signed dispute token. With a signature of this dispute token, which is simply the output of sha3(Trade ID, 0x06)
, the arbitrator can resolve the dispute in either party's favour.
To do this, we call the external resolveDispute
function with the trade's static properties, the signed dispute token, and the percentage of the escrow's balance that will be sent to each party. It is impossible for the arbitrator to have the disputed ETH sent to anybody else.
In addition to this page, which collates most of the information related to LocalCryptos' inner-workings, you can learn more about LocalCryptos in our Academy section or FAQ. You can also find information in our security page.
If you have any questions, please reach out to us.