Ledger
Guides

Transfers

Moving one balance from one key to another without losing any of it.

A transfer moves a number out of one key and into another. It works whether either side is online, on another server, or offline, and it can't lose money or make any.

local Store = Ledger.New({
	Name = "PlayerData",
	Default = { Gold = 100 },
	Balance = "Gold",
	Reducer = Reducer,
})

local Ok, Why = Store:Transfer(FromUserId, ToUserId, 250):Wait()

Balance names the field it moves. It has to be a number field that's already in Default, and without it Transfer throws.

What actually happens

It's three steps, not one write.

Reserve. Ledger appends an op to the sender that takes the money out of the balance and puts it in _Held under a transfer id. If the sender doesn't have enough, your reducer never even sees it, the reserve is refused and you get Refused.

Deliver. It appends an op to the receiver that adds the money and records the transfer id in their _Received.

Settle. It goes back to the sender and drops the hold, because the money has arrived.

Money is only ever in one of three places: the sender's balance, the sender's _Held, or the receiver's balance. There's no moment where it's in two, and no moment where it's in none. That's what makes it safe to crash halfway.

Crashing halfway

If the server dies between reserve and deliver, the money is sitting in the sender's _Held. It's out of their balance so they can't spend it twice, and it hasn't arrived yet.

Ledger picks that up on its own. A background sweeper notices held money and finishes the job, and the sender's next load kicks off a recovery too. You don't have to write a cron job for this and you shouldn't call RecoverTransfers by hand in normal operation.

If the receiver turns out to be gone or the delivery keeps failing, the hold eventually expires and the money goes back to the sender.

Ids and retries

By default each transfer gets a fresh id, which means calling it twice moves the money twice. That's usually what you want for a trade.

When it's a retry of the same logical transfer, name it:

local Ok, Why = Store:Transfer(From, To, 250, `trade:{TradeId}`):Wait()

Now a second call with that id doesn't move anything again. You get true back, because the transfer already went through. That's the answer to retry on, and it stays true however many times you ask.

The amount and the receiver have to match on every attempt. A name that already went through, asked again for a different amount or a different key, answers Spent. Ledger records what a name meant next to the name, so it can tell an honest retry from the same name being reused for something else.

Ids are 1 to 64 characters. The id has to be the same string on every attempt, so make it once and keep it somewhere the retry can read it:

-- wrong, a new id each attempt, so a retry sends the money a second time
Store:Transfer(From, To, 250, HttpService:GenerateGUID(false)):Wait()

-- right, the id was made when the trade opened and lives on the trade
Store:Transfer(From, To, 250, `trade:{Trade.Id}`):Wait()

The second line uses a GUID too. It works because the GUID was made once and the retry reads the same one. Making a GUID at the call site is what breaks. Every attempt is then a different transfer, so Ledger has nothing to match it against and the money moves again.

TradeId is whatever already names that trade. If nothing names it yet, make the id when the trade opens and store it on the trade, next to the two players and the offer. An order id, a match id and a receipt id all work the same way.

Never build an id from the clock. Never build one from the amount and the two keys either. The same two players can trade the same amount twice, and you would swallow the second trade as a duplicate.

Where the id comes from covers the three cases and how long each id has to survive.

Reading the answer

true means the money moved and both sides are settled.

Refused means the sender didn't have it. An amount that isn't positive and finite throws at the call site instead, because that's a bug in the caller rather than an answer about the money.

Spent means the hold sat there long enough to expire and the money went back to the sender. Nothing moved and that id is finished, so don't hand anything over on it. A transfer that actually went through answers true, not this.

Busy means a transaction is holding the sender's key. Ledger has already scheduled a cleanup pass, try again shortly.

Unresolved means the reserve went through but the delivery didn't finish. The money is set aside and Ledger will either finish it or refund it on its own. Don't retry with a new id, that would move it twice, and don't tell the player it failed.

Housekeeping

Delivered transfer ids sit in the receiver's _Received so a redelivery can't pay twice. They're dropped after 30 days, which is well past the point any retry could still turn up.

Store:ClearDelivered(Key) forces that pass early. You basically never need it.

Store:RecoverTransfers(Key) forces a recovery on one key. The sweeper already does this, so it's here for a support tool, or for a live incident where you want one key dealt with right now.

Store:Erase(Key) hands over any money the key was sending out. After that it turns away anything sent to the key and answers Held, so the sender gets it back instead of losing it. That lasts a full 8 days, and a write to the key does not cut it short. If the handover itself fails, it warns with how much was lost and you'll have to pay the receivers back yourself. See Erase.

When to use a transaction instead

A transfer moves one balance one way. If you need two different things to move together, like gold one way and an item the other, that's a transaction.

On this page