diff --git a/eds/config/config.go b/eds/config/config.go index 7ddc7530e..101824f58 100644 --- a/eds/config/config.go +++ b/eds/config/config.go @@ -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 { diff --git a/eds/config/config_validation_test.go b/eds/config/config_validation_test.go index c0e865284..261c76488 100644 --- a/eds/config/config_validation_test.go +++ b/eds/config/config_validation_test.go @@ -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, }, }, }, { diff --git a/eds/internal/api/tokenpool/token_standard.go b/eds/internal/api/tokenpool/token_standard.go index 1240f0697..402864cc9 100644 --- a/eds/internal/api/tokenpool/token_standard.go +++ b/eds/internal/api/tokenpool/token_standard.go @@ -1,8 +1,11 @@ package tokenpool import ( + "bytes" "context" + "encoding/json" "fmt" + "io" "net/http" "time" @@ -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 @@ -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 } diff --git a/eds/internal/api/tokenpool/tokenpool.go b/eds/internal/api/tokenpool/tokenpool.go index 2b2eb770b..7ce68aaa1 100644 --- a/eds/internal/api/tokenpool/tokenpool.go +++ b/eds/internal/api/tokenpool/tokenpool.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "errors" "fmt" + "maps" "net/http" "slices" "strconv" @@ -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) } @@ -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) @@ -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