Files
dev 2b54eb1527 feat(provider/northcapital): full TransactAPI adapter implementation
Pass 2 — every endpoint method is fully wired against the TransactAPI
public REST surface; the encrypted-webhook consumer is fully
implemented per the documented AES-256-CBC envelope; the conformance
suite drives every endpoint against httptest.Server fixtures with no
production HTTP traffic.

Endpoints (all fully implemented, no stubs):

  Party / Entity
    CreateParty, CreateEntity, GetParty, ListParties (paginated)

  KYC / AML / Accredited / Suitability
    PerformKYC, PerformAML
    PerformAccredited (income, net_worth, third_party_letter,
                       professional_license) + PerformAccreditedFull
    Suitability

  Offerings
    CreateOffering, GetOffering, ListOfferings (paginated)

  Trades
    CreateTrade, GetTrade, BulkUploadTrades (multipart),
    CancelTrade, RefundTrade,
    SendSubscriptionDocs, GetTradeDocuments

  Custody
    OpenCustodyAccount, GetCustodyAccount

  ATS / Secondary
    PublishATSEvent (uses EventID as the natural idem-key),
    GetSecondaryTrades

  Admin
    UpdateWebhookAuthKey

  Webhook
    ConsumeWebhook — full AES-256-CBC decryption per the documented
    TransactAPI scheme: URL-safe Base64 decode, undo "plusencr" → "+"
    substitution, split 16-byte IV, AES-256-CBC decrypt, PKCS#7 unpad
    (constant-time), parse "k=v, k=v, …" plaintext into the broker-wide
    WebhookEvent shape. HMAC-SHA256 signature gate runs when
    WebhookAuthKey is configured, with comma-separated key rotation
    overlap support.

Cross-cutting:

  - Uniform pagination: list endpoints iterate offset/limit internally
    until a short page or MaxRecords cap; callers receive complete
    result sets without writing pagination logic.
  - Rate-limiting + retry: exponential backoff with full jitter on 429
    and 5xx, honoring Retry-After (delta-seconds and HTTP-date); after
    the retry budget surfaces wrapped ErrRateLimited.
  - Idempotency: deriveIdempotencyKey(orgID, endpoint, canonicalBody)
    SHA-256 fallback on every mutating call; caller-supplied
    IdempotencyKey always wins. ATS PublishATSEvent uses EventID
    directly so duplicate events collapse upstream.
  - Authentication: clientID + developerAPIKey injected as form fields
    on every legacy v1 request; Bearer + X-Client-Id on the modern v3
    transport.
  - APIError carries StatusCode, Endpoint, Body, RetryAfter for
    errors.As recovery; envelope-level TransactAPI failures
    (statusCode != "101") are surfaced through the same shape.

Conformance suite (broker/pkg/provider/northcapital/conformance_test.go):

  - Every endpoint's happy path with form-field + idempotency-header
    assertions
  - All four accredited methods exercised independently
  - Bulk upload exercises the multipart/form-data path
  - Pagination iteration across multiple pages
  - 4xx mapping → *APIError; envelope-level errors mapped identically
  - 429 retry-then-succeed; 429 budget exhausted → ErrRateLimited
  - Retry-After parsing
  - Webhook signature: missing, bad, valid, rotation overlap
  - Webhook decryption: valid envelope, wrong key, invalid base64,
    plusencr substitution path, missing config
  - PKCS#7 unpadder unit tests (positive + 4 negative cases)

Build green: go build ./..., go vet ./..., go test -race ./pkg/provider/
northcapital/... all pass. 40 test cases pass, 0 failures.

Assumptions pinned for the NCPS sandbox handshake:

  1. Legacy v1 endpoints use form-encoded bodies with clientID +
     developerAPIKey carried as form fields on every call. Confirmed
     against the public reference; pinned if the sandbox key issuance
     diverges.
  2. The webhook HMAC ordering is decrypt-then-MAC over the raw
     envelope bytes. The public encrypted-webhooks guide is silent on
     this point; if the sandbox reveals MAC-then-decrypt, swap the
     ordering in ConsumeWebhook.
  3. The X-TransactAPI-Signature header name is the Lux-side
     convention pinned in the integration brief §7; remap if the
     sandbox uses a different upstream name.
  4. WebhookDecryptionKey is required to be exactly 32 bytes; the
     public guide says NCPS pads server-side to 32, but the adapter
     enforces 32 on the inbound to fail closed.

Source-of-design: Public-Spec
Source-ref: https://transactapi.readme.io/reference
Clean-Team-Member: dev-agent (pass 2 — full implementation)
Signed-off-by: dev <[email protected]>
2026-05-26 17:55:39 -07:00

204 lines
5.8 KiB
Go

// Offerings setup — TransactAPI Workstream 3.
//
// Endpoints:
// POST /tapiv3/index.php/v1/createOffering — new Offering record
// POST /tapiv3/index.php/v1/getOffering — read by offeringId
// POST /tapiv3/index.php/v1/getAllOfferings — directory listing, paginated
//
// Each Offering binds 1:1 to an ERC-3643 / T-REX SecurityToken on
// the Lux Network primary EVM (luxfi/captable). Token metadata is
// anchored at issuance; reconciliation between TransactAPI Offering
// state and on-chain token state runs continuously (luxfi/transfer).
//
// Source-of-design: Public-Spec
// Source-ref: https://transactapi.readme.io/reference
package northcapital
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strconv"
)
type wireOffering struct {
OfferingID string `json:"offeringId"`
IssuerName string `json:"issuerName"`
OfferingName string `json:"offeringName"`
OfferingType string `json:"offeringType"`
TargetAmount string `json:"targetAmount"`
MinInvestment string `json:"minimumInvestment"`
MaxInvestment string `json:"maximumInvestment"`
UnitPrice string `json:"unitPrice"`
Currency string `json:"currency"`
Status string `json:"offeringStatus"`
OpenedAt string `json:"openedDate"`
ClosedAt string `json:"closedDate"`
SecurityClass string `json:"securityClass"`
CUSIP string `json:"cusip"`
CreatedDate string `json:"createdDate"`
UpdatedDate string `json:"updatedDate"`
}
// CreateOffering registers a new offering with TransactAPI.
func (p *Provider) CreateOffering(ctx context.Context, req *CreateOfferingRequest) (*Offering, error) {
if req == nil {
return nil, errors.New("northcapital: CreateOfferingRequest is required")
}
form := url.Values{}
form.Set("issuerName", req.IssuerName)
form.Set("offeringName", req.OfferingName)
form.Set("offeringType", req.OfferingType)
if req.TargetAmount != "" {
form.Set("targetAmount", req.TargetAmount)
}
if req.MinInvestment != "" {
form.Set("minimumInvestment", req.MinInvestment)
}
if req.MaxInvestment != "" {
form.Set("maximumInvestment", req.MaxInvestment)
}
if req.UnitPrice != "" {
form.Set("unitPrice", req.UnitPrice)
}
if req.Currency != "" {
form.Set("currency", req.Currency)
}
if req.SecurityClass != "" {
form.Set("securityClass", req.SecurityClass)
}
if req.CUSIP != "" {
form.Set("cusip", req.CUSIP)
}
const path = "/tapiv3/index.php/v1/createOffering"
idemKey := p.resolveIdemKey(req.IdempotencyKey, req.OrgID, path, canonicalForm(form))
raw, err := p.doForm(ctx, "POST", path, form, idemKey)
if err != nil {
return nil, err
}
env, err := decodeEnvelope(path, raw)
if err != nil {
return nil, err
}
return decodeOffering(env.Offering, "createOffering")
}
// GetOffering reads an Offering by TransactAPI offeringId.
func (p *Provider) GetOffering(ctx context.Context, offeringID string) (*Offering, error) {
if offeringID == "" {
return nil, errors.New("northcapital: offeringID required")
}
form := url.Values{}
form.Set("offeringId", offeringID)
const path = "/tapiv3/index.php/v1/getOffering"
raw, err := p.doForm(ctx, "POST", path, form, "")
if err != nil {
return nil, err
}
env, err := decodeEnvelope(path, raw)
if err != nil {
return nil, err
}
return decodeOffering(env.Offering, "getOffering")
}
// ListOfferings returns all Offerings accessible to this clientID.
// Pagination is handled internally.
func (p *Provider) ListOfferings(ctx context.Context) ([]*Offering, error) {
return p.ListOfferingsPaged(ctx, ListOptions{})
}
// ListOfferingsPaged is the explicit-options variant of ListOfferings.
func (p *Provider) ListOfferingsPaged(ctx context.Context, opts ListOptions) ([]*Offering, error) {
const path = "/tapiv3/index.php/v1/getAllOfferings"
pageSize := opts.PageSize
if pageSize == 0 {
pageSize = 500
}
var out []*Offering
for page := 1; ; page++ {
form := url.Values{}
form.Set("offset", strconv.Itoa((page-1)*pageSize))
form.Set("limit", strconv.Itoa(pageSize))
for k, v := range opts.Filter {
form.Set(k, v)
}
raw, err := p.doForm(ctx, "POST", path, form, "")
if err != nil {
return nil, err
}
env, err := decodeEnvelope(path, raw)
if err != nil {
return nil, err
}
if len(env.Offerings) == 0 {
break
}
var arr []wireOffering
if err := json.Unmarshal(env.Offerings, &arr); err != nil {
return nil, fmt.Errorf("northcapital: listOfferings decode: %w", err)
}
if len(arr) == 0 {
break
}
for i := range arr {
out = append(out, wireOfferingToOffering(&arr[i]))
if opts.MaxRecords > 0 && len(out) >= opts.MaxRecords {
return out, nil
}
}
if len(arr) < pageSize {
break
}
}
return out, nil
}
func decodeOffering(raw json.RawMessage, where string) (*Offering, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("northcapital: %s: empty offeringDetails", where)
}
var w wireOffering
if err := json.Unmarshal(raw, &w); err != nil {
var arr []wireOffering
if err2 := json.Unmarshal(raw, &arr); err2 == nil && len(arr) > 0 {
w = arr[0]
} else {
return nil, fmt.Errorf("northcapital: %s decode: %w", where, err)
}
}
return wireOfferingToOffering(&w), nil
}
func wireOfferingToOffering(w *wireOffering) *Offering {
o := &Offering{
ID: w.OfferingID,
IssuerName: w.IssuerName,
OfferingName: w.OfferingName,
OfferingType: w.OfferingType,
TargetAmount: w.TargetAmount,
MinInvestment: w.MinInvestment,
MaxInvestment: w.MaxInvestment,
UnitPrice: w.UnitPrice,
Currency: w.Currency,
Status: w.Status,
SecurityClass: w.SecurityClass,
CUSIP: w.CUSIP,
}
o.CreatedAt = parseDate(w.CreatedDate)
o.UpdatedAt = parseDate(w.UpdatedDate)
if w.OpenedAt != "" {
t := parseDate(w.OpenedAt)
o.OpenedAt = &t
}
if w.ClosedAt != "" {
t := parseDate(w.ClosedAt)
o.ClosedAt = &t
}
return o
}