Skip to content

Commit de72bcd

Browse files
authored
Create-on-send flows for withdrawals (#197)
1 parent 4f6e7e1 commit de72bcd

27 files changed

+943
-510
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ go 1.23.0
55
require (
66
github.com/aws/aws-sdk-go-v2 v0.17.0
77
github.com/bits-and-blooms/bloom/v3 v3.1.0
8-
github.com/code-payments/code-protobuf-api v1.19.1-0.20250602171721-c057e3310d81
8+
github.com/code-payments/code-protobuf-api v1.19.1-0.20250603030803-cbe2bfca5052
99
github.com/code-payments/code-vm-indexer v0.1.11-0.20241028132209-23031e814fba
1010
github.com/emirpasic/gods v1.12.0
1111
github.com/envoyproxy/protoc-gen-validate v1.2.1

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,8 @@ github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnht
7878
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
7979
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
8080
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
81-
github.com/code-payments/code-protobuf-api v1.19.1-0.20250602171721-c057e3310d81 h1:sX2VijdiCok3hJI1Yv/a+NslvVzCTolpNDQIOFzk9DE=
82-
github.com/code-payments/code-protobuf-api v1.19.1-0.20250602171721-c057e3310d81/go.mod h1:ee6TzKbgMS42ZJgaFEMG3c4R3dGOiffHSu6MrY7WQvs=
81+
github.com/code-payments/code-protobuf-api v1.19.1-0.20250603030803-cbe2bfca5052 h1:lfxaakPHAWFPukrqsUn8nYdpw1WaXQfP4KLCzmL8UxU=
82+
github.com/code-payments/code-protobuf-api v1.19.1-0.20250603030803-cbe2bfca5052/go.mod h1:ee6TzKbgMS42ZJgaFEMG3c4R3dGOiffHSu6MrY7WQvs=
8383
github.com/code-payments/code-vm-indexer v0.1.11-0.20241028132209-23031e814fba h1:Bkp+gmeb6Y2PWXfkSCTMBGWkb2P1BujRDSjWeI+0j5I=
8484
github.com/code-payments/code-vm-indexer v0.1.11-0.20241028132209-23031e814fba/go.mod h1:jSiifpiBpyBQ8q2R0MGEbkSgWC6sbdRTyDBntmW+j1E=
8585
github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6 h1:NmTXa/uVnDyp0TY5MKi197+3HWcnYWfnHGyaFthlnGw=

pkg/code/async/sequencer/fulfillment_handler.go

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/mr-tron/base58"
88

99
commonpb "github.com/code-payments/code-protobuf-api/generated/go/common/v1"
10+
transactionpb "github.com/code-payments/code-protobuf-api/generated/go/transaction/v2"
1011
indexerpb "github.com/code-payments/code-vm-indexer/generated/indexer/v1"
1112

1213
"github.com/code-payments/code-server/pkg/code/common"
@@ -209,20 +210,35 @@ func (h *NoPrivacyTransferWithAuthorityFulfillmentHandler) CanSubmitToBlockchain
209210

210211
// The source user account is a Code account, so we must validate it exists on
211212
// the blockchain prior to sending funds from it.
212-
isSourceAccountCreated, err := isTokenAccountOnBlockchain(ctx, h.data, fulfillmentRecord.Source)
213+
isSourceAccountCreated, err := isAccountInitialized(ctx, h.data, fulfillmentRecord.Source)
213214
if err != nil {
214215
return false, err
215216
} else if !isSourceAccountCreated {
216217
return false, nil
217218
}
218219

219220
// The destination user account might be a Code account or external wallet, so we
220-
// must validate it exists on the blockchain prior to send funds to it.
221-
isDestinationAccountCreated, err := isTokenAccountOnBlockchain(ctx, h.data, *fulfillmentRecord.Destination)
221+
// must validate it exists on the blockchain prior to sending funds to it, or if we'll
222+
// be creating it at time of send.
223+
destinationAccount, err := common.NewAccountFromPublicKeyString(*fulfillmentRecord.Destination)
222224
if err != nil {
223225
return false, err
224-
} else if !isDestinationAccountCreated {
225-
return false, nil
226+
}
227+
isInternalTransfer, err := isInternalVmTransfer(ctx, h.data, destinationAccount)
228+
if err != nil {
229+
return false, err
230+
}
231+
hasCreateOnSendFee, err := h.data.HasFeeAction(ctx, fulfillmentRecord.Intent, transactionpb.FeePaymentAction_CREATE_ON_SEND_WITHDRAWAL)
232+
if err != nil {
233+
return false, err
234+
}
235+
if isInternalTransfer || !hasCreateOnSendFee {
236+
isDestinationAccountCreated, err := isAccountInitialized(ctx, h.data, *fulfillmentRecord.Destination)
237+
if err != nil {
238+
return false, err
239+
} else if !isDestinationAccountCreated {
240+
return false, nil
241+
}
226242
}
227243

228244
// Check whether there's an earlier fulfillment that should be scheduled first
@@ -269,17 +285,17 @@ func (h *NoPrivacyTransferWithAuthorityFulfillmentHandler) SupportsOnDemandTrans
269285
}
270286

271287
func (h *NoPrivacyTransferWithAuthorityFulfillmentHandler) MakeOnDemandTransaction(ctx context.Context, fulfillmentRecord *fulfillment.Record, selectedNonce *transaction_util.Nonce) (*solana.Transaction, error) {
272-
virtualSignatureBytes, err := base58.Decode(*fulfillmentRecord.VirtualSignature)
288+
actionRecord, err := h.data.GetActionById(ctx, fulfillmentRecord.Intent, fulfillmentRecord.ActionId)
273289
if err != nil {
274290
return nil, err
275291
}
276292

277-
virtualNonce, err := common.NewAccountFromPublicKeyString(*fulfillmentRecord.VirtualNonce)
293+
virtualSignatureBytes, err := base58.Decode(*fulfillmentRecord.VirtualSignature)
278294
if err != nil {
279295
return nil, err
280296
}
281297

282-
actionRecord, err := h.data.GetActionById(ctx, fulfillmentRecord.Intent, fulfillmentRecord.ActionId)
298+
virtualNonce, err := common.NewAccountFromPublicKeyString(*fulfillmentRecord.VirtualNonce)
283299
if err != nil {
284300
return nil, err
285301
}
@@ -354,6 +370,24 @@ func (h *NoPrivacyTransferWithAuthorityFulfillmentHandler) MakeOnDemandTransacti
354370
*actionRecord.Quantity,
355371
)
356372
} else {
373+
isCreateOnSend, err := h.data.HasFeeAction(ctx, fulfillmentRecord.Intent, transactionpb.FeePaymentAction_CREATE_ON_SEND_WITHDRAWAL)
374+
if err != nil {
375+
return &solana.Transaction{}, err
376+
}
377+
378+
var destinationOwnerAccount *common.Account
379+
if isCreateOnSend {
380+
intentRecord, err := h.data.GetIntent(ctx, fulfillmentRecord.Intent)
381+
if err != nil {
382+
return nil, err
383+
}
384+
385+
destinationOwnerAccount, err = common.NewAccountFromPublicKeyString(intentRecord.SendPublicPaymentMetadata.DestinationOwnerAccount)
386+
if err != nil {
387+
return nil, err
388+
}
389+
}
390+
357391
txn, makeTxnErr = transaction_util.MakeExternalTransferWithAuthorityTransaction(
358392
selectedNonce.Account,
359393
selectedNonce.Blockhash,
@@ -368,7 +402,10 @@ func (h *NoPrivacyTransferWithAuthorityFulfillmentHandler) MakeOnDemandTransacti
368402
sourceMemory,
369403
sourceIndex,
370404

405+
isCreateOnSend,
406+
destinationOwnerAccount,
371407
destinationTokenAccount,
408+
372409
*actionRecord.Quantity,
373410
)
374411
}
@@ -397,7 +434,7 @@ func (h *NoPrivacyWithdrawFulfillmentHandler) CanSubmitToBlockchain(ctx context.
397434

398435
// The source user account is a Code account, so we must validate it exists on
399436
// the blockchain prior to sending funds from it.
400-
isSourceAccountCreated, err := isTokenAccountOnBlockchain(ctx, h.data, fulfillmentRecord.Source)
437+
isSourceAccountCreated, err := isAccountInitialized(ctx, h.data, fulfillmentRecord.Source)
401438
if err != nil {
402439
return false, err
403440
} else if !isSourceAccountCreated {
@@ -406,7 +443,7 @@ func (h *NoPrivacyWithdrawFulfillmentHandler) CanSubmitToBlockchain(ctx context.
406443

407444
// The destination user account might be a Code account or external wallet, so we
408445
// must validate it exists on the blockchain prior to send funds to it.
409-
isDestinationAccountCreated, err := isTokenAccountOnBlockchain(ctx, h.data, *fulfillmentRecord.Destination)
446+
isDestinationAccountCreated, err := isAccountInitialized(ctx, h.data, *fulfillmentRecord.Destination)
410447
if err != nil {
411448
return false, err
412449
} else if !isDestinationAccountCreated {
@@ -700,7 +737,6 @@ func (h *CloseEmptyTimelockAccountFulfillmentHandler) OnFailure(ctx context.Cont
700737
// is dust in the account.
701738
//
702739
// todo: Implement auto-recovery when we know the account is empty
703-
// todo: Do "something" to indicate the client needs to resign a new transaction
704740
return false, nil
705741
}
706742

@@ -712,7 +748,7 @@ func (h *CloseEmptyTimelockAccountFulfillmentHandler) IsRevoked(ctx context.Cont
712748
return false, false, nil
713749
}
714750

715-
func isTokenAccountOnBlockchain(ctx context.Context, data code_data.Provider, address string) (bool, error) {
751+
func isAccountInitialized(ctx context.Context, data code_data.Provider, address string) (bool, error) {
716752
// Try our cache of Code timelock accounts
717753
timelockRecord, err := data.GetTimelockByVault(ctx, address)
718754
if err == timelock.ErrTimelockNotFound {

pkg/code/async/sequencer/intent_handler.go

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,6 @@ func (h *OpenAccountsIntentHandler) OnActionUpdated(ctx context.Context, intentI
3838
}
3939

4040
for _, actionRecord := range actionRecords {
41-
if actionRecord.ActionType != action.OpenAccount {
42-
continue
43-
}
44-
4541
// Intent is failed if at least one OpenAccount action fails
4642
if actionRecord.State == action.StateFailed {
4743
return markIntentFailed(ctx, h.data, intentId)
@@ -73,11 +69,11 @@ func (h *SendPublicPaymentIntentHandler) OnActionUpdated(ctx context.Context, in
7369
}
7470

7571
actionRecordsToCheck := actionRecords
76-
if len(actionRecords) > 1 {
72+
if len(actionRecords) > 2 {
7773
// Do not include the auto-return action, which is a different server-side
7874
// initiated intent using the final action here.
7975
//
80-
// todo: Assumes > 1 case is just remote send
76+
// todo: Assumes > 2 case is just remote send, but saves a DB call
8177
actionRecordsToCheck = actionRecordsToCheck[:len(actionRecordsToCheck)-1]
8278
}
8379

pkg/code/common/account.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ func ValidateExternalTokenAccount(ctx context.Context, data code_data.Provider,
451451
default:
452452
// Unfortunate if Solana is down, but this only impacts withdraw flows,
453453
// and we need to guarantee this isn't going to something that's not
454-
// a Kin token acocunt.
454+
// a core mint token acocunt.
455455
return false, "", err
456456
}
457457
}

pkg/code/common/subsidizer.go

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,15 @@ const (
3030
)
3131

3232
// todo: doesn't consider external deposits
33+
// todo: need a better system given fees are dynamic, we'll consider the worst case for each fulfillment type to be safe
3334
var (
34-
// This doesn't account for recovery of rent, which implies some fulfillments
35-
// actually have negative fees. We often need to think about "in flight" costs
36-
// and SOL balances for our subsidizer, so we exclude rent recovery which
37-
// ensures our estimates are always on the conservative side of things.
3835
lamportsByFulfillment = map[fulfillment.Type]uint64{
39-
fulfillment.InitializeLockedTimelockAccount: 5000, // 0.000005 SOL (5000 lamports per signature)
40-
fulfillment.NoPrivacyTransferWithAuthority: 5000, // 0.000005 SOL (5000 lamports per signature)
41-
fulfillment.NoPrivacyWithdraw: 5000, // 0.000005 SOL (5000 lamports per signature)
42-
fulfillment.CloseEmptyTimelockAccount: 5000, // 0.000005 SOL (5000 lamports per signature)
36+
fulfillment.InitializeLockedTimelockAccount: 5050,
37+
fulfillment.NoPrivacyTransferWithAuthority: 203928 + 5125,
38+
fulfillment.NoPrivacyWithdraw: 5100,
39+
fulfillment.CloseEmptyTimelockAccount: 5100,
4340
}
44-
lamportsPerCreateNonceAccount uint64 = 1450000 // 0.00145 SOL
41+
lamportsPerCreateNonceAccount uint64 = 1450000
4542
)
4643

4744
var (

pkg/code/data/action/action.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"errors"
55
"time"
66

7+
transactionpb "github.com/code-payments/code-protobuf-api/generated/go/transaction/v2"
8+
79
"github.com/code-payments/code-server/pkg/code/data/intent"
810
"github.com/code-payments/code-server/pkg/pointer"
911
)
@@ -49,7 +51,7 @@ type Record struct {
4951
Source string // Source token account involved
5052
Destination *string // Destination token account involved, when it makes sense
5153

52-
// Kin quark amount involved, when it makes sense. This must be set for actions
54+
// Core mint quark amount involved, when it makes sense. This must be set for actions
5355
// that make balance changes across Code accounts! For deferred actions that are
5456
// initially in the unknown state, the balance may be nil and updated at a later
5557
// time. Store implementations will enforce which actions will allow quantity updates.
@@ -60,6 +62,8 @@ type Record struct {
6062
// use cases before forming a firm opinion.
6163
Quantity *uint64
6264

65+
FeeType *transactionpb.FeePaymentAction_FeeType
66+
6367
State State
6468

6569
CreatedAt time.Time
@@ -92,6 +96,10 @@ func (r *Record) Validate() error {
9296
return errors.New("quantity is required when set")
9397
}
9498

99+
if r.FeeType != nil && *r.FeeType == transactionpb.FeePaymentAction_UNKNOWN {
100+
return errors.New("fee type is required when set")
101+
}
102+
95103
return nil
96104
}
97105

@@ -109,6 +117,8 @@ func (r *Record) Clone() Record {
109117
Destination: pointer.StringCopy(r.Destination),
110118
Quantity: pointer.Uint64Copy(r.Quantity),
111119

120+
FeeType: (*transactionpb.FeePaymentAction_FeeType)(pointer.Int32Copy((*int32)(r.FeeType))),
121+
112122
State: r.State,
113123

114124
CreatedAt: r.CreatedAt,
@@ -125,8 +135,10 @@ func (r *Record) CopyTo(dst *Record) {
125135
dst.ActionType = r.ActionType
126136

127137
dst.Source = r.Source
128-
dst.Destination = r.Destination
129-
dst.Quantity = r.Quantity
138+
dst.Destination = pointer.StringCopy(r.Destination)
139+
dst.Quantity = pointer.Uint64Copy(r.Quantity)
140+
141+
dst.FeeType = (*transactionpb.FeePaymentAction_FeeType)(pointer.Int32Copy((*int32)(r.FeeType)))
130142

131143
dst.State = r.State
132144

pkg/code/data/action/memory/store.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"sync"
77
"time"
88

9+
transactionpb "github.com/code-payments/code-protobuf-api/generated/go/transaction/v2"
10+
911
"github.com/code-payments/code-server/pkg/code/data/action"
1012
"github.com/code-payments/code-server/pkg/code/data/intent"
1113
"github.com/code-payments/code-server/pkg/pointer"
@@ -111,6 +113,22 @@ func (s *store) filterByActionType(items []*action.Record, want action.Type) []*
111113
return res
112114
}
113115

116+
func (s *store) filterByFeeType(items []*action.Record, want transactionpb.FeePaymentAction_FeeType) []*action.Record {
117+
var res []*action.Record
118+
119+
for _, item := range items {
120+
if item.FeeType == nil {
121+
continue
122+
}
123+
124+
if *item.FeeType == want {
125+
res = append(res, item)
126+
}
127+
}
128+
129+
return res
130+
}
131+
114132
func (s *store) filterByState(items []*action.Record, include bool, states ...action.State) []*action.Record {
115133
var res []*action.Record
116134

@@ -295,6 +313,17 @@ func (s *store) GetGiftCardAutoReturnAction(ctx context.Context, giftCardVault s
295313
return &cloned, nil
296314
}
297315

316+
// CountFeeActions implements action.store.CountFeeActions
317+
func (s *store) CountFeeActions(ctx context.Context, intent string, feeType transactionpb.FeePaymentAction_FeeType) (uint64, error) {
318+
s.mu.Lock()
319+
defer s.mu.Unlock()
320+
321+
items := s.findByIntent(intent)
322+
items = s.filterByFeeType(items, feeType)
323+
items = s.filterByState(items, false, action.StateRevoked)
324+
return uint64(len(items)), nil
325+
}
326+
298327
func (s *store) getNetBalance(account string) int64 {
299328
var res int64
300329

0 commit comments

Comments
 (0)