Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion eds/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,14 @@ type TransferFactory struct {
}

type BurnMintFactory struct {
Type FactoryType `toml:"type" validate:"oneof='' address"`
Type FactoryType `toml:"type" validate:"oneof='' address url"`

TemplateId *string `toml:"template_id" validate:"required_if=Type address"`
Party *string `toml:"party" validate:"required_if=Type address"`
InstanceAddress *contracts.InstanceAddress `toml:"instance_address" validate:"required_if=Type address"`

TokenStandardURL *string `toml:"token_standard_url" validate:"excluded_unless=Type url,required_if=Type url,omitnil,url"`
TokenStandardAuthConfig *commonconfig.AuthConfig `toml:"token_standard_auth" validate:"excluded_unless=Type url"`
}

type TokenPool struct {
Expand Down
14 changes: 14 additions & 0 deletions eds/config/config_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,20 @@ func TestConfigValidation(t *testing.T) {
InstanceAddress: nil, // missing
},
wantErr: true,
}, {
name: "Type url valid",
s: BurnMintFactory{
Type: FactoryTypeURL,
TokenStandardURL: new("https://registry.example.com"),
},
wantErr: false,
}, {
name: "Type url invalid missing URL",
s: BurnMintFactory{
Type: FactoryTypeURL,
TokenStandardURL: nil, // missing
},
wantErr: true,
},
},
}, {
Expand Down
128 changes: 121 additions & 7 deletions eds/internal/api/tokenpool/token_standard.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package tokenpool

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"

Expand Down Expand Up @@ -131,14 +134,12 @@ func getTransferFactory(ctx context.Context, poolOwner types.PARTY, acs store.Ac
return nil, nil //nolint:nilnil
}

type burnMintFactory func(ctx context.Context) (string, []oapiCommon.DisclosedContract, error)
type burnMintFactory func(ctx context.Context, instrumentId splice_api_token_holding_v1.InstrumentId) (string, splice_api_token_metadata_v1.ChoiceContext, []oapiCommon.DisclosedContract, error)

func getBurnMintFactory(acs store.ActiveContractStoreInterface, cfg config.BurnMintFactory) (burnMintFactory, error) {
func getBurnMintFactory(ctx context.Context, acs store.ActiveContractStoreInterface, cfg config.BurnMintFactory) (burnMintFactory, error) {
switch cfg.Type {
case config.FactoryTypeDisabled:
return nil, nil //nolint:nilnil
case config.FactoryTypeURL:
return nil, fmt.Errorf("unsupported factory type: %s", cfg.Type)
case config.FactoryTypeAddress:
factoryAddress := *cfg.InstanceAddress

Expand All @@ -151,13 +152,126 @@ func getBurnMintFactory(acs store.ActiveContractStoreInterface, cfg config.BurnM
PartyID: *cfg.Party,
})

return func(ctx context.Context) (string, []oapiCommon.DisclosedContract, error) {
return func(ctx context.Context, _ splice_api_token_holding_v1.InstrumentId) (string, splice_api_token_metadata_v1.ChoiceContext, []oapiCommon.DisclosedContract, error) {
activeTransferFactory, ok := acs.Get(factoryAddress)
if !ok {
return "", nil, fmt.Errorf("no active contract found for transfer factory at address %s", factoryAddress)
return "", splice_api_token_metadata_v1.ChoiceContext{}, nil, fmt.Errorf("no active contract found for transfer factory at address %s", factoryAddress)
}

return activeTransferFactory.GetCreatedEvent().GetContractId(), splice_api_token_metadata_v1.ChoiceContext{}, []oapiCommon.DisclosedContract{converters.ActiveContractToDisclosedContract(activeTransferFactory)}, nil
}, nil
case config.FactoryTypeURL:
// DA utility-registry factory resolution endpoint (e.g. /mint/v0/request):
// POST {TokenStandardURL} (URL is fully configured, including path)
// Request: { holder, instrumentId } (mint endpoint shape)
// Response: FactoryWithChoiceContext { factoryId, choiceContext.{choiceContextData, disclosedContracts} }
//
// We use the /mint/v0/request endpoint because DA's /burn-mint-factory endpoint
// currently has a bug where issuer-credentials is returned as [] while
// /mint/v0/request correctly populates them. Both endpoints return the same
// factoryId (AllocationFactory) and the same context keys that
// AllocationFactory_InternalBurnMint reads (instrument-configuration +
// issuer-credentials). The mint endpoint is read-only — it does not actually
// mint; it returns the factory + context needed to later exercise the choice.
// Revisit this when DA fixes the /burn-mint-factory endpoint to populate
// issuer-credentials directly.
//
// Auth is optional via TokenStandardAuthConfig (DA's public endpoints have security: []).
httpClient := &http.Client{}

var requestEditor transferInstructionV1.RequestEditorFn
if cfg.TokenStandardAuthConfig != nil {
authProvider, err := cfg.TokenStandardAuthConfig.NewProvider(ctx)
if err != nil {
return nil, fmt.Errorf("failed to create auth provider for BurnMintFactory: %w", err)
}
requestEditor = func(ctx context.Context, req *http.Request) error {
token, err := authProvider.TokenSource().Token()
if err != nil {
return fmt.Errorf("failed to retrieve token: %w", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))

return nil
}
}

factoryURL := *cfg.TokenStandardURL

return func(ctx context.Context, instrumentId splice_api_token_holding_v1.InstrumentId) (string, splice_api_token_metadata_v1.ChoiceContext, []oapiCommon.DisclosedContract, error) {
// DA's mint request body shape: { holder, instrumentId }.
// holder is required by the schema but the backend does not use it for
// factory/context resolution — the choiceContext is keyed by instrumentId.
// Send instrumentId.admin as a dummy holder (same party, harmless).
requestBody := map[string]any{
"holder": instrumentId.Admin,
"instrumentId": map[string]any{
"admin": instrumentId.Admin,
"id": instrumentId.Id,
},
}
bodyBytes, err := json.Marshal(requestBody)
if err != nil {
return "", splice_api_token_metadata_v1.ChoiceContext{}, nil, fmt.Errorf("failed to marshal BurnMintFactory request: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, factoryURL, bytes.NewReader(bodyBytes))
if err != nil {
return "", splice_api_token_metadata_v1.ChoiceContext{}, nil, fmt.Errorf("failed to create BurnMintFactory request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if requestEditor != nil {
if err := requestEditor(ctx, req); err != nil {
return "", splice_api_token_metadata_v1.ChoiceContext{}, nil, fmt.Errorf("failed to apply auth to BurnMintFactory request: %w", err)
}
}

resp, err := httpClient.Do(req)
if err != nil {
return "", splice_api_token_metadata_v1.ChoiceContext{}, nil, fmt.Errorf("failed to call BurnMintFactory endpoint %q: %w", factoryURL, err)
}
defer resp.Body.Close()

respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", splice_api_token_metadata_v1.ChoiceContext{}, nil, fmt.Errorf("failed to read BurnMintFactory response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", splice_api_token_metadata_v1.ChoiceContext{}, nil, fmt.Errorf("BurnMintFactory endpoint %q returned status %d: %s", factoryURL, resp.StatusCode, string(respBytes))
}

var factoryResp struct {
FactoryId string `json:"factoryId"`
ChoiceContext struct {
ChoiceContextData map[string]any `json:"choiceContextData"`
DisclosedContracts []struct {
TemplateId string `json:"templateId"`
ContractId string `json:"contractId"`
CreatedEventBlob string `json:"createdEventBlob"`
SynchronizerId string `json:"synchronizerId"`
} `json:"disclosedContracts"`
} `json:"choiceContext"`
}
if err := json.Unmarshal(respBytes, &factoryResp); err != nil {
return "", splice_api_token_metadata_v1.ChoiceContext{}, nil, fmt.Errorf("failed to parse BurnMintFactory response: %w", err)
}

disclosedContracts := make([]oapiCommon.DisclosedContract, len(factoryResp.ChoiceContext.DisclosedContracts))
for i, c := range factoryResp.ChoiceContext.DisclosedContracts {
disclosedContracts[i] = oapiCommon.DisclosedContract{
TemplateId: c.TemplateId,
ContractId: c.ContractId,
CreatedEventBlob: c.CreatedEventBlob,
SynchronizerId: c.SynchronizerId,
}
}

choiceContext, err := contracts.ChoiceContextFromData(factoryResp.ChoiceContext.ChoiceContextData)
if err != nil {
return "", splice_api_token_metadata_v1.ChoiceContext{}, nil, fmt.Errorf("failed to convert BurnMintFactory choice context: %w", err)
}

return activeTransferFactory.GetCreatedEvent().GetContractId(), []oapiCommon.DisclosedContract{converters.ActiveContractToDisclosedContract(activeTransferFactory)}, nil
return factoryResp.FactoryId, choiceContext, disclosedContracts, nil
}, nil
}

Expand Down
15 changes: 10 additions & 5 deletions eds/internal/api/tokenpool/tokenpool.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/hex"
"errors"
"fmt"
"maps"
"net/http"
"slices"
"strconv"
Expand Down Expand Up @@ -98,7 +99,7 @@ func NewServer(
PartyID: tokenPool.PartyID,
})
if tokenPool.BurnMintFactory != nil {
getFactoryFunc, err := getBurnMintFactory(activeContractStore, *tokenPool.BurnMintFactory)
getFactoryFunc, err := getBurnMintFactory(ctx, activeContractStore, *tokenPool.BurnMintFactory)
if err != nil {
return nil, fmt.Errorf("failed to get burn mint factory for token pool with address %s: %w", tokenPool.InstanceAddress, err)
}
Expand Down Expand Up @@ -354,16 +355,18 @@ func (s Server) burnMintTokenPoolSend(
// Get BurnMintFactory (if enabled)
var factoryDisclosures []oapiCommon.DisclosedContract
if cfg.burnMintFactory != nil {
burnMintFactory, disclosedFactoryContracts, err := cfg.burnMintFactory(c)
factoryId, factoryCtx, disclosedFactoryContracts, err := cfg.burnMintFactory(c, burnMintTokenPool.InstrumentId)
if err != nil {
s.logger.Error().Err(err).Msg("burn mint factory returned an error")
c.AbortWithStatusJSON(http.StatusInternalServerError, oapiCommon.ErrorResponse{Error: "internal server error"})
return
}
choiceContext.Values[string(burnminttokenpool.BurnMintFactoryContextKey)] = splice_api_token_metadata_v1.AnyValue{
AVContractId: new(types.CONTRACT_ID(burnMintFactory)),
AVContractId: new(types.CONTRACT_ID(factoryId)),
}
factoryDisclosures = append(factoryDisclosures, disclosedFactoryContracts...)
// Merge factory-returned context (e.g. issuer credentials) into the extra-args context
maps.Copy(burnMintFactoryContext.Values, factoryCtx.Values)
}

// Get TransferPreapproval (if enabled)
Expand Down Expand Up @@ -657,16 +660,18 @@ func (s Server) burnMintTokenPoolExecute(
// Get BurnMintFactory (if enabled)
var factoryDisclosures []oapiCommon.DisclosedContract
if cfg.burnMintFactory != nil {
burnMintFactory, disclosedFactoryContracts, err := cfg.burnMintFactory(c)
factoryId, factoryCtx, disclosedFactoryContracts, err := cfg.burnMintFactory(c, burnMintTokenPool.InstrumentId)
if err != nil {
s.logger.Error().Err(err).Msg("burn mint factory returned an error")
c.AbortWithStatusJSON(http.StatusInternalServerError, oapiCommon.ErrorResponse{Error: "internal server error"})
return
}
choiceContext.Values[string(burnminttokenpool.BurnMintFactoryContextKey)] = splice_api_token_metadata_v1.AnyValue{
AVContractId: new(types.CONTRACT_ID(burnMintFactory)),
AVContractId: new(types.CONTRACT_ID(factoryId)),
}
factoryDisclosures = append(factoryDisclosures, disclosedFactoryContracts...)
// Merge factory-returned context (e.g. issuer credentials) into the extra-args context
maps.Copy(burnMintFactoryContext.Values, factoryCtx.Values)
}

// If the BurnMintFactory context contains any values, set it as part of the choiceContext
Expand Down
Loading