Handling expired swaps
Handling expired swaps
Swaps are designed to complete automatically, but real systems have edge cases. An intent can expire before execution, leaving funds temporarily locked in a contract. Cancellation is the mechanism that lets the original owner unwind the attempt and recover any deposited funds, without relying on custody or manual intervention.
Who is allowed to cancel an expired intent is fixed at creation time. Once cancellation happens, Mynth returns encrypted material that only the designated owner can turn back into the signing key needed to regain control of the funds.
The role of owner when you generate an intent
When you generate an intent via /api/address/generate, you set the owner property. This choice matters because cancellation authority cannot be changed later. This field defines the authority that is permitted to cancel the intent if a swap fails.
You don’t need a special wallet or setup. The owner can simply be an address you already control on Cardano, Ethereum, Solana, Sui, or TRON, or the hash of an Ed25519 public key if you prefer to manage signing directly.
If you omit owner, ownership defaults to a centralized trusted party. If you want a fully trustless setup, explicitly set owner to your own key or Cardano address so that only you can authorize cancellation after expiration.
What /api/address/cancel returns and why it’s encrypted
Calling /api/address/cancel cancels an expired intent and returns the material needed to reconstruct the key that controls the funds held by the contract.
The important detail is how this seed is delivered. Mynth returns encrypted seed material using the user’s x25519 public key. It’s recommended to produce a new x25519 key for each request and avoid key reuse. Because the seed material is encrypted to the user’s key, Mynth cannot intercept or reconstruct the secret. You, as the user, must decrypt the response to derive the final seed.
The response contains an array of encrypted seeds and a modulus. The seed is intentionally split so that no single value is usable on its own. Each element in seeds should be decrypted into hex, then interpreted as a number. You add those numbers together and apply the modulus. The result reveals the final seed.
That final seed is what you use to build signatures that authorize movement of the underlying funds.
How the final seed is derived
The encrypted fragments collectively encode the private key material. Each fragment is decrypted locally, interpreted as a number, and combined with the others. The final key is obtained by reducing the sum modulo the provided modulus. At no point does Mynth see the reconstructed key; all decryption and combination happens entirely on your side.
Example: signing the cancellation request and reconstructing the seed
The following example illustrates one possible cancellation flow. It shows how to sign the intent hash, submit a cancellation request, and locally reconstruct the key from the encrypted response. It generates a fresh x25519 keypair for the request, signs the intent hash using the cancellation authority, submits the request to /api/address/cancel, decrypts the returned encrypted seed fragments, and reconstructs the final seed by summing and applying the modulus.
import { decrypt, ECIES_CONFIG, PrivateKey } from "eciesjs";
import ky from "ky";
import { privateKeyToAccount, privateKeyToAddress } from "viem/accounts";
import { expect, it } from "vitest";
ECIES_CONFIG.ellipticCurve = "x25519";
const signerKey =
"0x902c5df9b11fa18e69c13514cbb89513ca90f62f0d4616b63b304828d49d80f4";
const intent = {
address: "0xa9ed9BBeDF4D373c9E99681EcA43b6680c05295B",
binary:
"CkA5Y2YyMzYxMGMxNzAxMzU5NjkyNjI1ZWJiNTVkMGI4MWI4ZTc4MDRlY2Q4ODY4ZGJjMWU4ZjVhMDZlMDI2YjYwEgZzdGFibGUaBnN0YWJsZSIqMHgzMDk2MkM3MWU1Mzg5MWI5ODAyMjYyNDhEOGIxOTU4MDdCNjM2YTA3KioweDMwOTYyQzcxZTUzODkxYjk4MDIyNjI0OEQ4YjE5NTgwN0I2MzZhMDcyODQyMDRhNjE3YjBkYTVhNmNiNzBjNWNhOTg2NWY1YTI4OGM2YTJhMmZmZWI5OGQwM2UyNzk3NDk2OioweDc3OURlZDBjOWUxMDIyMjI1ZjhFMDYzMGIzNWE5YjU0YkU3MTM3MzZCKjB4Nzc5RGVkMGM5ZTEwMjIyMjVmOEUwNjMwYjM1YTliNTRiRTcxMzczNkgBUAFYlJ6/ygZghIK/ygY=",
hash: "7df9d06703a3255c218e06c248c945d80f5937f7a25ba3bee0f577ed2167c08c",
};
const endpoint = "https://www.mynth.ai/api/address/cancel";
type Response = {
code: number;
contents: {
modulus: string;
seeds: string[];
};
};
it("can cancel intent", async () => {
const secret = new PrivateKey(undefined, "x25519");
const signer = privateKeyToAccount(signerKey);
const signature = await signer.signMessage({
message: { raw: `0x${intent.hash}` },
});
expect(signature).toBeTruthy();
expect(signer.address).toBe("0x30962C71e53891b980226248D8b195807B636a07");
const response = await ky
.post(endpoint, {
json: {
blinding: secret.publicKey.toHex(),
intent: intent.binary,
signature,
},
throwHttpErrors: false,
})
.json<Response>();
expect(response.code).toBe(200);
const { modulus: curveOrder, seeds: privateKeysEncrypted } =
response.contents;
const entropy =
privateKeysEncrypted.reduce((acc, $key) => {
const key = Buffer.from($key, "hex");
const decrypted = decrypt(secret.secret, key);
const hex = Buffer.from(decrypted).toString("hex");
return acc + BigInt(`0x${hex}`);
}, 0n) % BigInt(curveOrder);
const privateKey = `0x${entropy.toString(16)}` as const;
expect(privateKeyToAddress(privateKey)).toBe(intent.address);
});
Regaining control after a failed swap
Cancellation in Mynth is designed to work without introducing new trust assumptions. By fixing ownership at intent creation and returning only encrypted fragments on cancellation, Mynth ensures that fund recovery is always controlled by the same key that initiated the intent. The system provides the pieces, but control is restored only through local reconstruction under the owner’s control.