Detecting Double-Spend Bugs in API Exchanges Using Multithreaded Tests
This article explains the concept of a “double‑spend” bug encountered during a coin‑exchange API, describes a multithreaded testing approach in Java to reproduce the issue, analyzes the root cause of non‑atomic balance checks, and provides sample code illustrating the detection and prevention method.
In this article the author introduces the term “double‑spend” (originally from blockchain) to describe a bug encountered during ordinary API testing, where the same virtual currency is deducted twice.
Scenario : A platform‑wide coin is used to exchange gifts of varying price. The user logs in, selects a gift quantity and clicks exchange. Two relevant APIs are involved: (1) fetch activity and gift details, (2) perform the exchange. Other record‑related APIs are omitted.
Testing tool : Java is used to wrap the API calls into methods. A custom performance‑testing framework launches multiple threads that repeatedly invoke the exchange request, allowing the detection of concurrency issues.
Solution : By sending a high volume of concurrent requests, the framework can reveal double‑spend bugs. The test constructs appropriate request data, sends the HttpRequestBase objects in parallel, and compares the final data to identify inconsistencies.
Business logic of the exchange API : The service retrieves the user’s balance, checks if it is greater than or equal to the total price, then proceeds to deduct the coins and record the transaction.
Test case : User A starts with a balance of 100 000 coins and attempts to exchange a gift worth 100 coins, issuing 1 010 concurrent requests. The expected outcome is a zero balance and 1 000 successful exchanges; the remaining 10 requests should fail due to insufficient balance.
Observed bug : After the test, the user’s balance is zero but the number of exchanged gifts exceeds 1 000, and some of the last 10 requests incorrectly succeed, indicating a double‑spend.
Root‑cause analysis : The balance is fetched and the total price is checked before the deduction step, but the deduction is performed without re‑validating the balance. Because the operation is not atomic, multiple threads may use the stale balance value, causing the same amount of coins to be deducted more than once.
Testing code (simplified):
package com.fission.najm.activity.before.workPractise;
import com.fission.najm.base.NajmBase;
import com.fun.frame.excute.Concurrent;
import com.fun.frame.thead.RequestThread;
import net.sf.json.JSONObject;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpRequestBase;
public class Exchange extends NajmBase {
public String loginKey;
public String exchangeCode = "";
public int balance;
public int coin;
public HttpRequestBase Rechargerequest;
public HttpRequestBase exchangeRequest;
public static void main(String[] args) {
NajmBase base = new NajmBase();
Exchange exchange = new Exchange(base);
exchange.recharge();
RequestThread requestThread = new RequestThread(exchangeRequest, 101);
new Concurrent(requestThread,10).start();
allOver();
}
/** Recharge */
public JSONObject recharge() {
JSONObject response = null;
String url = "http://www.7najm.com/cash/exchangecrecharge";
JSONObject params = new JSONObject();
params.put("loginKey", loginKey);
params.put("exchangeCode", exchangeCode);
params.put("requestType", "We");
Rechargerequest = getHttpPost(url, params);
return response;
}
/** Get recharge record */
public JSONObject getRechargeRecord() {
JSONObject response = null;
String url = "http://www.7najm.com/cash/getecrrecord";
JSONObject args = new JSONObject();
args.put("loginKey", loginKey);
args.put("page", 1);
args.put("pageSize", 10);
args.put("requestType", "Web");
HttpGet httpGet = getHttpGet(url, args);
response = getHttpResponseEntityByJson(httpGet);
output(response);
return response;
}
/** Get user balance */
public JSONObject getBalance() {
JSONObject response = null;
String url = "http://www.7najm.com/cash/exchangebalance";
JSONObject args = new JSONObject();
args.put("loginKey", loginKey);
args.put("requestType", "Web");
HttpGet httpGet = getHttpGet(url, args);
response = getHttpResponseEntityByJson(httpGet);
if (response.containsKey("dataInfo"))
balance = response.getInt("dataInfo");
output(response);
return response;
}
/** Get exchange code */
public JSONObject getRechargeCode() {
JSONObject response = null;
String url = "http://www.7najm.com/cash/getexchangecode";
JSONObject params = new JSONObject();
params.put("loginKey", loginKey);
params.put("requestType", "0");
params.put("balance", coin);
exchangeRequest = getHttpPost(url, params);
response = getHttpResponseEntityByJson(exchangeRequest);
if (response.containsKey("dataInfo")) {
exchangeCode = response.getJSONObject("dataInfo").getString("exchangeCode");
}
output(response);
return response;
}
/** Get code record */
public JSONObject getCodeRecord() {
JSONObject response = null;
String url = "http://www.7najm.com/cash/getecrecord";
JSONObject args = new JSONObject();
args.put("loginKey", loginKey);
args.put("page", 1);
args.put("pageSize", 20);
args.put("requestType", "Web");
args.put("codeType", 1);
HttpGet httpGet = getHttpGet(url, args);
response = getHttpResponseEntityByJson(httpGet);
output(response);
return response;
}
}By running this multithreaded test, developers can expose the non‑atomic balance deduction and implement proper synchronization or transactional safeguards to eliminate double‑spend bugs in similar exchange services.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
