Implementing Pubkey and Duration-Based Token Locking for Cashu in Zeus Wallet
Step 1: You Send Bitcoin to the Mint
Your wallet sends a Lightning payment (or on-chain Bitcoin, depending on the mint) to the Cashu mint — the server that issues and verifies tokens.
Think of the mint like a digital shop — you give it Bitcoin, and you’ll get ecash tokens in return.
Step 2: The Mint Creates and Signs Your Token — Without Seeing It.
Your wallet creates a blinded request — a clever way of asking for tokens without showing the mint what it’s signing. The mint signs this request and sends it back to you.
Think of it like putting your request inside a locked box — the mint can sign the outside, but it can’t see what’s inside.
Step 3: You Now Have a Private Cashu Token
Your wallet unblinds the signed token, and now it’s ready to use.
You can:
- 💸 Send it to someone
- ☕ Spend it
- 🔁 Redeem it for Bitcoin
— and the mint can’t track who you are or what you’re doing with it
Why Do We Need Token Locking?
Cashu tokens are great — they’re fast, private, and easy to use, just like
digital cash.
But here’s the thing:
Anyone who holds a token can spend it.
This is by design — like handing someone a ₹100 note. But sometimes, you want more control over who can use that note and when.
Let’s say you generate a Cashu token and send it to someone?
- What if someone else gets access to it (accidentally or maliciously)?
- What if you want it to be used only by one specific person?
That’s why we introduced a powerful new feature in Cashu: token locking.
Using this, you can:
- 🔐 Lock a token to a specific public key — so only the intended user can spend it using their wallet.
- ⏱️ Set a duration (expiry time) — so the token can only be used within a limited time window.
This means you now have full control over who can use the token and for how long it stays valid.
How I Built the Token Locking Feature in Zeus Wallet
Implementing token locking in Zeus Wallet was a challenging yet rewarding experience. The goal was to allow users to lock tokens to a specific pubkey with an optional unlock time , enhancing security and usability for future recovery or scripting purposes.
I broke the feature down into three main steps :
🔧 Step 1: Update the mintToken
Function
To begin, I extended the existing mintToken
function to accept additional
fields — a pubkey
and an optional lockTime
.
1
2
pubkey?: string;
lockTime?: number;
These are passed in from the UI where the user can input a recovery key and
choose an unlock duration (in seconds or as a Unix timestamp). If present, the
code constructs a p2pk
object like this:
1
2
3
4
5
6
const p2pk = pubkey
? {
pubkey,
...(lockTime && { locktime: lockTime })
}
: undefined;
This The p2pk
object encapsulates the lock conditions and gets passed to the
minting logic, so the minted token is bound by these constraints.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
getSpendingProofsWithPreciseCounter = async (
wallet: CashuWallet,
amountToPay: number,
proofs: Proof[],
currentCounter: number,
swap?: boolean,
p2pk?: { pubkey: string; locktime?: number; refundKeys?: Array<string> }
) => {
try {
const { keep: proofsToKeep, send: proofsToSend } = swap
? await wallet.swap(amountToPay, proofs, {
keysetId: wallet.keysetId,
counter: p2pk ? undefined : currentCounter,
includeFees: true,
p2pk
})
: await wallet.send(amountToPay, proofs, {
keysetId: wallet.keysetId,
counter: p2pk ? undefined : currentCounter,
includeFees: true,
p2pk
});
const existingProofsIds = proofs.map((p) => p.secret);
const newKeepProofsCount = proofsToKeep.filter(
(p) => !existingProofsIds.includes(p.secret)
).length;
const newSendProofsCount = proofsToSend.filter(
(p) => !existingProofsIds.includes(p.secret)
).length;
console.log(newKeepProofsCount, 'NEW PROOFS KEEP COUNT');
console.log(newSendProofsCount, 'NEW PROOFS SEND COUNT');
const newCounterValue =
currentCounter + newKeepProofsCount + newSendProofsCount;
return {
proofsToSend,
proofsToKeep,
newCounterValue
};
} catch (err) {
console.error('Error in getSpendingProofsWithPreciseCounter:', err);
throw err;
}
};
🔁 Step 2: Pass Lock Info to getSpendingProofsWithPreciseCounter
During the minting process, I reused our internal function:This function either sends or swaps the required amount using existing proofs, but now I modified it to accept and pass down the p2pk object.
Inside this function, the token-lock logic is injected:
1
2
3
4
5
6
await wallet.send(amountToPay, proofs, {
keysetId: wallet.keysetId,
counter: p2pk ? undefined : currentCounter,
includeFees: true,
p2pk
})
This is where the magic happens. By passing the p2pk
object into the
wallet.send
call, the newly minted token becomes cryptographically locked to
the provided pubkey and (if given) unlockable only after the locktime
.
🕒 Step 3: Duration Handling and UI
On the UI side, I allowed users to:
- Enter a recovery pubkey
- Set a duration or unlock time

The duration is converted to a Unix timestamp before passing it as lockTime
to the backend logic. This ensures tokens can be locked for, say, 1 hour, 1
day, or even until a specific date, enhancing flexibility.
✅ Result
The final minted token now includes:
- A p2pk script with a
pubkey
and optionallocktime
- Which makes it non-spendable until that time or without the correct pubkey
Now time to Claiming or Spending the Locked Token :

How Zeus Verifies and Claims a Locked Token :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
async function claimToken(encodedToken: string, decoded: CashuToken) {
const mintUrl = decoded.token[0].mint;
const wallet = this.cashuWallets[mintUrl];
const walletPubkey = wallet.pubkey;
const isLocked = CashuUtils.isTokenP2PKLocked(decoded);
let isLockedToWallet = false;
if (isLocked) {
const firstLockedPubkey = CashuUtils.getP2PKPubkeySecret(decoded.proofs[0].secret);
const allProofsLockedToSamePubkey = decoded.proofs.every((proof) => {
const proofPubkey = CashuUtils.getP2PKPubkeySecret(proof.secret);
return proofPubkey === firstLockedPubkey;
});
if (!allProofsLockedToSamePubkey) {
this.loading = false;
return {
success: false,
errorMessage: localeString('stores.CashuStore.claimError.inconsistentLocking')
};
}
isLockedToWallet = firstLockedPubkey === walletPubkey;
if (!isLockedToWallet) {
this.loading = false;
return {
success: false,
errorMessage: localeString('stores.CashuStore.claimError.lockedToWallet')
};
}
}
// Prepare key for claiming
const counter = wallet.counter + decoded.proofs.length;
const currentSeed = this.getSeed();
const currentPrivkey = Base64Utils.base64ToHex(
Base64Utils.bytesToBase64(currentSeed.slice(0, 32))
);
const currentDerivedPubkey = '02' + bytesToHex(schnorr.getPublicKey(currentPrivkey));
if (currentDerivedPubkey !== walletPubkey) {
this.loading = false;
return {
success: false,
errorMessage: localeString('stores.CashuStore.claimError.keyMismatch')
};
}
// Receive token
const newProofs = await wallet.receive(encodedToken, {
keysetId: wallet.keysetId,
privkey: currentPrivkey,
counter
});
const amtSat = CashuUtils.sumProofsValue(newProofs);
const newCounter = counter + newProofs.length;
const totalBalanceSats = new BigNumber(this.totalBalanceSats || 0)
.plus(amtSat || 0)
.toNumber();
const balanceSats = new BigNumber(wallet.balanceSats || 0)
.plus(amtSat || 0)
.toNumber();
// Update proofs, balances, and counters
wallet.proofs.push(...newProofs);
await this.setMintProofs(mintUrl, wallet.proofs);
await this.setMintCounter(mintUrl, newCounter);
await this.setMintBalance(mintUrl, balanceSats);
await this.setTotalBalance(totalBalanceSats);
// Record received token
this.receivedTokens?.push(
new CashuToken({
...decoded,
received: true,
encodedToken,
received_at: Date.now() / 1000
})
);
await Storage.setItem(`${this.getLndDir()}-cashu-received-tokens`, this.receivedTokens);
this.checkAndSweepMints(mintUrl);
this.loading = false;
return { success: true, errorMessage: '' };
}
1. Is This Token Locked?
We first check if the token is P2PK (pubkey) locked using:
1
CashuUtils.isTokenP2PKLocked(decodedToken)
If yes, we know this token can only be claimed by the wallet that owns the matching pubkey.
2. Are All Token Proofs Locked to the Same Pubkey?
Each token can include multiple “proofs” (like coins in a bag). We verify that all of them are locked to the same pubkey :
1
2
3
const allProofsLockedToSamePubkey = decoded.proofs.every((proof) => {
return CashuUtils.getP2PKPubkeySecret(proof.secret) === firstLockedPubkey;
});
This prevents people from trying to mix in random or fake proofs.
3. Does This Wallet Own That Pubkey?
Next, we check if this wallet actually owns the private key that can
unlock the token.
We do that by deriving the wallet’s current pubkey from its seed:
If it doesn’t match the pubkey the token is locked to — sorry, this token isn’t for you.
4. Check Lock Duration (If Any)
If a lock duration was set when the token was created, we make sure enough time has passed before allowing the claim.
This prevents early claiming and enforces a “wait until…” rule ⏰
5. Claim It Into the Wallet
Once everything checks out — the pubkey matches and time is valid — we finally redeem the token into the Zeus Wallet:
1
2
3
4
5
await wallet.receive(encodedToken, {
keysetId,
privkey: currentPrivkey,
counter,
});
The token is now stored securely in your wallet and ready to be spent, sent, or swapped later.
Check out the demo video below to see how you can Lock Cashu Token to Pubkey with duration in Zeus wallet :
https://youtube.com/shorts/Y_cluvuhTqI?feature=share
Conclusion:
With the introduction of token locking in Zeus Wallet , we’re stepping into a more secure and permissioned eCash experience. Locking tokens to a specific pubkey (and optionally to a duration) ensures that only the rightful wallet owner can claim or spend the token — even if someone else gets access to the token string.
This is a powerful step toward secure, flexible, and programmable digital cash. Whether you’re building apps on top of Cashu or just want tighter control over your tokens, this feature opens up exciting new use cases.