diff --git a/docs/concepts/tokens/decentralized-exchange/automated-market-makers.md b/docs/concepts/tokens/decentralized-exchange/automated-market-makers.md
index efa7842f301..e9e620b4a4c 100644
--- a/docs/concepts/tokens/decentralized-exchange/automated-market-makers.md
+++ b/docs/concepts/tokens/decentralized-exchange/automated-market-makers.md
@@ -65,6 +65,85 @@ The diagram below illustrates how an offer interacts with other offers and AMM l
![Offer path through DEX.](/docs/img/amm-clob-diagram.png)
+You can also use the test harness below to experiment with offers and AMM interactions.
+
+
+
+
+
+
+
+
+
+
+
+
Alice's Wallet
+
+
+ Taker Gets:
+
+
+ Currency:
+
+ XRP
+ USD
+
+
+
+
+ Taker Pays:
+
+
+ Currency:
+
+ USD
+ XRP
+
+
+
+
Create Offer
+
Alice's Offers
+
+
+
+
Bob's Wallet
+
+
+ Taker Gets:
+
+
+ Currency:
+
+ XRP
+ USD
+
+
+
+
+ Taker Pays:
+
+
+ Currency:
+
+ USD
+ XRP
+
+
+
+
Create Offer
+
Bob's Offers
+
+
+
+
+
+
+
### Restrictions on Assets
To prevent misuse, some restrictions apply to the assets used in an AMM. If you try to create an AMM with an asset that does not meet these restrictions, the transaction fails. The rules are as follows:
diff --git a/static/js/tutorials/amm-clob-interactive.js b/static/js/tutorials/amm-clob-interactive.js
new file mode 100644
index 00000000000..93b0c05ab1f
--- /dev/null
+++ b/static/js/tutorials/amm-clob-interactive.js
@@ -0,0 +1,298 @@
+if (typeof module !== "undefined") {
+ // Use var here because const/let are block-scoped to the if statement.
+ var xrpl = require('xrpl')
+ }
+
+const client = new xrpl.Client("wss://s.devnet.rippletest.net:51233");
+client.connect()
+
+let aliceWallet = null
+let bobWallet = null
+let issuerWallet = null
+
+let aliceWalletBalance = null
+let bobWalletBalance = null
+
+// Add an event listener to the startButton
+document.addEventListener("DOMContentLoaded", function() {
+ startButton.addEventListener("click", start)
+ aCreateOfferButton.addEventListener("click", aliceCreateOffer)
+ bCreateOfferButton.addEventListener("click", bobCreateOffer)
+});
+
+// Function to get Alice and Bob balances
+
+async function getBalances() {
+ aliceWalletBalance = await client.getBalances(aliceWallet.address)
+ bobWalletBalance = await client.getBalances(bobWallet.address)
+
+ aliceWalletField.value = `${aliceWalletBalance[0].value} XRP / ${aliceWalletBalance[1].value} USD`
+ bobWalletField.value = `${bobWalletBalance[0].value} XRP / ${bobWalletBalance[1].value} USD`
+}
+
+// Function to update AMM
+
+async function ammInfoUpdate() {
+ const ammInfo = await client.request({
+ "command": "amm_info",
+ "asset": {
+ "currency": "XRP"
+ },
+ "asset2": {
+ "currency": "USD",
+ "issuer": issuerWallet.address
+ },
+ "ledger_index": "validated"
+ })
+
+ ammInfoField.value = JSON.stringify(ammInfo.result.amm, null, 2)
+}
+
+// Function to update Alice and Bobs offers
+
+async function updateOffers() {
+ const aliceOffers = await client.request({
+ "command": "account_offers",
+ "account": aliceWallet.address
+ })
+
+ if ( aliceOffers.result.offers == "" ) {
+ aliceOffersField.value = `No offers.`
+ } else {
+ aliceOffersField.value = `${JSON.stringify(aliceOffers.result.offers, null, 2)}`
+ }
+
+ const bobOffers = await client.request({
+ "command": "account_offers",
+ "account": bobWallet.address
+ })
+
+ if ( bobOffers.result.offers == "" ) {
+ bobOffersField.value = `No offers.`
+ } else {
+ bobOffersField.value = `${JSON.stringify(bobOffers.result.offers, null, 2)}`
+ }
+}
+
+// Function to set up test harness
+async function start() {
+
+ // Fund wallets and wait for each to complete
+ startButton.textContent = "Loading wallets...";
+
+ const issuerStart = client.fundWallet()
+ const ammStart = client.fundWallet()
+ const aliceStart = client.fundWallet()
+ const bobStart = client.fundWallet()
+
+ const [issuerResult, ammResult, aliceResult, bobResult] = await Promise.all([issuerStart, ammStart, aliceStart, bobStart])
+
+ issuerWallet = issuerResult.wallet
+ const ammWallet = ammResult.wallet
+ aliceWallet = aliceResult.wallet
+ bobWallet = bobResult.wallet
+
+ // Set up account settings
+ startButton.textContent = "Setting up account settings...";
+
+ const issuerSetRipple = client.submitAndWait({
+ "TransactionType": "AccountSet",
+ "Account": issuerWallet.address,
+ "SetFlag": xrpl.AccountSetAsfFlags.asfDefaultRipple
+ }, {autofill: true, wallet: issuerWallet})
+
+ const ammSetTrust = client.submitAndWait({
+ "TransactionType": "TrustSet",
+ "Account": ammWallet.address,
+ "Flags": 262144,
+ "LimitAmount": {
+ "currency": "USD",
+ "issuer": issuerWallet.address,
+ "value": "10000"
+ }
+ }, {autofill: true, wallet: ammWallet})
+
+ const aliceSetTrust = client.submitAndWait({
+ "TransactionType": "TrustSet",
+ "Account": aliceWallet.address,
+ "Flags": 262144,
+ "LimitAmount": {
+ "currency": "USD",
+ "issuer": issuerWallet.address,
+ "value": "10000"
+ }
+ }, {autofill: true, wallet: aliceWallet})
+
+ const bobSetTrust = client.submitAndWait({
+ "TransactionType": "TrustSet",
+ "Account": bobWallet.address,
+ "Flags": 262144,
+ "LimitAmount": {
+ "currency": "USD",
+ "issuer": issuerWallet.address,
+ "value": "10000"
+ }
+ }, {autofill: true, wallet: bobWallet})
+
+ await Promise.all([issuerSetRipple, ammSetTrust, aliceSetTrust, bobSetTrust])
+
+ // Send USD token
+ startButton.textContent = "Sending USD...";
+
+ const issuerAccountInfo = await client.request({
+ "command": "account_info",
+ "account": issuerWallet.address
+ })
+
+ let sequence = issuerAccountInfo.result.account_data.Sequence
+
+ const ammUSD = client.submitAndWait({
+ "TransactionType": "Payment",
+ "Account": issuerWallet.address,
+ "Amount": {
+ "currency": "USD",
+ "value": "1000",
+ "issuer": issuerWallet.address
+ },
+ "Destination": ammWallet.address,
+ "Sequence": sequence ++
+ }, {autofill: true, wallet: issuerWallet})
+
+ const aliceUSD = client.submitAndWait({
+ "TransactionType": "Payment",
+ "Account": issuerWallet.address,
+ "Amount": {
+ "currency": "USD",
+ "value": "1000",
+ "issuer": issuerWallet.address
+ },
+ "Destination": aliceWallet.address,
+ "Sequence": sequence ++
+ }, {autofill: true, wallet: issuerWallet})
+
+ const bobUSD = client.submitAndWait({
+ "TransactionType": "Payment",
+ "Account": issuerWallet.address,
+ "Amount": {
+ "currency": "USD",
+ "value": "1000",
+ "issuer": issuerWallet.address
+ },
+ "Destination": bobWallet.address,
+ "Sequence": sequence ++
+ }, {autofill: true, wallet: issuerWallet})
+
+ await Promise.all([ammUSD, aliceUSD, bobUSD])
+
+ // Update Alice and Bob's XRP and USD balances
+
+ getBalances()
+
+ // Set up AMM
+ startButton.textContent = "Creating AMM...";
+
+ await client.submitAndWait({
+ "TransactionType": "AMMCreate",
+ "Account": ammWallet.address,
+ "Amount": "50000000", // XRP as drops
+ "Amount2": {
+ "currency": "USD",
+ "issuer": issuerWallet.address,
+ "value": "500"
+ },
+ "TradingFee": 500 // 0.5%
+ }, {autofill: true, wallet: ammWallet})
+
+ // Update AMM
+ ammInfoUpdate()
+
+ startButton.textContent = "Ready (Click to Restart)";
+
+}
+
+
+// Submit Alice Offers
+async function aliceCreateOffer() {
+
+ try {
+ let aliceTakerGets = null
+ let aliceTakerPays = null
+
+ if ( aliceTakerGetsCurrency.value == 'XRP' ) {
+ aliceTakerGets = xrpl.xrpToDrops(aliceTakerGetsAmount.value)
+ } else {
+ aliceTakerGets = {
+ "currency": "USD",
+ "issuer": issuerWallet.address,
+ "value": aliceTakerGetsAmount.value
+ }
+ }
+
+ if ( aliceTakerPaysCurrency.value == 'XRP' ) {
+ aliceTakerPays = xrpl.xrpToDrops(aliceTakerPaysAmount.value)
+ } else {
+ aliceTakerPays = {
+ "currency": "USD",
+ "issuer": issuerWallet.address,
+ "value": aliceTakerPaysAmount.value
+ }
+ }
+
+ await client.submitAndWait({
+ "TransactionType": "OfferCreate",
+ "Account": aliceWallet.address,
+ "TakerGets": aliceTakerGets,
+ "TakerPays": aliceTakerPays
+ }, {autofill: true, wallet: aliceWallet})
+
+ updateOffers()
+ getBalances()
+ ammInfoUpdate()
+
+ } catch (error) {
+ aliceOffersField.value = `${error.message}`
+ }
+}
+
+// Submit Bob Offers
+async function bobCreateOffer() {
+
+ try {
+ let bobTakerGets = null
+ let bobTakerPays = null
+
+ if ( bobTakerGetsCurrency.value == 'XRP' ) {
+ bobTakerGets = xrpl.xrpToDrops(bobTakerGetsAmount.value)
+ } else {
+ bobTakerGets = {
+ "currency": "USD",
+ "issuer": issuerWallet.address,
+ "value": bobTakerGetsAmount.value
+ }
+ }
+
+ if ( bobTakerPaysCurrency.value == 'XRP' ) {
+ bobTakerPays = xrpl.xrpToDrops(bobTakerPaysAmount.value)
+ } else {
+ bobTakerPays = {
+ "currency": "USD",
+ "issuer": issuerWallet.address,
+ "value": bobTakerPaysAmount.value
+ }
+ }
+
+ await client.submitAndWait({
+ "TransactionType": "OfferCreate",
+ "Account": bobWallet.address,
+ "TakerGets": bobTakerGets,
+ "TakerPays": bobTakerPays
+ }, {autofill: true, wallet: bobWallet})
+
+ updateOffers()
+ getBalances()
+ ammInfoUpdate()
+
+ } catch (error) {
+ bobOffersField.value = `${error.message}`
+ }
+}
\ No newline at end of file