A Deep Dive Into the New V2 jpg.store Smart Contract
Updated: Jun 21
A few months ago, jpg.store came to Canonical with a request for an advanced smart contract for their NFT marketplace. They wanted a smart contract which supported the following features:
Individual token offers
Batching multiple purchases/cancels into a single transaction
Bundling a collection of tokens for sale
Flexible additional payouts for royalties and other fees
This was a challenging request, but we pride ourselves at Canonical LLC as a Plutus smart contract consultancy which pushes the boundary of what is possible on Cardano.
Now that this contract has been successfully launched on mainnet, and we wanted to walk through the design.
The contract is very flexible, and currently only a small portion of all the features are supported by jpg.store, but as time passes more will be implemented. We will focus here on what is possible with the contract, and less on what features jpg.store has implemented as of today.
The V2 Design
The contract is designed around the idea of a generic token swap. A swapper locks assets at the contract address, and includes a list of who should be paid out, and by how much, when the swap occurs.
Here is the datum, which shows how we express what the swapper should receive.
The datum `Swap` has two fields: the `sOwner` which says which user can cancel the swap, e.g. the one that listed the swap, and the payouts, `sSwapPayouts`. The payouts is a list of type `Payout`, where `Payout` is a pair of an address and expected value that must be paid to the user.
One thing to note, we are using `ExpectedValue` instead of the normal `Value`. Let's take a look at `ExpectedValue`'s type to understand why:
`ExpectedValue` is a Map of `CurrencySymbol` to a tuple. This allows us to say, "we want to be paid one token of XYZ policy id" and leave the `TokenName` map empty. This is how we can support policy id offers, or a mix of policy id only offers and offers on a basket of specific tokens.
Also notice the newtypes `Natural` and `WholeNumber`. We need to ensure these numbers are positive because we combine them for payouts, and otherwise we would be open to attacks.
The V2 is designed to have multiple smart contract inputs in the same transaction. Whenever you have multiple script inputs in the same transaction, one has to watch out for the "double satisfaction" weakness.
The V2 contract takes a novel approach to prevent double satisfaction. Instead of validating the contract input by input, each input validates the entire transaction at once.
The contract finds all the input datums and merges the amounts that must be paid to every participant.
However, doing this naively would be expensive and repeat the same computation over and over again. The V2 contract has an optimization, so only the first script input does the validation, and the others are skipped.
The contract takes a very paranoid approach to making sure every script input selects the same first input. Although the current implementation of the cardano-node passes inputs in the same order to every script input, right now, to prevent against different behavior in the future, we first sort all the inputs.
We also error if there are any other types of scripts as part of this transaction. This is because we can't ensure we won't have a double satisfaction issue when combining payouts with an unknown script.
Offers in Depth
The V2 contract supports a complex range of offers. The simplest type of offer is a straightforward offer on a single NFT. However, this is not the only type of offer you can make. You can also make an offer on a NFT policy id, which is useful for buying any floor NFT of a collection. However, the contract is even more flexible. You can make an offer on any combination of specific NFTs and a count of NFTs by policy id alone.
Since you can also configure what the payment is for NFTs, you can use the contract for generic swaps of any collection of tokens for any other collection of tokens. For instance, I could use the contract to trade 2 Clays and 100 Ada for any Chilled Kong and a specific Spacebudz.
It is also worth pointing out, although NFTs are the main use case for jpg.store, the contract supports fungible token swaps seamlessly as well.
Now it is time to talk about a somewhat surprising part of the contract. When accepting an offer, the contract will filter out datums owned by the user that signed the transaction. What this means, is the contract does not check that signer's obligation are meant. So, how can we be sure that the signer is getting what it wants?
The signer sees the transaction before signing, so the user gets to verify the transaction is correct when signing the transaction in their wallet.
In general, smart contracts are used to ensure the parties that are not signing the transaction receive the payouts they deserve, which is the case V2.
Since we filter out the signer's transaction, this lets an owner of a listed NFT accept a lower price, without cancelling their listing.
The Token Purchase Flow
Let's walk through a simple example of listing and purchasing a collection of tokens with the V2 contract. Notice I am saying "tokens" and not "NFTs" because the contract works for both fungible and non-fungible tokens.
In our case, we will list a Pavia NFT and some $PAVIA tokens together for 100 Ada. Additionally, we will add a payout for the royalties, the marketplace and a final payout to our cold storage wallet. Notice the address is different from the one used for ownership.
Once it is listed, the owner can cancel it, or wait for another user to purchase it.
If another user decides to purchase the tokens, they must create a transaction that meets all the obligations the listing user has requested. Specifically, they must pay the royalties, marketplace and the listing user.
A Simple Offer Flow
The offer flow is similar to the direct sale. There are two flavors, an offer on a listed asset and an offer on an unlisted asset. Both flavors have the same locking transaction, but the unlocking transaction is different. Let's get started.
To start the flow, the offerer makes a 100,000 Ada offer on any five SpaceBudz. Here we see an advanced offer, which is an offer for based on policy id and count, as opposed to a policy id and token name.
If a user has five SpaceBudz in their wallet, they can create a transaction to payout the SpaceBudz to the offerer and accept the payment locked in the script UTxO. This transaction is almost identical to the transaction in the case of the direct sale.
The more interesting situation is what happens if the user has listed some NFTs they would like to sale. Let's say they have listed 4 of their SpaceBudz for a combined 110,000 Ada. They can then include those 4 UTxOs, plus and additional UTxO from their wallet with the SpaceBudz and accept the offer.
However, there is a complication. According to the datums in the UTxOs the accepting users should be paid 100,000 Ada. Luckily the contract is built to handle this situation. This is why we filter out of the signer's datums. This allows the old obligations to be dropped and gives the listing user complete control. You can think of accepting an offer as canceling the old obligations and accepting the new ones.
As you can see, the current jpg.store contract supports a host of additional features which have not been released yet. Expect more great improvements to come from jpg.store.
Not only that, jpg.store already has a host of new smart contract ideas they would like to get to market. We can't wait to help bring even more innovation to the Cardano ecosystem.
As always, don't hesitate to contact us if you would like help developing smart contracts on Cardano.