Skip to content

Commit b1e6361

Browse files
authored
feat: claim back expired offers (#339)
* feat(transfer): claim back pending/expired offers (withdraw) Adds sender-side claim-back for offer-based (registry/AllocationFactory) transfers — e.g. an unaccepted or expired USDCx offer — so the sender can reclaim the locked holding by exercising TransferInstruction_Withdraw. API (3 endpoints): - POST /api/v2/transfer/outgoing/{contractID}/withdraw/prepare (non-custodial) - POST /api/v2/transfer/outgoing/{contractID}/withdraw/execute (non-custodial; reuses Execute) - POST /api/v2/transfer/outgoing/{contractID}/withdraw/custodial (custodial, server-signed) The request is validated against the indexer first (direct lookup by contract id, new GET /indexer/v1/admin/transfers/{contractID}): the offer must exist, be owned by the caller (sender), be offer-kind, and still be withdrawable (pending/expired). Registry "already accepted/archived" conflicts map to 409. SDK: registry GetWithdrawChoiceContext + client WithdrawTransferInstruction / PrepareWithdrawTransfer, factored to share the choice-context command builder and submission tails with the existing accept flow. Implements #292. * fix(transfer): nil-guard the indexer lookup in claimableTransfer Address review: GetTransfer's interface permits (nil, nil); treat a nil result as not-found rather than dereferencing it. * test(e2e): claim back an expired USDCx offer to an external party A custodial sender offers USDCx to an external (unregistered) party with a short validity; the offer expires unaccepted, the sender claims it back via the custodial withdraw endpoint, the offer leaves the expired set (archived on-ledger), and the sender's USDCx balance is whole again. Adds the WithdrawCustodial e2e shim + APIServer interface method and a WaitForOutgoingTransferGone DSL helper. * fix(devstack): serve withdraw choice-context in usdcx-registry The claim-back (TransferInstruction_Withdraw) flow fetches a withdraw choice-context from the registrar, but the devstack usdcx-registry mock only routed .../choice-contexts/accept — so custodial/non-custodial withdraw 500'd in e2e. Withdraw needs the same instrument-level context (TransferRule + InstrumentConfiguration) as accept, so route both to the same handler.
1 parent 123a84a commit b1e6361

27 files changed

Lines changed: 1327 additions & 82 deletions

pkg/cantonsdk/token/client.go

Lines changed: 101 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const (
4747
spliceTransferFactory = "TransferFactory"
4848
transferInstrEntity = "TransferInstruction"
4949
acceptChoice = "TransferInstruction_Accept"
50+
withdrawChoice = "TransferInstruction_Withdraw"
5051

5152
spliceHoldingModule = "Splice.Api.Token.HoldingV1"
5253
spliceHoldingEntity = "Holding"
@@ -141,6 +142,17 @@ type Token interface {
141142
PrepareAcceptTransfer(
142143
ctx context.Context, partyID, instructionCID, instrumentAdmin string,
143144
) (*PreparedTransfer, error)
145+
146+
// WithdrawTransferInstruction reclaims a pending/expired offer-based transfer for a
147+
// custodial sender by exercising TransferInstruction_Withdraw server-side.
148+
WithdrawTransferInstruction(ctx context.Context, partyID, instructionCID, instrumentAdmin string) error
149+
150+
// PrepareWithdrawTransfer builds a Canton transaction for a non-custodial sender to
151+
// reclaim a pending/expired offer and returns the hash to sign externally. Use
152+
// ExecuteTransfer to complete it.
153+
PrepareWithdrawTransfer(
154+
ctx context.Context, partyID, instructionCID, instrumentAdmin string,
155+
) (*PreparedTransfer, error)
144156
}
145157

146158
// Client implements CIP-56 token operations.
@@ -1285,22 +1297,66 @@ func (c *Client) AcceptTransferInstruction(ctx context.Context, partyID, instruc
12851297
if partyID == "" || instructionCID == "" || instrumentAdmin == "" {
12861298
return fmt.Errorf("partyID, instructionCID, and instrumentAdmin are required")
12871299
}
1300+
cmd, disclosed, err := c.buildInstructionChoiceCommand(ctx, instructionCID, instrumentAdmin, "accept", acceptChoice)
1301+
if err != nil {
1302+
return err
1303+
}
1304+
if err := c.exerciseInstructionAsCustodial(ctx, partyID, cmd, disclosed); err != nil {
1305+
return fmt.Errorf("accept transfer instruction: %w", err)
1306+
}
1307+
return nil
1308+
}
1309+
1310+
// WithdrawTransferInstruction reclaims a pending or expired offer-based transfer for a
1311+
// custodial sender: the middleware holds the user's key and exercises
1312+
// TransferInstruction_Withdraw server-side, returning the locked holding to the sender.
1313+
// Mirrors AcceptTransferInstruction but for the sender side.
1314+
func (c *Client) WithdrawTransferInstruction(ctx context.Context, partyID, instructionCID, instrumentAdmin string) error {
1315+
if partyID == "" || instructionCID == "" || instrumentAdmin == "" {
1316+
return fmt.Errorf("partyID, instructionCID, and instrumentAdmin are required")
1317+
}
1318+
cmd, disclosed, err := c.buildInstructionChoiceCommand(ctx, instructionCID, instrumentAdmin, "withdraw", withdrawChoice)
1319+
if err != nil {
1320+
return err
1321+
}
1322+
if err := c.exerciseInstructionAsCustodial(ctx, partyID, cmd, disclosed); err != nil {
1323+
return fmt.Errorf("withdraw transfer instruction: %w", err)
1324+
}
1325+
return nil
1326+
}
1327+
1328+
// buildInstructionChoiceCommand fetches the registrar's choice-context for the given
1329+
// action ("accept"/"withdraw") on a TransferInstruction and builds the exercise command
1330+
// plus its disclosed contracts. Shared by the accept and withdraw (claim-back) flows,
1331+
// which differ only in the registry endpoint and the on-ledger choice name.
1332+
func (c *Client) buildInstructionChoiceCommand(
1333+
ctx context.Context, instructionCID, instrumentAdmin, action, choice string,
1334+
) (*lapiv2.Command, []*lapiv2.DisclosedContract, error) {
12881335
if c.registryClient == nil {
1289-
return fmt.Errorf("no registry client configured for accept")
1336+
return nil, nil, fmt.Errorf("no registry client configured for %s", action)
12901337
}
12911338
extCfg, ok := c.cfg.ExternalTokens[instrumentAdmin]
12921339
if !ok {
1293-
return fmt.Errorf("unsupported external token issuer: %s", instrumentAdmin)
1340+
return nil, nil, fmt.Errorf("unsupported external token issuer: %s", instrumentAdmin)
12941341
}
12951342

1296-
acceptResp, err := c.registryClient.GetAcceptChoiceContext(ctx, extCfg.RegistryURL, instrumentAdmin, instructionCID)
1343+
var ctxResp *AcceptContextResponse
1344+
var err error
1345+
switch action {
1346+
case "accept":
1347+
ctxResp, err = c.registryClient.GetAcceptChoiceContext(ctx, extCfg.RegistryURL, instrumentAdmin, instructionCID)
1348+
case "withdraw":
1349+
ctxResp, err = c.registryClient.GetWithdrawChoiceContext(ctx, extCfg.RegistryURL, instrumentAdmin, instructionCID)
1350+
default:
1351+
return nil, nil, fmt.Errorf("unsupported instruction action: %s", action)
1352+
}
12971353
if err != nil {
1298-
return fmt.Errorf("get accept choice context: %w", err)
1354+
return nil, nil, fmt.Errorf("get %s choice context: %w", action, err)
12991355
}
13001356

1301-
ctxValue, err := encodeChoiceContextRecord(acceptResp.ChoiceContextData)
1357+
ctxValue, err := encodeChoiceContextRecord(ctxResp.ChoiceContextData)
13021358
if err != nil {
1303-
return fmt.Errorf("encode choice context: %w", err)
1359+
return nil, nil, fmt.Errorf("encode choice context: %w", err)
13041360
}
13051361

13061362
extraArgs := &lapiv2.Value{
@@ -1314,9 +1370,9 @@ func (c *Client) AcceptTransferInstruction(ctx context.Context, partyID, instruc
13141370
},
13151371
}
13161372

1317-
disclosed, err := convertDisclosedContractSlice(acceptResp.DisclosedContracts, c.cfg.DomainID)
1373+
disclosed, err := convertDisclosedContractSlice(ctxResp.DisclosedContracts, c.cfg.DomainID)
13181374
if err != nil {
1319-
return fmt.Errorf("convert disclosed contracts: %w", err)
1375+
return nil, nil, fmt.Errorf("convert disclosed contracts: %w", err)
13201376
}
13211377

13221378
cmd := &lapiv2.Command{
@@ -1328,7 +1384,7 @@ func (c *Client) AcceptTransferInstruction(ctx context.Context, partyID, instruc
13281384
EntityName: transferInstrEntity,
13291385
},
13301386
ContractId: instructionCID,
1331-
Choice: acceptChoice,
1387+
Choice: choice,
13321388
ChoiceArgument: &lapiv2.Value{
13331389
Sum: &lapiv2.Value_Record{
13341390
Record: &lapiv2.Record{
@@ -1341,19 +1397,23 @@ func (c *Client) AcceptTransferInstruction(ctx context.Context, partyID, instruc
13411397
},
13421398
},
13431399
}
1400+
return cmd, disclosed, nil
1401+
}
13441402

1345-
// External parties (the only kind this api-server creates for custodial users)
1346-
// can't be submitted as via plain SubmitAndWait — the participant doesn't hold
1347-
// the signing key. Use Interactive Submission with the user's stored key.
1403+
// exerciseInstructionAsCustodial submits a TransferInstruction choice on behalf of a
1404+
// custodial party via Interactive Submission with the user's middleware-held key.
1405+
// External parties can't use plain SubmitAndWait (the participant doesn't hold the key).
1406+
func (c *Client) exerciseInstructionAsCustodial(
1407+
ctx context.Context, partyID string, cmd *lapiv2.Command, disclosed []*lapiv2.DisclosedContract,
1408+
) error {
13481409
if c.keyResolver == nil {
1349-
return fmt.Errorf("accept transfer instruction: no key resolver configured " +
1350-
"(custodial accept requires the api-server to hold the user's signing key)")
1410+
return fmt.Errorf("no key resolver configured " +
1411+
"(custodial instruction exercise requires the api-server to hold the user's signing key)")
13511412
}
13521413
signerKey, err := c.keyResolver(partyID)
13531414
if err != nil {
1354-
return fmt.Errorf("accept transfer instruction: resolve signing key for %s: %w", partyID, err)
1415+
return fmt.Errorf("resolve signing key for %s: %w", partyID, err)
13551416
}
1356-
13571417
commands := &lapiv2.Commands{
13581418
SynchronizerId: c.cfg.DomainID,
13591419
CommandId: uuid.NewString(),
@@ -1362,10 +1422,7 @@ func (c *Client) AcceptTransferInstruction(ctx context.Context, partyID, instruc
13621422
Commands: []*lapiv2.Command{cmd},
13631423
DisclosedContracts: disclosed,
13641424
}
1365-
if err := c.prepareAndExecuteAsUser(ctx, commands, signerKey, partyID); err != nil {
1366-
return fmt.Errorf("accept transfer instruction: %w", err)
1367-
}
1368-
return nil
1425+
return c.prepareAndExecuteAsUser(ctx, commands, signerKey, partyID)
13691426
}
13701427

13711428
func (c *Client) PrepareAcceptTransfer(
@@ -1374,63 +1431,35 @@ func (c *Client) PrepareAcceptTransfer(
13741431
if partyID == "" || instructionCID == "" || instrumentAdmin == "" {
13751432
return nil, fmt.Errorf("partyID, instructionCID, and instrumentAdmin are required")
13761433
}
1377-
if c.registryClient == nil {
1378-
return nil, fmt.Errorf("no registry client configured for accept")
1379-
}
1380-
extCfg, ok := c.cfg.ExternalTokens[instrumentAdmin]
1381-
if !ok {
1382-
return nil, fmt.Errorf("unsupported external token issuer: %s", instrumentAdmin)
1383-
}
1384-
1385-
acceptResp, err := c.registryClient.GetAcceptChoiceContext(ctx, extCfg.RegistryURL, instrumentAdmin, instructionCID)
1386-
if err != nil {
1387-
return nil, fmt.Errorf("get accept choice context: %w", err)
1388-
}
1389-
1390-
ctxValue, err := encodeChoiceContextRecord(acceptResp.ChoiceContextData)
1434+
cmd, disclosed, err := c.buildInstructionChoiceCommand(ctx, instructionCID, instrumentAdmin, "accept", acceptChoice)
13911435
if err != nil {
1392-
return nil, fmt.Errorf("encode choice context: %w", err)
1436+
return nil, err
13931437
}
1438+
return c.prepareInstructionTx(ctx, partyID, instructionCID, cmd, disclosed)
1439+
}
13941440

1395-
extraArgs := &lapiv2.Value{
1396-
Sum: &lapiv2.Value_Record{
1397-
Record: &lapiv2.Record{
1398-
Fields: []*lapiv2.RecordField{
1399-
{Label: "context", Value: ctxValue},
1400-
{Label: "meta", Value: values.EmptyMetadata()},
1401-
},
1402-
},
1403-
},
1441+
// PrepareWithdrawTransfer builds a Canton transaction for a non-custodial sender to
1442+
// reclaim a pending or expired offer-based transfer (TransferInstruction_Withdraw) and
1443+
// returns the hash the sender signs externally. Use ExecuteTransfer to complete it.
1444+
func (c *Client) PrepareWithdrawTransfer(
1445+
ctx context.Context, partyID, instructionCID, instrumentAdmin string,
1446+
) (*PreparedTransfer, error) {
1447+
if partyID == "" || instructionCID == "" || instrumentAdmin == "" {
1448+
return nil, fmt.Errorf("partyID, instructionCID, and instrumentAdmin are required")
14041449
}
1405-
1406-
disclosed, err := convertDisclosedContractSlice(acceptResp.DisclosedContracts, c.cfg.DomainID)
1450+
cmd, disclosed, err := c.buildInstructionChoiceCommand(ctx, instructionCID, instrumentAdmin, "withdraw", withdrawChoice)
14071451
if err != nil {
1408-
return nil, fmt.Errorf("convert disclosed contracts: %w", err)
1409-
}
1410-
1411-
cmd := &lapiv2.Command{
1412-
Command: &lapiv2.Command_Exercise{
1413-
Exercise: &lapiv2.ExerciseCommand{
1414-
TemplateId: &lapiv2.Identifier{
1415-
PackageId: c.cfg.SpliceTransferPackageID,
1416-
ModuleName: spliceTransferModule,
1417-
EntityName: transferInstrEntity,
1418-
},
1419-
ContractId: instructionCID,
1420-
Choice: acceptChoice,
1421-
ChoiceArgument: &lapiv2.Value{
1422-
Sum: &lapiv2.Value_Record{
1423-
Record: &lapiv2.Record{
1424-
Fields: []*lapiv2.RecordField{
1425-
{Label: "extraArgs", Value: extraArgs},
1426-
},
1427-
},
1428-
},
1429-
},
1430-
},
1431-
},
1452+
return nil, err
14321453
}
1454+
return c.prepareInstructionTx(ctx, partyID, instructionCID, cmd, disclosed)
1455+
}
14331456

1457+
// prepareInstructionTx prepares a TransferInstruction-choice exercise for external
1458+
// (non-custodial) signing and returns the hash to sign. Shared by the accept and
1459+
// withdraw prepare flows.
1460+
func (c *Client) prepareInstructionTx(
1461+
ctx context.Context, partyID, instructionCID string, cmd *lapiv2.Command, disclosed []*lapiv2.DisclosedContract,
1462+
) (*PreparedTransfer, error) {
14341463
authCtx := c.ledger.AuthContext(ctx)
14351464
prepResp, err := c.ledger.Interactive().PrepareSubmission(authCtx, &interactivev2.PrepareSubmissionRequest{
14361465
UserId: c.cfg.UserID,
@@ -1441,7 +1470,7 @@ func (c *Client) PrepareAcceptTransfer(
14411470
DisclosedContracts: disclosed,
14421471
})
14431472
if err != nil {
1444-
return nil, fmt.Errorf("prepare accept submission: %w", err)
1473+
return nil, fmt.Errorf("prepare submission: %w", err)
14451474
}
14461475

14471476
pt := &PreparedTransfer{
@@ -1453,7 +1482,7 @@ func (c *Client) PrepareAcceptTransfer(
14531482
ExpiresAt: time.Now().UTC().Add(preparedTxCacheTTL),
14541483
}
14551484

1456-
c.logger.Info("prepared non-custodial accept",
1485+
c.logger.Info("prepared non-custodial instruction tx",
14571486
zap.String("transfer_id", pt.TransferID),
14581487
zap.String("party_id", partyID),
14591488
zap.String("contract_id", instructionCID),

pkg/cantonsdk/token/registry_client.go

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ import (
1717
)
1818

1919
const (
20-
registryPathFmt = "/api/token-standard/v0/registrars/%s/registry/transfer-instruction/v1/transfer-factory"
21-
acceptContextPathFmt = "/api/token-standard/v0/registrars/%s/registry/transfer-instruction/v1/%s/choice-contexts/accept"
20+
registryPathFmt = "/api/token-standard/v0/registrars/%s/registry/transfer-instruction/v1/transfer-factory"
21+
// choiceContextPathFmt is the registrar's per-instruction choice-context
22+
// endpoint. The final %s is the action ("accept" or "withdraw").
23+
choiceContextPathFmt = "/api/token-standard/v0/registrars/%s/registry/transfer-instruction/v1/%s/choice-contexts/%s"
2224
)
2325

2426
// RegistryClient calls the Splice Transfer Factory Registry API to discover
@@ -294,35 +296,54 @@ func ConvertAnyValueChoiceContext(raw json.RawMessage) (AcceptChoiceContext, err
294296
func (rc *RegistryClient) GetAcceptChoiceContext(
295297
ctx context.Context, registryBaseURL, registrarParty, instructionCID string,
296298
) (*AcceptContextResponse, error) {
297-
path := fmt.Sprintf(acceptContextPathFmt, registrarParty, instructionCID)
299+
return rc.getChoiceContext(ctx, registryBaseURL, registrarParty, instructionCID, "accept")
300+
}
301+
302+
// GetWithdrawChoiceContext calls the registrar's withdraw choice-context endpoint for a
303+
// TransferInstruction. Returns the choiceContextData and disclosed contracts needed to
304+
// exercise TransferInstruction_Withdraw (sender reclaims a pending/expired offer).
305+
func (rc *RegistryClient) GetWithdrawChoiceContext(
306+
ctx context.Context, registryBaseURL, registrarParty, instructionCID string,
307+
) (*AcceptContextResponse, error) {
308+
return rc.getChoiceContext(ctx, registryBaseURL, registrarParty, instructionCID, "withdraw")
309+
}
310+
311+
// getChoiceContext fetches a per-instruction choice-context for the given action
312+
// ("accept" or "withdraw"). The response shape is identical across actions, so both
313+
// the accept and withdraw flows share this. action is interpolated into the registrar
314+
// endpoint path and the error messages.
315+
func (rc *RegistryClient) getChoiceContext(
316+
ctx context.Context, registryBaseURL, registrarParty, instructionCID, action string,
317+
) (*AcceptContextResponse, error) {
318+
path := fmt.Sprintf(choiceContextPathFmt, registrarParty, instructionCID, action)
298319
url := strings.TrimRight(registryBaseURL, "/") + path
299320
body := []byte(`{"meta":{},"excludeDebugFields":false}`)
300321

301322
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
302323
if err != nil {
303-
return nil, fmt.Errorf("create accept context request: %w", err)
324+
return nil, fmt.Errorf("create %s context request: %w", action, err)
304325
}
305326
httpReq.Header.Set("Content-Type", "application/json")
306327

307328
resp, err := rc.httpClient.Do(httpReq)
308329
if err != nil {
309-
return nil, fmt.Errorf("accept context request failed: %w", err)
330+
return nil, fmt.Errorf("%s context request failed: %w", action, err)
310331
}
311332
defer resp.Body.Close()
312333

313334
const maxResponseBytes = 1 << 20
314335
respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
315336
if err != nil {
316-
return nil, fmt.Errorf("read accept context response: %w", err)
337+
return nil, fmt.Errorf("read %s context response: %w", action, err)
317338
}
318339

319340
if resp.StatusCode != http.StatusOK {
320-
return nil, fmt.Errorf("accept context returned %d: %s", resp.StatusCode, string(respBody))
341+
return nil, fmt.Errorf("%s context returned %d: %s", action, resp.StatusCode, string(respBody))
321342
}
322343

323344
var result AcceptContextResponse
324345
if err := json.Unmarshal(respBody, &result); err != nil {
325-
return nil, fmt.Errorf("parse accept context response: %w", err)
346+
return nil, fmt.Errorf("parse %s context response: %w", action, err)
326347
}
327348
return &result, nil
328349
}

0 commit comments

Comments
 (0)