Published on

Explore the First RPC-less Soroban dApp: On-Chain Complaints and Feedback on all-things Stellar.

Authors

Yesterday's announcement about Mercury Ecosystem V1 release was a big deal for us. In fact, we mentioned it unlocks lots of possibilities in the world of data processing and APIs for your contracts.

In this post, we will actually explore one of the coolest things introduced in Mercury's V1: fully-featured Web3 APIs.

For the non-developers, here's the link to the dApp: https://feedback.xycloo.com/

The Concept: On-Chain Feedback

The concept of this dApp is definitely not taken from Base's onchaincomplaints (it is). Basically, what we've built it's a barebones on-chain social where you post and upvote/downvote feedback and complaints you have about Stellar and Soroban.

Why complain on-chain?

Complaining on-chain is actually a cool idea:

  1. It shows you actually know at least know how to interact with the chain. I.e you're not judging without having ever touched a Stellar wallet.
  2. Even though this is testnet for now, larger messages = more fees. There's no better incentive to go straight to the point.
  3. It's a cool way of showing how you can start building your APIs with Mercury's Cloud Execution Environment.

The contract

The contract should enable us to post new feedback, and vote/downvote existing feedback. It's a total of three user-facing actions so the code is quite straightforward and simple. It's still a good Soroban contract referene as, in its simplicity, enforces a pair of important contract development (also Soroban-related) concepts:

  • We don't store the text, just a hash of the text and emit the actual text as an event. Events are not free, but cheaper than allocating space on the ledger.
  • The entries are temporary entries. There is no inherent value in keeping this stuff on-chain. It is true that this way votes of evicted feedback will be wiped out the ledger, but remember that history still remains! After all, if an feedback hasn't been voted in 30 days we can consider the discussion closed.

The source code can be found at https://github.com/xycloo/onchain-stellar-complaints/tree/main/feedback/contracts/feedback/src.

For the sake of the article, here is the definition of the three user-meant functions I'm referring to above:

pub fn send(env: Env, from: Address, message: Bytes) -> Result<(), Error> {}

pub fn upvote(env: &Env, from: Address, hash: BytesN<32>) -> Result<(), Error> {}

pub fn downvote(env: Env, from: Address, hash: BytesN<32>) -> Result<(), Error> {}

Codebase setup

The codebase can be found at https://github.com/xycloo/onchain-stellar-complaints/.

The repository contains the contract, the Mercury API, and frontend.

We already explored at a high level the contract, so let's jump to the Mercury API.

Indexer + Simulation + Data retrieval = Custom APIs On Mercury Cloud

The feedback-indexer is not only an indexer but also the code that gets executed when the frontend wants to build simulated transactions and when it is retrieving all the indexed feedback entries.

Indexer

We want real-time indexing of votes and the feedback that gets posted. More specifically what we really want is just a lookup table for feedback (mainly, text and votes) each of which should also be updated real-time when new votes come in.

Note: Indexing and maintaining fast retrieval of entries that are non-recoverable when evicted from the ledger (like in our case) is another importanat application of dApp indexing on Soroban.

In Zephyr, the function that gets called real-time as ledgers close is the on_close() function:

#[derive(DatabaseDerive, Clone)]
#[with_name("feedback")]
pub struct Feedback {
    pub source: ScVal,
    pub hash: ScVal,
    pub text: ScVal,
    pub votes: ScVal,
}

#[no_mangle]
pub extern "C" fn on_close() {
    let env = EnvClient::new();
    let events = env.reader().pretty().soroban_events();
    for event in events {
        if event.contract == CONTRACT_ADDRESS {
            let topics = event.topics;
            if SString::from_str(&env.soroban(), "feedback") == env.from_scval(&topics[0]) {
                env.put(&Feedback {
                    source: topics[1].clone(),
                    hash: topics[2].clone(),
                    text: event.data.clone(),
                    votes: env.to_scval(1_i32),
                });
            } else if SString::from_str(&env.soroban(), "upvote") == env.from_scval(&topics[0]) {
                update_feedback(&env, topics[2].clone(), true)
            } else if SString::from_str(&env.soroban(), "downvote") == env.from_scval(&topics[0]) {
                update_feedback(&env, topics[2].clone(), false);
            }
        }
    }
}

This basically does exactly what we want, monitor real-time if new events for our contract are emitted, and if so handle the possible scenarios accordingly: either store a new feedback or update an existing one with their new number of votes.

Data Retrieval

No one likes to deal with raw XDR data, especially from a frontend. With Zephyr functions you can work with Rust's type-safety and potentially also with the native soroban SDK (as already done by the on_close() function). The result will be a json response you can define as you want:

Note: this specific function does a potentially large iteration, thus we rely on raw XDR rather than the host env for efficiency. You can also choose to use the Soroban native SDK, but we don't recommend it for unbound potentially large iterations.

#[derive(Serialize, Deserialize, Clone)]
pub struct FeedbackHttp {
    pub from: String,
    pub hash: String,
    pub text: String,
    pub votes: i32,
}

#[no_mangle]
pub extern "C" fn feedbacks() {
    let env = EnvClient::empty();
    let feedbacks: Vec<FeedbackHttp> = Feedback::read_to_rows(&env)
        .iter()
        .map(|entry| {
            let ScVal::Address(address) = &entry.source else { panic!()};
            let ScVal::Bytes(hash) = &entry.hash else { panic!()};
            let ScVal::Bytes(text) = &entry.text else { panic!()};
            let ScVal::I32(votes) = entry.votes else { panic!()};

            FeedbackHttp {
                from: address.to_string(),
                hash: hex::encode(hash.0.as_slice()),
                text: String::from_utf8(text.to_vec()).unwrap_or("Invalid text".into()),
                votes,
            }
        })
        .collect();

    env.conclude(feedbacks)
}

Getting Simulated Transactions

Why are we doing conversions client-side, building complex and nested XDR arguments for our contracts when we could simply choose to do it with the contract's own typesystem? That's what I've been asking myself, especially given my discutible ability of building frontends and my familiarity with Rust and Soroban.

#[no_mangle]
pub extern "C" fn simulate() {
    let env = EnvClient::empty();
    let request: SimulationRequest = env.read_request_body();

    let response = match request {
        SimulationRequest::Send(SimulateSend { from, sequence, message }) => {
            let address = Address::from_string(&SString::from_str(&env.soroban(), &from));
            let message = Bytes::from_slice(&env.soroban(), message.as_bytes());
            env.simulate_contract_call_to_tx(
                from,
                sequence,
                CONTRACT_ADDRESS,
                Symbol::new(&env.soroban(), "send"),
                vec![
                    &env.soroban(),
                    address.into_val(env.soroban()),
                    message.into_val(env.soroban()),
                ],
            )
            
        },
        SimulationRequest::Vote(SimulateVote { from, sequence, hash, upvote }) => {
            let address = Address::from_string(&SString::from_str(&env.soroban(), &from));
            let hash = BytesN::<32>::from_array(&env.soroban(), &to_array::<u8, 32>(hex::decode(hash).unwrap()));
            let action = if upvote {
                "upvote"
            } else {
                "downvote"
            };

            env.simulate_contract_call_to_tx(
                from,
                sequence,
                CONTRACT_ADDRESS,
                Symbol::new(&env.soroban(), action),
                vec![
                    &env.soroban(),
                    address.into_val(env.soroban()),
                    hash.into_val(env.soroban())
                ],
            )
        }
    }
    .unwrap();

    env.conclude(response)
}

What's this?

simulate_contract_call_to_tx will yield an assembled and simulated transaction that your frontend will only just have to sign before sending it out to the network.

This enables you to skip through building transactions, invocation arguments, simulating it and assembling the transaction. Depending on the complexity of the arguments this might be considered redundant, but keep in mind that:

  • This is RPC functionality that's already included in Mercury. If you need indexing, then you also have a "free" RPC.
  • Rust typesystem beats JS/TS, even on simpler types. Bindings break and in the end you'll end up writing XDR even for simpler arguments.
  • Once the arguments start getting a bit more complex being able to use the contract's typesystem (rust soroban native SDK) really outgames any existing solution.

Your frontend only has to deal with a custom-defined API while you handle casts and building arguments on a much friendlier environment:

#[derive(Serialize, Deserialize, Clone)]
pub struct SimulateSend {
    pub from: String,
    pub sequence: i64,
    pub message: String,
}

#[derive(Serialize, Deserialize, Clone)]
pub struct SimulateVote {
    pub from: String,
    pub sequence: i64,
    pub hash: String,
    pub upvote: bool,
}

#[derive(Serialize, Deserialize, Clone)]
pub enum SimulationRequest {
    Send(SimulateSend),
    Vote(SimulateVote)
}

Frontend

The purpose of this last section is to show how much simpler and friendlier can a frontend that relies on the above API be.

In fact, the whole code (considering all markup is about 200 lines of code) and the interactions with the stellar network are:

Note: this requires a horizon connection to get the sequence, which is something that won't be needed anymore in the next releases.

async function loadFeedbacks() {
  const res = await fetch(`${process.env.ENDPOINT}/zephyr/execute`, {
    method: 'POST',
    headers: {
      Authorization: ['Bearer', process.env.MERCURY_JWT].join(' '),
      'Content-Type': 'application/json'
    },
    cache: "no-cache",
    body: JSON.stringify({
      mode: {
        "Function": {
          fname: "feedbacks",
          arguments: ""
        }
      }
    })
  })
  const json_res = await res.json()
  return json_res
}

async function createTransaction(mode, from, sequence, params) {
    const res = await ProxyPOST(JSON.stringify({ mode: { "Function": { fname: "simulate", arguments: JSON.stringify({ [mode]: { from, sequence: sequence + 1, ...params } }) } } }));
    return res;
}

async function handleTransaction(kit, from, sequence, mode, params, elementId) {
    document.getElementById(elementId).innerText = "pending";
    const res = await createTransaction(mode, from, sequence, params);
    const { result } = await kit.signTx({ xdr: res.tx, publicKeys: [from], network: WalletNetwork.TESTNET });
    const signedTx = StellarSdk.xdr.TransactionEnvelope.fromXDR(result, "base64");
    const tx = new StellarSdk.Transaction(signedTx, WalletNetwork.TESTNET);
    const server = new StellarSdk.Horizon.Server('https://horizon-testnet.stellar.org');
    const sendResponse = await server.submitTransaction(tx);
    if (sendResponse.successful) {
        document.getElementById(elementId).innerText = "Successful";
        document.location.reload(true);
    } else {
        document.getElementById(elementId).innerText = sendResponse.errorResultXdr;
    }
}

async function vote(hash, upvote) {
    const kit = new StellarWalletsKit(kitConfig);
    await kit.openModal({
        onWalletSelected: async (option) => {
            kit.setWallet(option.id);
            const publicKey = await kit.getPublicKey();
            const server = new StellarSdk.Horizon.Server('https://horizon-testnet.stellar.org');
            const sequence = parseInt((await server.loadAccount(publicKey)).sequenceNumber());

            await handleTransaction(kit, publicKey, sequence, 'Vote', { hash, upvote }, hash);
        }
    });
}

async function feedback(message) {
    const kit = new StellarWalletsKit(kitConfig);
    await kit.openModal({
        onWalletSelected: async (option) => {
            kit.setWallet(option.id);
            const publicKey = await kit.getPublicKey();
            const server = new StellarSdk.Horizon.Server('https://horizon-testnet.stellar.org');
            const sequence = parseInt((await server.loadAccount(publicKey)).sequenceNumber());

            await handleTransaction(kit, publicKey, sequence, 'Send', { message }, 'send-status');
        }
    });
}

Considering that most of this is just some wallets connection and transaction signing (which you'd do anyway) we're pretty proud of what we have here.

Also, we know this is not perfect yet and if you have feedback you want to give you're welcome to do so! You can either join the xyclooLabs server or even post your thoughts in the stellar developers discord, we're pretty active there too.


What are you waiting for? Go share feedback and complaints about Stellar, on Stellar.

https://feedback.xycloo.com/