Emit entitlement calldata in quotes and verify tx payload

This commit is contained in:
Joshua 2026-02-18 13:28:01 -08:00
parent 86a57b2888
commit 0696762d24
8 changed files with 175 additions and 13 deletions

View File

@ -12,6 +12,7 @@ SECRET_API_QUOTE_TTL_SECONDS=900
SECRET_API_DOMAIN_NAME=EDUT Designation
SECRET_API_VERIFYING_CONTRACT=0x0000000000000000000000000000000000000000
SECRET_API_MEMBERSHIP_CONTRACT=0x0000000000000000000000000000000000000000
SECRET_API_ENTITLEMENT_CONTRACT=0x0000000000000000000000000000000000000000
SECRET_API_MINT_CURRENCY=USDC
SECRET_API_MINT_AMOUNT_ATOMIC=100000000
SECRET_API_MINT_DECIMALS=6

View File

@ -86,6 +86,7 @@ Company-first sponsor path is also supported:
- `SECRET_API_CHAIN_ID` (default `84532`)
- `SECRET_API_CHAIN_RPC_URL` (optional, enables on-chain tx receipt verification)
- `SECRET_API_REQUIRE_ONCHAIN_TX_VERIFICATION` (default `false`; when `true`, membership confirm and marketplace checkout confirm fail closed without chain receipt verification)
- `SECRET_API_ENTITLEMENT_CONTRACT` (optional; when set, marketplace quote emits purchase calldata for entitlement settlement contract)
### Membership

View File

@ -6,6 +6,7 @@ import (
"crypto/ecdsa"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
@ -741,6 +742,38 @@ func TestMarketplaceConfirmRequiresChainRPCWhenStrictVerificationEnabled(t *test
}
}
func TestMarketplaceQuoteUsesEntitlementContractTransactionWhenConfigured(t *testing.T) {
a, _, cleanup := newTestApp(t)
defer cleanup()
a.cfg.EntitlementContract = "0x1111111111111111111111111111111111111111"
ownerKey := mustKey(t)
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil {
t.Fatalf("seed active membership: %v", err)
}
quote := postJSONExpect[marketplaceCheckoutQuoteResponse](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
Wallet: ownerAddr,
OfferID: offerIDWorkspaceCore,
OrgRootID: "org.marketplace.txshape",
PrincipalID: "human.owner",
PrincipalRole: "org_root_owner",
WorkspaceID: "workspace.alpha",
}, http.StatusOK)
if got := strings.ToLower(strings.TrimSpace(fmt.Sprint(quote.Tx["to"]))); got != strings.ToLower(a.cfg.EntitlementContract) {
t.Fatalf("tx.to mismatch: got=%s want=%s quote=%+v", got, a.cfg.EntitlementContract, quote.Tx)
}
if got := strings.ToLower(strings.TrimSpace(fmt.Sprint(quote.Tx["from"]))); got != ownerAddr {
t.Fatalf("tx.from mismatch: got=%s want=%s quote=%+v", got, ownerAddr, quote.Tx)
}
data := strings.ToLower(strings.TrimSpace(fmt.Sprint(quote.Tx["data"])))
if !strings.HasPrefix(data, "0x") || data == "0x" {
t.Fatalf("expected encoded entitlement calldata, got=%+v", quote.Tx)
}
}
func TestMarketplaceWorkspaceAddOnRequiresCoreEntitlement(t *testing.T) {
a, _, cleanup := newTestApp(t)
defer cleanup()

View File

@ -9,11 +9,14 @@ import (
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
)
const membershipABI = `[{"inputs":[{"internalType":"address","name":"recipient","type":"address"}],"name":"mintMembership","outputs":[{"internalType":"uint256","name":"tokenId","type":"uint256"}],"stateMutability":"payable","type":"function"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"wallet","type":"address"},{"indexed":true,"internalType":"uint256","name":"tokenId","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"amountPaid","type":"uint256"},{"indexed":false,"internalType":"address","name":"currency","type":"address"}],"name":"MembershipMinted","type":"event"}]`
const entitlementABI = `[{"inputs":[{"internalType":"string","name":"offerId","type":"string"},{"internalType":"address","name":"ownerWallet","type":"address"},{"internalType":"bytes32","name":"orgRootKey","type":"bytes32"},{"internalType":"bytes32","name":"workspaceKey","type":"bytes32"}],"name":"purchaseEntitlement","outputs":[{"internalType":"uint256","name":"entitlementId","type":"uint256"}],"stateMutability":"payable","type":"function"}]`
func encodeMintMembershipCalldata(recipient string) (string, error) {
parsed, err := abi.JSON(strings.NewReader(membershipABI))
if err != nil {
@ -76,12 +79,27 @@ func verifyMintedOnChain(ctx context.Context, cfg Config, txHash string, expecte
}
func verifyTransactionSenderOnChain(ctx context.Context, cfg Config, txHash string, expectedSender string) error {
return verifyTransactionCallOnChain(ctx, cfg, txHash, expectedSender, "", "", "")
}
func verifyTransactionCallOnChain(
ctx context.Context,
cfg Config,
txHash string,
expectedSender string,
expectedTo string,
expectedData string,
expectedValueHex string,
) error {
if strings.TrimSpace(cfg.ChainRPCURL) == "" {
return nil
}
expectedSender = strings.TrimSpace(expectedSender)
if expectedSender == "" {
return fmt.Errorf("expected sender missing")
expectedTo = strings.TrimSpace(expectedTo)
expectedData = strings.ToLower(strings.TrimSpace(expectedData))
expectedValueHex = strings.ToLower(strings.TrimSpace(expectedValueHex))
if expectedSender == "" && expectedTo == "" && expectedData == "" && expectedValueHex == "" {
return fmt.Errorf("no expected tx parameters provided")
}
client, err := ethclient.DialContext(ctx, cfg.ChainRPCURL)
@ -120,10 +138,63 @@ func verifyTransactionSenderOnChain(ctx context.Context, cfg Config, txHash stri
return fmt.Errorf("resolve tx sender: %w", err)
}
gotSender := strings.ToLower(from.Hex())
wantSender := strings.ToLower(common.HexToAddress(expectedSender).Hex())
if gotSender != wantSender {
return fmt.Errorf("tx sender mismatch got=%s want=%s", gotSender, wantSender)
if expectedSender != "" {
gotSender := strings.ToLower(from.Hex())
wantSender := strings.ToLower(common.HexToAddress(expectedSender).Hex())
if gotSender != wantSender {
return fmt.Errorf("tx sender mismatch got=%s want=%s", gotSender, wantSender)
}
}
if expectedTo != "" {
if tx.To() == nil {
return fmt.Errorf("tx target missing")
}
gotTo := strings.ToLower(tx.To().Hex())
wantTo := strings.ToLower(common.HexToAddress(expectedTo).Hex())
if gotTo != wantTo {
return fmt.Errorf("tx target mismatch got=%s want=%s", gotTo, wantTo)
}
}
if expectedData != "" {
gotData := "0x" + common.Bytes2Hex(tx.Data())
if strings.ToLower(gotData) != expectedData {
return fmt.Errorf("tx calldata mismatch")
}
}
if expectedValueHex != "" {
wantValue := new(big.Int)
if _, ok := wantValue.SetString(strings.TrimPrefix(expectedValueHex, "0x"), 16); !ok {
return fmt.Errorf("invalid expected tx value")
}
if tx.Value().Cmp(wantValue) != 0 {
return fmt.Errorf("tx value mismatch got=%s want=%s", tx.Value().String(), wantValue.String())
}
}
return nil
}
func encodePurchaseEntitlementCalldata(offerID, ownerWallet, orgRootID, workspaceID string) (string, error) {
parsed, err := abi.JSON(strings.NewReader(entitlementABI))
if err != nil {
return "", fmt.Errorf("parse entitlement abi: %w", err)
}
ownerAddress := common.HexToAddress(ownerWallet)
orgRootKey := hashIdentifierKey(orgRootID)
workspaceKey := hashIdentifierKey(workspaceID)
data, err := parsed.Pack("purchaseEntitlement", strings.TrimSpace(offerID), ownerAddress, orgRootKey, workspaceKey)
if err != nil {
return "", fmt.Errorf("pack entitlement calldata: %w", err)
}
return "0x" + common.Bytes2Hex(data), nil
}
func hashIdentifierKey(value string) common.Hash {
value = strings.TrimSpace(value)
if value == "" {
return common.Hash{}
}
return common.BytesToHash(crypto.Keccak256([]byte(value)))
}

View File

@ -22,6 +22,7 @@ type Config struct {
DomainName string
VerifyingContract string
MembershipContract string
EntitlementContract string
MintCurrency string
MintAmountAtomic string
MintDecimals int
@ -51,6 +52,7 @@ func loadConfig() Config {
DomainName: env("SECRET_API_DOMAIN_NAME", "EDUT Designation"),
VerifyingContract: strings.ToLower(env("SECRET_API_VERIFYING_CONTRACT", "0x0000000000000000000000000000000000000000")),
MembershipContract: strings.ToLower(env("SECRET_API_MEMBERSHIP_CONTRACT", "0x0000000000000000000000000000000000000000")),
EntitlementContract: strings.ToLower(env("SECRET_API_ENTITLEMENT_CONTRACT", "0x0000000000000000000000000000000000000000")),
MintCurrency: strings.ToUpper(env("SECRET_API_MINT_CURRENCY", "USDC")),
MintAmountAtomic: env("SECRET_API_MINT_AMOUNT_ATOMIC", "100000000"),
MintDecimals: envInt("SECRET_API_MINT_DECIMALS", 6),

View File

@ -376,6 +376,20 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
writeErrorCode(w, http.StatusInternalServerError, "quote_generation_failed", "failed to generate quote id")
return
}
txTo := strings.ToLower(strings.TrimSpace(a.cfg.MembershipContract))
txData := "0x"
txValueHex := "0x0"
if entitlementContract := strings.ToLower(strings.TrimSpace(a.cfg.EntitlementContract)); entitlementContract != "" &&
!strings.EqualFold(entitlementContract, "0x0000000000000000000000000000000000000000") {
entitlementCalldata, calldataErr := encodePurchaseEntitlementCalldata(offer.OfferID, wallet, orgRootID, workspaceID)
if calldataErr != nil {
writeErrorCode(w, http.StatusInternalServerError, "quote_generation_failed", "failed to encode checkout transaction")
return
}
txTo = entitlementContract
txData = entitlementCalldata
}
now := time.Now().UTC()
expiresAt := now.Add(a.cfg.QuoteTTL)
accessClass := "connected"
@ -400,6 +414,9 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
PolicyHash: a.cfg.GovernancePolicyHash,
AccessClass: accessClass,
AvailabilityState: "active",
ExpectedTxTo: txTo,
ExpectedTxData: txData,
ExpectedTxValueHex: txValueHex,
CreatedAt: now,
ExpiresAt: expiresAt,
}
@ -427,9 +444,10 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
PolicyHash: quote.PolicyHash,
ExpiresAt: quote.ExpiresAt.Format(time.RFC3339Nano),
Tx: map[string]any{
"to": strings.ToLower(a.cfg.MembershipContract),
"value": "0x0",
"data": "0x",
"from": quote.PayerWallet,
"to": quote.ExpectedTxTo,
"value": quote.ExpectedTxValueHex,
"data": quote.ExpectedTxData,
},
AccessClass: quote.AccessClass,
AvailabilityState: quote.AvailabilityState,
@ -535,7 +553,15 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re
if expectedPayer == "" {
expectedPayer = wallet
}
if err := verifyTransactionSenderOnChain(r.Context(), a.cfg, req.TxHash, expectedPayer); err != nil {
if err := verifyTransactionCallOnChain(
r.Context(),
a.cfg,
req.TxHash,
expectedPayer,
quote.ExpectedTxTo,
quote.ExpectedTxData,
quote.ExpectedTxValueHex,
); err != nil {
writeErrorCode(w, http.StatusConflict, "tx_verification_failed", fmt.Sprintf("tx verification pending/failed: %v", err))
return
}

View File

@ -141,6 +141,9 @@ type marketplaceQuoteRecord struct {
PolicyHash string
AccessClass string
AvailabilityState string
ExpectedTxTo string
ExpectedTxData string
ExpectedTxValueHex string
CreatedAt time.Time
ExpiresAt time.Time
ConfirmedAt *time.Time

View File

@ -97,6 +97,9 @@ func (s *store) migrate(ctx context.Context) error {
policy_hash TEXT NOT NULL,
access_class TEXT NOT NULL,
availability_state TEXT NOT NULL,
expected_tx_to TEXT,
expected_tx_data TEXT,
expected_tx_value_hex TEXT,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
confirmed_at TEXT,
@ -239,6 +242,15 @@ func (s *store) migrate(ctx context.Context) error {
if err := s.ensureColumn(ctx, "quotes", "sponsor_org_root_id", "TEXT"); err != nil {
return err
}
if err := s.ensureColumn(ctx, "marketplace_quotes", "expected_tx_to", "TEXT"); err != nil {
return err
}
if err := s.ensureColumn(ctx, "marketplace_quotes", "expected_tx_data", "TEXT"); err != nil {
return err
}
if err := s.ensureColumn(ctx, "marketplace_quotes", "expected_tx_value_hex", "TEXT"); err != nil {
return err
}
return nil
}
@ -420,8 +432,8 @@ func (s *store) putMarketplaceQuote(ctx context.Context, quote marketplaceQuoteR
INSERT INTO marketplace_quotes (
quote_id, wallet, payer_wallet, offer_id, org_root_id, principal_id, principal_role, workspace_id,
currency, amount_atomic, total_amount_atomic, decimals, membership_included, line_items_json,
policy_hash, access_class, availability_state, created_at, expires_at, confirmed_at, confirmed_tx_hash
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
policy_hash, access_class, availability_state, expected_tx_to, expected_tx_data, expected_tx_value_hex, created_at, expires_at, confirmed_at, confirmed_tx_hash
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(quote_id) DO UPDATE SET
wallet=excluded.wallet,
payer_wallet=excluded.payer_wallet,
@ -439,6 +451,9 @@ func (s *store) putMarketplaceQuote(ctx context.Context, quote marketplaceQuoteR
policy_hash=excluded.policy_hash,
access_class=excluded.access_class,
availability_state=excluded.availability_state,
expected_tx_to=excluded.expected_tx_to,
expected_tx_data=excluded.expected_tx_data,
expected_tx_value_hex=excluded.expected_tx_value_hex,
created_at=excluded.created_at,
expires_at=excluded.expires_at,
confirmed_at=excluded.confirmed_at,
@ -460,6 +475,9 @@ func (s *store) putMarketplaceQuote(ctx context.Context, quote marketplaceQuoteR
strings.TrimSpace(quote.PolicyHash),
strings.ToLower(strings.TrimSpace(quote.AccessClass)),
strings.ToLower(strings.TrimSpace(quote.AvailabilityState)),
nullableString(strings.ToLower(strings.TrimSpace(quote.ExpectedTxTo))),
nullableString(strings.ToLower(strings.TrimSpace(quote.ExpectedTxData))),
nullableString(strings.ToLower(strings.TrimSpace(quote.ExpectedTxValueHex))),
quote.CreatedAt.UTC().Format(time.RFC3339Nano),
quote.ExpiresAt.UTC().Format(time.RFC3339Nano),
formatNullableTime(quote.ConfirmedAt),
@ -472,12 +490,13 @@ func (s *store) getMarketplaceQuote(ctx context.Context, quoteID string) (market
row := s.db.QueryRowContext(ctx, `
SELECT quote_id, wallet, payer_wallet, offer_id, org_root_id, principal_id, principal_role, workspace_id,
currency, amount_atomic, total_amount_atomic, decimals, membership_included, line_items_json,
policy_hash, access_class, availability_state, created_at, expires_at, confirmed_at, confirmed_tx_hash
policy_hash, access_class, availability_state, expected_tx_to, expected_tx_data, expected_tx_value_hex, created_at, expires_at, confirmed_at, confirmed_tx_hash
FROM marketplace_quotes
WHERE quote_id = ?
`, strings.TrimSpace(quoteID))
var rec marketplaceQuoteRecord
var payerWallet, orgRootID, principalID, principalRole, workspaceID sql.NullString
var expectedTxTo, expectedTxData, expectedTxValueHex sql.NullString
var createdAt, expiresAt, confirmedAt, confirmedTxHash sql.NullString
var membershipIncluded int
err := row.Scan(
@ -498,6 +517,9 @@ func (s *store) getMarketplaceQuote(ctx context.Context, quoteID string) (market
&rec.PolicyHash,
&rec.AccessClass,
&rec.AvailabilityState,
&expectedTxTo,
&expectedTxData,
&expectedTxValueHex,
&createdAt,
&expiresAt,
&confirmedAt,
@ -514,6 +536,9 @@ func (s *store) getMarketplaceQuote(ctx context.Context, quoteID string) (market
rec.PrincipalID = principalID.String
rec.PrincipalRole = principalRole.String
rec.WorkspaceID = workspaceID.String
rec.ExpectedTxTo = expectedTxTo.String
rec.ExpectedTxData = expectedTxData.String
rec.ExpectedTxValueHex = expectedTxValueHex.String
rec.MembershipIncluded = membershipIncluded == 1
rec.CreatedAt = parseRFC3339Nullable(createdAt)
rec.ExpiresAt = parseRFC3339Nullable(expiresAt)