← Writing

A gift on tokenized stock and cash asset only one wallet can open

· By Rizkyy. · 7 min read

A gift on tokenized stock and cash asset only one wallet can open
On this page

Gifting a stock sounds simple until you ask who is allowed to take it out.

I built some app for hackathon and its consumer app which, you can pick a company, pick a person, and send them a slice of it. The person on the other end might never have heard of crypto. They might not have an account yet. They might only have an email address. The gift still has to be theirs and nobody else's, and it has to come back to you if they never show up.

That one sentence hides most of the design. This post walks through the on-chain half of it: a small Anchor program that acts as an escrow with three rules.

What a gift is

A gift is not a database row with a "locked" flag. It is two accounts owned by the program:

  • a Gift account that records the facts: sender, recipient, mint, who paid the rent, the amount, the expiry and a random 16-byte id
  • a token vault, owned by that Gift account, that holds the actual shares
RS
#[account]
#[derive(InitSpace)]
pub struct Gift {
    pub sender: Pubkey,
    pub recipient: Pubkey,
    pub mint: Pubkey,
    pub rent_payer: Pubkey,
    pub amount: u64,
    pub expires_at: i64,
    pub gift_id: [u8; 16],
    pub bump: u8,
}

The address of the Gift account is a PDA derived from three things: the literal seed gift, the sender's key and the gift id.

RS
seeds = [GIFT_SEED, sender.key().as_ref(), gift_id.as_ref()],

Two things follow from that. First, nobody can create a gift at an address that already belongs to someone else's gift, because the sender is part of the address. Second, nobody can guess a gift address from outside. The 16 random bytes see to that. The address is also the only thing that ever signs for the vault: the program signs with the PDA seeds, and no private key exists for it.

That last point is the whole trick of escrow on Solana. The vault is owned by an account that has no key. The only thing that can move the tokens is the program, and the program only does what its instructions allow.

Creating one

create_gift moves the shares from the sender's token account into the vault and writes the facts down. The checks before it are small on purpose:

RS
require!(amount > 0, MorrowError::ZeroAmount);
require_keys_neq!(recipient, ctx.accounts.sender.key(), MorrowError::SelfGift);
require!(expires_at > Clock::get()?.unix_timestamp, MorrowError::InvalidExpiry);

No empty gifts, no gifting yourself, no expiry in the past. The transfer uses transfer_checked, which makes the token program verify the mint's decimals, so a mismatch between what the app thinks it is sending and what the mint says fails loudly instead of moving the wrong amount.

Two signers appear on the instruction: sender and payer. The sender authorizes the transfer. The payer covers the rent for the two new accounts. They are separate because in the app a relayer pays the fees and the rent, so the person sending never needs to hold SOL. The payer's key is stored on the gift so the rent can find its way back to exactly them later.

Only the recipient can open it

Claiming is where the lock lives, and it is surprisingly little code. The constraint on the Gift account does the work:

RS
#[account(
    mut,
    seeds = [GIFT_SEED, gift.sender.as_ref(), gift.gift_id.as_ref()],
    bump = gift.bump,
    has_one = recipient @ MorrowError::WrongRecipient,
    has_one = mint,
    has_one = rent_payer,
    close = rent_payer,
)]
pub gift: Account<'info, Gift>,

Read the has_one lines as sentences. The recipient account passed in must be the one stored on the gift, and it must be a Signer. The mint must match. The rent payer must match. If any of them differ, the transaction fails before a single token moves. There is no admin override and no server key that can say "release this one anyway". If the recipient's signature is missing, nothing moves, and that includes me.

When it passes, claim_gift sends the whole vault to the recipient's token account (created on the spot if it does not exist) and closes both accounts. Closing the gift account sends its rent back to the payer in the same instruction.

The recipient who has not arrived yet

This is the part that took the most thought. A key can only sign if it exists. How do you lock a gift to a person who has not signed up?

When you send to an email address that has never used Morrow, the server asks Privy to pregenerate an embedded wallet for that email. The wallet's public key exists immediately, even though nobody has logged in. That key becomes gift.recipient. The gift is bound to the person's email before they ever open the app.

When they finally sign up with that email, Privy hands them the wallet that was already waiting. They sign the claim with it, quietly, with no prompt, and the shares land in an account that has been theirs all along.

The lock is on a key that exists before its owner arrives.

It also means two other things stay true. A typo in the address is not a disaster, because an unclaimed gift goes home (more on that next). And the server can resolve @handles, emails and Telegram names to wallets without ever being able to claim on anyone's behalf.

Three ways out, and no fourth

Escrow without an exit is a trap, so a gift can leave in exactly three ways.

  1. The recipient claims it.
  2. The sender takes it back, any time before it is claimed.
  3. After expiry, anyone can trigger the refund, and it goes to the sender.

The third rule is the one I care about most. A daily cron on my server calls refund_gift for expired gifts, but the program does not rely on the cron. The condition is checked on-chain:

RS
require!(
    ctx.accounts.authority.key() == sender
        || Clock::get()?.unix_timestamp >= expires_at,
    MorrowError::NotRefundable
);

Before expiry only the sender can refund. After it, any signer at all can. In both cases the tokens go to the sender, because the destination is pinned with has_one = sender. So the permission to trigger a refund can be handed out freely: the caller can never redirect the money, only set it in motion. An unclaimed gift always goes home, even if my server disappears.

One place where money leaves

Claim and refund share one helper, release_vault. It moves the entire balance out of the PDA-owned vault, then closes the vault so the rent returns to whoever paid it.

RS
fn release_vault(/* ... */) -> Result<()> {
    token_interface::transfer_checked(/* vault -> destination, signed with the PDA seeds */)?;
    token_interface::close_account(/* vault -> rent receiver, signed with the PDA seeds */)
}

Having both paths run through one function means there is exactly one place in the program where tokens leave a vault. That is the one place to audit, and the one place a mistake could cost someone their gift.

Closing the vault matters for a second reason. Rent on Solana is a deposit, not a fee. An escrow that never closes its accounts quietly keeps other people's SOL forever. Here, everything that was opened for a gift is closed when the gift ends, and the deposit goes back to the account that put it up.

What the same shape gave me for free

Once the pattern worked, I reused it twice.

A gift card is the same vault, locked behind a redeem code instead of a person. Only the sha256 of the code is stored on-chain, so whoever presents the code can claim it, and a wrong one fails with WrongCode.

A fund is a vault bound to a beneficiary and an unlock date. Anyone can put shares into it. What goes in can only come out for the beneficiary, and only after the date. Creation caps the lock at 25 years, so a typo in a year cannot lock money away for a lifetime.

Different products, one idea: a vault owned by a keyless account, and a short list of conditions under which it opens.

What I would still check

The program is small, but small is not the same as proven. Before this holds real money for strangers I want an outside review and fuzzing on the account constraints. I also want a clear answer for tokens whose mint charges a transfer fee. Some of the private-company tokens do, so the app already tells people that a little less than the amount sent will arrive, and the fee is taken twice (into the vault and out of it).

The part I would keep no matter what changes is simple. The person receiving the gift never has to trust me. The rules live in the program, and the program can only do what the signatures allow.