Redemptions

Goals

This guide is a technical guide with examples of how to implement Liquid Collective’s redemption workflow into a Platform's workflow. For more information on Liquid Collective’s ETH staking withdrawal architecture, check out Liquid Collective’s LsETH redemption documentation

Pre-read

Review the Authentication Guide for the Alluvial API.

Implementation

The guide will using the Holesky environment for smart contract calls.

For this example, the implementation will use both smart contract calls and offchain calls via the Redemption API.

The Redemption API is an offchain API that exposes:

  • The list of redeem requests for an owner (wallet) including their redemption IDs

  • The status information about redeem requests (including id, redeemed amount, satisfaction status, claim status, etc.)

  • The heights of the withdrawal stack (ETH supplied) and the redeem queue (LsETH queued to redeem). Learn more here on different queues & stacks.

  • Time estimates for redemptions (projected and fulfilled)

For Contract Addresses: view this page. Make sure you use the Contract Address when sending txs.

For ABIs: This guide uses Goerli testnet and interacts with two contracts: LsETH and RedeemManager. RedeemManger contract is only needed if listening to RedeemManager contract events.

Ethereum NetworkProxyImplementation

LsETH

0x1d8b30cC38Dba8aBce1ac29Ea27d9cFd05379A09

0x6edbde63319df1c54ee94075191c3d2ac5a1bf81

RedeemManager

0x0693875efbF04dDAd955c04332bA3324472DF980

0x3b377e3ac2cc844d8d27b8930f6a3035d3a3fc5b

Create Redemption Request

The LsETH contract exposes a function called requestRedeem.

As an Platform, you allow users to submit (or to request that you submit on their behalf) the amount of LsETH they wish to redeem.

Note: In some cases, the recipient may be different from the message sender. For example: if using an omnibus model, you may have LsETH in a wallet separate from the user’s ETH wallet. In this case, the LsETH wallet will make the requestRedeem, however, the recipient of ETH will be the ETH wallet.

After successfully calling requestRedeem function an ID will be generated. Keep this redeemRequestId as you will use it later on.

Below is a code snippet that will call the requestRedeem function.

Code uses Chainstack as its provider to interact with smart contracts and uses Ethers.js

Contract.json is the ABI associated with the LsETH contract

index.js
const { ethers } = require("ethers");
const Contract = require("./Contract.json");

(async () => {

   const nodeUrl = "https://ethereum-holesky.core.chainstack.com/<ID>";
   const provider = new ethers.providers.JsonRpcProvider(nodeUrl);
 
   const signer = new ethers.Wallet("<INSERT WALLET SECRET>", provider);
   
   const LsETHContract = new ethers.Contract(Contract.address, Contract.abi, signer )
    
    const walletAddress = "<INSERT WALLET ADDRESS>";

    const value = ethers.utils.parseEther("0.000001");

    //Get estimate for gas limit
    const redeem_estimation = await LsETHContract.estimateGas.requestRedeem(value, walletAddress, { gasLimit: 1});

    // Create Redeem Request
    let tx = await LsETHContract.requestRedeem(value, walletAddress, { gasLimit: redeem_estimation})
    let receipt = await tx.wait();
    console.log(tx)

})();

RequestedRedeem event

When creating a requestRedeem, the LsETH contract will emit a RequestedRedeem event. The event contains the redeem request IDs. Additionally, you can use the Redemption API to retrieve redeem request IDs associated with an owner, which is demonstrated later on in this guide.

Below is a code snippet for listening to the RequestedRedeem event via websocket connection.

Request:

Code uses Chainstack websocket as its provider to interact with smart contracts.

RedeemManger.json is the ABI for the RedeemManager contract. ABIs can be found in Etherscan.

websocket.js
const ethers = require("ethers");
const Contract = require("./RedeemManager.json");
 
async function main(){
    
    const provider = new ethers.providers.WebSocketProvider('wss://ethereum-holesky.core.chainstack.com/ws/<ID>', 17000);

    const redeemManagerContract = new ethers.Contract(Contract.address, Contract.abi, provider );

    redeemManagerContract.on("RequestedRedeem",(owner, height, amount, maxRedeemableEth, id) => {
      console.log('RequestedRedeem event');
      console.log(`Owner of redeem ${owner}`)
      console.log(`Height ${height.toString()}`);
      console.log(`Amount of LsETH to redeem ${amount.toString()}`);
      console.log(`Maximum amount of ETH to redeem ${maxRedeemableEth.toString()}`);
      console.log(`Request Redeem ID ${id}`);
    });
}
main();

run in terminal

node websocket.js

Response:

As you can see in the response below, the redeem request ID is 18. This is the request ID associated with the request earlier in the “Create Redeem Request” section of this guide.

The full lifecycle method visual can be seen here.

websocket.js
RequestedRedeem event
Owner of redeem <WALLET ADDRESS>
Height 4980971589444881813
Amount of LsETH to redeem 10000000
Maximum amount of ETH to redeem 10212074
Request Redeem ID 18

eth/v0/redeems

You can use the Redemption API to list the redeemRequests for a given recipient wallet.

You can get a list of all redemptions associated with a wallet using the curl request below.

Request

index.sh
curl -X 'GET' \
  'https://api.staging.alluvial.finance/eth/v0/redeems?owner=<WALLET ADDRESS>' \
  -H 'accept: application/json' \
  -H 'Authorization: Bearer e...jk'

Response:

[
  {
    "id": 18,
    "withdrawal_event_id": -1,
    "total_amount_lseth": "10000000",
    "claimed_amount_lseth": "0",
    "claimable_amount_lseth": "0",
    "max_redeemable_amount_eth": "10209790",
    "owner": "<WALLET ADDRESS>",
    "height": "4980971589354881813",
    "status_claim": "NOT_CLAIMED",
    "status_satisfaction": "PENDING_SATISFACTION",
    "requested_at": 9017542
  }
]

You can see information related to the status of the redemption. Later on, you will see the status update as the redeemRequest becomes satisfied and can be claimed.

For all status combinations check out this Redeem Status Matrix

Timing projections

Now that you have a redeem ID, use the projections endpoint to get an estimate of how long it might take for the redeemRequest to be satisfied and become claimable.

Request:

index.sh
curl 'https://api.staging.alluvial.finance/eth/v0/redeems/18/projection' \
  -H 'Authorization: Bearer e...jk'

Response:

The response below displays when the redeemRequest is likely to be redeemable. Once redeemable, you can use resolveRedeemRequests to get the matching withdrawal event ID.

{
    "projected_redeemable_at": "2023-05-19T19:16:24Z"
}

Reported withdrawal event

A withdrawal event is triggered at the time of the protocol’s Oracle report. Oracles report every 24 hours.

Withdrawal event IDs are not generated instantly, even if there is enough supply. Withdrawal event IDs will be generated at the next Oracle report after redeem request is submitted. This is by design to ensure a fair redemption process for all Liquid Collective participants.

Below is an example event.

Request:

websocket.js
redeemManagerContract.on("ReportedWithdrawal",(height, amount, ethAmount, id) => {
      console.log('ReportedWithdrawal event');
      console.log(`Height ${height.toString()}`);
      console.log(`Amount of LsETH to redeem ${amount.toString()}`);
      console.log(`ETH amount being withdrawn ${ethAmount.toString()}`);
      console.log(`Withdrawal event ID ${id}`);
    });

This event will emit the newly created withdrawal ID, as you can see below:

Response:

websocket.js
ReportedWithdrawal event
Height 4980971589444881813
Amount of LsETH to redeem 10000000
ETH amount being withdrawn 10212074
Withdrawal event ID 10

Resolve redemption request

In order to see if a redemption request can be satisfied, use the resolveRedeemRequests function call.

Request:

index.js
const { ethers } = require("ethers");
const Contract = require("./Contract.json");

(async () => {
    
    const LsETHContract = new ethers.Contract(Contract.address, Contract.abi, provider )

     let arrRequestId = [18];

     let resolveRedeem = await LsETHContract.resolveRedeemRequests(arrRequestId);
     console.log(resolveRedeem.toString())
})();

Response:

10

You receive a redeem ID of 10. 10 is the specific withdrawal event ID that is produced when the Liquid Collective Protocol has enough ETH to satisfy (either partial or full) the redemption. This means that the redeemRequest has been satisfied and the corresponding withdrawal event ID was returned.

This withdrawal event may fully satisfy or partially satisfy the redeem request ID. To ensure redemption requests are fully satisfied prior to claiming, use the /eth/v0/redeems/{idx} endpoint.

A response of…

  • -1 means that the request is not satisfied yet

  • -2 means that the request is out of bounds

  • -3 means that the request has already been claimed

An alternative method to getting the Withdrawal event ID is using the /eth/v0/redeems/{id} endpoint.

Request:

index.sh
curl 'https://api.staging.alluvial.finance/eth/v0/redeems/18' \
  -H 'Authorization: Bearer e...jk'

Response:

The response below indicates that this redemption can be fully satisfied, meaning there is enough supply in the withdrawal stack to fulfill this redemption. This is noted via the "status_satisfaction": "FULLY_SATISFIED" and also the claimable_amount_lseth is equal to the total_amount_lseth.

For other potential states, check out the appendix at the bottom of the doc.

[
  {
    "id": 18,
    "withdrawal_event_id": 10,
    "total_amount_lseth": "10000000",
    "claimed_amount_lseth": "0",
    "claimable_amount_lseth": "10000000",
    "max_redeemable_amount_eth": "10209790",
    "owner": "<WALLET ADDRESS>",
    "height": "4980971589354881813",
    "status_claim": "NOT_CLAIMED",
    "status_satisfaction": "FULLY_SATISFIED",
    "requested_at": 9017542
  }
]

Claim redemption requests

With both a request redeem ID and withdrawal event ID you can submit a claim for the redemption calling the claimRedeemRequests function.

Before claiming, ensure you have correct Withdrawal event ID. Withdrawal events IDs will change based on redemptions happening on the protocol.

Request:

index.js
const arrRequestId = [18];
const arrWithdrawalId = [10]

//Get estimate for gas limit
const claim_estimation = await LsETHContract.estimateGas.claimRedeemRequests(arrRequestId, arrWithdrawalId, { gasLimit: 1});

const claimRedeemRequests = await LsETHContract.claimRedeemRequests(arrRequestId, arrWithdrawalId, { gasLimit: claim_estimation});
const receipt = await claimRedeemRequests.wait();
console.log(claimRedeemRequests);

After making a claim there will be two events triggered, which will be inspected next in this guide.

ClaimedRedeemRequest event

On successfully claiming a redeemRequest, a ClaimedRedeemRequest event is emitted. This event indicates the amount of LsETH claimed, the amount of ETH sent to the recipient, and the remaining LsETH amount that has been satisfied but not yet claimed.

A remaining LsETH amount of 0 means the request has been fully claimed. If the amount is greater than 0, the request has been partially claimed and another claim will be required to fully claim the request.

Request:

websocket.js
redeemManagerContract.on("ClaimedRedeemRequest",(redeemRequestId,recipient, ethAmount, lsEthAmount,  remainingLsEthAmount) => {
    console.log('ClaimedRedeemRequest event');
    console.log(`Redeem Request ID ${redeemRequestId}`);
    console.log(`Recipient of redeem request ${recipient}`);
    console.log(`Amount of ETH ${ethAmount.toString()}`)
    console.log(`Amount of LsETH ${lsEthAmount.toString()}`)
    console.log(`Amount of remaining LsETH ${remainingLsEthAmount.toString()}`)
});

Response:

websocket.js
ClaimedRedeemRequest event
Redeem Request ID 18
Recipient of redeem request <WALLET ADDRESS>
Amount of ETH 10209790
Amount of LsETH 10000000
Amount of remaining LsETH 0

SatisfiedRedeemRequest event

On successfully claiming a redemption request one or more SatisfiedRedeemRequest events are emitted.

Those events provide details about the withdrawal events that have satisfied a redemption request.

Request:

websocket.js
redeemManagerContract.on("SatisfiedRedeemRequest",(redeemRequestId, withdrawalEventId,lsEthAmountSatisfied, ethAmountSatisfied, lsEthAmountRemaining, ethAmountExceeding) => {
      console.log('SatisfiedRedeemRequest event');
      console.log(`Redeem Request ID ${redeemRequestId}`);
      console.log(`Withdrawal ID ${withdrawalEventId}`);
      console.log(`Amount of LsETH satisfied ${lsEthAmountSatisfied.toString()}`);
      console.log(`Amount of ETH satisfied  ${ethAmountSatisfied.toString()}`);
      console.log(`Amount of LsETH left to satisfy ${lsEthAmountRemaining.toString()}`);
      console.log(`Amount of ETH added to buffer  ${ethAmountExceeding.toString()}`);

    });

Response:

websocket.js
SatisfiedRedeemRequest event
Redeem Request ID 18
Withdrawal ID 10
Amount of LsETH satisfied 10000000
Amount of ETH satisfied  10209790
Amount of LsETH left to satisfy 0
Amount of ETH added to buffer  163

eth/v0/redeems

An alternative approach is to use the Alluvial API to call the /redeems endpoint, as it will return the status of both satisfaction and claim status.

Request:

index.sh
curl -X 'GET' \
  'https://api.staging.alluvial.finance/eth/v0/redeems?owner=<WALLET ADDRESS>' \
  -H 'accept: application/json' \
  -H 'Authorization: Bearer e...jk'

Non-finalized blocks are returned in the Alluvial API.

Response:

Below the endpoint returns that this request redeem has been fully satisfied and claimed.

 {
    "id": 18,
    "withdrawal_event_id": -3,
    "total_amount_lseth": "10000000",
    "claimed_amount_lseth": "10000000",
    "claimable_amount_lseth": "0",
    "max_redeemable_amount_eth": "10209790",
    "owner": "<WALLET ADDRESS>",
    "height": "4980971589354881813",
    "status_claim": "FULLY_CLAIMED",
    "status_satisfaction": "FULLY_SATISFIED",
    "requested_at": 9017542
  }

You are now able to fully implement the redemption process for the Liquid Collective protocol!

Redemption Information

The Alluvial API exposes information about both theWithdrawal stack and Redeem queue.

Redemption height

Platforms who want visibility into the current supply (amount of withdrawals) and demand (amount of redeems) can use the /redeems_info endpoint.

Request:

index.sh
curl -X 'GET' \
  'https://api.staging.alluvial.finance/eth/v0/redeems_info' \
  -H 'accept: application/json' \
  -H 'Authorization: Bearer e...jk'

Response:

Below you can see that the redeem queue is greater than the withdrawal stack. This indicates that more supply is needed to fulfill the redeem requests. Additional supply can enter via more deposits and/or exiting a validator.

amounts returned are denominated in LsETH

index.sh
{
  "total_amount_withdrawal_stack_lseth": "204462016832064499274",
  "total_amount_redeem_queue_lseth": "237456822758541259805"
}

In addition to exposing the Withdrawal stack and Redeem queue, Alluvial exposes an estimated time when redeems will be fulfilled.

Request:

index.sh
curl -X 'GET' \
  'https://api.staging.alluvial.finance/eth/v0/redeems_info/projection'\
  -H 'accept: application/json' \
  -H 'Authorization: Bearer e...jk'

Response:

The current projections timestamp logic is:

  • If the timestamp is in the past, it means that there is enough supply to fulfill demand and Alluvial returns the timestamp associated with the latest redeem request.

  • If the timestamp is in the future, it means there isn't enough supply to fulfill demand and time is needed to add supply into the Withdrawal stack.

This is an estimated time and actual times may vary depending on frequency of deposits and redemptions on the Liquid Collective protocol.

index.sh
{
  "projected_fulfilled_at": "2023-06-15T11:02:24Z"
}

Appendix

Full Code

Full code for making deposit transaction

Code is meant for testing purposes and should not be used directly for production workloads.

index.js
const { ethers } = require("ethers");
const Contract = require("../Contract.json");
const walletAddress = "0xbe79ff177a8F6a0D9656cF47D8687f43666a4d1e";

(async () => {

    const nodeUrl = "https://ethereum-holesky.core.chainstack.com/<ID>";
    const provider = new ethers.providers.JsonRpcProvider(nodeUrl);

    const gasPrice = await provider.getGasPrice();

    const signer = new ethers.Wallet("<INSERT WALLET PRIVATE KEY>", provider);

    const riverContract = new ethers.Contract(Contract.address, Contract.abi, signer)

    const value = ethers.utils.parseEther("0.000000000001");

    //Get estimate for gas limit
    const redeem_estimation = await riverContract.estimateGas.requestRedeem(value, walletAddress, { gasLimit: 1 });
    
    // Create Redeem Request
    let tx = await riverContract.requestRedeem(value, walletAddress, { gasLimit: redeem_estimation })

    // ***** Start resolve redeem function block *****
    // Uncomment when running claimRedeemRequests()
    //let arrRequestId = [18];

    //let resolveRedeem = await LsETHContract.resolveRedeemRequests(arrRequestId);
    // console.log(resolveRedeem.toString())
    // ***** Stop resolve redeem function block *****

    // ***** Start claiming function block *****
    // Get estimate for gas limit
    // const claim_estimation = await LsETHContract.estimateGas.claimRedeemRequests(arrRequestId, arrWithdrawalId, { gasLimit: 1});

    //let arrRequestId = [18];
    //const arrWithdrawalId = [10]

    //const claimRedeemRequests = await LsETHContract.claimRedeemRequests(arrRequestId, arrWithdrawalId, { gasLimit: claim_estimation});
    //const receipt = await claimRedeemRequests.wait();
    //console.log(claimRedeemRequests);
    // ***** end claiming function block *****

})();

/redeems response payloads

The redemption process is very dynamic. You can see a matrix of all the redeem statuses here. Below will show the differnce between response payloads when requesting from either:

  • eth/v0/redeems/18

  • eth/v0/redeems?owner=

NOT_CLAIMED & PENDING_SATISFACTION

Why would this state happen?

  • This is the initial state for all redeem requests.

index.sh
{
    "id": 1,
    "withdrawal_event_id": -1,
    "total_amount_lseth": "1000000",
    "claimed_amount_lseth": "0",
    "claimable_amount_lseth": "0",
    "max_redeemable_amount_eth": "1023507",
    "owner": "<WALLET ADDRESS>",
    "height": "201956822758540259700",
    "status_claim": "NOT_CLAIMED",
    "status_satisfaction": "PENDING_SATISFACTION",
    "requested_at": 9134158
  }

NOT_CLAIMED & PARTIALLY_SATISFIED

Why would this state happen?

  • This happens when the redemption request can be partially satisfied and the owner has not made a claimRedeemRequest against the redeem request.

  • Notice that the claimable_amount_lseth is greater than 0 but less than the total_amount_lseth.

index.sh
{
    "id": 1,
    "withdrawal_event_id": 1,
    "total_amount_lseth": "1000000",
    "claimed_amount_lseth": "0",
    "claimable_amount_lseth": "900000",
    "max_redeemable_amount_eth": "1023507",
    "owner": "<WALLET ADDRESS>",
    "height": "201956822758540259700",
    "status_claim": "NOT_CLAIMED",
    "status_satisfaction": "PARTIALLY_SATISFIED",
    "requested_at": 9134158
  }

NOT_CLAIMED & FULLY_SATISFIED

Why would this state happen?

  • The amount of supply can fully satisfy the amount of LsETH being redeemed in this request.

  • Notice that total_amount_lseth == claimable_amount_lseth

index.sh
{
    "id": 1,
    "withdrawal_event_id": 1,
    "total_amount_lseth": "1000000",
    "claimed_amount_lseth": "0",
    "claimable_amount_lseth": "1000000",
    "max_redeemable_amount_eth": "1023507",
    "owner": "<WALLET ADDRESS>",
    "height": "201956822758540259700",
    "status_claim": "NOT_CLAIMED",
    "status_satisfaction": "FULLY_SATISFIED",
    "requested_at": 9134158
  }

PARTIALLY_CLAIMED & PENDING_SATISFACTION

Why would this state happen?

  • The owner has claimed part of redeem request before the redeem request was fully satisfied. More supply will need to be added, hence the withdrawal_event_id is == -1, whereas before there would have been a positive integer associated with the withdrawal_event_id (ex. 0, 1, 2, etc...).

  • Note that claimed_amount_lseth is equal to previously claimed amount.

index.sh
{
    "id": 1,
    "withdrawal_event_id": -1,
    "total_amount_lseth": "1000000",
    "claimed_amount_lseth": "900000",
    "claimable_amount_lseth": "0",
    "max_redeemable_amount_eth": "1023507",
    "owner": "<WALLET ADDRESS>",
    "height": "201956822758540259700",
    "status_claim": "PARTIALLY_CLAIMED",
    "status_satisfaction": "PENDING_SATISFACTION",
    "requested_at": 9134158
  }

PARTIALLY_CLAIMED & PARTIALLY_SATISFIED

Why would this state happen?

  • The owner has claimed part of redeem request before the redeem request was fully satisfied. More supply was found, however not enough to fully satisfy this redeem request.

  • In the example below you can see that total_amount_lseth > claimed_amount_lseth + claimable_amount_lseth. There is 40000 LsETH still left to fully satisfy this request.

index.sh
{
    "id": 1,
    "withdrawal_event_id": 2,
    "total_amount_lseth": "1000000",
    "claimed_amount_lseth": "900000",
    "claimable_amount_lseth": "60000",
    "max_redeemable_amount_eth": "1023507",
    "owner": "<WALLET ADDRESS>",
    "height": "201956822758540259700",
    "status_claim": "PARTIALLY_CLAIMED",
    "status_satisfaction": "PARTIALLY_SATISFIED",
    "requested_at": 9134158
  }

PARTIALLY_CLAIMED & FULLY_SATISFIED

Why would this state happen?

  • The owner has claimed part of redeem request before the redeem request was fully satisfied.

  • In the example below you can see that total_amount_lseth = claimed_amount_lseth + claimable_amount_lseth.

index.sh
{
    "id": 1,
    "withdrawal_event_id": 2,
    "total_amount_lseth": "1000000",
    "claimed_amount_lseth": "900000",
    "claimable_amount_lseth": "100000",
    "max_redeemable_amount_eth": "1023507",
    "owner": "<WALLET ADDRESS>",
    "height": "201956822758540259700",
    "status_claim": "PARTIALLY_CLAIMED",
    "status_satisfaction": "FULLY_SATISFIED",
    "requested_at": 9134158
  }

FULLY_CLAIMED & FULLY_SATISFIED

Why would this state happen?

  • This is final state for all redeem requests. This indicates that the redeem request has been fully satisfied.

  • total_amount_lseth == claimed_amount_lseth and claimable_amount_lseth == 0

  • withdrawal_event_id reflects this state with the -3 ID.

index.sh
{
    "id": 1,
    "withdrawal_event_id": -3,
    "total_amount_lseth": "1000000",
    "claimed_amount_lseth": "1000000",
    "claimable_amount_lseth": "0",
    "max_redeemable_amount_eth": "1023507",
    "owner": "<WALLET ADDRESS>",
    "height": "201956822758540259700",
    "status_claim": "FULLY_CLAIMED",
    "status_satisfaction": "FULLY_SATISFIED",
    "requested_at": 9134158
  }

Last updated