Skip to content
A dense dark wall of racked teal authorization plates, each stamped with an identical blank seal in its upper corner and a shallow value channel along its lower edge, crossed by a rigid reader bar whose contact fingers touch only the seal corners; one plate glows red with a matching seal and a value channel cut into a deep trench the reader never reaches.
exploitsAugust 7, 20263 min read

Swan Treasury's $625K Signer Key Leak: One Key, Three Contracts, Five Cents of Gas

Dmitry Serdyuk
Dmitry SerdyukCo-Founder & CDO

Updated on August 7, 2026

TL;DR

At 18:21:45 UTC on July 30, 2026, a single BNB Chain transaction pulled 871,443 STY out of Swan Treasury's contracts and left the attacker holding 625,246.92 USDT. The cause was a compromised off-chain signer key, reported by monitoring service Defimon Alerts and picked up by crypto.news the following day. Holding that key, the attacker signed a purchase order for themselves with the discount parameter set to 1, which multiplied their token payout by exactly 100. The purchase price was funded by a PancakeSwap flash loan and repaid inside the same transaction. Out-of-pocket cost: 0.0000890222 BNB of gas, about five cents. Swan Treasury has published no postmortem as of August 7, 2026, and contract storage still holds the compromised signer, in a contract whose owner is now the zero address and whose rotation function can therefore never run again. The leaked key is what made the transaction possible, but the reason it was worth $625,000 rather than $6,250 is that the contract bounded nothing the signature asked for.


Swan Treasury and the Three Contracts That Paid Out

Swan Treasury is a BNB Chain project marketing privacy-oriented financial infrastructure: anonymous identity, private swaps, dark pool liquidity, and on-chain vaults, with STY as the ecosystem token. STY traded around $2.87 at the time.

The whole event is one transaction, 0xc8c3325b...96cbd4, mined in block 113056961, carrying 39 token transfers and 66 event logs. The main victim contract is verified on BscScan, so the calldata can be read against the Solidity that consumed it.

Three Swan Treasury contracts paid out:

  • ZhaiquanBuy at 0x27af47fa...3bac6a8c released 687,245 STY through its buy() function.
  • An unverified sibling at 0x758905b3...5c91A1a88 released a further 184,198 STY.
  • A third contract at 0x473aea0C...656F7c7cF released 15,801 USDT.

Defimon reports forged claim() and transfer() signatures on sibling contracts, matching those second and third legs. ZhaiquanBuy is the leg we can read line by line, so that is where the analysis lives.


How Did One Signature Buy 687,245 STY for $19,753?

Swan Treasury sold STY through a signed-order pattern. A backend decides what a user may buy and at what discount, signs those terms with a private key, and hands the signature to the user. The contract recovers the signer address and, if it matches the address on file, executes the purchase. The idea is sound and common: pricing logic that would be expensive on-chain lives off-chain, and the signature is the receipt.

The pricing function, taken directly from that verified source:

function getBuyamount(uint256 buyusdt, uint256 discount) public view returns (uint256) {
    uint256 styprice = ISYTTOKEN(_sty).getCurrentPrice();
    uint256 buyamount = buyusdt.mul(1e18).div(styprice);
    if (discount > 0) {
        buyamount = buyamount.mul(100).div(discount);
    }
    return buyamount;
}

discount is a divisor out of 100. At discount = 100 the buyer pays face value. At discount = 50 they get twice as many tokens for the same money. At discount = 1, one hundred times as many. There is no floor: the only guard is discount > 0, which avoids a division by zero and does nothing to bound the price.

Now the caller side:

function buy(uint256 orderid, uint256 totalamount, uint256 times, uint256 discount,
             uint256 cooldown, string memory signedmsg) public {
    address user = msg.sender;
    bytes32 hashValue = keccak256(abi.encode(user, orderid, totalamount, times, discount, cooldown));
    require(_signer == tryRecover(hashValue, hexStr2bytes(signedmsg)), "reward: invalid signer");
    ...
    IBEP20(_usdt).transferFrom(user, _feeowner, thistimebuyamount);
    IBEP20(_sty).transfer(user, getBuyamount(thistimebuyamount, discount));
}

Every economic term in that function, the order id, the total, the installments, the discount, the cooldown, arrives from the caller. The contract trusts all of it on the strength of one require, which answers a single question: was this message signed by _signer? It never asks whether those terms are terms the protocol would ever offer.

That is the whole bug: authentication with no authorization envelope, CWE-863. The signature proves identity and grants unlimited scope.

Verify it against the transaction. The attacker moved 19,753.631575 USDT into the purchase and received 687,245 STY, or $0.0287432 per token. STY's own on-chain price function was returning about $2.8743. The ratio is 100.0. The multiplier that discount = 1 produces is visible in the token math to five significant figures.


The Attack, Step by Step

#ActionOn-chain evidence
1Attacker EOA 0x840fF67C...C92cDada4 is funded by 0x15bbc25B...726aCd714Attacker address, first transaction July 30
2Attacker deploys exploit contract 0xCbAFd579...27dFAadFBContract creator is the attacker EOA
3Exploit contract borrows 19,753.63 USDT from a PancakeSwap V2 pairFlash loan repaid at 19,803.14 USDT, a 0.2506% fee
4Calls ZhaiquanBuy.buy() with a self-signed order, discount = 1Selector 0xf01045db, mined in block 113056961
5ZhaiquanBuy sends 687,245 STY, purchase price routed to _feeownerSTY transfer from 0x27af47fa...3bac6a8c
6Forged signatures pull 184,198 STY and 15,801 USDT from two sibling contractsTransfers from 0x758905b3... and 0x473aea0C...
7440,950 STY sold into the PancakeSwap V2 STY/USDT pool in tranchesPool receives 440,950.158 STY, pays out 740,275.77 USDT
8Flash loan repaid, protocol-side fee splits paid out66,616.02 USDT and two payments of 22,205.34 USDT leave the exploit contract
9Exploit contract retains 625,246.92 USDTNet USDT delta on 0xCbAFd579...27dFAadFB
1018:21:50, five seconds later, a Withdraw call sends all 625,246.92 USDT to the attacker EOAWithdraw transaction 0xb16c4b4f...
1118:22:35, the EOA forwards the full amount to a second wallet 0xC3e39ef0...Dcb4b4479Second wallet, funded by the same address as the attacker
1218:35:52, a second attempt against buy() revertsTransaction 0x2f35e9f3... fails with Exceed Max Buy Times, having cleared the signer check

The path branches, so it is worth drawing. Three separate Swan Treasury contracts pay out on the same key, a flash loan wraps the entire sequence, and the sale that funds the repayment happens before the loan closes.

rendering diagram…

The $625,000 Reconciles to the Cent

Headline numbers in incident reporting are usually somebody's estimate. This one is a measured quantity, and the arithmetic closes.

USDT into the exploit contract: 740,275.77 from the PancakeSwap pool, 15,801.00 from the third Swan Treasury contract, 19,753.63 from the flash loan. Total 775,830.40.

USDT out: 19,753.63 to _feeowner as the purchase price, 19,803.14 repaying the flash loan with its fee, then 22,205.34, 22,205.34, and 66,616.02 to three addresses on the protocol's own fee and referral splits. Total 150,583.47.

Those figures are rounded to the cent, so the rounded subtraction gives 625,246.93. At full precision it gives 625,246.924077986144291377 USDT, exactly what BscScan's net-transfer view reports as the contract's balance change. Defimon's "~$625K" is the retained balance of one contract at the end of one transaction.

The token side closes too. 871,443 STY left Swan Treasury. 440,950 were dumped into the pool. The remaining 430,493 went to two addresses, 258,296 and 172,197, through the protocol's own distribution logic. The two figures sum to 871,443 with nothing unaccounted for.

Selling 440,950 STY into one pool cost the attacker real money: 740,275.77 USDT for those tokens, an average of $1.679 each against the $2.8743 the contract priced them at. Depth took 41.6% off the position, and the tranching in the logs is the attacker working around it.

One number deserves emphasis over the loss. The attacker's own capital at risk was the gas fee: 0.0000890222 BNB, five cents at the BNB price that day. The purchase price and the flash-loan fee were both paid out of proceeds generated inside the same transaction. Nothing was staged, nothing was pre-funded beyond a small BNB balance for gas, and the exploit transaction went out at nonce 1 from a wallet that had existed for hours.


Was the Key the Failure, or the Contract?

Both, and the order matters for anyone deciding what to fix.

Defimon's technical read is that every ecrecover in the transaction resolved to the exact address hardcoded as _signer, which rules out a signature-verification flaw and points at the key itself. That analysis is correct, and the recovery helper backs it up: tryRecover is a plain 65-byte ecrecover with a length check, no malleability handling, no clever failure mode to exploit. Someone had the key.

How the key leaked is the one thing nobody has published. Not the reporting, not the alert, not Swan Treasury. Treat every explanation you have seen for it, including "hardcoded key," as unconfirmed. What is hardcoded in the contract is the signer's address, which is public information by design:

constructor(address usdt, address sty, address feeowner) {
    _usdt = usdt;
    _sty = sty;
    _feeowner = feeowner;
    _signer = 0xdEb4E4ed40E8d29d10A326Fb4652fDaa55438284;
}

That address is a public key fingerprint. Publishing it costs nothing. The private key behind it lived on a backend somewhere, and its exposure route is an open question.

The contract also ships setSigner(address) behind onlyOwner, so on paper the key was replaceable in one transaction. On-chain it is not. Calling owner() on the contract returns the zero address, so no account can satisfy onlyOwner and setSigner can never execute again. Reading the contract's storage directly, slot 3, where _signer sits, still holds 0xdEb4E4ed...55438284: the same key compromised on July 30, still trusted on August 7, with nothing left that can replace it.

So the short version of this incident, that a signing key baked into a deployed contract cannot be rotated, is true here for a reason the source alone does not show. The rotation path was written. Ownership was given up at some point afterwards, and that took the path with it.

The absence of a postmortem invites a question we cannot answer: with no published exposure route, the public record does not distinguish an external compromise of the backend from a leak by someone who already held the key. We have seen no evidence for either reading and assert neither. Swan Treasury could close it in a paragraph and has not.

So the key compromise opened the door. The contract decided how much was behind it. A require(discount >= MIN_DISCOUNT) line, or a per-order ceiling on totalamount, would have capped this incident at whatever the protocol's genuine best offer was. The same stolen key against a contract that bounded its own promises would still have been a serious incident, at a fraction of this size.


The Fifteen Minutes After the Drain

The money left fast. Five seconds after the drain, a Withdraw call moved all 625,246.92 USDT to the attacker's wallet, which forwarded the whole balance to a second address, 0xC3e39ef0...Dcb4b4479, forty-five seconds later. Fifty seconds, start to finish.

That second wallet was funded by 0x15bbc25B...726aCd714, the same address that funded the attacker's wallet. Both are now empty. That funder is itself an unlabeled wallet created two days before the attack and funded by another one, so the trail runs back through fresh addresses with no exchange or mixer attribution on BscScan and none published by any security firm. Where the money went after the second hop is not something we can state.

At 18:35:52, fourteen minutes after the drain, the attacker called buy() again. It reverted with Exceed Max Buy Times, the per-order replay counter, a check that sits below the signature check in the function body. Reaching it means require(_signer == tryRecover(...)) passed first.

The signer key was still valid fourteen minutes after 871,443 tokens left the building.

Nobody had rotated it, because nobody knew yet. What stopped the second attempt came from the contract itself: orderBuiedCount[orderid] <= times, the one bound in buy() that the attacker could not set freely at signing time without burning a new order id. No pause was triggered, no alert fired, no operator intervened in those fourteen minutes.


Three More Defects in the Same Contract

Three more defects sit in the same source. None caused this loss. All three say something about the review the code had.

The cooldown is decorative. buy() writes nextbuyTime[orderid] = getBaseNextBuyTime().add(cooldown * 1 days) and no function in the contract ever reads that mapping. The rate limit exists as a public getter for an off-chain consumer to check. It is enforced by the backend that signs orders, which means it is enforced by the same key that leaked. When the key went, the only throttle in the system went with it.

There is no domain separation. The digest is keccak256(abi.encode(user, orderid, totalamount, times, discount, cooldown)), passed straight to ecrecover. No ERC-191 0x19 prefix, no EIP-712 domain separator binding the signature to a chain id, a contract address, and a version. A signature minted for this contract is valid against any contract that recovers the same six-field layout, anywhere. Defimon's observation that the attacker reused the key across sibling contracts is that property being spent.

The hex decoder is broken for two characters. Signatures arrive as an ASCII hex string, parsed by a hand-rolled hexStr2bytes whose uppercase branch tests _a > 66. A and B are ASCII 65 and 66, so both fail it and decode as 17 and 18 instead of 10 and 11. Any uppercase signature containing an A or B recovers the wrong address and reverts (a latent bug that only ever cost legitimate users a failed transaction).

Individually these are minor. Together they suggest the contract never went through adversarial review by anyone.


Why This Generalizes

Off-chain signers are everywhere: allowlist mints, gasless approvals, claim portals, points programs, KYC gates. The pattern is good, and most implementations carry the same latent shape. It is easy to miss because the signature check looks like the control.

A signature is authentication, and authentication is not authorization. ecrecover tells you a message came from a particular key. It tells you nothing about whether the contract should honor what the message says. Whatever the signature leaves unbounded, the key holder decides, including the price.

So the blast radius of a signer key is the widest outcome any valid signature can produce. For Swan Treasury that was the entire STY float in the selling contracts, because discount had no floor. Most teams have never written that number down for their own signer.

This is the same class of failure as AFX Trade's $24.15M bridge validator key compromise and the $36M Humanity Protocol key incident: a single credential that was, in practice, an unbounded permission. What differs here is scale in the other direction. Swan Treasury lost $625,000 instead of $24 million because the contracts happened to hold that much and no more, and because the STY/USDT pool ran out of depth on the way down. Both are accidents of size. Neither is a control the protocol built.


What Operators Should Do

  1. Write down what one valid signature can authorize, in dollars. Take the widest values every signed parameter accepts, run them through your own pricing math, and read the answer. That number is what your signer key is worth to an attacker.

  2. Bound every signed economic parameter on-chain. require(discount >= MIN_DISCOUNT), a ceiling on the per-order amount, a daily cap on tokens released through the signed path. The backend already knows its limits. Encode them so the contract survives the backend being wrong.

  3. Enforce rate limits on-chain. A nextbuyTime that is written and never read protects you only while the signer behaves. Add the require, or delete the mapping so no reviewer mistakes it for a control.

  4. Use EIP-712 with a real domain separator. Chain id, contract address, version, and a per-user nonce. A few thousand gas stops one signature from spending across your other contracts.

  5. Alert on signature semantics. A rule that fires on any buy() where discount drops below the lowest value your backend issues would have flagged this transaction as it landed, as would a cap on token outflow per transaction. That is detection rather than prevention, since the drain is atomic. What it buys is the minutes afterwards.

  6. Rotate signer keys on a schedule, and confirm you still can. A leaked key produces no observable signal until someone spends it, so rotation on suspicion never fires. Worse, setSigner here is now unreachable because the contract has no owner. Renouncing ownership reads as a trust signal and quietly deletes your incident response. Before giving up an admin role, list what that role was the only way to fix.

  7. Verify your contracts, including the siblings. The unverified 0x758905b3 contract has 145,794 transactions and shipped 184,198 STY here, and nobody outside the team can read what authorized it. Closed source slows down the people who might have warned you, and an attacker reading bytecode is not one of them.


Where an Audit Would Have Landed, and Where Monitoring Would Not

Honesty about this one: a competent smart contract review would have caught it, and runtime monitoring would not have stopped it.

The missing bound on discount, the write-only cooldown, and the raw ecrecover are all visible in a static reading of a 120-line contract. That is audit territory, and pretending otherwise would be selling.

The drain was atomic. One transaction, 1.78 million gas, all three contracts emptied in a single block. There is no window in which an alert fires and a human intervenes, because there is no moment between the first token leaving and the last dollar being extracted.

What monitoring buys here is narrower, and this incident happens to measure it exactly. The attacker came back at 18:35:52 and tried again. That is a fourteen-minute gap in which a signature-semantics alert on the first transaction could have reached someone, and in which setSigner could have been called. The second attempt failed on its own, so the counterfactual is worth nothing in dollars this time. On a run where the attacker picks a fresh order id, that fourteen minutes is the whole game. Our Tripwire work lives in that window. It would not have saved the $625,000 here.


Frequently Asked Questions

How much did Swan Treasury lose in the July 2026 exploit? 625,246.92 USDT, from 871,443 STY extracted across three Swan Treasury contracts. The commonly cited "$625K" is the exact net balance change, not an estimate.

What was the root cause of the Swan Treasury hack? A compromised off-chain signer key, combined with a buy() function that validated who signed a purchase order but placed no bound on the terms inside it. Setting the signed discount parameter to 1 multiplied the token payout by 100.

How did the key leak? Unknown. Neither Swan Treasury nor any security firm has published the exposure route as of August 7, 2026. The signer address was hardcoded in the constructor and public from deployment, which is normal and not itself the leak.

Could the signer key have been rotated? Not any more. The contract exposes setSigner(address) behind onlyOwner, but owner() returns the zero address, so the function can never run again. Contract storage still held the compromised signer as of August 7, 2026.

Is this a flaw in the ecrecover pattern itself? No. Signature-gated purchases are a reasonable design. The failure is treating a valid signature as unlimited authority. Bound the parameters on-chain and the same stolen key produces a much smaller loss.


Sources / References

Dmitry Serdyuk
Dmitry Serdyuk

Co-Founder & CDO

Full-Stack Operator | Building across security, AI, and digital infrastructure.