fix: sync StarGift lifecycle hardening

This commit is contained in:
A 2026-07-21 15:47:55 +08:00
parent bfdfe825f6
commit d69a34a4a8
30 changed files with 1883 additions and 132 deletions

View file

@ -0,0 +1,6 @@
-- Durable edit events and repaired user gift projections are intentionally not
-- rewound. Dropping the new lookup/index constraints is sufficient rollback.
ALTER TABLE peer_star_gifts
DROP CONSTRAINT IF EXISTS peer_star_gifts_hidden_unpinned_check;
DROP TABLE IF EXISTS star_gift_user_message_refs;

View file

@ -0,0 +1,127 @@
-- Official clients may continue lifecycle actions from a freshly emitted
-- messageActionStarGiftUnique. Keep those user-local message ids as explicit
-- durable references to the same saved gift aggregate.
CREATE TABLE star_gift_user_message_refs (
owner_user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
msg_id integer NOT NULL,
saved_gift_id bigint NOT NULL REFERENCES peer_star_gifts(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (owner_user_id, msg_id),
CONSTRAINT star_gift_user_message_refs_msg_check CHECK (owner_user_id > 0 AND msg_id > 0)
);
CREATE INDEX star_gift_user_message_refs_saved_idx
ON star_gift_user_message_refs(saved_gift_id, owner_user_id, msg_id);
INSERT INTO star_gift_user_message_refs(owner_user_id,msg_id,saved_gift_id)
SELECT box.owner_user_id, box.box_id, gift.id
FROM message_boxes box
JOIN unique_star_gifts unique_gift
ON (box.media #>> '{service_action,star_gift_unique,gift,ID}') ~ '^[0-9]+$'
AND unique_gift.id = (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint
JOIN peer_star_gifts gift
ON gift.id = unique_gift.source_saved_gift_id
AND gift.owner_peer_type = 'user'
AND gift.owner_peer_id = box.owner_user_id
WHERE NOT box.deleted
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND box.box_id <> gift.msg_id;
-- Hidden gifts cannot remain pinned. Compacting the whole owner vector here
-- also repairs historical gaps before the invariant is constrained.
ALTER TABLE peer_star_gifts
ADD CONSTRAINT peer_star_gifts_hidden_unpinned_check
CHECK (pinned_order<=6 AND (NOT unsaved OR pinned_order=0)) NOT VALID;
CREATE TEMP TABLE star_gift_pin_repairs ON COMMIT DROP AS
SELECT id,new_order
FROM (
SELECT id,
row_number() OVER (PARTITION BY owner_peer_type,owner_peer_id
ORDER BY pinned_order,id)::integer AS new_order
FROM peer_star_gifts
WHERE lifecycle_status='active' AND NOT unsaved AND pinned_order>0
) ranked
WHERE new_order<=6;
UPDATE peer_star_gifts SET pinned_order=0 WHERE pinned_order<>0;
UPDATE peer_star_gifts gift
SET pinned_order=repair.new_order
FROM star_gift_pin_repairs repair
WHERE gift.id=repair.id;
-- peer and saved_id share one TL flag and are channel-only. Earlier user gift
-- projections set peer=user (and sometimes a user box id in saved_id), which
-- made TDesktop select zero/stale ids instead of the emitted service message.
CREATE TEMP TABLE star_gift_user_unique_media_repairs (
owner_user_id bigint NOT NULL,
box_id integer NOT NULL,
peer_type text NOT NULL,
peer_id bigint NOT NULL,
repaired_media jsonb NOT NULL,
PRIMARY KEY(owner_user_id,box_id)
) ON COMMIT DROP;
INSERT INTO star_gift_user_unique_media_repairs(owner_user_id,box_id,peer_type,peer_id,repaired_media)
SELECT box.owner_user_id,
box.box_id,
box.peer_type,
box.peer_id,
jsonb_set(
box.media #- '{service_action,star_gift_unique,saved_id}',
'{service_action,star_gift_unique,peer}',
'{"ID":0,"Type":""}'::jsonb,
true
)
FROM message_boxes box
WHERE NOT box.deleted
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND box.media #>> '{service_action,star_gift_unique,peer,Type}' = 'user';
DO $$
DECLARE
repair record;
next_pts integer;
event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer;
BEGIN
FOR repair IN
SELECT owner_user_id,box_id,peer_type,peer_id,repaired_media
FROM star_gift_user_unique_media_repairs
ORDER BY owner_user_id,box_id
LOOP
INSERT INTO user_update_watermarks(user_id,contiguous_pts)
VALUES(repair.owner_user_id,0)
ON CONFLICT(user_id) DO NOTHING;
UPDATE user_update_watermarks
SET contiguous_pts=contiguous_pts+1,updated_at=now()
WHERE user_id=repair.owner_user_id
RETURNING contiguous_pts INTO next_pts;
UPDATE message_boxes
SET media=repair.repaired_media,pts=next_pts
WHERE owner_user_id=repair.owner_user_id AND box_id=repair.box_id AND NOT deleted;
INSERT INTO user_update_events(
user_id,pts,pts_count,date,event_type,message_box_id,peer_type,peer_id
) VALUES(
repair.owner_user_id,next_pts,1,event_date,'edit_message',repair.box_id,repair.peer_type,repair.peer_id
);
INSERT INTO dispatch_outbox(
target_user_id,pts,event_type,exclude_auth_key_id,exclude_session_id
) VALUES(repair.owner_user_id,next_pts,'edit_message',0,0);
END LOOP;
END
$$;
UPDATE private_messages
SET media=jsonb_set(
media #- '{service_action,star_gift_unique,saved_id}',
'{service_action,star_gift_unique,peer}',
'{"ID":0,"Type":""}'::jsonb,
true
)
WHERE media #>> '{service_action,kind}' = 'star_gift_unique'
AND media #>> '{service_action,star_gift_unique,peer,Type}' = 'user';

View file

@ -0,0 +1 @@
-- Validation changes no data and the constraint belongs to migration 0126.

View file

@ -0,0 +1,5 @@
-- 0126 repaired historical rows and installed the constraint as NOT VALID so
-- it could coexist with the deferrable unique-gift owner trigger in one
-- migration transaction. Validate after that transaction has committed.
ALTER TABLE peer_star_gifts
VALIDATE CONSTRAINT peer_star_gifts_hidden_unpinned_check;

View file

@ -0,0 +1,38 @@
-- Aggregate/message repairs and emitted edit events are authoritative business
-- history and are intentionally not reversed. Restore only the pre-0128
-- deferred owner guard shape.
CREATE OR REPLACE FUNCTION public.telesrv_check_unique_star_gift_owner() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
unique_id bigint;
gift_owner_type text;
gift_owner_id bigint;
gift_owner_address text;
gift_burned boolean;
saved_status text;
saved_owner_type text;
saved_owner_id bigint;
BEGIN
IF TG_TABLE_NAME = 'unique_star_gifts' THEN
unique_id := COALESCE(NEW.id, OLD.id);
ELSE
unique_id := COALESCE(NEW.unique_gift_id, OLD.unique_gift_id);
END IF;
IF unique_id IS NULL THEN RETURN NULL; END IF;
SELECT owner_peer_type, owner_peer_id, owner_address, burned
INTO gift_owner_type, gift_owner_id, gift_owner_address, gift_burned
FROM public.unique_star_gifts WHERE id=unique_id;
IF NOT FOUND THEN RETURN NULL; END IF;
SELECT lifecycle_status, owner_peer_type, owner_peer_id
INTO saved_status, saved_owner_type, saved_owner_id
FROM public.peer_star_gifts WHERE unique_gift_id=unique_id;
IF NOT FOUND THEN RAISE EXCEPTION 'unique star gift missing saved aggregate'; END IF;
IF gift_burned THEN
IF saved_status <> 'burned' THEN RAISE EXCEPTION 'burned unique star gift has live saved aggregate'; END IF;
ELSIF gift_owner_address <> '' THEN
IF saved_status <> 'exported' THEN RAISE EXCEPTION 'exported unique star gift has non-exported saved aggregate'; END IF;
ELSIF saved_status <> 'active' OR gift_owner_type IS DISTINCT FROM saved_owner_type OR gift_owner_id IS DISTINCT FROM saved_owner_id THEN
RAISE EXCEPTION 'unique star gift owner mismatch';
END IF;
RETURN NULL;
END;
$$;

View file

@ -0,0 +1,498 @@
-- Official Android clients use a positive can_craft_at both as the Craft
-- capability marker and as the readiness boundary. Earlier zero-delay
-- upgrades persisted 0 while retaining a positive craft chance, so TDesktop
-- could Craft the gift but Android hid the entry entirely.
--
-- Block concurrent lifecycle/message writers while aggregate facts, message
-- snapshots and durable edit edges are repaired in this migration transaction.
LOCK TABLE public.peer_star_gifts, public.unique_star_gifts,
public.message_boxes, public.private_messages IN SHARE ROW EXCLUSIVE MODE;
DO $$
BEGIN
-- Craft capability remains an intrinsic collectible fact while ownership
-- moves between users and channels. Terminal/external states cannot Craft.
UPDATE public.unique_star_gifts unique_gift
SET craft_chance_permille = 0,
updated_at = now()
FROM public.peer_star_gifts saved_gift
WHERE saved_gift.unique_gift_id = unique_gift.id
AND unique_gift.craft_chance_permille > 0
AND (
saved_gift.lifecycle_status <> 'active'
OR unique_gift.owner_address <> ''
OR unique_gift.burned
OR unique_gift.crafted
);
UPDATE public.peer_star_gifts saved_gift
SET can_craft_at = 0
FROM public.unique_star_gifts unique_gift
WHERE unique_gift.id = saved_gift.unique_gift_id
AND unique_gift.craft_chance_permille = 0
AND saved_gift.can_craft_at <> 0;
IF EXISTS (
SELECT 1
FROM public.unique_star_gifts unique_gift
JOIN public.peer_star_gifts saved_gift
ON saved_gift.unique_gift_id = unique_gift.id
WHERE unique_gift.craft_chance_permille > 0
AND (
saved_gift.owner_peer_type NOT IN ('user', 'channel')
OR saved_gift.lifecycle_status <> 'active'
OR unique_gift.owner_address <> ''
OR unique_gift.burned
OR unique_gift.crafted
OR NOT EXISTS (
SELECT 1
FROM public.star_gift_collectible_models model
WHERE model.collectible_revision_id = unique_gift.collectible_revision_id
AND model.crafted
)
)
) THEN
RAISE EXCEPTION 'positive star gift craft chance has no valid owned aggregate';
END IF;
-- created_at is the stable persisted proxy for the original upgrade
-- transaction date on legacy rows. New writes use the exact request date.
UPDATE public.peer_star_gifts saved_gift
SET can_craft_at = GREATEST(
1,
LEAST(2147483647, FLOOR(EXTRACT(EPOCH FROM unique_gift.created_at))::bigint)::integer
)
FROM public.unique_star_gifts unique_gift
WHERE unique_gift.id = saved_gift.unique_gift_id
AND unique_gift.craft_chance_permille > 0
AND saved_gift.can_craft_at = 0;
IF EXISTS (
SELECT 1
FROM public.peer_star_gifts saved_gift
JOIN public.unique_star_gifts unique_gift
ON unique_gift.id = saved_gift.unique_gift_id
WHERE (unique_gift.craft_chance_permille > 0)
IS DISTINCT FROM (saved_gift.can_craft_at > 0)
) THEN
RAISE EXCEPTION 'star gift craft chance/readiness repair did not converge';
END IF;
END
$$;
CREATE TEMP TABLE star_gift_craft_message_repairs (
owner_user_id bigint NOT NULL,
box_id integer NOT NULL,
unique_gift_id bigint NOT NULL,
desired_craft_chance integer NOT NULL,
desired_can_craft_at integer NOT NULL,
PRIMARY KEY (owner_user_id, box_id)
) ON COMMIT DROP;
-- Adding capability is owner-scoped: repair only the current owner's
-- authoritative unique action (upgrade_msg_id) and the other visible box of
-- that same logical private message. Never add Craft back to an old owner's
-- historical transfer/resale action.
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM public.peer_star_gifts saved_gift
JOIN public.unique_star_gifts unique_gift
ON unique_gift.id = saved_gift.unique_gift_id
WHERE saved_gift.owner_peer_type = 'user'
AND saved_gift.lifecycle_status = 'active'
AND saved_gift.can_craft_at > 0
AND NOT EXISTS (
SELECT 1
FROM public.message_boxes owner_box
WHERE owner_box.owner_user_id = saved_gift.owner_peer_id
AND owner_box.box_id = saved_gift.upgrade_msg_id
AND NOT owner_box.deleted
AND owner_box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND owner_box.media #>> '{service_action,star_gift_unique,gift,ID}' = unique_gift.id::text
)
) THEN
RAISE EXCEPTION 'craftable star gift is missing its current owner action';
END IF;
IF EXISTS (
SELECT 1
FROM public.message_boxes box
WHERE box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND box.media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
AND (
jsonb_typeof(box.media #> '{service_action,star_gift_unique,can_craft_at}') <> 'number'
OR COALESCE(box.media #>> '{service_action,star_gift_unique,can_craft_at}', '') !~ '^[0-9]+$'
OR (box.media #>> '{service_action,star_gift_unique,can_craft_at}')::numeric > 2147483647
)
) THEN
RAISE EXCEPTION 'star gift message has malformed can_craft_at';
END IF;
IF EXISTS (
SELECT 1
FROM public.message_boxes box
WHERE box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL
AND (
jsonb_typeof(box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}') <> 'number'
OR COALESCE(box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}', '') !~ '^[0-9]+$'
OR (box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}')::numeric > 1000
)
) THEN
RAISE EXCEPTION 'star gift message has malformed craft chance';
END IF;
END
$$;
INSERT INTO star_gift_craft_message_repairs(
owner_user_id, box_id, unique_gift_id,
desired_craft_chance, desired_can_craft_at
)
SELECT visible_box.owner_user_id,
visible_box.box_id,
unique_gift.id,
unique_gift.craft_chance_permille,
saved_gift.can_craft_at
FROM public.peer_star_gifts saved_gift
JOIN public.unique_star_gifts unique_gift
ON unique_gift.id = saved_gift.unique_gift_id
JOIN public.message_boxes owner_box
ON owner_box.owner_user_id = saved_gift.owner_peer_id
AND owner_box.box_id = saved_gift.upgrade_msg_id
AND NOT owner_box.deleted
AND owner_box.media #>> '{service_action,star_gift_unique,gift,ID}' = unique_gift.id::text
JOIN public.message_boxes visible_box
ON visible_box.message_sender_id = owner_box.message_sender_id
AND visible_box.private_message_id = owner_box.private_message_id
AND NOT visible_box.deleted
WHERE saved_gift.owner_peer_type = 'user'
AND saved_gift.lifecycle_status = 'active'
AND saved_gift.can_craft_at > 0
AND (
COALESCE(NULLIF(visible_box.media #>> '{service_action,star_gift_unique,can_craft_at}', '')::integer, 0)
IS DISTINCT FROM saved_gift.can_craft_at
OR COALESCE(NULLIF(visible_box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}', '')::integer, 0)
IS DISTINCT FROM unique_gift.craft_chance_permille
);
-- Only the current owner's authoritative logical message may expose Craft.
-- Terminal gifts, channel-owned gifts (until channel Craft is implemented),
-- and old-owner historical actions must have both wire markers removed.
INSERT INTO star_gift_craft_message_repairs(
owner_user_id, box_id, unique_gift_id,
desired_craft_chance, desired_can_craft_at
)
SELECT box.owner_user_id, box.box_id, unique_gift.id, 0, 0
FROM public.message_boxes box
JOIN public.unique_star_gifts unique_gift
ON (box.media #>> '{service_action,star_gift_unique,gift,ID}') ~ '^[0-9]+$'
AND unique_gift.id = (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint
JOIN public.peer_star_gifts saved_gift
ON saved_gift.unique_gift_id = unique_gift.id
WHERE NOT box.deleted
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND (
box.media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
OR box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL
)
AND NOT EXISTS (
SELECT 1
FROM public.message_boxes authority
WHERE saved_gift.owner_peer_type = 'user'
AND saved_gift.lifecycle_status = 'active'
AND saved_gift.can_craft_at > 0
AND unique_gift.craft_chance_permille > 0
AND authority.owner_user_id = saved_gift.owner_peer_id
AND authority.box_id = saved_gift.upgrade_msg_id
AND NOT authority.deleted
AND authority.media #>> '{service_action,kind}' = 'star_gift_unique'
AND authority.media #>> '{service_action,star_gift_unique,gift,ID}' = unique_gift.id::text
AND authority.message_sender_id = box.message_sender_id
AND authority.private_message_id = box.private_message_id
)
ON CONFLICT (owner_user_id, box_id) DO UPDATE
SET unique_gift_id = EXCLUDED.unique_gift_id,
desired_craft_chance = 0,
desired_can_craft_at = 0;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM star_gift_craft_message_repairs target
JOIN public.message_boxes box
ON box.owner_user_id = target.owner_user_id
AND box.box_id = target.box_id
WHERE box.deleted
OR box.media #>> '{service_action,kind}' <> 'star_gift_unique'
OR box.media #>> '{service_action,star_gift_unique,gift,ID}' <> target.unique_gift_id::text
) THEN
RAISE EXCEPTION 'craft readiness repair target is not the expected unique gift action';
END IF;
IF EXISTS (
SELECT 1
FROM star_gift_craft_message_repairs target
JOIN public.message_boxes box
ON box.owner_user_id = target.owner_user_id
AND box.box_id = target.box_id
WHERE NOT EXISTS (
SELECT 1
FROM public.private_messages private_message
WHERE private_message.sender_user_id = box.message_sender_id
AND private_message.id = box.private_message_id
AND private_message.media #>> '{service_action,kind}' = 'star_gift_unique'
AND private_message.media #>> '{service_action,star_gift_unique,gift,ID}' = target.unique_gift_id::text
)
) THEN
RAISE EXCEPTION 'craft readiness repair target has no matching private message';
END IF;
IF EXISTS (
SELECT 1
FROM star_gift_craft_message_repairs target
JOIN public.message_boxes box
ON box.owner_user_id = target.owner_user_id
AND box.box_id = target.box_id
GROUP BY box.message_sender_id, box.private_message_id
HAVING COUNT(DISTINCT (
target.unique_gift_id,
target.desired_craft_chance,
target.desired_can_craft_at
)) <> 1
) THEN
RAISE EXCEPTION 'craft readiness repair has conflicting logical message targets';
END IF;
END
$$;
DO $$
DECLARE
repair record;
next_pts integer;
event_date integer := LEAST(2147483647, EXTRACT(EPOCH FROM clock_timestamp())::bigint)::integer;
repaired_media jsonb;
repaired_private_media jsonb;
affected_rows bigint;
BEGIN
FOR repair IN
SELECT target.owner_user_id,
target.box_id,
target.unique_gift_id,
target.desired_craft_chance,
target.desired_can_craft_at,
box.peer_type,
box.peer_id,
box.message_sender_id,
box.private_message_id,
box.media
FROM star_gift_craft_message_repairs target
JOIN public.message_boxes box
ON box.owner_user_id = target.owner_user_id
AND box.box_id = target.box_id
AND NOT box.deleted
ORDER BY target.owner_user_id, target.box_id
FOR UPDATE OF box
LOOP
IF repair.desired_craft_chance > 0 THEN
repaired_media := jsonb_set(
jsonb_set(
repair.media,
'{service_action,star_gift_unique,gift,CraftChancePermille}',
to_jsonb(repair.desired_craft_chance),
true
),
'{service_action,star_gift_unique,can_craft_at}',
to_jsonb(repair.desired_can_craft_at),
true
);
ELSE
repaired_media := repair.media
#- '{service_action,star_gift_unique,can_craft_at}'
#- '{service_action,star_gift_unique,gift,CraftChancePermille}';
END IF;
IF repaired_media #>> '{service_action,kind}' <> 'star_gift_unique'
OR repaired_media #>> '{service_action,star_gift_unique,gift,ID}' <> repair.unique_gift_id::text
OR (
repair.desired_craft_chance > 0
AND (
repaired_media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}'
IS DISTINCT FROM repair.desired_craft_chance::text
OR repaired_media #>> '{service_action,star_gift_unique,can_craft_at}'
IS DISTINCT FROM repair.desired_can_craft_at::text
)
)
OR (
repair.desired_craft_chance = 0
AND (
repaired_media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL
OR repaired_media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
)
) THEN
RAISE EXCEPTION 'craft readiness repair cannot project message box for user %, box %',
repair.owner_user_id, repair.box_id;
END IF;
INSERT INTO public.user_update_watermarks (user_id, contiguous_pts)
VALUES (repair.owner_user_id, 0)
ON CONFLICT (user_id) DO NOTHING;
UPDATE public.user_update_watermarks
SET contiguous_pts = contiguous_pts + 1,
updated_at = now()
WHERE user_id = repair.owner_user_id
RETURNING contiguous_pts INTO next_pts;
UPDATE public.message_boxes
SET media = repaired_media,
pts = next_pts
WHERE owner_user_id = repair.owner_user_id
AND box_id = repair.box_id
AND NOT deleted;
GET DIAGNOSTICS affected_rows = ROW_COUNT;
IF affected_rows <> 1 THEN
RAISE EXCEPTION 'craft readiness repair lost user %, box %', repair.owner_user_id, repair.box_id;
END IF;
SELECT media
INTO repaired_private_media
FROM public.private_messages
WHERE sender_user_id = repair.message_sender_id
AND id = repair.private_message_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'craft readiness repair missing private message for user %, box %',
repair.owner_user_id, repair.box_id;
END IF;
IF repaired_private_media #>> '{service_action,kind}' <> 'star_gift_unique'
OR repaired_private_media #>> '{service_action,star_gift_unique,gift,ID}' <> repair.unique_gift_id::text THEN
RAISE EXCEPTION 'craft readiness repair found mismatched private message for user %, box %',
repair.owner_user_id, repair.box_id;
END IF;
IF repair.desired_craft_chance > 0 THEN
repaired_private_media := jsonb_set(
jsonb_set(
repaired_private_media,
'{service_action,star_gift_unique,gift,CraftChancePermille}',
to_jsonb(repair.desired_craft_chance),
true
),
'{service_action,star_gift_unique,can_craft_at}',
to_jsonb(repair.desired_can_craft_at),
true
);
IF repaired_private_media #>> '{service_action,star_gift_unique,can_craft_at}'
IS DISTINCT FROM repair.desired_can_craft_at::text
OR repaired_private_media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}'
IS DISTINCT FROM repair.desired_craft_chance::text THEN
RAISE EXCEPTION 'craft readiness repair cannot project private message for user %, box %',
repair.owner_user_id, repair.box_id;
END IF;
ELSE
repaired_private_media := repaired_private_media
#- '{service_action,star_gift_unique,can_craft_at}'
#- '{service_action,star_gift_unique,gift,CraftChancePermille}';
IF repaired_private_media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
OR repaired_private_media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL THEN
RAISE EXCEPTION 'craft readiness repair cannot clear private message for user %, box %',
repair.owner_user_id, repair.box_id;
END IF;
END IF;
UPDATE public.private_messages
SET media = repaired_private_media
WHERE sender_user_id = repair.message_sender_id
AND id = repair.private_message_id;
GET DIAGNOSTICS affected_rows = ROW_COUNT;
IF affected_rows <> 1 THEN
RAISE EXCEPTION 'craft readiness repair lost private message for user %, box %',
repair.owner_user_id, repair.box_id;
END IF;
INSERT INTO public.user_update_events (
user_id, pts, pts_count, date, event_type,
message_box_id, peer_type, peer_id
) VALUES (
repair.owner_user_id, next_pts, 1, event_date, 'edit_message',
repair.box_id, repair.peer_type, repair.peer_id
);
INSERT INTO public.dispatch_outbox (
target_user_id, pts, event_type,
exclude_auth_key_id, exclude_session_id
) VALUES (
repair.owner_user_id, next_pts, 'edit_message', 0, 0
);
END LOOP;
END
$$;
-- Extend the existing deferred unique/saved aggregate guard. Upgrade, Craft
-- and export update the two tables in separate statements, so commit-time
-- validation observes the final atomic state without a read fallback.
CREATE OR REPLACE FUNCTION public.telesrv_check_unique_star_gift_owner() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
unique_id bigint;
gift_owner_type text;
gift_owner_id bigint;
gift_owner_address text;
gift_burned boolean;
gift_crafted boolean;
gift_craft_chance integer;
gift_revision_id bigint;
saved_status text;
saved_owner_type text;
saved_owner_id bigint;
saved_can_craft_at integer;
BEGIN
IF TG_TABLE_NAME = 'unique_star_gifts' THEN
unique_id := COALESCE(NEW.id, OLD.id);
ELSE
unique_id := COALESCE(NEW.unique_gift_id, OLD.unique_gift_id);
END IF;
IF unique_id IS NULL THEN RETURN NULL; END IF;
SELECT owner_peer_type, owner_peer_id, owner_address, burned, crafted,
craft_chance_permille, collectible_revision_id
INTO gift_owner_type, gift_owner_id, gift_owner_address, gift_burned, gift_crafted,
gift_craft_chance, gift_revision_id
FROM public.unique_star_gifts WHERE id=unique_id;
IF NOT FOUND THEN RETURN NULL; END IF;
SELECT lifecycle_status, owner_peer_type, owner_peer_id, can_craft_at
INTO saved_status, saved_owner_type, saved_owner_id, saved_can_craft_at
FROM public.peer_star_gifts WHERE unique_gift_id=unique_id;
IF NOT FOUND THEN RAISE EXCEPTION 'unique star gift missing saved aggregate'; END IF;
IF gift_burned THEN
IF saved_status <> 'burned' THEN RAISE EXCEPTION 'burned unique star gift has live saved aggregate'; END IF;
ELSIF gift_owner_address <> '' THEN
IF saved_status <> 'exported' THEN RAISE EXCEPTION 'exported unique star gift has non-exported saved aggregate'; END IF;
ELSIF saved_status <> 'active' OR gift_owner_type IS DISTINCT FROM saved_owner_type OR gift_owner_id IS DISTINCT FROM saved_owner_id THEN
RAISE EXCEPTION 'unique star gift owner mismatch';
END IF;
IF gift_craft_chance > 0 THEN
IF saved_can_craft_at <= 0
OR saved_status <> 'active'
OR saved_owner_type NOT IN ('user', 'channel')
OR gift_owner_address <> ''
OR gift_burned
OR gift_crafted THEN
RAISE EXCEPTION 'unique star gift craft capability has invalid aggregate state';
END IF;
IF NOT EXISTS (
SELECT 1
FROM public.star_gift_collectible_models model
WHERE model.collectible_revision_id = gift_revision_id
AND model.crafted
) THEN
RAISE EXCEPTION 'unique star gift craft chance has no crafted model';
END IF;
ELSIF saved_can_craft_at <> 0 THEN
RAISE EXCEPTION 'unique star gift readiness exists without craft chance';
END IF;
RETURN NULL;
END;
$$;

View file

@ -0,0 +1,4 @@
ALTER TABLE public.star_gift_craft_commands
DROP CONSTRAINT IF EXISTS star_gift_craft_output_receipt_check,
DROP COLUMN IF EXISTS output_fingerprint,
DROP COLUMN IF EXISTS output_media;

View file

@ -0,0 +1,65 @@
-- A successful Craft outcome and its self-service message are separated by a
-- process boundary. Freeze the exact output intent in the outcome receipt so
-- retries never rebuild a different message from mutable gift/profile state.
ALTER TABLE public.star_gift_craft_commands
ADD COLUMN output_media jsonb,
ADD COLUMN output_fingerprint bytea;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM public.star_gift_craft_commands command
WHERE command.success
AND 1 <> (
SELECT COUNT(*)
FROM public.private_messages message
WHERE message.sender_user_id = command.user_id
AND message.recipient_user_id = command.user_id
AND message.sender_snapshot #>> '{message,Media,service_action,kind}' = 'star_gift_unique'
AND message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,gift,ID}' = command.result_unique_gift_id::text
AND COALESCE((message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,craft}')::boolean, false)
AND octet_length(message.request_fingerprint) = 32
)
) THEN
RAISE EXCEPTION 'successful craft command is missing its exact immutable output receipt';
END IF;
END
$$;
WITH outputs AS (
SELECT command.user_id,
command.command_key,
message.sender_snapshot #> '{message,Media}' AS media,
message.request_fingerprint
FROM public.star_gift_craft_commands command
JOIN public.private_messages message
ON message.sender_user_id = command.user_id
AND message.recipient_user_id = command.user_id
AND message.sender_snapshot #>> '{message,Media,service_action,kind}' = 'star_gift_unique'
AND message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,gift,ID}' = command.result_unique_gift_id::text
AND COALESCE((message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,craft}')::boolean, false)
WHERE command.success
)
UPDATE public.star_gift_craft_commands command
SET output_media = output.media,
output_fingerprint = output.request_fingerprint
FROM outputs output
WHERE command.user_id = output.user_id
AND command.command_key = output.command_key;
ALTER TABLE public.star_gift_craft_commands
ADD CONSTRAINT star_gift_craft_output_receipt_check CHECK (
(success
AND result_unique_gift_id IS NOT NULL
AND output_media IS NOT NULL
AND output_media #>> '{service_action,kind}' = 'star_gift_unique'
AND COALESCE((output_media #>> '{service_action,star_gift_unique,craft}')::boolean, false)
AND output_media #>> '{service_action,star_gift_unique,gift,ID}' = result_unique_gift_id::text
AND octet_length(output_fingerprint) = 32)
OR
(NOT success
AND result_unique_gift_id IS NULL
AND output_media IS NULL
AND output_fingerprint IS NULL)
);

View file

@ -55,9 +55,9 @@ const tdesktopClient = "tdesktop"
//
// WebK directly calls Array.some on fragment_prefixes while rendering user profiles,
// so this compatibility key must always remain an array, even when it is empty.
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","fragment_prefixes":["888"],"forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stars_purchase_blocked":false,"stargifts_blocked":false,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","fragment_prefixes":["888"],"forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stars_purchase_blocked":false,"stargifts_blocked":false,"stargifts_pinned_to_top_limit":6,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
const defaultAppConfigHash = 23 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
const defaultAppConfigHash = 24 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
// Service 提供客户端启动配置与国家区号目录。
//

View file

@ -48,6 +48,7 @@ func TestAppConfigPremiumKeys(t *testing.T) {
"reactions_user_max_default": 1,
"reactions_user_max_premium": 3,
"boosts_channel_level_max": 100,
"stargifts_pinned_to_top_limit": 6,
"about_length_limit_default": 70,
"about_length_limit_premium": 140,
"dialogs_pinned_limit_default": 5,

View file

@ -879,6 +879,9 @@ const (
MaxStarGiftCollectionTitleRunes = 12
MaxStarGiftCollectionsPerPeer = 100
MaxStarGiftCollectionItems = 1000
// MaxPinnedStarGifts matches stargifts_pinned_to_top_limit advertised to
// official clients. Pin requests are complete replacement vectors.
MaxPinnedStarGifts = 6
)
// Star gift 哨兵错误rpc 层 errors.Is 映射为 tgerr

View file

@ -269,7 +269,10 @@ func tgMessageActionStarGiftUnique(action *domain.MessageStarGiftUniqueAction) t
if action.DropOriginalDetailsStars > 0 {
out.SetDropOriginalDetailsStars(action.DropOriginalDetailsStars)
}
if action.CanCraftAt > 0 {
// Channel Craft is not executable yet. Gate on the authoritative gift owner
// as a final wire boundary so historical JSON/admin-log actions or a future
// constructor cannot accidentally expose Android's Craft entry marker.
if action.Gift.Owner.Type == domain.PeerTypeUser && action.CanCraftAt > 0 {
out.SetCanCraftAt(action.CanCraftAt)
}
if action.FromUserID != 0 {

View file

@ -1007,9 +1007,11 @@ func starGiftLifecycleErr(err error) error {
return tgerr.New(400, "STARGIFT_OWNER_INVALID")
case errors.Is(err, domain.ErrStarGiftWithdrawalUnavailable):
return tgerr.New(400, "STARGIFT_WITHDRAWAL_UNAVAILABLE")
case errors.Is(err, domain.ErrStarGiftCraftUnavailable):
return tgerr.New(400, "STARGIFT_CRAFT_UNAVAILABLE")
case errors.Is(err, domain.ErrStarGiftNotFound), errors.Is(err, domain.ErrStarGiftResaleUnavailable),
errors.Is(err, domain.ErrStarGiftTransferUnavailable), errors.Is(err, domain.ErrStarGiftOfferInvalid),
errors.Is(err, domain.ErrStarGiftCraftUnavailable), errors.Is(err, domain.ErrStarGiftAuctionUnavailable),
errors.Is(err, domain.ErrStarGiftAuctionUnavailable),
errors.Is(err, domain.ErrStarGiftUnavailable), errors.Is(err, domain.ErrStarGiftInvalid),
errors.Is(err, domain.ErrStarGiftCollectibleUnavailable):
return starGiftInvalidErr()

View file

@ -1071,6 +1071,27 @@ func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.Sta
item.SetPrepaidUpgradeHash(g.PrepaidUpgradeHash)
}
}
if g.CanExportAt > 0 {
item.SetCanExportAt(g.CanExportAt)
}
if g.TransferStars > 0 {
item.SetTransferStars(g.TransferStars)
}
if g.CanTransferAt > 0 {
item.SetCanTransferAt(g.CanTransferAt)
}
if g.CanResellAt > 0 {
item.SetCanResellAt(g.CanResellAt)
}
if g.DropOriginalDetailsStars > 0 {
item.SetDropOriginalDetailsStars(g.DropOriginalDetailsStars)
}
// Channel Craft execution is not implemented yet. Android uses this field
// as the entry/capability marker, so only advertise the currently
// executable user-owned path while retaining the durable DB entitlement.
if g.Owner.Type == domain.PeerTypeUser && g.CanCraftAt > 0 {
item.SetCanCraftAt(g.CanCraftAt)
}
if g.PinnedOrder > 0 {
item.PinnedToTop = true
}

View file

@ -118,7 +118,7 @@ func (s *craftStarGiftRPCService) GetSaved(_ context.Context, ref domain.SavedSt
}
continue
}
if saved.MsgID == ref.MsgID {
if saved.MsgID == ref.MsgID || saved.UpgradeMsgID == ref.MsgID {
return saved, true, nil
}
}
@ -181,11 +181,12 @@ func TestCraftStarGiftAcceptsOfficialSlugAndCanonicalizesAliases(t *testing.T) {
t.Fatalf("duplicate aliases err=%v craft calls=%d", err, service.craftCall)
}
_, err = r.onPaymentsCraftStarGift(ctx, &tg.PaymentsCraftStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{
updates, err = r.onPaymentsCraftStarGift(ctx, &tg.PaymentsCraftStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{
&tg.InputSavedStarGiftUser{MsgID: 116},
}})
if !tgerr.Is(err, "STARGIFT_INVALID") || service.craftCall != 0 {
t.Fatalf("upgrade message id accepted as gift identity: err=%v craft calls=%d", err, service.craftCall)
if err != nil || updates == nil || service.craftCall != 1 || service.craftReq.CommandKey != "rpc:50" ||
len(service.craftReq.Refs) != 1 || service.craftReq.Refs[0].MsgID != 116 {
t.Fatalf("upgrade message alias craft: updates=%T req=%+v err=%v calls=%d", updates, service.craftReq, err, service.craftCall)
}
}
@ -314,6 +315,162 @@ func TestSavedStarGiftProjectionCombinesHistoricalCatalogWithCurrentCollectibleA
}
}
func TestSavedStarGiftProjectionPreservesCollectibleLifecycle(t *testing.T) {
const (
giftID = int64(8001)
revision = int64(9001)
readyAt = 1_780_000_123
exportAt = 1_780_000_200
transferAt = 1_780_000_300
resellAt = 1_780_000_400
)
unique := domain.UniqueStarGift{ID: 9901, GiftID: giftID, Title: "Craftable", Slug: "craftable-1", Num: 1,
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 7102}, CraftChancePermille: 250}
saved := domain.SavedStarGift{
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 7102}, GiftID: giftID, RevisionID: revision,
MsgID: 44, Date: 100, UniqueGiftID: unique.ID, Unique: &unique,
CanExportAt: exportAt, TransferStars: 25, CanTransferAt: transferAt, CanResellAt: resellAt,
DropOriginalDetailsStars: 30, CanCraftAt: readyAt,
}
projected := tgSavedStarGifts([]domain.SavedStarGift{saved}, nil, nil)
if len(projected) != 1 {
t.Fatalf("saved lifecycle projection count = %d", len(projected))
}
assertLifecycle := func(t *testing.T, item tg.SavedStarGift) {
t.Helper()
if value, ok := item.GetCanExportAt(); !ok || value != exportAt {
t.Fatalf("can_export_at = %d set=%v", value, ok)
}
if value, ok := item.GetTransferStars(); !ok || value != 25 {
t.Fatalf("transfer_stars = %d set=%v", value, ok)
}
if value, ok := item.GetCanTransferAt(); !ok || value != transferAt {
t.Fatalf("can_transfer_at = %d set=%v", value, ok)
}
if value, ok := item.GetCanResellAt(); !ok || value != resellAt {
t.Fatalf("can_resell_at = %d set=%v", value, ok)
}
if value, ok := item.GetDropOriginalDetailsStars(); !ok || value != 30 {
t.Fatalf("drop_original_details_stars = %d set=%v", value, ok)
}
if value, ok := item.GetCanCraftAt(); !ok || value != readyAt {
t.Fatalf("can_craft_at = %d set=%v", value, ok)
}
}
assertLifecycle(t, projected[0])
zero := tgSavedStarGifts([]domain.SavedStarGift{{
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 7102}, GiftID: giftID, RevisionID: revision,
MsgID: 45, Date: 101, UniqueGiftID: unique.ID, Unique: &unique,
}}, nil, nil)[0]
if _, ok := zero.GetCanExportAt(); ok {
t.Fatal("zero can_export_at must be absent")
}
if _, ok := zero.GetTransferStars(); ok {
t.Fatal("zero transfer_stars must be absent")
}
if _, ok := zero.GetCanTransferAt(); ok {
t.Fatal("zero can_transfer_at must be absent")
}
if _, ok := zero.GetCanResellAt(); ok {
t.Fatal("zero can_resell_at must be absent")
}
if _, ok := zero.GetDropOriginalDetailsStars(); ok {
t.Fatal("zero drop_original_details_stars must be absent")
}
if _, ok := zero.GetCanCraftAt(); ok {
t.Fatal("zero can_craft_at must be absent")
}
channelSaved := saved
channelSaved.Owner = domain.Peer{Type: domain.PeerTypeChannel, ID: 8102}
channelSaved.MsgID = 0
channelSaved.SavedID = 51
channelProjected := tgSavedStarGifts([]domain.SavedStarGift{channelSaved}, nil, nil)[0]
if _, ok := channelProjected.GetCanCraftAt(); ok {
t.Fatal("channel can_craft_at must be absent until channel Craft is executable")
}
if value, ok := channelProjected.GetSavedID(); !ok || value != channelSaved.SavedID {
t.Fatalf("channel saved_id = %d set=%v", value, ok)
}
for _, profile := range []tlprofile.Profile{tlprofile.Profile225, tlprofile.Profile226, tlprofile.Profile227, tlprofile.Profile228} {
wire := &tg.PaymentsSavedStarGifts{Count: 1, Gifts: projected, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}
encoded := &bin.Buffer{}
if err := tlprofile.EncodeObject(profile, wire, encoded); err != nil {
t.Fatalf("encode Layer %d saved lifecycle: %v", profile, err)
}
decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: encoded.Buf}, tlprofile.Limits{})
if err != nil {
t.Fatalf("decode Layer %d saved lifecycle: %v", profile, err)
}
decoded, ok := decodedObject.(*tg.PaymentsSavedStarGifts)
if !ok || len(decoded.Gifts) != 1 {
t.Fatalf("decode Layer %d saved lifecycle type = %T", profile, decodedObject)
}
assertLifecycle(t, decoded.Gifts[0])
channelWire := &tg.PaymentsSavedStarGifts{Count: 1, Gifts: []tg.SavedStarGift{channelProjected}, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}
channelEncoded := &bin.Buffer{}
if err := tlprofile.EncodeObject(profile, channelWire, channelEncoded); err != nil {
t.Fatalf("encode Layer %d channel saved lifecycle: %v", profile, err)
}
channelDecodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: channelEncoded.Buf}, tlprofile.Limits{})
if err != nil {
t.Fatalf("decode Layer %d channel saved lifecycle: %v", profile, err)
}
channelDecoded, ok := channelDecodedObject.(*tg.PaymentsSavedStarGifts)
if !ok || len(channelDecoded.Gifts) != 1 {
t.Fatalf("decode Layer %d channel saved lifecycle type = %T", profile, channelDecodedObject)
}
if _, ok := channelDecoded.Gifts[0].GetCanCraftAt(); ok {
t.Fatalf("Layer %d channel saved gift exposed can_craft_at", profile)
}
}
}
func TestChannelUniqueActionSuppressesCraftReadinessAcrossProfiles(t *testing.T) {
const readyAt = 1_780_000_123
unique := domain.UniqueStarGift{
ID: 9902, GiftID: 8002, Title: "Channel Craftable", Slug: "channel-craftable-1", Num: 1,
Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: 8102}, CraftChancePermille: 250,
}
action := tgMessageActionStarGiftUnique(&domain.MessageStarGiftUniqueAction{
Gift: unique, Peer: unique.Owner, SavedID: 52, Saved: true, CanCraftAt: readyAt,
}).(*tg.MessageActionStarGiftUnique)
if _, ok := action.GetCanCraftAt(); ok {
t.Fatal("channel unique action must not expose can_craft_at")
}
projectedGift, ok := action.Gift.(*tg.StarGiftUnique)
if !ok || projectedGift.CraftChancePermille != unique.CraftChancePermille {
t.Fatalf("channel unique gift lost intrinsic Craft chance: %#v", action.Gift)
}
for _, profile := range []tlprofile.Profile{tlprofile.Profile225, tlprofile.Profile226, tlprofile.Profile227, tlprofile.Profile228} {
wire := &bin.Buffer{}
if err := tlprofile.EncodeObject(profile, action, wire); err != nil {
t.Fatalf("encode Layer %d channel unique action: %v", profile, err)
}
decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: wire.Buf}, tlprofile.Limits{})
if err != nil {
t.Fatalf("decode Layer %d channel unique action: %v", profile, err)
}
decoded, ok := decodedObject.(*tg.MessageActionStarGiftUnique)
if !ok {
t.Fatalf("decode Layer %d channel unique action type = %T", profile, decodedObject)
}
if _, ok := decoded.GetCanCraftAt(); ok {
t.Fatalf("Layer %d channel unique action exposed can_craft_at", profile)
}
gift, ok := decoded.Gift.(*tg.StarGiftUnique)
if !ok || gift.CraftChancePermille != unique.CraftChancePermille {
t.Fatalf("Layer %d channel unique gift = %#v", profile, decoded.Gift)
}
}
}
func TestStarGiftLifecycleCraftUnavailableError(t *testing.T) {
if err := starGiftLifecycleErr(domain.ErrStarGiftCraftUnavailable); !tgerr.Is(err, "STARGIFT_CRAFT_UNAVAILABLE") {
t.Fatalf("craft unavailable mapping = %v", err)
}
}
func TestMessageStarGiftProjectionSeparatesPaidPriceFromPrepaidAmount(t *testing.T) {
ordinary, ok := tgMessageActionStarGift(&domain.MessageStarGiftAction{
GiftID: 8001, Stars: 50, ConvertStars: 25, CanUpgrade: true, UpgradePriceStars: 75,

View file

@ -530,6 +530,15 @@ func (s *StarGiftStore) SetUnsaved(_ context.Context, ref domain.SavedStarGiftRe
for i := range s.gifts {
if s.savedStarGiftMatchesRef(s.gifts[i], ref) && s.gifts[i].LifecycleStatus.Live() {
s.gifts[i].Unsaved = unsaved
if unsaved && s.gifts[i].PinnedOrder > 0 {
removedOrder := s.gifts[i].PinnedOrder
s.gifts[i].PinnedOrder = 0
for j := range s.gifts {
if s.gifts[j].Owner == ref.Owner && s.gifts[j].PinnedOrder > removedOrder {
s.gifts[j].PinnedOrder--
}
}
}
return true, nil
}
}
@ -701,10 +710,16 @@ func (s *StarGiftStore) ReorderCollections(_ context.Context, owner domain.Peer,
func (s *StarGiftStore) SetPinned(_ context.Context, owner domain.Peer, savedGiftIDs []int64) error {
s.mu.Lock()
defer s.mu.Unlock()
if len(savedGiftIDs) > domain.MaxPinnedStarGifts {
return domain.ErrStarGiftCollectibleInvalid
}
ids, err := s.validCollectionGiftIDsLocked(owner, savedGiftIDs)
if err != nil {
return err
}
if len(ids) != len(savedGiftIDs) {
return domain.ErrStarGiftCollectibleInvalid
}
order := make(map[int64]int, len(ids))
for i, id := range ids {
order[id] = i + 1
@ -712,6 +727,9 @@ func (s *StarGiftStore) SetPinned(_ context.Context, owner domain.Peer, savedGif
for i := range s.gifts {
if s.gifts[i].Owner == owner {
s.gifts[i].PinnedOrder = order[s.gifts[i].ID]
if s.gifts[i].PinnedOrder > 0 {
s.gifts[i].Unsaved = false
}
}
}
return nil

View file

@ -47,6 +47,24 @@ func TestStarGiftProfilePinOrderAndPagination(t *testing.T) {
if !slices.Equal(got, want) {
t.Fatalf("paged order = %v, want %v", got, want)
}
if ok, err := store.SetUnsaved(ctx, domain.SavedStarGiftRef{Owner: owner, MsgID: 100}, true); err != nil || !ok {
t.Fatalf("hide pinned gift = %v err %v", ok, err)
}
hidden, found, err := store.GetByRef(ctx, domain.SavedStarGiftRef{Owner: owner, MsgID: 100})
if err != nil || !found || !hidden.Unsaved || hidden.PinnedOrder != 0 {
t.Fatalf("hidden pinned gift = %+v found %v err %v", hidden, found, err)
}
remaining, found, err := store.GetByRef(ctx, domain.SavedStarGiftRef{Owner: owner, MsgID: 102})
if err != nil || !found || remaining.PinnedOrder != 1 {
t.Fatalf("remaining pin = %+v found %v err %v", remaining, found, err)
}
if err := store.SetPinned(ctx, owner, []int64{ids[0], ids[2]}); err != nil {
t.Fatalf("repin hidden gift: %v", err)
}
repinned, found, err := store.GetByRef(ctx, domain.SavedStarGiftRef{Owner: owner, MsgID: 100})
if err != nil || !found || repinned.Unsaved || repinned.PinnedOrder != 1 {
t.Fatalf("repinned gift = %+v found %v err %v", repinned, found, err)
}
if err := store.SetPinned(ctx, owner, nil); err != nil {
t.Fatalf("clear pinned: %v", err)

View file

@ -649,11 +649,16 @@ LEFT JOIN unique_star_gifts u ON u.id=p.unique_gift_id
WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.lifecycle_status='active'
AND (p.saved_id::bigint=ANY($3::bigint[]) OR u.slug=ANY($4::text[]))`
if owner.Type == domain.PeerTypeUser {
query = `SELECT p.msg_id::bigint, COALESCE(u.slug, ''), p.id
query = `SELECT ref.msg_id, COALESCE(u.slug, ''), p.id
FROM peer_star_gifts p
LEFT JOIN unique_star_gifts u ON u.id=p.unique_gift_id
CROSS JOIN LATERAL (
SELECT p.msg_id::bigint AS msg_id
UNION ALL
SELECT r.msg_id::bigint FROM star_gift_user_message_refs r WHERE r.saved_gift_id=p.id
) ref
WHERE p.owner_peer_type=$1 AND p.owner_peer_id=$2 AND p.lifecycle_status='active'
AND (p.msg_id::bigint=ANY($3::bigint[])
AND (ref.msg_id=ANY($3::bigint[])
OR u.slug=ANY($4::text[]))`
}
rows, err := s.db.Query(ctx, query, string(owner.Type), owner.ID, values, slugs)
@ -744,15 +749,42 @@ func (s *StarGiftStore) SetUnsaved(ctx context.Context, ref domain.SavedStarGift
if !ref.Valid() {
return false, domain.ErrStarGiftNotFound
}
where, args := savedStarGiftRefWhere(ref)
args = append(args, unsaved)
tag, err := s.db.Exec(ctx, `
UPDATE peer_star_gifts SET unsaved = $4
WHERE `+where+` AND lifecycle_status='active'`, args...)
changed := false
err := withTx(ctx, s.db, "set star gift unsaved", func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(ref.Owner)); err != nil {
return err
}
where, args := savedStarGiftRefWhere(ref)
var savedID int64
var pinnedOrder int
err := tx.QueryRow(ctx, `SELECT id,pinned_order FROM peer_star_gifts WHERE `+where+` AND lifecycle_status='active' FOR UPDATE`, args...).Scan(&savedID, &pinnedOrder)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET unsaved=$2,pinned_order=CASE WHEN $2 THEN 0 ELSE pinned_order END WHERE id=$1`, savedID, unsaved); err != nil {
return err
}
if unsaved && pinnedOrder > 0 {
// The positive-order unique index is immediate. Move the bounded
// vector one vacant slot at a time so no transient duplicate order
// can be observed by PostgreSQL.
for order := pinnedOrder + 1; order <= domain.MaxPinnedStarGifts; order++ {
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=$4
WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND pinned_order=$3`, string(ref.Owner.Type), ref.Owner.ID, order, order-1); err != nil {
return err
}
}
}
changed = true
return nil
})
if err != nil {
return false, fmt.Errorf("set star gift unsaved: %w", err)
}
return tag.RowsAffected() > 0, nil
return changed, nil
}
func (s *StarGiftStore) MarkConverted(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
@ -836,7 +868,11 @@ func savedStarGiftRefWhere(ref domain.SavedStarGiftRef) (string, []any) {
return "owner_peer_type = $1 AND owner_peer_id = $2 AND saved_id = $3", args
default:
args = append(args, ref.MsgID)
return "owner_peer_type = $1 AND owner_peer_id = $2 AND msg_id = $3", args
return `owner_peer_type = $1 AND owner_peer_id = $2 AND (
msg_id = $3 OR EXISTS (
SELECT 1 FROM star_gift_user_message_refs r
WHERE r.saved_gift_id = id AND r.owner_user_id = $2 AND r.msg_id = $3
))`, args
}
}

View file

@ -690,6 +690,9 @@ func (s *StarGiftStore) ReorderCollections(ctx context.Context, owner domain.Pee
}
func (s *StarGiftStore) SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error {
if len(savedGiftIDs) > domain.MaxPinnedStarGifts {
return domain.ErrStarGiftCollectibleInvalid
}
return withTx(ctx, s.db, "set pinned star gifts", func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, starGiftCollectionLockKey(owner)); err != nil {
return err
@ -698,11 +701,14 @@ func (s *StarGiftStore) SetPinned(ctx context.Context, owner domain.Peer, savedG
if err != nil {
return err
}
if len(ids) != len(savedGiftIDs) {
return domain.ErrStarGiftCollectibleInvalid
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=0 WHERE owner_peer_type=$1 AND owner_peer_id=$2 AND pinned_order<>0`, string(owner.Type), owner.ID); err != nil {
return err
}
for order, id := range ids {
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=$2 WHERE id=$1`, id, order+1); err != nil {
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET pinned_order=$2,unsaved=false WHERE id=$1`, id, order+1); err != nil {
return err
}
}

View file

@ -116,13 +116,28 @@ func TestStarGiftCollectibleUpgradeAggregatePostgres(t *testing.T) {
t.Fatalf("owner upgrade service message = %+v", ownerMessage)
}
uniqueAction := ownerMessage.Media.ServiceAction.StarGiftUnique
if uniqueAction.SavedID != int64(saved.MsgID) {
t.Fatalf("unique action saved_id = %d, want stable source msg id %d", uniqueAction.SavedID, saved.MsgID)
if uniqueAction.SavedID != 0 || uniqueAction.Peer.Type != "" || uniqueAction.Peer.ID != 0 {
t.Fatalf("user unique action leaked channel peer/saved_id: %+v", uniqueAction)
}
senderUniqueAction := upgraded.Send.SenderMessage.Media.ServiceAction.StarGiftUnique
if senderUniqueAction == nil || senderUniqueAction.SavedID != 0 {
t.Fatalf("sender unique action leaked owner-only saved_id: %+v", senderUniqueAction)
}
if byOutput, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: ownerMessage.ID}); err != nil || !found || byOutput.ID != savedID {
t.Fatalf("owner upgrade output ref = %+v found %v err %v", byOutput, found, err)
}
var ownerAliasCount, senderAliasCount int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_user_message_refs
WHERE owner_user_id=$1 AND msg_id=$2 AND saved_gift_id=$3`, owner.ID, ownerMessage.ID, savedID).Scan(&ownerAliasCount); err != nil {
t.Fatalf("load owner upgrade output alias: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_user_message_refs
WHERE owner_user_id=$1 AND msg_id=$2`, sender.ID, ownerMessage.ID).Scan(&senderAliasCount); err != nil {
t.Fatalf("load sender upgrade output alias: %v", err)
}
if ownerAliasCount != 1 || senderAliasCount != 0 {
t.Fatalf("upgrade output aliases owner=%d sender=%d, want owner-only", ownerAliasCount, senderAliasCount)
}
ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID)
if ownerSourceEdit.Event.Pts <= ownerMessage.Pts || ownerSourceEdit.Message.Media == nil ||
ownerSourceEdit.Message.Media.ServiceAction == nil || ownerSourceEdit.Message.Media.ServiceAction.StarGift == nil ||

View file

@ -11,6 +11,7 @@ import (
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store"
)
const (
@ -18,6 +19,37 @@ const (
maxStarGiftAuctionAcquired = 1000
)
// starGiftCraftOutputIntent is the immutable message intent committed with a
// successful craft outcome. The aggregate may subsequently be hidden, listed
// or otherwise edited; an exact retry must still use the first intent and its
// fingerprint instead of rebuilding a different message from mutable state.
type starGiftCraftOutputIntent struct {
Media *domain.MessageMedia
Fingerprint []byte
Date int
SavedGiftID int64
}
func starGiftCraftOutputMedia(userID int64, gift domain.UniqueStarGift, saved domain.SavedStarGift) *domain.MessageMedia {
return &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGiftUnique, StarGiftUnique: &domain.MessageStarGiftUniqueAction{
Gift: gift, FromUserID: userID, Saved: !saved.Unsaved, Craft: true,
CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars, CanTransferAt: saved.CanTransferAt,
CanResellAt: saved.CanResellAt, DropOriginalDetailsStars: saved.DropOriginalDetailsStars,
CanCraftAt: saved.CanCraftAt,
},
}}
}
func starGiftCraftOutputRequest(req domain.StarGiftCraftRequest, intent starGiftCraftOutputIntent) domain.SendPrivateTextRequest {
return domain.SendPrivateTextRequest{
SenderUserID: req.UserID, RecipientUserID: req.UserID,
RandomID: lifecycleCommandRandomID("craft", req.UserID, req.CommandKey), Date: intent.Date,
OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.UserID,
IdempotencyFingerprint: append([]byte(nil), intent.Fingerprint...), Media: intent.Media,
}
}
func defaultStarGiftCraftDraw(upper int) (int, error) {
if upper <= 0 {
return 0, domain.ErrStarGiftCraftUnavailable
@ -158,7 +190,8 @@ func (s *StarGiftLifecycleStore) ListCraftStarGifts(ctx context.Context, userID,
}
args := []any{userID, giftID}
where := `p.owner_peer_type='user' AND p.owner_peer_id=$1 AND p.gift_id=$2
AND p.lifecycle_status='active' AND p.unique_gift_id IS NOT NULL AND p.can_craft_at<=EXTRACT(EPOCH FROM now())::integer
AND p.lifecycle_status='active' AND p.unique_gift_id IS NOT NULL
AND p.can_craft_at>0 AND p.can_craft_at<=EXTRACT(EPOCH FROM now())::integer
AND NOT u.burned AND u.owner_address='' AND u.craft_chance_permille>0
AND EXISTS (SELECT 1 FROM star_gift_collectible_models m
WHERE m.collectible_revision_id=u.collectible_revision_id AND m.crafted)`
@ -234,11 +267,11 @@ func (s *StarGiftLifecycleStore) CraftStarGift(ctx context.Context, req domain.S
// A committed failed craft has already moved every input out of the active
// lifecycle. Consult the immutable receipt before active-gift resolution so
// an exact transport retry can still replay the same terminal result.
if replay, found, err := s.loadCraftReplay(ctx, req); err != nil || found {
if replay, output, found, err := s.loadCraftReplay(ctx, req); err != nil || found {
if err != nil || !replay.Success {
return replay, err
}
return s.deliverCraftSuccess(ctx, req, replay)
return s.deliverCraftSuccess(ctx, req, replay, output)
}
savedIDs, err := NewStarGiftStore(s.db).ResolveSavedIDs(ctx, owner, req.Refs)
if err != nil {
@ -248,7 +281,7 @@ func (s *StarGiftLifecycleStore) CraftStarGift(ctx context.Context, req domain.S
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
}
var result domain.StarGiftCraftResult
var resultUniqueID int64
var output starGiftCraftOutputIntent
err = withTx(ctx, s.db, "craft star gift", func(tx pgx.Tx) error {
lockedRows, err := tx.Query(ctx, `SELECT id FROM peer_star_gifts WHERE id=ANY($1::bigint[]) ORDER BY id FOR UPDATE`, sortedUniqueInt64(savedIDs))
if err != nil {
@ -269,7 +302,7 @@ func (s *StarGiftLifecycleStore) CraftStarGift(ctx context.Context, req domain.S
chance := 0
for i := range req.Refs {
saved, err := lockSavedStarGiftByID(ctx, tx, savedIDs[i])
if err != nil || !saved.LifecycleStatus.Live() || saved.UniqueGiftID == 0 || saved.CanCraftAt > req.Date {
if err != nil || !saved.LifecycleStatus.Live() || saved.UniqueGiftID == 0 || saved.CanCraftAt <= 0 || saved.CanCraftAt > req.Date {
return domain.ErrStarGiftCraftUnavailable
}
unique, found, err := NewStarGiftStore(tx).UniqueByID(ctx, saved.UniqueGiftID)
@ -374,111 +407,164 @@ WHERE id=ANY($1::bigint[])`, savedIDs[burnFrom:]); err != nil {
}
result.SourceEdits = sourceEdits
var resultID any
var outputMediaJSON any
var outputFingerprint any
if result.Success {
resultID = firstUniqueID
resultUniqueID = firstUniqueID
gift, found, err := NewStarGiftStore(tx).UniqueByID(ctx, firstUniqueID)
if err != nil || !found {
if err != nil {
return err
}
return domain.ErrStarGiftCraftUnavailable
}
saved, found, err := savedStarGiftByID(ctx, tx, firstSavedID)
if err != nil || !found || saved.Owner != owner || saved.UniqueGiftID != gift.ID ||
!saved.LifecycleStatus.Live() || gift.Owner != owner || gift.Burned || !gift.Crafted {
if err != nil {
return err
}
return domain.ErrStarGiftCraftUnavailable
}
media := starGiftCraftOutputMedia(req.UserID, gift, saved)
intent := starGiftCraftOutputIntent{Media: media, Date: req.Date, SavedGiftID: saved.ID}
fingerprint, err := store.PrivateSendFingerprint(starGiftCraftOutputRequest(req, intent))
if err != nil {
return fmt.Errorf("fingerprint crafted gift output: %w", err)
}
mediaJSON, err := encodeMessageMedia(media)
if err != nil {
return fmt.Errorf("encode crafted gift output: %w", err)
}
intent.Fingerprint = fingerprint
output = intent
outputMediaJSON = mediaJSON
outputFingerprint = fingerprint
giftCopy := gift
result.Gift = &giftCopy
}
_, err = tx.Exec(ctx, `INSERT INTO star_gift_craft_commands(user_id,command_key,input_unique_gift_ids,gift_id,
success,result_unique_gift_id,chance_permille,created_at,source_edit_pts) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID,
strings.TrimSpace(req.CommandKey), uniqueIDs, giftID, result.Success, resultID, chance, req.Date, sourceEditPTS)
success,result_unique_gift_id,chance_permille,created_at,source_edit_pts,output_media,output_fingerprint)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, req.UserID, strings.TrimSpace(req.CommandKey), uniqueIDs,
giftID, result.Success, resultID, chance, req.Date, sourceEditPTS, outputMediaJSON, outputFingerprint)
return err
})
if err != nil {
if isUniqueViolation(err) {
if replay, found, replayErr := s.loadCraftReplay(ctx, req); replayErr != nil || found {
if replay, replayOutput, found, replayErr := s.loadCraftReplay(ctx, req); replayErr != nil || found {
if replayErr == nil && found && replay.Success {
return s.deliverCraftSuccess(ctx, req, replay, replayOutput)
}
return replay, replayErr
}
}
return domain.StarGiftCraftResult{}, err
}
if result.Success {
gift, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, resultUniqueID)
if err != nil || !found {
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
}
result.Gift = &gift
}
if result.Success {
return s.deliverCraftSuccess(ctx, req, result)
return s.deliverCraftSuccess(ctx, req, result, output)
}
return result, nil
}
func (s *StarGiftLifecycleStore) deliverCraftSuccess(ctx context.Context, req domain.StarGiftCraftRequest, result domain.StarGiftCraftResult) (domain.StarGiftCraftResult, error) {
if result.Gift == nil || s.messages == nil {
func (s *StarGiftLifecycleStore) deliverCraftSuccess(ctx context.Context, req domain.StarGiftCraftRequest, result domain.StarGiftCraftResult, output starGiftCraftOutputIntent) (domain.StarGiftCraftResult, error) {
if result.Gift == nil || s.messages == nil || output.Media == nil || output.Date <= 0 || output.SavedGiftID <= 0 ||
store.ValidateSendFingerprint(output.Fingerprint, "crafted gift output") != nil {
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
}
saved, found, err := savedStarGiftByUniqueID(ctx, s.db, result.Gift.ID)
if err != nil || !found || saved.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) {
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
}
sent, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: req.UserID,
RecipientUserID: req.UserID, RandomID: lifecycleCommandRandomID("craft", req.UserID, req.CommandKey), Date: req.Date,
OriginAuthKeyID: req.OriginAuthKeyID, OriginSessionID: req.OriginSessionID, OriginUserID: req.UserID,
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGiftUnique, StarGiftUnique: &domain.MessageStarGiftUniqueAction{
Gift: *result.Gift, FromUserID: req.UserID, Peer: saved.Owner, Saved: !saved.Unsaved, Craft: true,
CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars, CanTransferAt: saved.CanTransferAt,
CanResellAt: saved.CanResellAt, DropOriginalDetailsStars: saved.DropOriginalDetailsStars,
CanCraftAt: saved.CanCraftAt}}}})
messageReq := starGiftCraftOutputRequest(req, output)
hooks := privateSendTxHooks{after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
return registerUserStarGiftMessageRef(ctx, tx, req.UserID, sent.SenderMessage.ID, output.SavedGiftID, result.Gift.ID)
}}
sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks)
if err != nil {
return domain.StarGiftCraftResult{}, err
}
registered, err := userStarGiftMessageRefMatches(ctx, s.db, req.UserID, sent.SenderMessage.ID, output.SavedGiftID)
if err != nil || !registered {
if err != nil {
return domain.StarGiftCraftResult{}, err
}
return domain.StarGiftCraftResult{}, domain.ErrStarGiftCraftUnavailable
}
result.Send = sent
result.Duplicate = result.Duplicate || sent.Duplicate
return result, nil
}
func (s *StarGiftLifecycleStore) loadCraftReplay(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, bool, error) {
func (s *StarGiftLifecycleStore) loadCraftReplay(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, starGiftCraftOutputIntent, bool, error) {
var success bool
var resultID *int64
var chance int
var inputUniqueIDs []int64
var sourceEditPTS []int32
err := s.db.QueryRow(ctx, `SELECT input_unique_gift_ids,success,result_unique_gift_id,chance_permille,source_edit_pts
var createdAt int
var outputMediaJSON, outputFingerprint []byte
err := s.db.QueryRow(ctx, `SELECT input_unique_gift_ids,success,result_unique_gift_id,chance_permille,source_edit_pts,
created_at,output_media,output_fingerprint
FROM star_gift_craft_commands WHERE user_id=$1 AND command_key=$2`,
req.UserID, strings.TrimSpace(req.CommandKey)).Scan(&inputUniqueIDs, &success, &resultID, &chance, &sourceEditPTS)
req.UserID, strings.TrimSpace(req.CommandKey)).Scan(&inputUniqueIDs, &success, &resultID, &chance, &sourceEditPTS,
&createdAt, &outputMediaJSON, &outputFingerprint)
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarGiftCraftResult{}, false, nil
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, nil
}
if err != nil {
return domain.StarGiftCraftResult{}, false, err
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, err
}
if len(req.Refs) != len(inputUniqueIDs) || len(req.Refs) != len(sourceEditPTS) {
return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
}
savedIDs := make([]int64, 0, len(inputUniqueIDs))
owner := domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}
for i, uniqueID := range inputUniqueIDs {
saved, found, err := savedStarGiftByUniqueID(ctx, s.db, uniqueID)
if err != nil || !found || saved.Owner != owner || saved.UniqueGiftID != uniqueID {
return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
}
ref := req.Refs[i]
if ref.Owner != owner || ref.Slug == "" && ref.MsgID != saved.MsgID {
return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable
if ref.Owner != owner {
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
}
if ref.Slug != "" {
unique, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, uniqueID)
if err != nil || !found || !strings.EqualFold(ref.Slug, unique.Slug) {
return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
}
} else if ref.MsgID != saved.MsgID {
matches, err := userStarGiftMessageRefMatches(ctx, s.db, req.UserID, ref.MsgID, saved.ID)
if err != nil {
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, err
}
if !matches {
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
}
}
savedIDs = append(savedIDs, saved.ID)
}
sourceEdits, err := s.loadCraftInputMessageReplays(ctx, req, savedIDs, sourceEditPTS)
if err != nil {
return domain.StarGiftCraftResult{}, false, err
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, err
}
result := domain.StarGiftCraftResult{Success: success, Chance: chance, SourceEdits: sourceEdits, Duplicate: true}
if resultID != nil {
gift, found, err := NewStarGiftStore(s.db).UniqueByID(ctx, *resultID)
if err != nil || !found {
return domain.StarGiftCraftResult{}, false, domain.ErrStarGiftCraftUnavailable
if !success {
if resultID != nil || len(outputMediaJSON) != 0 || len(outputFingerprint) != 0 {
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
}
result.Gift = &gift
return result, starGiftCraftOutputIntent{}, true, nil
}
return result, true, nil
if resultID == nil || createdAt <= 0 || store.ValidateSendFingerprint(outputFingerprint, "crafted gift replay") != nil {
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
}
media, err := decodeMessageMedia(string(outputMediaJSON))
if err != nil || media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil ||
media.ServiceAction.Kind != domain.MessageServiceActionStarGiftUnique || media.ServiceAction.StarGiftUnique == nil ||
!media.ServiceAction.StarGiftUnique.Craft || media.ServiceAction.StarGiftUnique.Gift.ID != *resultID {
return domain.StarGiftCraftResult{}, starGiftCraftOutputIntent{}, false, domain.ErrStarGiftCraftUnavailable
}
gift := media.ServiceAction.StarGiftUnique.Gift
result.Gift = &gift
output := starGiftCraftOutputIntent{Media: media, Fingerprint: append([]byte(nil), outputFingerprint...),
Date: createdAt, SavedGiftID: savedIDs[0]}
return result, output, true, nil
}
func chooseCraftedModel(ctx context.Context, tx pgx.Tx, revisionID int64) (int64, error) {

View file

@ -142,6 +142,26 @@ func TestStarGiftStorePostgres(t *testing.T) {
if !slices.Equal(gotMsgIDs, wantMsgIDs) {
t.Fatalf("pinned paged msg ids = %v, want %v", gotMsgIDs, wantMsgIDs)
}
// Hiding a pinned gift atomically unpins it and compacts the remaining
// vector. Pinning it again makes it visible in the same owner transaction.
if ok, err := st.SetUnsaved(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100}, true); err != nil || !ok {
t.Fatalf("hide pinned gift = %v err %v", ok, err)
}
hiddenPinned, found, err := st.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100})
if err != nil || !found || !hiddenPinned.Unsaved || hiddenPinned.PinnedOrder != 0 {
t.Fatalf("hidden pinned gift = %+v found %v err %v", hiddenPinned, found, err)
}
remainingPinned, found, err := st.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 102})
if err != nil || !found || remainingPinned.PinnedOrder != 1 {
t.Fatalf("remaining pin after compaction = %+v found %v err %v", remainingPinned, found, err)
}
if err := st.SetPinned(ctx, ownerPeer, []int64{savedIDs[0], savedIDs[2]}); err != nil {
t.Fatalf("repin hidden gift: %v", err)
}
repinned, found, err := st.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100})
if err != nil || !found || repinned.Unsaved || repinned.PinnedOrder != 1 {
t.Fatalf("repinned hidden gift = %+v found %v err %v", repinned, found, err)
}
if err := st.SetPinned(ctx, ownerPeer, nil); err != nil {
t.Fatalf("clear pinned profile order: %v", err)
}

View file

@ -389,6 +389,7 @@ func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domai
}},
}
var result domain.StarGiftTransferResult
var sourceSaved domain.SavedStarGift
hooks := privateSendTxHooks{
before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error {
saved, unique, err := lockTransferableStarGift(ctx, tx, req.ActorUserID, req.Ref, req.Date)
@ -414,6 +415,7 @@ func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domai
if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil {
return err
}
sourceSaved = saved
unique.Owner = req.To
saved.Owner = req.To
result.Saved, result.Unique, result.Balance = saved, unique, balance
@ -433,6 +435,9 @@ func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domai
can_transfer_at=0 WHERE id=$1`, result.Saved.ID, req.To.ID, req.ActorUserID, msgID, req.Date); err != nil {
return err
}
if err := registerUserStarGiftMessageRef(ctx, tx, req.To.ID, msgID, result.Saved.ID, result.Unique.ID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `INSERT INTO star_gift_transfer_commands(actor_user_id,command_key,unique_gift_id,
from_peer_type,from_peer_id,to_peer_type,to_peer_id,charge_stars,balance_after,created_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, req.ActorUserID, strings.TrimSpace(req.CommandKey), result.Unique.ID,
@ -441,6 +446,10 @@ func (s *StarGiftLifecycleStore) TransferStarGift(ctx context.Context, req domai
}
result.Saved.MsgID, result.Saved.SavedID, result.Saved.UpgradeMsgID, result.Saved.Date = msgID, 0, msgID, req.Date
result.Saved.FromUserID = req.ActorUserID
if sourceSaved.Owner.Type == domain.PeerTypeUser {
_, err := s.retireUserStarGiftMessagesTx(ctx, tx, sourceSaved, result.Unique, req.Date)
return err
}
return nil
},
}
@ -502,6 +511,7 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req
}
var result domain.StarGiftTransferResult
var commissionAmount int64
var sourceSaved domain.SavedStarGift
hooks := privateSendTxHooks{
before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error {
var listingCurrency, sellerType string
@ -557,12 +567,15 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req
gift.ResellAmount = nil
gift.LastSaleDate = req.Date
gift.LastSaleAmount = &domain.StarGiftAmount{Currency: req.Amount.Currency, Amount: req.Amount.Amount}
sourceSaved = saved
saved.Owner = req.To
if req.To.Type == domain.PeerTypeChannel {
saved.MsgID, saved.SavedID = 0, saved.ID
}
result.Saved, result.Unique, result.Balance = saved, gift, balance
send.Media.ServiceAction.StarGiftUnique = transferUniqueAction(gift, messageSenderID, req.To, saved)
resaleAmount := req.Amount
send.Media.ServiceAction.StarGiftUnique.ResaleAmount = &resaleAmount
return nil
},
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
@ -578,6 +591,11 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req
WHERE id=$1`, result.Saved.ID, string(req.To.Type), req.To.ID, messageSenderID, msgID, savedID, req.Date); err != nil {
return err
}
if req.To.Type == domain.PeerTypeUser {
if err := registerUserStarGiftMessageRef(ctx, tx, req.To.ID, msgID, result.Saved.ID, result.Unique.ID); err != nil {
return err
}
}
if _, err := tx.Exec(ctx, `INSERT INTO star_gift_sales(unique_gift_id,seller_peer_type,seller_peer_id,
buyer_peer_type,buyer_peer_id,currency,amount,commission_amount,sold_at,command_key)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, result.Unique.ID, string(seller.Type), seller.ID,
@ -601,6 +619,11 @@ func (s *StarGiftLifecycleStore) PurchaseResaleStarGift(ctx context.Context, req
}
result.Saved.MsgID, result.Saved.SavedID, result.Saved.UpgradeMsgID, result.Saved.Date = msgID, savedID, msgID, req.Date
result.Saved.FromUserID = messageSenderID
if sourceSaved.Owner.Type == domain.PeerTypeUser {
if _, err := s.retireUserStarGiftMessagesTx(ctx, tx, sourceSaved, result.Unique, req.Date); err != nil {
return err
}
}
return updateStarGiftResaleProjection(ctx, tx, result.Unique.GiftID)
},
}
@ -803,7 +826,7 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d
offer.Gift = gift
actionKind := domain.MessageServiceActionStarGiftUnique
action := &domain.MessageServiceAction{Kind: actionKind, StarGiftUnique: &domain.MessageStarGiftUniqueAction{
Gift: gift, FromUserID: req.OwnerUserID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: offer.BuyerUserID},
Gift: gift, FromUserID: req.OwnerUserID,
Transferred: true, FromOffer: true, Saved: true,
}}
if req.Decline {
@ -816,6 +839,7 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: action}}
var result domain.StarGiftOfferResult
var commissionAmount int64
var sourceSaved domain.SavedStarGift
hooks := privateSendTxHooks{
before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error {
locked, err := scanStarGiftOffer(tx.QueryRow(ctx, `SELECT id,buyer_user_id,owner_peer_type,owner_peer_id,unique_gift_id,
@ -874,10 +898,15 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d
current.LastSaleDate = req.Date
current.LastSaleAmount = &locked.Price
locked.Status, locked.ResolvedAt, locked.Gift = "accepted", req.Date, current
sourceSaved = saved
saved.Owner = domain.Peer{Type: domain.PeerTypeUser, ID: locked.BuyerUserID}
result.Offer = locked
result.Unique = current
result.Saved = saved
send.Media.ServiceAction.StarGiftUnique.Gift = current
send.Media.ServiceAction.StarGiftUnique = transferUniqueAction(current, req.OwnerUserID, saved.Owner, saved)
send.Media.ServiceAction.StarGiftUnique.FromOffer = true
resaleAmount := locked.Price
send.Media.ServiceAction.StarGiftUnique.ResaleAmount = &resaleAmount
return nil
},
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
@ -890,6 +919,10 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d
WHERE id=$1`, result.Saved.ID, result.Offer.BuyerUserID, req.OwnerUserID, msgID, req.Date); err != nil {
return err
}
if err := registerUserStarGiftMessageRef(ctx, tx, result.Offer.BuyerUserID, msgID,
result.Saved.ID, result.Unique.ID); err != nil {
return err
}
result.Saved.Owner = domain.Peer{Type: domain.PeerTypeUser, ID: result.Offer.BuyerUserID}
result.Saved.FromUserID, result.Saved.MsgID, result.Saved.SavedID, result.Saved.UpgradeMsgID, result.Saved.Date = req.OwnerUserID, msgID, 0, msgID, req.Date
if _, err := tx.Exec(ctx, `INSERT INTO star_gift_sales(unique_gift_id,seller_peer_type,seller_peer_id,
@ -899,6 +932,9 @@ func (s *StarGiftLifecycleStore) ResolveStarGiftOffer(ctx context.Context, req d
req.Date, fmt.Sprintf("offer:%d", result.Offer.ID)); err != nil {
return err
}
if _, err := s.retireUserStarGiftMessagesTx(ctx, tx, sourceSaved, result.Unique, req.Date); err != nil {
return err
}
return updateStarGiftResaleProjection(ctx, tx, result.Offer.Gift.GiftID)
},
}
@ -1077,6 +1113,7 @@ func (s *StarGiftLifecycleStore) transferStarGiftWithoutPrivateMessage(ctx conte
if err := removeSavedGiftFromCollections(ctx, tx, saved.Owner, saved.ID); err != nil {
return err
}
sourceSaved := saved
if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET owner_peer_type='channel',owner_peer_id=$2,updated_at=now() WHERE id=$1`, unique.ID, req.To.ID); err != nil {
return err
}
@ -1100,6 +1137,11 @@ func (s *StarGiftLifecycleStore) transferStarGiftWithoutPrivateMessage(ctx conte
return err
}
result.Saved, result.Unique, result.Balance = saved, unique, balance
if sourceSaved.Owner.Type == domain.PeerTypeUser {
if _, err := s.retireUserStarGiftMessagesTx(ctx, tx, sourceSaved, unique, req.Date); err != nil {
return err
}
}
return nil
})
return result, err
@ -1148,17 +1190,23 @@ func ensureNoStarGiftMarketConflict(ctx context.Context, tx pgx.Tx, uniqueID int
}
func transferUniqueAction(unique domain.UniqueStarGift, fromUserID int64, to domain.Peer, saved domain.SavedStarGift) *domain.MessageStarGiftUniqueAction {
peer := to
savedID := saved.SavedID
canCraftAt := saved.CanCraftAt
if to.Type == domain.PeerTypeUser {
// For a user-owned transferred gift the action message itself becomes
// inputSavedStarGiftUser.msg_id. A channel saved_id belongs to a different
// identity namespace and must never leak into the recipient's user view.
// peer and saved_id are a shared channel-only TL flag. A user-owned
// transferred gift is managed by this action message's id.
peer = domain.Peer{}
savedID = 0
} else {
// Preserve the durable entitlement for a future transfer back to a user,
// but keep channel Craft hidden until its write/update path exists.
canCraftAt = 0
}
return &domain.MessageStarGiftUniqueAction{Gift: unique, FromUserID: fromUserID, Peer: to,
return &domain.MessageStarGiftUniqueAction{Gift: unique, FromUserID: fromUserID, Peer: peer,
SavedID: savedID, Transferred: true, Saved: true, CanExportAt: saved.CanExportAt,
TransferStars: saved.TransferStars, CanTransferAt: saved.CanTransferAt, CanResellAt: saved.CanResellAt,
DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: saved.CanCraftAt}
DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: canCraftAt}
}
func (s *StarGiftLifecycleStore) debitLifecycleAmount(ctx context.Context, tx pgx.Tx, userID int64, amount domain.StarGiftAmount,
@ -1468,15 +1516,22 @@ WHERE provider_request_id=$1 FOR UPDATE`, providerRequestID).Scan(&uniqueID, &ow
requestHash := sha256.Sum256([]byte(providerRequestID))
giftAddress := fmt.Sprintf("telesrv-gift:%s:%x", unique.Slug, requestHash[:8])
if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts SET owner_peer_type=NULL,owner_peer_id=NULL,
owner_address=$2,gift_address=$3,updated_at=now() WHERE id=$1`, uniqueID, ownerAddress, giftAddress); err != nil {
owner_address=$2,gift_address=$3,craft_chance_permille=0,updated_at=now() WHERE id=$1`, uniqueID, ownerAddress, giftAddress); err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET lifecycle_status='exported',unsaved=true,pinned_order=0 WHERE id=$1`, saved.ID); err != nil {
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET lifecycle_status='exported',unsaved=true,pinned_order=0,can_craft_at=0 WHERE id=$1`, saved.ID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE star_gift_withdrawal_requests SET status='completed',completed_at=$2 WHERE provider_request_id=$1`, providerRequestID, date); err != nil {
return err
}
unique.Owner = domain.Peer{}
unique.OwnerAddress = ownerAddress
unique.GiftAddress = giftAddress
unique.CraftChancePermille = 0
if _, err := s.retireUserStarGiftMessagesTx(ctx, tx, saved, unique, date); err != nil {
return err
}
return updateStarGiftResaleProjection(ctx, tx, unique.GiftID)
})
if err != nil {

View file

@ -140,14 +140,61 @@ WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, prepaid.Send.RecipientMessa
t.Fatalf("upgrade prepaid gift: %v", err)
}
if upgraded.Saved.TransferStars != 25 || upgraded.Saved.DropOriginalDetailsStars != 25 ||
upgraded.Saved.CanCraftAt != now+2 ||
upgraded.Unique.CraftChancePermille != 500 || !upgraded.Unique.KeepOriginalDetails {
t.Fatalf("issued lifecycle snapshot = saved %+v unique %+v", upgraded.Saved, upgraded.Unique)
}
readinessTx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin craft readiness guard probe: %v", err)
}
if _, err = readinessTx.Exec(ctx, `UPDATE peer_star_gifts SET can_craft_at=0 WHERE id=$1`, upgraded.Saved.ID); err != nil {
_ = readinessTx.Rollback(ctx)
t.Fatalf("stage mismatched craft readiness: %v", err)
}
if _, err = readinessTx.Exec(ctx, `SET CONSTRAINTS ALL IMMEDIATE`); err == nil {
_ = readinessTx.Rollback(ctx)
t.Fatal("deferred guard accepted positive craft chance with zero readiness")
}
_ = readinessTx.Rollback(ctx)
chanceTx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin craft chance guard probe: %v", err)
}
if _, err = chanceTx.Exec(ctx, `UPDATE unique_star_gifts SET craft_chance_permille=0 WHERE id=$1`, upgraded.Unique.ID); err != nil {
_ = chanceTx.Rollback(ctx)
t.Fatalf("stage mismatched craft chance: %v", err)
}
if _, err = chanceTx.Exec(ctx, `SET CONSTRAINTS ALL IMMEDIATE`); err == nil {
_ = chanceTx.Rollback(ctx)
t.Fatal("deferred guard accepted positive readiness with zero craft chance")
}
_ = chanceTx.Rollback(ctx)
terminalTx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin atomic craft terminal guard probe: %v", err)
}
if _, err = terminalTx.Exec(ctx, `UPDATE unique_star_gifts SET craft_chance_permille=0 WHERE id=$1`, upgraded.Unique.ID); err != nil {
_ = terminalTx.Rollback(ctx)
t.Fatalf("stage terminal craft chance: %v", err)
}
if _, err = terminalTx.Exec(ctx, `UPDATE peer_star_gifts SET can_craft_at=0 WHERE id=$1`, upgraded.Saved.ID); err != nil {
_ = terminalTx.Rollback(ctx)
t.Fatalf("stage terminal craft readiness: %v", err)
}
if _, err = terminalTx.Exec(ctx, `SET CONSTRAINTS ALL IMMEDIATE`); err != nil {
_ = terminalTx.Rollback(ctx)
t.Fatalf("deferred guard rejected atomic craft terminal state: %v", err)
}
if err = terminalTx.Rollback(ctx); err != nil {
t.Fatalf("rollback atomic craft terminal guard probe: %v", err)
}
upgradeAction := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
senderUpgradeAction := upgraded.Send.SenderMessage.Media.ServiceAction.StarGiftUnique
ownerSourceEdit := upgradedSourceEditForUser(upgraded, owner.ID)
if upgradeAction == nil || upgradeAction.SavedID != int64(purchased.Saved.MsgID) ||
senderUpgradeAction == nil || senderUpgradeAction.SavedID != 0 ||
if upgradeAction == nil || upgradeAction.SavedID != 0 || upgradeAction.Peer.Type != "" || upgradeAction.Peer.ID != 0 ||
upgradeAction.CanCraftAt != now+2 || senderUpgradeAction == nil || senderUpgradeAction.SavedID != 0 ||
senderUpgradeAction.CanCraftAt != now+2 ||
ownerSourceEdit.Message.Media == nil || ownerSourceEdit.Message.Media.ServiceAction == nil ||
ownerSourceEdit.Message.Media.ServiceAction.StarGift == nil ||
ownerSourceEdit.Message.Media.ServiceAction.StarGift.UpgradeMsgID != upgraded.Saved.UpgradeMsgID ||
@ -307,6 +354,37 @@ WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, upgraded.Send.RecipientMess
if err != nil || transferred.Unique.Owner != ownerPeer || transferred.Saved.TransferStars != 25 || transferred.Balance.Balance != 9975 {
t.Fatalf("paid transfer = %+v err %v", transferred, err)
}
var retiredSourceMediaJSON string
var retiredSourcePTS int
if err := pool.QueryRow(ctx, `SELECT media::text,pts FROM message_boxes
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, resaleBuyer.ID, resold.Saved.MsgID).
Scan(&retiredSourceMediaJSON, &retiredSourcePTS); err != nil {
t.Fatalf("load retired transfer source projection: %v", err)
}
retiredSourceMedia, err := decodeMessageMedia(retiredSourceMediaJSON)
if err != nil || retiredSourceMedia == nil || retiredSourceMedia.ServiceAction == nil ||
retiredSourceMedia.ServiceAction.StarGiftUnique == nil {
t.Fatalf("decode retired transfer source projection: media=%+v err=%v", retiredSourceMedia, err)
}
retiredSourceAction := retiredSourceMedia.ServiceAction.StarGiftUnique
if retiredSourceAction.Gift.Owner != ownerPeer || retiredSourceAction.Gift.CraftChancePermille != 0 ||
!retiredSourceAction.Transferred || retiredSourceAction.Saved || retiredSourceAction.CanCraftAt != 0 ||
retiredSourceAction.CanExportAt != 0 || retiredSourceAction.TransferStars != 0 ||
retiredSourceAction.CanTransferAt != 0 || retiredSourceAction.CanResellAt != 0 ||
retiredSourceAction.DropOriginalDetailsStars != 0 || retiredSourceAction.ResaleAmount != nil {
t.Fatalf("retired transfer source remained actionable: %+v", retiredSourceAction)
}
var retiredEventCount, retiredOutboxCount int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM user_update_events
WHERE user_id=$1 AND pts=$2 AND event_type='edit_message' AND message_box_id=$3`, resaleBuyer.ID, retiredSourcePTS, resold.Saved.MsgID).
Scan(&retiredEventCount); err != nil || retiredEventCount != 1 {
t.Fatalf("retired transfer source event count=%d err=%v", retiredEventCount, err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox
WHERE target_user_id=$1 AND pts=$2 AND event_type='edit_message'`, resaleBuyer.ID, retiredSourcePTS).
Scan(&retiredOutboxCount); err != nil || retiredOutboxCount != 1 {
t.Fatalf("retired transfer source outbox count=%d err=%v", retiredOutboxCount, err)
}
clearedUser, found, err := users.ByID(ctx, resaleBuyer.ID)
if err != nil || !found || !clearedUser.EmojiStatus().Empty() {
t.Fatalf("transferred collectible status was not cleared: user=%+v found=%v err=%v", clearedUser, found, err)
@ -381,13 +459,13 @@ WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, resaleBu
if _, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{
{Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID},
{Owner: ownerPeer, Slug: secondUpgrade.Unique.Slug},
}); !errors.Is(err, domain.ErrStarGiftNotFound) {
t.Fatalf("upgrade message id lookup err = %v, want ErrStarGiftNotFound", err)
}); !errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
t.Fatalf("duplicate upgrade output/slug identities err = %v", err)
}
if saved, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{
Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID,
}); err != nil || found {
t.Fatalf("upgrade message id resolved a gift: saved=%+v found=%v err=%v", saved, found, err)
}); err != nil || !found || saved.ID != secondUpgrade.Saved.ID {
t.Fatalf("upgrade output message id failed to resolve: saved=%+v found=%v err=%v", saved, found, err)
}
if _, err := gifts.ResolveSavedIDs(ctx, ownerPeer, []domain.SavedStarGiftRef{
{Owner: ownerPeer, MsgID: secondUpgrade.Saved.MsgID},
@ -406,6 +484,15 @@ WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, resaleBu
if err != nil || !crafted.Success || crafted.Chance != 1000 || crafted.Gift == nil || !crafted.Gift.Crafted || crafted.Send.RecipientMessage.ID <= 0 {
t.Fatalf("craft result = %+v err %v", crafted, err)
}
craftOutputAction := crafted.Send.SenderMessage.Media.ServiceAction.StarGiftUnique
if craftOutputAction == nil || craftOutputAction.Peer.Type != "" || craftOutputAction.Peer.ID != 0 || craftOutputAction.SavedID != 0 || !craftOutputAction.Craft {
t.Fatalf("craft output action leaked channel identity: %+v", craftOutputAction)
}
if byOutput, found, err := gifts.GetByRef(ctx, domain.SavedStarGiftRef{
Owner: ownerPeer, MsgID: crafted.Send.SenderMessage.ID,
}); err != nil || !found || byOutput.ID != transferred.Saved.ID || byOutput.UniqueGiftID != crafted.Gift.ID {
t.Fatalf("craft output message ref = %+v found %v err %v", byOutput, found, err)
}
craftedInputEdit := craftedSourceEditForUserAndGift(crafted, owner.ID, transferred.Unique.ID)
craftedInputAction := starGiftUniqueActionFromEdit(craftedInputEdit)
burnedInputEdit := craftedSourceEditForUserAndGift(crafted, owner.ID, secondUpgrade.Unique.ID)
@ -446,6 +533,40 @@ WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, edit.Message.ID).Scan(&shar
craftedSourceEditForUserAndGift(craftedReplay, owner.ID, secondUpgrade.Unique.ID).Event.Pts != burnedInputEdit.Event.Pts {
t.Fatalf("craft success replay = %+v err %v", craftedReplay, err)
}
craftOutputRef := domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: crafted.Send.SenderMessage.ID}
if changed, err := gifts.SetUnsaved(ctx, craftOutputRef, true); err != nil || !changed {
t.Fatalf("hide crafted output before replay: changed=%v err=%v", changed, err)
}
hiddenReplay, err := lifecycle.CraftStarGift(ctx, craftReq)
if err != nil || !hiddenReplay.Duplicate || hiddenReplay.Send.SenderMessage.ID != crafted.Send.SenderMessage.ID {
t.Fatalf("craft replay after hide = %+v err %v", hiddenReplay, err)
}
if changed, err := gifts.SetUnsaved(ctx, craftOutputRef, false); err != nil || !changed {
t.Fatalf("restore crafted output before listing replay: changed=%v err=%v", changed, err)
}
listedCraftOutput, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: owner.ID,
Ref: craftOutputRef, Amount: &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 125}, Date: now + 148,
})
if err != nil || listedCraftOutput.ResellAmount == nil || listedCraftOutput.ResellAmount.Amount != 125 {
t.Fatalf("list crafted output before replay = %+v err %v", listedCraftOutput, err)
}
listedReplay, err := lifecycle.CraftStarGift(ctx, craftReq)
if err != nil || !listedReplay.Duplicate || listedReplay.Gift == nil || listedReplay.Gift.ResellAmount != nil ||
listedReplay.Send.SenderMessage.ID != crafted.Send.SenderMessage.ID {
t.Fatalf("craft replay after listing did not use frozen output = %+v err %v", listedReplay, err)
}
if _, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{ActorUserID: owner.ID,
Ref: craftOutputRef, Date: now + 149,
}); err != nil {
t.Fatalf("remove crafted output listing: %v", err)
}
var outputReceiptMedia string
var outputReceiptFingerprint []byte
if err := pool.QueryRow(ctx, `SELECT output_media::text,output_fingerprint FROM star_gift_craft_commands
WHERE user_id=$1 AND command_key=$2`, owner.ID, craftReq.CommandKey).Scan(&outputReceiptMedia, &outputReceiptFingerprint); err != nil ||
outputReceiptMedia == "" || len(outputReceiptFingerprint) != 32 {
t.Fatalf("craft immutable output receipt: media=%q fingerprint=%d err=%v", outputReceiptMedia, len(outputReceiptFingerprint), err)
}
var craftListings, resaleAvailability int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM star_gift_listings WHERE unique_gift_id=ANY($1::bigint[])`,
[]int64{transferred.Unique.ID, secondUpgrade.Unique.ID}).Scan(&craftListings); err != nil || craftListings != 0 {
@ -487,7 +608,7 @@ WHERE b.owner_user_id=$1 AND b.box_id=$2`, owner.ID, edit.Message.ID).Scan(&shar
WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{StarsProceedsPermille: 900, TONProceedsPermille: 900}),
WithStarGiftCraftDraw(func(upper int) (int, error) { return upper - 1, nil }))
failureReq := domain.StarGiftCraftRequest{UserID: owner.ID,
Refs: []domain.SavedStarGiftRef{{Owner: ownerPeer, MsgID: thirdUpgrade.Saved.MsgID}},
Refs: []domain.SavedStarGiftRef{{Owner: ownerPeer, MsgID: thirdUpgrade.Saved.UpgradeMsgID}},
CommandKey: "craft-fail-" + suffix, Date: now + 150,
}
failedCraft, err := failingLifecycle.CraftStarGift(ctx, failureReq)
@ -526,6 +647,11 @@ can_resell_at,drop_original_details_stars,can_craft_at FROM peer_star_gifts WHER
craftedSourceEditForUserAndGift(failedReplay, owner.ID, thirdUpgrade.Unique.ID).Event.Pts != failedInputEdit.Event.Pts {
t.Fatalf("craft failure replay = %+v err %v", failedReplay, err)
}
wrongAliasReplay := failureReq
wrongAliasReplay.Refs = []domain.SavedStarGiftRef{{Owner: ownerPeer, MsgID: secondUpgrade.Saved.UpgradeMsgID}}
if _, err := failingLifecycle.CraftStarGift(ctx, wrongAliasReplay); !errors.Is(err, domain.ErrStarGiftCraftUnavailable) {
t.Fatalf("craft replay accepted another aggregate alias: %v", err)
}
invalidRetry := failureReq
invalidRetry.CommandKey = "craft-fail-new-command-" + suffix
if _, err := failingLifecycle.CraftStarGift(ctx, invalidRetry); !errors.Is(err, domain.ErrStarGiftCraftUnavailable) {
@ -629,10 +755,14 @@ func TestStarGiftChannelLifecycleAtomicPostgres(t *testing.T) {
}
if _, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 5, SlugPrefix: "channel-life-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectibleModel, Name: "Channel Model", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestDocumentPtr(baseDocumentID+1, "channel-model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "channel-model"), Animation: collectibleTestAnimationPtr("channel-model.tgs")}},
Models: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleModel, Name: "Channel Model", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestDocumentPtr(baseDocumentID+1, "channel-model.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "channel-model"), Animation: collectibleTestAnimationPtr("channel-model.tgs")},
{Kind: domain.StarGiftCollectibleModel, Name: "Channel Crafted", RarityKind: domain.StarGiftRarityLegendary, Crafted: true,
Document: collectibleTestDocumentPtr(baseDocumentID+2, "channel-crafted.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "channel-crafted"), Animation: collectibleTestAnimationPtr("channel-crafted.tgs")},
},
Patterns: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectiblePattern, Name: "Channel Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+2, "channel-pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "channel-pattern"), Animation: collectibleTestAnimationPtr("channel-pattern.tgs")}},
Document: collectibleTestPatternDocumentPtr(baseDocumentID+3, "channel-pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+3, "channel-pattern"), Animation: collectibleTestAnimationPtr("channel-pattern.tgs")}},
Backdrops: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectibleBackdrop, Name: "Channel Backdrop", BackdropID: 88,
CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff,
RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000}},
@ -761,7 +891,9 @@ WHERE channel_id=$1 AND message::text LIKE '%prepaid_upgrade%'`, created.Channel
}
action := upgraded.Send.RecipientMessage.Media.ServiceAction.StarGiftUnique
if action == nil || action.FromUserID != domain.OfficialSystemUserID || action.Peer != channelPeer ||
action.SavedID != prepaidPurchase.Saved.SavedID || !action.Upgrade || !action.PrepaidUpgrade || action.TransferStars != 25 {
action.SavedID != prepaidPurchase.Saved.SavedID || !action.Upgrade || !action.PrepaidUpgrade || action.TransferStars != 25 ||
action.CanCraftAt != 0 || action.Gift.CraftChancePermille != 500 ||
upgraded.Saved.CanCraftAt != now+5 || upgraded.Unique.CraftChancePermille != 500 {
t.Fatalf("channel upgrade service action = %+v", action)
}
var ptsAfterUpgrade int
@ -805,7 +937,9 @@ WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`, created.Channe
}
resold, err := lifecycle.PurchaseResaleStarGift(ctx, resaleReq)
if err != nil || resold.Unique.Owner != targetChannelPeer || resold.Saved.Owner != targetChannelPeer ||
resold.Saved.SavedID != upgraded.Saved.ID || resold.Balance.Balance != 999000 {
resold.Saved.SavedID != upgraded.Saved.ID || resold.Balance.Balance != 999000 ||
resold.Saved.CanCraftAt != upgraded.Saved.CanCraftAt ||
resold.Unique.CraftChancePermille != upgraded.Unique.CraftChancePermille {
t.Fatalf("channel-to-channel local TON resale = %+v err %v", resold, err)
}
var channelTON, channelTONTxns, targetResaleLogs, commission int64
@ -837,6 +971,36 @@ WHERE channel_id=$1 AND message::text LIKE '%star_gift_unique%'`, created.Channe
if err := pool.QueryRow(ctx, `SELECT balance_nanoton FROM channel_ton_balances WHERE channel_id=$1`, created.Channel.ID).Scan(&channelTON); err != nil || channelTON != 900 {
t.Fatalf("channel TON proceeds after replay = %d err %v", channelTON, err)
}
toUser, err := lifecycle.TransferStarGift(ctx, domain.StarGiftTransferRequest{
ActorUserID: actor.ID,
Ref: domain.SavedStarGiftRef{
Owner: targetChannelPeer, SavedID: resold.Saved.SavedID,
},
To: domain.Peer{Type: domain.PeerTypeUser, ID: actor.ID}, ChargeStars: resold.Saved.TransferStars,
CommandKey: "channel-craft-entitlement-to-user-" + suffix, Date: now + 8,
})
if err != nil || toUser.Saved.Owner.Type != domain.PeerTypeUser || toUser.Saved.Owner.ID != actor.ID ||
toUser.Saved.CanCraftAt != upgraded.Saved.CanCraftAt ||
toUser.Unique.CraftChancePermille != upgraded.Unique.CraftChancePermille {
t.Fatalf("channel-to-user Craft entitlement transfer = %+v err %v", toUser, err)
}
toUserAction := toUser.Send.SenderMessage.Media.ServiceAction.StarGiftUnique
if toUserAction == nil || toUserAction.CanCraftAt != upgraded.Saved.CanCraftAt {
t.Fatalf("channel-to-user action did not restore Craft readiness: %+v", toUserAction)
}
backToChannel, err := lifecycle.TransferStarGift(ctx, domain.StarGiftTransferRequest{
ActorUserID: actor.ID,
Ref: domain.SavedStarGiftRef{
Owner: toUser.Saved.Owner, MsgID: toUser.Saved.MsgID,
},
To: channelPeer, ChargeStars: toUser.Saved.TransferStars,
CommandKey: "user-craft-entitlement-to-channel-" + suffix, Date: now + 9,
})
if err != nil || backToChannel.Saved.Owner != channelPeer ||
backToChannel.Saved.CanCraftAt != upgraded.Saved.CanCraftAt ||
backToChannel.Unique.CraftChancePermille != upgraded.Unique.CraftChancePermille {
t.Fatalf("user-to-channel Craft entitlement transfer = %+v err %v", backToChannel, err)
}
var remainsBefore int
if err := pool.QueryRow(ctx, `SELECT availability_remains FROM star_gift_catalog WHERE gift_id=$1`, entry.Gift.ID).Scan(&remainsBefore); err != nil {
@ -890,6 +1054,119 @@ WHERE channel_id=$1 AND message::text LIKE '%auction_acquired%'`, created.Channe
}
}
func TestStarGiftCraftFailureConsumesThreeInputsPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
now := int(time.Now().Unix())
users := NewUserStore(pool)
buyer := createTestUser(t, ctx, users, "+1883"+suffix+"01", "CraftBuyer", "")
owner := createTestUser(t, ctx, users, "+1883"+suffix+"02", "CraftOwner", "")
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
stars := NewStarsStore(pool)
for _, userID := range []int64{buyer.ID, owner.ID} {
if _, _, err := stars.EnsureGrant(ctx, userID, 10000, now); err != nil {
t.Fatalf("grant craft stars to %d: %v", userID, err)
}
}
gifts := NewStarGiftStore(pool)
baseDocumentID := time.Now().UnixNano() & 0x7ffffffffffff000
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Title: "Three Input Craft " + suffix, Stars: 50, ConvertStars: 20, Enabled: true,
Document: collectibleTestDocument(baseDocumentID, "three-input.tgs"),
Blob: collectibleTestBlob(baseDocumentID, "three-input"), Animation: collectibleTestAnimation("three-input.tgs"),
Actor: "integration", CommandID: "three-input-catalog-" + suffix,
})
if err != nil {
t.Fatalf("create three-input catalog: %v", err)
}
if _, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 10, SlugPrefix: "three-" + suffix,
Models: []domain.StarGiftCollectibleAttribute{
{Kind: domain.StarGiftCollectibleModel, Name: "Base", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestDocumentPtr(baseDocumentID+1, "base.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+1, "base"), Animation: collectibleTestAnimationPtr("base.tgs")},
{Kind: domain.StarGiftCollectibleModel, Name: "Crafted", RarityKind: domain.StarGiftRarityLegendary, Crafted: true,
Document: collectibleTestDocumentPtr(baseDocumentID+2, "crafted.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+2, "crafted"), Animation: collectibleTestAnimationPtr("crafted.tgs")},
},
Patterns: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern",
RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
Document: collectibleTestPatternDocumentPtr(baseDocumentID+3, "pattern.tgs"), Blob: collectibleTestBlobPtr(baseDocumentID+3, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs")}},
Backdrops: []domain.StarGiftCollectibleAttribute{{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 88,
CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff,
RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000}},
Actor: "integration", CommandID: "three-input-pool-" + suffix,
}); err != nil {
t.Fatalf("publish three-input collectible: %v", err)
}
messages := NewMessageStore(pool)
lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000,
WithStarGiftCraftDraw(func(upper int) (int, error) { return upper - 1, nil }))
upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 250,
}))
refs := make([]domain.SavedStarGiftRef, 0, 3)
uniqueIDs := make([]int64, 0, 3)
for i := 0; i < 3; i++ {
purchaseReq := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{
BuyerUserID: buyer.ID, To: ownerPeer, GiftID: entry.Gift.ID, IncludeUpgrade: true,
CommandKey: fmt.Sprintf("three-input-purchase-%s-%d", suffix, i), Date: now + i,
})
purchased, err := lifecycle.PurchaseStarGift(ctx, purchaseReq)
if err != nil {
t.Fatalf("purchase three-input gift %d: %v", i, err)
}
upgraded, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{UserID: owner.ID,
Ref: domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: purchased.Saved.MsgID}, RequirePrepaid: true,
CommandKey: fmt.Sprintf("three-input-upgrade-%s-%d", suffix, i), Date: now + 10 + i,
})
if err != nil {
t.Fatalf("upgrade three-input gift %d: %v", i, err)
}
refs = append(refs, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: upgraded.Saved.UpgradeMsgID})
uniqueIDs = append(uniqueIDs, upgraded.Unique.ID)
}
req := domain.StarGiftCraftRequest{UserID: owner.ID, Refs: refs,
CommandKey: "three-input-craft-fail-" + suffix, Date: now + 20}
failed, err := lifecycle.CraftStarGift(ctx, req)
if err != nil || failed.Success || failed.Chance != 750 || failed.Gift != nil {
t.Fatalf("three-input craft failure = %+v err=%v", failed, err)
}
for _, uniqueID := range uniqueIDs {
edit := craftedSourceEditForUserAndGift(failed, owner.ID, uniqueID)
action := starGiftUniqueActionFromEdit(edit)
if edit.Event.Pts <= 0 || action == nil || !action.Gift.Burned || action.Gift.CraftChancePermille != 0 ||
action.Saved || action.CanCraftAt != 0 {
t.Fatalf("three-input terminal edit for %d = %+v", uniqueID, edit)
}
var burned bool
var status string
if err := pool.QueryRow(ctx, `SELECT u.burned,p.lifecycle_status
FROM unique_star_gifts u JOIN peer_star_gifts p ON p.unique_gift_id=u.id WHERE u.id=$1`, uniqueID).
Scan(&burned, &status); err != nil || !burned || status != "burned" {
t.Fatalf("three-input terminal aggregate %d burned=%v status=%q err=%v", uniqueID, burned, status, err)
}
}
var sourcePTS []int32
var outputMedia, outputFingerprint []byte
if err := pool.QueryRow(ctx, `SELECT source_edit_pts,output_media,output_fingerprint
FROM star_gift_craft_commands WHERE user_id=$1 AND command_key=$2`, owner.ID, req.CommandKey).
Scan(&sourcePTS, &outputMedia, &outputFingerprint); err != nil || len(sourcePTS) != 3 ||
len(outputMedia) != 0 || len(outputFingerprint) != 0 {
t.Fatalf("three-input failure receipt pts=%v media=%d fingerprint=%d err=%v", sourcePTS, len(outputMedia), len(outputFingerprint), err)
}
replay, err := lifecycle.CraftStarGift(ctx, req)
if err != nil || !replay.Duplicate || replay.Success || replay.Chance != 750 {
t.Fatalf("three-input failure replay = %+v err=%v", replay, err)
}
for i, uniqueID := range uniqueIDs {
if edit := craftedSourceEditForUserAndGift(replay, owner.ID, uniqueID); edit.Event.Pts != int(sourcePTS[i]) {
t.Fatalf("three-input replay pts for %d = %d want %d", uniqueID, edit.Event.Pts, sourcePTS[i])
}
}
}
func issueLifecyclePurchaseForm(t *testing.T, ctx context.Context, lifecycle *StarGiftLifecycleStore,
req domain.StarGiftPurchaseRequest) domain.StarGiftPurchaseRequest {
t.Helper()

View file

@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
if err != nil {
t.Fatalf("migrate star gift lifecycle schema: %v", err)
}
if status.Dirty || status.Empty || status.Version != 125 {
t.Fatalf("migration status = %+v, want clean version 125", status)
if status.Dirty || status.Empty || status.Version != 129 {
t.Fatalf("migration status = %+v, want clean version 129", status)
}
}

View file

@ -0,0 +1,196 @@
package postgres
import (
"context"
"errors"
"fmt"
"sort"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// retireUserStarGiftMessagesTx closes every user-scoped unique-gift action
// emitted for the source ownership epoch. Ownership moves and terminal export
// must not leave an older chat card with Craft/transfer/resale capabilities.
// The aggregate mutation and all message edits share one transaction and each
// visible box receives its own durable pts/event/outbox entry.
func (s *StarGiftLifecycleStore) retireUserStarGiftMessagesTx(
ctx context.Context,
tx pgx.Tx,
source domain.SavedStarGift,
current domain.UniqueStarGift,
date int,
) ([]domain.EditedMessageForUser, error) {
if s == nil || s.messages == nil || source.Owner.Type != domain.PeerTypeUser || source.Owner.ID <= 0 ||
source.ID <= 0 || source.UniqueGiftID <= 0 || current.ID != source.UniqueGiftID || date <= 0 {
return nil, domain.ErrStarGiftTransferUnavailable
}
messageIDs := map[int]struct{}{}
if source.MsgID > 0 {
messageIDs[source.MsgID] = struct{}{}
}
if source.UpgradeMsgID > 0 {
messageIDs[source.UpgradeMsgID] = struct{}{}
}
rows, err := tx.Query(ctx, `
SELECT msg_id FROM star_gift_user_message_refs
WHERE owner_user_id=$1 AND saved_gift_id=$2
ORDER BY msg_id`, source.Owner.ID, source.ID)
if err != nil {
return nil, fmt.Errorf("list star gift message projections: %w", err)
}
for rows.Next() {
var msgID int
if err := rows.Scan(&msgID); err != nil {
rows.Close()
return nil, fmt.Errorf("scan star gift message projection: %w", err)
}
if msgID > 0 {
messageIDs[msgID] = struct{}{}
}
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, fmt.Errorf("iterate star gift message projections: %w", err)
}
rows.Close()
ids := make([]int, 0, len(messageIDs))
for msgID := range messageIDs {
ids = append(ids, msgID)
}
sort.Ints(ids)
q := sqlcgen.New(tx)
edits := make([]domain.EditedMessageForUser, 0, len(ids)*2)
seenPrivateMessages := make(map[string]struct{}, len(ids))
for _, msgID := range ids {
var peerType string
var peerID int64
err := tx.QueryRow(ctx, `
SELECT peer_type,peer_id FROM message_boxes
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted
FOR UPDATE`, source.Owner.ID, msgID).Scan(&peerType, &peerID)
if errors.Is(err, pgx.ErrNoRows) {
continue
}
if err != nil {
return nil, fmt.Errorf("lock star gift message projection: %w", err)
}
if peerType != string(domain.PeerTypeUser) || peerID <= 0 {
return nil, fmt.Errorf("star gift message projection %d is not private", msgID)
}
target, err := q.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{
OwnerUserID: source.Owner.ID, BoxID: int32(msgID), PeerType: peerType, PeerID: peerID,
})
if err != nil {
return nil, fmt.Errorf("load star gift message projection: %w", err)
}
logicalKey := fmt.Sprintf("%d:%d", target.MessageSenderID, target.PrivateMessageID)
if _, duplicate := seenPrivateMessages[logicalKey]; duplicate {
continue
}
seenPrivateMessages[logicalKey] = struct{}{}
boxes, err := q.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{
OwnerUserIds: privateMessageOwnerIDs(source.Owner.ID, peerID),
MessageSenderID: target.MessageSenderID, PrivateMessageID: target.PrivateMessageID,
})
if err != nil {
return nil, fmt.Errorf("list visible star gift message projections: %w", err)
}
var privateMediaJSON []byte
matched := false
for _, box := range boxes {
media, err := decodeMessageMedia(box.MediaJson)
if err != nil {
return nil, fmt.Errorf("decode star gift message projection: %w", err)
}
if media == nil || media.Kind != domain.MessageMediaKindService || media.ServiceAction == nil ||
media.ServiceAction.Kind != domain.MessageServiceActionStarGiftUnique || media.ServiceAction.StarGiftUnique == nil ||
media.ServiceAction.StarGiftUnique.Gift.ID != current.ID {
continue
}
matched = true
action := media.ServiceAction.StarGiftUnique
retiredGift := current
retiredGift.CraftChancePermille = 0
retiredGift.ResellAmount = nil
action.Gift = retiredGift
action.Peer = domain.Peer{}
action.SavedID = 0
action.Saved = false
if validLifecyclePeer(current.Owner) && current.Owner != source.Owner {
action.Transferred = true
}
action.CanExportAt = 0
action.TransferStars = 0
action.ResaleAmount = nil
action.CanTransferAt = 0
action.CanResellAt = 0
action.DropOriginalDetailsStars = 0
action.CanCraftAt = 0
mediaJSON, err := encodeMessageMedia(media)
if err != nil {
return nil, fmt.Errorf("encode retired star gift projection: %w", err)
}
pts, err := s.messages.reservePts(ctx, tx, box.OwnerUserID)
if err != nil {
return nil, fmt.Errorf("allocate retired star gift pts: %w", err)
}
tag, err := tx.Exec(ctx, `
UPDATE message_boxes SET media=$3,pts=$4
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, box.OwnerUserID, box.BoxID, mediaJSON, int32(pts))
if err != nil {
return nil, fmt.Errorf("update retired star gift projection: %w", err)
}
if tag.RowsAffected() != 1 {
return nil, fmt.Errorf("update retired star gift projection lost row")
}
msg, err := messageFromVisibleBoxRow(box)
if err != nil {
return nil, err
}
msg.Media = media
msg.Pts = pts
if err := replaceMessageBoxMediaIndexTx(ctx, tx, msg.OwnerUserID, msg.Peer.ID, msg.ID, msg.Date, msg.Media, msg.Entities); err != nil {
return nil, err
}
event := domain.UpdateEvent{UserID: msg.OwnerUserID, Type: domain.UpdateEventEditMessage,
Pts: pts, PtsCount: 1, Date: date, Message: msg}
if err := appendUserUpdateEvent(ctx, tx, q, msg.OwnerUserID, event); err != nil {
return nil, fmt.Errorf("append retired star gift edit event: %w", err)
}
if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{
TargetUserID: msg.OwnerUserID, Pts: int32(pts), EventType: string(domain.UpdateEventEditMessage),
ExcludeAuthKeyID: 0, ExcludeSessionID: 0,
}); err != nil {
return nil, fmt.Errorf("enqueue retired star gift edit: %w", err)
}
if box.OwnerUserID == box.MessageSenderID || len(privateMediaJSON) == 0 {
privateMediaJSON, err = encodeSharedPrivateStarGiftMedia(media)
if err != nil {
return nil, err
}
}
edits = append(edits, domain.EditedMessageForUser{UserID: msg.OwnerUserID, Message: msg, Event: event})
}
if !matched {
continue
}
if len(privateMediaJSON) == 0 {
return nil, fmt.Errorf("retired star gift projection missing shared media")
}
if _, err := tx.Exec(ctx, `
UPDATE private_messages SET media=$3
WHERE sender_user_id=$1 AND id=$2`, target.MessageSenderID, target.PrivateMessageID, privateMediaJSON); err != nil {
return nil, fmt.Errorf("update retired star gift private media: %w", err)
}
}
return edits, nil
}

View file

@ -7,19 +7,38 @@ import (
)
func TestTransferUniqueActionSavedIDNamespace(t *testing.T) {
saved := domain.SavedStarGift{SavedID: 42}
saved := domain.SavedStarGift{SavedID: 42, CanCraftAt: 1_780_000_123}
unique := domain.UniqueStarGift{ID: 7}
user := domain.Peer{Type: domain.PeerTypeUser, ID: 100}
channel := domain.Peer{Type: domain.PeerTypeChannel, ID: 200}
if action := transferUniqueAction(unique, 1, user, saved); action.SavedID != 0 {
if action := transferUniqueAction(unique, 1, user, saved); action.SavedID != 0 || action.CanCraftAt != saved.CanCraftAt {
t.Fatalf("user transfer action leaked channel saved_id: %+v", action)
}
if action := transferUniqueAction(unique, 1, channel, saved); action.SavedID != saved.SavedID {
if action := transferUniqueAction(unique, 1, channel, saved); action.SavedID != saved.SavedID || action.CanCraftAt != 0 {
t.Fatalf("channel transfer action lost channel saved_id: %+v", action)
}
}
func TestStarGiftCraftReadyAt(t *testing.T) {
const date = 1_780_000_000
if got := starGiftCraftReadyAt(date, 0); got != date {
t.Fatalf("zero-delay craft ready_at = %d, want %d", got, date)
}
if got := starGiftCraftReadyAt(date, 60); got != date+60 {
t.Fatalf("delayed craft ready_at = %d, want %d", got, date+60)
}
if got := starGiftCraftReadyAt(0, 0); got != 0 {
t.Fatalf("invalid-date craft ready_at = %d, want 0", got)
}
if got := starGiftCraftReadyAt(1<<31-10, 60); got != 1<<31-1 {
t.Fatalf("overflow craft ready_at = %d, want max int32", got)
}
if got := starGiftCraftReadyAt(1<<31+10, 0); got != 1<<31-1 {
t.Fatalf("oversized-date craft ready_at = %d, want max int32", got)
}
}
func TestEncodeSharedPrivateStarGiftMediaOmitsUserBoxLocalRefs(t *testing.T) {
ordinary := &domain.MessageMedia{
Kind: domain.MessageMediaKindService,

View file

@ -11,11 +11,10 @@ import (
// projectPrivateStarGiftSourceRef exposes a user-owned gift's stable source
// message identity only in the gift owner's message-box projection. Telegram
// defines gift_msg_id as receiver-only, and TDesktop also treats user unique
// saved_id as an inputSavedStarGiftUser identity. A non-owner counterpart box
// id is therefore not a valid substitute: it could resolve to an unrelated
// gift owned by that viewer. The shared private_messages row omits the local
// reference for the same reason.
// defines gift_msg_id as receiver-only. A non-owner counterpart box id is not
// a valid substitute: it could resolve to an unrelated gift owned by that
// viewer. User unique actions do not use channel-only peer/saved_id fields;
// their owner-scoped message ids are registered separately at write time.
func projectPrivateStarGiftSourceRef(
_ context.Context,
_ pgx.Tx,
@ -60,25 +59,6 @@ func projectPrivateStarGiftSourceRef(
} else {
recipientAction.GiftMsgID = sourceOwnerBoxID
}
case privateStarGiftUniqueAction(shared) != nil:
sharedAction := privateStarGiftUniqueAction(shared)
senderAction := privateStarGiftUniqueAction(sender)
recipientAction := privateStarGiftUniqueAction(recipient)
if sharedAction.Peer.Type != domain.PeerTypeUser || sharedAction.Peer.ID != sourceOwnerUserID ||
sharedAction.SavedID != int64(sourceOwnerBoxID) {
return privateSendMediaProjection{}, fmt.Errorf(
"project private unique star gift source: saved_id %d does not match owner box %d",
sharedAction.SavedID, sourceOwnerBoxID,
)
}
sharedAction.SavedID = 0
senderAction.SavedID = 0
recipientAction.SavedID = 0
if req.SenderUserID == sourceOwnerUserID {
senderAction.SavedID = int64(sourceOwnerBoxID)
} else {
recipientAction.SavedID = int64(sourceOwnerBoxID)
}
default:
return privateSendMediaProjection{}, fmt.Errorf("project private star gift source: unsupported media")
}

View file

@ -122,9 +122,15 @@ WHERE collectible_revision_id=$1 AND crafted
}
craftChancePermille := 0
canCraftAt := 0
// Keep the durable Craft entitlement attached to the collectible across
// user/channel ownership moves. The RPC projection suppresses the
// readiness marker for channel owners until channel Craft execution is
// implemented, without destroying the official gift property.
if craftable {
craftChancePermille = s.lifecycle.CraftChancePermille
canCraftAt = starGiftReadyAt(req.Date, s.lifecycle.CraftDelaySeconds)
if craftChancePermille > 0 {
canCraftAt = starGiftCraftReadyAt(req.Date, s.lifecycle.CraftDelaySeconds)
}
}
if revision.Issued >= revision.SupplyTotal {
return domain.ErrStarGiftCollectibleSoldOut
@ -230,12 +236,6 @@ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID, commandKey, locked.ID, req.For
}
return nil
},
projectMedia: func(ctx context.Context, tx pgx.Tx, messageReq *domain.SendPrivateTextRequest) (privateSendMediaProjection, error) {
if result.Saved.Owner.Type != domain.PeerTypeUser {
return privateSendMediaProjection{Shared: messageReq.Media, Sender: messageReq.Media, Recipient: messageReq.Media}, nil
}
return projectPrivateStarGiftSourceRef(ctx, tx, messageReq, result.Saved.Owner.ID, result.Saved.MsgID)
},
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
ownerMessageID := sent.RecipientMessage.ID
if saved.FromUserID == req.UserID {
@ -251,6 +251,12 @@ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, req.UserID, commandKey, locked.ID, req.For
if tag.RowsAffected() != 1 {
return fmt.Errorf("save star gift upgrade message id lost aggregate row")
}
if result.Saved.Owner.Type == domain.PeerTypeUser {
if err := registerUserStarGiftMessageRef(ctx, tx, result.Saved.Owner.ID, ownerMessageID,
result.Saved.ID, result.Unique.ID); err != nil {
return err
}
}
result.Saved.UpgradeMsgID = ownerMessageID
if result.Saved.Owner.Type == domain.PeerTypeUser {
edits, err := s.markPrivateStarGiftSourceUpgradedTx(ctx, tx, req, result.Saved, sent)
@ -311,19 +317,27 @@ func starGiftUpgradeUniqueAction(saved domain.SavedStarGift, unique domain.Uniqu
// peer plus action.peer=channel and action.saved_id.
fromUserID = messageSenderID
}
peer := saved.Owner
savedID := saved.SavedID
canCraftAt := saved.CanCraftAt
if saved.Owner.Type == domain.PeerTypeUser {
// For user-owned gifts messageActionStarGiftUnique.saved_id is the
// stable source gift message id. TDesktop uses this back-reference as
// inputSavedStarGiftUser.msg_id for crafting and later lifecycle RPCs.
savedID = int64(saved.MsgID)
// peer and saved_id share one TL flag and are defined for channel gifts.
// For user gifts both must be absent; official clients use the emitted
// service-message id (registered owner-locally by the send transaction).
peer = domain.Peer{}
savedID = 0
} else {
// The current Craft state machine is user-owned only. Android treats a
// positive can_craft_at as the channel Craft entry marker, so do not
// advertise a write path that the server cannot execute yet.
canCraftAt = 0
}
return &domain.MessageStarGiftUniqueAction{
Gift: unique, FromUserID: fromUserID, Peer: saved.Owner, SavedID: savedID,
Gift: unique, FromUserID: fromUserID, Peer: peer, SavedID: savedID,
Upgrade: true, Saved: !saved.Unsaved, PrepaidUpgrade: req.RequirePrepaid,
CanExportAt: saved.CanExportAt, TransferStars: saved.TransferStars,
CanTransferAt: saved.CanTransferAt, CanResellAt: saved.CanResellAt,
DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: saved.CanCraftAt,
DropOriginalDetailsStars: saved.DropOriginalDetailsStars, CanCraftAt: canCraftAt,
}
}
@ -469,6 +483,27 @@ func starGiftReadyAt(date, delaySeconds int) int {
return date + delaySeconds
}
// starGiftCraftReadyAt differs intentionally from the other lifecycle delay
// fields. Official Android clients use a positive can_craft_at both as the
// capability marker and as the readiness boundary, so an immediately
// craftable gift must carry its upgrade date instead of omitting the field.
func starGiftCraftReadyAt(date, delaySeconds int) int {
if date <= 0 || delaySeconds < 0 {
return 0
}
const maxProtocolDate = int(1<<31 - 1)
if date >= maxProtocolDate {
return maxProtocolDate
}
if delaySeconds == 0 {
return date
}
if delaySeconds > maxProtocolDate-date {
return maxProtocolDate
}
return date + delaySeconds
}
func lockSavedStarGiftForUpgrade(ctx context.Context, tx pgx.Tx, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) {
where, args := savedStarGiftRefWhere(ref)
return lockSavedStarGiftWhere(ctx, tx, where, args...)

View file

@ -0,0 +1,59 @@
package postgres
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
)
// registerUserStarGiftMessageRef records an owner-scoped service-message alias
// for a user-owned gift. Official clients may continue from a freshly emitted
// messageActionStarGiftUnique and pass that message id to a lifecycle RPC,
// while payments.getSavedStarGifts may still expose the original received gift
// message as the aggregate's primary msg_id.
func registerUserStarGiftMessageRef(
ctx context.Context,
tx pgx.Tx,
ownerUserID int64,
msgID int,
savedGiftID int64,
uniqueGiftID int64,
) error {
if ownerUserID <= 0 || msgID <= 0 || savedGiftID <= 0 || uniqueGiftID <= 0 {
return fmt.Errorf("register user star gift message ref: invalid identity")
}
tag, err := tx.Exec(ctx, `
INSERT INTO star_gift_user_message_refs(owner_user_id,msg_id,saved_gift_id)
SELECT $1,$2,p.id
FROM peer_star_gifts p
WHERE p.id=$3 AND p.owner_peer_type='user' AND p.owner_peer_id=$1
AND p.unique_gift_id=$4 AND p.lifecycle_status='active'
ON CONFLICT(owner_user_id,msg_id) DO UPDATE
SET saved_gift_id=EXCLUDED.saved_gift_id
WHERE star_gift_user_message_refs.saved_gift_id=EXCLUDED.saved_gift_id`, ownerUserID, msgID, savedGiftID, uniqueGiftID)
if err != nil {
return fmt.Errorf("register user star gift message ref: %w", err)
}
if tag.RowsAffected() != 1 {
return fmt.Errorf("register user star gift message ref: identity collision")
}
return nil
}
func userStarGiftMessageRefMatches(
ctx context.Context,
db interface {
QueryRow(context.Context, string, ...any) pgx.Row
},
ownerUserID int64,
msgID int,
savedGiftID int64,
) (bool, error) {
var matches bool
err := db.QueryRow(ctx, `SELECT EXISTS (
SELECT 1 FROM star_gift_user_message_refs
WHERE owner_user_id=$1 AND msg_id=$2 AND saved_gift_id=$3
)`, ownerUserID, msgID, savedGiftID).Scan(&matches)
return matches, err
}